Каждый рубист, поработавший с Ruby On Rails знаком с ORM ActiveRecord. Обсудим одну из предложенных из коробки валидаций, а именно, валидации на уникальность, и почему database_validations gem спасет консистенцию вашей базы данных.

Допустим, у вас есть модель пользователей с уникальностью на поле email, т.е.

class User < ApplicationRecord
  validates :email, uniqueness: true
end

Вы, возможно, уже знаете, что данная валидация выполняет следующий запрос

SELECT 1 FROM users WHERE email = $1

каждый раз, когда мы пытаемся сохранить запись в базу данных.

У данного подхода, есть несколько недостатков:

Во-первых, исполнение дополнительного запроса, и в случае, если в модели инициализировано несколько валидаций на уникальность, запрос будет выполнен на каждую из них. Это не эффективно, а также требует наличие индексов, если мы хотим, чтобы данные запросы исполнялись быстро.

Во-вторых, данное решение не гарантирует уникальность из-за возможной гонки за данными. Несколько конкурентных операций могут одновременно узнать об отсутствии конкретной записи, в следствии чего, сохранить одни и те же данные.

Конечно, редкие случаи с гонкой данных возможно разрешить добавив ограничение на уникальность на уровне базы данных. Но в данном случае, вы не получите ошибку валидации, запрос к БД просто упадет и вся транзакция откатится.

В этой ситуации поможет gem database_validations, который предоставляет совместимость между ограничениями в базе данных и валидациями.

Основной смысл работы gem представлен в следующем коде:

def save(options = {})
  ActiveRecord::Base.connection.transaction(requires_new: true) { super }
rescue ActiveRecord::RecordNotUnique => e
  Helpers.handle_unique_error!(self, e)
  false
end


Таким образом, мы пробуем сохранить данные, если все другие валидации пройдены, если транзакция падает и откатывается, мы парсим ошибку и присваиваем правильные значения в errors нашего объекта.

Ознакомившись с документацией и бенчмарками, можно прийти к выводу, что данный gem ускорит процесс сохранения записей в базу данных минимум в два раза.

Благодаря поддержке таких баз данных, как PostgreSQL, SQLite, MySQL и обратной совместимости с validates_uniqueness_of, процесс замены на validates_db_uniqueness_of занимает считанные минуты.

Удобный matcher для RSpec также присутствует из коробки:

specify do
  expect(described_class)
    .to validate_db_uniqueness_of(:field)
    .with_message('duplicate')
    .with_where('(some_field IS NULL)')
    .scoped_to(:another_field)
    .with_index(:unique_index)
end

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

Гем протестирован на приложении с 100+ валидациями на уникальность среди 50+ моделей.

Используйте гем и делитесь мнением. Любой вклад в дальнейшее развитие приветствуется!

Комментарии (5)


  1. AEP
    28.11.2018 00:08

    Ничего не написано про механизм работы и про требования к уровню изоляции транзакций в БД.


  1. alexesDev
    28.11.2018 00:49

    Для PostgreSQL есть citext и unique index, это 0 стороннего кода и все из коробки. Как и генерация хешей паролей с crypt и куча всего ещё, что на порядок более консистентнее, чем такой же код на ruby, который пытается угодить всем базам. Почему не доверить консистентность ПО, которое на этом специализируется?


    1. philpirj
      28.11.2018 01:00

      Данный gem как раз использует родной unique index, и перехватывает ошибку от БД, трансформируя её в ошибку валидации, которую можно показать пользователю.


      Как пишет автор, в противном случае бы пользователь увидел страницу HTTP 500.


      1. alexesDev
        28.11.2018 01:07

        Не совсем 500, смотря как написано, достаточно перехватывать ActiveRecord::RecordNotUnique.


        1. philpirj
          28.11.2018 01:56

          Можно перехватить, но информацию о том, какое поле вызвало такое исключение, придётся парсить из примерно вот такой строки:


          ERROR: duplicate key value violates unique constraint "index_users_on_email" DETAIL: Key (email)=(hello@example.com) already exists.

          database_validations делает это за вас, причём прозрачно, так, что разницы между validates_db_uniqueness_of и validates_uniqueness_of вы и не заметите.