Attestor
Validations and policies for immutable Ruby objects
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.
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:
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:
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:
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.
---
en:
attestor:
errors:
transfer:
inconsistent: "Credit differs from debet by %{fraud}"
To validate an object, use its #validate!
method:
debet = OpenStruct.new(sum: 100)
credit = OpenStruct.new(sum: 90)
fraud_transfer = Transfer.new(debet, credit)
begin
transfer.validate!
rescue Attestor::InvalidError => error
error.object == transfer
error.messages
end
Alternatively use the safe version #validate
.
It rescues from an exception and returns a corresponding report:
report = transfer.validate
report.valid?
report.invalid?
report.object == transfer
report.messages
report.error
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.
class Transfer
validate :consistent, except: :steal_of_money
end
Then call a #validate!
/#validate
methods with that context:
fraud_transfer.validate!
fraud_transfer.validate! :steal_of_money
You can use the same validator several times with different contexts. They will be used independently from each other.
class Transfer
validate :consistent, only: :fair_trade, :consistent
validate :consistent, only: :legal
end
You can group validations that uses shared context:
class Transfer
validations only: :fair_trade do
validate :consistent
validate :limited
end
end
Delegation
Extract validator to an external object (policy), that responds to validate!
.
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):
class Transfer
validates { ConsistentTransfer.new(:debet, :credit) }
end
or by method name:
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:
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:
class ConsistencyPolicy
include Attestor::Policy
end
Policy objects can be used by validates
method like other objects that respond to #validate!
:
class Transfer
validates { ConsistencyPolicy.new(debet, credit) }
end
Complex Policies
Policies (assertions) can be combined by logical methods.
Suppose we have two policy objects:
valid_policy.validate.valid?
invalid_policy.validate.valid?
Use factory methods to provide compositions:
complex_policy = valid_policy.not
complex_policy.validate!
complex_policy = valid_policy.and(valid_policy, invalid_policy)
complex_policy.validate!
complex_policy = invalid_policy.or(invalid_policy, valid_policy)
complex_policy.validate!
complex_policy = valid_policy.xor(valid_poicy, valid_policy)
complex_policy.validate!
complex_policy = valid_policy.xor(valid_poicy, invalid_policy)
complex_policy.validate!
The or
, and
and xor
methods called without argument(s) don't provide a policy object. They return lazy composer, expecting #not
method.
complex_policy = valid_policy.and.not(invalid_policy, invalid_policy)
valid_policy.and(invalid_policy.not, invalid_policy.not)
If you prefer wrapping to chaining, use the Policy
factory methods instead:
Policy.and(valid_policy, invalid_policy)
Policy.or(valid_policy, invalid_policy)
Policy.xor(valid_policy, invalid_policy)
Policy.not(valid_policy)
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.
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:
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.
Contributing
- Read the 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
License
See the MIT LICENSE.