Given the following DataClass
let(:constraint_error) { Lab42::DataClass::ConstraintError }
class Animal
extend Lab42::DataClass
attributes :name, :age, :species
end
And some specimen
let(:vilma) { Animal.new(name: "Vilma", species: "dog", age: 18) } # RIP my dear lab42
Then we can pattern match on it:
vilma => {name:, species:}
expect(name).to eq("Vilma")
expect(species).to eq("dog")
Data Classes can have very specific constraints on their attributes, we shall speculate about this by using Inheritance on the fly
Given a specialised form of Animal
class Dog < Animal
extend Lab42::DataClass
attributes :breed
constraint :breed, Set.new(["Labrador", "Australian Shepherd"])
constraint :species, [:==, "dog"] # This of course is a code smell, the base class needing to be constrained
# but for the sake of the demonstration please bear with me (just do not do
# this at home)
end
Then we can instantiate an object as long as we obey the constraints
Dog.new(age: 18, name: "Vilma", breed: "Labrador", species: "dog")
But we will get ConstraintError
s if we do not
expect do
Dog.new(age: 18, name: "Vilma", breed: "Pug", species: "dog")
end
.to raise_error(constraint_error)
Or
expect do
Dog.new(age: 18, name: "Vilma", breed: "Labrador", species: "human")
end
.to raise_error(constraint_error)
There are the following Builtin Constraints
All?(constraint)
a constraint that holds for all elements →-> { _1.all?(&constraint) }
Any?
a constraint that holds for any element →-> { _1.any?(&constraint) }
PairOf(fst, snd)
→-> { Pair === _1 && fst.(_1.first) && snd.(_1.second) }
TripleOf(fst, snd, trd)
→-> { Triple === _1 && fst.(_1.first) && snd.(_1.second) && trd.(_1.third) }
Option(constraint)
either nil or satisfies the constraint →-> { _1.nil? || constraint.(_1) }
Not(constraint)
negation of a constraint →-> { !constraint.(_1) }
Choice(*constraints)
satisfies one of the constraints, again useful in v0.8 withListOf
, e.g.ListOf(Choice(Symbol, String))
→-> { |v| constraints.any?{ |c| c.(v) } }
Lambda(arity=-1)
a callable with the given arity →-> { _1.respond_to?(:arity) && _1.arity == arity }
StartsWith(string)
→-> { _1.start_with?(string) }
EndsWith(string)
→-> { _1.end_with?(string) }
Contains(string)
→-> { _1.contains?(string) }
Anything
useful withPairOf
orTripleOf
e.g.PairOf(Symbol, Anything)
→-> {true}
Boolean
→Set.new([false, true])
Here is a simple example of their usage, detailed description can be found here
Given a dataclass with a builtin constraint (needs an explicit require)
require "lab42/data_class/builtin_constraints"
let(:entry) { DataClass(:value).with_constraint(value: PairOf(Symbol, Anything)) }
Then these constraints are well observed
expect(entry.new(value: Pair(:world, 42)).value).to eq(Pair(:world, 42))
expect{ entry.new(value: Pair("world", 43)) }
.to raise_error(Lab42::DataClass::ConstraintError)
expect{ entry.new(value: Triple(:world, 43, nil)) }
.to raise_error(Lab42::DataClass::ConstraintError)
These are special builtin constraints that allow to set attributes in a very specific, controlled way such as that the constraint on the attribute needs only be partially checked.
A good example is the ListOf
constraint.
If an attribute has the ListOf
constraint then its dataclass instance gets a special set
method
that allows to create a new dataclass instance in which only the change in the attribute and not the whole attribute needs
to be constraint checked.
Therefore we can still
some_instance.merge(list: some_instance.list.cons(1)) # Bad O(n)
or better
some_instance.set(:list).cons(1) # Goof O(1)
These special Constraints are described in detail here
Let us fix the code smell and introduce default values for attributes at the same time
Given a better base
module WithAgeAndName
extend Lab42::DataClass
attributes :name, :age
constraint :name, String
constraint :age, [:>=, 0]
end
And then a new Dog class
class BetterDog
include WithAgeAndName
extend Lab42::DataClass
AllowedBreeds = [
"Labrador", "Australian Shepherd"
]
attributes breed: "Labrador"
constraint :breed, Set.new(AllowedBreeds)
end
Then construction can use the defaults now
expect(BetterDog.new(age: 18, name: "Vilma").to_h)
.to eq(name: "Vilma", age: 18, breed: "Labrador")
And is object to the constraints of the included module
expect do
BetterDog.new(age: 18, name: :Vilma)
end
.to raise_error(constraint_error, "value :Vilma is not allowed for attribute :name")
And of the constraints of the base class too
expect do
BetterDog.new(age: 18, name: "Vilma", breed: "Pug")
end
.to raise_error(constraint_error, %{value "Pug" is not allowed for attribute :breed})
Let us change the domain for Derived Attributes now and assume that we are parsing Markdown and use Data Classes for our tokens produced by the Scanner, a Real World Use Case™ for once:
Given a base token
module Token
extend Lab42::DataClass
attributes :line, :content, lnb: 0
end
And, say a HeaderToken
token
class HeaderToken
include Token
extend Lab42::DataClass
derive :content do
_1.line.gsub(/^\s*\#+\s*/, "")
end
derive :level do
_1.line[/^\s*\#+\s*/].strip.length
end
end
Then we can observe how defaults and derivations provide us with the final object
expect(HeaderToken.new(line: "# Hello").to_h)
.to eq(line: "# Hello", content: "Hello", lnb: 0, level: 1)
With Derived Attributes we could assure that dependant data was correct, but sometimes dependency is more lose and can be expressed with Validations
The difference between Constraints and Validations is simply that a Validation is a block that will validate the whole instance of a Data Class.
Given a DataClass
let(:validation_error) { Lab42::DataClass::ValidationError }
class Person
extend Lab42::DataClass
attributes :name, :age, member: false
validate :members_are_18 do
_1.age >= 18 || !_1.member
end
end
Then we can assure that all members are at least 18 years old
expect do
Person.new(name: "junior", age: 17, member: true)
end
.to raise_error(validation_error)
And of course validation is also carried out when new instances are derived
senior = Person.new(name: "senior", age: 42, member: true)
expect do
senior.merge(name: "offspring", age: 10)
end
.to raise_error(validation_error)
I guess to many validations might in fact be a code smell, and even the simple example above might be better modelled with Constraints in mind
Given a Person module
module Person1
extend Lab42::DataClass
attributes :name, :age, :member
constraint :member, Set.new([false, true])
end
class Adult
include Person1
extend Lab42::DataClass
constraint :age, [:>=, 18]
end
class Child
include Person1
extend Lab42::DataClass
constraint :age, [:<, 18]
derive(:member){ false }
end
Seems to be a much cleaner approach
Then it also works better in the way that we cannot merge an Adult
into a Child
expect{ Adult.new(name: "senior", age: 18, member: true) }
.not_to raise_error
expect(Child.new(name: "junior", age: 17).to_h).to eq(name: "junior", age: 17, member: false)
Given an Operation DataClass
let(:duplicate_definition_error) { Lab42::DataClass::DuplicateDefinitionError }
Then we must not define the same operation twice
expect do
Class.new do
extend Lab42::DataClass
attributes(lhs: 0, rhs: 0)
derive(:result) {_1.lhs + _1.rhs}
derive(:result) {_1.lhs + _1.rhs}
end
end
.to raise_error(duplicate_definition_error, "Redefinition of derived attribute :result")