29.08.2019

runtime Отношения в Битрикс ORM D7

ORM D7 в Битрикс появился уже довольно давно. Очень удобно использовать одинаковые методы для запросов к БД с помощью ::getList, просто описывать свои таблицы с помощью ::getMap.
Например вот так можно выбрать информацию о товарах вместе с данными о доступном количестве и зарезервированном количестве + сразу же вывести и название товара.

$products = \Bitrix\Catalog\ProductTable::getList([
    "select" => [
        "PROD_ID" => "ID",
        "NAME" => "IBLOCK_ELEMENT.NAME",
        "IBLOCK_ID" => "IBLOCK_ELEMENT.IBLOCK_ID",
        "TYPE"
    ],
    "filter" => [
        "!TYPE" => \Bitrix\Catalog\ProductTable::TYPE_SKU,
        "IBLOCK_ELEMENT.ACTIVE" => "Y"
    ],
    "order" => [
        "QUANTITY" => "ASC",
        "QUANTITY_RESERVED" => "DESC",
    ]
])->fetchAll();

При использовании старого API: запрос к CCatalogProduct + запрос к CIblockElement мы бы получили вместо одного запроса - два. Да и нужно было бы писать гораздо больше кода для этого. Возможность работы с данными из других таблиц появилась благодаря указанию отношений в методе getMap. Но что делать если по какой то причине эта связь не указана?
В таком случае стоит воспользоваться секцией 'runtime' в массиве параметров метода getList.
Например так:

$dbOrders = \Bitrix\Sale\Internals\OrderTable::getList([
    "select" => [
        "*",
        "PROPERTY_VALUE.*",
    ],
    "filter" => [
        "PROPERTY_VALUE.PROPERTY.CODE" => "LOCATION",
        "!STATUD_ID" => ["C", "RS"]
    ],
    'runtime' => [
        'PROPERTY_VALUE' => [
            'data_type' => \Bitrix\Sale\Internals\OrderPropsValueTable::class,
            'reference' => [
                '=this.ID' => 'ref.ORDER_ID',
            ]
        ],
    ],
    "order" => [
        "ID" => "ASC"
    ]
]);

Данный запрос выведет список заказов в статусах C и RS сразу со значением свойства LOCATION. И все это одним запросом.

Вместо ассоциативного массива внутри runtime для каждого поля можно использовать обьекты класса \Bitrix\Main\Entity\ReferenceField. Тогда PROPERTY_VALUE из предыдущего примера будет выглядеть так:

new \Bitrix\Main\Entity\ReferenceField(
    'PROPERTY_VALUE',
    '\Bitrix\Sale\Internals\OrderPropsValue',
    array(
        '=this.ID' => 'ref.ORDER_ID',
    )
)

При добавлении \Bitrix\Main\Entity\ReferenceField в getList, во время формирования SQL запроса в секцию FROM будет добавлен JOIN. Тип JOIN можно указать так:

'PROPERTY_VALUE' => [
    'data_type' => \Bitrix\Sale\Internals\OrderPropsValueTable::class,
    'reference' => [
        '=this.ID' => 'ref.ORDER_ID',
    ],
    ['join_type' => 'LEFT']
]

Рассмотрим другой пример:

$cities = \Bitrix\Sale\Location\LocationTable::getList([
    "select" => ["ID"],
    "filter" => [
        "TYPE_ID" => 5,
        "ZIP.XML_ID" => false
    ],
    'runtime' => [
        'ZIP' => [
            'data_type' => \Bitrix\Sale\Location\ExternalTable::class,
            'reference' => [
                '=this.ID' => 'ref.LOCATION_ID',
                '=ref.SERVICE_ID' => new \Bitrix\Main\DB\SqlExpression('?i', $zipExternalId),
            ],
            ['join_type' => 'LEFT']
        ],
    ]
])->fetchAll();

В этом примере будут выбраны все местоположения для которых не заполнен почтовый индекс. В этом примере стоит обратить внимание на то, что в условиях ReferenceField есть такая конструкция '=ref.SERVICE_ID' => new \Bitrix\Main\DB\SqlExpression('?i', $zipExternalId),. С ее помощью в запрос будет подставлен ID внешнего сервиса отвечающего за почтовые индексы. Если этого не сделать то объединение таблиц произойдет по всем доступным сервисам. При этом условие будет добавлено не в секцию WHERE SQL запроса, а в соответствующий JOIN. Для этого примера SQL запрос будет выглядеть так:

SELECT 
    `sale_location_location`.`ID` AS `ID`
FROM `b_sale_location` `sale_location_location` 
    LEFT JOIN `b_sale_loc_ext` `sale_location_location_zip` ON `sale_location_location`.`ID` = `sale_location_location_zip`.`LOCATION_ID` AND `sale_location_location_zip`.`SERVICE_ID` = 2
WHERE `sale_location_location`.`TYPE_ID` = 5
AND (`sale_location_location_zip`.`XML_ID` IS NULL OR `sale_location_location_zip`.`XML_ID` = '')

Так же есть возможность настраивать связь с другими таблицами с помощью класса Bitrix\Main\ORM\Fields\Relations\Reference.

Дальше в примерах будет приводиться PHP код для описания runtime поля и примерный SQL код, который наглядно показывает изменения в запросе

Связь с другой таблицей


(new Reference(
    "USER", //Символьный код, который можно использовать в select и filter для ссылки на эту таблицу
    \Bitrix\Main\UserTable::class, //orm клас
    \Bitrix\Main\ORM\Query::on("this.USER_ID", "ref.ID"))//по какому полю выполняем join
);
//LEFT JOIN b_user on this.USER_ID = b_user.ID

Связь с другой таблицей и настройкой JOIN


(new Reference(
    "FILE",
    \Bitrix\Main\FileTable::class,
    \Bitrix\Main\ORM\Query::on("this.FILE_ID", "ref.ID")
))->configureJoinType(
    \Bitrix\Main\ORM\Query\Join::TYPE_INNER,
    // \Bitrix\Main\ORM\Query\Join::TYPE_LEFT,
    // \Bitrix\Main\ORM\Query\Join::TYPE_LEFT_OUTER,
    // \Bitrix\Main\ORM\Query\Join::TYPE_RIGHT,
    // \Bitrix\Main\ORM\Query\Join::TYPE_RIGHT_OUTER,
);

//INNER JOIN b_file on this.FILE_ID = b_file.ID

Связь с другой таблицей и доп условия фильтрации в JOIN


(new Reference(
    "FILE", 
    \Bitrix\Main\FileTable::class, 
    Join::on("this.FILE_ID", "ref.ID")
        ->where("ref.HEIGHT", "<=", 1000)
        ->where("ref.WIDTH", "<=", 1000)
));

//LEFT JOIN b_file on this.FILE_ID = b_file.ID and b_file.HEIGHT <= 1000 and b_file.WIDTH <= 1000

То же самое, но только вместо where используется addCondition и объект \Bitrix\Main\ORM\Query\Filter\Condition


(new Reference(
    "FILE", 
    \Bitrix\Main\FileTable::class, 
    Join::on("this.FILE_ID", "ref.ID")
        ->addCondition(new \Bitrix\Main\ORM\Query\Filter\Condition("ref.HEIGHT", "<=", 1000))
        ->addCondition(new \Bitrix\Main\ORM\Query\Filter\Condition("ref.WIDTH", "<=", 1000))
));

//LEFT JOIN b_file on this.FILE_ID = b_file.ID and b_file.HEIGHT <= 1000 and b_file.WIDTH <= 1000

То же самое, но только вместо where используется addCondition и объект ConditionTree


(new Reference(
    "FILE",
    \Bitrix\Main\FileTable::class,
    Join::on("this.FILE_ID", "ref.ID")
        ->addCondition((new \Bitrix\Main\ORM\Query\Filter\ConditionTree())
            ->addCondition(new \Bitrix\Main\ORM\Query\Filter\Condition("ref.HEIGHT", "<=", 1000))
            ->addCondition(new \Bitrix\Main\ORM\Query\Filter\Condition("ref.WIDTH", "<=", 1000))
        )
))
//LEFT JOIN b_file on this.FILE_ID = b_file.ID and (b_file.HEIGHT <= 1000 and b_file.WIDTH <= 1000)

То же самое, но смешиваем ConditionTree c вызовами where. Так получается короче


(new Reference(
    "FILE",
    \Bitrix\Main\FileTable::class,
    Join::on("this.FILE_ID", "ref.ID")
        ->addCondition((new \Bitrix\Main\ORM\Query\Filter\ConditionTree())
            ->where("ref.HEIGHT", "<=", 1000)
            ->where("ref.WIDTH", "<=", 1000)
        )
))
//LEFT JOIN b_file on this.FILE_ID = b_file.ID and (b_file.HEIGHT <= 1000 and b_file.WIDTH <= 1000)

Чем может быть полезен ConditionTree?

Например, можно добавить в группу условий or вместо and


(new Reference(
    "FILE", 
    \Bitrix\Main\FileTable::class, 
    Join::on("this.FILE_ID", "ref.ID")
        ->addCondition((new \Bitrix\Main\ORM\Query\Filter\ConditionTree()
            ->where("ref.HEIGHT", "<=", 1000)
            ->where("ref.WIDTH", "<=", 1000)
            ->logic("or")//будем or между двумя условиями
    )
));
//LEFT JOIN b_file on this.FILE_ID = b_file.ID and (b_file.HEIGHT <= 1000 or b_file.WIDTH <= 1000)

Так же можно добавить отрицание перед группой условий


(new Reference(
    "FILE", 
    \Bitrix\Main\FileTable::class, 
    Join::on("this.FILE_ID", "ref.ID")
        ->addCondition((new \Bitrix\Main\ORM\Query\Filter\ConditionTree()
            ->where("ref.HEIGHT", "<=", 1000)
            ->where("ref.WIDTH", "<=", 1000)
            ->negative()//перед этой группой условий будет отрицание
    )
));
//LEFT JOIN b_file on this.FILE_ID = b_file.ID and not (b_file.HEIGHT <= 1000 and b_file.WIDTH <= 1000)

Так же можно вообще не указывать Join::on, а сразу описать нужные условия в ConditionTree


(new Reference(
    "FILE", 
    \Bitrix\Main\FileTable::class, 
    (new \Bitrix\Main\ORM\Query\Filter\ConditionTree())
        ->where("this.FILE_ID", "=", ref.ID")
        ->where("ref.HEIGHT", "<=", 1000)
        ->where("ref.WIDTH", "<=", 1000)
));
//LEFT JOIN b_file on this.FILE_ID = b_file.ID and b_file.HEIGHT <= 1000 and b_file.WIDTH <= 1000)

В конце, хочу привести пример сложного запроса, который можно создать. Он взят из кода модуля forum.

В данном примере с помощью метода whereColumn добавляются условия ссылающиеся на другие RuntimeField


$query = MessageTable::query()
    ->setSelect(['ID'])
    ->where('TOPIC_ID', $topic->getId())
    ->registerRuntimeField('FORCED_INT_ID', new Main\Entity\ExpressionField('FORCED_ID', '%s + ""', ['ID']))
    ->registerRuntimeField(
        'USER_TOPIC',
        new Main\ORM\Fields\Relations\Reference(
            'USER_TOPIC',
            UserTopicTable::getEntity(),
            [
                '=this.TOPIC_ID' => 'ref.TOPIC_ID',
                '=ref.USER_ID' => ['?i', $this->getId()],
            ],
            ['join_type' => 'LEFT']
        )
    )
    ->registerRuntimeField(
        'USER_FORUM',
        new Main\ORM\Fields\Relations\Reference(
            'USER_FORUM',
            UserForumTable::getEntity(),
            [
                '=this.FORUM_ID' => 'ref.FORUM_ID',
                '=ref.USER_ID' => ['?i', $this->getId()]
            ],
            ['join_type' => 'LEFT']
        )
    )
    ->registerRuntimeField(
        'USER_FORUM_0',
        new Main\ORM\Fields\Relations\Reference(
            'FUF0',
            UserForumTable::getEntity(),
            [
                '=this.FORUM_ID' => ['?i', 0],
                '=ref.USER_ID' => ['?i', $this->getId()]
            ],
            ['join_type' => 'LEFT']
        )
    )
    ->where(
        Main\ORM\Query\Query::filter()
            ->logic('or')
            ->where(
                Main\ORM\Query\Query::filter()
                    ->whereNotNull('USER_TOPIC.LAST_VISIT')
                    ->whereColumn('POST_DATE', '>', 'USER_TOPIC.LAST_VISIT')
            )
            ->where(
                Main\ORM\Query\Query::filter()
                    ->whereNull('USER_TOPIC.LAST_VISIT')
                    ->whereColumn('POST_DATE', '>', 'USER_FORUM.LAST_VISIT')
            )
            ->where(
                Main\ORM\Query\Query::filter()
                    ->whereNull('USER_TOPIC.LAST_VISIT')
                    ->whereNull('USER_FORUM.LAST_VISIT')
                    ->whereColumn('POST_DATE', '>', 'USER_FORUM_0.LAST_VISIT')
            )
            ->where(
                Main\ORM\Query\Query::filter()
                    ->whereNull('USER_TOPIC.LAST_VISIT')
                    ->whereNull('USER_FORUM.LAST_VISIT')
                    ->whereNull('USER_FORUM_0.LAST_VISIT')
                    ->whereNotNull('ID')
            )
    )
    ->setOrder(['FORCED_INT_ID' => 'ASC'])
    ->setLimit(1)