Gem Version Gem Downloads

Usage

  gem install lab42_checked_class

With bundler

  gem 'lab42_checked_class'

In your code

require 'lab42/checked_class'

Synopsis

The lab42_checked_class gem exposes the Lab42::CheckedClass mixin.

Lab42::CheckedClass A mutable but checked behavior class

This gem aims to make less errorprone programming with Mutable State easier. It provides an elegant Ruby idiomatic syntax to define constraints and checks

It also allows to remove some boilerplate which shall also remove some potential sources of misstakes and ease reading abnd therefore comprehension of the code.

Lab42::CheckedClass offers:

  • Attributes are predefined and can have default values
  • Construction with keyword arguments, exclusively
  • Conversion to Hash instances (if you must)
  • Pattern matching exactly like Hash instances
  • Possibility to impose strong constraints on attributes
  • Predefined constraints and concise syntax for constraints
  • Possibility to impose arbitrary validation (constraints on the whole object)
  • Checked update blocks
  • Checked blocks for larger updates
  • Not yet: Inheritance with mixin of other dataclasses (multiple if you must)
  • Noy yet: Atomic Transaction

Speculations (literate specs)

The following specs are executed with the speculate about gem.

These specs assume the following setup code

    require 'lab42/checked_class'
    CheckedClass = Lab42::CheckedClass
    ConstraintError = CheckedClass::ConstraintError
    Constraint = CheckedClass::Constraint

This is realized in the spec_helper, but if you want to have these three constants in your namespace you can do the following:

Context: Importing constants into your namespace

Then we will get them loaded

  expected_output = "  \#{ENV[\"PWD\"]}/lib/lab42/checked_class/all.rb:5: warning: already initialized constant CheckedClass\n  \#{ENV[\"PWD\"]}/spec/spec_helper.rb:38: warning: previous definition of CheckedClass was here\n  \#{ENV[\"PWD\"]}/lib/lab42/checked_class/all.rb:6: warning: already initialized constant Constraint\n  \#{ENV[\"PWD\"]}/spec/spec_helper.rb:39: warning: previous definition of Constraint was here\n  \#{ENV[\"PWD\"]}/lib/lab42/checked_class/all.rb:7: warning: already initialized constant ConstraintError\n  \#{ENV[\"PWD\"]}/spec/spec_helper.rb:40: warning: previous definition of ConstraintError was here\n  EOT\n  expect {\n    require 'lab42/checked_class/all'\n  }.to output(expected_output).to_stderr\n"

Context: Attribute definitions and constraints

Given a checked class

    class MyChecked
      extend CheckedClass
      attributes do
        attr :a # No constraints, no defaults
        attr b: 42 # No constraints, but a default
        attr :c, Integer # Constraint, no default
        attr(d: 1) { it > 0 } # Default and block constraint
        attr(:e, :even?) # Symbolic constraint, no default
        attr(:f) { (it % 10).zero? } # Block constraint, no default
        attr(:g, [:>, 1], default: 2) # Default and functional constraint

        # Named constraint
        constrain("a + b > e") { it.a + it.b > it.e }   
        # Unnamed constraint
        constrain { it.e > it.d }
      end
    end

And we can construct an instance as long as we provide all necessary values

  let(:defaults) { MyChecked.new(a: 10, c: -4, e: 10, f: 0) }

Then it is correctly constructed

    defaults

And we can access all attributes

  expect(defaults.a).to eq(10)
  expect(defaults.b).to eq(42)
  expect(defaults.c).to eq(-4)
  expect(defaults.d).to eq(1)
  expect(defaults.e).to eq(10)
  expect(defaults.f).to eq(0)
  expect(defaults.g).to eq(2)

And missing arguments are caught during construction

    expected_message = "Missing arguments [:c, :e, :f]\nSpurious arguments [:x]"
    expect { MyChecked.new(a: 10, x:30) }.to raise_error(ArgumentError, expected_message)

And also we cannot create an instance that violates the constraints


    expected_message = "constraint for c\nconstraint for e\nconstraint"
    expect { MyChecked.new(a: 10, c: nil, e: -1, f: 10) }.to raise_error(ConstraintError, expected_message)
    # Named constraints
    expect { MyChecked.new(a: 10, c: -4, e: 90, f: 0) }.to raise_error(ConstraintError, "a + b > e")

Context: Defining attributes with method missing.

As long as a method is called that is not defined inside the attributes block, it will be interpreted as an attribute definition, as it cannot have a default value assigned, we can also use the defaults macro

Given such implicit attribute definitions

    Implicit = Class.new do
      extend CheckedClass
      attributes do
        alpha Numeric
        beta Constraint.bool?

        defaults alpha: 0, beta: true
      end
    end
    let(:instance) { Implicit.new }

Then we can access the implicitly defined attributes

    expect(instance.to_h).to eq(alpha: 0, beta: true)

And we will get a constraint error as expeced

    expected_message = "bool? constraint for beta"
    expect { instance.update(beta: 42) }
      .to raise_error(ConstraintError, expected_message)

You can see all predefined builtin constraints here

Context: Runtime Semantics of Constraints

Context: Checked und unchecked attribute updates

Given the following checked class

    let :klass do
      Class.new do
        extend CheckedClass
        attributes do
          attr :a, [:>, 1]
          attr :b, Integer
          constrain('even') { (it.a + it.b).even? }
        end
      end
    end

And an instance of it

  let(:instance) { klass.new(a: 2, b: 0) }

Then we can update attributes

    instance.update(a: 4)
    expect(instance.a).to eq(4)

And we can update many attributes

    instance.update(a: 3, b: 1)
    expect(instance.to_h).to eq(a: 3, b: 1)

But we must not violate the constraints

    expected_message = "constraint for a\neven"
    expect { instance.update(a: 0, b: 1)}.to raise_error(ConstraintError, expected_message)

But if you must, you can use the update! method

    instance.update!(a: 0, b: 1)
    expect(instance.to_h).to eq(a: 0, b: 1)

And it still will not free you from checks later on

    expected_message = "even"
    instance.update!(a: 0, b: 1)
    expect { instance.update(a: 2, b: 1)}.to raise_error(ConstraintError, expected_message)

And we can update attributes with a block too

    instance.update(:a) { it + 2}
    expect(instance.a).to eq(4)

Context: Checked Modifications

Instances also have the checked method allowing to execute arbitrary checked modifications

Given a constrained checked class for checked modifications

  class CheckedForModifications
    extend CheckedClass
    attributes do
      attr :a, :positive?
      attr b: 2
      constrain { it.a > it.b }
    end
  end

  let(:instance) { CheckedForModifications.new(a: 3) }

Then we can perform a checked update

    instance.checked do
      @a = 5
      @b = 4
    end
    expect(instance.to_h).to eq(a: 5, b: 4)

And will not succeed in violating the constraints of the class

    expected_message = "constraint for a\nconstraint"
    expect { instance.checked { @a = 0 }}
    .to raise_error(ConstraintError, expected_message)

Typically this will be used in methods of the class itself, see an example here

Context: Pattern Matching

An instance of a checked class matches exactly as the result of its #to_h method

Given such an instance

    let :instance do
      Class.new do
        extend CheckedClass
        attributes do
          attr a: 0
          attr b: "hello"
          attr c: [:value, 42]
        end
      end.new
    end

Then we can pattern match it as follows

    instance => {a:, b: "hello", c: [Symbol => value, *rest]}
    expect(a).to eq(0)
    expect(value).to eq(:value)
    expect(rest).to eq([42])

And the patterns are correctly enforced

    expect { instance => {d:}}.to raise_error(NoMatchingPatternKeyError, /key not found: :d\z/)

Context: Initialisation

These are features described here

LICENSE

Copyright 2025 Robert Dober [email protected]

GNU AFFERO GENERAL PUBLIC LICENSE v3.0 or later c.f LICENSE