Passed
Pull Request — master (#231)
by Wilmer
03:13 queued 35s
created

ActiveQuery::populate()   B

Complexity

Conditions 8
Paths 25

Size

Total Lines 32
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 8

Importance

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

630
                    $this->joinWithRelation($parent, /** @scrutinizer ignore-type */ $relation, $this->getJoinType($joinType, $fullName));
Loading history...
631
                } else {
632 84
                    $relation = $relations[$fullName];
633
                }
634 84
635
                $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

635
                /** @scrutinizer ignore-call */ 
636
                $primaryModel = $relation->getARInstance();
Loading history...
636
637
                $parent = $relation;
638 84
                $prefix = $fullName;
639
                $name = $childName;
640
            }
641
642
            $fullName = $prefix === '' ? $name : "$prefix.$name";
643
644
            if (!isset($relations[$fullName])) {
645
                $relations[$fullName] = $relation = $primaryModel->getRelation($name);
646 113
647
                if ($callback !== null) {
648 113
                    $callback($relation);
649 104
                }
650
651 89
                if (!empty($relation->getJoinWith())) {
652
                    $relation->buildJoinWith();
653 89
                }
654 89
655 28
                $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

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

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

1172
        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...
1173
    }
1174
}
1175