Категории товаров на PHP: выбор структуры данных для магазина
Организация категорий товаров в PHP магазине
Категории товаров являются основой навигации в интернет-магазине. Правильный выбор структуры хранения и алгоритмов работы с категориями влияет на производительность и удобство разработки. Рассмотрим несколько подходов, начиная с самого простого и заканчивая более продвинутыми.
Как реализовать иерархические категории с помощью смежной модели (adjacency list)?
Самый распространённый способ – хранение ссылки на родительскую категорию в поле parent_id. Это простой и интуитивно понятный подход. Таблица categories может выглядеть так:
CREATE TABLE categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id INT DEFAULT NULL,
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE CASCADE
);Shops category php (категории магазинов php)
Для построения дерева категорий обычно используется рекурсивная функция:
function getCategoriesTree($parentId = null) {
$pdo = new PDO('mysql:host=localhost;dbname=shop', 'user', 'pass');
$stmt = $pdo->prepare('SELECT * FROM categories WHERE parent_id ' . ($parentId === null ? 'IS NULL' : '= :parent_id'));
if ($parentId !== null) $stmt->execute([':parent_id' => $parentId]);
else $stmt->execute();
$categories = $stmt->fetchAll();
foreach ($categories as &$cat) {
$cat['children'] = getCategoriesTree($cat['id']);
}
return $categories;
}Category php shop (категория товара в магазине php)
Данный подход прост в реализации, но при большом количестве уровней может вызвать много запросов к базе данных. Проблему можно решить кешированием или построением дерева после одного запроса (например, с помощью сортировки по parent_id и обхода в памяти).
Цели: быстрая разработка, небольшой магазин с неглубокой иерархией.
Ошибки: забывают про индексы на parent_id, что замедляет запросы. Решение – добавить индекс: CREATE INDEX idx_parent ON categories(parent_id);
Типичная ошибка – рекурсия без ограничения глубины может вызвать бесконечный цикл при наличии циклической ссылки. Всегда стоит проверять parent_id на допустимость (например, при сохранении категории убедиться, что новый parent_id не является потомком самой категории).
Как быстро получать всех потомков и предков категории (модель nested set)?
Модель nested set (вложенные множества) добавляет поля lft (left) и rgt (right), которые определяют границы поддерева. Это позволяет одним запросом получить всех потомков категории.
CREATE TABLE categories_ns (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
lft INT NOT NULL,
rgt INT NOT NULL
);
-- Пример вставки корневой категории
INSERT INTO categories_ns (name, lft, rgt) VALUES ('Корень', 1, 2);-- Получить всех потомков категории с lft=2 и rgt=7
SELECT * FROM categories_ns WHERE lft > 2 AND rgt < 7 ORDER BY lft;Недостатки модели – сложность вставки и удаления узлов: приходится пересчитывать lft/rgt у многих записей. Для магазинов, где категории меняются нечасто, это компромисс между производительностью чтения и сложностью записи.
Цели: частые запросы дерева, глубокие иерархии, редко изменяемые данные.
Проблемы: при большом количестве операций вставки/удаления могут возникать блокировки и снижение производительности. Решение – выполнять пересчёт в хранимой процедуре или отложенно.
Распространённая ошибка – неправильный пересчёт границ при добавлении нового узла. Например, вставка дочернего элемента должна обновить lft/rgt у всех родственных узлов, которые находятся правее. Легко допустить опечатку в SQL. Рекомендуется тестировать на небольшом наборе данных.
Как хранить путь категории в виде строки (materialized path)?
В модели materialized path для каждой категории запоминается полный путь от корня в виде строки, например, 1/4/7/. Поле path может быть VARCHAR с максимальной длиной, достаточной для глубины иерархии.
CREATE TABLE categories_mp (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
path VARCHAR(500) NOT NULL DEFAULT ''
);
-- Пример вставки
INSERT INTO categories_mp (name, path) VALUES ('Электроника', '1/');
INSERT INTO categories_mp (name, path) VALUES ('Телефоны', '1/2/');
INSERT INTO categories_mp (name, path) VALUES ('Смартфоны', '1/2/3/');-- Получить всех потомков категории с path='1/2/'
SELECT * FROM categories_mp WHERE path LIKE '1/2/%' ORDER BY path;Преимущество – простота запросов на чтение поддерева (like). Недостаток – изменение пути (например, перемещение категории) требует UPDATE всех потомков. Также сложнее получить непосредственных детей (нужно дополнительное условие на количество слешей).
Цели: простая реализация для неглубоких статичных иерархий, быстрый поиск по части пути.
Ошибка – использование LIKE '%...%' с ведущей звездой, что убивает индексы. Всегда нужно искать с фиксированного начала (LIKE '1/2/%'). Индекс на path не поможет, если шаблон начинается с %.
Дополнительные примеры и развёрнутые пояснения
1. Рекурсивная выборка с учётом активности товаров (adjacency list)
function getActiveCategoriesTree($parentId = null) {
global $pdo;
$sql = 'SELECT c.* FROM categories c
JOIN products p ON c.id = p.category_id
WHERE p.active = 1 AND c.parent_id ' . ($parentId === null ? 'IS NULL' : '= ?');
$stmt = $pdo->prepare($sql);
$params = ($parentId === null) ? [] : [$parentId];
$stmt->execute($params);
$result = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$children = getActiveCategoriesTree($row['id']);
if ($children || true) { // показываем категорию, если есть активные товары или потомки
$row['children'] = $children;
$result[] = $row;
}
}
return $result;
}Пример возвращаемого массива:
[
[
'id' => 1,
'name' => 'Электроника',
'parent_id' => null,
'children' => [
[
'id' => 2,
'name' => 'Телефоны',
'parent_id' => 1,
'children' => []
]
]
]
]Пояснение: функция фильтрует категории, в которых есть активные товары. Если категория пуста, но имеет потомков с товарами, она всё равно показывается. Это позволяет избежать пустых разделов.
2. Построение выпадающего списка с вложенными уровнями (HTML select)
function buildOptions($categories, $depth = 0, $excludeId = null) {
$html = '';
foreach ($categories as $cat) {
if ($excludeId && $cat['id'] == $excludeId) continue;
$prefix = str_repeat(' ', $depth);
if ($depth > 0) $prefix .= '- ';
$html .= '<option value="' . $cat['id'] . '">' . $prefix . htmlspecialchars($cat['name']) . '</option>';
if (!empty($cat['children'])) {
$html .= buildOptions($cat['children'], $depth + 1, $excludeId);
}
}
return $html;
}Результат: <option value="1">Электроника</option> <option value="2"> - Телефоны</option> <option value="3"> - - Смартфоны</option>
3. Хлебные крошки (breadcrumbs) с помощью materialized path
function getBreadcrumbs($path, $pdo) {
$ids = explode('/', trim($path, '/'));
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT id, name FROM categories_mp WHERE id IN ($placeholders) ORDER BY path");
$stmt->execute($ids);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}Пример вызова для path='1/2/3/': [ ['id' => 1, 'name' => 'Электроника'], ['id' => 2, 'name' => 'Телефоны'], ['id' => 3, 'name' => 'Смартфоны'] ]
4. Nested set: вставка нового листового узла
function insertNode($pdo, $parentId, $name) {
// Получаем rgt родителя
$stmt = $pdo->prepare('SELECT rgt FROM categories_ns WHERE id = ?');
$stmt->execute([$parentId]);
$parentRgt = $stmt->fetchColumn();
// Сдвигаем все узлы, которые находятся правее места вставки
$pdo->exec("UPDATE categories_ns SET rgt = rgt + 2 WHERE rgt >= $parentRgt");
$pdo->exec("UPDATE categories_ns SET lft = lft + 2 WHERE lft > $parentRgt");
// Вставляем новый узел
$stmt = $pdo->prepare('INSERT INTO categories_ns (name, lft, rgt) VALUES (?, ?, ?)');
$stmt->execute([$name, $parentRgt, $parentRgt + 1]);
}После вставки: lft/rgt соседних узлов корректно увеличены.