Issues (174)

src/services/Similar.php (1 issue)

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
Documentation Bug introduced by
It seems like $query->limit can also be of type yii\db\ExpressionInterface. However, the property $limit is declared as type integer|null. Maybe add an additional type check?

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 $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. 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.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
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