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

ActiveQuery::each()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 1 Features 0
Metric Value
eloc 2
c 1
b 1
f 0
dl 0
loc 4
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)
145
            ->setPopulatedMethod(fn($rows, $indexBy) => $this->populate($rows, $indexBy));
0 ignored issues
show
Bug introduced by
The method setPopulatedMethod() does not exist on Yiisoft\Db\Query\BatchQueryResultInterface. ( Ignorable by Annotation )

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

145
            ->/** @scrutinizer ignore-call */ setPopulatedMethod(fn($rows, $indexBy) => $this->populate($rows, $indexBy));

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...
146 497
    }
147
148
    public function each(int $batchSize = 100): BatchQueryResultInterface
149
    {
150
        return parent::each($batchSize)
151
            ->setPopulatedMethod(fn($rows, $indexBy) => $this->populate($rows, $indexBy));
152
    }
153 497
154 80
    public function prepare(QueryBuilderInterface $builder): QueryInterface
155
    {
156 80
        /**
157
         * NOTE: because the same ActiveQuery may be used to build different SQL statements, one for count query, the
158
         * other for row data query, it is important to make sure the same ActiveQuery can be used to build SQL
159 497
         * statements multiple times.
160 489
         */
161
        if (!empty($this->joinWith)) {
162
            $this->buildJoinWith();
163 497
            /** clean it up to avoid issue {@see https://github.com/yiisoft/yii2/issues/2687} */
164 76
            $this->joinWith = [];
165
        }
166 76
167
        if (empty($this->getFrom())) {
168
            $this->from = [$this->getPrimaryTableName()];
169 497
        }
170
171 489
        if (empty($this->getSelect()) && !empty($this->getJoin())) {
172
            [, $alias] = $this->getTableNameAndAlias();
173
174 113
            $this->select(["$alias.*"]);
175
        }
176 113
177
        if ($this->primaryModel === null) {
178 20
            /** eager loading */
179
            $query = (new Query($this->db))
180 20
                ->where($this->getWhere())
181 101
                ->limit($this->getLimit())
182
                ->offset($this->getOffset())
183
                ->orderBy($this->getOrderBy())
184
                ->indexBy($this->getIndexBy())
185
                ->select($this->select)
186
                ->selectOption($this->selectOption)
187 28
                ->distinct($this->distinct)
188
                ->from($this->from)
189 28
                ->groupBy($this->groupBy)
190 28
                ->setJoin($this->join)
191 20
                ->having($this->having)
192 8
                ->setUnion($this->union)
193
                ->params($this->params)
194
                ->withQueries($this->withQueries);
195 8
        } else {
196 28
            /** lazy loading of a relation */
197
            $where = $this->getWhere();
198
199
            if ($this->via instanceof self) {
200
                /** via junction table */
201
                $viaModels = $this->via->findJunctionRows([$this->primaryModel]);
202
203
                $this->filterByModels($viaModels);
204
            } elseif (is_array($this->via)) {
205
                [$viaName, $viaQuery, $viaCallableUsed] = $this->via;
206
207
                if ($viaQuery->getMultiple()) {
208
                    if ($viaCallableUsed) {
209 28
                        $viaModels = $viaQuery->all();
210
                    } elseif ($this->primaryModel->isRelationPopulated($viaName)) {
211 101
                        $viaModels = $this->primaryModel->$viaName;
212
                    } else {
213
                        $viaModels = $viaQuery->all();
214 113
                        $this->primaryModel->populateRelation($viaName, $viaModels);
215 113
                    }
216
                } else {
217
                    if ($viaCallableUsed) {
218 497
                        $model = $viaQuery->onePopulate();
219 24
                    } elseif ($this->primaryModel->isRelationPopulated($viaName)) {
220
                        $model = $this->primaryModel->$viaName;
221
                    } else {
222 497
                        $model = $viaQuery->onePopulate();
223
                        $this->primaryModel->populateRelation($viaName, $model);
224
                    }
225
                    $viaModels = $model === null ? [] : [$model];
226
                }
227
                $this->filterByModels($viaModels);
228
            } else {
229
                $this->filterByModels([$this->primaryModel]);
230
            }
231
232
            $query = (new Query($this->db))
233
                ->where($this->getWhere())
234
                ->limit($this->getLimit())
235
                ->offset($this->getOffset())
236
                ->orderBy($this->getOrderBy())
237 419
                ->indexBy($this->getIndexBy())
238
                ->select($this->select)
239 419
                ->selectOption($this->selectOption)
240 74
                ->distinct($this->distinct)
241
                ->from($this->from)
242
                ->groupBy($this->groupBy)
243 406
                ->setJoin($this->join)
244
                ->having($this->having)
245 406
                ->setUnion($this->union)
246 64
                ->params($this->params)
247
                ->withQueries($this->withQueries);
248
            $this->where($where);
249 406
        }
250 131
251
        if (!empty($this->on)) {
252
            $query->andWhere($this->on);
253 406
        }
254 16
255
        return $query;
256
    }
257 406
258
    /**
259
     * Converts the raw query results into the format as specified by this query.
260
     *
261
     * This method is internally used to convert the data fetched from database into the format as required by this
262
     * query.
263
     *
264
     * @param array $rows the raw query result from database.
265
     *
266
     * @throws Exception
267
     * @throws InvalidArgumentException
268
     * @throws InvalidConfigException
269
     * @throws NotSupportedException
270
     * @throws ReflectionException
271 64
     * @throws Throwable
272
     *
273 64
     * @return array the converted query result.
274
     */
275 64
    public function populate(array $rows, Closure|string|null $indexBy = null): array
276
    {
277 64
        if (empty($rows)) {
278
            return [];
279 8
        }
280 8
281 8
        $models = $this->createModels($rows);
282 8
283
        if (!empty($this->join) && $this->getIndexBy() === null) {
284 4
            $models = $this->removeDuplicatedModels($models);
285
        }
286 7
287
        if (!empty($this->with)) {
288
            $this->findWith($this->with, $models);
289 4
        }
290
291 4
        if ($this->inverseOf !== null) {
292
            $this->addInverseRelations($models);
293
        }
294 4
295
        return ArrayHelper::populate($models, $indexBy);
0 ignored issues
show
Bug introduced by
The method populate() does not exist on Yiisoft\Db\Helper\ArrayHelper. ( Ignorable by Annotation )

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

295
        return ArrayHelper::/** @scrutinizer ignore-call */ populate($models, $indexBy);

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...
296
    }
297 60
298
    /**
299
     * Removes duplicated models by checking their primary key values.
300
     *
301 60
     * This method is mainly called when a join query is performed, which may cause duplicated rows being returned.
302
     *
303 60
     * @param array $models the models to be checked.
304 60
     *
305
     * @throws CircularReferenceException
306 4
     * @throws Exception
307
     * @throws InvalidConfigException
308
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
309 56
     * @throws NotFoundException
310
     * @throws NotInstantiableException
311 56
     *
312 28
     * @return array the distinctive models.
313 56
     */
314 56
    private function removeDuplicatedModels(array $models): array
315
    {
316
        $hash = [];
317
318
        $pks = $this->getARInstance()->primaryKey();
319 64
320
        if (count($pks) > 1) {
321
            /** composite primary key */
322
            foreach ($models as $i => $model) {
323
                $key = [];
324
                foreach ($pks as $pk) {
325
                    if (!isset($model[$pk])) {
326
                        /** do not continue if the primary key is not part of the result set */
327
                        break 2;
328
                    }
329
                    $key[] = $model[$pk];
330
                }
331
332 305
                $key = serialize($key);
333
334 305
                if (isset($hash[$key])) {
335
                    unset($models[$i]);
336 305
                } else {
337 301
                    $hash[$key] = true;
338
                }
339 301
            }
340
        } elseif (empty($pks)) {
341
            throw new InvalidConfigException("Primary key of '$this->arClass' can not be empty.");
342 32
        } else {
343
            /** single column primary key */
344
            $pk = reset($pks);
345
346
            foreach ($models as $i => $model) {
347
                if (!isset($model[$pk])) {
348
                    /** do not continue if the primary key is not part of the result set */
349
                    break;
350
                }
351
352 449
                $key = $model[$pk];
353
354 449
                if (isset($hash[$key])) {
355 445
                    unset($models[$i]);
356
                } else {
357 4
                    $hash[$key] = true;
358 4
                }
359
            }
360
        }
361 449
362
        return array_values($models);
363 449
    }
364
365 449
    public function allPopulate(): array
366
    {
367
        $rows = $this->all();
368
369
        if ($rows !== []) {
370
            $rows = $this->populate($rows, $this->indexBy);
371
        }
372
373
        return $rows;
374
    }
375
376
    public function onePopulate(): array|ActiveRecordInterface|null
377
    {
378
        $row = $this->one();
379 61
380
        if ($row !== null) {
381 61
            $activeRecord = $this->populate([$row], $this->indexBy);
382 57
            $row = reset($activeRecord) ?: null;
383
        }
384
385 4
        return $row;
386 4
    }
387 4
388 4
    /**
389
     * Creates a DB command that can be used to execute this query.
390 4
     *
391
     * @throws Exception
392 4
     *
393
     * @return CommandInterface the created DB command instance.
394
     */
395
    public function createCommand(): CommandInterface
396
    {
397
        if ($this->sql === null) {
398
            [$sql, $params] = $this->db->getQueryBuilder()->build($this);
399
        } else {
400
            $sql = $this->sql;
401
            $params = $this->params;
402
        }
403
404
        return $this->db->createCommand($sql, $params);
405
    }
406
407
    /**
408
     * Queries a scalar value by setting {@see select} first.
409
     *
410
     * Restores the value of select to make this query reusable.
411
     *
412
     * @param ExpressionInterface|string $selectExpression
413
     *
414
     * @throws Exception
415
     * @throws InvalidConfigException
416
     * @throws Throwable
417
     */
418
    protected function queryScalar(string|ExpressionInterface $selectExpression): bool|string|null|int|float
419
    {
420
        if ($this->sql === null) {
421
            return parent::queryScalar($selectExpression);
422
        }
423
424
        $command = (new Query($this->db))->select([$selectExpression])
425
            ->from(['c' => "($this->sql)"])
426
            ->params($this->params)
427
            ->createCommand();
428
429
        return $command->queryScalar();
430
    }
431
432
    /**
433
     * Joins with the specified relations.
434
     *
435
     * This method allows you to reuse existing relation definitions to perform JOIN queries. Based on the definition of
436
     * the specified relation(s), the method will append one or multiple JOIN statements to the current query.
437
     *
438
     * If the `$eagerLoading` parameter is true, the method will also perform eager loading for the specified relations,
439
     * which is equivalent to calling {@see with()} using the specified relations.
440
     *
441
     * Note that because a JOIN query will be performed, you are responsible to disambiguate column names.
442
     *
443
     * This method differs from {@see with()} in that it will build up and execute a JOIN SQL statement  for the primary
444
     * table. And when `$eagerLoading` is true, it will call {@see with()} in addition with the specified relations.
445
     *
446
     * @param array|string $with the relations to be joined. This can either be a string, representing a relation name
447
     * or an array with the following semantics:
448
     *
449
     * - Each array element represents a single relation.
450
     * - You may specify the relation name as the array key and provide an anonymous functions that can be used to
451 99
     *   modify the relation queries on-the-fly as the array value.
452
     * - If a relation query does not need modification, you may use the relation name as the array value.
453 99
     *
454
     * The relation name may optionally contain an alias for the relation table (e.g. `books b`).
455 99
     *
456 99
     * Sub-relations can also be specified, see {@see with()} for the syntax.
457 95
     *
458 95
     * In the following you find some examples:
459
     *
460
     * ```php
461 99
     * // find all orders that contain books, and eager loading "books".
462
     * $orderQuery = new ActiveQuery(Order::class, $db);
463 20
     * $orderQuery->joinWith('books', true, 'INNER JOIN')->all();
464
     *
465 20
     * // find all orders, eager loading "books", and sort the orders and books by the book names.
466
     * $orderQuery = new ActiveQuery(Order::class, $db);
467 20
     * $orderQuery->joinWith([
468
     *     'books' => function (ActiveQuery $query) {
469 20
     *         $query->orderBy('item.name');
470
     *     }
471 20
     * ])->all();
472 16
     *
473
     * // find all orders that contain books of the category 'Science fiction', using the alias "b" for the books table.
474 20
     * $order = new ActiveQuery(Order::class, $db);
475
     * $orderQuery->joinWith(['books b'], true, 'INNER JOIN')->where(['b.category' => 'Science fiction'])->all();
476
     * ```
477 99
     * @param array|bool $eagerLoading whether to eager load the relations specified in `$with`. When this is a boolean,
478 95
     * it applies to all relations specified in `$with`. Use an array to explicitly list which relations in `$with` need
479
     * to be eagerly loaded.  Note, that this does not mean, that the relations are populated from the query result. An
480 48
     * extra query will still be performed to bring in the related data. Defaults to `true`.
481
     * @param array|string $joinType the join type of the relations specified in `$with`.  When this is a string, it
482
     * applies to all relations specified in `$with`. Use an array in the format of `relationName => joinType` to
483
     * specify different join types for different relations.
484 99
     *
485
     * @return $this the query object itself.
486 99
     */
487
    public function joinWith(
488
        array|string $with,
489 84
        array|bool $eagerLoading = true,
490
        array|string $joinType = 'LEFT JOIN'
491 84
    ): self {
492
        $relations = [];
493 84
494
        foreach ((array) $with as $name => $callback) {
495 84
            if (is_int($name)) {
496
                $name = $callback;
497 84
                $callback = null;
498 84
            }
499
500 84
            if (preg_match('/^(.*?)(?:\s+AS\s+|\s+)(\w+)$/i', $name, $matches)) {
501
                /** relation is defined with an alias, adjust callback to apply alias */
502
                [, $relation, $alias] = $matches;
503
504
                $name = $relation;
505
506
                $callback = static function (self $query) use ($callback, $alias) {
507
                    $query->alias($alias);
508
509
                    if ($callback !== null) {
510 84
                        $callback($query);
511 16
                    }
512
                };
513
            }
514 84
515
            if ($callback === null) {
516
                $relations[] = $name;
517
            } else {
518
                $relations[$name] = $callback;
519
            }
520
        }
521 84
522
        $this->joinWith[] = [$relations, $eagerLoading, $joinType];
523 84
524 84
        return $this;
525
    }
526 84
527
    /**
528
     * @throws CircularReferenceException
529 84
     * @throws InvalidConfigException
530
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
531 84
     * @throws NotFoundException
532 84
     * @throws NotInstantiableException
533 84
     */
534 84
    public function buildJoinWith(): void
535
    {
536
        $join = $this->join;
537
538 84
        $this->join = [];
539
540 84
        $arClass = $this->getARInstance();
541
542
        foreach ($this->joinWith as [$with, $eagerLoading, $joinType]) {
543
            $this->joinWithRelations($arClass, $with, $joinType);
544 84
545
            if (is_array($eagerLoading)) {
546
                foreach ($with as $name => $callback) {
547
                    if (is_int($name)) {
548
                        if (!in_array($callback, $eagerLoading, true)) {
549
                            unset($with[$name]);
550
                        }
551
                    } elseif (!in_array($name, $eagerLoading, true)) {
552
                        unset($with[$name]);
553
                    }
554
                }
555
            } elseif (!$eagerLoading) {
556
                $with = [];
557
            }
558
559
            $this->with($with);
560
        }
561 53
562
        /**
563 53
         * Remove duplicated joins added by joinWithRelations that may be added e.g. when joining a relation and a via
564
         * relation at the same time.
565
         */
566
        $uniqueJoins = [];
567
568
        foreach ($this->join as $j) {
569
            $uniqueJoins[serialize($j)] = $j;
570
        }
571
        $this->join = array_values($uniqueJoins);
572
573 84
        /** {@see https://github.com/yiisoft/yii2/issues/16092 } */
574
        $uniqueJoinsByTableName = [];
575 84
576
        foreach ($this->join as $config) {
577 84
            $tableName = serialize($config[1]);
578 84
            if (!array_key_exists($tableName, $uniqueJoinsByTableName)) {
579 80
                $uniqueJoinsByTableName[$tableName] = $config;
580 80
            }
581
        }
582
583 84
        $this->join = array_values($uniqueJoinsByTableName);
584 84
585 84
        if (!empty($join)) {
586
            /** Append explicit join to joinWith() {@see https://github.com/yiisoft/yii2/issues/2880} */
587 84
            $this->join = empty($this->join) ? $join : array_merge($this->join, $join);
588 20
        }
589 20
    }
590 20
591
    /**
592 20
     * Inner joins with the specified relations.
593 12
     *
594 12
     * This is a shortcut method to {@see joinWith()} with the join type set as "INNER JOIN". Please refer to
595
     * {@see joinWith()} for detailed usage of this method.
596 8
     *
597
     * @param array|string $with the relations to be joined with.
598
     * @param array|bool $eagerLoading whether to eager load the relations. Note, that this does not mean, that the
599 20
     * relations are populated from the query result. An extra query will still be performed to bring in the related
600
     * data.
601 20
     *
602 20
     * @return $this the query object itself.
603 20
     *
604
     * {@see joinWith()}
605
     */
606 84
    public function innerJoinWith(array|string $with, array|bool $eagerLoading = true): self
607
    {
608 84
        return $this->joinWith($with, $eagerLoading, 'INNER JOIN');
609 84
    }
610
611 84
    /**
612 48
     * Modifies the current query by adding join fragments based on the given relations.
613
     *
614
     * @param ActiveRecordInterface $arClass the primary model.
615 84
     * @param array $with the relations to be joined.
616 12
     * @param array|string $joinType the join type.
617
     *
618
     * @throws CircularReferenceException
619 84
     * @throws InvalidConfigException
620
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
621
     * @throws NotFoundException
622 84
     * @throws NotInstantiableException
623
     */
624
    private function joinWithRelations(ActiveRecordInterface $arClass, array $with, array|string $joinType): void
625
    {
626
        $relations = [];
627
628
        foreach ($with as $name => $callback) {
629
            if (is_int($name)) {
630
                $name = $callback;
631
                $callback = null;
632 84
            }
633
634 84
            $primaryModel = $arClass;
635
            $parent = $this;
636
            $prefix = '';
637
638 84
            while (($pos = strpos($name, '.')) !== false) {
639
                $childName = substr($name, $pos + 1);
640
                $name = substr($name, 0, $pos);
641
                $fullName = $prefix === '' ? $name : "$prefix.$name";
642
643
                if (!isset($relations[$fullName])) {
644
                    $relations[$fullName] = $relation = $primaryModel->getRelation($name);
645
                    $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

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

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

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

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

1186
        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...
1187
    }
1188
}
1189