Passed
Pull Request — master (#245)
by Wilmer
02:45
created

ActiveQuery::allPopulate()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

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

640
                    $this->joinWithRelation($parent, /** @scrutinizer ignore-type */ $relation, $this->getJoinType($joinType, $fullName));
Loading history...
641
                } else {
642
                    $relation = $relations[$fullName];
643
                }
644
645
                $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

645
                /** @scrutinizer ignore-call */ 
646
                $primaryModel = $relation->getARInstance();
Loading history...
646 113
647
                $parent = $relation;
648 113
                $prefix = $fullName;
649 104
                $name = $childName;
650
            }
651 89
652
            $fullName = $prefix === '' ? $name : "$prefix.$name";
653 89
654 89
            if (!isset($relations[$fullName])) {
655 28
                $relations[$fullName] = $relation = $primaryModel->getRelation($name);
656
657 81
                if ($callback !== null) {
658
                    $callback($relation);
659
                }
660
661 109
                if (!empty($relation->getJoinWith())) {
662 8
                    $relation->buildJoinWith();
663
                }
664 109
665
                $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

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

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

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