Bitrix D7 ORM: DataManager, Entity, getList и типичные ошибки

Старый код Битрикс до сих пор полон 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.