Issues (174)

src/services/Similar.php (4 issues)

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;
0 ignored issues
show
The type craft\base\ElementInterface was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
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;
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 {
0 ignored issues
show
The method on() does not exist on craft\elements\db\ElementQueryInterface. Did you maybe mean one()? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

148
        $query->/** @scrutinizer ignore-call */ 
149
                on(ElementQuery::EVENT_AFTER_PREPARE, function(CancelableEvent $event) use ($queryConditions): void {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
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']);
0 ignored issues
show
The method addSelect() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

188
        $query->query->/** @scrutinizer ignore-call */ 
189
                       addSelect(['COUNT(*) as count']);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
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
0 ignored issues
show
The method limit() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

202
        $query->subQuery->/** @scrutinizer ignore-call */ 
203
                          limit(null); // inner limit to null -> fetch all possible entries, sort them afterwards

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
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