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's validate
instance method just raises an exception (Attestor::InvalidError
), that carries errors outside of the object. The object stays untouched (and can be made immutable).
Installation
Add this line to your application's Gemfile:
# Gemfile
gem "attestor"
Then execute:
bundle
Or add it manually:
gem install attestor
Basic Use
Declare validation in the same way as ActiveModel's .validate
method does:
class Transfer < Struct.new(:debet, :credit)
include Attestor::Validations
validate :consistent
end
You have to define an instance validator method (that can be private):
class Transfer < Struct.new(:debet, :credit)
# ...
private
def consistent
fraud = credit.sum - debet.sum
invalid :inconsistent, fraud: fraud if fraud != 0
end
end
The #invalid
method translates its argument in a current class scope and raises an exception.
# config/locales/en.yml
---
en:
attestor:
errors:
transfer:
inconsistent: "Credit differs from debet by %{fraud}"
Alternatively, you can describe validation in the block (called in the scope of instance):
class Transfer
# ...
validate { invalid :inconsistent if credit.sum != debet.sum }
end
To run validations use the #validate
instance method:
debet = OpenStruct.new(sum: 100)
credit = OpenStruct.new(sum: 90)
fraud_transfer = Transfer.new(debet, credit)
begin
transfer.validate
rescue => error
error.object == transfer # => true
error.
# => ["Credit differs from debet by 10"]
end
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 < Struct.new(:debet, :credit)
include Attestor::Validations
validate :consistent, except: :steal_of_money
end
Then call a validate method with that context:
fraud_transfer.validate # => InvalidError
fraud_transfer.validate :steal_of_money # => PASSES!
You can use the same validator several times with different contexts. Any validation will be made independently from the others:
class Transfer
# ...
validate :consistent, only: :fair_trade
validate :consistent, only: :legal
# This is the same as:
# validate :consistent, only: [:fair_trade, :legal]
end
Delegation
Extract validator to the external object (policy), that responds to validate
.
class ConsistentTransfer.new(:debet, :credit)
include Attestor::Validations
def validate
invalid :inconsistent unless debet.sum == credit.sum
end
end
Then use validates
helper:
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 change between validate :something
and validates :something
is that:
validate
expects#something
to make checks and raise error by itselfvalidates
expects#something
to respond to#validate
Policy Objects
Basically the policy includes Attestor::Validations
with additional methods to allow chaining policies by logical methods.
To create a policy as a Struct
use the builder method:
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 < OpenStruct
include Attestor::Policy
# ...
end
Policy objects can be used by validates
method like other validatable objects:
class Transfer
# ...
validates { ConsistencyPolicy.new(debet, credit) }
end
They also respond to valid?
and invalid?
methods (that just rescues from vaidate
missing any error messages).
Complex Policies
Policies (assertions) can be combined by logical methods.
Suppose we have two policy objects:
valid_policy.valid? # => true
invalid_policy.valid? # => false
Use factory methods to provide compositions:
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.
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:
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.
Compatibility
Tested under rubies compatible to rubies with API 2.0+:
- MRI 2.0+
- Rubinius-2 (mode 2.0)
- JRuby 9000+ (mode 2.0+)
Uses RSpec 3.0+ for testing and hexx-suit for dev/test tools collection.
Contributing
- Fork the project.
- Read the STYLEGUIDE.
- Make your feature addition or bug fix.
- Add tests for it. This is important so I don't break it in a future version unintentionally.
- Commit, do not mess with Rakefile or version (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
- Send me a pull request. Bonus points for topic branches.
License
See the MIT LICENSE.