Отчёт "топ-10 пользователей по сумме заказов за месяц" превратился в 15 000 строк getList и 501 SQL-запрос в foreach - сайт лёг по таймауту. После обновления ядра попытка вставить SUM(AMOUNT) в select дала ArgumentException: Expression as an array in select section is no more supported. Решение - Table::query() с ExpressionField: агрегация COUNT/SUM уходит в один SQL, фильтр по итогу - в HAVING автоматически. Ниже пошаговый гайд для 1С-Битрикс: Управление сайтом без выгрузки всего набора в PHP.
Table::query() нужен, когда фильтры и поля собираются динамически в сервисе модуля, а не статическим массивом getList. ExpressionField и registerRuntimeField описывают SUM/COUNT/CONCAT объектом, не строкой SQL. Фильтр по агрегату (>CNT) ORM переносит в HAVING, неагрегированные поля из select добавляет в GROUP BY. Отладка: getQuery() покажет SQL без выполнения запроса. Результат: один запрос вместо N+1 в цикле.
Речь про коробочную CMS, не про CRM-портал Bitrix24. ORM (Object-Relational Mapping) - слой D7, который связывает PHP-класс *Table с таблицей MySQL. Свою таблицу вы уже создали в гайде по DataManager и getMap (B71). Простую выборку инфоблока разобрали в сравнении GetList и ElementTable (B60). Здесь - сложные отчёты: агрегация, группировка и отладка SQL.
Выберите, когда нужен Table::query(), а когда хватит getList
getList с массивом filter/select/order - нормальный выбор для фиксированной выборки из компонента. На практике Query выигрывает, когда условия собираются по шагам в сервисе модуля: сегодня фильтр по дате, завтра - по статусу и сумме, плюс runtime-поле COUNT без правки getMap.
| Сценарий | getList-массив | Table::query() |
|---|---|---|
| Фиксированный filter в компоненте | Да - короче и привычнее | Избыточно |
| Динамические addFilter по GET-параметрам | Каша вложенных if | Да - читаемый сервис |
| SUM/COUNT по группе USER_ID | Только через runtime + group | Да - registerRuntimeField + setGroup |
| JOIN с именем из другой таблицы | runtime Reference | Да, плюс гайд по Reference и JOIN (B79) |
| CRUD Highload-справочника | getList HL DataClass | Редко - см. CRUD Highload через D7 (B78) |
Делайте: переносите сборку условий в lib/Service/, а не в шаблон компонента. Не делайте: не тяните 10 000 строк в PHP, чтобы посчитать SUM в foreach - БД сделает это быстрее одним запросом.
Соберите Table::query() с динамическими фильтрами пошагово
OrderLogTable::query() - флюент-обёртка над Entity\Query. Методы addSelect, addFilter (или where), setOrder, setLimit и setOffset цепляются по условию.
- Создайте $query = OrderLogTable::query();
- Добавьте скалярные поля: $query->addSelect('USER_ID')->addSelect('STATUS');
- Задайте базовый фильтр по дате: $query->where('DATE_CREATE', '>=', $dateFrom);
- Условно добавьте фильтр по статусу: if ($status) { $query->where('STATUS', $status); }
- Установите сортировку и пагинацию: setOrder(['TOTAL' => 'DESC'])->setLimit(10)->setOffset($page * 10);
- Выполните $result = $query->exec(); и обходите fetch() без fetchAll на весь набор.
$query = OrderLogTable::query()
->addSelect('USER_ID')
->where('DATE_CREATE', '>=', $dateFrom);
if ($status !== '') {
$query->where('STATUS', $status);
}
$result = $query
->setOrder(['USER_ID' => 'ASC'])
->setLimit(50)
->setOffset($offset)
->exec();
Workflow отчёта: сервис собирает query → registerRuntimeField → setGroup → фильтр по агрегату → getQuery() на staging → exec() на prod.
Делайте: setLimit/setOffset для пагинации. Не делайте: fetchAll() на десятки тысяч строк.
Настройте ExpressionField и registerRuntimeField для SUM и COUNT
ExpressionField - виртуальное поле: имя для PHP, SQL-выражение для БД. Плейсхолдер %s подставляет имя колонки из buildFrom. С версии main 12.0.0 поле регистрируют на Query через registerRuntimeField - эквивалент runtime в getList.
use Bitrix\Main\ORM\Fields\ExpressionField;
$query = OrderLogTable::query()
->registerRuntimeField(
'TOTAL',
new ExpressionField('TOTAL', 'SUM(%s)', 'AMOUNT')
)
->registerRuntimeField(
'CNT',
new ExpressionField('CNT', 'COUNT(*)')
)
->addSelect('USER_ID')
->addSelect('TOTAL')
->addSelect('CNT');
Агрегаты COUNT, SUM, AVG, MAX, MIN и CONCAT работают в одном запросе. Для дат между MySQL и PostgreSQL берите SqlHelper вместо NOW() напрямую.
Типичная ошибка после PHP 8 / ORM 2.0: массив ['SUM(%s)', 'AMOUNT'] или строка 'SUM(AMOUNT)' в select дают ArgumentException: Expression as an array in select section is no more supported. Замена - только объект ExpressionField или registerRuntimeField.
Делайте: давайте runtime-полям короткие alias в верхнем регистре (TOTAL, CNT). Не делайте: не копируйте с форумов сниппеты со строкой SQL в select - они сломаются на свежем ядре.
Настройте GROUP BY и HAVING без ручного SQL
Когда в select есть агрегат, ORM группирует по неагрегированным полям. Явная группировка: setGroup(['USER_ID']). Фильтр по скаляру до группировки уходит в WHERE, по агрегату - в HAVING.
$query = OrderLogTable::query()
->registerRuntimeField('TOTAL', new ExpressionField('TOTAL', 'SUM(%s)', 'AMOUNT'))
->registerRuntimeField('CNT', new ExpressionField('CNT', 'COUNT(*)'))
->addSelect('USER_ID')
->addSelect('TOTAL')
->addSelect('CNT')
->setGroup(['USER_ID'])
->where('CNT', '>', 5)
->where('TOTAL', '>', 10000)
->setOrder(['TOTAL' => 'DESC'])
->setLimit(10);
Ловушка: лишний ID в select вместе с SUM добавит ID в GROUP BY и "размажет" агрегат. В отчёте только ключ группировки плюс агрегаты. Query::expr()->length() и expr()->count() заменяют ручной ExpressionField для типовых функций.
Делайте: фильтруйте агрегат через alias runtime-поля (>CNT). Не делайте: не пишите HAVING вручную в Connection::query - ORM уже маршрутизирует условие.
Соберите отчёт одним запросом вместо N+1 в foreach
Антипаттерн: getList на каждый orderId в цикле. Верно: один query с setGroup('USER_ID'), SUM(AMOUNT) и setLimit(10). Поля из связанной таблицы - runtime Reference в том же query (B79), не отдельный запрос в foreach.
Делайте: перед релизом посчитайте число SQL в профайлере или SqlTracker. Не делайте: не оправдывайте N+1 тем, что "всего 200 заказов" - на проде их станет 20 000.
Проверьте SQL через getQuery() до выполнения на prod
getQuery() вернёт SQL без exec(). SqlTracker на Connection: startTracker(), getList() или exec(), getQueries() - покажет фактический запрос. Сверьте GROUP BY и HAVING на staging с тем же объёмом данных, что на prod.
Делайте: getQuery() перед exec(). Не делайте: не правьте агрегат вслепую на боевом.
Пройдите чек-лист перед выкладкой отчёта на prod
- Проверьте агрегацию в SQL, не в foreach.
- Убедитесь, что ExpressionField - объект, не строка в select.
- Уберите лишние поля из select, кроме ключей группировки.
- Включите setLimit/setOffset вместо fetchAll.
- Сверьте getQuery() на staging: GROUP BY, HAVING, индексы на полях фильтра.
Нужна помощь с архитектурой модуля и отчётами - обсудим задачу, примеры внедрений - в портфолио.
Автор: Максим Мольков, Senior-разработчик 1С-Битрикс.
Источники: выборка данных ORM Bitrix Framework, построитель запросов Query, ExpressionField D7 API, урок по GROUP BY в ORM.
Частые вопросы
Чем Table::query() отличается от getList с массивом?
getList принимает готовый массив filter/select/order - удобно для статики. Table::query() позволяет по шагам addSelect, where, registerRuntimeField и setGroup в сервисе. Для отчётов с агрегацией query читается проще, чем вложенные if вокруг массива getList.
Как посчитать SUM или COUNT через ExpressionField?
На Query вызовите registerRuntimeField с new ExpressionField('TOTAL', 'SUM(%s)', 'AMOUNT') и addSelect('TOTAL'). Для COUNT - ExpressionField('CNT', 'COUNT(*)'). Фильтр where('CNT', '>', 5) ORM перенесёт в HAVING. Не вставляйте строку SUM(AMOUNT) в select - только объект поля.
Почему select со строкой SQL больше не работает?
С ORM 2.0 и PHP 8 массив или строка в select блокируются: ArgumentException про security reason. Замените на ExpressionField или registerRuntimeField. Это защита от инъекций через "выражения" в select.
Как отладить SQL запроса ORM без выполнения?
После сборки query вызовите getQuery() - вернётся строка SQL. Для уже выполненного getList включите SqlTracker на Connection, выполните запрос, прочитайте getQueries() и getSql(). Сверьте GROUP BY и HAVING до выкладки на prod.
Нужен ли явный GROUP BY для агрегации в ORM?
При фильтре по агрегату ORM часто добавляет GROUP BY сам. Для отчёта лучше явно setGroup(['USER_ID']) - так предсказуемее. Помните: каждое неагрегированное поле в select попадёт в GROUP BY, лишний ID разобьёт сумму по строкам.
Работает ли ExpressionField с Highload-блоком?
Да - HL DataClass после compileEntity тоже *Table. registerRuntimeField и setGroup применимы к getList и query HL-сущности. Базовый CRUD HL - в отдельном гайде B78; здесь фокус на агрегации и Query для любой ORM-таблицы.