В Laravel я бы сразу полез в query builder, а тут увлёкся Ruby-методами. Rails 7.0, MySQL 8. За год около 120 тысяч оплаченных заказов, страница отчёта открывается 9 секунд, в логе сотни запросов к order_items.
def revenue_by_month(year)
orders = Order.where(status: "paid", created_at: Date.new(year).all_year)
result = Hash.new(0)
orders.each do |order|
month = order.created_at.strftime("%Y-%m")
result[month] += order.items.sum { |i| i.price * i.quantity }
end
result
end
Понимаю, что это N+1 и что считать надо в базе. Как это нормально записать в Active Record, не уходя целиком в сырой SQL?
Считать надо от позиций, а не от заказов, тогда всё сводится к одному запросу с group и sum:
OrderItem
.joins(:order)
.where(orders: { status: "paid", created_at: Time.zone.local(year).all_year })
.group(Arel.sql("DATE_FORMAT(orders.created_at, '%Y-%m')"))
.sum(Arel.sql("order_items.price * order_items.quantity"))
# => {"2023-01"=>..., "2023-02"=>..., ...}, значения BigDecimal
Заметь, я поменял Date.new(year).all_year на Time.zone.local. Диапазон дат на datetime-колонке превращается в BETWEEN до '2023-12-31', то есть до полуночи, и почти все продажи 31 декабря теряются. Потом покажи EXPLAIN, на таком объёме всё решает индекс по (status, created_at) на orders. И проверь часовой пояс в группировке: created_at хранится в UTC, так что оплата первого числа до трёх ночи по нашему времени уедет в предыдущий месяц.
В MySQL 8 по умолчанию включён ONLY_FULL_GROUP_BY, так что лишних колонок в select к этому запросу не добавляй, сразу будет ошибка
Сделал через joins и group, добавил индекс (status, created_at) на orders. Было 9,2 секунды, стало 0,6, EXPLAIN показывает range по новому индексу. С 31 декабря kostya.sokurov попал в точку, я терял почти целый день продаж. Таблицы поясов в MySQL у нас не загружены, так что группирую по DATE_FORMAT(CONVERT_TZ(orders.created_at, '+00:00', '+03:00'), '%Y-%m'). С выгрузкой бухгалтерии за сентябрь сошлось до рубля)