Skip to content

Tree / Hierarchy — Adjacency List Pattern

This guide covers working with nested data (categories, menus, comment threads) in NeNe using the adjacency-list model and the TreeHelper utility class.


What is the adjacency list pattern?

Every row stores only its own id and its direct parent_id. Root nodes use NULL (or 0) for parent_id. The tree structure is implicit in the data.

id | parent_id | name
---|-----------|-------------
 1 |      NULL | Electronics
 2 |         1 | Laptops
 3 |         2 | Gaming
 4 |         1 | Tablets
 5 |      NULL | Clothing
 6 |         5 | Shirts

When to use adjacency list: - The tree is shallow (< 5–6 levels in practice). - Reads happen once per request — fetch everything, work in PHP. - The schema must stay simple: one foreign-key column. - You need fast single-node inserts / moves (only the parent_id column changes).

Alternatives to consider: - Nested sets — fast subtree reads, slow writes. - Materialized path — easy LIKE queries, moderate write cost. - Recursive CTEs — database-native traversal, good for large trees.


Typical SQL schema

CREATE TABLE categories (
    id        INT UNSIGNED NOT NULL AUTO_INCREMENT,
    parent_id INT UNSIGNED NULL DEFAULT NULL,
    name      VARCHAR(120)  NOT NULL,
    sort      SMALLINT      NOT NULL DEFAULT 0,
    PRIMARY KEY (id),
    KEY idx_parent (parent_id),
    CONSTRAINT fk_category_parent
        FOREIGN KEY (parent_id) REFERENCES categories (id)
        ON DELETE RESTRICT ON UPDATE CASCADE
);

Root nodes use parent_id = NULL. A partial index on parent_id speeds up both "fetch roots" queries and subtree queries.


TreeHelper API reference

Nene\Func\TreeHelper — all methods are static.

Method Signature Description
build build(array $nodes, ?int $rootId = null, string $idKey = 'id', string $parentKey = 'parent_id'): array Build nested tree; returns list of root nodes each with a children key.
ancestors ancestors(array $nodes, int $nodeId, ...): array Return root-first ancestor chain (excludes the target node). Suitable for breadcrumbs.
descendants descendants(array $nodes, int $nodeId, ...): array Return flat list of all nodes under the given node (any depth).
depth depth(array $nodes, int $nodeId, ...): int Return 0-based depth of a node. Returns -1 if not found.
flatten flatten(array $tree, int $depth = 0): array Flatten a nested tree back to a flat list with a _depth key added to each node.

All methods accept optional $idKey and $parentKey parameters to support non-standard column names.


Fetching the full tree with one query

// In a mapper / repository
$categories = $this->db->fetchAll('SELECT id, parent_id, name FROM categories ORDER BY sort, name');

// All TreeHelper operations are then pure PHP — no further DB calls.

Fetching everything once and working in PHP is appropriate for trees where the entire set fits comfortably in memory. For category trees of up to ~1 000 nodes this is the recommended approach.


Build pattern — nested JSON for API responses

use Nene\Func\TreeHelper;

$flat   = $categoryMapper->findAll();
$tree   = TreeHelper::build($flat);

// Returns: [
//   ['id'=>1, 'name'=>'Electronics', 'children'=>[
//     ['id'=>2, 'name'=>'Laptops', 'children'=>[
//       ['id'=>3, 'name'=>'Gaming', 'children'=>[]]
//     ]],
//     ['id'=>4, 'name'=>'Tablets', 'children'=>[]]
//   ]],
//   ...
// ]

header('Content-Type: application/json');
echo json_encode($tree);

Flatten pattern — <select> menus with indentation

use Nene\Func\TreeHelper;

$flat   = $categoryMapper->findAll();
$tree   = TreeHelper::build($flat);
$items  = TreeHelper::flatten($tree);

// $items is a depth-sorted flat list; each node has a `_depth` key.
// Render an indented <option> list:
foreach ($items as $item) {
    $indent = str_repeat(' ', $item['_depth']);   // full-width space for visual indent
    echo "<option value=\"{$item['id']}\">{$indent}{$item['name']}</option>\n";
}

Ancestors — breadcrumb navigation

use Nene\Func\TreeHelper;

$flat       = $categoryMapper->findAll();
$crumbs     = TreeHelper::ancestors($flat, $currentCategoryId);
// $crumbs is ordered root-first (Electronics → Laptops)
// The current node is NOT included; append it yourself if needed.

foreach ($crumbs as $crumb) {
    echo '<a href="/category/' . $crumb['id'] . '">' . htmlspecialchars($crumb['name']) . '</a> › ';
}
echo htmlspecialchars($currentCategoryName);

Descendants — bulk operations on a subtree

use Nene\Func\TreeHelper;

$flat        = $categoryMapper->findAll();
$subtree     = TreeHelper::descendants($flat, $parentCategoryId);
$ids         = array_column($subtree, 'id');

// Soft-delete the entire subtree in one query:
if (!empty($ids)) {
    $placeholders = implode(',', array_fill(0, count($ids), '?'));
    $db->execute(
        "UPDATE categories SET deleted_at = NOW() WHERE id IN ($placeholders)",
        $ids,
    );
}

Depth — indented display

use Nene\Func\TreeHelper;

$flat  = $categoryMapper->findAll();
$depth = TreeHelper::depth($flat, $nodeId);

if ($depth === -1) {
    // node does not exist
}
// depth 0 = root, 1 = first child, 2 = grandchild, …

Performance note

All TreeHelper methods iterate over the flat $nodes array in PHP.

Method Complexity
build O(n²) — each recursion scans the full array
ancestors O(n) — index built once, then pointer-following
descendants O(n²) — recursive scan
depth O(n) — index built once, then pointer-following
flatten O(n) — single pass

O(n²) is acceptable for trees under ~1 000 nodes — typical for category or menu hierarchies. For larger datasets (forums with millions of threads, org charts with tens of thousands of nodes) consider:

  • A recursive CTE (WITH RECURSIVE) — single SQL query, scales to any size.
  • A nested set model — fast reads for subtrees, expensive writes.
  • Pre-computing and caching the tree (Redis, APCu) with a TTL that matches the update frequency.