on an ActiveModel Object, how do I check uniqueness?
Asked Answered
F

2

15

In Bryan Helmkamp's excellent blog post called "7 Patterns to Refactor Fat ActiveRecord Models", he mentions using Form Objects to abstract away multi-layer forms and stop using accepts_nested_attributes_for.

Edit: see below for a solution.

I've almost exactly duplicated his code sample, as I had the same problem to solve:

class Signup
  include Virtus

  extend ActiveModel::Naming
  include ActiveModel::Conversion
  include ActiveModel::Validations

  attr_reader :user
  attr_reader :account

  attribute :name, String
  attribute :account_name, String
  attribute :email, String

  validates :email, presence: true
  validates :account_name,
    uniqueness: { case_sensitive: false },
    length: 3..40,
    format: { with: /^([a-z0-9\-]+)$/i }

  # Forms are never themselves persisted
  def persisted?
    false
  end

  def save
    if valid?
      persist!
      true
    else
      false
    end
  end

private

  def persist!
    @account = Account.create!(name: account_name)
    @user = @account.users.create!(name: name, email: email)
  end
end

One of the things different in my piece of code, is that I need to validate the uniqueness of the account name (and user e-mail). However, ActiveModel::Validations doesn't have a uniqueness validator, as it's supposed to be a non-database backed variant of ActiveRecord.

I figured there are three ways to handle this:

  • Write my own method to check this (feels redundant)
  • Include ActiveRecord::Validations::UniquenessValidator (tried this, didn't get it to work)
  • Or add the constraint in the data storage layer

I would prefer to use the last one. But then I'm kept wondering how I would implement this.

I could do something like (metaprogramming, I would need to modify some other areas):

  def persist!
    @account = Account.create!(name: account_name)
    @user = @account.users.create!(name: name, email: email)
  rescue ActiveRecord::RecordNotUnique
    errors.add(:name, "not unique" )
    false
  end

But now I have two checks running in my class, first I use valid? and then I use a rescue statement for the data storage constraints.

Does anyone know of a good way to handle this issue? Would it be better to perhaps write my own validator for this (but then I'd have two queries to the database, where ideally one would be enough).

Faddist answered 3/2, 2013 at 7:32 Comment(1)
If this can help anyone: in a similar situation I include "ActiveRecord::Validations" instead of "ActiveModel::Validations" - in this way validates_uniqueness_of is availableLemures
F
9

Bryan was kind enough to comment on my question to his blog post. With his help, I've come up with the following custom validator:

class UniquenessValidator < ActiveRecord::Validations::UniquenessValidator
  def setup(klass)
    super
    @klass = options[:model] if options[:model]
  end

  def validate_each(record, attribute, value)
    # UniquenessValidator can't be used outside of ActiveRecord instances, here
    # we return the exact same error, unless the 'model' option is given.
    #
    if ! options[:model] && ! record.class.ancestors.include?(ActiveRecord::Base)
      raise ArgumentError, "Unknown validator: 'UniquenessValidator'"

    # If we're inside an ActiveRecord class, and `model` isn't set, use the
    # default behaviour of the validator.
    #
    elsif ! options[:model]
      super

    # 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.
    #
    else
      record_org, attribute_org = record, attribute

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

      super

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

You can use it in your ActiveModel classes like so:

  validates :account_name,
    uniqueness: { case_sensitive: false, model: Account, attribute: 'name' }

The only problem you'll have with this, is if your custom model class has validations as well. Those validations aren't run when you call Signup.new.save, so you will have to check those some other way. You can always use save(validate: false) inside the above persist! method, but then you have to make sure all validations are in the Signup class, and keep that class up to date, when you change any validations in Account or User.

Faddist answered 3/2, 2013 at 11:29 Comment(1)
Note that in Rails 4.1, #setup is deprecated on validators, and will be removed in 4.2. Changing the method to initialize should work as-is.Diestock
M
12

Creating a custom validator may be overkill if this just happens to be a one-off requirement.

A simplified approach...

class Signup

  (...)

  validates :email, presence: true
  validates :account_name, length: {within: 3..40}, format: { with: /^([a-z0-9\-]+)$/i }

  # Call a private method to verify uniqueness

  validate :account_name_is_unique


  def persisted?
    false
  end

  def save
    if valid?
      persist!
      true
    else
      false
    end
  end

  private

  # Refactor as needed

  def account_name_is_unique
    if Account.where(name: account_name).exists?
      errors.add(:account_name, 'Account name is taken')
    end
  end

  def persist!
    @account = Account.create!(name: account_name)
    @user = @account.users.create!(name: name, email: email)
  end
end
Murdock answered 23/4, 2013 at 18:28 Comment(3)
This will only work for new objects. When updating the record you will receive an error due to the current object already being in the database.Oboe
This is a Signup form, an action which only occurs once in a given user's lifecycle. :) But your point is understood. If you were looking to reuse this form object, one approach may be a #find_or_initialize_by followed by #persisted? to handle each case. An easier alternative approach would be a separate form object for edits and updates to the persisted object.Murdock
I would use exists? instead of count == 0 api.rubyonrails.org/classes/ActiveRecord/…Fredrika
F
9

Bryan was kind enough to comment on my question to his blog post. With his help, I've come up with the following custom validator:

class UniquenessValidator < ActiveRecord::Validations::UniquenessValidator
  def setup(klass)
    super
    @klass = options[:model] if options[:model]
  end

  def validate_each(record, attribute, value)
    # UniquenessValidator can't be used outside of ActiveRecord instances, here
    # we return the exact same error, unless the 'model' option is given.
    #
    if ! options[:model] && ! record.class.ancestors.include?(ActiveRecord::Base)
      raise ArgumentError, "Unknown validator: 'UniquenessValidator'"

    # If we're inside an ActiveRecord class, and `model` isn't set, use the
    # default behaviour of the validator.
    #
    elsif ! options[:model]
      super

    # 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.
    #
    else
      record_org, attribute_org = record, attribute

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

      super

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

You can use it in your ActiveModel classes like so:

  validates :account_name,
    uniqueness: { case_sensitive: false, model: Account, attribute: 'name' }

The only problem you'll have with this, is if your custom model class has validations as well. Those validations aren't run when you call Signup.new.save, so you will have to check those some other way. You can always use save(validate: false) inside the above persist! method, but then you have to make sure all validations are in the Signup class, and keep that class up to date, when you change any validations in Account or User.

Faddist answered 3/2, 2013 at 11:29 Comment(1)
Note that in Rails 4.1, #setup is deprecated on validators, and will be removed in 4.2. Changing the method to initialize should work as-is.Diestock

© 2022 - 2024 — McMap. All rights reserved.