В 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
Проблемы кажутся очевидными, но магическое мышление в отношении готовых решений играет злую шутку с программистами любого уровня.
Благодарю, что дочитали до конца!