Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/pikachuexe/contracted_value

Library for creating contracted immutable(by default) value objects
https://github.com/pikachuexe/contracted_value

contracts gem ruby value-object

Last synced: 25 days ago
JSON representation

Library for creating contracted immutable(by default) value objects

Awesome Lists containing this project

README

        

# 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