PikachuEXE/contracted_value

View on GitHub
README.md

Summary

Maintainability
Test Coverage
# ContractedValue

Library for creating contracted immutable(by default) value objects

This gem allows creation of value objects which are
- contracted (enforced by [`contracts.ruby`](https://github.com/egonSchiele/contracts.ruby))
- immutable (enforced by [`ice_nine`](https://github.com/dkubb/ice_nine))

See details explanation in below sections


## Status

[![GitHub Build Status](https://img.shields.io/github/actions/workflow/status/PikachuEXE/contracted_value/tests.yaml?branch=master&style=flat-square)](https://github.com/PikachuEXE/contracted_value/actions/workflows/tests.yaml)

[![Gem Version](http://img.shields.io/gem/v/contracted_value.svg?style=flat-square)](http://badge.fury.io/rb/contracted_value)
[![License](https://img.shields.io/github/license/PikachuEXE/contracted_value.svg?style=flat-square)](http://badge.fury.io/rb/contracted_value)

[![Code Climate](https://img.shields.io/codeclimate/maintainability/PikachuEXE/contracted_value.svg?style=flat-square)](https://codeclimate.com/github/PikachuEXE/contracted_value)
[![Coverage Status](http://img.shields.io/coveralls/PikachuEXE/contracted_value.svg?style=flat-square)](https://coveralls.io/r/PikachuEXE/contracted_value)

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


## Installation

Add this line to your application's Gemfile:

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

```bash
$ bundle
```

Or install it yourself as:

```bash
$ gem install contracted_value
```


## Usage

The examples below might contain some of my habbits,  
like including [`contracts.ruby`](https://github.com/egonSchiele/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

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

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

```ruby
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`](https://github.com/egonSchiele/contracts.ruby) contract)  
Passing input value violating an attribute's contract would cause an error  

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


```ruby
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`](https://github.com/dkubb/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)  

```ruby
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?
```ruby
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
```ruby
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.  

- [values](https://github.com/tcrayford/values)
- [active_attr](https://github.com/cgriego/active_attr)
- [dry-struct](https://github.com/dry-rb/dry-struct)

### [values](https://github.com/tcrayford/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](https://github.com/cgriego/active_attr)
Got similar issue as `values`  

### [dry-struct](https://github.com/dry-rb/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