Rails - Почему валидация на уникальность не работает?

Что не так с валидацией на уникальность?

Валидация на уникальность в rails не дает 100% гарантии. Давайте попробуем разобраться почему.Создадим простую модель с валидацией уникальности и посмотрим как она будет работать.


class Post < ActiveRecord::Base
  validates :name, uniqueness: true
end

Пробуем создать новую запись


Post.create! name: 'My Post'
   (0.0ms)  begin transaction
  Post Exists (0.2ms)  SELECT  1 AS one FROM "posts" WHERE "posts"."name" = 'My Post' LIMIT 1
  SQL (0.2ms)  INSERT INTO "posts" ("name") VALUES (?)  
   (2.8ms)  commit transaction

Итак, мы видим что у нас в транзакции 2 запроса, причем суммарно время запросов 0.4 ms, а время транзакции 2.8ms. Более того, чем больше будет размер базы, тем больше будет это время. В транзакции также может быть намного больше действий, например, сохранение данных в связанные таблицы, обновление счетчика у родительской таблицы, touch родительской таблицы, сохранение данных в callback'ах, да и много чего еще.

Плюс, на больших данных значительно вырастает время самих запросов. Таким образом, во время проверки на уникальность запись уже может физически лежать в базе, но она будет невидима до окончания транзакции. Ниже диаграмма, которое это наглядно отображает.

Коллизия при валидации записи на уникальноссть

Что же делать?

Для начала нужно добавить индекс на уровне базы.


add_index :posts, :name, unique: true

Дальше переопределяем метод first_or_create в нужной модели, саму валидацию я бы тоже оставил. Мы перехватим исключение и попробуем еще раз. Для того чтобы получить ошибку валидации перепишем метод save


class Post < ActiveRecord::Base
  validates :name, uniqueness: true

  def self.first_or_create(attributes = nil, options = {}, retry_counter = 0, &block)
    super(attributes, options, &block)
  rescue PG::UniqueViolation, ActiveRecord::RecordNotUnique => error
    if retry_counter <= 10
      first_or_create(attributes, options, retry_counter + 1, &block)
    else
      raise "Can't retry on find_or_create"
    end
  end

  def save
    super
  rescue PG::UniqueViolation, ActiveRecord::RecordNotUnique => error
    self.errors[:name] << 'not unique'
    return false
  end
end

Теперь наша валидация работает железно и на этом пожалуй все.

 

 

 

 

Stop coding meme

Надеюсь, что прочитав предыдущее решение, Вы подумали, что автор обкурился.

Как сделать правильно?

Я за пол часа так и не придумал вескую причину переопределить метод rails. НИКОГДА, СОВСЕМ НИКОГДА, не даже если НЕ, У МЕНЯ ТУТ 100% МОЙ СЛУЧАЙ. не переопределяете основополагающие методы Rails или Ruby. Когда программист видит в коде все то, к чему он привык(save, redirect_to, map и т.д.), он должен быть на 100% уверен, что это работает ровно так, как описано в ядре фреймворка и никак иначе. Правильным решением будет вынести эту логику в модуль для повторного использования, и создать новые методы, название которых четко говорит о том, что происходит проверка уникальности. Мой вариант


module GuaranteedUniqueness
  extend ActiveSupport::Concern

  class_methods do
    def find_or_create_with_retry(search_attributes, create_attributes = {}, retry_counter=0)
      begin
        where(search_attributes).first || create!(search_attributes.merge(create_attributes))
      rescue PG::UniqueViolation, ActiveRecord::RecordNotUnique => error
        if retry_counter <= 10
          find_or_create_with_retry(search_attributes, create_attributes, retry_counter + 1)
        else
          raise "Can't retry on find_or_create"
        end
      end
    end
  end
end
Выводы
  1. Валидация в Rails не дает 100% гарантии
  2. Мы должны делегировать проверку уникальности базе данных
  3. За переопределение базовых методов нужно бить по рукам
Комментарии