Passed
Pull Request — master (#245)
by Alexander
04:32 queued 01:51
created

ActiveQuery::removeDuplicatedModels()   B

Complexity

Conditions 10
Paths 5

Size

Total Lines 49
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 10.1167

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 10
eloc 27
c 2
b 0
f 0
nc 5
nop 1
dl 0
loc 49
ccs 17
cts 19
cp 0.8947
crap 10.1167
rs 7.6666

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\ActiveRecord;
6
7
use Closure;
8
use ReflectionException;
9
use Throwable;
10
use Yiisoft\Db\Command\CommandInterface;
11
use Yiisoft\Db\Connection\ConnectionInterface;
12
use Yiisoft\Db\Exception\Exception;
13
use Yiisoft\Db\Exception\InvalidArgumentException;
14
use Yiisoft\Db\Exception\InvalidConfigException;
15
use Yiisoft\Db\Exception\NotSupportedException;
16
use Yiisoft\Db\Expression\ExpressionInterface;
17
use Yiisoft\Db\Helper\ArrayHelper;
18
use Yiisoft\Db\Query\BatchQueryResultInterface;
19
use Yiisoft\Db\Query\Query;
20
use Yiisoft\Db\Query\QueryInterface;
21
use Yiisoft\Db\QueryBuilder\QueryBuilderInterface;
22
use Yiisoft\Definitions\Exception\CircularReferenceException;
23
use Yiisoft\Definitions\Exception\NotInstantiableException;
24
use Yiisoft\Factory\NotFoundException;
25
26
use function array_merge;
27
use function array_values;
28
use function count;
29
use function implode;
30
use function in_array;
31
use function is_array;
32
use function is_int;
33
use function is_string;
34
use function preg_match;
35
use function reset;
36
use function serialize;
37
use function strpos;
38
use function substr;
39
40
/**
41
 * ActiveQuery represents a db query associated with an Active Record class.
42
 *
43
 * An ActiveQuery can be a normal query or be used in a relational context.
44
 *
45
 * ActiveQuery instances are usually created by {@see findOne()}, {@see findBySql()}, {@see findAll()}.
46
 *
47
 * Relational queries are created by {@see ActiveRecord::hasOne()} and {@see ActiveRecord::hasMany()}.
48
 *
49
 * Normal Query
50
 * ------------
51
 *
52
 * ActiveQuery mainly provides the following methods to retrieve the query results:
53
 *
54
 * - {@see one()}: returns a single record populated with the first row of data.
55
 * - {@see all()}: returns all records based on the query results.
56
 * - {@see count()}: returns the number of records.
57
 * - {@see sum()}: returns the sum over the specified column.
58
 * - {@see average()}: returns the average over the specified column.
59
 * - {@see min()}: returns the min over the specified column.
60
 * - {@see max()}: returns the max over the specified column.
61
 * - {@see scalar()}: returns the value of the first column in the first row of the query result.
62
 * - {@see column()}: returns the value of the first column in the query result.
63
 * - {@see exists()}: returns a value indicating whether the query result has data or not.
64
 *
65
 * Because ActiveQuery extends from {@see Query}, one can use query methods, such as {@see where()}, {@see orderBy()} to
66
 * customize the query options.
67
 *
68
 * ActiveQuery also provides the following more query options:
69
 *
70
 * - {@see with()}: list of relations that this query should be performed with.
71
 * - {@see joinWith()}: reuse a relation query definition to add a join to a query.
72
 * - {@see indexBy()}: the name of the column by which the query result should be indexed.
73
 * - {@see asArray()}: whether to return each record as an array.
74
 *
75
 * These options can be configured using methods of the same name. For example:
76
 *
77
 * ```php
78
 * $customerQuery = new ActiveQuery(Customer::class, $db);
79
 * $query = $customerQuery->with('orders')->asArray()->all();
80
 * ```
81
 *
82
 * Relational query
83
 * ----------------
84
 *
85
 * In relational context ActiveQuery represents a relation between two Active Record classes.
86
 *
87
 * Relational ActiveQuery instances are usually created by calling {@see ActiveRecord::hasOne()} and
88
 * {@see ActiveRecord::hasMany()}. An Active Record class declares a relation by defining a getter method which calls
89
 * one of the above methods and returns the created ActiveQuery object.
90
 *
91
 * A relation is specified by {@see link()} which represents the association between columns of different tables; and
92
 * the multiplicity of the relation is indicated by {@see multiple()}.
93
 *
94
 * If a relation involves a junction table, it may be specified by {@see via()} or {@see viaTable()} method.
95
 *
96
 * These methods may only be called in a relational context. Same is true for {@see inverseOf()}, which marks a relation
97
 * as inverse of another relation and {@see onCondition()} which adds a condition that's to be added to relational
98
 * query join condition.
99
 */
100
class ActiveQuery extends Query implements ActiveQueryInterface
101
{
102
    use ActiveQueryTrait;
103
    use ActiveRelationTrait;
104
105
    private string|null $sql = null;
106
    private array|string|null $on = null;
107
    private array $joinWith = [];
108
    private ActiveRecordInterface|null $arInstance = null;
109 720
110
    public function __construct(
111 720
        protected string $arClass,
112 720
        protected ConnectionInterface $db,
113 720
        private ActiveRecordFactory|null $arFactory = null,
114
        private string $tableName = ''
115 720
    ) {
116 720
        parent::__construct($db);
117
    }
118
119
    /**
120
     * Executes a query and returns all results as an array.
121
     *
122
     * If null, the db connection returned by {@see arClass} will be used.
123
     *
124
     * @throws Exception
125
     * @throws InvalidConfigException
126
     * @throws Throwable
127 244
     *
128
     * @return array The query results. If the query results in nothing, an empty array will be returned.
129 244
     *
130
     * @psalm-return ActiveRecord[]|array
131
     */
132
    public function all(): array
133
    {
134
        if ($this->shouldEmulateExecution()) {
135
            return [];
136
        }
137
138
        return $this->populate($this->createCommand()->queryAll(), $this->indexBy);
139
    }
140
141
    public function batch(int $batchSize = 100): BatchQueryResultInterface
142
    {
143
        return parent::batch($batchSize)->setPopulatedMethod(fn ($rows, $indexBy) => $this->populate($rows, $indexBy));
144
    }
145
146 497
    public function each(int $batchSize = 100): BatchQueryResultInterface
147
    {
148
        return parent::each($batchSize)->setPopulatedMethod(fn ($rows, $indexBy) => $this->populate($rows, $indexBy));
149
    }
150
151
    /**
152
     * @throws CircularReferenceException
153 497
     * @throws Exception
154 80
     * @throws InvalidConfigException
155
     * @throws NotFoundException
156 80
     * @throws NotInstantiableException
157
     * @throws Throwable
158
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
159 497
     */
160 489
    public function prepare(QueryBuilderInterface $builder): QueryInterface
161
    {
162
        /**
163 497
         * NOTE: Because the same ActiveQuery may be used to build different SQL statements, one for count query, the
164 76
         * other for row data query, it's important to make sure the same ActiveQuery can be used to build SQL
165
         * statements many times.
166 76
         */
167
        if (!empty($this->joinWith)) {
168
            $this->buildJoinWith();
169 497
            /**
170
             * Clean it up to avoid issue @link https://github.com/yiisoft/yii2/issues/2687
171 489
             */
172
            $this->joinWith = [];
173
        }
174 113
175
        if (empty($this->getFrom())) {
176 113
            $this->from = [$this->getPrimaryTableName()];
177
        }
178 20
179
        if (empty($this->getSelect()) && !empty($this->getJoin())) {
180 20
            [, $alias] = $this->getTableNameAndAlias();
181 101
182
            $this->select(["$alias.*"]);
183
        }
184
185
        if ($this->primaryModel === null) {
186
            $query = $this->createInstance();
187 28
        } else {
188
            $where = $this->getWhere();
189 28
190 28
            if ($this->via instanceof self) {
191 20
                /** via junction table */
192 8
                $viaModels = $this->via->findJunctionRows([$this->primaryModel]);
193
194
                $this->filterByModels($viaModels);
195 8
            } elseif (is_array($this->via)) {
196 28
                [$viaName, $viaQuery, $viaCallableUsed] = $this->via;
197
198
                if ($viaQuery->getMultiple()) {
199
                    if ($viaCallableUsed) {
200
                        $viaModels = $viaQuery->all();
201
                    } elseif ($this->primaryModel->isRelationPopulated($viaName)) {
202
                        $viaModels = $this->primaryModel->$viaName;
203
                    } else {
204
                        $viaModels = $viaQuery->all();
205
                        $this->primaryModel->populateRelation($viaName, $viaModels);
206
                    }
207
                } else {
208
                    if ($viaCallableUsed) {
209 28
                        $model = $viaQuery->onePopulate();
210
                    } elseif ($this->primaryModel->isRelationPopulated($viaName)) {
211 101
                        $model = $this->primaryModel->$viaName;
212
                    } else {
213
                        $model = $viaQuery->onePopulate();
214 113
                        $this->primaryModel->populateRelation($viaName, $model);
215 113
                    }
216
                    $viaModels = $model === null ? [] : [$model];
217
                }
218 497
                $this->filterByModels($viaModels);
219 24
            } else {
220
                $this->filterByModels([$this->primaryModel]);
221
            }
222 497
223
            $query = $this->createInstance();
224
            $this->where($where);
225
        }
226
227
        if (!empty($this->on)) {
228
            $query->andWhere($this->on);
229
        }
230
231
        return $query;
232
    }
233
234
    /**
235
     * Converts the raw query results into the format as specified by this query.
236
     *
237 419
     * This method is internally used to convert the data fetched from a database into the format as required by this
238
     * query.
239 419
     *
240 74
     * @param array $rows The raw query result from a database.
241
     *
242
     * @throws Exception
243 406
     * @throws InvalidArgumentException
244
     * @throws InvalidConfigException
245 406
     * @throws NotSupportedException
246 64
     * @throws ReflectionException
247
     * @throws Throwable
248
     *
249 406
     * @return array The converted query result.
250 131
     */
251
    public function populate(array $rows, Closure|string|null $indexBy = null): array
252
    {
253 406
        if (empty($rows)) {
254 16
            return [];
255
        }
256
257 406
        $models = $this->createModels($rows);
258
259
        if (!empty($this->join) && $this->getIndexBy() === null) {
260
            $models = $this->removeDuplicatedModels($models);
261
        }
262
263
        if (!empty($this->with)) {
264
            $this->findWith($this->with, $models);
265
        }
266
267
        if ($this->inverseOf !== null) {
268
            $this->addInverseRelations($models);
269
        }
270
271 64
        return ArrayHelper::populate($models, $indexBy);
272
    }
273 64
274
    /**
275 64
     * Removes duplicated models by checking their primary key values.
276
     *
277 64
     * This method is mainly called when a join query is performed, which may cause duplicated rows being returned.
278
     *
279 8
     * @param array $models The models to be checked.
280 8
     *
281 8
     * @throws CircularReferenceException
282 8
     * @throws Exception
283
     * @throws InvalidConfigException
284 4
     * @throws NotFoundException
285
     * @throws NotInstantiableException
286 7
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
287
     *
288
     * @return array The distinctive models.
289 4
     */
290
    private function removeDuplicatedModels(array $models): array
291 4
    {
292
        $hash = [];
293
294 4
        $pks = $this->getARInstance()->primaryKey();
295
296
        if (count($pks) > 1) {
297 60
            // Composite primary key.
298
            foreach ($models as $i => $model) {
299
                $key = [];
300
                foreach ($pks as $pk) {
301 60
                    if (!isset($model[$pk])) {
302
                        // Don't continue if the primary key isn't part of the result set.
303 60
                        break 2;
304 60
                    }
305
                    $key[] = $model[$pk];
306 4
                }
307
308
                $key = serialize($key);
309 56
310
                if (isset($hash[$key])) {
311 56
                    unset($models[$i]);
312 28
                } else {
313 56
                    $hash[$key] = true;
314 56
                }
315
            }
316
        } elseif (empty($pks)) {
317
            throw new InvalidConfigException("Primary key of '$this->arClass' can not be empty.");
318
        } else {
319 64
            // Single column primary key.
320
            $pk = reset($pks);
321
322
            foreach ($models as $i => $model) {
323
                if (!isset($model[$pk])) {
324
                    // Don't continue if the primary key isn't part of the result set.
325
                    break;
326
                }
327
328
                $key = $model[$pk];
329
330
                if (isset($hash[$key])) {
331
                    unset($models[$i]);
332 305
                } else {
333
                    $hash[$key] = true;
334 305
                }
335
            }
336 305
        }
337 301
338
        return array_values($models);
339 301
    }
340
341
    /**
342 32
     * @throws Exception
343
     * @throws InvalidArgumentException
344
     * @throws InvalidConfigException
345
     * @throws NotSupportedException
346
     * @throws ReflectionException
347
     * @throws Throwable
348
     */
349
    public function allPopulate(): array
350
    {
351
        $rows = $this->all();
352 449
353
        if ($rows !== []) {
354 449
            $rows = $this->populate($rows, $this->indexBy);
355 445
        }
356
357 4
        return $rows;
358 4
    }
359
360
    /**
361 449
     * @throws Exception
362
     * @throws InvalidArgumentException
363 449
     * @throws InvalidConfigException
364
     * @throws NotSupportedException
365 449
     * @throws ReflectionException
366
     * @throws Throwable
367
     */
368
    public function onePopulate(): array|ActiveRecordInterface|null
369
    {
370
        $row = $this->one();
371
372
        if ($row !== null) {
373
            $activeRecord = $this->populate([$row], $this->indexBy);
374
            $row = reset($activeRecord) ?: null;
375
        }
376
377
        return $row;
378
    }
379 61
380
    /**
381 61
     * Creates a db command that can be used to execute this query.
382 57
     *
383
     * @throws Exception
384
     */
385 4
    public function createCommand(): CommandInterface
386 4
    {
387 4
        if ($this->sql === null) {
388 4
            [$sql, $params] = $this->db->getQueryBuilder()->build($this);
389
        } else {
390 4
            $sql = $this->sql;
391
            $params = $this->params;
392 4
        }
393
394
        return $this->db->createCommand($sql, $params);
395
    }
396
397
    /**
398
     * Queries a scalar value by setting {@see select()} first.
399
     *
400
     * Restores the value of select to make this query reusable.
401
     *
402
     * @param ExpressionInterface|string $selectExpression The expression to be selected.
403
     *
404
     * @throws Exception
405
     * @throws InvalidArgumentException
406
     * @throws InvalidConfigException
407
     * @throws NotSupportedException
408
     * @throws Throwable
409
     */
410
    protected function queryScalar(string|ExpressionInterface $selectExpression): bool|string|null|int|float
411
    {
412
        if ($this->sql === null) {
413
            return parent::queryScalar($selectExpression);
414
        }
415
416
        $command = (new Query($this->db))->select([$selectExpression])
417
            ->from(['c' => "($this->sql)"])
418
            ->params($this->params)
419
            ->createCommand();
420
421
        return $command->queryScalar();
422
    }
423
424
    /**
425
     * Joins with the specified relations.
426
     *
427
     * This method allows you to reuse existing relation definitions to perform JOIN queries. Based on the definition of
428
     * the specified relation(s), the method will append one or many JOIN statements to the current query.
429
     *
430
     * If the `$eagerLoading` parameter is true, the method will also perform eager loading for the specified relations,
431
     * which is equal to calling {@see with()} using the specified relations.
432
     *
433
     * Note: That because a JOIN query will be performed, you're responsible for disambiguated column names.
434
     *
435
     * This method differs from {@see with()} in that it will build up and execute a JOIN SQL statement for the primary
436
     * table. And when `$eagerLoading` is true, it will call {@see with()} in addition with the specified relations.
437
     *
438
     * @param array|string $with The relations to be joined. This can either be a string, representing a relation name
439
     * or an array with the following semantics:
440
     *
441
     * - Each array element represents a single relation.
442
     * - You may specify the relation name as the array key and give anonymous functions that can be used to change the
443
     *   relation queries on-the-fly as the array value.
444
     * - If a relation query doesn't need modification, you may use the relation name as the array value.
445
     *
446
     * The relation name may optionally contain an alias for the relation table (e.g. `books b`).
447
     *
448
     * Sub-relations can also be specified, see {@see with()} for the syntax.
449
     *
450
     * In the following you find some examples:
451 99
     *
452
     * ```php
453 99
     * // Find all orders that contain books, and eager loading "books".
454
     * $orderQuery = new ActiveQuery(Order::class, $db);
455 99
     * $orderQuery->joinWith('books', true, 'INNER JOIN')->all();
456 99
     *
457 95
     * // find all orders, eager loading "books", and sort the orders and books by the book names.
458 95
     * $orderQuery = new ActiveQuery(Order::class, $db);
459
     * $orderQuery->joinWith([
460
     *     'books' => function (ActiveQuery $query) {
461 99
     *         $query->orderBy('item.name');
462
     *     }
463 20
     * ])->all();
464
     *
465 20
     * // Find all orders that contain books of the category 'Science fiction', using the alias "b" for the book table.
466
     * $order = new ActiveQuery(Order::class, $db);
467 20
     * $orderQuery->joinWith(['books b'], true, 'INNER JOIN')->where(['b.category' => 'Science fiction'])->all();
468
     * ```
469 20
     * @param array|bool $eagerLoading Whether to eager load the relations specified in `$with`. When this is boolean.
470
     * It applies to all relations specified in `$with`. Use an array to explicitly list which relations in `$with` a
471 20
     * need to be eagerly loaded.
472 16
     * Note: That this doesn't mean that the relations are populated from the query result. An
473
     * extra query will still be performed to bring in the related data. Defaults to `true`.
474 20
     * @param array|string $joinType The join type of the relations specified in `$with`.  When this is a string, it
475
     * applies to all relations specified in `$with`. Use an array in the format of `relationName => joinType` to
476
     * specify different join types for different relations.
477 99
     */
478 95
    public function joinWith(
479
        array|string $with,
480 48
        array|bool $eagerLoading = true,
481
        array|string $joinType = 'LEFT JOIN'
482
    ): self {
483
        $relations = [];
484 99
485
        foreach ((array) $with as $name => $callback) {
486 99
            if (is_int($name)) {
487
                $name = $callback;
488
                $callback = null;
489 84
            }
490
491 84
            if (preg_match('/^(.*?)(?:\s+AS\s+|\s+)(\w+)$/i', $name, $matches)) {
492
                /** The relation is defined with an alias, adjust callback to apply alias */
493 84
                [, $relation, $alias] = $matches;
494
495 84
                $name = $relation;
496
497 84
                $callback = static function (self $query) use ($callback, $alias) {
498 84
                    $query->alias($alias);
499
500 84
                    if ($callback !== null) {
501
                        $callback($query);
502
                    }
503
                };
504
            }
505
506
            if ($callback === null) {
507
                $relations[] = $name;
508
            } else {
509
                $relations[$name] = $callback;
510 84
            }
511 16
        }
512
513
        $this->joinWith[] = [$relations, $eagerLoading, $joinType];
514 84
515
        return $this;
516
    }
517
518
    /**
519
     * @throws CircularReferenceException
520
     * @throws InvalidConfigException
521 84
     * @throws NotFoundException
522
     * @throws NotInstantiableException
523 84
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
524 84
     */
525
    public function buildJoinWith(): void
526 84
    {
527
        $join = $this->join;
528
529 84
        $this->join = [];
530
531 84
        $arClass = $this->getARInstance();
532 84
533 84
        foreach ($this->joinWith as [$with, $eagerLoading, $joinType]) {
534 84
            $this->joinWithRelations($arClass, $with, $joinType);
535
536
            if (is_array($eagerLoading)) {
537
                foreach ($with as $name => $callback) {
538 84
                    if (is_int($name)) {
539
                        if (!in_array($callback, $eagerLoading, true)) {
540 84
                            unset($with[$name]);
541
                        }
542
                    } elseif (!in_array($name, $eagerLoading, true)) {
543
                        unset($with[$name]);
544 84
                    }
545
                }
546
            } elseif (!$eagerLoading) {
547
                $with = [];
548
            }
549
550
            $this->with($with);
551
        }
552
553
        /**
554
         * Remove duplicated joins added by joinWithRelations that may be added, for example, when joining a relation
555
         * and a via relation at the same time.
556
         */
557
        $uniqueJoins = [];
558
559
        foreach ($this->join as $j) {
560
            $uniqueJoins[serialize($j)] = $j;
561 53
        }
562
        $this->join = array_values($uniqueJoins);
563 53
564
        /**
565
         * @link https://github.com/yiisoft/yii2/issues/16092
566
         */
567
        $uniqueJoinsByTableName = [];
568
569
        foreach ($this->join as $config) {
570
            $tableName = serialize($config[1]);
571
            if (!array_key_exists($tableName, $uniqueJoinsByTableName)) {
572
                $uniqueJoinsByTableName[$tableName] = $config;
573 84
            }
574
        }
575 84
576
        $this->join = array_values($uniqueJoinsByTableName);
577 84
578 84
        if (!empty($join)) {
579 80
            /**
580 80
             * Append explicit join to {@see joinWith()} {@link https://github.com/yiisoft/yii2/issues/2880}
581
             */
582
            $this->join = empty($this->join) ? $join : array_merge($this->join, $join);
583 84
        }
584 84
    }
585 84
586
    /**
587 84
     * Inner joins with the specified relations.
588 20
     *
589 20
     * This is a shortcut method to {@see joinWith()} with the join type set as "INNER JOIN".
590 20
     *
591
     * Please refer to {@see joinWith()} for detailed usage of this method.
592 20
     *
593 12
     * @param array|string $with The relations to be joined with.
594 12
     * @param array|bool $eagerLoading Whether to eager load the relations.
595
     * Note: That this doesn't mean that the relations are populated from the query result.
596 8
     * An extra query will still be performed to bring in the related data.
597
     *
598
     * @see joinWith()
599 20
     */
600
    public function innerJoinWith(array|string $with, array|bool $eagerLoading = true): self
601 20
    {
602 20
        return $this->joinWith($with, $eagerLoading, 'INNER JOIN');
603 20
    }
604
605
    /**
606 84
     * Modifies the current query by adding join fragments based on the given relations.
607
     *
608 84
     * @param ActiveRecordInterface $arClass The primary model.
609 84
     * @param array $with The relations to be joined.
610
     * @param array|string $joinType The join type.
611 84
     *
612 48
     * @throws CircularReferenceException
613
     * @throws InvalidConfigException
614
     * @throws NotFoundException
615 84
     * @throws NotInstantiableException
616 12
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
617
     */
618
    private function joinWithRelations(ActiveRecordInterface $arClass, array $with, array|string $joinType): void
619 84
    {
620
        $relations = [];
621
622 84
        foreach ($with as $name => $callback) {
623
            if (is_int($name)) {
624
                $name = $callback;
625
                $callback = null;
626
            }
627
628
            $primaryModel = $arClass;
629
            $parent = $this;
630
            $prefix = '';
631
632 84
            while (($pos = strpos($name, '.')) !== false) {
633
                $childName = substr($name, $pos + 1);
634 84
                $name = substr($name, 0, $pos);
635
                $fullName = $prefix === '' ? $name : "$prefix.$name";
636
637
                if (!isset($relations[$fullName])) {
638 84
                    $relations[$fullName] = $relation = $primaryModel->getRelation($name);
639
                    $this->joinWithRelation($parent, $relation, $this->getJoinType($joinType, $fullName));
0 ignored issues
show
Bug introduced by
It seems like $relation can also be of type null; however, parameter $child of Yiisoft\ActiveRecord\Act...ery::joinWithRelation() does only seem to accept Yiisoft\ActiveRecord\ActiveQueryInterface, maybe add an additional type check? ( Ignorable by Annotation )

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

639
                    $this->joinWithRelation($parent, /** @scrutinizer ignore-type */ $relation, $this->getJoinType($joinType, $fullName));
Loading history...
640
                } else {
641
                    $relation = $relations[$fullName];
642
                }
643
644
                $primaryModel = $relation->getARInstance();
0 ignored issues
show
Bug introduced by
The method getARInstance() does not exist on Yiisoft\ActiveRecord\ActiveQueryInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Yiisoft\ActiveRecord\ActiveQueryInterface. ( Ignorable by Annotation )

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

644
                /** @scrutinizer ignore-call */ 
645
                $primaryModel = $relation->getARInstance();
Loading history...
645
646 113
                $parent = $relation;
647
                $prefix = $fullName;
648 113
                $name = $childName;
649 104
            }
650
651 89
            $fullName = $prefix === '' ? $name : "$prefix.$name";
652
653 89
            if (!isset($relations[$fullName])) {
654 89
                $relations[$fullName] = $relation = $primaryModel->getRelation($name);
655 28
656
                if ($callback !== null) {
657 81
                    $callback($relation);
658
                }
659
660
                if (!empty($relation->getJoinWith())) {
661 109
                    $relation->buildJoinWith();
662 8
                }
663
664 109
                $this->joinWithRelation($parent, $relation, $this->getJoinType($joinType, $fullName));
0 ignored issues
show
Bug introduced by
It seems like $parent can also be of type null; however, parameter $parent of Yiisoft\ActiveRecord\Act...ery::joinWithRelation() does only seem to accept Yiisoft\ActiveRecord\ActiveQueryInterface, maybe add an additional type check? ( Ignorable by Annotation )

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

664
                $this->joinWithRelation(/** @scrutinizer ignore-type */ $parent, $relation, $this->getJoinType($joinType, $fullName));
Loading history...
665
            }
666
        }
667 109
    }
668
669
    /**
670
     * Returns the join type based on the given join type parameter and the relation name.
671
     *
672
     * @param array|string $joinType The given join type(s).
673
     * @param string $name The relation name.
674
     *
675
     * @return string The real join type.
676
     */
677
    private function getJoinType(array|string $joinType, string $name): string
678
    {
679 84
        if (is_array($joinType) && isset($joinType[$name])) {
680
            return $joinType[$name];
681 84
        }
682 84
683
        return is_string($joinType) ? $joinType : 'INNER JOIN';
684 84
    }
685
686 12
    /**
687 12
     * Returns the table name and the table alias for {@see arClass}.
688
     *
689 12
     * @throws CircularReferenceException
690
     * @throws NotFoundException
691
     * @throws NotInstantiableException
692 84
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
693
     */
694 28
    private function getTableNameAndAlias(): array
695 28
    {
696
        if (empty($this->from)) {
697 28
            $tableName = $this->getPrimaryTableName();
698
        } else {
699
            $tableName = '';
700 84
701 84
            foreach ($this->from as $alias => $tableName) {
702
                if (is_string($alias)) {
703 84
                    return [$tableName, $alias];
704 84
                }
705 84
                break;
706
            }
707
        }
708 84
709 84
        if (preg_match('/^(.*?)\s+({{\w+}}|\w+)$/', $tableName, $matches)) {
710
            $alias = $matches[2];
711
        } else {
712 84
            $alias = $tableName;
713
        }
714 84
715 84
        return [$tableName, $alias];
716
    }
717
718 84
    /**
719
     * Joins a parent query with a child query.
720 84
     *
721 84
     * The current query object will be modified so.
722
     *
723
     * @param ActiveQuery $parent The parent query.
724
     * @param ActiveQuery $child The child query.
725
     * @param string $joinType The join type.
726
     *
727 84
     * @throws CircularReferenceException
728
     * @throws NotFoundException
729 84
     * @throws NotInstantiableException
730 28
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
731
     */
732
    private function joinWithRelation(ActiveQueryInterface $parent, ActiveQueryInterface $child, string $joinType): void
733 84
    {
734
        $via = $child->via;
0 ignored issues
show
Bug introduced by
Accessing via on the interface Yiisoft\ActiveRecord\ActiveQueryInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
735
        $child->via = null;
736
737 84
        if ($via instanceof self) {
738 32
            // via table
739
            $this->joinWithRelation($parent, $via, $joinType);
740
            $this->joinWithRelation($via, $child, $joinType);
741 84
742
            return;
743
        }
744
745 84
        if (is_array($via)) {
746
            // via relation
747
            $this->joinWithRelation($parent, $via[1], $joinType);
748
            $this->joinWithRelation($via[1], $child, $joinType);
749 84
750 12
            return;
751 12
        }
752
753
        [$parentTable, $parentAlias] = $parent->getTableNameAndAlias();
0 ignored issues
show
Bug introduced by
The method getTableNameAndAlias() does not exist on Yiisoft\ActiveRecord\ActiveQueryInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Yiisoft\ActiveRecord\ActiveQueryInterface. ( Ignorable by Annotation )

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

753
        /** @scrutinizer ignore-call */ 
754
        [$parentTable, $parentAlias] = $parent->getTableNameAndAlias();
Loading history...
754
        [$childTable, $childAlias] = $child->getTableNameAndAlias();
755 84
756
        if (!empty($child->link)) {
0 ignored issues
show
Bug introduced by
Accessing link on the interface Yiisoft\ActiveRecord\ActiveQueryInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
757
            if (!str_contains($parentAlias, '{{')) {
758
                $parentAlias = '{{' . $parentAlias . '}}';
759
            }
760 84
761
            if (!str_contains($childAlias, '{{')) {
762
                $childAlias = '{{' . $childAlias . '}}';
763
            }
764
765
            $on = [];
766
767
            foreach ($child->link as $childColumn => $parentColumn) {
768
                $on[] = "$parentAlias.[[$parentColumn]] = $childAlias.[[$childColumn]]";
769
            }
770
771
            $on = implode(' AND ', $on);
772
773
            if (!empty($child->on)) {
0 ignored issues
show
Bug introduced by
Accessing on on the interface Yiisoft\ActiveRecord\ActiveQueryInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
774
                $on = ['and', $on, $child->on];
775
            }
776
        } else {
777
            $on = $child->on;
778
        }
779
780
        $this->join($joinType, empty($child->getFrom()) ? $childTable : $child->getFrom(), $on);
781
782
        if (!empty($child->getWhere())) {
783
            $this->andWhere($child->getWhere());
784
        }
785
786
        if (!empty($child->getHaving())) {
787
            $this->andHaving($child->getHaving());
788 29
        }
789
790 29
        if (!empty($child->getOrderBy())) {
791
            $this->addOrderBy($child->getOrderBy());
792 29
        }
793
794 29
        if (!empty($child->getGroupBy())) {
795
            $this->addGroupBy($child->getGroupBy());
796
        }
797
798
        if (!empty($child->getParams())) {
799
            $this->addParams($child->getParams());
800
        }
801
802
        if (!empty($child->getJoin())) {
803
            foreach ($child->getJoin() as $join) {
804
                $this->join[] = $join;
805
            }
806
        }
807
808
        if (!empty($child->getUnion())) {
809
            foreach ($child->getUnion() as $union) {
810
                $this->union[] = $union;
811 10
            }
812
        }
813 10
    }
814 5
815
    /**
816 5
     * Sets the ON condition for a relational query.
817
     *
818
     * The condition will be used in the ON part when {@see joinWith()} is called.
819 10
     *
820
     * Otherwise, the condition will be used in the WHERE part of a query.
821 10
     *
822
     * Use this method to specify more conditions when declaring a relation in the {@see ActiveRecord} class:
823
     *
824
     * ```php
825
     * public function getActiveUsers(): ActiveQuery
826
     * {
827
     *     return $this->hasMany(User::class, ['id' => 'user_id'])->onCondition(['active' => true]);
828
     * }
829
     * ```
830
     *
831
     * Note that this condition is applied in case of a join as well as when fetching the related records.
832
     * These only fields of the related table can be used in the condition.
833
     * Trying to access fields of the primary record will cause an error in a non-join-query.
834
     *
835
     * @param array|string $condition The ON condition. Please refer to {@see Query::where()} on how to specify this
836
     * parameter.
837
     * @param array $params The parameters (name => value) to be bound to the query.
838 10
     */
839
    public function onCondition(array|string $condition, array $params = []): self
840 10
    {
841 5
        $this->on = $condition;
842
843 5
        $this->addParams($params);
844
845
        return $this;
846 10
    }
847
848 10
    /**
849
     * Adds ON condition to the existing one.
850
     *
851
     * The new condition and the existing one will be joined using the 'AND' operator.
852
     *
853
     * @param array|string $condition The new ON condition.
854
     * Please refer to {@see where()} on how to specify this parameter.
855
     * @param array $params the parameters (name => value) to be bound to the query.
856
     *
857
     * @see onCondition()
858
     * @see orOnCondition()
859
     */
860
    public function andOnCondition(array|string $condition, array $params = []): self
861
    {
862
        if ($this->on === null) {
863
            $this->on = $condition;
864
        } else {
865
            $this->on = ['and', $this->on, $condition];
866
        }
867
868
        $this->addParams($params);
869
870
        return $this;
871
    }
872
873
    /**
874 32
     * Adds ON condition to the existing one.
875
     *
876 32
     * The new condition and the existing one will be joined using the 'OR' operator.
877
     *
878 32
     * @param array|string $condition The new ON condition.
879
     * Please refer to {@see where()} on how to specify this parameter.
880 32
     * @param array $params The parameters (name => value) to be bound to the query.
881
     *
882 32
     * @see onCondition()
883
     * @see andOnCondition()
884 32
     */
885 8
    public function orOnCondition(array|string $condition, array $params = []): self
886
    {
887
        if ($this->on === null) {
888 32
            $this->on = $condition;
889
        } else {
890
            $this->on = ['or', $this->on, $condition];
891
        }
892
893
        $this->addParams($params);
894
895
        return $this;
896
    }
897
898
    /**
899
     * Specifies the junction table for a relational query.
900
     *
901 41
     * Use this method to specify a junction table when declaring a relation in the {@see ActiveRecord} class:
902
     *
903 41
     * ```php
904 41
     * public function getItems()
905 41
     * {
906
     *     return $this->hasMany(Item::class, ['id' => 'item_id'])->viaTable('order_item', ['order_id' => 'id']);
907 4
     * }
908
     * ```
909 4
     *
910 4
     * @param string $tableName The name of the junction table.
911 4
     * @param array $link The link between the junction table and the table associated with {@see primaryModel}.
912 4
     * The keys of the array represent the columns in the junction table, and the values represent the columns in the
913
     * {@see primaryModel} table.
914
     * @param callable|null $callable A PHP callback for customizing the relation associated with the junction table.
915
     * Its signature should be `function($query)`, where `$query` is the query to be customized.
916
     *
917 41
     * @see via()
918
     */
919
    public function viaTable(string $tableName, array $link, callable $callable = null): self
920
    {
921
        $arClass = $this->primaryModel ? $this->primaryModel::class : $this->arClass;
922
        $arClassInstance = new self($arClass, $this->db);
923
924
        /** @psalm-suppress UndefinedMethod */
925
        $relation = $arClassInstance->from([$tableName])->link($link)->multiple(true)->asArray();
926
927
        $this->via = $relation;
928
929 168
        if ($callable !== null) {
930
            $callable($relation);
931 168
        }
932 136
933
        return $this;
934
    }
935 44
936
    /**
937
     * Define an alias for the table defined in {@see arClass}.
938 553
     *
939
     * This method will adjust {@see from()} so that an already defined alias will be overwritten.
940 553
     *
941
     * If none was defined, {@see from()} will be populated with the given alias.
942
     *
943
     * @param string $alias The table alias.
944
     *
945
     * @throws CircularReferenceException
946
     * @throws NotFoundException
947
     * @throws NotInstantiableException
948
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
949
     */
950
    public function alias(string $alias): self
951
    {
952
        if (empty($this->from) || count($this->from) < 2) {
953 52
            [$tableName] = $this->getTableNameAndAlias();
954
            $this->from = [$alias => $tableName];
955 52
        } else {
956
            $tableName = $this->getPrimaryTableName();
957
958
            foreach ($this->from as $key => $table) {
959
                if ($table === $tableName) {
960
                    unset($this->from[$key]);
961 204
                    $this->from[$alias] = $tableName;
962
                }
963 204
            }
964
        }
965
966
        return $this;
967
    }
968
969
    /**
970
     * Returns table names used in {@see from} indexed by aliases.
971 4
     *
972
     * Both aliases and names are enclosed into {{ and }}.
973 4
     *
974
     * @throws CircularReferenceException
975
     * @throws InvalidArgumentException
976 5
     * @throws NotFoundException
977
     * @throws NotInstantiableException
978 5
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
979
     */
980
    public function getTablesUsedInFrom(): array
981
    {
982
        if (empty($this->from)) {
983
            return $this->db->getQuoter()->cleanUpTableNames([$this->getPrimaryTableName()]);
984
        }
985
986
        return parent::getTablesUsedInFrom();
987
    }
988 228
989
    /**
990 228
     * @throws CircularReferenceException
991
     * @throws NotFoundException
992
     * @throws NotInstantiableException
993
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
994
     */
995
    protected function getPrimaryTableName(): string
996
    {
997
        return $this->getARInstance()->getTableName();
998
    }
999
1000 5
    /**
1001
     * @return array|string|null the join condition to be used when this query is used in a relational context.
1002 5
     *
1003
     * The condition will be used in the ON part when {@see joinWith()} is called. Otherwise, the condition will be used
1004
     * in the WHERE part of a query.
1005
     *
1006
     * Please refer to {@see Query::where()} on how to specify this parameter.
1007
     *
1008
     * @see onCondition()
1009
     */
1010
    public function getOn(): array|string|null
1011
    {
1012
        return $this->on;
1013
    }
1014
1015
    /**
1016 285
     * @return array $value A list of relations that this query should be joined with.
1017
     */
1018 285
    public function getJoinWith(): array
1019
    {
1020 285
        return $this->joinWith;
1021 185
    }
1022
1023
    /**
1024 285
     * @return string|null The SQL statement to be executed for retrieving AR records.
1025
     *
1026 189
     * This is set by {@see ActiveRecord::findBySql()}.
1027
     */
1028 189
    public function getSql(): string|null
1029 189
    {
1030
        return $this->sql;
1031 189
    }
1032
1033
    public function getARClass(): string|null
1034
    {
1035
        return $this->arClass;
1036
    }
1037
1038
    /**
1039 189
     * @throws Exception
1040
     * @throws InvalidArgumentException
1041 189
     * @throws InvalidConfigException
1042
     * @throws Throwable
1043 108
     */
1044 108
    public function findOne(mixed $condition): array|ActiveRecordInterface|null
1045 108
    {
1046
        return $this->findByCondition($condition)->onePopulate();
1047
    }
1048 253
1049
    /**
1050
     * @param mixed $condition The primary key value or a set of column values.
1051
     *
1052
     * @throws Exception
1053
     * @throws InvalidArgumentException
1054
     * @throws InvalidConfigException
1055
     * @throws Throwable
1056
     *
1057
     * @return array Of ActiveRecord instance, or an empty array if nothing matches.
1058
     */
1059
    public function findAll(mixed $condition): array
1060
    {
1061
        return $this->findByCondition($condition)->all();
1062
    }
1063
1064
    /**
1065
     * Finds ActiveRecord instance(s) by the given condition.
1066
     *
1067
     * This method is internally called by {@see findOne()} and {@see findAll()}.
1068
     *
1069
     * @param mixed $condition Please refer to {@see findOne()} for the explanation of this parameter.
1070 8
     *
1071
     * @throws CircularReferenceException
1072 8
     * @throws Exception
1073
     * @throws InvalidArgumentException
1074
     * @throws NotFoundException
1075 15
     * @throws NotInstantiableException
1076
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException If there is no primary key defined.
1077 15
     */
1078 15
    protected function findByCondition(mixed $condition): static
1079
    {
1080
        $arInstance = $this->getARInstance();
1081 12
1082
        if (!is_array($condition)) {
1083 12
            $condition = [$condition];
1084 12
        }
1085
1086
        if (!ArrayHelper::isAssociative($condition) && !$condition instanceof ExpressionInterface) {
1087 625
            /** query by primary key */
1088
            $primaryKey = $arInstance->primaryKey();
1089 625
1090 1
            if (isset($primaryKey[0])) {
1091
                $pk = $primaryKey[0];
1092
1093 625
                if (!empty($this->getJoin()) || !empty($this->getJoinWith())) {
1094
                    $pk = $arInstance->getTableName() . '.' . $pk;
1095 625
                }
1096
1097
                /**
1098 1
                 * if the condition is scalar, search for a single primary key, if it's array, search for many primary
1099
                 * key values.
1100 1
                 */
1101
                $condition = [$pk => array_values($condition)];
1102 1
            } else {
1103
                throw new InvalidConfigException('"' . $arInstance::class . '" must have a primary key.');
1104
            }
1105
        } else {
1106
            $aliases = $arInstance->filterValidAliases($this);
1107
            $condition = $arInstance->filterCondition($condition, $aliases);
1108
        }
1109
1110
        return $this->where($condition);
1111
    }
1112
1113
    /**
1114
     * Creates an {@see ActiveQuery} instance with a given SQL statement.
1115
     *
1116
     * Note: That because the SQL statement is already specified, calling more query modification methods
1117
     * (such as {@see where()}, {@see order()) on the created {@see ActiveQuery} instance will have no effect.
1118
     *
1119
     * However, calling {@see with()}, {@see asArray()} or {@see indexBy()} is still fine.
1120
     *
1121
     * Below is an example:
1122
     *
1123
     * ```php
1124
     * $customerQuery = new ActiveQuery(Customer::class, $db);
1125
     * $customers = $customerQuery->findBySql('SELECT * FROM customer')->all();
1126
     * ```
1127
     *
1128
     * @param string $sql The SQL statement to be executed.
1129
     * @param array $params The parameters to be bound to the SQL statement during execution.
1130
     */
1131
    public function findBySql(string $sql, array $params = []): self
1132
    {
1133
        return $this->sql($sql)->params($params);
1134
    }
1135
1136
    public function on(array|string|null $value): self
1137
    {
1138
        $this->on = $value;
1139
        return $this;
1140
    }
1141
1142
    public function sql(string|null $value): self
1143
    {
1144
        $this->sql = $value;
1145
        return $this;
1146
    }
1147
1148
    /**
1149
     * @throws CircularReferenceException
1150
     * @throws NotFoundException
1151
     * @throws NotInstantiableException
1152
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
1153
     */
1154
    public function getARInstance(): ActiveRecordInterface
1155
    {
1156
        if ($this->arFactory !== null) {
1157
            return $this->getARInstanceFactory();
1158
        }
1159
1160
        $class = $this->arClass;
1161
1162
        return new $class($this->db, null, $this->tableName);
1163
    }
1164
1165
    /**
1166
     * @throws CircularReferenceException
1167
     * @throws NotFoundException
1168
     * @throws NotInstantiableException
1169
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
1170
     */
1171
    public function getARInstanceFactory(): ActiveRecordInterface
1172
    {
1173
        return $this->arFactory->createAR($this->arClass, $this->tableName, $this->db);
0 ignored issues
show
Bug introduced by
The method createAR() 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

1173
        return $this->arFactory->/** @scrutinizer ignore-call */ createAR($this->arClass, $this->tableName, $this->db);

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...
1174
    }
1175
1176
    private function createInstance(): self
1177
    {
1178
        return (new self($this->arClass, $this->db))
1179
            ->where($this->getWhere())
1180
            ->limit($this->getLimit())
1181
            ->offset($this->getOffset())
1182
            ->orderBy($this->getOrderBy())
1183
            ->indexBy($this->getIndexBy())
1184
            ->select($this->select)
1185
            ->selectOption($this->selectOption)
1186
            ->distinct($this->distinct)
1187
            ->from($this->from)
1188
            ->groupBy($this->groupBy)
1189
            ->setJoin($this->join)
1190
            ->having($this->having)
1191
            ->setUnion($this->union)
1192
            ->params($this->params)
1193
            ->withQueries($this->withQueries);
1194
    }
1195
}
1196