'name', 'tagsAssoc' => [ 'className' => 'Tags.Tags', 'joinTable' => 'tags_tagged', 'foreignKey' => 'fk_id', 'targetForeignKey' => 'tag_id', 'propertyName' => 'tags', ], 'tagsCounter' => ['counter'], 'taggedAssoc' => [ 'className' => 'Tags.Tagged', 'foreignKey' => 'fk_id' ], 'implementedEvents' => [ 'Model.beforeMarshal' => 'beforeMarshal', 'Model.beforeFind' => 'beforeFind', 'Model.beforeSave' => 'beforeSave', ], 'implementedMethods' => [ 'normalizeTags' => 'normalizeTags', ], 'implementedFinders' => [ 'tagged' => 'findByTag', 'untagged' => 'findUntagged', ], ]; public function initialize(array $config): void { $this->bindAssociations(); $this->attachCounters(); } public function bindAssociations() { $config = $this->getConfig(); $tagsAssoc = $config['tagsAssoc']; $taggedAssoc = $config['taggedAssoc']; $table = $this->_table; $tableAlias = $this->_table->getAlias(); $assocConditions = ['Tagged.fk_model' => $tableAlias]; if (!$table->hasAssociation('Tagged')) { $table->hasMany('Tagged', array_merge( $taggedAssoc, [ 'conditions' => $assocConditions ] )); } if (!$table->hasAssociation('Tags')) { $table->belongsToMany('Tags', array_merge( $tagsAssoc, [ 'through' => $table->Tagged->getTarget(), 'conditions' => $assocConditions, ] )); } if (!$table->Tags->hasAssociation($tableAlias)) { $table->Tags->belongsToMany($tableAlias, array_merge( $tagsAssoc, [ 'className' => get_class($table), ] )); } if (!$table->Tagged->hasAssociation($tableAlias)) { $table->Tagged->belongsTo($tableAlias, [ 'className' => get_class($table), 'foreignKey' => $tagsAssoc['foreignKey'], 'conditions' => $assocConditions, 'joinType' => 'INNER', ]); } if (!$table->Tagged->hasAssociation($tableAlias . 'Tags')) { $table->Tagged->belongsTo($tableAlias . 'Tags', [ 'className' => $tagsAssoc['className'], 'foreignKey' => $tagsAssoc['targetForeignKey'], 'conditions' => $assocConditions, 'joinType' => 'INNER', ]); } } public function attachCounters() { $config = $this->getConfig(); $taggedTable = $this->_table->Tagged; if (!$taggedTable->hasBehavior('CounterCache')) { $taggedTable->addBehavior('CounterCache', [ 'Tags' => $config['tagsCounter'] ]); } } public function beforeMarshal($event, $data, $options) { $property = $this->getConfig('tagsAssoc.propertyName'); $options['accessibleFields'][$property] = true; $options['associated']['Tags']['accessibleFields']['id'] = true; if (isset($data['tags'])) { if (!empty($data['tags'])) { $data[$property] = $this->normalizeTags($data['tags']); } } } public function beforeSave($event, $entity, $options) { if (empty($entity->tags)) { return; } foreach ($entity->tags as $k => $tag) { if (!$tag->isNew()) { continue; } $existingTag = $this->getExistingTag($tag->name); if (!$existingTag) { continue; } $joinData = $tag->_joinData; $tag = $existingTag; $tag->_joinData = $joinData; $entity->tags[$k] = $tag; } } public function normalizeTags($tags) { $result = []; $modelAlias = $this->_table->getAlias(); $common = [ '_joinData' => [ 'fk_model' => $modelAlias ] ]; $tagsTable = $this->_table->Tags; $displayField = $tagsTable->getDisplayField(); $tagIdentifiers = []; foreach ($tags as $tag) { if (empty($tag)) { continue; } if (is_object($tag)) { $result[] = $tag->toArray(); } $tagIdentifier = $this->getTagIdentifier($tag); if (isset($tagIdentifiers[$tagIdentifier])) { continue; } $tagIdentifiers[$tagIdentifier] = true; $existingTag = $this->getExistingTag($tagIdentifier); if ($existingTag) { $result[] = array_merge($common, ['id' => $existingTag->id]); continue; } $result[] = array_merge( $common, [ 'name' => $tagIdentifier, 'colour' => '#924da6' ] ); } return $result; } protected function getTagIdentifier($tag) { if (is_object($tag)) { return $tag->name; } else { return trim($tag); } } protected function getExistingTag($tagName) { $tagsTable = $this->_table->Tags->getTarget(); $query = $tagsTable->find()->where([ 'Tags.name' => $tagName ]) ->select('Tags.id'); return $query->first(); } public function findByTag(Query $query, array $options) { $finderField = $optionsKey = $this->getConfig('finderField'); if (!$finderField) { $finderField = $optionsKey = 'name'; } if (!isset($options[$optionsKey])) { throw new RuntimeException(__('Expected key `{0}` not present in find(\'tagged\') options argument.', $optionsKey)); } $isAndOperator = isset($options['OperatorAND']) ? $options['OperatorAND'] : true; $filterValue = $options[$optionsKey]; if (!$filterValue) { return $query; } $filterValue = $this->dissectArgs($filterValue); if (!empty($filterValue['NOT']) || !empty($filterValue['LIKE'])) { return $this->findByComplexQueryConditions($query, $filterValue, $finderField, $isAndOperator); } $subQuery = $this->buildQuerySnippet($filterValue, $finderField, $isAndOperator); if (is_string($subQuery)) { $query->matching('Tags', function ($q) use ($finderField, $subQuery) { $key = 'Tags.' . $finderField; return $q->where([ $key => $subQuery, ]); }); return $query; } $modelAlias = $this->_table->getAlias(); return $query->where([$modelAlias . '.id IN' => $subQuery]); } public function findUntagged(Query $query, array $options) { $modelAlias = $this->_table->getAlias(); $foreignKey = $this->getConfig('tagsAssoc.foreignKey'); $conditions = ['fk_model' => $modelAlias]; $this->_table->hasOne('NoTags', [ 'className' => $this->getConfig('taggedAssoc.className'), 'foreignKey' => $foreignKey, 'conditions' => $conditions ]); $query = $query->contain(['NoTags'])->where(['NoTags.id IS' => null]); return $query; } protected function dissectArgs($filterValue): array { if (!is_array($filterValue)) { return $filterValue; } $dissected = [ 'AND' => [], 'NOT' => [], 'LIKE' => [], ]; foreach ($filterValue as $value) { if (substr($value, 0, 1) == '!') { $dissected['NOT'][] = substr($value, 1); } else if (strpos($value, '%') != false) { $dissected['LIKE'][] = $value; } else { $dissected['AND'][] = $value; } } if (empty($dissected['NOT']) && empty($dissected['LIKE'])) { return $dissected['AND']; } return $dissected; } protected function buildQuerySnippet($filterValue, string $finderField, bool $OperatorAND=true) { if (!is_array($filterValue)) { return $filterValue; } $key = 'Tags.' . $finderField; $foreignKey = $this->getConfig('tagsAssoc.foreignKey'); $conditions = [ $key . ' IN' => $filterValue, ]; $query = $this->_table->Tagged->find(); if ($OperatorAND) { $query->contain(['Tags']) ->group('Tagged.' . $foreignKey) ->having('COUNT(*) = ' . count($filterValue)) ->select('Tagged.' . $foreignKey) ->where($conditions); } else { $query->contain(['Tags']) ->select('Tagged.' . $foreignKey) ->where($conditions); } return $query; } protected function findByComplexQueryConditions($query, $filterValue, string $finderField, bool $OperatorAND=true) { $key = 'Tags.' . $finderField; $taggedAlias = 'Tagged'; $foreignKey = $this->getConfig('tagsAssoc.foreignKey'); if (!empty($filterValue['AND'])) { $subQuery = $this->buildQuerySnippet($filterValue['AND'], $finderField, $OperatorAND); $modelAlias = $this->_table->getAlias(); $query->where([$modelAlias . '.id IN' => $subQuery]); } if (!empty($filterValue['NOT'])) { $subQuery = $this->buildQuerySnippet($filterValue['NOT'], $finderField, false); $modelAlias = $this->_table->getAlias(); $query->where([$modelAlias . '.id NOT IN' => $subQuery]); } if (!empty($filterValue['LIKE'])) { $conditions = ['OR' => []]; foreach($filterValue['LIKE'] as $likeValue) { $conditions['OR'][] = [ $key . ' LIKE' => $likeValue, ]; } $subQuery = $this->buildQuerySnippet($filterValue['NOT'], $finderField, $OperatorAND); if ($OperatorAND) { $subQuery = $this->_table->Tagged->find() ->contain(['Tags']) ->group('Tagged.' . $foreignKey) ->having('COUNT(*) >= ' . count($filterValue['LIKE'])) ->select('Tagged.' . $foreignKey) ->where($conditions); } else { $subQuery = $this->_table->Tagged->find() ->contain(['Tags']) ->select('Tagged.' . $foreignKey) ->where($conditions); } $modelAlias = $this->_table->getAlias(); $query->where([$modelAlias . '.id IN' => $subQuery]); } return $query; } }