Работа с категориями товаров в PHP: от таблиц до иерархий
Организация категорий товаров в PHP
Категории товаров в веб-приложении обычно идентифицируются уникальным идентификатором (ID). Правильный выбор структуры хранения и методов работы с категориями влияет на производительность запросов, удобство сопровождения и масштабирование проекта. В этой статье рассматриваются основные подходы к реализации категорий в PHP с примерами кода и разбором типичных проблем.
Базовое решение: таблица categories с parent_id (Adjacency List)
Как реализовать простую неограниченную вложенность категорий и связывать их с товарами?
Это классическая реляционная модель. Каждая категория хранится в таблице categories и может ссылаться на родительскую категорию через поле parent_id. Товары связываются с категорией через внешний ключ category_id в таблице products.
-- Создание таблицы категорий
CREATE TABLE categories (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id INT UNSIGNED DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE CASCADE
);
-- Создание таблицы товаров
CREATE TABLE products (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
price DECIMAL(10,2) NOT NULL,
category_id INT UNSIGNED DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
);Categories php products id (категории товаров в php (id))
Связь ON DELETE CASCADE для parent_id автоматически удаляет дочерние категории при удалении родителя. Для товаров используется ON DELETE SET NULL, чтобы не терять товары при удалении категории.
// Пример вставки категорий и товаров через PDO
$pdo = new PDO('mysql:host=localhost;dbname=shop', 'user', 'pass', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
// Добавляем корневую категорию
$stmt = $pdo->prepare('INSERT INTO categories (name) VALUES (?)');
$stmt->execute(['Электроника']);
$electronicsId = $pdo->lastInsertId();
// Добавляем подкатегорию
$stmt = $pdo->prepare('INSERT INTO categories (name, parent_id) VALUES (?, ?)');
$stmt->execute(['Телефоны', $electronicsId]);
// Добавляем товар
$stmt = $pdo->prepare('INSERT INTO products (name, price, category_id) VALUES (?, ?, ?)');
$stmt->execute(['Смартфон X', 29999.99, $electronicsId]);
Выборка товаров по категории вместе с подкатегориями – частая задача. Без рекурсивных запросов её сложно решить. Используйте WITH RECURSIVE (MySQL 8+):
WITH RECURSIVE cat_tree AS (
SELECT id FROM categories WHERE id = :categoryId
UNION ALL
SELECT c.id FROM categories c
INNER JOIN cat_tree ct ON ct.id = c.parent_id
)
SELECT p.id, p.name, p.price
FROM products p
JOIN cat_tree ct ON p.category_id = ct.id;
Типичные сложности и их устранение:
- Производительность – рекурсивный запрос при большой вложенности (более 10 уровней) может замедляться. Создайте индексы по полям
parent_idиcategory_id. - Удаление категории – каскадное удаление может привести к потере дочерних категорий. Если нужно переназначать родителя, используйте
ON UPDATE CASCADEили обрабатывайте логикой приложения. - Циклические ссылки – не допускайте, чтобы parent_id указывал на самого себя или образовал кольцо. Проверяйте на уровне приложения или используйте триггер.
Когда использовать: подходит для большинства проектов с умеренной вложенностью и простыми запросами. Легко реализуется и интуитивно понятна.
Вариант: вложенные множества (Nested Sets)
Как выбирать все дочерние категории без рекурсивных запросов?
Метод использует два дополнительных поля lft и rgt, которые задают интервалы для быстрой выборки поддерева.
CREATE TABLE categories_ns (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
lft INT UNSIGNED NOT NULL,
rgt INT UNSIGNED NOT NULL
);
-- Пример вставки корня (lft=1, rgt=2)
INSERT INTO categories_ns (name, lft, rgt) VALUES ('Товары', 1, 2);
-- Добавление дочерней категории требует пересчета lft/rgt у всех последующих узлов
-- (реализация сложнее, чем в adjacency list)
Выборка всех потомков категории с id=5:
SELECT child.id, child.name
FROM categories_ns child
JOIN categories_ns parent ON parent.id = 5
WHERE child.lft BETWEEN parent.lft AND parent.rgt;
-- Без рекурсии, очень быстро
Проблемы:
- Вставка/удаление – требует обновления lft/rgt у многих строк, что приводит к блокировкам при высокой нагрузке.
- Сложность поддержки – легко ошибиться в расчетах интервалов.
Решение: использовать библиотеки (например, etrepat/baum для Laravel) или отказаться от этого метода, если часто изменяется структура.
Когда использовать: когда структура категорий меняется редко (например, один раз на этапе разработки), а чтение поддерева должно быть максимально быстрым (витрины, статические товары).
Вариант: материализованный путь (Materialized Path)
Как хранить полный путь категории в виде строки для упрощения запросов?
В таблицу добавляется поле path, содержащее цепочку идентификаторов, например 1/5/12.
CREATE TABLE categories_path (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
path VARCHAR(255) NOT NULL COMMENT 'путь, напр. 1/5/'
);
-- Вставка корня
INSERT INTO categories_path (name, path) VALUES ('Каталог', '1/');
-- Вставка потомка (id = 5, путь = '1/5/')
INSERT INTO categories_path (name, path) VALUES ('Ноутбуки', '1/5/');
Выборка всех потомков категории с path '1/5/':
SELECT * FROM categories_path WHERE path LIKE '1/5/%';
Проблемы:
- Производительность LIKE – запросы с
LIKE %не могут эффективно использовать индексы, если паттерн начинается с подстановки. Однако здесь паттерн фиксированный слева, поэтому индекс поpathможет работать, но только если используетсяLIKE 'prefix%'. - Изменение родителя – требует перестроения пути у всех потомков.
Когда использовать: для неглубоких и стабильных иерархий (до 3-4 уровней), когда простой строковый путь удобен при сериализации или передаче в URL.
Вариант: таблица замыканий (Closure Table)
Как хранить все связи предок-потомок в отдельной таблице для гибкой выборки?
Создаётся таблица category_closure с полями ancestor_id, descendant_id и depth.
CREATE TABLE categories_closure (
ancestor_id INT UNSIGNED NOT NULL,
descendant_id INT UNSIGNED NOT NULL,
depth INT UNSIGNED 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
);
-- После вставки категории необходимо добавить все связи (включая саму себя)
-- Пример: вставили категорию id=10, у неё parent_id=5
-- Нужно связать 10 с самой собой (depth=0), и 10 как потомок всех предков (5, затем предки 5)...
Выборка всех потомков категории id=5:
SELECT c.id, c.name
FROM categories c
JOIN categories_closure cc ON c.id = cc.descendant_id
WHERE cc.ancestor_id = 5 AND cc.depth > 0;
Сложности:
- Объём данных – при тысячах категорий количество записей может значительно расти (O(N^2) в худшем случае). Для большого числа категорий это может быть проблемой.
- Сложность вставки/удаления – требуется транзакционное добавление всех связей.
Решение: использовать для иерархий, где часто выполняются выборки поддерева и предков (например, построение навигации, хлебных крошек).
Когда использовать: для глубоких и динамичных иерархий с частыми запросами поддеревьев; хорошо сочетается с индексами и не требует рекурсивных CTE.
Заключение:
Выбор подхода зависит от частоты изменений структуры, объема данных и требуемых типов запросов. Adjacency list – самый простой для старта. Nested Sets и Closure Table дают преимущество в скорости чтения, но сложнее в модификации. Materialized Path удобен для экспорта/импорта. Рекомендуется начинать с adjacency list и при необходимости переходить на closure table через хранимые процедуры.
Расширенные примеры работы с категориями
Пример 1. Полный CRUD для категорий с PDO (adjacency list)
Класс для управления категориями с проверками и транзакциями.
class CategoryManager {
private PDO $pdo;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
// Создание категории
public function create(string $name, ?int $parentId = null): int {
if ($parentId !== null) {
$this->ensureCategoryExists($parentId);
}
$stmt = $this->pdo->prepare('INSERT INTO categories (name, parent_id) VALUES (?, ?)');
$stmt->execute([$name, $parentId]);
return (int)$this->pdo->lastInsertId();
}
// Обновление категории
public function update(int $id, string $name, ?int $parentId = null): void {
$this->ensureCategoryExists($id);
if ($parentId !== null) {
if ($this->isSelfOrDescendant($id, $parentId)) {
throw new Exception('Нельзя назначить дочернюю категорию родителем');
}
}
$stmt = $this->pdo->prepare('UPDATE categories SET name=?, parent_id=? WHERE id=?');
$stmt->execute([$name, $parentId, $id]);
}
// Удаление категории (с переносом дочерних на уровень выше)
public function delete(int $id): void {
$this->pdo->beginTransaction();
try {
// Переназначаем parent_id у дочерних категорий
$parent = $this->getParentId($id);
$stmt = $this->pdo->prepare('UPDATE categories SET parent_id=? WHERE parent_id=?');
$stmt->execute([$parent, $id]);
// Удаляем саму категорию
$stmt = $this->pdo->prepare('DELETE FROM categories WHERE id=?');
$stmt->execute([$id]);
$this->pdo->commit();
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
}
// Вспомогательные методы
private function getParentId(int $id): ?int {
$stmt = $this->pdo->prepare('SELECT parent_id FROM categories WHERE id=?');
$stmt->execute([$id]);
return $stmt->fetchColumn() ?: null;
}
private function isSelfOrDescendant(int $id, int $potentialParent): bool {
// Проверка с помощью рекурсивного CTE
$sql = 'WITH RECURSIVE descendants AS (
SELECT id FROM categories WHERE parent_id = ?
UNION ALL
SELECT c.id FROM categories c
INNER JOIN descendants d ON c.parent_id = d.id
)
SELECT 1 FROM descendants WHERE id = ?';
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$id, $potentialParent]);
return (bool)$stmt->fetchColumn();
}
private function ensureCategoryExists(int $id): void {
$stmt = $this->pdo->prepare('SELECT 1 FROM categories WHERE id=?');
$stmt->execute([$id]);
if (!$stmt->fetchColumn()) {
throw new Exception('Категория не найдена');
}
}
}
Результат использования:
$manager = new CategoryManager($pdo);
$rootId = $manager->create('Корневая');
$childId = $manager->create('Дочерняя', $rootId);
$manager->update($childId, 'Изменённая дочерняя', null); // переместить в корень
$manager->delete($rootId); // дочерние станут корневыми
Пример 2. Выборка дерева категорий в один запрос с рекурсивным CTE
Генерация вложенного массива для построения меню.
function getCategoryTree(PDO $pdo): array {
$sql = 'WITH RECURSIVE tree AS (
SELECT id, name, parent_id, 0 AS level
FROM categories
WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id, t.level + 1
FROM categories c
JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree ORDER BY level, name';
$stmt = $pdo->query($sql);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Преобразуем в иерархический массив
$tree = [];
$index = [];
foreach ($rows as $row) {
$node = ['id' => $row['id'], 'name' => $row['name'], 'children' => []];
$index[$row['id']] = &$node;
if ($row['parent_id'] === null) {
$tree[] = &$node;
} else {
$index[$row['parent_id']]['children'][] = &$node;
}
unset($node);
}
return $tree;
}
Пример вывода:
Array
(
[0] => Array
(
[id] => 1
[name] => Электроника
[children] => Array
(
[0] => Array
(
[id] => 2
[name] => Телефоны
[children] => Array()
)
)
)
)
Пример 3. Использование Closure Table для получения всех предков категории
function getAncestors(int $categoryId, PDO $pdo): array {
$sql = 'SELECT a.id, a.name, cc.depth
FROM categories_closure cc
JOIN categories a ON a.id = cc.ancestor_id
WHERE cc.descendant_id = ?
ORDER BY cc.depth DESC';
$stmt = $pdo->prepare($sql);
$stmt->execute([$categoryId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// Пример для категории id=12:
// Массив: [ ['id'=>1,'name'=>'Корень','depth'=>3], ['id'=>5,'name'=>'Подкатегория','depth'=>2], ['id'=>12,'name'=>'Текущая','depth'=>0] ]
Результат: формирование хлебных крошек (breadcrumbs).
Пример 4. Модификация Nested Sets: перемещение поддерева
-- Перемещение категории с id=10 (включая потомков) под родителя id=20
-- В реальной реализации лучше использовать библиотеку, но принцип:
-- 1. Определяем размер поддерева: @size = (rgt - lft + 1)
-- 2. Вставляем временный промежуток под новым родителем
-- 3. Сдвигаем lft/rgt поддерева на новое место
-- 4. Удаляем старый промежуток
Пример кода на PHP (упрощённый):
function moveSubtree(int $nodeId, int $newParentId, PDO $pdo): void {
$pdo->beginTransaction();
try {
// Получаем lft, rgt перемещаемого узла
$stmt = $pdo->prepare('SELECT lft, rgt FROM categories_ns WHERE id=?');
$stmt->execute([$nodeId]);
$node = $stmt->fetch(PDO::FETCH_ASSOC);
$size = $node['rgt'] - $node['lft'] + 1;
// Получаем rgt нового родителя
$stmt = $pdo->prepare('SELECT rgt FROM categories_ns WHERE id=?');
$stmt->execute([$newParentId]);
$parentRgt = $stmt->fetchColumn();
// Освобождаем место под новые узлы (сдвиг вправо)
$pdo->exec("UPDATE categories_ns SET lft = lft + {$size} WHERE lft > {$parentRgt}");
$pdo->exec("UPDATE categories_ns SET rgt = rgt + {$size} WHERE rgt >= {$parentRgt}");
// Сдвигаем само поддерево на новое место
$diff = $parentRgt - $node['lft'] + 1;
$pdo->exec("UPDATE categories_ns SET lft = lft + {$diff}, rgt = rgt + {$diff} WHERE lft BETWEEN {$node['lft']} AND {$node['rgt']}");
// Убираем старый промежуток (сдвиг влево)
$pdo->exec("UPDATE categories_ns SET lft = lft - {$size} WHERE lft > {$node['rgt']}");
$pdo->exec("UPDATE categories_ns SET rgt = rgt - {$size} WHERE rgt > {$node['rgt']}");
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
}
Пример 5. Поиск продуктов через несколько категорий (Materialized Path)
-- Предположим, у товаров есть category_path, копирующий путь категории
SELECT p.name, p.price
FROM products p
WHERE p.category_path LIKE '1/5/%' OR p.category_path LIKE '1/10/%';
Или если путь товара хранит полный путь категории, можно не использовать JOIN.
+----------------+--------+ | name | price | +----------------+--------+ | Ноутбук A | 50000 | | Планшет B | 20000 | +----------------+--------+