0% прочитано

Дерево категорій не закінчується на одному рівні

Якщо бізнес-правило говорить про категорію і все під нею, relation children недостатньо. Потрібно відрізняти безпосередніх дітей від усіх нащадків і чітко визначати, де рекурсія має завершуватися.

20 жовтня 2026 р. 10 хв читанняЛаравель

Нещодавно я знову натрапила на типову помилку в роботі з деревом категорій: код правильно працював із children, але бізнесова семантика вимагала зовсім іншого.

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

На невеликому дереві ця різниця легко залишається непоміченою. Поки структура виглядає як parent -> child, реалізація здається правильною. Помилка проявляється тільки тоді, коли з’являється третій рівень.

Якщо правило звучить як "ця категорія і все під нею", relation children описує неправильну межу. Тут потрібні descendants.

Direct children і all descendants - це різні поняття

Одне дерево, але дві різні вибірки
parent  |  +-- child-a  |     |  |     +-- grandchild-a1  |     |  |     +-- grandchild-a2  |  +-- child-b        |        +-- grandchild-b1

Для parent direct children - це тільки child-a і child-b.

All descendants - це вже child-a, child-b, grandchild-a1, grandchild-a2 і grandchild-b1.

Технічно це може виглядати як невелика різниця в query, але для бізнес-логіки це зовсім інший набір даних.

Звичайна children relation вирішує тільки один рівень

Звичайний adjacency-list relation
public function children(): HasMany{    return $this->hasMany(        Category::class,        'parent_id',    );}

Такий relation правильний. Проблема не в ньому.

Проблема з’являється, коли код використовує children у місці, де правило насправді говорить про всю підгілку.

Тоді деталі імплементації починають непомітно змінювати зміст бізнесового правила.

Типова помилка проходить тести з двома рівнями

Такий тест не перевіряє рекурсивну семантику
$root = Category::factory()->create(); $child = Category::factory()->create([    'parent_id' => $root->id,]); $this->assertTrue(    $root->children->contains($child),);

Якщо test fixture має лише root і child, помилка не проявиться.

Щоб перевірити правило "усе під категорією", потрібен хоча б третій рівень. Я тепер спеціально додаю grandchild у такі тести.

Третій рівень захищає потрібну семантику
$root = Category::factory()->create(); $child = Category::factory()->create([    'parent_id' => $root->id,]); $grandchild = Category::factory()->create([    'parent_id' => $child->id,]); $ids = $service->descendantIds(    $root,); $this->assertEqualsCanonicalizing(    [

Рекурсивне eager завантаження може бути достатнім для невеликого дерева

Relation завантажує дітей кожного наступного рівня
public function descendantsTree(): HasMany{    return $this        ->children()        ->with(            'descendantsTree',        );}

Для невеликого дерева та простого use case такий підхід може бути цілком достатнім.

Ми отримуємо вкладену структуру й можемо пройти її рекурсивно в PHP.

Але важливо розуміти вартість: Eloquent буде виконувати queries для рівнів дерева. Для великої або дуже глибокої структури це вже може бути невдалим вибором.

Якщо потрібні тільки IDs, я не будую зайву доменну структуру

Рекурсія повертає саме те, що потрібно use case
private function collectDescendantIds(    Category $category,): array {    $ids = [];     foreach (        $category->descendantsTree        as $child    ) {        $ids[] = $child->id;         array_push(            $ids,            ...$this->collectDescendantIds(                $child,            ),

Якщо наступний query потребує тільки набору category IDs, не завжди потрібно передавати ціле вкладене дерево далі по коду програми.

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

Parent теж не завжди входить у descendants

Ще одна семантична деталь: descendants зазвичай означає все нижче, але не сам root node.

Якщо бізнесове правило звучить як "ця категорія і всі її нащадки", root потрібно додати окремо.

Root і descendants краще виражати явно
$categoryIds = [    $category->id,    ...$service->descendantIds(        $category,    ),];

Де рекурсія повинна завершуватися

Найприродніша точка завершення - node без дітей.

Але в реальній системі я перевіряю ще кілька речей:

  • чи є бізнесове обмеження глибини;
  • чи можна переходити між різними типами або scopes категорій;
  • чи гарантовано база не містить циклів;
  • чи не стане повне дерево занадто великим для одного request.

Захист від циклу потрібен, якщо структура не гарантована базою

Це вже не дерево, а цикл
A|vB|vC|+----> A

Для справжнього дерева така структура недопустима.

Якщо база або service layer не гарантує ациклічну структуру, рекурсивний прохід повинен мати захист через visited IDs або явне обмеження глибини.

Інакше одна пошкоджена relation може перетворити простий query на нескінченну рекурсію.

Для великих дерев краще подумати про рекурсивне CTE

Один query може пройти всю підгілку
WITH RECURSIVE descendants AS (    SELECT id, parent_id    FROM categories    WHERE parent_id = :root_id     UNION ALL     SELECT c.id, c.parent_id    FROM categories c    INNER JOIN descendants d        ON c.parent_id = d.id)SELECT idFROM descendants;

Рекурсивний CTE корисний, коли дерево глибоке, потрібно отримати лише плоский набір descendants або PHP-recursion створює зайву кількість queries.

Але я не переходжу на CTE автоматично. Для невеликого дерева простий Eloquent code може бути зрозумілішим і достатньо швидким.

Вибір залежить не тільки від розміру дерева

Я дивлюся на use case:

  • для UI-tree часто потрібна вкладена структура;

  • для фільтрації часто достатньо descendant IDs;

  • для авторизації або правил видимості важлива точна семантика root + descendants;

  • для великих batch operations краще уникати завантаження повних Eloquent models без потреби.

Назва методу повинна показувати глибину семантики

Різні назви для різних контрактів
children() descendants() descendantIds() selfAndDescendantIds()

Після такого bugfix я особливо уважна до назв.

Метод children() не повинен раптом повертати grandchildren. А метод із назвою descendants() не повинен зупинятися на першому рівні.

Точна назва зменшує шанс, що через кілька місяців хтось використає правильний код із неправильною семантикою.

Мій мінімальний набір тестів для дерева

  • node без дітей повертає порожній descendant set;
  • один child повертається;
  • grandchild теж повертається;
  • дві паралельні гілки не губляться;
  • root не входить у descendants, якщо контракт цього не каже;
  • self + descendants включає root рівно один раз;
  • scope boundary не перетинається, якщо це заборонено правилом.

Помилка була не в рекурсії, а в неправильному понятті

Найкорисніший висновок із цього кейсу для мене простий: спочатку потрібно правильно назвати потрібний набір даних.

Якщо правило говорить про прямих дітей, одного рівня достатньо. Якщо правило говорить про всю гілку, потрібно явно працювати з descendants.

Рекурсія тут лише технічний спосіб реалізації. Справжня помилка починається раніше, коли children і descendants сприймаються як одне й те саме.

Якщо бізнесове правило каже "все під цією категорією", я більше не дивлюся на children. Я перевіряю, чи код справді проходить повне дерево нащадків.