Теггирование различных элементов очень часто встречается в разработке сайтов. Сами теги реализуются просто — это отдельная таблица с названиями тегов и таблица связки многие-ко-многим между тегами и какими-то сущностями. Однако реализация фильтрации по тегам — не такая уж простая задача. Фильтровать сущности по тегам как правило приходится одним из следующих вариантов:
- выбрать все элементы без тегов
- выбрать элементы, имеющие все указанные теги (и возможно, другие теги)
- выбрать элементы, имеющие один или несколько из указанных тегов (и возможно, другие теги)
Рассмотрим каждый из этих случаев и приведем пример конкретной реализации фильтрации на 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.