Sonntag, 14. Dezember 2014

Create a custom ActiveModel validator!

There are use cases, when the Ruby on Rails standard validators do not satisfy the requirements. Creating a custom validator often times look like:
class Brand < ActiveRecord::Base
  validate :name_likeness
private
  def name_likeness
    like_conditions = name.split.map { |term| 
      self.class.arel_table[:name].matches("%#{term}%") 
    }.inject(&:or)
    return if self.class.where(like_conditions).count.zero?
    errors.add :name, "has already been taken likewise"
  end
end
which ensures the brands name has no term, which already was used in any brands name component.
and using it:
Brand.where("name LIKE ?", "%Fox%")
=> [<Brand id:1, name: 'Fox Media']
brand = Brand.new name: 'Fox Racing'
brand.save
=> false
brand.full_messages_for :name
=> ["Name has already been taken likewise"]
which works but there are various reasons why it should be refactored:
  1. when the custom validation has to be shared on several model classes
  2. because complex validation logic should be extracted into a concerning class
The custom validation logic should be moved into the custom validation class app/validators/likeness_validator.rb:
class LikenessValidator < ActiveModel::EachValidator
  def validate_each record, attribute, value
    like_conditions = value.split.map { |term|
      record.class.arel_table[:name].matches("%#{term}%")
    }.inject(&:or)
    return if record.class.where(like_conditions).count.zero?
    record.errors.add :name, "has already been taken likewise"
  end 
end   
which inherits from ActiveModel::EachValidator and overwrites its validate_each. The logic mostly was copied from the former validating method with the exception of moving the scope from self to record. The former name is passed by the parameter value.
Of course the autoload_paths should be expanded (config/application.rb):
config.autoload_paths += %W["#{config.root}/app/validators/"]
Then the Brand model class can be refactored:
class Brand < ActiveRecord::Base
  validates :name, likeness: true
end
Please note the naming convention: the validation class was named LikenessValidator and is pointed to with the option likeness.
Validating again:
Brand.where("name LIKE ?", "%Fox%")
=> [<Brand id:1, name: 'Fox Media']
brand = Brand.new name: 'Fox Racing'
brand.save
=> false
brand.full_messages_for :name
=> ["Name has already been taken likewise"]
works as expected.
Additional parameters can be assigned like:
class Brand < ActiveRecord::Base
  validates :name, likeness: { min_length: 5 }
end
and used in the custom validation class:
class LikenessValidator < ActiveModel::EachValidator
  def validate_each record, attribute, value
    terms = terms_with_min_length value, options[:min_length]
    return if terms.empty?
    like_conditions = terms.map { |term| 
      record.class.arel_table[:name].matches("%#{term}%") 
      }.inject(&:or)
    return if record.class.where(like_conditions).count.zero?
    record.errors.add :name, "has already been taken likewise"
  end
private
  def terms_with_min_length term, min_length=nil
    terms = term.split
    return terms if min_length.nil?
    term.split.select { |term| 
      term.length >= min_length 
    }
  end
end   
Brand name terms with less than 5 characters satisfy the validation:
Brand.where("name LIKE ?", "%Fox%")
=> [<Brand id:1, name: 'Fox Media']
brand = Brand.new name: 'Fox Racing'
brand.save
=> true

Further articles of interest:

Supported by Ruby 2.1.1 and Ruby on Rails 4.1.8