Реализация фильтрации по тегам в Yii и не только

Теггирование различных элементов очень часто встречается в разработке сайтов. Сами теги реализуются просто — это отдельная таблица с названиями тегов и таблица связки многие-ко-многим между тегами и какими-то сущностями. Однако реализация фильтрации по тегам — не такая уж простая задача. Фильтровать сущности по тегам как правило приходится одним из следующих вариантов:

  • выбрать все элементы без тегов
  • выбрать элементы, имеющие все указанные теги (и возможно, другие теги)
  • выбрать элементы, имеющие один или несколько из указанных тегов (и возможно, другие теги)

Рассмотрим каждый из этих случаев и приведем пример конкретной реализации фильтрации на Yii.

Выборка элементов без тегов легко реализуется на SQL:

    SELECT DISTINCT i.*
    FROM item AS i
    LEFT JOIN item_tag it ON i.id = it.item_id
    WHERE it.item_id IS NULL;

Условием корректности данного запроса является то, что в таблице item_tag не может быть NULL-значений (ибо такие записи не имеют смысла).
Таким образом, если LEFT JOIN выдает NULL-значение для item_id из связанной таблицы item_tag, то это значит для элемента из таблицы item нет ни одного тега.

Выборка элементов, имеющих хотя бы один из указанных тегов тоже довольно легко выполняется на SQL:

    SELECT DISTINCT i.*
    FROM item AS i
    INNER JOIN item_tag it on i.id = it.item_id
    INNER JOIN tag t on it.tag_id = t.id
    WHERE t.name in (:tagsList);

Здесь:

  • :tagsList — список название тегов

Условием корректности данного запроса является то, что в таблице tag не может быть тегов с одинаковым названием (ибо дублирующиеся теги не имеют смысла).
Список тегов должен быть непустой, иначе нужно использовать первую выборку.

А вот выборка элементов, имеющих все указанные теги на SQL будет посложнее:

    SELECT DISTINCT i.* FROM item as i
    WHERE EXISTS(
        SELECT NULL
        FROM item_tag AS it
        INNER JOIN tag t ON t.id = it.tag_id
        WHERE
            it.item_id = i.id AND
            t.name in (:tagsList)
        GROUP BY it.item_id
        HAVING COUNT(DISTINCT t.name) = :uniqueTagsCount
    );

Здесь:

  • :tagsList — список название тегов
  • :uniqueTagsCount — количество уникальных значений в списке названий тегов

Условия корректности данного запроса — такие же как и у предыдущего.
Подзапрос в данном запросе выбирает одну запись в том случае, если у элемента есть теги со значениями, заданными списком :tagsList и если количество таких тегов соответствует количеству уникальных элементов в списке :tagsList. Если же у элемента нет всех тегов из списка или количество выбранных тегов меньше, чем количество тегов в списке, то не выбирается ни одной записи. И на основании существования данной выборки мы уже выбираем подходящие нам элементы.

 

После изучения теоретической части, можно перейти к практической и привести пример кода реализации именованных групп условий (named scopes) в Yii. Обратите внимание на метод taggedWithAll и на то, как реализована проверка EXISTS для подзапроса и как строится этот подзапрос.

 

<?php

/**
 * @property Tag[] $tags
 */
class Item extends CActiveRecord
{
    const TAGGED_ANY = 'any';
    const TAGGED_ALL = 'all';

    /**
     * @return Item
     */
    public static function model($className=__CLASS__)
    {
        return parent::model($className);
    }

    /**
     * @return string table name
     */
    public function tableName()
    {
        return '{{item}}';
    }

    /**
     * @return mixed relations
     */
    public function relations()
    {
        return array(
            'tags' => array(self::MANY_MANY, 'Tag', '{{item_tag}}(item_id, tag_id)'),
        );
    }

    /**
     * Filters all items that do not have tags. It'll probably not be used directly,
     * but will be used as one of edge cases for taggedWithAny and taggedWithAll scopes
     * 
     * @return Item
     */
    public function notTagged()
    {
        $criteria = new CDbCriteria();
        $criteria->join = 'LEFT JOIN {{item_tag}} it on t.id = it.item_id';
        $criteria->addCondition('it.item_id IS NULL');
        $criteria->distinct = true;
        $this->getDbCriteria()->mergeWith($criteria);
        return $this;
    }

    /**
     * Filters all items that have one or several of provided tags.
     * notTagged scope is used in case if no tags provided.
     * 
     * @param string[] $tags Array of tag names
     * @return Item
     */
    public function taggedWithAny(array $tags)
    {
        if (empty($tags)) {
            $this->notTagged();
        } else {
            $criteria = new CDbCriteria();
            $criteria->join = 'INNER JOIN {{item_tag}} it on t.id = it.item_id INNER JOIN {{tag}} tt on it.tag_id = tt.id';
            $criteria->addInCondition('tt.name', $tags);
            $criteria->distinct = true;
            $this->getDbCriteria()->mergeWith($criteria);
        }
        return $this;
    }

    /**
     * Filters all items that have all of provided tags (may have some other tags also).
     * notTagged scope is used in case if no tags provided.
     * 
     * @param string[] $tags Array of tag names
     * @return Item
     */
    public function taggedWithAll(array $tags)
    {
        if (empty($tags)) {
            $this->notTagged();
        } else {
            // Build subquery first
            $subCriteria = new CDbCriteria(array(
                'select' => 'NULL',
                'join' => 'INNER JOIN {{tag}} tt ON tt.id = it.tag_id',
                'condition' => 'it.item_id = t.id',
                'group' => 'it.item_id',
                'having' => 'COUNT(DISTINCT tt.name) = :tagsCount',
                'params' => array(':tagsCount' => count(array_unique($tags))),
            ));
            $subCriteria->addInCondition('tt.name', $tags);
            $subQuery = $this->getCommandBuilder()->createFindCommand('{{item_tag}}', $subCriteria, 'it');

            $criteria = new CDbCriteria();
            $criteria->condition = "EXISTS (".$subQuery->text.")";
            $criteria->params = $subCriteria->params;
            $criteria->distinct = true;
            $this->getDbCriteria()->mergeWith($criteria);
        }
        return $this;
    }

    /**
     * Filters all items by one of specified algorithms: with all tags or with any of tags provided
     * 
     * @param string[] $tags Array of tag names
     * @param string $allOrAny type of filtering Item::TAGGED_ANY or Item::TAGGED_ALL
     * @return Item
     */
    public function taggedWith(array $tags, $allOrAny) {
        switch ($allOrAny) {
            case self::TAGGED_ANY:
                return $this->taggedWithAny($tags);
                break;
            case self::TAGGED_ALL:
                return $this->taggedWithAll($tags);
                break;
            default:
                return $this->taggedWithAny($tags);
                break;
        }
    }

}

PS: Красивые теги на картинке взяты с cssflow.com.