Issues (174)

src/services/Similar.php (3 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;
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 */
0 ignored issues
show
Missing short description in doc comment
Loading history...
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 {
149
            /** @var ElementQuery $query */
0 ignored issues
show
The open comment tag must be the only content on the line
Loading history...
The close comment tag must be the only content on the line
Loading history...
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