Issues (174)

src/services/Similar.php (72 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/
0 ignored issues
show
The tag in position 1 should be the @copyright tag
Loading history...
9
 * @copyright Copyright (c) 2018 nystudio107.com
0 ignored issues
show
@copyright tag must contain a year and the name of the copyright holder
Loading history...
10
 */
0 ignored issues
show
PHP version not specified
Loading history...
Missing @category tag in file comment
Loading history...
Missing @package tag in file comment
Loading history...
Missing @author tag in file comment
Loading history...
Missing @license tag in file comment
Loading history...
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
/**
0 ignored issues
show
Missing short description in doc comment
Loading history...
29
 * @author    nystudio107.com
0 ignored issues
show
The tag in position 1 should be the @package tag
Loading history...
Content of the @author tag must be in the form "Display Name <[email protected]>"
Loading history...
Tag value for @author tag indented incorrectly; expected 2 spaces but found 4
Loading history...
30
 * @package   Similar
0 ignored issues
show
Tag value for @package tag indented incorrectly; expected 1 spaces but found 3
Loading history...
31
 * @since     1.0.0
0 ignored issues
show
The tag in position 3 should be the @author tag
Loading history...
Tag value for @since tag indented incorrectly; expected 3 spaces but found 5
Loading history...
32
 */
0 ignored issues
show
Missing @category tag in class comment
Loading history...
Missing @license tag in class comment
Loading history...
Missing @link tag in class comment
Loading history...
33
class Similar extends Component
34
{
35
    // Public Properties
36
    // =========================================================================
37
38
    /**
0 ignored issues
show
Missing short description in doc comment
Loading history...
39
     * @var string|array The previous order in the query
40
     */
41
    public string|array $preOrder = [];
42
43
    /**
0 ignored issues
show
Missing short description in doc comment
Loading history...
44
     * @var ?int
45
     */
46
    public ?int $limit = null;
47
48
    /**
0 ignored issues
show
Missing short description in doc comment
Loading history...
49
     * @var Element[]
50
     */
51
    public array $targetElements = [];
52
53
    // Public Methods
54
    // =========================================================================
55
56
    /**
0 ignored issues
show
Missing short description in doc comment
Loading history...
57
     * @param array $data
0 ignored issues
show
Missing parameter comment
Loading history...
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 */
0 ignored issues
show
The open comment tag must be the only content on the line
Loading history...
Missing short description in doc comment
Loading history...
The close comment tag must be the only content on the line
Loading history...
73
        $element = $data['element'];
74
        $context = $data['context'];
75
        $criteria = $data['criteria'] ?? [];
76
77
        if (is_object($criteria)) {
78
            /** @var ElementQueryInterface $criteria */
0 ignored issues
show
The open comment tag must be the only content on the line
Loading history...
Missing short description in doc comment
Loading history...
The close comment tag must be the only content on the line
Loading history...
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
The open comment tag must be the only content on the line
Loading history...
Missing short description in doc comment
Loading history...
The close comment tag must be the only content on the line
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;
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 */
0 ignored issues
show
The open comment tag must be the only content on the line
Loading history...
Missing short description in doc comment
Loading history...
The close comment tag must be the only content on the line
Loading history...
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...
The opening parenthesis of a multi-line function call should be the last content on the line.
Loading history...
Expected 1 space after FUNCTION keyword; 0 found
Loading history...
149
            /** @var ElementQuery $query */
0 ignored issues
show
The open comment tag must be the only content on the line
Loading history...
Missing short description in doc comment
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', [
0 ignored issues
show
The opening parenthesis of a multi-line function call should be the last content on the line.
Loading history...
157
                    'elements_sites.siteId' => $siteId,
158
                    'elements.id' => $elementIds,],
159
                ]);
0 ignored issues
show
For multi-line function calls, the closing parenthesis should be on a new line.

If a function call spawns multiple lines, the coding standard suggests to move the closing parenthesis to a new line:

someFunctionCall(
    $firstArgument,
    $secondArgument,
    $thirdArgument
); // Closing parenthesis on a new line.
Loading history...
160
            }
161
        });
0 ignored issues
show
For multi-line function calls, the closing parenthesis should be on a new line.

If a function call spawns multiple lines, the coding standard suggests to move the closing parenthesis to a new line:

someFunctionCall(
    $firstArgument,
    $secondArgument,
    $thirdArgument
); // Closing parenthesis on a new line.
Loading history...
162
163
        $elements = $query->all();
164
165
        /** @var Element $element */
0 ignored issues
show
The open comment tag must be the only content on the line
Loading history...
Missing short description in doc comment
Loading history...
The close comment tag must be the only content on the line
Loading history...
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 */
0 ignored issues
show
The open comment tag must be the only content on the line
Loading history...
Missing short description in doc comment
Loading history...
The close comment tag must be the only content on the line
Loading history...
171
                $element->count = $similarCounts[$key];
172
            }
173
        }
174
175
        if (empty($criteria['orderBy'])) {
176
            /** @phpstan-ignore-next-line */
0 ignored issues
show
The open comment tag must be the only content on the line
Loading history...
Missing short description in doc comment
Loading history...
The close comment tag must be the only content on the line
Loading history...
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
0 ignored issues
show
Missing doc comment for function eventAfterPrepareHandler()
Loading history...
184
    {
185
        /** @var ElementQuery $query */
0 ignored issues
show
The open comment tag must be the only content on the line
Loading history...
Missing short description in doc comment
Loading history...
The close comment tag must be the only content on the line
Loading history...
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([
0 ignored issues
show
The opening parenthesis of a multi-line function call should be the last content on the line.
Loading history...
192
                'count' => 'DESC',
193
            ], $this->preOrder));
0 ignored issues
show
For multi-line function calls, the closing parenthesis should be on a new line.

If a function call spawns multiple lines, the coding standard suggests to move the closing parenthesis to a new line:

someFunctionCall(
    $firstArgument,
    $secondArgument,
    $thirdArgument
); // Closing parenthesis on a new line.
Loading history...
194
        } elseif (is_string($this->preOrder)) {
0 ignored issues
show
The condition is_string($this->preOrder) is always true.
Loading history...
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
0 ignored issues
show
Missing parameter comment
Loading history...
Tag value for @param tag indented incorrectly; expected 2 spaces but found 1
Loading history...
222
     * @param array $criteria
0 ignored issues
show
Missing parameter comment
Loading history...
Expected 19 spaces after parameter type; 1 found
Loading history...
Tag value for @param tag indented incorrectly; expected 2 spaces but found 1
Loading history...
223
     * @return ElementQueryInterface
0 ignored issues
show
Tag @return cannot be grouped with parameter tags in a doc comment
Loading history...
224
     */
225
    protected function getElementQuery(string|ElementInterface $elementType, array $criteria): ElementQueryInterface
226
    {
227
        /** @var string|ElementInterface $elementType */
0 ignored issues
show
The open comment tag must be the only content on the line
Loading history...
Missing short description in doc comment
Loading history...
The close comment tag must be the only content on the line
Loading history...
228
        $query = $elementType::find();
229
        Craft::configure($query, $criteria);
230
231
        return $query;
232
    }
233
}
234