Категории контента: выбор архитектуры для управления данными

Раздел: Веб-разработка -> Управление контентом

Категории контента являются ключевым элементом любой системы управления контентом. Они позволяют структурировать материалы, упрощают навигацию и поиск. В данной статье рассматриваются основные подходы к организации категорий в базе данных, их реализация на PHP и SQL, а также типичные проблемы и пути их решения. Весь код ориентирован на работу с MySQL и PDO.

Основной подход: иерархическая модель с использованием вложенных множеств (Nested Sets)

Как организовать быстрое получение всех подкатегорий и пути до корня без рекурсивных запросов?

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

CREATE TABLE categories (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  lft INT NOT NULL,
  rgt INT NOT NULL,
  INDEX(lft, rgt)
) ENGINE=InnoDB;

Начальная вставка корневого элемента:

INSERT INTO categories (name, lft, rgt) VALUES ('Root', 1, 2);

Добавление дочерней категории (под родителем с id=1):

LOCK TABLE categories WRITE;
SELECT @myRight := rgt FROM categories WHERE id = 1;
UPDATE categories SET rgt = rgt + 2 WHERE rgt >= @myRight;
UPDATE categories SET lft = lft + 2 WHERE lft >= @myRight;
INSERT INTO categories (name, lft, rgt) VALUES ('Child', @myRight, @myRight + 1);
UNLOCK TABLES;

Получение всех подкатегорий для корня:

SELECT node.* FROM categories node, categories parent
WHERE node.lft BETWEEN parent.lft AND parent.rgt
AND parent.id = 1
ORDER BY node.lft;

Получение пути к категории (все родители):

SELECT parent.* FROM categories node, categories parent
WHERE node.lft BETWEEN parent.lft AND parent.rgt
AND node.id = ?
ORDER BY parent.lft;

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

  • Блокировки таблицы при вставке/удалении (LOCK TABLES). В production лучше использовать транзакции с SELECT ... FOR UPDATE.
  • Сложность пересчета left/right при перемещениях: требуется перестроение всех значений.
  • Ошибка на 1 (off-by-one) в SQL-выражениях – например, при добавлении нужно смещение на 2, а при удалении на ширину узла.
  • Большие деревья – частые блокировки снижают производительность.

Вариант 1: Adjacency List (простая соседская связь)

Как реализовать категории с минимальными затратами на обновление структуры?

Каждая категория хранит ссылку на родителя через поле parent_id. Добавление и удаление узлов происходит мгновенно, но получение всего дерева требует рекурсии.

CREATE TABLE categories_adj (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  parent_id INT DEFAULT NULL,
  FOREIGN KEY (parent_id) REFERENCES categories_adj(id) ON DELETE CASCADE
);

Пример рекурсивной функции на PHP для построения дерева:

function getTree($parentId = null) {
    global $pdo;
    $stmt = $pdo->prepare('SELECT * FROM categories_adj WHERE parent_id = ?');
    $stmt->execute([$parentId]);
    $children = $stmt->fetchAll();
    $tree = [];
    foreach ($children as $child) {
        $child['children'] = getTree($child['id']);
        $tree[] = $child;
    }
    return $tree;
}

Проблемы:

  • Рекурсивные запросы при большом количестве уровней могут быть очень медленными.
  • Получение всех потомков одной выборкой невозможно без рекурсивных CTE (MySQL 8+).
  • Ошибка циклической ссылки (если не контролировать parent_id).

Вариант 2: Materialized Path (материализованный путь)

Как хранить полный путь для быстрых выборок подкатегорий без рекурсии?

В каждой записи хранится строка пути (например, '1/4/7/'). Тогда все потомки получаются через LIKE '1/4/%'. Добавление узла требует обновления пути.

CREATE TABLE categories_path (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  path VARCHAR(500) NOT NULL
);
INSERT INTO categories_path (name, path) VALUES ('Root', '1/');
INSERT INTO categories_path (name, path) VALUES ('Child', '1/2/');

Получение всех потомков:

SELECT * FROM categories_path WHERE path LIKE '1/%';

Проблемы:

  • Ограничение на длину строки пути (глубина дерева).
  • Обновление пути при перемещении узла требует изменения всех его потомков.
  • Индекс по полю path с LIKE начинается с '%' неэффективен; нужно хранить путь в обратном порядке или использовать другие подходы.

Вариант 3: Closure Table (таблица замыканий)

Как получить максимальную гибкость запросов и избежать рекурсии?

Создается отдельная таблица, хранящая все пары ancestor/descendant с дополнительным полем depth.

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),
  FOREIGN KEY (descendant_id) REFERENCES categories(id)
);
-- Для категории с id=1 и её потомка с id=2:
INSERT INTO categories_closure VALUES (1,1,0), (2,2,0), (1,2,1);

Получение всех потомков:

SELECT c.* FROM categories c
JOIN categories_closure cl ON c.id = cl.descendant_id
WHERE cl.ancestor_id = ? AND cl.depth > 0;

Проблемы:

  • Большой объем данных при глубоком дереве (n^2 записей).
  • Сложность операций вставки и удаления – нужно обновлять множество строк.
  • Необходимость в дополнительных скриптах для поддержки целостности.

Расширенные примеры для подхода Nested Sets.

Пример 1. Функция построения дерева в PHP с вложенными множествами (без рекурсии, используя сортировку по lft):

Пример
function buildTree($pdo) {
    $stmt = $pdo->query('SELECT id, name, lft, rgt FROM categories ORDER BY lft');
    $rows = $stmt->fetchAll();
    $tree = [];
    $stack = [];
    foreach ($rows as $row) {
        $row['children'] = [];
        $depth = count($stack);
        while (!empty($stack) && $stack[count($stack)-1]['rgt'] < $row['lft']) {
            array_pop($stack);
        }
        if (!empty($stack)) {
            $stack[count($stack)-1]['children'][] = &$row;
        } else {
            $tree[] = &$row;
        }
        $stack[] = &$row;
    }
    return $tree;
}
$tree = buildTree($pdo);
// Вывод в виде JSON
header('Content-Type: application/json');
echo json_encode($tree, JSON_PRETTY_PRINT);
[
  {
    "id": 1,
    "name": "Root",
    "lft": 1,
    "rgt": 20,
    "children": [
      {
        "id": 2,
        "name": "Child A",
        "lft": 2,
        "rgt": 9,
        "children": [...]
      },
      ...
    ]
  }
]

Пример 2. Перемещение узла в другое место (внутри одного уровня):

Пример
function moveNode($pdo, $nodeId, $newParentId) {
    // Получаем данные узла и нового родителя
    $stmt = $pdo->prepare('SELECT lft, rgt, (rgt - lft + 1) AS width FROM categories WHERE id = ?');
    $stmt->execute([$nodeId]);
    $node = $stmt->fetch();
    $stmt = $pdo->prepare('SELECT rgt FROM categories WHERE id = ?');
    $stmt->execute([$newParentId]);
    $parent = $stmt->fetch();
    $pdo->beginTransaction();
    // Удаляем узел из старой позиции (все значения временно отрицательны)
    $pdo->exec("UPDATE categories SET lft = -lft, rgt = -rgt WHERE lft >= {$node['lft']} AND rgt <= {$node['rgt']}");
    // Сдвигаем оставшиеся узлы
    $pdo->exec("UPDATE categories SET lft = lft - {$node['width']} WHERE lft > {$node['rgt']}");
    $pdo->exec("UPDATE categories SET rgt = rgt - {$node['width']} WHERE rgt > {$node['rgt']}");
    // Сдвигаем нового родителя и соседей, чтобы освободить место
    $pdo->exec("UPDATE categories SET lft = lft + {$node['width']} WHERE lft > {$parent['rgt']}");
    $pdo->exec("UPDATE categories SET rgt = rgt + {$node['width']} WHERE rgt >= {$parent['rgt']}");
    // Восстанавливаем узел с новыми значениями
    $newLft = $parent['rgt'] + 1;
    $pdo->exec("UPDATE categories SET lft = -lft + ($newLft - {$node['lft']}), rgt = -rgt + ($newLft - {$node['lft']}) WHERE lft < 0");
    $pdo->commit();
}

Примечание:

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

Пример 3. Удаление узла вместе с поддеревом:

Пример
function deleteSubtree($pdo, $nodeId) {
    $stmt = $pdo->prepare('SELECT lft, rgt, (rgt - lft + 1) AS width FROM categories WHERE id = ?');
    $stmt->execute([$nodeId]);
    $node = $stmt->fetch();
    $pdo->beginTransaction();
    $pdo->exec("DELETE FROM categories WHERE lft >= {$node['lft']} AND rgt <= {$node['rgt']}");
    $pdo->exec("UPDATE categories SET lft = lft - {$node['width']} WHERE lft > {$node['rgt']}");
    $pdo->exec("UPDATE categories SET rgt = rgt - {$node['width']} WHERE rgt > {$node['rgt']}");
    $pdo->commit();
}

Результат выполнения:

-- До удаления:
 id | name  | lft | rgt
  1 | Root  |  1  | 20
  2 | Child |  2  |  9
  3 | Sub   |  3  |  4
-- После удаления узла id=2:
 id | name  | lft | rgt
  1 | Root  |  1  | 12

Пример 4. Для подхода Closure Table – получение всех предков (ancestors) заданного узла:

Пример
SELECT a.name AS ancestor, cl.depth
FROM categories_closure cl
JOIN categories a ON cl.ancestor_id = a.id
WHERE cl.descendant_id = 5
ORDER BY cl.depth DESC;
+----------+-------+
| ancestor | depth |
+----------+-------+
| Root     |     2 |
| Parent   |     1 |
| Self     |     0 |
+----------+-------+
- Index php categories (категории контента)
- View product php id (страница товара на php)
- Content pages php page (страница контента на php)
- Tags php tag list (список тегов в php)

Категории контента - comments

En
Index php categories (php)