ActiveModel uniqueness validations

14 Jul 2017

Jean Mertz posted a nice writeup a while back on how to do uniqueness validations in ActiveModel. The gist of it is that since an ActiveModel object could map to any number of ActiveRecord classes, he subclassed ActiveRecord::Validations::UniquenessValidator to allow client code to specify the class in the validation declaration:

validates :email, presence: true, uniqueness: { model: Person }

One small update to his code - using Rails 5.1.2, it seems ActiveRecord's runtime behavior has changed and so you need to override the constructor, not the setup method. So just:

def initialize(options)
  super
  @klass = options[:model] if options[:model]
end

And in order to work with composite unique constraints we need to factor in the scope variable name, so this needs to happen just before the super call:

if options[:scope]
  base_attrs[options[:scope]] = record.send(options[:scope])
end
record = options[:model].new(base_attrs)

Also, I wanted to call this out clearly as a custom validation, so I named the class ActiveModelUniquenessValidator and then declared the validation like this:

validates :email, presence: true
validates :email, active_model_uniqueness: { model: Person }

This is a nice tweak that can help clean up some custom validations. Good stuff, thanks Jean!

P.S. Here's the complete snippet as I'm using it:

# Very much based on https://coderwall.com/p/u4ckka/uniqueness-validations-in-activemodel
class ActiveModelUniquenessValidator < ActiveRecord::Validations::UniquenessValidator
  def initialize(options)
    super
    @klass = options[:model] if options[:model]
  end

  def validate_each(record, attribute, value)
    # Custom validator options. The validator can be called in any class, as
    # long as it includes `ActiveModel::Validations`. You can tell the validator
    # which ActiveRecord based class to check against, using the `model`
    # option. Also, if you are using a different attribute name, you can set the
    # correct one for the ActiveRecord class using the `attribute` option.
    #
    record_org, attribute_org = record, attribute

    attribute = options[:attribute].to_sym if options[:attribute]
    record = options[:model].new(attribute => value)
    if options[:scope]
      base_attrs[options[:scope]] = record.send(options[:scope])
    end
    record = options[:model].new(base_attrs)

    super

    if record.errors.any?
      record_org.errors.add(attribute_org, :taken,
        options.except(:case_sensitive, :scope).merge(value: value))
    end
  end
end