ContractedValue
Library for creating contracted immutable(by default) value objects
This gem allows creation of value objects which are
- contracted (enforced by
contracts.ruby
) - immutable (enforced by
ice_nine
)
See details explanation in below sections
Status
The above badges are generated by https://shields.io/
Installation
Add this line to your application's Gemfile:
# `require` can be set to `true` safely without too much side effect
# (except having additional modules & classes defined which could be wasting memory).
# But there is no point requiring it unless in test
# Also maybe add it inside a "group"
gem "contracted_value", require: false
And then execute:
$ bundle
Or install it yourself as:
$ gem install contracted_value
Usage
The examples below might contain some of my habbits,
like including contracts.ruby
modules in class
You don't have to do it
Attribute Declaration
You can declare with or without contract/default value But an attribute cannot be declared twice
module ::Geometry
end
module ::Geometry::LocationRange
class Entry < ::ContractedValue::Value
include ::Contracts::Core
include ::Contracts::Builtin
attribute(
:latitude,
contract: Numeric,
)
attribute(
:longitude,
contract: Numeric,
)
attribute(
:radius_in_meter,
contract: And[Numeric, Send[:positive?]],
)
attribute(
:latitude,
) # => error, declared already
end
end
location_range = ::Geometry::LocationRange::Entry.new(
latitude: 22.2,
longitude: 114.4,
radius_in_meter: 1234,
)
Attribute Assignment
Only Hash
and ContractedValue::Value
can be passed to .new
module ::Geometry
end
module ::Geometry::Location
class Entry < ::ContractedValue::Value
include ::Contracts::Core
include ::Contracts::Builtin
attribute(
:latitude,
contract: Numeric,
)
attribute(
:longitude,
contract: Numeric,
)
end
end
module ::Geometry::LocationRange
class Entry < ::ContractedValue::Value
include ::Contracts::Core
include ::Contracts::Builtin
attribute(
:latitude,
contract: Numeric,
)
attribute(
:longitude,
contract: Numeric,
)
attribute(
:radius_in_meter,
contract: Maybe[And[Numeric, Send[:positive?]]],
default_value: nil,
)
end
end
location = ::Geometry::Location::Entry.new(
latitude: 22.2,
longitude: 114.4,
)
location_range = ::Geometry::LocationRange::Entry.new(location)
Passing objects of different ContractedValue::Value
subclasses to .new
Possible due to the implementation calling #to_h
for ContractedValue::Value
objects
But in case the attribute names are different, or adding new attributes/updating existing attributes is needed
You will need to call #to_h
to get a Hash
and do whatever modification needed before passing into .new
class Pokemon < ::ContractedValue::Value
attribute(:name)
attribute(:type)
end
class Pikachu < ::Pokemon
attribute(:name, default_value: "Pikachu")
attribute(:type, default_value: "Thunder")
end
# Ya I love using pokemon as examples, problem?
pikachu = Pikachu.new(name: "PikaPika")
pikachu.name #=> "PikaPika"
pikachu.type #=> "Thunder"
pokemon1 = Pokemon.new(pikachu)
pokemon1.name #=> "PikaPika"
pokemon1.type #=> "Thunder"
pokemon2 = Pokemon.new(pikachu.to_h.merge(name: "Piak"))
pokemon2.name #=> "Piak"
pokemon2.type #=> "Thunder"
Input Validation
Input values are validated on object creation (instead of on attribute value access) with 2 validations:
- Value contract
- Value presence
Value contract
An attribute can be declared without any contract, and any input value would be pass the validation
But you can pass a contract via contract
option (must be a contracts.ruby
contract)
Passing input value violating an attribute's contract would cause an error
class YetAnotherRationalNumber < ::ContractedValue::Value
include ::Contracts::Core
include ::Contracts::Builtin
attribute(
:numerator,
contract: ::Integer,
)
attribute(
:denominator,
contract: And[::Integer, Not[Send[:zero?]]],
)
end
YetAnotherRationalNumber.new(
numerator: 1,
denominator: 0,
) # => Error
Value presence
An attribute declared should be provided a value on object creation, even the input value is nil
Otherwise an error is raised
You can pass default value via option default_value
The default value will need to confront to the contract passed in contract
option too
module ::WhatIsThis
class Entry < ::ContractedValue::Value
include ::Contracts::Core
include ::Contracts::Builtin
attribute(
:something_required,
)
attribute(
:something_optional,
default_value: nil,
)
attribute(
:something_with_error,
contract: NatPos,
default_value: 0,
) # => error
end
end
WhatIsThis::Entry.new(
something_required: 123,
).something_optional # => nil
Object Freezing
All input values are frozen using ice_nine
by default
But some objects won't work properly when deeply frozen (rails obviously)
So you can specify how input value should be frozen (or not frozen) with option refrigeration_mode
Possible values are:
-
:deep
(default) :shallow
:none
However the value object itself is always frozen
Any lazy method caching with use of instance var would cause FrozenError
(Many Rails classes use lazy caching heavily so most rails object can't be frozen to work properly)
class SomeDataEntry < ::ContractedValue::Value
include ::Contracts::Core
include ::Contracts::Builtin
attribute(
:cold_hash,
contract: ::Hash,
)
attribute(
:cool_hash,
contract: ::Hash,
refrigeration_mode: :shallow,
)
attribute(
:warm_hash,
contract: ::Hash,
refrigeration_mode: :none,
)
def cached_hash
@cached_hash ||= {}
end
end
entry = SomeDataEntry.new(
cold_hash: {a: {b: 0}},
cool_hash: {a: {b: 0}},
warm_hash: {a: {b: 0}},
)
entry.cold_hash[:a].delete(:b) # => `FrozenError`
entry.cool_hash[:a].delete(:b) # => fine
entry.cool_hash.delete(:a) # => `FrozenError`
entry.warm_hash.delete(:a) # => fine
entry.cached_hash # => `FrozenError`
Beware that the value passed to default_value
option when declaring an attribute is always deeply frozen
This is to avoid any in-place change which changes the default value of any value object class attribute
Value Object Class Inheritance
You can create a value object class inheriting an existing value class instead of ::ContractedValue::Value
All existing attributes can be used
No need to explain right?
class Pokemon < ::ContractedValue::Value
attribute(:name)
end
class Pikachu < ::Pokemon
attribute(:type, default_value: "Thunder")
end
# Ya I love using pokemon as examples, problem?
pikachu = Pikachu.new(name: "PikaPika")
pikachu.name #=> "PikaPika"
pikachu.type #=> "Thunder"
All existing attributes can be redeclared
Within the same class you cannot redefine an attribute But in subclasses you can
class Pokemon < ::ContractedValue::Value
attribute(:name)
end
class Pikachu < ::Pokemon
include ::Contracts::Core
include ::Contracts::Builtin
attribute(
:name,
contract: And[::String, Not[Send[:empty?]]],
default_value: String.new("Pikachu"),
refrigeration_mode: :none,
)
end
# Ya I love using pokemon as examples, problem?
Pikachu.new.name # => "Pikachu"
Pikachu.new.name.frozen? # => true, as mentioned above default value are always deeply frozen
Pikachu.new(name: "Pikaaaachuuu").name.frozen? # => false
Related gems
Here is a list of gems which I found and I have tried some of them.
But eventually I am unsatisfied so I build this gem.
I used to use this a bit
But I keep having to write the attribute names in Values.new
,
then the same attribute names again with attr_reader
+ contract (since I want to use contract)
Also the input validation happens on attribute value access instead of on object creation
Got similar issue as values
Seems more suitable for form objects instead of just value objects (for me)
Contributing
- Fork it ( https://github.com/PikachuEXE/contracted_value/fork )
- Create your branch (Preferred to be prefixed with
feature
/fix
/other sensible prefixes) - Commit your changes (No version related changes will be accepted)
- Push to the branch on your forked repo
- Create a new Pull Request