Организация категорий для интернет-магазина: PHP-решения с примерами
Руководство по реализации категорий товаров на PHP
Наиболее эффективное решение: Closure Table (таблица замыканий)
Этот подход использует отдельную таблицу для хранения всех путей между узлами дерева категорий. Он обеспечивает высокую производительность как для чтения (получение подкатегорий, предков, уровня вложенности), так и для записи (добавление, удаление, перемещение узлов). Closure Table особенно удобен для интернет-магазинов с частыми изменениями структуры категорий.
Пример структуры таблиц:
CREATE TABLE categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL
);
CREATE TABLE category_paths (
ancestor INT NOT NULL,
descendant INT NOT NULL,
depth INT NOT NULL,
PRIMARY KEY (ancestor, descendant),
FOREIGN KEY (ancestor) REFERENCES categories(id),
FOREIGN KEY (descendant) REFERENCES categories(id)
);
Shops category php (категории магазинов php)
Добавление новой категории в корень:
INSERT INTO categories (name) VALUES ('Электроника');
SET @new_id = LAST_INSERT_ID();
INSERT INTO category_paths (ancestor, descendant, depth)
SELECT ancestor, @new_id, depth+1 FROM category_paths
WHERE descendant = @new_id
UNION ALL SELECT @new_id, @new_id, 0;
Category php shop (категория товара в магазине php)
Получение всех подкатегорий для категории с id=1:
SELECT c.* FROM categories c
JOIN category_paths cp ON c.id = cp.descendant
WHERE cp.ancestor = 1;
Типичные ошибки при использовании Closure Table связаны с неправильным обновлением путей при перемещении узла. Например, если переместить подкатегорию в другой раздел, необходимо удалить старые пути и вставить новые, учитывая все потомки. Рекомендуется использовать транзакции.
Проблема: дублирование записей при вставке
При добавлении новой категории важно не забыть включить ссылку самой на себя (ancestor=descendant, depth=0). Пропуск этой записи приведёт к тому, что категория не будет найдена при запросах к самой себе.
Как реализовать категории с помощью Adjacency List (родительская ссылка)?
Это самый простой способ: каждая категория хранит ссылку на своего родителя в столбце parent_id. Добавление и перемещение категорий выполняются легко, но получение всех потомков или предков требует рекурсивных запросов (в MySQL, например, с помощью рекурсивных CTE). Для глубоких деревьев производительность чтения падает.
CREATE TABLE categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255),
parent_id INT NULL,
FOREIGN KEY (parent_id) REFERENCES categories(id)
);
Получение всех дочерних категорий второго уровня:
SELECT * FROM categories WHERE parent_id = 1;
Случаи использования: небольшие магазины с редким изменением структуры и неглубоким деревом (до 3-4 уровней). Проблемы: сложность выборки всего дерева, риск рекурсивных циклов при неправильной вставке.
Ошибка: циклические ссылки
Если при обновлении parent_id случайно установить значение, создающее цикл (например, ребёнок становится родителем своего предка), запросы зацикливаются. Защита: проверять на циклы через триггеры или прикладной код.
Как применить Nested Sets (вложенные множества) для ускорения чтения?
Метод основан на нумерации узлов левым и правым ключами (lft, rgt). Все подкатегории одной категории находятся между lft и rgt родителя. Чтение очень быстрое (один запрос без рекурсий), но вставка и удаление требуют пересчёта ключей у многих узлов, что может быть дорого при частых изменениях.
CREATE TABLE categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255),
lft INT NOT NULL,
rgt INT NOT NULL
);
Получение всех подкатегорий для категории с id=1:
SELECT c2.* FROM categories c1
JOIN categories c2 ON c2.lft BETWEEN c1.lft AND c1.rgt
WHERE c1.id = 1;
Случаи использования: магазины с редко изменяемой структурой (например, статичный каталог), где важнее скорость чтения, чем скорость записи. Проблемы: сложность реализации вставки и удаления, необходимость блокировки таблицы во время операций.
Проблема: неправильный пересчёт ключей
При вставке новой категории нужно увеличить все lft/rgt, которые больше или равны вставляемому значению. Ошибка в порядке обновления приводит к дублированию или пропуску ключей. Рекомендуется выполнять операции в транзакции с явной блокировкой.
Как использовать Path Enumeration (материализованный путь) для простоты?
Каждая категория хранит строку, содержащую путь от корня, например "1/3/5/". Поиск подкатегорий выполняется через LIKE, что может быть неэффективно для больших объёмов. Однако добавление и перемещение требуют обновления путей у всех потомков, что проще, чем в Nested Sets.
CREATE TABLE categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255),
path VARCHAR(255) NOT NULL
);
Получение всех подкатегорий для пути "1/3/":
SELECT * FROM categories WHERE path LIKE '1/3/%';
Случаи использования: простые проекты, где не требуется высокая производительность и дерево неглубокое. Проблемы: длина пути ограничена, LIKE-запросы не используют индексы эффективно, сложность поддержки при перемещении.
Ошибка: несоответствие формата пути
Если в пути хранить без завершающего слеша, LIKE '1/3%' может захватить лишние категории (например, 1/30). Всегда используйте разделитель в конце и начале, и в запросе добавляйте '%' после точного пути.
Расширенные примеры реализации Closure Table
Полная реализация на PHP с PDO и пояснениями
Ниже представлен класс для работы с категориями через Closure Table. Примеры охватывают основные операции.
Подготовка базы данных
-- Создание таблиц (MySQL)
CREATE TABLE categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL
) ENGINE=InnoDB;
CREATE TABLE category_paths (
ancestor INT NOT NULL,
descendant INT NOT NULL,
depth INT NOT NULL,
PRIMARY KEY (ancestor, descendant),
FOREIGN KEY (ancestor) REFERENCES categories(id) ON DELETE CASCADE,
FOREIGN KEY (descendant) REFERENCES categories(id) ON DELETE CASCADE
) ENGINE=InnoDB;
Класс CategoryTree
<?php
class CategoryTree {
private $pdo;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
// Добавление корневой категории
public function addRootCategory(string $name): int {
$this->pdo->beginTransaction();
try {
$stmt = $this->pdo->prepare("INSERT INTO categories (name) VALUES (:name)");
$stmt->execute([':name' => $name]);
$newId = (int)$this->pdo->lastInsertId();
// Путь к самой себе
$stmt = $this->pdo->prepare("INSERT INTO category_paths (ancestor, descendant, depth) VALUES (:id, :id, 0)");
$stmt->execute([':id' => $newId]);
$this->pdo->commit();
return $newId;
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
}
// Добавление дочерней категории под parentId
public function addChildCategory(string $name, int $parentId): int {
$this->pdo->beginTransaction();
try {
// Проверяем, существует ли родитель
$stmt = $this->pdo->prepare("SELECT id FROM categories WHERE id = :id");
$stmt->execute([':id' => $parentId]);
if (!$stmt->fetch()) {
throw new InvalidArgumentException("Parent category not found");
}
$stmt = $this->pdo->prepare("INSERT INTO categories (name) VALUES (:name)");
$stmt->execute([':name' => $name]);
$newId = (int)$this->pdo->lastInsertId();
// Вставляем пути: для каждого предка родителя + новый узел
$stmt = $this->pdo->prepare("
INSERT INTO category_paths (ancestor, descendant, depth)
SELECT ancestor, :descendant, depth + 1
FROM category_paths
WHERE descendant = :parent
");
$stmt->execute([':descendant' => $newId, ':parent' => $parentId]);
// Путь к самому себе
$stmt = $this->pdo->prepare("INSERT INTO category_paths (ancestor, descendant, depth) VALUES (:id, :id, 0)");
$stmt->execute([':id' => $newId]);
$this->pdo->commit();
return $newId;
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
}
// Получение всех потомков (включая саму категорию) для заданной categoryId
public function getDescendants(int $categoryId): array {
$stmt = $this->pdo->prepare("
SELECT c.id, c.name, cp.depth
FROM categories c
JOIN category_paths cp ON c.id = cp.descendant
WHERE cp.ancestor = :ancestor
");
$stmt->execute([':ancestor' => $categoryId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// Получение всех предков (включая саму категорию) для заданной categoryId
public function getAncestors(int $categoryId): array {
$stmt = $this->pdo->prepare("
SELECT c.id, c.name, cp.depth
FROM categories c
JOIN category_paths cp ON c.id = cp.ancestor
WHERE cp.descendant = :descendant
");
$stmt->execute([':descendant' => $categoryId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// Перемещение узла с поддеревом под новый родительский узел
public function moveSubtree(int $nodeId, int $newParentId): void {
$this->pdo->beginTransaction();
try {
// Проверяем, что nodeId не является предком newParentId (цикл)
$stmt = $this->pdo->prepare("
SELECT COUNT(*) FROM category_paths
WHERE ancestor = :node AND descendant = :parent
");
$stmt->execute([':node' => $nodeId, ':parent' => $newParentId]);
if ($stmt->fetchColumn() > 0) {
throw new InvalidArgumentException("Cannot move category inside its own subtree");
}
// Удаляем старые пути, которые проходят через nodeId как потомка (кроме самого себя)
// Но оставляем пути внутри поддерева (они потом будут вставлены с новым ancestor)
// Полная реализация сложнее; для краткости - удаляем все пути, где descendant является узлом поддерева,
// а ancestor не является частью поддерева? Рекомендуется использовать готовую библиотеку.
// Демонстрация принципа:
// 1. Удалить все пути, где descendant принадлежит поддереву nodeId, а ancestor снаружи.
// 2. Для каждого потомка в поддереве добавить пути от newParentId и его предков.
// Для простоты в примере используем готовый алгоритм из документации.
// Пропустим детальную реализацию, чтобы не загромождать.
$this->pdo->commit();
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
}
}
Результат выполнения
// Пример использования
$tree = new CategoryTree($pdo);
$electronicsId = $tree->addRootCategory('Электроника');
$phonesId = $tree->addChildCategory('Телефоны', $electronicsId);
$tabletsId = $tree->addChildCategory('Планшеты', $electronicsId);
$appleId = $tree->addChildCategory('Apple', $phonesId);
descendants = $tree->getDescendants($electronicsId);
print_r($descendants);
/*
Array
(
[0] => Array ( [id] => 1 [name] => Электроника [depth] => 0 )
[1] => Array ( [id] => 2 [name] => Телефоны [depth] => 1 )
[2] => Array ( [id] => 4 [name] => Apple [depth] => 2 )
[3] => Array ( [id] => 3 [name] => Планшеты [depth] => 1 )
)
*/
Примечание
Метод moveSubtree требует аккуратной реализации с использованием временных таблиц или рекурсивных запросов для корректного обновления всех путей. В больших проектах рекомендуется использовать специализированные пакеты, например, etrepat/baum или laravel-nestedset.