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