Given
let :my_data_class do
DataClass :value, prefix: "<", suffix: ">" do
def show
[prefix, value, suffix].join
end
end
end
let(:my_instance) { my_data_class.new(value: 42) }
Then I have defined a method on my dataclass
expect(my_instance.show).to eq("<42>")
Given two instances of a DataClass
let(:data_class) { DataClass :a }
let(:instance1) { data_class.new(a: 1) }
let(:instance2) { data_class.new(a: 1) }
Then they are equal in the sense of ==
and eql?
expect(instance1).to eq(instance2)
expect(instance2).to eq(instance1)
expect(instance1 == instance2).to be_truthy
expect(instance2 == instance1).to be_truthy
But not in the sense of equal?
, of course
expect(instance1).not_to be_equal(instance2)
expect(instance2).not_to be_equal(instance1)
Then we still get frozen instances
expect(instance1).to be_frozen
... is a no, look here if you want inheritance.
Functional approaches are of course possible, depending on your style, use case and context, here is just one example:
Given a class factory
let :token do
->(*a, **k) do
DataClass(*a, **(k.merge(text: "")))
end
end
Then we have reused the token
successfully
empty = token.()
integer = token.(:value)
boolean = token.(value: false)
expect(empty.new.to_h).to eq(text: "")
expect(integer.new(value: -1).to_h).to eq(text: "", value: -1)
expect(boolean.new.value).to eq(false)
Given a behavior like
module Humanize
def humanize
"my value is #{value}"
end
end
let(:class_level) { DataClass(value: 1).include(Humanize) }
Then we can access the included method
expect(class_level.new.humanize).to eq("my value is 1")
A DataClass
object behaves like the result of it's to_h
in pattern matching
Given
let(:numbers) { DataClass(:name, values: []) }
let(:odds) { numbers.new(name: "odds", values: (1..4).map{ _1 + _1 + 1}) }
let(:evens) { numbers.new(name: "evens", values: (1..4).map{ _1 + _1}) }
Then we can match accordingly
match = case odds
in {name: "odds", values: [1, *]}
:not_really
in {name: "evens"}
:still_naaah
in {name: "odds", values: [hd, *]}
hd
else
:strange
end
expect(match).to eq(3)
And in in
expressions
evens => {values: [_, second, *]}
expect(second).to eq(4)
Given a nice little dataclass Box
let(:box) { DataClass content: nil }
Then we can also use it in a case statement
value = case box.new
when box
42
else
0
end
expect(value).to eq(42)
And all the associated methods
expect(box.new).to be_a(box)
expect(box === box.new).to be_truthy
It is useful to be able to filter heterogeneous lists of DataClass
instances by means of &to_proc
, therefore
Given two different DataClass
objects
let(:class1) { DataClass :value }
let(:class2) { DataClass :value }
And a list of instances
let(:list) {[class1.new(value: 1), class2.new(value: 2), class1.new(value: 3)]}
Then we can filter
expect(list.filter(&class2)).to eq([class2.new(value: 2)])
We have already seen the to_h
method, however if we want to pass an instance of DataClass
as
keyword parameters we need an implementation of to_hash
, which of course is just an alias
Given this keyword method
def extract_value(value:, **others)
[value, others]
end
And this DataClass
:
let(:my_class) { DataClass(value: 1, base: 2) }
Then we can pass it as keyword arguments
expect(extract_value(**my_class.new)).to eq([1, base: 2])
Values of attributes of a DataClass
can have constraints
Given a DataClass
with constraints
let :switch do
DataClass(on: false).with_constraint(on: -> { [false, true].member? _1 })
end
Then boolean values are acceptable
expect{ switch.new }.not_to raise_error
expect(switch.new.merge(on: true).on).to eq(true)
But we can neither construct or merge with non boolean values
expect{ switch.new(on: nil) }
.to raise_error(Lab42::DataClass::ConstraintError, "value nil is not allowed for attribute :on")
expect{ switch.new.merge(on: 42) }
.to raise_error(Lab42::DataClass::ConstraintError, "value 42 is not allowed for attribute :on")
And therefore defaultless attributes cannot have a constraint that is violated by a nil value
error_head = "constraint error during validation of default value of attribute :value"
error_body = " undefined method `>' for nil:NilClass"
error_message = [error_head, error_body].join("\n")
expect{ DataClass(value: nil).with_constraint(value: -> { _1 > 0 }) }
.to raise_error(Lab42::DataClass::ConstraintError, /#{error_message}/)
And defining constraints for undefined attributes is not the best of ideas
expect { DataClass(a: 1).with_constraint(b: -> {true}) }
.to raise_error(Lab42::DataClass::UndefinedAttributeError, "constraints cannot be defined for undefined attributes [:b]")
Often repeating patterns are implemented as non lambda constraints, depending on the type of a constraint it is implicitly converted to a lambda as specified below:
Given a shortcut for our ConstraintError
let(:constraint_error) { Lab42::DataClass::ConstraintError }
let(:positive) { DataClass(:value) }
... are sent to the value of the attribute, this is not very surprising of course ;)
Then a first implementation of Positive
positive_by_symbol = positive.with_constraint(value: :positive?)
expect(positive_by_symbol.new(value: 1).value).to eq(1)
expect{positive_by_symbol.new(value: 0)}.to raise_error(constraint_error)
... are also sent to the value of the attribute, this time we can provide paramaters
And we can implement a different form of Positive
positive_by_ary = positive.with_constraint(value: [:>, 0])
expect(positive_by_ary.new(value: 1).value).to eq(1)
expect{positive_by_ary.new(value: 0)}.to raise_error(constraint_error)
If however we are interested in membership we have to wrap the Array
into a Set
And this works with a Set
positive_by_set = positive.with_constraint(value: Set.new([*1..10]))
expect(positive_by_set.new(value: 1).value).to eq(1)
expect{positive_by_set.new(value: 0)}.to raise_error(constraint_error)
And also with a Range
positive_by_range = positive.with_constraint(value: 1..Float::INFINITY)
expect(positive_by_range.new(value: 1).value).to eq(1)
expect{positive_by_range.new(value: 0)}.to raise_error(constraint_error)
This seems quite obvious, and of course it works
Then we can also have a regex based constraint
vowel = DataClass(:word).with_constraint(word: /[aeiou]/)
expect(vowel.new(word: "alpha").word).to eq("alpha")
expect{vowel.new(word: "krk")}.to raise_error(constraint_error)
If for example want values just to be of some class, well easy
Then we can use the class as a constraint
container = DataClass(:value).with_constraint(value: String)
expect(container.new(value: "42")[:value]).to eq("42")
expect{ container.new(value: 42) }.to raise_error(constraint_error, "value 42 is not allowed for attribute :value")
Then we can also use instance methods to implement our Positive
positive_by_instance_method = positive.with_constraint(value: Fixnum.instance_method(:positive?))
expect(positive_by_instance_method.new(value: 1).value).to eq(1)
expect{positive_by_instance_method.new(value: 0)}.to raise_error(constraint_error)
Or we can use methods to implement it
positive_by_method = positive.with_constraint(value: 0.method(:<))
expect(positive_by_method.new(value: 1).value).to eq(1)
expect{positive_by_method.new(value: 0)}.to raise_error(constraint_error)
So far we have only speculated about constraints concerning one attribute, however sometimes we want to have arbitrary constraints which can only be calculated by access to more attributes
Given a Point
DataClass
let(:point) { DataClass(:x, :y).validate{ |point| point.x > point.y } }
let(:validation_error) { Lab42::DataClass::ValidationError }
Then we will get a ValidationError
if we construct a point left of the main diagonal
expect{ point.new(x: 0, y: 1) }
.to raise_error(validation_error)
But as validation might need more than the default values we will not execute them at compile time
expect{ DataClass(x: 0, y: 0).validate{ |inst| inst.x > inst.y } }
.to_not raise_error
And we can name validations to get better error messages
better_point = DataClass(:x, :y).validate(:too_left){ |point| point.x > point.y }
ok_point = better_point.new(x: 1, y: 0)
expect{ ok_point.merge(y: 1) }
.to raise_error(validation_error, "too_left")
And remark how bad unnamed validation errors might be
error_message_rgx = %r{
\#<Proc:0x[0-9a-f]+ \s .* spec/speculations/speculations/FACTORY_FUNCTION_spec\.rb: \d+ > \z
}x
expect{ point.new(x: 0, y: 1) }
.to raise_error(validation_error, error_message_rgx)
As with the class based usage we can define Derived Attributes with the factory
Given a data class with a derived attribute
let(:pythagoras) { DataClass(:a, :b).derive(:c){ Math.sqrt(_1.a**2 + _1.b**2)} }
Then the hypotenuse is derived
expect(pythagoras.new(a: 3.0, b: 4.0)[:c]).to eq(5.0)
All the above mentioned features can be achieved with a more conventional syntax by extending a class
with Lab42::DataClass
Given a class that extends DataClass
let :my_class do
Class.new do
extend Lab42::DataClass
attributes :age, member: false
constraint :member, Set.new([false, true])
validate(:too_young_for_member) { |instance| !(instance.member && instance.age < 18) }
end
end
let(:constraint_error) { Lab42::DataClass::ConstraintError }
let(:validation_error) { Lab42::DataClass::ValidationError }
let(:my_instance) { my_class.new(age: 42) }
let(:my_vip) { my_instance.merge(member: true) }
Then we can observe that instances of such a class
expect(my_instance.to_h).to eq(age: 42, member: false)
expect(my_vip.to_h).to eq(age: 42, member: true)
expect(my_instance.member).to be_falsy
And we will get constraint errors if applicable
expect{my_instance.merge(member: nil)}
.to raise_error(constraint_error)
And of course validations still work too
expect{ my_vip.merge(age: 17) }
.to raise_error(validation_error, "too_young_for_member")