На объекте ActiveModel, как я могу проверить уникальность?

В Bryan Helmkamp отличная запись в блоге под названием " 7 шаблонов для рефакторинга Fat ActiveRecord Models", он упоминает использование Form Objects для абстрактного удаления нескольких -слойные формы и прекратить использование accepts_nested_attributes_for.

Изменить: см. ниже для решения.

Я почти точно продублировал его образец кода, так как я решил такую ​​же проблему:

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

Одна из вещей, отличных в моей части кода, заключается в том, что мне нужно проверить уникальность имени учетной записи (и электронной почты пользователя). Тем не менее, ActiveModel::Validations не имеет валидатора uniqueness, поскольку он должен быть резервным вариантом, не поддерживаемым базой данных ActiveRecord.

Я понял, что есть три способа справиться с этим:

  • Напишите мой собственный метод, чтобы проверить это (кажется лишним)
  • Включить ActiveRecord:: Validations:: UniquenessValidator (попробовал это, не получил его для работы)
  • Или добавьте ограничение на уровне хранения данных

Я бы предпочел использовать последний. Но потом я все время задаюсь вопросом, как это реализовать.

Я мог бы сделать что-то вроде (метапрограммирование, мне нужно было бы изменить некоторые другие области):

  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

Но теперь у меня есть две проверки, запущенные в моем классе, сначала я использую valid?, а затем я использую оператор rescue для ограничений хранения данных.

Кто-нибудь знает хороший способ справиться с этой проблемой? Было бы лучше, возможно, написать мой собственный валидатор для этого (но тогда у меня было бы два запроса к базе данных, где в идеале было бы достаточно).

Ответ 1

Брайан был достаточно любезен для комментариев по моему вопросу к его сообщению в блоге. С его помощью я придумал следующий пользовательский валидатор:

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

Вы можете использовать его в своих классах ActiveModel, например:

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

Единственная проблема, с которой вы столкнетесь, заключается в том, что ваш собственный класс model имеет также проверки. Эти проверки не выполняются при вызове Signup.new.save, поэтому вам придется проверять их каким-либо другим способом. Вы всегда можете использовать save(validate: false) внутри указанного метода persist!, но тогда вы должны убедиться, что все проверки находятся в классе Signup и обновите этот класс до даты, когда вы изменяете любые проверки в Account или User.

Ответ 2

Создание пользовательского валидатора может быть излишним, если это просто одноразовое требование.

Упрощенный подход...

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