Работа с набором меток в PHP: подходы и реализация

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

В управлении контентом часто требуется работа с тегами (метками). Ниже рассматриваются различные способы хранения и обработки списка тегов в PHP. Основное внимание уделяется эффективной реализации с использованием реляционной базы данных.

Подходы к работе со списком тегов

Наиболее эффективное решение для управления тегами - хранение в отдельной таблице с отношением многие ко многим. Это позволяет выполнять сложные запросы, избегать дублирования данных и обеспечивать целостность.

Как реализовать нормализованное хранение тегов с помощью PDO?

Создаются две таблицы: tags (id, name) и content_tags (content_id, tag_id). Пример кода для MySQL:


$sql = 'CREATE TABLE tags (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) UNIQUE NOT NULL
)';
$pdo->exec($sql);

$sql = 'CREATE TABLE content_tags (
    content_id INT NOT NULL,
    tag_id INT NOT NULL,
    PRIMARY KEY (content_id, tag_id),
    FOREIGN KEY (tag_id) REFERENCES tags(id)
)';
$pdo->exec($sql);
  

Процедура добавления тега к контенту включает проверку существования тега и вставку связи:


function addTagToContent($pdo, $contentId, $tagName) {
    // Проверяется существование тега
    $stmt = $pdo->prepare('SELECT id FROM tags WHERE name = ?');
    $stmt->execute([$tagName]);
    $row = $stmt->fetch();
    if ($row) {
        $tagId = $row['id'];
    } else {
        $stmt = $pdo->prepare('INSERT INTO tags (name) VALUES (?)');
        $stmt->execute([$tagName]);
        $tagId = $pdo->lastInsertId();
    }
    // Связь устанавливается
    $stmt = $pdo->prepare('INSERT IGNORE INTO content_tags (content_id, tag_id) VALUES (?, ?)');
    $stmt->execute([$contentId, $tagId]);
}
  
Типичные ошибки: забывается обработка дубликата тега (рекомендуется INSERT IGNORE или предварительная проверка). Также игнорируются внешние ключи, что приводит к сиротским записям. Желательно использование транзакций.

Как хранить теги в виде строки с разделителями?

Самый простой способ заключается в объединении тегов через запятую. Подходит для небольших проектов без сложного поиска.


$tags = ['php', 'контент', 'управление'];
$tagsStr = implode(',', $tags);
// Вывод
$tagsArray = explode(',', $tagsStr);
foreach ($tagsArray as $tag) {
    echo htmlspecialchars(trim($tag)) . ' ';
}
  
Проблемы: поиск контента по тегу требует LIKE %tag%, что медленно. Невозможно переименовать тег без обновления всех строк. Дублирование данных. Не рекомендуется для больших объемов.

Как использовать JSON для хранения массива тегов?

Современные СУБД (MySQL 5.7+, PostgreSQL) поддерживают тип JSON, что позволяет хранить структурированные данные в одном поле.


$tags = ['php', 'json', 'теги'];
$tagsJson = json_encode($tags);
// Извлечение
$tagsArray = json_decode($tagsJson, true);
foreach ($tagsArray as $tag) {
    echo htmlspecialchars($tag) . ' ';
}
  

В MySQL возможен поиск по JSON с помощью JSON_CONTAINS.


$stmt = $pdo->prepare('SELECT * FROM content WHERE JSON_CONTAINS(tags, ?)');
$stmt->execute(['"php"']);
  
Недостатки: обновление одного тега требует перезаписи всего поля. Индексация для полноценного поиска затруднена (только функциональные индексы). Подходит для статичного набора тегов.

Как создать облако тегов с помощью библиотеки?

Библиотеки, например php-tag-cloud, упрощают генерацию облака тегов с различными размерами.


use PhpTagCloud\TagCloud;

$tags = [
    'PHP' => 50,
    'MySQL' => 30,
    'JavaScript' => 20,
];
$cloud = new TagCloud($tags);
echo $cloud->render();
  
Ошибки: зависимость от внешней библиотеки, необходимость данных о частоте. Библиотека может быть неподдерживаемой.

Как организовать древовидную структуру тегов?

Для категорий с подкатегориями применяется таблица с полем parent_id.


$sql = 'CREATE TABLE tags (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    parent_id INT DEFAULT NULL,
    FOREIGN KEY (parent_id) REFERENCES tags(id)
)';
$pdo->exec($sql);
// Выборка потомков
function getChildren($pdo, $parentId) {
    $stmt = $pdo->prepare('SELECT * FROM tags WHERE parent_id = ?');
    $stmt->execute([$parentId]);
    return $stmt->fetchAll();
}
  
Сложности: рекурсивные запросы, проблемы производительности при большой вложенности. Для оптимизации используются материализованные пути или вложенные множества.

Расширенные примеры работы с тегами

Ниже приведены примеры, охватывающие класс для управления тегами, поиск по нескольким тегам, автодополнение и импорт из CSV.

Пример

// Пример 1. Класс TagManager для нормализованного хранения
class TagManager {
    private PDO $pdo;

    public function __construct(PDO $pdo) {
        $this->pdo = $pdo;
    }

    public function addTagsToContent(int $contentId, array $tagNames): void {
        $this->pdo->beginTransaction();
        try {
            foreach ($tagNames as $name) {
                $name = trim($name);
                if ($name === '') continue;
                // Получение или создание тега
                $stmt = $this->pdo->prepare('SELECT id FROM tags WHERE name = ?');
                $stmt->execute([$name]);
                $row = $stmt->fetch();
                if (!$row) {
                    $stmt = $this->pdo->prepare('INSERT INTO tags (name) VALUES (?)');
                    $stmt->execute([$name]);
                    $tagId = (int)$this->pdo->lastInsertId();
                } else {
                    $tagId = (int)$row['id'];
                }
                // Установка связи
                $stmt = $this->pdo->prepare('INSERT IGNORE INTO content_tags (content_id, tag_id) VALUES (?, ?)');
                $stmt->execute([$contentId, $tagId]);
            }
            $this->pdo->commit();
        } catch (Exception $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }

    public function getTagsForContent(int $contentId): array {
        $stmt = $this->pdo->prepare('SELECT t.name FROM tags t JOIN content_tags ct ON t.id = ct.tag_id WHERE ct.content_id = ?');
        $stmt->execute([$contentId]);
        return $stmt->fetchAll(PDO::FETCH_COLUMN);
    }

    public function renderTagCloud(): string {
        $stmt = $this->pdo->query('SELECT t.name, COUNT(ct.content_id) as cnt FROM tags t JOIN content_tags ct ON t.id = ct.tag_id GROUP BY t.id ORDER BY cnt DESC');
        $tags = $stmt->fetchAll();
        if (empty($tags)) return '';
        $min = min(array_column($tags, 'cnt'));
        $max = max(array_column($tags, 'cnt'));
        $html = '';
        foreach ($tags as $tag) {
            $size = 1 + (($tag['cnt'] - $min) / ($max - $min)) * 4; // от 1 до 5
            $html .= '' . htmlspecialchars($tag['name']) . ' ';
        }
        return $html;
    }
}
// Пример использования класса
$manager = new TagManager($pdo);
$manager->addTagsToContent(1, ['PHP', 'MySQL', 'Web']);
$tags = $manager->getTagsForContent(1);
// $tags = ['PHP', 'MySQL', 'Web']
echo $manager->renderTagCloud();
// Выводит HTML с тегами разного размера в зависимости от числа использований
Пример

// Пример 2. Поиск контента по списку тегов (AND/OR)
function searchByTags(PDO $pdo, array $tags, string $mode = 'AND'): array {
    $placeholders = implode(',', array_fill(0, count($tags), '?'));
    if ($mode === 'AND') {
        $sql = 'SELECT c.* FROM content c
                JOIN content_tags ct ON c.id = ct.content_id
                JOIN tags t ON ct.tag_id = t.id
                WHERE t.name IN (' . $placeholders . ')
                GROUP BY c.id
                HAVING COUNT(DISTINCT t.id) = ?';
        $params = array_merge($tags, [count($tags)]);
    } else {
        $sql = 'SELECT DISTINCT c.* FROM content c
                JOIN content_tags ct ON c.id = ct.content_id
                JOIN tags t ON ct.tag_id = t.id
                WHERE t.name IN (' . $placeholders . ')';
        $params = $tags;
    }
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll();
}
// Вызов
$resultsAnd = searchByTags($pdo, ['PHP', 'база данных'], 'AND');
$resultsOr = searchByTags($pdo, ['PHP', 'база данных'], 'OR');
Пример

// Пример 3. Автодополнение тегов (AJAX endpoint)
// Файл ajax_tags.php
<?
header('Content-Type: application/json');
require 'db.php';
$q = $_GET['q'] ?? '';
if (strlen($q) < 1) {
    echo json_encode([]);
    exit;
}
$stmt = $pdo->prepare('SELECT name FROM tags WHERE name LIKE ? LIMIT 10');
$stmt->execute([$q . '%']);
$suggestions = $stmt->fetchAll(PDO::FETCH_COLUMN);
echo json_encode($suggestions);
?>
// Запрос: GET /ajax_tags.php?q=ph
// Ответ: ["php","phpmyadmin","photoshop"]
Пример

// Пример 4. Импорт тегов из CSV
function importTagsFromCSV(PDO $pdo, string $csvFile): void {
    $handle = fopen($csvFile, 'r');
    $pdo->beginTransaction();
    try {
        while (($row = fgetcsv($handle)) !== false) {
            $name = trim($row[0] ?? '');
            if ($name === '') continue;
            $stmt = $pdo->prepare('INSERT IGNORE INTO tags (name) VALUES (?)');
            $stmt->execute([$name]);
        }
        $pdo->commit();
        fclose($handle);
    } catch (Exception $e) {
        $pdo->rollBack();
        fclose($handle);
        throw $e;
    }
}
// CSV файл (tags.csv):
// php,MySQL,JavaScript
// После импорта в таблице tags появились записи (дубликаты игнорируются)
- Content pages php page (страница контента на php)
- Tags php tag list (список тегов в php)
- Index php categories (категории контента)
- View product php id (страница товара на php)

Список тегов в PHP - comments

En
Tags php tag list (php)