Passed
Pull Request — master (#239)
by Wilmer
03:33 queued 50s
created

ActiveQuery::joinWithRelations()   B

Complexity

Conditions 10
Paths 101

Size

Total Lines 47
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 10.017

Importance

Changes 0
Metric Value
cc 10
eloc 29
c 0
b 0
f 0
nc 101
nop 3
dl 0
loc 47
ccs 17
cts 18
cp 0.9444
crap 10.017
rs 7.6583

How to fix   Complexity   

Long Method

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

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

Commonly applied refactorings include:

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

616
                    $this->joinWithRelation($parent, /** @scrutinizer ignore-type */ $relation, $this->getJoinType($joinType, $fullName));
Loading history...
617
                } else {
618
                    $relation = $relations[$fullName];
619 84
                }
620
621
                $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

621
                /** @scrutinizer ignore-call */ 
622
                $primaryModel = $relation->getARInstance();
Loading history...
622 84
623
                $parent = $relation;
624
                $prefix = $fullName;
625
                $name = $childName;
626
            }
627
628
            $fullName = $prefix === '' ? $name : "$prefix.$name";
629
630
            if (!isset($relations[$fullName])) {
631
                $relations[$fullName] = $relation = $primaryModel->getRelation($name);
632 84
633
                if ($callback !== null) {
634 84
                    $callback($relation);
635
                }
636
637
                if (!empty($relation->getJoinWith())) {
638 84
                    $relation->buildJoinWith();
639
                }
640
641
                $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

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

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

1159
        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...
1160
    }
1161
}
1162