Перейти к содержимому

D7 ORM в 1С-Битрикс - работа с БД через сущности

D7 ORM - современный способ работать с базой в 1С-Битрикс: вы описываете таблицу классом-сущностью и делаете типобезопасные выборки, вместо сырого SQL. Разберём, как это устроено, и покажем рабочие примеры - от простой выборки до связей и транзакций. ORM - лишь одна из подсистем ядра Bitrix\Main; логирование, валидация, GeoIP и другие сервисы разобраны в статье «Подсистемы ядра».

Как это работает

Сущность - это таблица. Одна таблица описывается одним классом-наследником Bitrix\Main\ORM\Data\DataManager (старый алиас - Bitrix\Main\Entity\DataManager). Имя класса обязано заканчиваться на Table: BookTable, ProjectTable - базовое имя без суффикса зарезервировано под класс объекта. Класс определяет минимум два метода: getTableName() (имя таблицы) и getMap() (список полей). Файл кладут в папку lib модуля - автозагрузчик подключит его сам при первом обращении.

Поля - это объекты, а не массивы. IntegerField, StringField, TextField, BooleanField, DateField, DatetimeField, FloatField, EnumField и другие. Настройки задаются fluent-методами: configurePrimary(), configureRequired(), configureSize(), configureDefaultValueNow(). Отдельно стоит ExpressionField - вычисляемое поле на SQL-выражении, доступное только для чтения.

Чтение начинается с query(). DataManager::query() возвращает объект Bitrix\Main\ORM\Query\Query с методами setSelect(), where(), setOrder(), setLimit(), registerRuntimeField(), exec(). Привычные getList(), getRow(), getRowById(), getByPrimary() - совместимые обёртки над тем же query(). Результат выборки - объект Result, из которого данные забирают четырьмя способами: fetch() и fetchAll() дают массивы, fetchObject() и fetchCollection() - объекты. Сама выборка тоже умеет кешироваться параметром cache - устройство всех уровней кеша разобрано в статье про кеширование выборок.

Объектная модель. fetchObject() возвращает EntityObject с именованными геттерами и сеттерами (getTitle(), setTitle()) плюс универсальными get(), set(), require(), fill(), save(), delete(). Объект живёт в одном из состояний: RAW → ACTUAL → CHANGED → DELETED. Важно: get() - это не lazy-loading, незагруженное поле само не подтянется. Сами геттеры и сеттеры существуют только во время выполнения через __call(), поэтому по умолчанию их не видит и IDE - как вернуть автодополнение аннотациями, показано в статье «Автодополнение и подсказки IDE».

Отношения описываются полями. Reference - связь N:1 и 1:1 с условием Join::on(), OneToMany - обратная сторона 1:N, ManyToMany - связь N:M через промежуточную таблицу. Тип JOIN по умолчанию - LEFT.

Под ORM лежит слой БД. Когда ORM не хватает, есть прямой доступ: Application::getConnection() даёт объект Connection с методами query(), queryScalar(), queryExecute() и управлением транзакциями. Переносимый SQL собирают через SqlHelper и SqlExpression.

Примеры

1. Описываем сущность

use Bitrix\Main\ORM\Data\DataManager;
use Bitrix\Main\ORM\Fields\DatetimeField;
use Bitrix\Main\ORM\Fields\IntegerField;
use Bitrix\Main\ORM\Fields\StringField;
final class ProjectTable extends DataManager
{
public static function getTableName(): string
{
return 'b_example_project';
}
public static function getMap(): array
{
return [
(new IntegerField('ID'))
->configurePrimary()
->configureAutocomplete(),
(new StringField('TITLE'))
->configureRequired()
->configureSize(255)
->configureTitle('Название проекта'),
(new DatetimeField('CREATED_AT'))
->configureDefaultValueNow(),
];
}
}

Весь контракт хранения виден в одном месте: первичный ключ с автоинкрементом, обязательное поле с ограничением длины, дата со значением «сейчас» по умолчанию. Если getTableName() не задать, имя таблицы соберётся автоматически из неймспейса и получится что-то вроде b_somepartner_mybookscatalog_book - лучше указывать явно.

Два ограничения, о которые спотыкаются: StringField не хранит больше 255 символов (для длинного текста нужен TextField), а пользовательские поля (UF) в getMap() не описывают - достаточно вернуть их идентификатор из getUfId().

2. Первая выборка: массивы или объекты

// Способ 1: плоские массивы - для списков, экспорта, агрегатов
$rows = ProjectTable::query()
->setSelect(['ID', 'TITLE', 'CREATED_AT'])
->where('ACTIVE', true)
->setOrder(['ID' => 'DESC'])
->setLimit(2)
->fetchAll();

Что окажется в $rows:

array(2) {
[0] => array(3) {
'ID' => int(17)
'TITLE' => string(12) "Редизайн"
'CREATED_AT' => object(Bitrix\Main\Type\DateTime)
}
[1] => array(3) { ... }
}

Обратите внимание на две вещи, которых не было в старом ядре: ID пришёл целым числом, а не строкой, а дата - объектом Bitrix\Main\Type\DateTime, а не строкой «17.03.2026 12:00:00». ORM приводит значения к типам, объявленным в getMap().

// Способ 2: объекты - когда дальше работа с сущностью и связями
$project = ProjectTable::query()
->setSelect(['*'])
->where('ID', 17)
->fetchObject();
echo $project->getTitle(); // именованный геттер
echo $project->get('TITLE'); // то же самое универсально
// Способ 3: короткие обёртки для типовых случаев
$row = ProjectTable::getRowById(17); // одна строка массивом
$res = ProjectTable::getList([ // привычный массивный стиль
'select' => ['ID', 'TITLE'],
'filter' => ['=ACTIVE' => true],
'limit' => 10,
]);

Правило выбора простое: fetch() и fetchAll() - плоская техническая выборка, список, агрегаты; fetchObject() и fetchCollection() - когда нужны связи, изменение и сохранение. К агрегирующим запросам с GROUP BY объектную выборку применять нельзя - форма результата будет некорректной.

3. Фильтры: главный подвох

Самая частая ошибка новичка выглядит безобидно:

// НЕПРАВИЛЬНО: это поиск подстроки, а не сравнение
$rows = ProjectTable::getList(['filter' => ['TITLE' => 'тест']])->fetchAll();
// SQL: WHERE TITLE LIKE '%тест%' → найдёт «тестовый», «протестировано»…
// ПРАВИЛЬНО: оператор указан явно
$rows = ProjectTable::getList(['filter' => ['=TITLE' => 'тест']])->fetchAll();
// SQL: WHERE TITLE = 'тест'

Фильтр без оператора выполняет LIKE-поиск. Симптом - «выборка вернула лишние строки». Для точного сравнения нужен префикс =.

В новом коде фильтры лучше собирать не массивами, а через Query::filter() - это объект ConditionTree, куски которого можно переиспользовать и вкладывать:

use Bitrix\Main\ORM\Query\Query;
$activeFilter = Query::filter()
->where('ACTIVE', true)
->whereNotNull('ASSIGNED_BY_ID');
$visibilityFilter = Query::filter()
->logic('or')
->where('CREATED_BY', $userId)
->where('RESPONSIBLE_ID', $userId);
$tasks = TaskTable::query()
->setSelect(['ID', 'TITLE', 'RESPONSIBLE_ID'])
->where(Query::filter()->where($activeFilter)->where($visibilityFilter))
->fetchAll();

Внешние условия соединяются через AND, а вложенный filter()->logic('or') даёт скобочную группу. Вот что уходит в базу на более простом примере:

\Bitrix\Main\UserTable::query()
->where('ACTIVE', true)
->where(Query::filter()
->logic('or')
->where('ID', 1)
->where('LOGIN', 'admin')
)
->exec();
// WHERE `main_user`.`ACTIVE` = 'Y' AND (`main_user`.`ID` = 1 OR `main_user`.`LOGIN` = 'admin')

Заодно видно, как true строго приводится к 'Y' - вручную конвертировать булевы значения не нужно.

4. Агрегаты и вычисляемые поля

Поле, которого нет в таблице, регистрируется на время запроса как runtime-поле:

use Bitrix\Main\ORM;
BookTable::getList([
'select' => ['PUBLISH_DATE'],
'filter' => ['>CNT' => 5],
'runtime' => [
new ORM\Fields\ExpressionField('CNT', 'COUNT(*)'),
],
]);
// SELECT PUBLISH_DATE, COUNT(*) AS CNT FROM my_book
// GROUP BY PUBLISH_DATE HAVING COUNT(*) > 5

GROUP BY писать не нужно - ORM видит агрегат рядом с обычным полем и группирует сама, а условие по агрегату честно уезжает в HAVING. Runtime-поле живёт ровно один запрос: в следующем getList() его придётся зарегистрировать заново.

5. Связи: 1:N и N:M

Связи объявляются в getMap() - один раз, а не JOIN-ами в каждом запросе:

// N:1 в BookTable - книга принадлежит издательству
(new Reference(
'PUBLISHER',
PublisherTable::class,
Join::on('this.PUBLISHER_ID', 'ref.ID')
))->configureJoinType('inner'),
// 1:N в PublisherTable - обратная сторона ('PUBLISHER' - имя Reference-поля партнёра)
(new OneToMany('BOOKS', BookTable::class, 'PUBLISHER'))
->configureJoinType('inner'),
// N:M в BookTable - через промежуточную таблицу
(new ManyToMany('AUTHORS', AuthorTable::class))
->configureTableName('b_book_author'),

Дальше связь используется как обычное поле - её достаточно указать в select:

$book = BookTable::getByPrimary(1, [
'select' => ['*', 'PUBLISHER'],
])->fetchObject();
echo $book->getPublisher()->getTitle();
// смена издательства: внешний ключ проставится сам
$publisher = PublisherTable::wakeUpObject(253);
$book->setPublisher($publisher);
$book->save();

Две вещи, которые экономят часы отладки:

  • Полям связанной сущности задавайте алиасы - 'select' => ['*', 'PUB_' => 'PUBLISHER']. Иначе в результате появятся имена вида MAIN_TEST_TYPOGRAPHY_BOOK_PUBLISHER_TITLE.
  • Если у связи есть собственные данные (например QUANTITY), ManyToMany не подходит: addTo() запишет только значение по умолчанию и обновить его будет нельзя. Нужна сущность-посредник с двумя Reference:
$item = StoreBookTable::createObject()
->setBook($book)
->setStore($store)
->setQuantity(5);
$item->save();
// или массивным стилем по составному ключу
StoreBookTable::update(
['STORE_ID' => 34, 'BOOK_ID' => 1],
['QUANTITY' => 12]
);

6. Запись: add, update, объекты и коллекции

use Bitrix\Main\Type;
$result = BookTable::add([
'ISBN' => '978-0321127426',
'TITLE' => 'Patterns of Enterprise Application Architecture',
'PUBLISH_DATE' => new Type\Date('2002-11-16', 'Y-m-d'),
]);
if ($result->isSuccess())
{
$id = $result->getId();
}
else
{
// массив сообщений об ошибках валидации
$errors = $result->getErrorMessages();
}

Проверять isSuccess() обязательно: если операция не прошла валидацию, а результат никто не посмотрел, ORM сама сгенерирует E_USER_WARNING со списком ошибок. Даты передавайте объектами Type\Date и Type\DateTime, а не строками.

Вычисляемые обновления считает база - через SqlExpression с плейсхолдерами:

// правильно - значение подставляется через ?i
BookTable::update($id, [
'READERS_COUNT' => new \Bitrix\Main\DB\SqlExpression('?# + ?i', 'READERS_COUNT', $readersCount),
]);
// НЕПРАВИЛЬНО - конкатенация значения в шаблон = SQL-инъекция
BookTable::update($id, [
'READERS_COUNT' => new \Bitrix\Main\DB\SqlExpression('?# + '.$readersCount, 'READERS_COUNT'),
]);

Объектный сценарий удобен, когда меняется и запись, и её связи:

$task = TaskTable::query()
->setSelect(['*', 'PROJECT', 'COMMENTS'])
->where('ID', $taskId)
->fetchObject();
if ($task === null)
{
return null;
}
$task->set('TITLE', $newTitle);
$task->addTo(
'COMMENTS',
TaskCommentTable::createObject()->set('MESSAGE', $commentMessage)
);
$task->save();

Объект и связанные PROJECT с COMMENTS загружены одним запросом. Изменения копятся в памяти, и один save() фиксирует и саму задачу, и новый комментарий.

Массовую вставку ведут коллекцией - это один INSERT вместо цикла запросов:

$books = BookTable::createCollection();
$books[] = BookTable::createObject()->setTitle('Title 112');
$books[] = BookTable::createObject()->setTitle('Title 113');
$books[] = BookTable::createObject()->setTitle('Title 114')->setIsbn('114-000');
$books->save(true);
// INSERT INTO ... (`TITLE`, `ISBN`) VALUES
// ('Title 112', DEFAULT), ('Title 113', DEFAULT), ('Title 114', '114-000')

Аргумент true в save() отключает события - при мультивставке с автоинкрементом это необходимо. Создавайте объекты через createObject() и createCollection(), а не через системные классы EO_*: фабрики сохраняют контракт сущности и значения по умолчанию.

7. Транзакция

$db = \Bitrix\Main\Application::getConnection();
try {
$db->startTransaction();
// изменения через ORM и/или прямой SQL
\Bitrix\Main\SiteTable::update('s1', ['ACTIVE' => 'N']);
$db->commitTransaction();
} catch (Throwable $e) {
$db->rollbackTransaction();
throw $e;
}

Внутри транзакции допустимы и ORM-операции, и сырой SQL. Держите транзакции короткими и осторожно относитесь к вложенности: вложенный startTransaction() создаёт SAVEPOINT, вложенный commitTransaction() ничего не фиксирует, а вложенный rollbackTransaction() откатывает только до SAVEPOINT и бросает TransactionException.

8. Когда ORM не хватает: прямой SQL

use Bitrix\Main\Application;
use Bitrix\Main\DB\SqlExpression;
if ($ids === [])
{
return []; // пустой массив в ?@ недопустим
}
$connection = Application::getConnection();
$sql = (new SqlExpression(
'SELECT ID, TITLE FROM ?# WHERE ID IN (?@)',
'b_example_item',
$ids,
))->compile();
$rows = $connection->query($sql)->fetchAll();

Плейсхолдеры SqlExpression закрывают все типичные подстановки: ?s - строка с экранированием, ?i - целое, ?f - дробное, ?# - идентификатор, ?@ - список для IN, ?v - VALUES.

Отдельно запомните: параметр $binds у методов query(), queryScalar() и queryExecute() не защищает от SQL-инъекций - это не prepared statements. Безопасность обеспечивают только SqlExpression и SqlHelper.

Со старого кода на D7

Старый кодD7-эквивалент
global $DB; $DB->Query($sql)Application::getConnection()->query($sql)
CDBResult::Fetch() в циклеResult::fetch() / fetchAll() / fetchObject()
массив в getMap() с 'data_type'объекты полей IntegerField, StringField + configure*()
фильтр-массив '=FIELD' => $vQuery::filter()->where('FIELD', $v)
$DB->Query('SELECT ... WHERE ID IN ('.$ids.')')SqlExpression('... IN (?@)', $ids)
CIBlockElement::GetList()ORM инфоблока: IblockTable::compileEntity('ApiCode') → \Bitrix\Iblock\Elements\Element{ApiCode}Table::getList()

Последняя строка требует уточнения: для инфоблоков не используйте базовый ElementTable напрямую - он видит только базовые поля. Нужна скомпилированная сущность конкретного инфоблока (у него должен быть заполнен API_CODE). Подробнее - в статье об инфоблоках.

Справочник API

Сущность и поля

МетодНазначениеОсобенности
DataManagerбазовый класс сущностиимя наследника обязано оканчиваться на Table; доступен с 12.0.0
getTableName() / getMap()имя таблицы / описание полейоба обязательны; актуальные поля - через getEntity()->getFields()
getUfId()идентификатор пользовательских полейUF в getMap() не описывают
getEntity()объект сущности Entity\Baseдаёт getFields(), getField(), cleanCache()
*Field + configure*()IntegerField, StringField, TextField, BooleanField, DateField, DatetimeField, FloatField, EnumFieldStringField - максимум 255 символов, длинный текст в TextField
ExpressionFieldвиртуальное поле на SQL-выражениитолько выборка, фильтр, группировка, сортировка; записать нельзя
isCacheable()отключение кеша сущностивернуть false для часто изменяемых таблиц (с main 24.100.0)

Чтение

МетодНазначениеОсобенности
query()точка входа нового кода, объект QuerysetSelect, where*, setOrder, setLimit, exec, getQuery() для SQL без выполнения
getList()массивный стиль: select, filter, order, limit, runtime, cache, count_totalобёртка над query(); переопределение не сработает при работе через Query
getById() / getByPrimary() / getRowById() / getRow() / getCount()выборка по ключу, одна строка, количествосоставной ключ - массив вида ['k1' => v1, 'k2' => v2]
Query::filter()контейнер условий ConditionTreewhere, whereNull, whereIn, whereBetween, whereLike, logic('or'), negative()
Query::expr()SQL-функцииcount, countDistinct, sum, min, avg, max, length, lower, upper, concat
registerRuntimeField()поле на время запросаживёт один запрос; в фильтре ExpressionField регистрируется сам
QueryHelper::decompose()честный LIMIT при связяхотдельный запрос на каждое отношение; лечит 1:N и N:M
fetch() / fetchAll() / fetchObject() / fetchCollection()получение данных из Resultиз кеша приходит ArrayResult, у которого getFields() всегда null
'cache' => ['ttl' => N]кеширование выборкивыборки с JOIN не кешируются без 'cache_joins' => true

Объекты и коллекции

МетодНазначениеОсобенности
createObject() / createCollection()фабрики новых объектовпредпочтительнее системных EO_*
wakeUpObject() / wakeUpCollection()объект из известной актуальной записибез запроса к базе
get() / set() / require() / fill()доступ к полямget() не делает lazy-loading; require() бросает исключение при пустом значении
addTo() / removeFrom() / removeAll()изменение связейменяют состояние в памяти - нужен save()
Collection::save($ignoreEvents)сохранение коллекцииновые объекты вставляются одним INSERT
collectValues()выгрузка значений наружувместо обхода внутренних свойств

Запись и события

МетодНазначениеОсобенности
add()вставка, возвращает AddResultisSuccess(), getId(); даты - объектами
update()обновление, возвращает UpdateResultфакт изменения строки - getAffectedRowsCount(), а не isSuccess()
delete()удаление записине каскадит; события срабатывают
addMulti() / updateMulti() / deleteByFilter()массовые операциипустой фильтр в deleteByFilter() недопустим
ORM\EventManagerподписка на событиякоды вида DataManager::EVENT_ON_BEFORE_ADD
метод onBeforeAdd() в классеобработчик событияраспознаётся автоматически; правки - через EventResult::modifyFields()

Слой БД

МетодНазначениеОсобенности
Application::getConnection()объект Connectionбез аргумента - соединение default
query() / queryScalar() / queryExecute()строки / одно значение / выполнение без выборки$binds не защищают от инъекций
SqlExpressionбезопасная сборка SQLплейсхолдеры ?s, ?i, ?f, ?#, ?@, ?v
SqlHelper::quote()экранирование идентификаторадля значений - forSql() и convertToDb*(), они не взаимозаменяемы
prepareInsert() / prepareUpdate() / prepareMerge()сборка INSERT, UPDATE, UPSERTвместо склейки строк
startTransaction() / commitTransaction() / rollbackTransaction()транзакциивложенные работают через SAVEPOINT
startTracker()отладка запросов через Diag\SqlTrackerу каждого запроса есть SQL, время и трейс

Частые ошибки

Фильтр без оператора ищет подстроку. Симптом: в выборке лишние строки. ['TITLE' => 'тест'] превращается в LIKE '%тест%'. Как правильно: всегда указывать оператор - ['=TITLE' => 'тест'] или Query::filter()->where('TITLE', 'тест').

setLimit() при связях 1:N обрезает не то. Симптом: вместо пяти книг пришла одна, да ещё с неполным списком авторов. Лимит применяется к строкам SQL, а JOIN размножает строки. Как правильно: QueryHelper::decompose($query) - он делает честный лимит по основным записям и отдельный запрос на каждую связь. Та же причина у другого симптома - резкого роста потребления памяти: выборка нескольких связей одним запросом даёт декартово произведение (15 × 7 × 11 = 1155 строк вместо 33).

get() считают ленивой загрузкой. Симптом: у объекта null вместо значения или исключение при require(). Незагруженное поле само не подтянется. Как правильно: указать поле в select заранее либо дозагрузить явно - fill(['FIELD']).

delete() не удаляет связанные данные. Симптом: после удаления записи в базе остаётся мусор - комментарии, привязки, файлы. Как правильно: удалять связанное явно или в обработчике события onDelete.

isSuccess() путают с фактом изменения. Симптом: код считает, что строка обновлена, хотя UPDATE не задел ни одной записи. isSuccess() говорит только об отсутствии ошибок. Как правильно: проверять getAffectedRowsCount().

$binds считают защитой от инъекций. Симптом: инъекция в коде, который выглядит параметризованным. В query(), queryScalar() и queryExecute() параметры не экранируются. Как правильно: собирать SQL через SqlExpression с плейсхолдерами или через SqlHelper.

Частые вопросы

Чем ORM отличается от CIBlockElement::GetList?

Это разные поколения API. CIBlockElement::GetList - процедурный метод старого ядра: возвращает строки, значения приходят строками, связи и группировка задаются позиционными аргументами. D7 ORM описывает таблицу классом-сущностью, приводит значения к типам полей (даты становятся объектами DateTime), даёт объекты со связями и события. Для инфоблоков ORM подключается через скомпилированную сущность инфоблока с заполненным API_CODE, а инфраструктурные операции (создание инфоблоков, свойств, права, поисковая индексация) по-прежнему делает классическое API.

Почему выборка вернула лишние строки?

Скорее всего, в фильтре нет оператора: запись [TITLE => тест] выполняет LIKE-поиск подстроки, а не сравнение на равенство. Используйте =TITLE или метод where() у Query::filter(). Вторая частая причина - фильтр или выборка по связи 1:N: JOIN размножает строки, лечится через QueryHelper::decompose().

Как сделать выборку одним запросом вместо цикла?

Укажите связи в select - например [*, PUBLISHER] - и данные партнёра приедут вместе с основной записью. Если нужны объекты со связями и честным лимитом, используйте QueryHelper::decompose(). Для массовой вставки собирайте коллекцию и вызывайте save(true): все новые объекты уйдут одним INSERT.

Когда можно опускаться до прямого SQL?

Когда сценарий не выражается через ORM: сложные агрегаты, UPSERT, служебные DDL-операции. Точка входа - Application::getConnection(), а не global $DB. Обязательно собирайте запрос через SqlExpression с плейсхолдерами или SqlHelper: параметр $binds от инъекций не защищает.

Почему IDE не подсказывает методы объектов сущности?

Большинство методов объектов и коллекций виртуальные - они работают через __call, поэтому статический анализ их не видит. Сгенерируйте аннотации командой php bitrix.php orm:annotate из папки /bitrix/: по умолчанию сканируется только модуль main, другие указываются ключом -m. Результат попадёт в /bitrix/modules/orm_annotations.php.

Связанные темы

Первоисточники