В Ruby‑разработке ActiveRecord давно стал стандартом: он интуитивно понятен, встроен в Rails и позволяет быстро проводить CRUD‑операции. По мере роста проекта его «удобство» нередко начинает оборачиваться скрытыми проблемами.

Есть ощущение, что решение «из коробки» не только все сделает за вас, но и убережет от ошибок. Обманчивая простота ActiveRecord при работе с БД особенно сказывается на начинающих разработчиках. Не нужно писать мудреные SQL-запросы, т.к. есть интуитивно понятный интерфейс, а MVP надо сделать "еще вчера" и не хочется усложнять.

Мне очень знакома эта ситуация. Я начинала свое практическое знакомство с Ruby и Rails на стажировке. Нам с командой ребят надо было создать MVP приложения для работы со складскими остатками небольшой сети магазинов. Мы придумали схему БД, связи и всю начинку и достаточно быстро выкатили продукт. Это ли не чудо, что буквально за пару недель можно с нуля создать работающий продукт? Пусть сырой и немного кривой, но речь сейчас не об этом.

Естественно, проблемы начались и достаточно быстро.

Проблема 1: цикл "вжух и готово"

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

Схема cкладского продукта с основными полями.

class Product < ApplicationRecord
  name: string (название)
  description: text (описание)
  expiration_date: date (срок годности)
  received_by: integer (ID кладовщика, принявшего на склад)
  written_off_by: integer (ID кладовщика, списавшего со склада, nullable)
  write_off_date: datetime (дата списания, nullable)

  belongs_to :received_by, class_name: 'Clerk', foreign_key: :received_by
  belongs_to :written_off_by, class_name: 'Clerk', foreign_key: :written_off_by, optional: true
end

Стоит базовая задача: найти вс�� товары, которые были списаны конкретным сотрудником за период. И в этот момент, если не разобраться в запросах в БД ("под капотом" ActiveRecord), начинается та самая злая магия.

def get_written_off_products(clerk_id, start_date, end_date)
  products = Product.where(
    written_off_by: clerk_id,
    write_off_date: start_date..end_date
  )

  products.each do |product|
    puts "Продукт: #{product.name}"
    puts "Дата списания: #{product.write_off_date}"
    puts "Имя кладовщика: #{product.written_off_by.name}"
  end

  products
end

Для опытного разработчика проблема очевидна. Что же здесь не так?

Давайте разбираться.

С помощью Product.where мы находим все продукты с ID кладовщика за определенный период времени. Далее для вывода в отчет нам необходимы название продукта, имя кладовщика и дата списания.

В цикле products.each при каждой итерации происходит запрос в БД для получения данных о кладовщике (имя). Это не проблема, если записей в БД пару сотен, а если это склад уровня маркетплейса? Вот и сказочке конец - злая магия победила добро.

Эта проблема называется N+1: один запрос для списка продуктов и N дополнительных запросов для получения имени кладовщика.

Как можно исправить ситуацию? Мне ближе решение с использованием INNER_JOIN.

Скрытый текст

Данное решение отлично работает на новом проекте в другой компании.

def get_written_off_products(clerk_id, start_date, end_date)
  Product
    .joins(:written_off_by)
    .where(
      written_off_by: clerk_id,
      write_off_date: start_date..end_date
    )
    .select(
      'products.name',
      'products.expiration_date',
      'products.written_off_date',
      'clerks.name AS clerk_name'
    )
    .order('products.write_off_date DESC')
end

Одним запросом в БД мы получаем таблицу продуктов, "д��ойним" таблицу кладовщиков по ID и отсекаем даты по диапазону. С помощью select выбираем нужные поля для отображения и сортируем по дате списания.

Что еще в коде выглядит магическим?

На мой взгляд, .joins(:written_off_by) не указывает явно, что ассоциирована таблица кладовщиков. Безусловно, это можно посмотреть в модели Product, но ведь так теряется фокус и время.

Проблема 2: трудности перевода

Конструкции Ruby читаются, как обычные английские фразы — писать код на нём естественно. Например, выражения вроде users.each или if user.active? воспринимаются, как простые предложения на английском.

Но, как говорится, есть нюанс: некоторые слова, похожие по смыслу (например, any? и exists?), в Ruby on Rails — это методы с разной реализацией и назначением.

Допустим, нам нужен отчет по компаниям и информация, есть ли у них заказы.

companies = Company
  .includes(:orders)
  .where(active: true)
  .order(name: :asc)

companies.each do |company|
  status = company.orders.any? ? "есть заказы" : "нет заказов"
  puts "#{company.name}: #{status}"
end

Пример выглядит, как n + 1? Подождите делать выводы. Особенность метода any? в том, что он работает с уже выгруженной коллекцией (должна быть заранее) и не отправляет запросы в БД в таком случае.

Однако есть метод с похожим по смыслу названием exists?, но реализация у него совсем иная.

companies = Company
  .includes(:orders)
  .where(active: true)
  .order(name: :asc)

companies.each do |company|
  status = company.orders.exists? ? "есть заказы" : "нет заказов"
  puts "#{company.name}: #{status}"
end

Вот здесь и кроется ошибка. Метод exists? отправляет запрос в БД и уже не работает с загруженной коллекцией:

SELECT 1 FROM orders WHERE company_id = ? LIMIT 1;

Мы снова получаем ошибку n + 1, а могли бы ее избежать, используя метод any? и не боясь при этом цикла.

Выводы:

Есть такое выражение (автора не нашла):

Если не знать физику, то буквально всё будет казаться волшебством.

При кажущейся простоте готового решения необходимо понимать, как это работает под капотом. Что помогло мне разобраться?

  • более опытные коллеги

  • документация Ruby on Rails

  • исходный код Ruby on Rails (точная реализация всего)

  • stackoverflow

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

Благодарю, что дочитали до конца!