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

Разделы изнутри - дерево, ключи, привязки элементов

Разбираем устройство разделов инфоблока: как платформа хранит дерево, что означают ключи вложенности и как элемент связан со своими разделами. Дальше смотрим, что пересчитывается при перемещении ветки, а что придётся чинить руками.

Механика

Дерево разделов хранится в базе не одной лишь ссылкой на родителя. У каждого раздела есть левый и правый ключ вложенности, а также уровень глубины и признак глобальной активности.

Ключи вложенности превращают дерево разделов в набор вложенных отрезков на числовой прямой. Раздел лежит внутри родителя, если его левый ключ больше левого ключа родителя и меньше правого.

Такое хранение даёт выборку целой ветки одним запросом и совсем без рекурсии. Каталог с пятью уровнями вложенности отдаёт поддерево так же быстро, как плоский список разделов.

Ширина отрезка между ключами сразу говорит о содержимом всей вложенной ветки. Разница правого и левого ключа без единицы, делённая пополам, равна числу вложенных разделов этой ветки.

Глобальная активность считается платформой по всей цепочке родительских разделов сразу до корня. Раздел с активностью «да» становится глобально неактивным, как только выключен любой из его родительских разделов.

Элемент принадлежит одному основному разделу, и этот раздел определяет его адрес. Дополнительные привязки хранятся отдельной таблицей связей и участвуют в выборках, но не меняют адрес.

Ключи пересчитывает старое ядро инфоблоков при записи раздела своим штатным методом. Запись через объектную модель ключи, уровень и глобальную активность не трогает, и дерево остаётся несогласованным.

Удаление раздела через старое ядро уносит вместе с ним подразделы и элементы. Удаление той же записи объектной моделью рвёт дерево: потомки остаются в базе с ссылкой на исчезнувшего родителя.

Перемещение ветки меняет адреса всех её страниц на витрине сразу после сохранения. Вместе с адресами устаревают кэш разделов, поисковый индекс и фасетный индекс умного фильтра.

Цепочка родителей нужна далеко не только хлебным крошкам на странице. По ней строят наследование свойств, права доступа и индексацию элемента вместе с его вышестоящими разделами.

Шаги

  1. Прочитать ключи вложенности и уровень нужного раздела вместе с его активностью.
  2. Выбрать всю ветку одним запросом по диапазону ключей её корневого раздела.
  3. Посмотреть основной раздел элемента и все его дополнительные привязки к разделам.
  4. Проверить глобальную активность, если раздел виден в админке и скрыт на витрине.
  5. После перемещения ветки обновить фасетный индекс и сбросить кэш вывода разделов.

Код

Смотрим ключи вложенности раздела:

\Bitrix\Main\Loader::includeModule('iblock');
$res = CIBlockSection::GetList(['LEFT_MARGIN' => 'ASC'], ['IBLOCK_ID' => $iblockId], false,
['ID', 'NAME', 'DEPTH_LEVEL', 'LEFT_MARGIN', 'RIGHT_MARGIN', 'ACTIVE', 'GLOBAL_ACTIVE']);
while ($row = $res->GetNext()) {
printf("%3d %s%s [%d..%d]\n", $row['ID'], str_repeat(' ', $row['DEPTH_LEVEL'] - 1), $row['NAME'],
$row['LEFT_MARGIN'], $row['RIGHT_MARGIN']);
}
// сортировка по левому ключу печатает дерево в естественном порядке обхода

Порядок по левому ключу и есть порядок дерева. Никакой рекурсии для печати структуры не нужно: отступ берут из уровня глубины, а вложенность видна по границам отрезков.

Берём всю ветку одним запросом:

$root = CIBlockSection::GetList([], ['IBLOCK_ID' => $iblockId, 'ID' => $sectionId], false,
['ID', 'LEFT_MARGIN', 'RIGHT_MARGIN'])->Fetch();
$branch = CIBlockSection::GetList(['LEFT_MARGIN' => 'ASC'], ['IBLOCK_ID' => $iblockId,
'>=LEFT_MARGIN' => $root['LEFT_MARGIN'], '<=RIGHT_MARGIN' => $root['RIGHT_MARGIN']], false, ['ID', 'NAME']);
printf("разделов в ветке: %d\n", $branch->SelectedRowsCount());
// диапазон ключей заменяет обход дерева по родителям

Считаем размер ветки без выборки:

$children = (int) (($root['RIGHT_MARGIN'] - $root['LEFT_MARGIN'] - 1) / 2);
printf("вложенных разделов: %d\n", $children);
// разность ключей знает про потомков всё, кроме их названий

Эта арифметика удобна для проверок целостности дерева. Отрицательное или нечётное значение означает, что ключи разъехались и разделы нужно пересохранить штатным методом записи.

Смотрим привязки элемента к разделам:

$element = CIBlockElement::GetList([], ['IBLOCK_ID' => $iblockId, 'ID' => $elementId], false, false,
['ID', 'IBLOCK_SECTION_ID'])->Fetch();
$extra = \Bitrix\Iblock\SectionElementTable::getList(['filter' => ['=IBLOCK_ELEMENT_ID' => $elementId],
'select' => ['IBLOCK_SECTION_ID', 'ADDITIONAL_PROPERTY_ID']])->fetchAll();
print_r([$element['IBLOCK_SECTION_ID'], $extra]);
// основной раздел задаёт адрес товара, дополнительные привязки - только выборки

Таблица связей хранит и служебное поле привязки к свойству элемента. Строки с заполненным свойством появляются от свойств типа «привязка к разделам» и к основной структуре каталога отношения не имеют.

Дубли товара в списках каталога растут именно из этих дополнительных привязок. Выборка по ветке возвращает элемент столько раз, сколько у него привязок внутри этой ветки, если не задана группировка по элементу.

Проверяем глобальную активность:

$row = CIBlockSection::GetList([], ['IBLOCK_ID' => $iblockId, 'ID' => $sectionId], false,
['ID', 'ACTIVE', 'GLOBAL_ACTIVE'])->Fetch();
printf("активен=%s глобально=%s\n", $row['ACTIVE'], $row['GLOBAL_ACTIVE']);
// расхождение означает выключенного родителя где-то выше по цепочке

Берём цепочку родителей:

$chain = CIBlockSection::GetNavChain($iblockId, $sectionId, ['ID', 'NAME', 'CODE'], true);
print_r($chain); // от корня к текущему разделу: годится для крошек и индексации
// последний аргумент возвращает массив вместо выборки с курсором

Цепочку берут готовым методом, а не собственным подъёмом по родителям. Свой цикл делает по запросу на каждый уровень и на глубоком каталоге заметно нагружает базу данных.

Чиним дерево после прямых правок базы:

$section = new CIBlockSection();
$section->Update($sectionId, ['IBLOCK_SECTION_ID' => $newParentId, 'SORT' => 500]);
// запись старым ядром пересчитывает ключи, уровень и глобальную активность ветки
\Bitrix\Iblock\PropertyIndex\Manager::markAsInvalid($iblockId); // фасет сам не обновится

Пересохранение раздела штатным методом дешевле любых ручных вычислений. Ключи и активность платформа пересчитает сама, а от разработчика требуется только не писать эти поля напрямую.

Ограничения

Объектная модель инфоблоков не считает ключи вложенности разделов при записи. После записи разделов через неё дерево приходится восстанавливать пересохранением через старое ядро.

Удаление раздела объектной моделью не трогает ни его подразделы, ни его элементы. Подразделы и элементы остаются в базе и продолжают находиться поиском до ручной чистки.

Дополнительные привязки элемента не меняют его собственный адрес на витрине сайта. Товар в трёх разделах живёт по адресу основного раздела, а остальные привязки влияют только на выборки.

Фасетный индекс не пересчитывается сам при перемещении ветки разделов внутри инфоблока. После переезда раздела умный фильтр показывает старые значения, пока индекс не помечен недействительным.

Права раздела наследуются вниз по дереву на все вложенные в него разделы. Закрытая ветка скрывает и подразделы, и элементы, даже когда у них собственных ограничений нет.

Типичные проблемы

Раздел активен, а на витрине его нет.

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

Товар выводится в списке несколько раз.

У него несколько привязок внутри одной ветки, а выборка идёт по диапазону ключей. Помогает группировка по элементу либо отбор строго по его основному разделу.

После записи разделов кодом дерево рассыпалось.

Запись шла объектной моделью, которая не пересчитывает ключи и уровень вложенности. Разделы пересохраняют старым ядром, и структура дерева собирается заново без ручных правок.

Удалённый раздел исчез, а его товары остались в поиске.

Удаление выполнено объектной моделью и не затронуло потомков с поисковым индексом. Раздел вместе со всем содержимым удаляют именно методом старого ядра инфоблоков.

Умный фильтр показывает разделы после переезда неверно.

Фасетный индекс продолжает хранить прежние привязки элементов к разделам этой ветки. Индекс помечают недействительным сразу после перемещения любого раздела в другую ветку.

Выборка ветки внезапно вернула весь инфоблок.

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

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

Зачем разделу два ключа вместо ссылки на родителя?

Ключи позволяют взять целую ветку одним запросом, без рекурсии по родителям. Ссылка на родителя при этом тоже хранится и используется при записи.

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

Посчитать разность правого и левого ключа, вычесть единицу и разделить пополам. Результат равен числу вложенных разделов любой глубины.

Почему раздел не виден, хотя он активен?

Глобальная активность отрицательная из-за выключенного родителя выше по цепочке. Витрина смотрит именно на глобальную активность, а не на собственный флаг.

Меняет ли дополнительная привязка адрес товара?

Нет, адрес определяет основной раздел элемента. Дополнительные привязки нужны для выборок и меню, но страницу товара не переносят.

Что делать после перемещения большой ветки?

Сбросить кэш разделов, обновить фасетный индекс и проверить адреса страниц. Ключи и активность платформа пересчитает сама при записи раздела.

Смежное

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