Passed
Pull Request — master (#240)
by Def
02:48
created

ActiveQuery::each()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 2
Bugs 2 Features 0
Metric Value
eloc 1
c 2
b 2
f 0
dl 0
loc 3
ccs 0
cts 0
cp 0
rs 10
cc 1
nc 1
nop 1
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 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 ActiveQuery::findOne()}, {@see ActiveQuery::findBySql()},
46
 * {@see ActiveQuery::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 additional 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 the
93
 * 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 is 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 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
    public function prepare(QueryBuilderInterface $builder): QueryInterface
153 497
    {
154 80
        /**
155
         * NOTE: because the same ActiveQuery may be used to build different SQL statements, one for count query, the
156 80
         * other for row data query, it is important to make sure the same ActiveQuery can be used to build SQL
157
         * statements multiple times.
158
         */
159 497
        if (!empty($this->joinWith)) {
160 489
            $this->buildJoinWith();
161
            /** clean it up to avoid issue {@see https://github.com/yiisoft/yii2/issues/2687} */
162
            $this->joinWith = [];
163 497
        }
164 76
165
        if (empty($this->getFrom())) {
166 76
            $this->from = [$this->getPrimaryTableName()];
167
        }
168
169 497
        if (empty($this->getSelect()) && !empty($this->getJoin())) {
170
            [, $alias] = $this->getTableNameAndAlias();
171 489
172
            $this->select(["$alias.*"]);
173
        }
174 113
175
        if ($this->primaryModel === null) {
176 113
            /** eager loading */
177
            $query = (new Query($this->db))
178 20
                ->where($this->getWhere())
179
                ->limit($this->getLimit())
180 20
                ->offset($this->getOffset())
181 101
                ->orderBy($this->getOrderBy())
182
                ->indexBy($this->getIndexBy())
183
                ->select($this->select)
184
                ->selectOption($this->selectOption)
185
                ->distinct($this->distinct)
186
                ->from($this->from)
187 28
                ->groupBy($this->groupBy)
188
                ->setJoin($this->join)
189 28
                ->having($this->having)
190 28
                ->setUnion($this->union)
191 20
                ->params($this->params)
192 8
                ->withQueries($this->withQueries);
193
        } else {
194
            /** lazy loading of a relation */
195 8
            $where = $this->getWhere();
196 28
197
            if ($this->via instanceof self) {
198
                /** via junction table */
199
                $viaModels = $this->via->findJunctionRows([$this->primaryModel]);
200
201
                $this->filterByModels($viaModels);
202
            } elseif (is_array($this->via)) {
203
                [$viaName, $viaQuery, $viaCallableUsed] = $this->via;
204
205
                if ($viaQuery->getMultiple()) {
206
                    if ($viaCallableUsed) {
207
                        $viaModels = $viaQuery->all();
208
                    } elseif ($this->primaryModel->isRelationPopulated($viaName)) {
209 28
                        $viaModels = $this->primaryModel->$viaName;
210
                    } else {
211 101
                        $viaModels = $viaQuery->all();
212
                        $this->primaryModel->populateRelation($viaName, $viaModels);
213
                    }
214 113
                } else {
215 113
                    if ($viaCallableUsed) {
216
                        $model = $viaQuery->onePopulate();
217
                    } elseif ($this->primaryModel->isRelationPopulated($viaName)) {
218 497
                        $model = $this->primaryModel->$viaName;
219 24
                    } else {
220
                        $model = $viaQuery->onePopulate();
221
                        $this->primaryModel->populateRelation($viaName, $model);
222 497
                    }
223
                    $viaModels = $model === null ? [] : [$model];
224
                }
225
                $this->filterByModels($viaModels);
226
            } else {
227
                $this->filterByModels([$this->primaryModel]);
228
            }
229
230
            $query = (new Query($this->db))
231
                ->where($this->getWhere())
232
                ->limit($this->getLimit())
233
                ->offset($this->getOffset())
234
                ->orderBy($this->getOrderBy())
235
                ->indexBy($this->getIndexBy())
236
                ->select($this->select)
237 419
                ->selectOption($this->selectOption)
238
                ->distinct($this->distinct)
239 419
                ->from($this->from)
240 74
                ->groupBy($this->groupBy)
241
                ->setJoin($this->join)
242
                ->having($this->having)
243 406
                ->setUnion($this->union)
244
                ->params($this->params)
245 406
                ->withQueries($this->withQueries);
246 64
            $this->where($where);
247
        }
248
249 406
        if (!empty($this->on)) {
250 131
            $query->andWhere($this->on);
251
        }
252
253 406
        return $query;
254 16
    }
255
256
    /**
257 406
     * Converts the raw query results into the format as specified by this query.
258
     *
259
     * This method is internally used to convert the data fetched from database into the format as required by this
260
     * query.
261
     *
262
     * @param array $rows the raw query result from database.
263
     *
264
     * @throws Exception
265
     * @throws InvalidArgumentException
266
     * @throws InvalidConfigException
267
     * @throws NotSupportedException
268
     * @throws ReflectionException
269
     * @throws Throwable
270
     *
271 64
     * @return array the converted query result.
272
     */
273 64
    public function populate(array $rows, Closure|string|null $indexBy = null): array
274
    {
275 64
        if (empty($rows)) {
276
            return [];
277 64
        }
278
279 8
        $models = $this->createModels($rows);
280 8
281 8
        if (!empty($this->join) && $this->getIndexBy() === null) {
282 8
            $models = $this->removeDuplicatedModels($models);
283
        }
284 4
285
        if (!empty($this->with)) {
286 7
            $this->findWith($this->with, $models);
287
        }
288
289 4
        if ($this->inverseOf !== null) {
290
            $this->addInverseRelations($models);
291 4
        }
292
293
        return ArrayHelper::populate($models, $indexBy);
294 4
    }
295
296
    /**
297 60
     * Removes duplicated models by checking their primary key values.
298
     *
299
     * This method is mainly called when a join query is performed, which may cause duplicated rows being returned.
300
     *
301 60
     * @param array $models the models to be checked.
302
     *
303 60
     * @throws CircularReferenceException
304 60
     * @throws Exception
305
     * @throws InvalidConfigException
306 4
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
307
     * @throws NotFoundException
308
     * @throws NotInstantiableException
309 56
     *
310
     * @return array the distinctive models.
311 56
     */
312 28
    private function removeDuplicatedModels(array $models): array
313 56
    {
314 56
        $hash = [];
315
316
        $pks = $this->getARInstance()->primaryKey();
317
318
        if (count($pks) > 1) {
319 64
            /** composite primary key */
320
            foreach ($models as $i => $model) {
321
                $key = [];
322
                foreach ($pks as $pk) {
323
                    if (!isset($model[$pk])) {
324
                        /** do not continue if the primary key is not part of the result set */
325
                        break 2;
326
                    }
327
                    $key[] = $model[$pk];
328
                }
329
330
                $key = serialize($key);
331
332 305
                if (isset($hash[$key])) {
333
                    unset($models[$i]);
334 305
                } else {
335
                    $hash[$key] = true;
336 305
                }
337 301
            }
338
        } elseif (empty($pks)) {
339 301
            throw new InvalidConfigException("Primary key of '$this->arClass' can not be empty.");
340
        } else {
341
            /** single column primary key */
342 32
            $pk = reset($pks);
343
344
            foreach ($models as $i => $model) {
345
                if (!isset($model[$pk])) {
346
                    /** do not continue if the primary key is not part of the result set */
347
                    break;
348
                }
349
350
                $key = $model[$pk];
351
352 449
                if (isset($hash[$key])) {
353
                    unset($models[$i]);
354 449
                } else {
355 445
                    $hash[$key] = true;
356
                }
357 4
            }
358 4
        }
359
360
        return array_values($models);
361 449
    }
362
363 449
    public function allPopulate(): array
364
    {
365 449
        $rows = $this->all();
366
367
        if ($rows !== []) {
368
            $rows = $this->populate($rows, $this->indexBy);
369
        }
370
371
        return $rows;
372
    }
373
374
    public function onePopulate(): array|ActiveRecordInterface|null
375
    {
376
        $row = $this->one();
377
378
        if ($row !== null) {
379 61
            $activeRecord = $this->populate([$row], $this->indexBy);
380
            $row = reset($activeRecord) ?: null;
381 61
        }
382 57
383
        return $row;
384
    }
385 4
386 4
    /**
387 4
     * Creates a DB command that can be used to execute this query.
388 4
     *
389
     * @throws Exception
390 4
     *
391
     * @return CommandInterface the created DB command instance.
392 4
     */
393
    public function createCommand(): CommandInterface
394
    {
395
        if ($this->sql === null) {
396
            [$sql, $params] = $this->db->getQueryBuilder()->build($this);
397
        } else {
398
            $sql = $this->sql;
399
            $params = $this->params;
400
        }
401
402
        return $this->db->createCommand($sql, $params);
403
    }
404
405
    /**
406
     * Queries a scalar value by setting {@see select} first.
407
     *
408
     * Restores the value of select to make this query reusable.
409
     *
410
     * @param ExpressionInterface|string $selectExpression
411
     *
412
     * @throws Exception
413
     * @throws InvalidConfigException
414
     * @throws Throwable
415
     */
416
    protected function queryScalar(string|ExpressionInterface $selectExpression): bool|string|null|int|float
417
    {
418
        if ($this->sql === null) {
419
            return parent::queryScalar($selectExpression);
420
        }
421
422
        $command = (new Query($this->db))->select([$selectExpression])
423
            ->from(['c' => "($this->sql)"])
424
            ->params($this->params)
425
            ->createCommand();
426
427
        return $command->queryScalar();
428
    }
429
430
    /**
431
     * Joins with the specified relations.
432
     *
433
     * This method allows you to reuse existing relation definitions to perform JOIN queries. Based on the definition of
434
     * the specified relation(s), the method will append one or multiple JOIN statements to the current query.
435
     *
436
     * If the `$eagerLoading` parameter is true, the method will also perform eager loading for the specified relations,
437
     * which is equivalent to calling {@see with()} using the specified relations.
438
     *
439
     * Note that because a JOIN query will be performed, you are responsible to disambiguate column names.
440
     *
441
     * This method differs from {@see with()} in that it will build up and execute a JOIN SQL statement  for the primary
442
     * table. And when `$eagerLoading` is true, it will call {@see with()} in addition with the specified relations.
443
     *
444
     * @param array|string $with the relations to be joined. This can either be a string, representing a relation name
445
     * or an array with the following semantics:
446
     *
447
     * - Each array element represents a single relation.
448
     * - You may specify the relation name as the array key and provide an anonymous functions that can be used to
449
     *   modify the relation queries on-the-fly as the array value.
450
     * - If a relation query does not need modification, you may use the relation name as the array value.
451 99
     *
452
     * The relation name may optionally contain an alias for the relation table (e.g. `books b`).
453 99
     *
454
     * Sub-relations can also be specified, see {@see with()} for the syntax.
455 99
     *
456 99
     * In the following you find some examples:
457 95
     *
458 95
     * ```php
459
     * // find all orders that contain books, and eager loading "books".
460
     * $orderQuery = new ActiveQuery(Order::class, $db);
461 99
     * $orderQuery->joinWith('books', true, 'INNER JOIN')->all();
462
     *
463 20
     * // find all orders, eager loading "books", and sort the orders and books by the book names.
464
     * $orderQuery = new ActiveQuery(Order::class, $db);
465 20
     * $orderQuery->joinWith([
466
     *     'books' => function (ActiveQuery $query) {
467 20
     *         $query->orderBy('item.name');
468
     *     }
469 20
     * ])->all();
470
     *
471 20
     * // find all orders that contain books of the category 'Science fiction', using the alias "b" for the books table.
472 16
     * $order = new ActiveQuery(Order::class, $db);
473
     * $orderQuery->joinWith(['books b'], true, 'INNER JOIN')->where(['b.category' => 'Science fiction'])->all();
474 20
     * ```
475
     * @param array|bool $eagerLoading whether to eager load the relations specified in `$with`. When this is a boolean,
476
     * it applies to all relations specified in `$with`. Use an array to explicitly list which relations in `$with` need
477 99
     * to be eagerly loaded.  Note, that this does not mean, that the relations are populated from the query result. An
478 95
     * extra query will still be performed to bring in the related data. Defaults to `true`.
479
     * @param array|string $joinType the join type of the relations specified in `$with`.  When this is a string, it
480 48
     * applies to all relations specified in `$with`. Use an array in the format of `relationName => joinType` to
481
     * specify different join types for different relations.
482
     *
483
     * @return $this the query object itself.
484 99
     */
485
    public function joinWith(
486 99
        array|string $with,
487
        array|bool $eagerLoading = true,
488
        array|string $joinType = 'LEFT JOIN'
489 84
    ): self {
490
        $relations = [];
491 84
492
        foreach ((array) $with as $name => $callback) {
493 84
            if (is_int($name)) {
494
                $name = $callback;
495 84
                $callback = null;
496
            }
497 84
498 84
            if (preg_match('/^(.*?)(?:\s+AS\s+|\s+)(\w+)$/i', $name, $matches)) {
499
                /** relation is defined with an alias, adjust callback to apply alias */
500 84
                [, $relation, $alias] = $matches;
501
502
                $name = $relation;
503
504
                $callback = static function (self $query) use ($callback, $alias) {
505
                    $query->alias($alias);
506
507
                    if ($callback !== null) {
508
                        $callback($query);
509
                    }
510 84
                };
511 16
            }
512
513
            if ($callback === null) {
514 84
                $relations[] = $name;
515
            } else {
516
                $relations[$name] = $callback;
517
            }
518
        }
519
520
        $this->joinWith[] = [$relations, $eagerLoading, $joinType];
521 84
522
        return $this;
523 84
    }
524 84
525
    /**
526 84
     * @throws CircularReferenceException
527
     * @throws InvalidConfigException
528
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
529 84
     * @throws NotFoundException
530
     * @throws NotInstantiableException
531 84
     */
532 84
    public function buildJoinWith(): void
533 84
    {
534 84
        $join = $this->join;
535
536
        $this->join = [];
537
538 84
        $arClass = $this->getARInstance();
539
540 84
        foreach ($this->joinWith as [$with, $eagerLoading, $joinType]) {
541
            $this->joinWithRelations($arClass, $with, $joinType);
542
543
            if (is_array($eagerLoading)) {
544 84
                foreach ($with as $name => $callback) {
545
                    if (is_int($name)) {
546
                        if (!in_array($callback, $eagerLoading, true)) {
547
                            unset($with[$name]);
548
                        }
549
                    } elseif (!in_array($name, $eagerLoading, true)) {
550
                        unset($with[$name]);
551
                    }
552
                }
553
            } elseif (!$eagerLoading) {
554
                $with = [];
555
            }
556
557
            $this->with($with);
558
        }
559
560
        /**
561 53
         * Remove duplicated joins added by joinWithRelations that may be added e.g. when joining a relation and a via
562
         * relation at the same time.
563 53
         */
564
        $uniqueJoins = [];
565
566
        foreach ($this->join as $j) {
567
            $uniqueJoins[serialize($j)] = $j;
568
        }
569
        $this->join = array_values($uniqueJoins);
570
571
        /** {@see https://github.com/yiisoft/yii2/issues/16092 } */
572
        $uniqueJoinsByTableName = [];
573 84
574
        foreach ($this->join as $config) {
575 84
            $tableName = serialize($config[1]);
576
            if (!array_key_exists($tableName, $uniqueJoinsByTableName)) {
577 84
                $uniqueJoinsByTableName[$tableName] = $config;
578 84
            }
579 80
        }
580 80
581
        $this->join = array_values($uniqueJoinsByTableName);
582
583 84
        if (!empty($join)) {
584 84
            /** Append explicit join to joinWith() {@see https://github.com/yiisoft/yii2/issues/2880} */
585 84
            $this->join = empty($this->join) ? $join : array_merge($this->join, $join);
586
        }
587 84
    }
588 20
589 20
    /**
590 20
     * Inner joins with the specified relations.
591
     *
592 20
     * This is a shortcut method to {@see joinWith()} with the join type set as "INNER JOIN". Please refer to
593 12
     * {@see joinWith()} for detailed usage of this method.
594 12
     *
595
     * @param array|string $with the relations to be joined with.
596 8
     * @param array|bool $eagerLoading whether to eager load the relations. Note, that this does not mean, that the
597
     * relations are populated from the query result. An extra query will still be performed to bring in the related
598
     * data.
599 20
     *
600
     * @return $this the query object itself.
601 20
     *
602 20
     * {@see joinWith()}
603 20
     */
604
    public function innerJoinWith(array|string $with, array|bool $eagerLoading = true): self
605
    {
606 84
        return $this->joinWith($with, $eagerLoading, 'INNER JOIN');
607
    }
608 84
609 84
    /**
610
     * Modifies the current query by adding join fragments based on the given relations.
611 84
     *
612 48
     * @param ActiveRecordInterface $arClass the primary model.
613
     * @param array $with the relations to be joined.
614
     * @param array|string $joinType the join type.
615 84
     *
616 12
     * @throws CircularReferenceException
617
     * @throws InvalidConfigException
618
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
619 84
     * @throws NotFoundException
620
     * @throws NotInstantiableException
621
     */
622 84
    private function joinWithRelations(ActiveRecordInterface $arClass, array $with, array|string $joinType): void
623
    {
624
        $relations = [];
625
626
        foreach ($with as $name => $callback) {
627
            if (is_int($name)) {
628
                $name = $callback;
629
                $callback = null;
630
            }
631
632 84
            $primaryModel = $arClass;
633
            $parent = $this;
634 84
            $prefix = '';
635
636
            while (($pos = strpos($name, '.')) !== false) {
637
                $childName = substr($name, $pos + 1);
638 84
                $name = substr($name, 0, $pos);
639
                $fullName = $prefix === '' ? $name : "$prefix.$name";
640
641
                if (!isset($relations[$fullName])) {
642
                    $relations[$fullName] = $relation = $primaryModel->getRelation($name);
643
                    $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

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

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

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

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

1184
        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...
1185
    }
1186
}
1187