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(*) > 5GROUP 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 с плейсхолдерами:
// правильно - значение подставляется через ?iBookTable::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' => $v | Query::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, EnumField | StringField - максимум 255 символов, длинный текст в TextField |
ExpressionField | виртуальное поле на SQL-выражении | только выборка, фильтр, группировка, сортировка; записать нельзя |
isCacheable() | отключение кеша сущности | вернуть false для часто изменяемых таблиц (с main 24.100.0) |
Чтение
| Метод | Назначение | Особенности |
|---|---|---|
query() | точка входа нового кода, объект Query | setSelect, 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() | контейнер условий ConditionTree | where, 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() | вставка, возвращает AddResult | isSuccess(), 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.
Связанные темы
- Инфоблоки - ORM инфоблоков и когда без классического API не обойтись
- Подсистемы ядра - логирование, валидация, GeoIP и другое в том же неймспейсе
- Кеширование - уровни кеша и сброс по тегу
- Автодополнение и IDE - как вернуть подсказки геттерам
и сеттерам сущности через
orm:annotate - Выборки из инфоблоков: GetList, ORM и разделы - ORM против старого ядра на инфоблоках
- Выборки из инфоблоков - решения по выборкам из кода
- Раздел Ядро D7 - остальные подсистемы современного ядра
- Свои таблицы на ORM: сущности, запросы, миграции - данные вне инфоблоков