项目作者: PikachuEXE

项目描述 :
Library for creating contracted immutable(by default) value objects
高级语言: Ruby
项目地址: git://github.com/PikachuEXE/contracted_value.git
创建时间: 2019-05-30T09:03:29Z
项目社区:https://github.com/PikachuEXE/contracted_value

开源协议:MIT License

下载


ContractedValue

Library for creating contracted immutable(by default) value objects

This gem allows creation of value objects which are

See details explanation in below sections

Status

GitHub Build Status

Gem Version
License

Code Climate
Coverage Status

The above badges are generated by https://shields.io/

Installation

Add this line to your application’s Gemfile:

  1. # `require` can be set to `true` safely without too much side effect
  2. # (except having additional modules & classes defined which could be wasting memory).
  3. # But there is no point requiring it unless in test
  4. # Also maybe add it inside a "group"
  5. gem "contracted_value", require: false

And then execute:

  1. $ bundle

Or install it yourself as:

  1. $ 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

  1. module ::Geometry
  2. end
  3. module ::Geometry::LocationRange
  4. class Entry < ::ContractedValue::Value
  5. include ::Contracts::Core
  6. include ::Contracts::Builtin
  7. attribute(
  8. :latitude,
  9. contract: Numeric,
  10. )
  11. attribute(
  12. :longitude,
  13. contract: Numeric,
  14. )
  15. attribute(
  16. :radius_in_meter,
  17. contract: And[Numeric, Send[:positive?]],
  18. )
  19. attribute(
  20. :latitude,
  21. ) # => error, declared already
  22. end
  23. end
  24. location_range = ::Geometry::LocationRange::Entry.new(
  25. latitude: 22.2,
  26. longitude: 114.4,
  27. radius_in_meter: 1234,
  28. )

Attribute Assignment

Only Hash and ContractedValue::Value can be passed to .new

  1. module ::Geometry
  2. end
  3. module ::Geometry::Location
  4. class Entry < ::ContractedValue::Value
  5. include ::Contracts::Core
  6. include ::Contracts::Builtin
  7. attribute(
  8. :latitude,
  9. contract: Numeric,
  10. )
  11. attribute(
  12. :longitude,
  13. contract: Numeric,
  14. )
  15. end
  16. end
  17. module ::Geometry::LocationRange
  18. class Entry < ::ContractedValue::Value
  19. include ::Contracts::Core
  20. include ::Contracts::Builtin
  21. attribute(
  22. :latitude,
  23. contract: Numeric,
  24. )
  25. attribute(
  26. :longitude,
  27. contract: Numeric,
  28. )
  29. attribute(
  30. :radius_in_meter,
  31. contract: Maybe[And[Numeric, Send[:positive?]]],
  32. default_value: nil,
  33. )
  34. end
  35. end
  36. location = ::Geometry::Location::Entry.new(
  37. latitude: 22.2,
  38. longitude: 114.4,
  39. )
  40. 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

  1. class Pokemon < ::ContractedValue::Value
  2. attribute(:name)
  3. attribute(:type)
  4. end
  5. class Pikachu < ::Pokemon
  6. attribute(:name, default_value: "Pikachu")
  7. attribute(:type, default_value: "Thunder")
  8. end
  9. # Ya I love using pokemon as examples, problem?
  10. pikachu = Pikachu.new(name: "PikaPika")
  11. pikachu.name #=> "PikaPika"
  12. pikachu.type #=> "Thunder"
  13. pokemon1 = Pokemon.new(pikachu)
  14. pokemon1.name #=> "PikaPika"
  15. pokemon1.type #=> "Thunder"
  16. pokemon2 = Pokemon.new(pikachu.to_h.merge(name: "Piak"))
  17. pokemon2.name #=> "Piak"
  18. 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

  1. class YetAnotherRationalNumber < ::ContractedValue::Value
  2. include ::Contracts::Core
  3. include ::Contracts::Builtin
  4. attribute(
  5. :numerator,
  6. contract: ::Integer,
  7. )
  8. attribute(
  9. :denominator,
  10. contract: And[::Integer, Not[Send[:zero?]]],
  11. )
  12. end
  13. YetAnotherRationalNumber.new(
  14. numerator: 1,
  15. denominator: 0,
  16. ) # => 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

  1. module ::WhatIsThis
  2. class Entry < ::ContractedValue::Value
  3. include ::Contracts::Core
  4. include ::Contracts::Builtin
  5. attribute(
  6. :something_required,
  7. )
  8. attribute(
  9. :something_optional,
  10. default_value: nil,
  11. )
  12. attribute(
  13. :something_with_error,
  14. contract: NatPos,
  15. default_value: 0,
  16. ) # => error
  17. end
  18. end
  19. WhatIsThis::Entry.new(
  20. something_required: 123,
  21. ).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)

  1. class SomeDataEntry < ::ContractedValue::Value
  2. include ::Contracts::Core
  3. include ::Contracts::Builtin
  4. attribute(
  5. :cold_hash,
  6. contract: ::Hash,
  7. )
  8. attribute(
  9. :cool_hash,
  10. contract: ::Hash,
  11. refrigeration_mode: :shallow,
  12. )
  13. attribute(
  14. :warm_hash,
  15. contract: ::Hash,
  16. refrigeration_mode: :none,
  17. )
  18. def cached_hash
  19. @cached_hash ||= {}
  20. end
  21. end
  22. entry = SomeDataEntry.new(
  23. cold_hash: {a: {b: 0}},
  24. cool_hash: {a: {b: 0}},
  25. warm_hash: {a: {b: 0}},
  26. )
  27. entry.cold_hash[:a].delete(:b) # => `FrozenError`
  28. entry.cool_hash[:a].delete(:b) # => fine
  29. entry.cool_hash.delete(:a) # => `FrozenError`
  30. entry.warm_hash.delete(:a) # => fine
  31. 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?

  1. class Pokemon < ::ContractedValue::Value
  2. attribute(:name)
  3. end
  4. class Pikachu < ::Pokemon
  5. attribute(:type, default_value: "Thunder")
  6. end
  7. # Ya I love using pokemon as examples, problem?
  8. pikachu = Pikachu.new(name: "PikaPika")
  9. pikachu.name #=> "PikaPika"
  10. pikachu.type #=> "Thunder"

All existing attributes can be redeclared

Within the same class you cannot redefine an attribute
But in subclasses you can

  1. class Pokemon < ::ContractedValue::Value
  2. attribute(:name)
  3. end
  4. class Pikachu < ::Pokemon
  5. include ::Contracts::Core
  6. include ::Contracts::Builtin
  7. attribute(
  8. :name,
  9. contract: And[::String, Not[Send[:empty?]]],
  10. default_value: String.new("Pikachu"),
  11. refrigeration_mode: :none,
  12. )
  13. end
  14. # Ya I love using pokemon as examples, problem?
  15. Pikachu.new.name # => "Pikachu"
  16. Pikachu.new.name.frozen? # => true, as mentioned above default value are always deeply frozen
  17. Pikachu.new(name: "Pikaaaachuuu").name.frozen? # => false

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.

values

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

active_attr

Got similar issue as values

dry-struct

Seems more suitable for form objects instead of just value objects (for me)

Contributing

  1. Fork it ( https://github.com/PikachuEXE/contracted_value/fork )
  2. Create your branch (Preferred to be prefixed with feature/fix/other sensible prefixes)
  3. Commit your changes (No version related changes will be accepted)
  4. Push to the branch on your forked repo
  5. Create a new Pull Request