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
Hashinstances (if you must) - Pattern matching exactly like
Hashinstances - 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
= "Missing arguments [:c, :e, :f]\nSpurious arguments [:x]"
expect { MyChecked.new(a: 10, x:30) }.to raise_error(ArgumentError, )
And also we cannot create an instance that violates the constraints
= "constraint for c\nconstraint for e\nconstraint"
expect { MyChecked.new(a: 10, c: nil, e: -1, f: 10) }.to raise_error(ConstraintError, )
# 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
= "bool? constraint for beta"
expect { instance.update(beta: 42) }
.to raise_error(ConstraintError, )
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
= "constraint for a\neven"
expect { instance.update(a: 0, b: 1)}.to raise_error(ConstraintError, )
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
= "even"
instance.update!(a: 0, b: 1)
expect { instance.update(a: 2, b: 1)}.to raise_error(ConstraintError, )
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
= "constraint for a\nconstraint"
expect { instance.checked { @a = 0 }}
.to raise_error(ConstraintError, )
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