Работа с категориями товаров в 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  |
+----------------+--------+

Категории товаров в PHP (ID) - comments

En
Categories php products id (php)