Старый код Битрикс до сих пор полон CIBlockElement::GetList, сырого $DB->Query и ручной сборки SQL. Это работает, пока таблица маленькая и никто не трогает схему. В D7 нормальный контракт — ORM: сущность (Entity), таблица (DataManager), выборка через getList / getByPrimary и запись через add / update / delete с проверкой Result.
Ниже — практический каркас: чем ORM отличается от CIBlock/CDatabase, скелет DataManager, фильтры и runtime, связи Reference и типичные ошибки (забытый модуль, кривые ключи filter, N+1, даты без Type\DateTime, кеш без тегов). Рядом по серии: Result / Error, DateTime, Cache / TaggedCache, Application / Context.
ORM в D7 vs CIBlock и CDatabase
CIBlock — API инфоблоков: удобно для контента, но фильтры «магические», свойства тянутся отдельными запросами, типизация слабая. $DB->Query даёт полный контроль и полный риск SQL-инъекций, рассинхрона схемы и копипасты JOIN-ов по проекту.
ORM D7 описывает свою таблицу (или таблицу модуля) как PHP-класс: имя таблицы, primary, карта полей, связи. Ядро само собирает SQL, экранирует значения, отдаёт строки как массивы (или объекты при необходимости) и единый Result на запись. Для кастомных сущностей модуля это основной путь; для инфоблоков часто остаётся HIBlock / ElementTable ядра — но идеи filter/select/runtime те же.
Скелет Entity и DataManager
Минимальный класс таблицы наследует Bitrix\Main\ORM\Data\DataManager. Имя класса принято с суффиксом Table:
namespace Vendor\Modulename\Internals;
use Bitrix\Main\ORM\Data\DataManager;
use Bitrix\Main\ORM\Fields\IntegerField;
use Bitrix\Main\ORM\Fields\StringField;
use Bitrix\Main\ORM\Fields\DatetimeField;
use Bitrix\Main\ORM\Fields\Relations\Reference;
use Bitrix\Main\ORM\Query\Join;
use Bitrix\Main\Type\DateTime;
class OrderTable extends DataManager
{
public static function getTableName(): string
{
return 'b_vendor_order';
}
public static function getMap(): array
{
return [
(new IntegerField('ID'))
->configurePrimary(true)
->configureAutocomplete(true),
(new StringField('CODE'))
->configureRequired(true)
->configureSize(64),
(new IntegerField('USER_ID'))
->configureRequired(true),
(new StringField('STATUS'))
->configureDefaultValue('N'),
(new DatetimeField('DATE_INSERT'))
->configureDefaultValue(static fn() => new DateTime()),
// связь на пользователя (см. ниже)
(new Reference(
'USER',
\Bitrix\Main\UserTable::class,
Join::on('this.USER_ID', 'ref.ID')
))->configureJoinType('inner'),
];
}
}
Перед любым вызовом загрузите модуль: Loader::includeModule('vendor.modulename'). Без этого автолоад класса Table часто «молча» падает или отдаёт class not found в самый неудобный момент.
getList, getByPrimary, add / update / delete
Выборка списка — всегда через параметры запроса, не через склейку SQL-строк:
use Bitrix\Main\Loader;
use Vendor\Modulename\Internals\OrderTable;
Loader::includeModule('vendor.modulename');
$res = OrderTable::getList([
'select' => ['ID', 'CODE', 'STATUS', 'DATE_INSERT', 'USER_LOGIN' => 'USER.LOGIN'],
'filter' => [
'=STATUS' => 'N',
'>=DATE_INSERT' => $from, // объект DateTime, не строка «на глаз»
],
'order' => ['ID' => 'DESC'],
'limit' => 50,
'offset' => 0,
]);
while ($row = $res->fetch()) {
// $row['USER_LOGIN'] уже из JOIN
}
Одна запись по первичному ключу:
$row = OrderTable::getByPrimary(42, [
'select' => ['ID', 'CODE', 'STATUS'],
])->fetch();
// или короче, если нужен весь набор полей по умолчанию:
$row = OrderTable::getById(42)->fetch();
Запись — только с проверкой Result (см. отдельную статью про Result и Error):
use Bitrix\Main\Type\DateTime;
$add = OrderTable::add([
'CODE' => 'ORD-1001',
'USER_ID' => 15,
'STATUS' => 'N',
'DATE_INSERT' => new DateTime(),
]);
if (!$add->isSuccess()) {
// не глотайте ошибки
$errors = $add->getErrorMessages();
// лог / Result наверх
return;
}
$id = (int)$add->getId();
$upd = OrderTable::update($id, ['STATUS' => 'P']);
if (!$upd->isSuccess()) {
// ...
}
$del = OrderTable::delete($id);
if (!$del->isSuccess()) {
// ...
}
Мутировать «вслепую» (add без isSuccess) — частая причина «тихих» дыр в данных: уникальный индекс не прошёл, обязательное поле пустое, а код уже пошёл дальше как будто всё ок.
filter, select, order, limit, runtime
Ключи фильтра в D7 — с оператором в префиксе: =FIELD, !=FIELD, %FIELD (LIKE), >FIELD, >=FIELD, <FIELD, @FIELD (IN). Голый 'STATUS' => 'N' без = в новых ветках ведёт себя иначе, чем вы ожидаете — привыкайте писать =STATUS.
$res = OrderTable::getList([
'select' => ['ID', 'CODE'],
'filter' => [
'=STATUS' => ['N', 'P'], // IN
'%=CODE' => 'ORD-', // LIKE %ORD-%
'!@ID' => [1, 2, 3], // NOT IN
[
'LOGIC' => 'OR',
['=USER_ID' => 15],
['=USER_ID' => 22],
],
],
'order' => ['DATE_INSERT' => 'DESC', 'ID' => 'DESC'],
'limit' => 20,
'count_total' => true,
]);
$total = $res->getCount(); // при count_total
$rows = $res->fetchAll();
runtime — когда поля нет в getMap, но оно нужно на один запрос: выражение, подзапрос, временная Reference. Краткий пример с выражением:
use Bitrix\Main\ORM\Fields\ExpressionField;
$res = OrderTable::getList([
'select' => ['STATUS', 'CNT'],
'runtime' => [
new ExpressionField('CNT', 'COUNT(%s)', ['ID']),
],
'group' => ['STATUS'],
]);
Связи Reference на полезном уровне
Reference в getMap описывает JOIN один раз. В select обращайтесь через точку: USER.LOGIN, USER.EMAIL. Альтернатива — runtime-Reference, если связь разовая и не хотите тащить её в карту сущности.
Главное правило против N+1: не делайте getList внутри цикла по уже выбранным строкам. Либо JOIN через Reference, либо один @ID => $ids вторым запросом и сборка map в PHP. Подробнее про N+1 и индексы — в заметке про оптимизацию SQL в D7; кеширование выборок — в Cache и TaggedCache.
Типичные ошибки
- Кривые ключи filter. Путаница
STATUS/=STATUS, опечатка в имени поля, фильтр по runtime-алиасу как по колонке таблицы. Смотрите SQL через отладку ядра, не угадывайте. - Забыли Loader::includeModule. Симптом — class not found или пустой автолоад только на cron/агенте, где модуль «сам» не подключён.
- N+1. Список заказов + в цикле
UserTable::getById. Лечится JOIN/Reference или пакетным@ID. - Мутация без checkResult. Игнорирование
isSuccess()после add/update/delete. Всегда поднимайте ошибки наверх через свой Result. - Даты строками. В ORM-поля datetime отдавайте
Bitrix\Main\Type\DateTime, в filter — тоже объекты. Разбор таймзон — в статье про DateTime. - «Голый» кеш ORM-результата.
fetchAll()в файловый кеш без тегов инвалидации — классика устаревших списков. Используйте TaggedCache и тег видаvendor_order, сбрасывайте на add/update/delete. - select => [‘*’] по привычке. Тянете TEXT/BLOB и лишние JOIN-ы. Явно перечисляйте поля.
Связка с остальным D7
ORM не живёт отдельно: HTTP-вход — через Request, настройки модуля — через Config\Option, сервисы репозитория — через ServiceLocator, диагностика медленных мест — через Diag\Logger. DataManager — слой хранения; бизнес-правила лучше не размазывать по статическим методам Table без необходимости.
Краткий чеклист
- Описали getTableName + getMap, primary и типы полей.
- Перед вызовом —
Loader::includeModule. - В filter — операторы (
=,@,%), в датах — Type\DateTime. - После add/update/delete —
isSuccess()и сообщения ошибок. - Связи — Reference / один пакетный запрос, не N+1.
- Тяжёлые списки — узкий select, limit, кеш с тегами.
ORM D7 — не «обёртка ради моды», а способ держать схему и запросы рядом с PHP-контрактом модуля. Один аккуратный DataManager экономит месяцы сырого SQL и сюрпризов CIBlock на проде. Если только входите в серию, логичная следующая остановка после хранения — Result / Error и кеш выборок через TaggedCache.
