Passed
Pull Request — master (#266)
by Def
11:38
created

ActiveQuery   F

Complexity

Total Complexity 142

Size/Duplication

Total Lines 904
Duplicated Lines 0 %

Test Coverage

Coverage 91.92%

Importance

Changes 7
Bugs 0 Features 0
Metric Value
wmc 142
eloc 339
c 7
b 0
f 0
dl 0
loc 904
ccs 273
cts 297
cp 0.9192
rs 2

38 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 7 1
A all() 0 7 2
C prepare() 0 72 15
A batch() 0 4 1
A each() 0 4 1
B findByCondition() 0 33 7
B populate() 0 25 7
A orOnCondition() 0 11 2
A getPrimaryTableName() 0 3 1
A alias() 0 17 5
A getJoinType() 0 7 4
A resetJoinWith() 0 3 1
A getTableNameAndAlias() 0 22 5
A sql() 0 4 1
F joinWithRelation() 0 85 18
A createInstance() 0 18 1
A getTablesUsedInFrom() 0 7 2
A innerJoinWith() 0 3 1
F joinWithRelations() 0 52 14
A andOnCondition() 0 11 2
A findOne() 0 3 1
A findAll() 0 3 1
A findBySql() 0 3 1
A onePopulate() 0 10 3
A getARInstance() 0 10 2
A joinWith() 0 38 6
A getARClass() 0 3 1
A queryScalar() 0 12 2
A viaTable() 0 15 2
C buildJoinWith() 0 58 13
B removeDuplicatedModels() 0 49 10
A createCommand() 0 10 2
A getOn() 0 3 1
A allPopulate() 0 9 2
A getSql() 0 3 1
A on() 0 4 1
A getJoinWith() 0 3 1
A onCondition() 0 7 1

How to fix   Complexity   

Complex Class

Complex classes like ActiveQuery often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ActiveQuery, and based on these observations, apply Extract Interface, too.

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

723
        $this->join($joinType, empty($child->getFrom()) ? $childTable : $child->getFrom(), /** @scrutinizer ignore-type */ $on ?? '');
Loading history...
724
725
        $where = $child->getWhere();
726
727 84
        if (!empty($where)) {
728
            $this->andWhere($where);
729 84
        }
730 28
731
        $having = $child->getHaving();
732
733 84
        if (!empty($having)) {
734
            $this->andHaving($having);
735
        }
736
737 84
        if (!empty($child->getOrderBy())) {
738 32
            $this->addOrderBy($child->getOrderBy());
739
        }
740
741 84
        if (!empty($child->getGroupBy())) {
742
            $this->addGroupBy($child->getGroupBy());
743
        }
744
745 84
        if (!empty($child->getParams())) {
746
            $this->addParams($child->getParams());
747
        }
748
749 84
        if (!empty($child->getJoins())) {
750 12
            foreach ($child->getJoins() as $join) {
751 12
                $this->join[] = $join;
752
            }
753
        }
754
755 84
        if (!empty($child->getUnions())) {
756
            foreach ($child->getUnions() as $union) {
757
                $this->union[] = $union;
758
            }
759
        }
760 84
    }
761
762
    public function onCondition(array|string $condition, array $params = []): self
763
    {
764
        $this->on = $condition;
765
766
        $this->addParams($params);
767
768
        return $this;
769
    }
770
771
    public function andOnCondition(array|string $condition, array $params = []): self
772
    {
773
        if ($this->on === null) {
774
            $this->on = $condition;
775
        } else {
776
            $this->on = ['and', $this->on, $condition];
777
        }
778
779
        $this->addParams($params);
780
781
        return $this;
782
    }
783
784
    public function orOnCondition(array|string $condition, array $params = []): self
785
    {
786
        if ($this->on === null) {
787
            $this->on = $condition;
788 29
        } else {
789
            $this->on = ['or', $this->on, $condition];
790 29
        }
791
792 29
        $this->addParams($params);
793
794 29
        return $this;
795
    }
796
797
    public function viaTable(string $tableName, array $link, callable $callable = null): self
798
    {
799
        $arClass = $this->primaryModel::class ?? $this->arClass;
800
        $arClassInstance = new self($arClass, $this->db);
801
802
        /** @psalm-suppress UndefinedMethod */
803
        $relation = $arClassInstance->from([$tableName])->link($link)->multiple(true)->asArray();
804
805
        $this->via = $relation;
806
807
        if ($callable !== null) {
808
            $callable($relation);
809
        }
810
811 10
        return $this;
812
    }
813 10
814 5
    public function alias(string $alias): self
815
    {
816 5
        if (empty($this->from) || count($this->from) < 2) {
817
            [$tableName] = $this->getTableNameAndAlias();
818
            $this->from = [$alias => $tableName];
819 10
        } else {
820
            $tableName = $this->getPrimaryTableName();
821 10
822
            foreach ($this->from as $key => $table) {
823
                if ($table === $tableName) {
824
                    unset($this->from[$key]);
825
                    $this->from[$alias] = $tableName;
826
                }
827
            }
828
        }
829
830
        return $this;
831
    }
832
833
    /**
834
     * @throws CircularReferenceException
835
     * @throws InvalidArgumentException
836
     * @throws NotFoundException
837
     * @throws NotInstantiableException
838 10
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
839
     */
840 10
    public function getTablesUsedInFrom(): array
841 5
    {
842
        if (empty($this->from)) {
843 5
            return $this->db->getQuoter()->cleanUpTableNames([$this->getPrimaryTableName()]);
844
        }
845
846 10
        return parent::getTablesUsedInFrom();
847
    }
848 10
849
    /**
850
     * @throws CircularReferenceException
851
     * @throws NotFoundException
852
     * @throws NotInstantiableException
853
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
854
     */
855
    protected function getPrimaryTableName(): string
856
    {
857
        return $this->getARInstance()->getTableName();
858
    }
859
860
    public function getOn(): array|string|null
861
    {
862
        return $this->on;
863
    }
864
865
    /**
866
     * @return array $value A list of relations that this query should be joined with.
867
     */
868
    public function getJoinWith(): array
869
    {
870
        return $this->joinWith;
871
    }
872
873
    public function getSql(): string|null
874 32
    {
875
        return $this->sql;
876 32
    }
877
878 32
    public function getARClass(): string|null
879
    {
880 32
        return $this->arClass;
881
    }
882 32
883
    /**
884 32
     * @throws Exception
885 8
     * @throws InvalidArgumentException
886
     * @throws InvalidConfigException
887
     * @throws Throwable
888 32
     */
889
    public function findOne(mixed $condition): array|ActiveRecordInterface|null
890
    {
891
        return $this->findByCondition($condition)->onePopulate();
892
    }
893
894
    /**
895
     * @param mixed $condition The primary key value or a set of column values.
896
     *
897
     * @throws Exception
898
     * @throws InvalidArgumentException
899
     * @throws InvalidConfigException
900
     * @throws Throwable
901 41
     *
902
     * @return array Of ActiveRecord instance, or an empty array if nothing matches.
903 41
     */
904 41
    public function findAll(mixed $condition): array
905 41
    {
906
        return $this->findByCondition($condition)->all();
907 4
    }
908
909 4
    /**
910 4
     * Finds ActiveRecord instance(s) by the given condition.
911 4
     *
912 4
     * This method is internally called by {@see findOne()} and {@see findAll()}.
913
     *
914
     * @param mixed $condition Please refer to {@see findOne()} for the explanation of this parameter.
915
     *
916
     * @throws CircularReferenceException
917 41
     * @throws Exception
918
     * @throws InvalidArgumentException
919
     * @throws NotFoundException
920
     * @throws NotInstantiableException
921
     */
922
    protected function findByCondition(mixed $condition): static
923
    {
924
        $arInstance = $this->getARInstance();
925
926
        if (!is_array($condition)) {
927
            $condition = [$condition];
928
        }
929 168
930
        if (!DbArrayHelper::isAssociative($condition) && !$condition instanceof ExpressionInterface) {
931 168
            /** query by primary key */
932 136
            $primaryKey = $arInstance->primaryKey();
933
934
            if (isset($primaryKey[0])) {
935 44
                $pk = $primaryKey[0];
936
937
                if (!empty($this->getJoins()) || !empty($this->getJoinWith())) {
938 553
                    $pk = $arInstance->getTableName() . '.' . $pk;
939
                }
940 553
941
                /**
942
                 * if the condition is scalar, search for a single primary key, if it's array, search for many primary
943
                 * key values.
944
                 */
945
                $condition = [$pk => array_values($condition)];
946
            } else {
947
                throw new InvalidConfigException('"' . $arInstance::class . '" must have a primary key.');
948
            }
949
        } else {
950
            $aliases = $arInstance->filterValidAliases($this);
951
            $condition = $arInstance->filterCondition($condition, $aliases);
952
        }
953 52
954
        return $this->where($condition);
955 52
    }
956
957
    public function findBySql(string $sql, array $params = []): self
958
    {
959
        return $this->sql($sql)->params($params);
960
    }
961 204
962
    public function on(array|string|null $value): self
963 204
    {
964
        $this->on = $value;
965
        return $this;
966
    }
967
968
    public function sql(string|null $value): self
969
    {
970
        $this->sql = $value;
971 4
        return $this;
972
    }
973 4
974
    public function getARInstance(): ActiveRecordInterface
975
    {
976 5
        if ($this->arFactory !== null) {
977
            return $this->arFactory->createAR($this->arClass, $this->tableName, $this->db);
978 5
        }
979
980
        /** @psalm-var class-string<ActiveRecordInterface> $class */
981
        $class = $this->arClass;
982
983
        return new $class($this->db, null, $this->tableName);
984
    }
985
986
    private function createInstance(): static
987
    {
988 228
        return (new static($this->arClass, $this->db))
989
            ->where($this->getWhere())
990 228
            ->limit($this->getLimit())
991
            ->offset($this->getOffset())
992
            ->orderBy($this->getOrderBy())
993
            ->indexBy($this->getIndexBy())
994
            ->select($this->select)
995
            ->selectOption($this->selectOption)
996
            ->distinct($this->distinct)
997
            ->from($this->from)
998
            ->groupBy($this->groupBy)
999
            ->setJoins($this->join)
1000 5
            ->having($this->having)
1001
            ->setUnions($this->union)
1002 5
            ->params($this->params)
1003
            ->withQueries($this->withQueries);
1004
    }
1005
}
1006