Реализация категорий каталога на PHP: выбор оптимальной структуры

Раздел: Разработка каталогов на PHP -> Структура каталога

Подходы к хранению иерархии категорий

Как эффективно организовать иерархию категорий с возможностью быстрой выборки подкатегорий и родительских цепочек?

Наиболее универсальное и производительное решение для большинства проектов - closure table (таблица замыканий). Оно сочетает простоту обновлений с высокой скоростью выборок.

Структура включает две таблицы:

CREATE TABLE categories (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    slug VARCHAR(255) UNIQUE
);

CREATE TABLE categories_closure (
    ancestor_id INT NOT NULL,
    descendant_id INT NOT NULL,
    depth INT NOT NULL DEFAULT 0,
    PRIMARY KEY (ancestor_id, descendant_id),
    FOREIGN KEY (ancestor_id) REFERENCES categories(id) ON DELETE CASCADE,
    FOREIGN KEY (descendant_id) REFERENCES categories(id) ON DELETE CASCADE
);

В таблице closure хранятся все пути от предка к потомку (включая самоссылку depth = 0).

Пример PHP-класса для работы с closure table (PDO):

class CategoryClosure {
    private PDO $pdo;

    public function __construct(PDO $pdo) {
        $this->pdo = $pdo;
    }

    // Добавление категории как дочерней к $parentId (если null - корень)
    public function addNode(string $name, string $slug, ?int $parentId = null): int {
        $this->pdo->beginTransaction();
        try {
            // вставка категории
            $stmt = $this->pdo->prepare('INSERT INTO categories (name, slug) VALUES (?, ?)');
            $stmt->execute([$name, $slug]);
            $newId = (int)$this->pdo->lastInsertId();

            // вставка замыкания для самой себя
            $stmt = $this->pdo->prepare('INSERT INTO categories_closure (ancestor_id, descendant_id, depth) VALUES (?, ?, 0)');
            $stmt->execute([$newId, $newId]);

            if ($parentId !== null) {
                // копируем все связи предков родителя, добавляя нового потомка
                $stmt = $this->pdo->prepare('
                    INSERT INTO categories_closure (ancestor_id, descendant_id, depth)
                    SELECT ancestor_id, ?, depth + 1
                    FROM categories_closure
                    WHERE descendant_id = ?
                ');
                $stmt->execute([$newId, $parentId]);
            }

            $this->pdo->commit();
            return $newId;
        } catch (Exception $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }

    // Получение всех потомков категории (включая саму себя)
    public function getDescendants(int $id, bool $includeSelf = true): array {
        $depthFilter = $includeSelf ? '' : 'AND c.depth > 0';
        $sql = "
            SELECT cat.*, c.depth
            FROM categories_closure c
            JOIN categories cat ON cat.id = c.descendant_id
            WHERE c.ancestor_id = ? $depthFilter
            ORDER BY c.depth, cat.name
        ";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$id]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    // Получение всех предков (родительская цепочка)
    public function getAncestors(int $id): array {
        $sql = "
            SELECT cat.*, c.depth
            FROM categories_closure c
            JOIN categories cat ON cat.id = c.ancestor_id
            WHERE c.descendant_id = ? AND c.depth > 0
            ORDER BY c.depth DESC
        ";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$id]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    // Перемещение поддерева под другого родителя
    public function moveNode(int $nodeId, int $newParentId): void {
        // удаляем все пути, где nodeId выступает потомком (кроме самоссылки? нужно оставить)
        // и вставляем новые пути от предков нового родителя + старые пути внутри поддерева
        $this->pdo->beginTransaction();
        try {
            // удаляем связи, где nodeId является потомком, но ancestor_id не равен nodeId
            $this->pdo->exec('DELETE FROM categories_closure 
                WHERE descendant_id = ? AND ancestor_id != ?', [$nodeId, $nodeId]);

            // добавляем новые связи от предков нового родителя
            $stmt = $this->pdo->prepare('
                INSERT INTO categories_closure (ancestor_id, descendant_id, depth)
                SELECT super.ancestor_id, sub.descendant_id, super.depth + sub.depth + 1
                FROM categories_closure super
                JOIN categories_closure sub ON sub.ancestor_id = ?
                WHERE super.descendant_id = ? AND sub.descendant_id != ?
            ');
            $stmt->execute([$nodeId, $newParentId, $nodeId]);

            $this->pdo->commit();
        } catch (Exception $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }
}

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

  • Большой объём данных. Closure table может занимать много места при глубокой вложенности (N^2 в худшем случае). Для каталогов до 100 000 категорий это допустимо.
  • Удаление категории. Необходимо каскадно удалять записи из closure (внешний ключ с ON DELETE CASCADE решает проблему).
  • Ошибки при перемещении. Рекурсивные CTE или множественные запросы могут нарушить целостность - используйте транзакции.

Вариант 1: Adjacency list (с рекурсивным CTE)

Простая таблица с parent_id. Выборка подкатегорий требует рекурсивных запросов (MySQL 8+, PostgreSQL). Для старых версий - множественные запросы в PHP.

CREATE TABLE categories_adj (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255),
    parent_id INT NULL,
    FOREIGN KEY (parent_id) REFERENCES categories_adj(id)
);

-- Получение всех потомков (MySQL 8+)
WITH RECURSIVE tree AS (
    SELECT id, name, parent_id, 0 depth
    FROM categories_adj WHERE id = ?
    UNION ALL
    SELECT c.id, c.name, c.parent_id, t.depth + 1
    FROM categories_adj c
    JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree;

Когда стоит использовать adjacency list?

Для простых иерархий с малой глубиной (менее 5 уровней) и редкими запросами на полное дерево. Недостаток - сложность получения всех потомков в старых СУБД.

Проблема: рекурсия в PHP через множественные запросы может привести к N+1 запросу. Решение - использовать рекурсивные CTE или materialized path.

Вариант 2: Nested sets (left/right)

Каждой категории назначаются целые числа lft и rgt, определяющие вложенность. Быстрое получение поддерева, но сложное обновление при добавлении/удалении.

CREATE TABLE categories_ns (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255),
    lft INT NOT NULL,
    rgt INT NOT NULL
);

-- Получить всех потомков категории с id = 12
SELECT * FROM categories_ns 
WHERE lft >= (SELECT lft FROM categories_ns WHERE id = 12)
  AND rgt <= (SELECT rgt FROM categories_ns WHERE id = 12)
ORDER BY lft;

Когда nested sets подходит?

Если дерево статично (часто читаем, редко обновляем). Пересчёт lft/rgt при вставке требует блокировки таблицы.

Ошибка: при параллельной вставке возможно нарушение целостности. Рекомендуется использовать транзакции и SELECT ... FOR UPDATE.

Вариант 3: Плоская таблица с дополнительным полем path

Хранение строки пути (например, '1/3/7') в поле path. Простота, но сложная сортировка и невозможность гарантировать целостность на уровне БД.

CREATE TABLE categories_flat (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255),
    path VARCHAR(1000) NOT NULL DEFAULT ''
);

-- Получить всех потомков категории с path = '1/3'
SELECT * FROM categories_flat 
WHERE path LIKE CONCAT('1/3', '%');

Когда используется плоская таблица?

Для простых приложений, где нет жёстких требований к нормализации, и не ожидается перемещения категорий.

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

Расширенные примеры и нестандартные ситуации

Пример 1: Построение дерева категорий с уровнем вложенности

Используя closure table, построим ассоциативный массив, группируя по родителю:

Пример
function buildTree(PDO $pdo): array {
    $stmt = $pdo->query('
        SELECT c.id, c.name, c.slug, cc.ancestor_id, cc.depth
        FROM categories c
        JOIN categories_closure cc ON cc.descendant_id = c.id
        WHERE cc.depth = 1 OR cc.depth = 0
        ORDER BY cc.ancestor_id, cc.depth, c.name
    ');
    $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
    $tree = [];
    $map = [];
    
    foreach ($rows as $row) {
        $map[$row['id']] = ['id' => $row['id'], 'name' => $row['name'], 'children' => []];
        if ($row['depth'] == 0) {
            $tree[$row['id']] = &$map[$row['id']];
        } else {
            $parentId = $row['ancestor_id'];
            if (isset($map[$parentId])) {
                $map[$parentId]['children'][] = &$map[$row['id']];
            }
        }
    }
    return $tree;
}

Результат:

Array
(
    [1] => Array
        (
            [id] => 1
            [name] => Электроника
            [children] => Array
                (
                    [0] => Array
                        (
                            [id] => 2
                            [name] => Телефоны
                            [children] => Array()
                        )
                )
        )
)

Пример 2: Миграция с adjacency list на closure table

Перенос данных из таблицы с parent_id в структуру closure:

Пример
// Предположим, таблица source: id, parent_id (NULL для корня)
function migrateToClosure(PDO $pdo): void {
    $pdo->beginTransaction();
    try {
        // 1. Копируем все категории (без изменения)
        $pdo->exec('INSERT INTO categories (id, name, slug) SELECT id, name, slug FROM source');
        
        // 2. Добавляем самоссылки depth=0
        $pdo->exec('INSERT INTO categories_closure (ancestor_id, descendant_id, depth) 
                     SELECT id, id, 0 FROM categories');
        
        // 3. Для каждой записи source с parent_id добавляем пути от предков родителя
        // В цикле (можно рекурсивным CTE, но проще итеративно)
        $parents = $pdo->query('SELECT id, parent_id FROM source WHERE parent_id IS NOT NULL')->fetchAll();
        $processed = [];
        while (!empty($parents)) {
            $newPaths = [];
            foreach ($parents as $row) {
                $childId = $row['id'];
                $parentId = $row['parent_id'];
                // Получаем все пути от предков родителя
                $stmt = $pdo->prepare('SELECT ancestor_id, depth FROM categories_closure WHERE descendant_id = ?');
                $stmt->execute([$parentId]);
                $ancestors = $stmt->fetchAll();
                foreach ($ancestors as $anc) {
                    $newPaths[] = [
                        'ancestor_id' => $anc['ancestor_id'],
                        'descendant_id' => $childId,
                        'depth' => $anc['depth'] + 1
                    ];
                }
                $processed[] = $childId;
            }
            // Вставка всех накопленных путей
            $stmt = $pdo->prepare('INSERT INTO categories_closure (ancestor_id, descendant_id, depth) VALUES (?, ?, ?)');
            foreach ($newPaths as $path) {
                $stmt->execute($path);
            }
            // Следующая итерация - находим детей, которые ещё не обработаны
            $placeholders = implode(',', array_fill(0, count($processed), '?'));
            $parents = $pdo->prepare("SELECT id, parent_id FROM source WHERE parent_id IN ($placeholders) AND id NOT IN ($placeholders)");
            $parents->execute(array_merge($processed, $processed));
            $parents = $parents->fetchAll();
        }
        $pdo->commit();
    } catch (Exception $e) {
        $pdo->rollBack();
        throw $e;
    }
}

Этот код обрабатывает дерево уровня за уровнем, избегая рекурсии. На практике для больших деревьев лучше использовать рекурсивное CTE внутри БД.

Пример 3: Bulk-операции с closure table (добавление поддерева)

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

Пример
// $newNodes = [['name'=>'A', 'slug'=>'a'], ['name'=>'B', 'slug'=>'b']];
$parentId = 5;
$pdo->beginTransaction();
try {
    $stmt = $pdo->prepare('INSERT INTO categories (name, slug) VALUES (?, ?)');
    $ids = [];
    foreach ($newNodes as $node) {
        $stmt->execute([$node['name'], $node['slug']]);
        $ids[] = (int)$pdo->lastInsertId();
    }
    // Вставляем самоссылки
    $pdo->prepare('INSERT INTO categories_closure (ancestor_id, descendant_id, depth) VALUES (?, ?, 0)')
       ->execute(array_map(function($id) { return [$id, $id]; }, $ids));
    
    // Копируем пути от предков родителя для каждого нового ID
    $stmt = $pdo->prepare('
        INSERT INTO categories_closure (ancestor_id, descendant_id, depth)
        SELECT ancestor_id, ?, depth + 1
        FROM categories_closure
        WHERE descendant_id = ?
    ');
    foreach ($ids as $id) {
        $stmt->execute([$id, $parentId]);
    }
    $pdo->commit();
} catch (Exception $e) {
    $pdo->rollBack();
}

Пример 4: Получение хлебных крошек с помощью nested sets (альтернативный вариант)

В nested sets для получения цепочки предков можно использовать условие между lft и rgt:

Пример
-- Получить все категории, предков для категории с id=42
SELECT parent.id, parent.name
FROM categories_ns AS node
JOIN categories_ns AS parent 
   ON parent.lft <= node.lft AND parent.rgt >= node.rgt
WHERE node.id = 42
ORDER BY parent.lft;

Результат:

+------+--------------+
| id   | name         |
+------+--------------+
| 1    | Корень       |
| 5    | Электроника  |
| 12   | Телефоны     |
| 42   | Смартфоны    |
+------+--------------+

Пример 5: Проблема избыточности closure table и её решение

При большом количестве категорий (сотни тысяч) таблица closure может занимать гигабайты. Один из способов уменьшить объём - хранить только пути до определённой глубины (например, до 4), а более глубокие уровни получать рекурсивно через adjacency list.

Пример
-- Гибридная схема: closure только для корня и первого уровня
CREATE TABLE categories_hybrid (
    id INT PRIMARY KEY,
    name VARCHAR(255),
    parent_id INT
);

CREATE TABLE categories_closure_limited (
    ancestor_id INT,
    descendant_id INT,
    depth TINYINT,
    PRIMARY KEY (ancestor_id, descendant_id)
);
-- Заполняем только пути depth <= 3

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

- Catalogue php cat (категория каталога php)

Категория каталога PHP - comments

En
Catalogue php cat (php)