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
Теперь наша валидация работает железно и на этом пожалуй все.
Надеюсь, что прочитав предыдущее решение, Вы подумали, что автор обкурился.
Как сделать правильно?
Я за пол часа так и не придумал вескую причину переопределить метод 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
Выводы
- Валидация в Rails не дает 100% гарантии
- Мы должны делегировать проверку уникальности базе данных
- За переопределение базовых методов нужно бить по рукам
Комментарии