Comparison With ActiveModel
As explained in the introduction, dry-validation focuses on explicitness, clarity and precision of validation logic. For those of us used to ActiveModel validations with their numerous options, ifs, ons and unlesses, dry-validation is a way to make even the most complex validation cases easy to read and understand.
But, how would we go about converting our ActiveModel validation code into dry-validation?
After reading this guide, you will know:
- How to use dry-validation to replace built-in ActiveModel validation helpers.
- How to use dry-validation to create your own custom validation methods.
Note that there isn't a one-to-one relationship between ActiveModel validators and Dry predicates. This guide shows you the closest matches, and highlights the differences where applicable.
For the main documentation on dry-validation predicates, see Built-in Predicates.
1. Validation Overview
When using ActiveModel validation, validations are declared in the model in the following format:
validates :name, :email, presence: true
You then update the model's state and call valid?
on the model to see if the state is correct. (In the opinion of the dry-rb team, this is a design flaw of ActiveModel. See this blog post for more information).
When using dry-validation, you declare your validation in a separate schema class using predicates to build up rules.
A predicate is a simple stateless method which receives some input and returns either true
or false
.
A simple schema can look like this:
schema = Dry::Validation.Schema do
required(:email).filled
required(:name).filled
end
2. Validation Helpers
2.1 acceptance
In ActiveModel validations this helper is used to validate that a checkbox on the user interface was checked when a form was submitted. This is typically used when the user needs to agree to your application's terms of service, confirm reading some text, or any similar concept.
In its simplest form:
ActiveModel Validation
validates :attr, acceptance: true
dry-validation
required(:attr).filled(:bool?, :true?)
When using the :accepts
option:
ActiveModel Validation
validates :attr, acceptance: { accept: 'yes' }
dry-validation
required(:attr).filled(eql?: 'yes')
Note: ActiveModel automatically creates a virtual acceptance attribute for you. If you are using Protected Parameters you will need to add this attribute yourself.
2.2 validates_associated
This validates whether the associated object or objects are all valid and works with any kind of association.
As your dry-validation schema validates the keys and values you provide, it has no idea the structure of model to which this data relates or it's associations.
You could acheive something to ActiveModel's validates_associated
by using a nested schema and passing in the attributes for your associated objects:
For single (has_one
/ belongs_to
) associations
dry-validation
schema = Dry::Validation.Schema do
required(:name).filled
required(:email).filled
required(:spouse).schema do
required(:name).filled
required(:email).filled
end
end
schema.({
name: 'Fred',
email: 'fred@somewhere.com',
spouse: {
name: 'Alex',
email: 'alex@somewhere.com'
}
})
For has_many
associations
dry-validation
schema = Dry::Validation.Schema do
required(:name).filled
required(:email).filled
required(:cars).each do
schema do
required(:registration_numer).filled
required(:make).filled
required(:model).filled
end
end
end
schema.({
name: 'Fred',
email: 'fred@somewhere.com',
cars: [
{
registration_number: 'ZX651MU',
make: 'Ford',
model: 'Mustang'
},
{
registration_number: 'MU65LTX',
make: 'Audi',
model: 'R8'
}
]
})
2.3 confirmation
This helper is used when you have two text fields that should receive exactly the same content. Common use cases include email addresses and passwords.
ActiveModel Validation
validates :attr, confirmation: true
dry-validation
required(:attr).confirmation
Note: ActiveModel automatically creates a virtual confirmation attribute for you whose name is the name of the field that has to be confirmed with "_confirmation" appended. If you are using Protected Parameters you will need to add this attribute yourself.
2.4 exclusion
This helper validates that the attributes' values are not included in a given enumerable object.
ActiveModel Validation
validates :attr, exclusion: { in: enumerable_object }
dry-validation
required(:attr).filled(excluded_from?: enumerable_object)
Note: As per ActiveModel docs,
:within
option is an alias of:in
2.5 format
This helper validates the attributes' values by testing whether they match or doesn't match a given regular expression.
ActiveModel Validation
validates :attr, format: { with: regex }
dry-validation
required(:attr).filled(format?: regex)
Doesn't Match
ActiveModel Validation
validates :attr, format: { without: regex }
dry-validation
required(:attr) { filled? & format?(regex).not }
2.6 inclusion
This helper validates that the attributes' values are included in a given enumerable object.
ActiveModel Validation
validates :attr, inclusion: { in: enumerable_object }
dry-validation
required(:attr).filled(included_in?: enumerable_object)
Note: As per ActiveModel docs,
:within
option is an alias of:in
2.7 length
This helper validates the length of the attribute's value. ActiveModel relies on a variety of options to specify length constraints in different ways. dry-validation uses different predicates for each constraint.
Minimum
ActiveModel Validation
validates :attr, length: { minimum: int }
dry-validation
required(:attr).filled(min_size?: int)
Maximum
ActiveModel Validation
validates :attr, length: { maximum: int }
dry-validation
required(:attr).filled(max_size?: int)
In
ActiveModel Validation
validates :attr, length: { in: range }
dry-validation
required(:attr).filled(size?: range)
Is
ActiveModel Validation
validates :attr, length: { is: int }
dry-validation
required(:attr).filled(size?: int)
Tokeniser Option
As with ActiveModel Validations, dry-validation counts characters by default. ActiveModel provides a :tokeniser
option to allow you to customise how the value is split. You can achieve the same thing in dry-validation by creating your own predicate e.g.:
Dry::Validation.Schema do
configure do
def word_count?(options, value)
words = value.split(/\s+/).size # split into seperate words
words >= options[:min_size] && words <= options[:max_size] # compare no. words with parameters
end
end
required(:attr).filled(word_count?: { min_size: 300, max_size: 400 } }
end
2.8 numericality
ActiveModel determines numericality either by trying to convert the value to a Float, or by using a Regex if you specify only_integer: true
.
In dry-validation, you can either validate that the value is of type Integer, Float, or Decimal using the .int?
, .float?
and .decimal?
predicates respectively, or you can use number?
to test if the value is numerical regardless of its specific data type.
ActiveModel Validation
validates :attr, numericality: true
dry-validation
Dry::Validation.Schema do
# if you know what type of number you require then simply use one of the options below:
required(:attr).filled(:int?)
required(:attr).filled(:float?)
required(:attr).filled(:decimal?)
# For anything which represents a number (e.g. '1', 15, '12.345' etc.)
# you can simply use:
required(:attr).filled(:number?)
end
Options - only_integer
ActiveModel Validation
validates :attr, numericality: { only_integer: true }
dry-validation
required(:attr).filled(format?: /\A[+-]?\d+\Z/) # option 1 - most similar to ActiveModel
required(:attr).filled(:int?) # option 2 - best practise
Options - greater_than
ActiveModel Validation
validates :attr, numericality: { greater_than: int }
dry-validation
required(:attr).filled(:int?, gt?: int)
Options - greater_than_or_equal_to
ActiveModel Validation
validates :attr, numericality: { greater_than_or_equal_to: int }
dry-validation
required(:attr).filled(:int?, gteq?: int)
Options - less_than
ActiveModel Validation
validates :attr, numericality: { less_than: int }
dry-validation
required(:attr).filled(:int?, lt?: int)
Options - less_than_or_equal_to
ActiveModel Validation
validates :attr, numericality: { less_than_or_equal_to: int }
dry-validation
required(:attr).filled(:int?, lteq?: int)
Options - equal_to
ActiveModel Validation
validates :attr, numericality: { equal_to: int }
dry-validation
required(:attr).filled(:int?, eql?: int)
Options - odd
ActiveModel Validation
validates :attr, numericality: { odd: true }
dry-validation
Dry::Validation.Schema do
required(:attr).filled(:int?, :odd?)
end
Options - even
ActiveModel Validation
validates :attr, numericality: { even: true }
dry-validation
Dry::Validation.Schema do
required(:attr).filled(:int?, :even?)
end
Note:
odd?
andeven?
predicates can only be used on integers.
Additional Uses:
dry-validation's predicates uses basic Ruby equality operators (<
, >
, ==
etc.) which means that they can be used to validate anything that's comparable.
For example you can use these predicates to validate dates straight out of the box:
required(:attr).filled(:date?, lteq?: start_date, gteq?: end_date)
2.9 presence
dry-validation has no exact equivalent of ActiveModel's presence
validation (validates :attr, presence: true
. The closest translation would be required(:attr).filled
; however there are a few differences.
Internally, ActiveModel's presence
validation calls the method present?
on the validated attribute, which is equivalent to !blank?
. Neither present?
nor blank?
are a inbuilt Ruby methods, but a monkey-patch added to every object by ActiveSupport, with the following semantics:
nil
andfalse
are blank- strings composed only of whitespace are blank
- empty arrays and hashes are blank
- any object that responds to
empty?
and is empty is blank. - everything else is present.
dry-validation's filled?
predicate is simpler than this, and considers everything to be filled except nil
, empty Strings, empty Arrays, and empty Hashes.
If you want to validate that a string key contains non-whitespace characters (like ActiveSupport's String#present?
, you can use a custom predicate such as:
WHITESPACE_PATTERN = /\A[[:space:]#{"\u200B\u200C\u200D\u2060\uFEFF"}]*\z/
def non_blank?(input)
!(WHITESPACE_PATTERN =~ input)
end
Associations
If you want to be sure that an association is present, you'll need to create a custom predicate to test whether the associated object itself is present. Here is a simple example of what such a predicate might look like:
schema = Dry::Validation.Schema do
configure do
def is_record?(class, value)
class.where(id: value).any?
end
end
required(:name).filled
required(:email).filled
required(:spouse_id).filled(is_record?: Person) # single association
required(:car_ids).filled(:array?, is_record?: Car) # many association
end
schema.({
name: 'Fred',
email: 'fred@somewhere.com',
spouse_id: 1,
car_ids: [21, 23, 24, 25]
})
Booleans
If you want to validate the presence of a boolean field (e.g. true or false) you should use the built in predicate .bool?
.
E.g. required(:attr).filled(:bool?)
2.10 absence
ActiveModel Validation
validates :attr, absence: true
dry-validation
Dry validation includes two predicates (empty?
and none?
) for absence. You should use whichever is most applicable to your situation, remembering that an empty string can be turned into nil using to_nil
coercion.
required(:attr).value(:none?) # only allows nil
required(:attr).value(:empty?) # only empty values: "", [], {}, or nil
Associations
If you want to be sure that an association is absent, we can do the opposite to checking that the association if present but use none for a single object and empty for may objects.
Checking that an association is absent is in many ways is simpler than its present?
equivilent as if the foreign_key / id is nil, then the association would also be nil.
We can therefore simply check that our ids are nil/ empty:
dry-validation
schema = Dry::Validation.Schema do
required(:name).filled
required(:email).filled
required(:spouse_id).value(:none?) # single association
required(:cars).value(:empty?) # many association
end
schema.({
name: 'Fred',
email: 'fred@somewhere.com',
spouse_id: '',
car_ids: []
})
Booleans
To validate the absence of a boolean field (e.g. not true or false) you should use:
required(:attr).value(:none?)
This validates that the value of the :attr
key is nil
.
2.11 uniqueness
Rails' uniqueness
validation is fundamentally different from the other validations because it requires a query against a database. (Accordingly, the uniqueness validation is contained within the activerecord
gem, while other validations are part of activemodel
.) You can test if an attribute is unique by creating a custom predicate to run this query to the database.
Let's take the example included in the offical Active Record Validation guide:
ActiveModel Validations
class Account < ApplicationRecord
validates :email, uniqueness: true
end
dry-validation
schema = Dry::Validation.Schema do
configure do
option :record
def unique?(attr_name, value)
record.class.where.not(id: record.id).where(attr_name => value).empty?
end
end
required(:email).filled(unique?: :email)
end
schema.with(record: user_account).call(input)
Note that our query checks for any records in our class which have the same value for our attribute and where the id is not equal to the record we are updating. This works for both new and persisted records.
Scope
To limit the scope of your query you can simply update your query as needed or as in our example below add a scope paramenter to your custom predicate for example:
schema = Dry::Validation.Schema do
configure do
option :record
def scoped_unique?(attr_name, scope, value)
record.class.where.not(id: record.id).where(scope).where(attr_name => value).empty?
end
end
required(:email).filled(scoped_unique?: [:email, { active: true }])
end
schema.with(record: user_account).call(input)
Case Sensitive
There is also a :case_sensitive option that you can use to define whether the uniqueness constraint will be case sensitive or not. In Active Model Validations this option defaults to true.
Depending on your chosen database, you might find that searches are case insensitive anyway. If not then you could simply update your query to perform a case insensitive search. The exact implementation will depend on your database but here's an example that works with PostgreSQL.
schema = Dry::Validation.Schema do
configure do
option :account
def case_insensitive_unique?(attr_name, value)
account.class.where.not(id: account.id).where("LOWER(#{attr_name}) = ?", value.downcase).empty?
end
end
required(:email).filled(case_insensitive_unique?: :email)
end
schema.with(object: user_account).call(input)
2.12 validates_with
The validates_with helper takes a class, or a list of classes to use for validation.
In reality by using dry-validation you are effectively doing this as your schema is an independent class.
You can read more about how dry-validation work here and more information on how to reuse your schemas here
2.13 validates_each
This helper validates attributes against a block.
Example as per the official Active Record Validation Guide
Active Model Validation
class Person < ApplicationRecord
validates_each :name, :surname do |record, attr, value|
record.errors.add(attr, 'must start with upper case') if value =~ /\A[[:lower:]]/
end
end
dry-validation
In dry-validation we don't provide such a helper. You can acheive the same thing by converting the contents of your validates_each block to a custom predicate and
Now for those of you who have been paying attention, for this simple example we could use our format?
predicate to validate this. However, lets for arguments sake say that we want to do this via a custom predicate what might that look like?
schema = Dry::Validation.Schema do
configure do
def starts_with_uppercase?(value)
value =~ /^[A-Z]*/ # check that the first character in our string is uppercase
end
end
required(:name).filled(:str, :starts_with_uppercase?)
required(:surname_name).filled(:str, :starts_with_uppercase?)
end
3. Common Validation Options
These are the common options allowed by ActiveModel validations, and their equivalents in dry-validation
3.1 :allow_nil
Simply use maybe
instead of required
when defining your rules.
ActiveModel Validation
validates :attr, length: { minimum: int, allow_nil: true }
dry-validation
required(:attr).maybe(str?, min_size?: int)
3.2 :allow_blank
Bareing in mind the differences explained between Ruby's
In dry-validation you will need to use a block when defining your rule instead of filled
, and include the .empty?
predicate into your rule.
ActiveModel Validation
validates :attr, length: { minimum: int, allow_blank: true }
dry-validation
required(:attr) { empty? | str? & min_size?(int) )
3.3 :message
Custom messages are implemented through a separate YAMl file. See Error Messages for full instructions.
3.4 :on
In dry-validation, validations are defined in schemas. You can create separate schemas for various states (e.g UserCreateSchema, UserUpdateSchema) and then choose the correct schema to run in the relevant action.
You can keep your schema code nice and DRY by reusing schemas.
4. Conditional Validation
ActiveModel Validation
In ActiveModel you can use :if
or :unless
to only perform a validation based on the result of a proc or method.
A simple schema can look like this:
validates :card_number, presence: true, if: :paid_with_card?
def paid_with_card?
payment_type == "card"
end
dry-validation
To achieve this in dry-validation you can use high-level rules.
Declare a rule for each of the attributes you need to reference:
required(:payment_type).filled(included_in?: ["card", "cash", "cheque"])
optional(:card_number).maybe
Declare a high level rule to require the card number if payment_type == 'card'
:
rule(require_card_number: [:card_number, :payment_type]) do |card_number, payment_type|
payment_type.eql?('card') > card_number.filled?
end
Put it all together and you get:
schema = Dry::Validation.Schema do
required(:payment_type).filled(included_in?: ["card", "cash", "cheque"])
optional(:card_number).maybe
rule(require_card_number: [:card_number, :payment_type]) do |card_number, payment_type|
payment_type.eql?('card') > card_number.filled?
end
end
schema.({
payment_type: 'cash',
}).success? # true
schema.({
payment_type: 'card',
}).success? # false
schema.({
payment_type: 'card',
card_number: '4242424242424242',
}).success? # true