nystudio107 /
craft-similar
| 1 | <?php |
||
| 2 | /** |
||
| 3 | * Similar plugin for Craft CMS 3.x |
||
| 4 | * |
||
| 5 | * Similar for Craft lets you find elements, Entries, Categories, Commerce |
||
| 6 | * Products, etc, that are similar, based on... other related elements. |
||
| 7 | * |
||
| 8 | * @link https://nystudio107.com/ |
||
| 9 | * @copyright Copyright (c) 2018 nystudio107.com |
||
| 10 | */ |
||
| 11 | |||
| 12 | namespace nystudio107\similar\services; |
||
| 13 | |||
| 14 | use Craft; |
||
| 15 | use craft\base\Component; |
||
| 16 | use craft\base\Element; |
||
| 17 | use craft\base\ElementInterface; |
||
| 18 | use craft\db\Table; |
||
| 19 | use craft\elements\db\ElementQuery; |
||
| 20 | use craft\elements\db\ElementQueryInterface; |
||
| 21 | use craft\elements\db\EntryQuery; |
||
| 22 | use craft\elements\db\OrderByPlaceholderExpression; |
||
| 23 | use craft\events\CancelableEvent; |
||
| 24 | use yii\base\Exception; |
||
| 25 | use function is_array; |
||
| 26 | use function is_object; |
||
| 27 | |||
| 28 | /** |
||
| 29 | * @author nystudio107.com |
||
| 30 | * @package Similar |
||
| 31 | * @since 1.0.0 |
||
| 32 | */ |
||
| 33 | class Similar extends Component |
||
| 34 | { |
||
| 35 | // Public Properties |
||
| 36 | // ========================================================================= |
||
| 37 | |||
| 38 | /** |
||
| 39 | * @var string|array The previous order in the query |
||
| 40 | */ |
||
| 41 | public string|array $preOrder = []; |
||
| 42 | |||
| 43 | /** |
||
| 44 | * @var ?int |
||
| 45 | */ |
||
| 46 | public ?int $limit = null; |
||
| 47 | |||
| 48 | /** |
||
| 49 | * @var Element[] |
||
| 50 | */ |
||
| 51 | public array $targetElements = []; |
||
| 52 | |||
| 53 | // Public Methods |
||
| 54 | // ========================================================================= |
||
| 55 | |||
| 56 | /** |
||
| 57 | * @param array $data |
||
| 58 | * |
||
| 59 | * @return array|ElementInterface |
||
| 60 | * @throws Exception |
||
| 61 | */ |
||
| 62 | public function find(array $data): array|ElementInterface |
||
| 63 | { |
||
| 64 | if (!isset($data['element'])) { |
||
| 65 | throw new Exception('Required parameter `element` was not supplied to `craft.similar.find`.'); |
||
| 66 | } |
||
| 67 | |||
| 68 | if (!isset($data['context'])) { |
||
| 69 | throw new Exception('Required parameter `context` was not supplied to `craft.similar.find`.'); |
||
| 70 | } |
||
| 71 | |||
| 72 | /** @var class-string|Element $element */ |
||
| 73 | $element = $data['element']; |
||
| 74 | $context = $data['context']; |
||
| 75 | $criteria = $data['criteria'] ?? []; |
||
| 76 | |||
| 77 | if (is_object($criteria)) { |
||
| 78 | /** @var ElementQueryInterface $criteria */ |
||
| 79 | $criteria = $criteria->toArray([], [], false); |
||
| 80 | } |
||
| 81 | |||
| 82 | // Get an ElementQuery for this Element |
||
| 83 | $elementClass = is_object($element) ? $element::class : $element; |
||
| 84 | /** @var EntryQuery $query */ |
||
| 85 | $query = $this->getElementQuery($elementClass, $criteria); |
||
| 86 | |||
| 87 | // Stash any orderBy directives from the $query for our anonymous function |
||
| 88 | $this->preOrder = $query->orderBy ?? []; |
||
| 89 | $this->limit = $query->limit; |
||
|
0 ignored issues
–
show
|
|||
| 90 | // Extract the $tagIds from the $context |
||
| 91 | if (is_array($context)) { |
||
| 92 | $tagIds = $context; |
||
| 93 | } else { |
||
| 94 | /** @var ElementQueryInterface $context */ |
||
| 95 | $tagIds = $context->ids(); |
||
| 96 | } |
||
| 97 | |||
| 98 | $this->targetElements = $tagIds; |
||
| 99 | |||
| 100 | // We need to modify the actual craft\db\Query after the ElementQuery has been prepared |
||
| 101 | $query->on(ElementQuery::EVENT_AFTER_PREPARE, fn(CancelableEvent $event) => $this->eventAfterPrepareHandler($event)); |
||
| 102 | // Return the data as an array, and only fetch the `id` and `siteId` |
||
| 103 | $query->asArray(true); |
||
| 104 | $query->select(['elements.id', 'elements_sites.siteId']); |
||
| 105 | $query->andWhere(['not', ['elements.id' => $element->getId()]]); |
||
| 106 | |||
| 107 | // Unless site criteria is provided, force the element's site. |
||
| 108 | if (empty($criteria['siteId']) && empty($criteria['site'])) { |
||
| 109 | $query->andWhere(['elements_sites.siteId' => $element->siteId]); |
||
| 110 | } |
||
| 111 | |||
| 112 | $query->andWhere(['in', 'relations.targetId', $tagIds]); |
||
| 113 | $query->leftJoin(['relations' => Table::RELATIONS], '[[elements.id]] = [[relations.sourceId]]'); |
||
| 114 | |||
| 115 | $results = $query->all(); |
||
| 116 | |||
| 117 | // Fetch the elements based on the returned `id` and `siteId` |
||
| 118 | $queryConditions = []; |
||
| 119 | $similarCounts = []; |
||
| 120 | |||
| 121 | // Build the query conditions for a new element query. |
||
| 122 | // The reason we have to do it in two steps is because the `count` property is added by a behavior after element creation |
||
| 123 | // So if we just try to tack that on in the original query, it will throw an error on element creation |
||
| 124 | foreach ($results as $config) { |
||
| 125 | $siteId = $config['siteId']; |
||
| 126 | $elementId = $config['id']; |
||
| 127 | |||
| 128 | if ($elementId && $siteId) { |
||
| 129 | if (empty($queryConditions[$siteId])) { |
||
| 130 | $queryConditions[$siteId] = []; |
||
| 131 | } |
||
| 132 | |||
| 133 | // Write down elements per site and similar counts |
||
| 134 | $queryConditions[$siteId][] = $elementId; |
||
| 135 | $key = $siteId . '-' . $elementId; |
||
| 136 | $similarCounts[$key] = $config['count']; |
||
| 137 | } |
||
| 138 | } |
||
| 139 | |||
| 140 | if (empty($results)) { |
||
| 141 | return []; |
||
| 142 | } |
||
| 143 | |||
| 144 | // Fetch all the elements in one fell swoop, including any preset eager-loaded conditions |
||
| 145 | $query = $this->getElementQuery($elementClass, $criteria); |
||
| 146 | |||
| 147 | // Make sure we fetch the elements that are similar only |
||
| 148 | $query->on(ElementQuery::EVENT_AFTER_PREPARE, function(CancelableEvent $event) use ($queryConditions): void { |
||
| 149 | /** @var ElementQuery $query */ |
||
| 150 | $query = $event->sender; |
||
| 151 | $first = true; |
||
| 152 | |||
| 153 | foreach ($queryConditions as $siteId => $elementIds) { |
||
| 154 | $method = $first ? 'where' : 'orWhere'; |
||
| 155 | $first = false; |
||
| 156 | $query->subQuery->$method(['and', [ |
||
| 157 | 'elements_sites.siteId' => $siteId, |
||
| 158 | 'elements.id' => $elementIds,], |
||
| 159 | ]); |
||
| 160 | } |
||
| 161 | }); |
||
| 162 | |||
| 163 | $elements = $query->all(); |
||
| 164 | |||
| 165 | /** @var Element $element */ |
||
| 166 | foreach ($elements as $element) { |
||
| 167 | // The `count` property is added dynamically by our CountBehavior behavior |
||
| 168 | $key = $element->siteId . '-' . $element->id; |
||
| 169 | if (!empty($similarCounts[$key])) { |
||
| 170 | /** @phpstan-ignore-next-line */ |
||
| 171 | $element->count = $similarCounts[$key]; |
||
| 172 | } |
||
| 173 | } |
||
| 174 | |||
| 175 | if (empty($criteria['orderBy'])) { |
||
| 176 | /** @phpstan-ignore-next-line */ |
||
| 177 | usort($elements, static fn($a, $b) => $a->count < $b->count ? 1 : ($a->count == $b->count ? 0 : -1)); |
||
| 178 | } |
||
| 179 | |||
| 180 | return $elements; |
||
| 181 | } |
||
| 182 | |||
| 183 | protected function eventAfterPrepareHandler(CancelableEvent $event): void |
||
| 184 | { |
||
| 185 | /** @var ElementQuery $query */ |
||
| 186 | $query = $event->sender; |
||
| 187 | // Add in the `count` param so we know how many were fetched |
||
| 188 | $query->query->addSelect(['COUNT(*) as count']); |
||
| 189 | if (is_array($this->preOrder)) { |
||
| 190 | $this->preOrder = array_filter($this->preOrder, fn($value) => !$value instanceof OrderByPlaceholderExpression); |
||
| 191 | $query->query->orderBy(array_merge([ |
||
| 192 | 'count' => 'DESC', |
||
| 193 | ], $this->preOrder)); |
||
| 194 | } elseif (is_string($this->preOrder)) { |
||
| 195 | $query->query->orderBy('count DESC, ' . str_replace('`', '', $this->preOrder)); |
||
| 196 | } |
||
| 197 | |||
| 198 | $query->query->groupBy(['relations.sourceId', 'elements.id', 'elements_sites.siteId']); |
||
| 199 | |||
| 200 | $query->query->andWhere(['in', 'relations.targetId', $this->targetElements]); |
||
| 201 | |||
| 202 | $query->subQuery->limit(null); // inner limit to null -> fetch all possible entries, sort them afterwards |
||
| 203 | $query->query->limit($this->limit); // or whatever limit is set |
||
| 204 | |||
| 205 | $query->subQuery->groupBy(['elements.id', 'elements_sites.id']); |
||
| 206 | |||
| 207 | if ($query instanceof EntryQuery) { |
||
| 208 | $query->subQuery->addGroupBy(['entries.postDate']); |
||
| 209 | } |
||
| 210 | |||
| 211 | if ($query->withStructure || ($query->withStructure !== false && $query->structureId)) { |
||
| 212 | $query->subQuery->addGroupBy(['structureelements.structureId', 'structureelements.lft']); |
||
| 213 | } |
||
| 214 | |||
| 215 | $event->isValid = true; |
||
| 216 | } |
||
| 217 | |||
| 218 | /** |
||
| 219 | * Returns the element query based on $elementType and $criteria |
||
| 220 | * |
||
| 221 | * @param string|ElementInterface $elementType |
||
| 222 | * @param array $criteria |
||
| 223 | * @return ElementQueryInterface |
||
| 224 | */ |
||
| 225 | protected function getElementQuery(string|ElementInterface $elementType, array $criteria): ElementQueryInterface |
||
| 226 | { |
||
| 227 | /** @var string|ElementInterface $elementType */ |
||
| 228 | $query = $elementType::find(); |
||
| 229 | Craft::configure($query, $criteria); |
||
| 230 | |||
| 231 | return $query; |
||
| 232 | } |
||
| 233 | } |
||
| 234 |
Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.
For example, imagine you have a variable
$accountIdthat can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to theidproperty of an instance of theAccountclass. This class holds a proper account, so the id value must no longer be false.Either this assignment is in error or a type check should be added for that assignment.