На практике вы уже умеете getList по одной таблице, но при добавлении в select "USER.NAME" ORM отвечает Unknown field, JOIN пустой, а в профайлере сотни запросов в цикле — типичная проблема, когда связь между таблицами не описана. Например, забыли runtime или скопировали INNER JOIN с форума, и половина заказов исчезает из выгрузки. Связь таблиц в D7 ORM - это Reference в getMap или runtime ReferenceField с Join::on('this.FIELD', 'ref.ID'). Ниже - как за один getList подтянуть автора, раздел или Highload, собрать цепочку из трех таблиц и проверить SQL без сырого JOIN вручную.
Reference - это описание связи между двумя ORM-таблицами: "поле this.CREATED_BY равно ref.ID". Постоянную связь кладут в getMap класса *Table, разовую - в runtime getList. По умолчанию LEFT JOIN: строки без связи не пропадают. Для select с точкой нужен псевдоним runtime. Цепочка из трех таблиц строится через this.alias.FIELD. Перед продом проверьте getQuery() и счетчик запросов.
Материал про коробочную CMS "1С-Битрикс: Управление сайтом", не про облачный Bitrix24. ORM (Object-Relational Mapping) - слой D7, который переводит PHP-вызовы getList в SQL без ручного написания JOIN. Reference - не магия: это декларация "какое поле нашей таблицы ссылается на какое поле чужой". Если своя *Table еще не создана, начните с гайда по DataManager и getMap; каркас модуля - в инструкции по созданию модуля. Дальше предполагаем, что OrderTable или аналог уже лежит в lib/ и getList по одной таблице работает.
Выберите, когда JOIN в ORM, а когда цикл или CIBlockElement
Reference не заменяет все выборки. Это инструмент, когда одна строка вашей таблицы ссылается на другую сущность по внешнему ключу (FK).
| Сценарий | Цикл getList | CIBlockElement (B60) | ORM + Reference |
|---|---|---|---|
| 10 заказов + имя автора | 11 SQL (N+1) | Тяжелый GetList | 1 JOIN - оптимально |
| Каталог 5000 SKU + 5 свойств | Тысячи запросов | Привычный API | Риск декартова произведения |
| Лог модуля + UserTable | Медленно на проде | Избыточно | Да - связь в getMap |
| HL-справочник + свой HL | N+1 | Нет | Runtime Reference к compileEntity |
Сравнение старого GetList и ElementTable - в статье про CIBlockElement vs D7 ORM. CRUD Highload без углубления в JOIN - в гайде по HL и compileEntity.
Делайте: один getList с JOIN, когда связей мало и нужны поля соседней таблицы. Не делайте: не тяните пять множественных свойств инфоблока в один запрос - получите 15x7x11 строк вместо 33.
Настройте постоянный Reference в getMap модуля
Если связь OrderTable - UserTable повторяется в каждом отчете, опишите ее один раз в getMap. Ядро само добавит LEFT JOIN при select с точкой.
use Bitrix\Main\ORM\Fields\Relations\Reference;
use Bitrix\Main\ORM\Query\Join;
use Bitrix\Main\UserTable;
// в getMap() класса OrderTable:
new Reference(
'USER',
UserTable::class,
Join::on('this.CREATED_BY', 'ref.ID')
),
this - текущая таблица (OrderTable), ref - связанная (UserTable). INNER JOIN, когда нужны только строки с существующим автором:
Join::on('this.CREATED_BY', 'ref.ID')
->where('ref.ACTIVE', 'Y')
->configureJoinType('inner')
В проектах встречаются два namespace: новый Bitrix\Main\ORM\Fields\Relations\Reference и legacy ReferenceField из Bitrix\Main\Entity. Синтаксис Join::on одинаковый, отличия в имени класса и способе передачи join_type. После добавления Reference в getMap select USER.NAME работает без runtime в каждом вызове.
Делайте: храните FK в миграции Sprint до Reference. Не делайте: не ставьте INNER по умолчанию - пропадут заказы без автора.
Добавьте runtime ReferenceField для разового JOIN
Runtime живет только в рамках одного getList. Подходит для админ-отчета или связи с ElementTable, которую не хотите тащить в getMap навсегда.
- Задайте псевдоним в runtime, например USER.
- Укажите Join::on('this.CREATED_BY', 'ref.ID') - поле this из основной таблицы, ref из UserTable.
- Добавьте в select поля с точкой: 'AUTHOR_NAME' => 'USER.NAME', 'AUTHOR_LOGIN' => 'USER.LOGIN'.
- Проверьте filter: фильтр по USER.LOGIN требует того же псевдонима в runtime.
- Выведите SQL через $query = OrderTable::query(); ... echo $query->getQuery();
- Сравните с профайлером: один запрос вместо цикла по ID.
use Bitrix\Main\Entity\ReferenceField;
use Bitrix\Main\UserTable;
OrderTable::getList([
'select' => [
'ID', 'SUM',
'AUTHOR_NAME' => 'USER.NAME',
],
'runtime' => [
new ReferenceField(
'USER',
UserTable::class,
['=this.CREATED_BY' => 'ref.ID'],
['join_type' => 'LEFT']
),
],
]);
Ошибка Unknown field USER.NAME почти всегда значит: забыли runtime или опечатка в псевдониме. Пустой AUTHOR_NAME при корректном SQL - проверьте CREATED_BY: ноль или битый FK. ExpressionField для COUNT или CONCAT тоже кладут в runtime рядом с Reference - ядро соберет агрегат в том же запросе.
Workflow одного отчета:
OrderTable::getList → runtime USER (ReferenceField) → select USER.NAME → getQuery() сверяем LEFT JOIN → профайлер показывает 1 SQL → выводим в админке без цикла по CREATED_BY
Делайте: копируйте рабочий runtime в сервис модуля. Не делайте: не ожидайте, что runtime из прошлого getList подхватится в следующем.
Соберите цепочку из трех и более таблиц
Второй JOIN ссылается не на корень, а на предыдущий runtime-алиас. Классический кейс: раздел - элемент - свойство.
'runtime' => [
new ReferenceField('SE', ElementTable::class,
['=this.IBLOCK_SECTION_ID' => 'ref.ID']),
new ReferenceField('PROP', PropertyTable::class,
['=ref.IBLOCK_ELEMENT_ID' => 'this.SE.ID']),
],
'select' => ['ID', 'PROP_VALUE' => 'PROP.VALUE'],
Ключевая строка: this.SE.IBLOCK_ELEMENT_ID - this указывает на алиас SE, а не на корневую таблицу. На форуме dev.1c-bitrix.ru это главный ответ на "можно ли в JOIN только this и ref".
ManyToMany через промежуточную таблицу описывают отдельной сущностью. Если в связующей таблице есть QUANTITY или дата - нужен OneToMany к посреднику, а не голый ManyToMany: к полям посредника ManyToMany не дает доступ.
Делайте: для HL используйте compileEntity и Reference на второй HL-блок. Не делайте: не копируйте алиасы вроде this.se с форума вслепую - назовите понятно: SECTION, AUTHOR, SKU.
Проверьте производительность: cache_joins и decompose
Выборки с JOIN по умолчанию не кешируются. Для редко меняющихся справочников:
'cache' => ['ttl' => 3600, 'cache_joins' => true]
При связи 1:N в одном запросе с limit ядро может размножить строки. Официальная документация рекомендует QueryHelper::decompose() - разбор результата на честные сущности без декартова произведения. На публичном каталоге с десятками свойств чаще выгоднее отдельные легкие запросы, чем один тяжелый JOIN.
Схема отладки: OrderTable::getList → профайлер (1 SQL?) → getQuery() (LEFT или INNER?) → индекс на CREATED_BY в MySQL. Типичная история с прода: разработчик скопировал INNER JOIN из примера инфоблока, и половина заказов с CREATED_BY = 0 исчезла из выгрузки. Смена на LEFT вернула строки; пустое имя автора обработали в шаблоне как "Система".
Делайте: явный select только нужных полей. Не делайте: не ставьте JOIN на главную витрину без замеров на staging.
Пройдите чек-лист перед выкладкой на прод
- Убедитесь, что FK есть в БД и проиндексирован.
- Выберите getMap для постоянных связей, runtime - для разовых.
- Сверьте итоговый SQL: ожидаемый LEFT/INNER и один round-trip.
- Откройте профайлер: нет N+1 при списке из 50+ строк.
- Вынесите сборку запроса в метод сервиса модуля в lib/.
- Сбросьте кеш компонента после записи в связанную таблицу.
Нужна помощь со связями в кастомном модуле на реальном проекте - обсудим задачу. Примеры внедрений - в портфолио.
Автор: Максим Мольков, разработчик 1С-Битрикс.
Источники: документация по отношениям ORM, выборка данных и runtime, практика внедрений на CMS.
Частые вопросы
Чем Reference в getMap отличается от ReferenceField в runtime?
getMap описывает связь навсегда: любой getList по OrderTable может взять USER.NAME без повторной настройки. Runtime добавляет JOIN только в текущий вызов и исчезает в следующем. Для отчетов раз в квартал - runtime; для поля автора в каждом списке - getMap.
Как связать две своих таблицы в модуле?
Добавьте IntegerField с FK в getMap дочерней таблицы, создайте индекс в миграции, затем Reference на родительскую *Table через Join::on('this.PARENT_ID', 'ref.ID'). Проверьте select с точкой и getQuery().
Почему select USER.NAME возвращает пустое значение?
Сначала убедитесь, что runtime с псевдонимом USER зарегистрирован в том же getList. Затем проверьте CREATED_BY: NULL или 0 даст пустое имя при корректном LEFT JOIN. При INNER строка может исчезнуть целиком.
Что означают this и ref в Join::on?
this - поля основной таблицы запроса (OrderTable). ref - поля связанной сущности (UserTable). Запись Join::on('this.CREATED_BY', 'ref.ID') читается как "CREATED_BY заказа равен ID пользователя".
Можно ли связать ORM с Highload-блоком?
Да. Получите DataManager через compileEntity HL-блока и передайте его класс в Reference или ReferenceField так же, как UserTable. Подробности CRUD HL - в отдельном гайде по Highload; здесь достаточно runtime JOIN по полю-ссылке.
Когда LEFT JOIN менять на INNER?
INNER оставляет только строки, где связь найдена. Используйте для отчетов "только с автором" через configureJoinType('inner'). Для списков заказов, где часть записей без пользователя, оставляйте LEFT по умолчанию.