Building Custom Validators in Rails
The basic ActiveRecord validations are well-known to most Rails developers.
class Person < ActiveRecord::Base
validates :name, presence: true
end
Person.new(name: nil).valid? # false
If there is no built-in validator that fits your needs you can always use a custom method to perform the validation.
class Image < ActiveRecord::Base
validate :valid_file_name_extension
private
def valid_file_name_extension
if file_name.present? && !%w{.jpg .png .jpeg .tif}.include?(File.extname(file_name.downcase))
errors.add(:file_name, :invalid_extension)
end
end
end
This approach becomes troublesome if you need to apply the same validation to multiple attributes or models. This is where we can use a custom validator.
Custom Validators
Custom validators usually inherit from the ActiveModel::EachValidator
class and define a validate_each
method. I usually put all validators in /app/validators
.
# app/validators/image_file_name_validator.rb
class ImageFileNameValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
end
end
The syntax for using our custom validator is the same as for the built-in validators.
class Image < ActiveRecord::Base
validates :file_name, image_file_name: { supported_extensions: %w{.jpg .png .jpeg .tif} }
end
The validate_each
method is provided with the model instance, the name of attribute being validated and the value of the attribute. We also have access to the slightly confusing options hash which contains optional parameters passed to the validator. In this case it would contain { supported_extensions: %w{.jpg .png .jpeg .tif} }
.
Implementing the logic for the validator is now pretty straightforward.
class ImageFileNameValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
supported_extensions = options.fetch(:supported_extensions, %w{.jpg .png .jpeg})
if value.present? && !supported_extensions.include?(File.extname(value.downcase))
record.errors.add(:file_name, :invalid_extension, valid_extensions: supported_extensions)
end
end
end
By default this code will validate that the extension is either .jpg, .png or .jpeg - but allows this list of valid extensions to be customized as we’re doing in our example.
Note that I’m passing a symbol - :invalid_extension - to the errors object, instead of specifying an error message. This means we can specify the actual error message in our locales file instead of hardcoding the error message. I am also supplying the valid extensions as additional details that can be used by the error message.
Rails also allows us to easily test custom validators, which will also help to illustrate how our validator behaves.
Of course, when testing a validator we need some kind of model to test against. Instead of coupling our validator to an existing model it would be ideal if we could generate some kind of stub model. While we can simply write a class in our test - see this approach for an example - I prefer to use the with_model gem to do the boilerplate for me. I’m also using the shoulda-matchers gem that allows for succinct syntax.
RSpec.describe ImageFileNameValidator do
context 'when supported extensions are supplied' do
with_model :ObjectWithFileName do
model do
attr_accessor :file_name
validates :file_name, image_file_name: { supported_extensions: %{.jpg .jpeg} }
end
end
let(:model) { ObjectWithFileName.new }
it 'allows nil values' do
expect(model).to allow_value(nil).for(:file_name)
end
it 'allows values with the specified extensions' do
expect(model).to allow_value('thumbnail.jpeg').for(:file_name)
expect(model).not_to allow_value('thumbnail.png').for(:file_name)
end
end
context 'when no supported extensions are specified' do
with_model :ObjectWithFileName do
model do
attr_accessor :file_name
validates :file_name, image_file_name: true
end
end
let(:model) { ObjectWithFileName.new }
it 'allows nil values' do
expect(model).to allow_value(nil).for(:file_name)
end
it 'allows jpg, png and jpeg extensions' do
expect(model).to allow_value('thumbnail.jpg').for(:file_name)
expect(model).to allow_value('thumbnail.png').for(:file_name)
expect(model).to allow_value('thumbnail.jpeg').for(:file_name)
expect(model).not_to allow_value('thumbnail.tif').for(:file_name)
end
end
end
Extending Built-in Validators
Many of the built-in validations allow us to specify many different options to get the exact validation behavior we want. For example, if we are storing the square footage of a property we would want to validate that the attribute is a positive integer.
class Property < ActiveRecord::Base
validates :size, greater_than: 0, only_integer: true, allow_nil: true
end
However, if we need the same validation on multiple attributes or models it becomes troublesome to duplicate this validation. For core application logic it makes sense to wrap this in a custom validator that simply captures the custom options we pass to the built-in validator.
class SquareFootageValidator < ActiveModel::Validations::NumericalityValidator
def initialize(options = {})
super(options.reverse_merge(greater_than: 0, only_integer: true, allow_nil: true))
end
end
We can use the same pattern to test our validator.
RSpec.describe SquareFootageValidator do
with_model :ObjectWithSizeFootage do
model do
attr_accessor :size
validates :size, square_footage: true
end
end
let(:model) { ObjectWithSizeFootage.new }
it 'allows integer values greater than zero' do
expect(model).to allow_value(1).for(:size)
expect(model).to allow_value(50).for(:size)
expect(model).not_to allow_value(0).for(:size)
expect(model).not_to allow_value(-50).for(:size)
end
it 'allows nil values' do
expect(model).to allow_value(nil).for(:size)
end
end