Документация

Tree: дерево в таблице

DevBx\Core\Internals\Tree — абстрактный наследник Bitrix\Main\Entity\DataManager для таблиц-деревьев по схеме вложенных множеств. Тот же приём, на котором стоит Bitrix\Sale\Location\LocationTable в самом Битриксе: у каждого узла хранятся границы поддерева, и поддерево читается одним запросом без рекурсии.

Пространство имён Internals означает «служебное»: это не публичная договорённость модуля, и подпись методов может измениться без предупреждения.

Что должно быть в карте полей

Поле Тип Зачем
ID целое, первичный ключ, автозаполнение
PARENT_ID целое родитель, 0 — корень
LEFT_MARGIN, RIGHT_MARGIN целые границы поддерева
DEPTH_LEVEL целое глубина, у корней — единица
php
namespace My\Module;

use Bitrix\Main\ORM\Fields;
use DevBx\Core\Internals\Tree;

class CategoryTable extends Tree
{
    public static function getTableName()
    {
        return 'my_module_category';
    }

    public static function getMap()
    {
        return [
            (new Fields\IntegerField('ID'))->configurePrimary()->configureAutocomplete(),
            new Fields\IntegerField('PARENT_ID'),
            new Fields\IntegerField('LEFT_MARGIN'),
            new Fields\IntegerField('RIGHT_MARGIN'),
            new Fields\IntegerField('DEPTH_LEVEL'),
            new Fields\StringField('NAME'),
        ];
    }
}

Запись

php
CategoryTable::add(['NAME' => 'Техника']);                       // корень
CategoryTable::add(['NAME' => 'Чайники', 'PARENT_ID' => $id]);   // ветка

CategoryTable::update($id, ['PARENT_ID' => $otherId]);           // перенос поддерева
CategoryTable::delete($id);                                      // узел вместе с поддеревом

Границы и глубину класс считает сам — передавать LEFT_MARGIN, RIGHT_MARGIN и DEPTH_LEVEL не нужно, при обычной записи они всё равно вычёркиваются.

Узел без родителя встаёт в конец: это лес, а не одно дерево, корней может быть сколько угодно.

Смена PARENT_ID в update переносит весь узел с потомками. Класс не даст назначить узел родителем самому себе и не даст перенести его внутрь собственного поддерева — обе попытки вернутся ошибкой поля, а не исключением.

Массовая загрузка

Каждая вставка внутрь дерева раздвигает границы соседей — на тысяче узлов это тысяча перестроений. Для загрузки «с нуля» перестроение отключают и делают один раз в конце:

php
foreach ($rows as $row) {
    CategoryTable::addExtended($row, ['REBALANCE' => false]);
}

CategoryTable::resort();

resort() обходит дерево в глубину и пересчитывает границы всем узлам. Вызывать его нужно в транзакции: посреди пересчёта дерево несогласованно.

Проверить результат: CategoryTable::checkIntegrity()true, если ни у одного узла границы не пусты.

Удаление

php
CategoryTable::deleteExtended($id, ['DELETE_SUBTREE' => false]);  // только сам узел

По умолчанию удаляется всё поддерево. Потомки удаляются прямым запросом, поэтому обработчики событий DataManager на них не сработают: ни onDelete, ни onAfterDelete. Если на удалении узла у вас висит очистка связанных данных, для поддерева её нужно делать самому — например, собрав номера через getSubTree до удаления.