Реализация категорий каталога на 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, когда требуется баланс между производительностью выборки и размером данных.