Skip to content

Latest commit

 

History

History
384 lines (294 loc) · 9.88 KB

README.md

File metadata and controls

384 lines (294 loc) · 9.88 KB

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:

# `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

  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