nepalez/attestor

View on GitHub
README.md

Summary

Maintainability
Test Coverage
Attestor
=====

Validations and policies for immutable Ruby objects

**The project is frozen in favor of the [Assertion](https://github.com/nepalez/assertion) gem, that implements even more clean way of setting statements about objects, and validating them.**

[![Gem Version](https://img.shields.io/gem/v/attestor.svg?style=flat)][gem]
[![Build Status](https://img.shields.io/travis/nepalez/attestor/master.svg?style=flat)][travis]
[![Dependency Status](https://img.shields.io/gemnasium/nepalez/attestor.svg?style=flat)][gemnasium]
[![Code Climate](https://img.shields.io/codeclimate/github/nepalez/attestor.svg?style=flat)][codeclimate]
[![Coverage](https://img.shields.io/coveralls/nepalez/attestor.svg?style=flat)][coveralls]
[![Inline docs](http://inch-ci.org/github/nepalez/attestor.svg)][inch]

[codeclimate]: https://codeclimate.com/github/nepalez/attestor
[coveralls]: https://coveralls.io/r/nepalez/attestor
[gem]: https://rubygems.org/gems/attestor
[gemnasium]: https://gemnasium.com/nepalez/attestor
[travis]: https://travis-ci.org/nepalez/attestor
[inch]: https://inch-ci.org/github/nepalez/attestor

Motivation
----------

I like the [ActiveModel::Validations] more than any other part of the whole [Rails]. The more I like it the more painful the problem that **it mutates validated objects**.

Every time you run validations, the collection of object's `#errors` is cleared and populated with new messages. So you can't validate frozen (immutable) objects without magic tricks.

To solve the problem, the `attestor` gem:

* Provides a simplest API for validating immutable objects.
* Makes it possible to isolate validators (as [policy objects]) from their targets.
* Allows policy objects to be composed by logical operations to provide complex policies.

[ActiveModel::Validations]: http://apidock.com/rails/ActiveModel/Validations
[Rails]: http://rubyonrails.org/
[policy objects]: http://blog.codeclimate.com/blog/2012/10/17/7-ways-to-decompose-fat-activerecord-models/

Approach
--------

Instead of collecting errors inside the object, the module defines two instance methods:

* `validate!` raises an exception (`Attestor::InvalidError`), that carries errors outside of the object.
* `validate` - the safe version of `validate!`. It rescues from the exception and returns a report object, that carries the exception as well as its error messages.

In both cases the inspected object stays untouched (and can be made immutable).

Installation
------------

Add this line to your application's Gemfile:

```ruby
# Gemfile
gem "attestor"
```

Then execute:

```
bundle
```

Or add it manually:

```
gem install attestor
```

Base Use
--------

Declare validation in the same way as ActiveModel's `.validate` method does:

```ruby
Transfer = Struct.new(:debet, :credit) do
  include Attestor::Validations

  validate :consistent

  private

  def consistent
    fraud = credit.sum - debet.sum
    invalid :inconsistent, fraud: fraud if fraud != 0
  end
end
```

Alternatively, you can describe validation in a block, executed in an instance's scope:

```ruby
class Transfer
  # ...
  validate { invalid :inconsistent if credit.sum != debet.sum }
end
```

The `#invalid` method translates its argument and raises an exception with the resulting message.

```yaml
# config/locales/en.yml
---
en:
  attestor:
    errors:
      transfer:
        inconsistent: "Credit differs from debet by %{fraud}"
```

To validate an object, use its `#validate!` method:

```ruby
debet  = OpenStruct.new(sum: 100)
credit = OpenStruct.new(sum: 90)
fraud_transfer = Transfer.new(debet, credit)

begin
  transfer.validate!       # with the bang
rescue Attestor::InvalidError => error
  error.object == transfer # => true
  error.messages           # => ["Credit differs from debet by 10"]
end
```

Alternatively use the safe version `#validate`.
It rescues from an exception and returns a corresponding report:

```ruby
report = transfer.validate  # without the bang

report.valid?               # => false
report.invalid?             # => true
report.object == transfer   # => true
report.messages             # => ["Credit differs from debet by 10"]
report.error                # => <Attestor::InvalidError ...>
```

Use of Contexts
---------------

Sometimes you need to validate the object agaist the subset of validations, not all of them.

To do this use `:except` and `:only` options of the `.validate` class method.

```ruby
class Transfer
  # ...
  validate :consistent, except: :steal_of_money
end
```

Then call a `#validate!`/`#validate` methods with that context:

```ruby
fraud_transfer.validate!                 # => InvalidError
fraud_transfer.validate! :steal_of_money # => PASSES!
```

You can use the same validator several times with different contexts. They will be used independently from each other.

```ruby
class Transfer
  # ...

  validate :consistent, only: :fair_trade, :consistent
  validate :consistent, only: :legal

end
```

You can group validations that uses shared context:

```ruby
class Transfer

  # This is the same as:
  #
  # validate :consistent, only: :fair_trade
  # validate :limited, only: :fair_trade
  validations only: :fair_trade do
    validate :consistent
    validate :limited
  end
end
```

Delegation
----------

Extract validator to an external object (policy), that responds to `validate!`.

```ruby
ConsistentTransfer = Struct.new(:debet, :credit) do
  include Attestor::Validations

  def validate!
    invalid :inconsistent unless debet.sum == credit.sum
  end
end
```

Then use `validates` helper (with an "s" at the end):

```ruby
class Transfer 
  # ...
  validates { ConsistentTransfer.new(:debet, :credit) }
end
```

or by method name:

```ruby
class Transfer
  # ...
  validates :consistent_transfer

  def consistent_transfer
    ConsistentTransfer.new(:debet, :credit)
  end
```

The difference between `.validate :something` and `.validates :something` methods is that:
* `.validate` expects `#something` to make checks and raise error by itself
* `.validates` expects `#something` to respond to `#validate!`

Policy Objects
--------------

Basically the policy includes `Attestor::Validations` with additional methods to allow logical compositions.

To create a policy as a `Struct` use the builder:

```ruby
ConsistencyPolicy = Attestor::Policy.new(:debet, :credit) do
  def validate!
    fraud = credit - debet
    invalid :inconsistent, fraud: fraud if fraud != 0
  end
end
```

If you doesn't need `Struct`, include `Attestor::Policy` to the class and initialize its arguments somehow else:

```ruby
class ConsistencyPolicy
  include Attestor::Policy
  # ...
end
```

Policy objects can be used by `validates` method like other objects that respond to `#validate!`:

```ruby
class Transfer
  # ...
  validates { ConsistencyPolicy.new(debet, credit) }
end
```

Complex Policies
----------------

Policies (assertions) can be combined by logical methods.

Suppose we have two policy objects:

```ruby
valid_policy.validate.valid?   # => true
invalid_policy.validate.valid? # => false
```

Use factory methods to provide compositions:

```ruby
complex_policy = valid_policy.not
complex_policy.validate! # => fails

complex_policy = valid_policy.and(valid_policy, invalid_policy)
complex_policy.validate! # => fails

complex_policy = invalid_policy.or(invalid_policy, valid_policy)
complex_policy.validate! # => passes

complex_policy = valid_policy.xor(valid_poicy, valid_policy)
complex_policy.validate! # => fails

complex_policy = valid_policy.xor(valid_poicy, invalid_policy)
complex_policy.validate! # => passes
```

The `or`, `and` and `xor` methods called without argument(s) don't provide a policy object. They return lazy composer, expecting `#not` method.

```ruby
complex_policy = valid_policy.and.not(invalid_policy, invalid_policy)
# this is the same as:
valid_policy.and(invalid_policy.not, invalid_policy.not)
```

If you prefer wrapping to chaining, use the `Policy` factory methods instead:

```ruby
Policy.and(valid_policy, invalid_policy)
# this is the same as: valid_policy.and(invalid_policy)

Policy.or(valid_policy, invalid_policy)
# this is the same as: valid_policy.or(invalid_policy)

Policy.xor(valid_policy, invalid_policy)
# this is the same as: valid_policy.xor(invalid_policy)

Policy.not(valid_policy)
# this is the same as: valid_policy.not
```

As before, you can use any number of policies (except for negation of a single policy) at any number of nesting.

RSpec helpers
-------------

In a RSpec tests you can use spies for valid and invalid objects:

* `valid_spy` is a spy that returns `nil` in response to `#validate!` and valid report in responce to `#validate`.
* `invalid_spy` raises on `#validate!` and returns invalid report in responce to `#validate` method call.

```ruby
require "attestor/rspec"

describe "something" do

  let(:valid_object)   { valid_spy   }
  let(:invalid_object) { invalid_spy }

  # ...
end
```

To check whether an arbitrary object is valid, simply use `#validate` method's result:

```ruby
expect(object.validate).to be_valid
expect(object.validate).to be_invalid
```

Compatibility
-------------

Tested under rubies compatible to rubies with API 1.9.3+:

* MRI 1.9.3+
* Rubinius-2 (modes 1.9+)
* JRuby 9.0.0.0.pre1+

Uses [RSpec] 3.0+ for testing and [hexx-suit] for dev/test tools collection.

[RSpec]: http://rspec.info
[hexx-suit]: https://github.com/nepalez/hexx-suit

Contributing
------------

* Read the [STYLEGUIDE](config/metrics/STYLEGUIDE).
* Fork the project
* Create your feature branch (`git checkout -b my-new-feature`)
* Add tests for it
* Commit your changes (`git commit -am '[UPDATE] Add some feature'`)
* Push to the branch (`git push origin my-new-feature`)
* Create a new Pull Request

Latest Changes
--------------

See the [CHANGELOG](CHANGELOG.md)

License
-------

See the [MIT LICENSE](LICENSE).