Passed
Push — master ( 1e7624...3c656f )
by Wilmer
10:52
created

ActiveQuery::prepare()   C

Complexity

Conditions 15
Paths 192

Size

Total Lines 72
Code Lines 42

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 25
CRAP Score 18.2046

Importance

Changes 4
Bugs 0 Features 0
Metric Value
cc 15
eloc 42
c 4
b 0
f 0
nc 192
nop 1
dl 0
loc 72
ccs 25
cts 33
cp 0.7576
crap 18.2046
rs 5.15

How to fix   Long Method    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 Closure;
8
use ReflectionException;
9
use Throwable;
10
use Yiisoft\Db\Command\CommandInterface;
11
use Yiisoft\Db\Connection\ConnectionInterface;
12
use Yiisoft\Db\Exception\Exception;
13
use Yiisoft\Db\Exception\InvalidArgumentException;
14
use Yiisoft\Db\Exception\InvalidConfigException;
15
use Yiisoft\Db\Exception\NotSupportedException;
16
use Yiisoft\Db\Expression\ExpressionInterface;
17
use Yiisoft\Db\Helper\ArrayHelper;
18
use Yiisoft\Db\Query\BatchQueryResultInterface;
19
use Yiisoft\Db\Query\Query;
20
use Yiisoft\Db\Query\QueryInterface;
21
use Yiisoft\Db\QueryBuilder\QueryBuilderInterface;
22
use Yiisoft\Definitions\Exception\CircularReferenceException;
23
use Yiisoft\Definitions\Exception\NotInstantiableException;
24
use Yiisoft\Factory\NotFoundException;
25
26
use function array_merge;
27
use function array_values;
28
use function count;
29
use function implode;
30
use function in_array;
31
use function is_array;
32
use function is_int;
33
use function is_string;
34
use function preg_match;
35
use function reset;
36
use function serialize;
37
use function strpos;
38
use function substr;
39
40
/**
41
 * 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
    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(fn ($rows, $indexBy) => $this->populate($rows, $indexBy));
144
    }
145
146 497
    public function each(int $batchSize = 100): BatchQueryResultInterface
147
    {
148
        return parent::each($batchSize)->setPopulatedMethod(fn ($rows, $indexBy) => $this->populate($rows, $indexBy));
149
    }
150
151
    /**
152
     * @throws CircularReferenceException
153 497
     * @throws Exception
154 80
     * @throws InvalidConfigException
155
     * @throws NotFoundException
156 80
     * @throws NotInstantiableException
157
     * @throws Throwable
158
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
159 497
     */
160 489
    public function prepare(QueryBuilderInterface $builder): QueryInterface
161
    {
162
        /**
163 497
         * NOTE: Because the same ActiveQuery may be used to build different SQL statements, one for count query, the
164 76
         * other for row data query, it's important to make sure the same ActiveQuery can be used to build SQL
165
         * statements many times.
166 76
         */
167
        if (!empty($this->joinWith)) {
168
            $this->buildJoinWith();
169 497
            /**
170
             * Clean it up to avoid issue @link https://github.com/yiisoft/yii2/issues/2687
171 489
             */
172
            $this->joinWith = [];
173
        }
174 113
175
        if (empty($this->getFrom())) {
176 113
            $this->from = [$this->getPrimaryTableName()];
177
        }
178 20
179
        if (empty($this->getSelect()) && !empty($this->getJoin())) {
180 20
            [, $alias] = $this->getTableNameAndAlias();
181 101
182
            $this->select(["$alias.*"]);
183
        }
184
185
        if ($this->primaryModel === null) {
186
            $query = $this->createInstance();
187 28
        } else {
188
            $where = $this->getWhere();
189 28
190 28
            if ($this->via instanceof self) {
191 20
                /** via junction table */
192 8
                $viaModels = $this->via->findJunctionRows([$this->primaryModel]);
193
194
                $this->filterByModels($viaModels);
195 8
            } elseif (is_array($this->via)) {
196 28
                [$viaName, $viaQuery, $viaCallableUsed] = $this->via;
197
198
                if ($viaQuery->getMultiple()) {
199
                    if ($viaCallableUsed) {
200
                        $viaModels = $viaQuery->all();
201
                    } elseif ($this->primaryModel->isRelationPopulated($viaName)) {
202
                        $viaModels = $this->primaryModel->$viaName;
203
                    } else {
204
                        $viaModels = $viaQuery->all();
205
                        $this->primaryModel->populateRelation($viaName, $viaModels);
206
                    }
207
                } else {
208
                    if ($viaCallableUsed) {
209 28
                        $model = $viaQuery->onePopulate();
210
                    } elseif ($this->primaryModel->isRelationPopulated($viaName)) {
211 101
                        $model = $this->primaryModel->$viaName;
212
                    } else {
213
                        $model = $viaQuery->onePopulate();
214 113
                        $this->primaryModel->populateRelation($viaName, $model);
215 113
                    }
216
                    $viaModels = $model === null ? [] : [$model];
217
                }
218 497
                $this->filterByModels($viaModels);
219 24
            } else {
220
                $this->filterByModels([$this->primaryModel]);
221
            }
222 497
223
            $query = $this->createInstance();
224
            $this->where($where);
225
        }
226
227
        if (!empty($this->on)) {
228
            $query->andWhere($this->on);
229
        }
230
231
        return $query;
232
    }
233
234
    /**
235
     * Converts the raw query results into the format as specified by this query.
236
     *
237 419
     * This method is internally used to convert the data fetched from a database into the format as required by this
238
     * query.
239 419
     *
240 74
     * @param array $rows The raw query result from a database.
241
     *
242
     * @throws Exception
243 406
     * @throws InvalidArgumentException
244
     * @throws InvalidConfigException
245 406
     * @throws NotSupportedException
246 64
     * @throws ReflectionException
247
     * @throws Throwable
248
     *
249 406
     * @return array The converted query result.
250 131
     */
251
    public function populate(array $rows, Closure|string|null $indexBy = null): array
252
    {
253 406
        if (empty($rows)) {
254 16
            return [];
255
        }
256
257 406
        $models = $this->createModels($rows);
258
259
        if (empty($models)) {
260
            return [];
261
        }
262
263
        if (!empty($this->join) && $this->getIndexBy() === null) {
264
            $models = $this->removeDuplicatedModels($models);
265
        }
266
267
        if (!empty($this->with)) {
268
            $this->findWith($this->with, $models);
269
        }
270
271 64
        if ($this->inverseOf !== null) {
272
            $this->addInverseRelations($models);
273 64
        }
274
275 64
        return ArrayHelper::populate($models, $indexBy);
276
    }
277 64
278
    /**
279 8
     * Removes duplicated models by checking their primary key values.
280 8
     *
281 8
     * This method is mainly called when a join query is performed, which may cause duplicated rows being returned.
282 8
     *
283
     * @param array $models The models to be checked.
284 4
     *
285
     * @throws CircularReferenceException
286 7
     * @throws Exception
287
     * @throws InvalidConfigException
288
     * @throws NotFoundException
289 4
     * @throws NotInstantiableException
290
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
291 4
     *
292
     * @return array The distinctive models.
293
     */
294 4
    private function removeDuplicatedModels(array $models): array
295
    {
296
        $hash = [];
297 60
298
        $pks = $this->getARInstance()->primaryKey();
299
300
        if (count($pks) > 1) {
301 60
            // Composite primary key.
302
            foreach ($models as $i => $model) {
303 60
                $key = [];
304 60
                foreach ($pks as $pk) {
305
                    if (!isset($model[$pk])) {
306 4
                        // Don't continue if the primary key isn't part of the result set.
307
                        break 2;
308
                    }
309 56
                    $key[] = $model[$pk];
310
                }
311 56
312 28
                $key = serialize($key);
313 56
314 56
                if (isset($hash[$key])) {
315
                    unset($models[$i]);
316
                } else {
317
                    $hash[$key] = true;
318
                }
319 64
            }
320
        } elseif (empty($pks)) {
321
            throw new InvalidConfigException("Primary key of '$this->arClass' can not be empty.");
322
        } else {
323
            // Single column primary key.
324
            $pk = reset($pks);
325
326
            foreach ($models as $i => $model) {
327
                if (!isset($model[$pk])) {
328
                    // Don't continue if the primary key isn't part of the result set.
329
                    break;
330
                }
331
332 305
                $key = $model[$pk];
333
334 305
                if (isset($hash[$key])) {
335
                    unset($models[$i]);
336 305
                } else {
337 301
                    $hash[$key] = true;
338
                }
339 301
            }
340
        }
341
342 32
        return array_values($models);
343
    }
344
345
    /**
346
     * @throws Exception
347
     * @throws InvalidArgumentException
348
     * @throws InvalidConfigException
349
     * @throws NotSupportedException
350
     * @throws ReflectionException
351
     * @throws Throwable
352 449
     */
353
    public function allPopulate(): array
354 449
    {
355 445
        $rows = $this->all();
356
357 4
        if ($rows !== []) {
358 4
            $rows = $this->populate($rows, $this->indexBy);
359
        }
360
361 449
        return $rows;
362
    }
363 449
364
    /**
365 449
     * @throws Exception
366
     * @throws InvalidArgumentException
367
     * @throws InvalidConfigException
368
     * @throws NotSupportedException
369
     * @throws ReflectionException
370
     * @throws Throwable
371
     */
372
    public function onePopulate(): array|ActiveRecordInterface|null
373
    {
374
        $row = $this->one();
375
376
        if ($row !== null) {
377
            $activeRecord = $this->populate([$row], $this->indexBy);
378
            $row = reset($activeRecord) ?: null;
379 61
        }
380
381 61
        return $row;
382 57
    }
383
384
    /**
385 4
     * Creates a db command that can be used to execute this query.
386 4
     *
387 4
     * @throws Exception
388 4
     */
389
    public function createCommand(): CommandInterface
390 4
    {
391
        if ($this->sql === null) {
392 4
            [$sql, $params] = $this->db->getQueryBuilder()->build($this);
393
        } else {
394
            $sql = $this->sql;
395
            $params = $this->params;
396
        }
397
398
        return $this->db->createCommand($sql, $params);
399
    }
400
401
    /**
402
     * Queries a scalar value by setting {@see select()} first.
403
     *
404
     * Restores the value of select to make this query reusable.
405
     *
406
     * @param ExpressionInterface|string $selectExpression The expression to be selected.
407
     *
408
     * @throws Exception
409
     * @throws InvalidArgumentException
410
     * @throws InvalidConfigException
411
     * @throws NotSupportedException
412
     * @throws Throwable
413
     */
414
    protected function queryScalar(string|ExpressionInterface $selectExpression): bool|string|null|int|float
415
    {
416
        if ($this->sql === null) {
417
            return parent::queryScalar($selectExpression);
418
        }
419
420
        $command = (new Query($this->db))->select([$selectExpression])
421
            ->from(['c' => "($this->sql)"])
422
            ->params($this->params)
423
            ->createCommand();
424
425
        return $command->queryScalar();
426
    }
427
428
    /**
429
     * Joins with the specified relations.
430
     *
431
     * This method allows you to reuse existing relation definitions to perform JOIN queries. Based on the definition of
432
     * the specified relation(s), the method will append one or many JOIN statements to the current query.
433
     *
434
     * If the `$eagerLoading` parameter is true, the method will also perform eager loading for the specified relations,
435
     * which is equal to calling {@see with()} using the specified relations.
436
     *
437
     * Note: That because a JOIN query will be performed, you're responsible for disambiguated column names.
438
     *
439
     * This method differs from {@see with()} in that it will build up and execute a JOIN SQL statement for the primary
440
     * table. And when `$eagerLoading` is true, it will call {@see with()} in addition with the specified relations.
441
     *
442
     * @param array|string $with The relations to be joined. This can either be a string, representing a relation name
443
     * or an array with the following semantics:
444
     *
445
     * - Each array element represents a single relation.
446
     * - You may specify the relation name as the array key and give anonymous functions that can be used to change the
447
     *   relation queries on-the-fly as the array value.
448
     * - If a relation query doesn't need modification, you may use the relation name as the array value.
449
     *
450
     * The relation name may optionally contain an alias for the relation table (e.g. `books b`).
451 99
     *
452
     * Sub-relations can also be specified, see {@see with()} for the syntax.
453 99
     *
454
     * In the following you find some examples:
455 99
     *
456 99
     * ```php
457 95
     * // Find all orders that contain books, and eager loading "books".
458 95
     * $orderQuery = new ActiveQuery(Order::class, $db);
459
     * $orderQuery->joinWith('books', true, 'INNER JOIN')->all();
460
     *
461 99
     * // find all orders, eager loading "books", and sort the orders and books by the book names.
462
     * $orderQuery = new ActiveQuery(Order::class, $db);
463 20
     * $orderQuery->joinWith([
464
     *     'books' => function (ActiveQuery $query) {
465 20
     *         $query->orderBy('item.name');
466
     *     }
467 20
     * ])->all();
468
     *
469 20
     * // Find all orders that contain books of the category 'Science fiction', using the alias "b" for the book table.
470
     * $order = new ActiveQuery(Order::class, $db);
471 20
     * $orderQuery->joinWith(['books b'], true, 'INNER JOIN')->where(['b.category' => 'Science fiction'])->all();
472 16
     * ```
473
     * @param array|bool $eagerLoading Whether to eager load the relations specified in `$with`. When this is boolean.
474 20
     * It applies to all relations specified in `$with`. Use an array to explicitly list which relations in `$with` a
475
     * need to be eagerly loaded.
476
     * Note: That this doesn't mean that the relations are populated from the query result. An
477 99
     * extra query will still be performed to bring in the related data. Defaults to `true`.
478 95
     * @param array|string $joinType The join type of the relations specified in `$with`.  When this is a string, it
479
     * applies to all relations specified in `$with`. Use an array in the format of `relationName => joinType` to
480 48
     * specify different join types for different relations.
481
     */
482
    public function joinWith(
483
        array|string $with,
484 99
        array|bool $eagerLoading = true,
485
        array|string $joinType = 'LEFT JOIN'
486 99
    ): self {
487
        $relations = [];
488
489 84
        foreach ((array) $with as $name => $callback) {
490
            if (is_int($name)) {
491 84
                $name = $callback;
492
                $callback = null;
493 84
            }
494
495 84
            if (preg_match('/^(.*?)(?:\s+AS\s+|\s+)(\w+)$/i', $name, $matches)) {
496
                /** The relation is defined with an alias, adjust callback to apply alias */
497 84
                [, $relation, $alias] = $matches;
498 84
499
                $name = $relation;
500 84
501
                $callback = static function (self $query) use ($callback, $alias) {
502
                    $query->alias($alias);
503
504
                    if ($callback !== null) {
505
                        $callback($query);
506
                    }
507
                };
508
            }
509
510 84
            if ($callback === null) {
511 16
                $relations[] = $name;
512
            } else {
513
                $relations[$name] = $callback;
514 84
            }
515
        }
516
517
        $this->joinWith[] = [$relations, $eagerLoading, $joinType];
518
519
        return $this;
520
    }
521 84
522
    /**
523 84
     * @throws CircularReferenceException
524 84
     * @throws InvalidConfigException
525
     * @throws NotFoundException
526 84
     * @throws NotInstantiableException
527
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
528
     */
529 84
    public function buildJoinWith(): void
530
    {
531 84
        $join = $this->join;
532 84
533 84
        $this->join = [];
534 84
535
        $arClass = $this->getARInstance();
536
537
        foreach ($this->joinWith as [$with, $eagerLoading, $joinType]) {
538 84
            $this->joinWithRelations($arClass, $with, $joinType);
539
540 84
            if (is_array($eagerLoading)) {
541
                foreach ($with as $name => $callback) {
542
                    if (is_int($name)) {
543
                        if (!in_array($callback, $eagerLoading, true)) {
544 84
                            unset($with[$name]);
545
                        }
546
                    } elseif (!in_array($name, $eagerLoading, true)) {
547
                        unset($with[$name]);
548
                    }
549
                }
550
            } elseif (!$eagerLoading) {
551
                $with = [];
552
            }
553
554
            $this->with($with);
555
        }
556
557
        /**
558
         * Remove duplicated joins added by joinWithRelations that may be added, for example, when joining a relation
559
         * and a via relation at the same time.
560
         */
561 53
        $uniqueJoins = [];
562
563 53
        foreach ($this->join as $j) {
564
            $uniqueJoins[serialize($j)] = $j;
565
        }
566
        $this->join = array_values($uniqueJoins);
567
568
        /**
569
         * @link https://github.com/yiisoft/yii2/issues/16092
570
         */
571
        $uniqueJoinsByTableName = [];
572
573 84
        foreach ($this->join as $config) {
574
            $tableName = serialize($config[1]);
575 84
            if (!array_key_exists($tableName, $uniqueJoinsByTableName)) {
576
                $uniqueJoinsByTableName[$tableName] = $config;
577 84
            }
578 84
        }
579 80
580 80
        $this->join = array_values($uniqueJoinsByTableName);
581
582
        if (!empty($join)) {
583 84
            /**
584 84
             * Append explicit join to {@see joinWith()} {@link https://github.com/yiisoft/yii2/issues/2880}
585 84
             */
586
            $this->join = empty($this->join) ? $join : array_merge($this->join, $join);
587 84
        }
588 20
    }
589 20
590 20
    /**
591
     * Inner joins with the specified relations.
592 20
     *
593 12
     * This is a shortcut method to {@see joinWith()} with the join type set as "INNER JOIN".
594 12
     *
595
     * Please refer to {@see joinWith()} for detailed usage of this method.
596 8
     *
597
     * @param array|string $with The relations to be joined with.
598
     * @param array|bool $eagerLoading Whether to eager load the relations.
599 20
     * Note: That this doesn't mean that the relations are populated from the query result.
600
     * An extra query will still be performed to bring in the related data.
601 20
     *
602 20
     * @see joinWith()
603 20
     */
604
    public function innerJoinWith(array|string $with, array|bool $eagerLoading = true): self
605
    {
606 84
        return $this->joinWith($with, $eagerLoading, 'INNER JOIN');
607
    }
608 84
609 84
    /**
610
     * Modifies the current query by adding join fragments based on the given relations.
611 84
     *
612 48
     * @param ActiveRecordInterface $arClass The primary model.
613
     * @param array $with The relations to be joined.
614
     * @param array|string $joinType The join type.
615 84
     *
616 12
     * @throws CircularReferenceException
617
     * @throws InvalidConfigException
618
     * @throws NotFoundException
619 84
     * @throws NotInstantiableException
620
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
621
     */
622 84
    private function joinWithRelations(ActiveRecordInterface $arClass, array $with, array|string $joinType): void
623
    {
624
        $relations = [];
625
626
        foreach ($with as $name => $callback) {
627
            if (is_int($name)) {
628
                $name = $callback;
629
                $callback = null;
630
            }
631
632 84
            $primaryModel = $arClass;
633
            $parent = $this;
634 84
            $prefix = '';
635
636
            while (($pos = strpos($name, '.')) !== false) {
637
                $childName = substr($name, $pos + 1);
638 84
                $name = substr($name, 0, $pos);
639
                $fullName = $prefix === '' ? $name : "$prefix.$name";
640
641
                if (!isset($relations[$fullName])) {
642
                    $relations[$fullName] = $relation = $primaryModel->getRelation($name);
643
                    if ($relation instanceof ActiveQueryInterface) {
644
                        $this->joinWithRelation($parent, $relation, $this->getJoinType($joinType, $fullName));
645
                    }
646 113
                } else {
647
                    $relation = $relations[$fullName];
648 113
                }
649 104
650
                if ($relation instanceof ActiveQueryInterface) {
651 89
                    $primaryModel = $relation->getARInstance();
652
                    $parent = $relation;
653 89
                }
654 89
655 28
                $prefix = $fullName;
656
                $name = $childName;
657 81
            }
658
659
            $fullName = $prefix === '' ? $name : "$prefix.$name";
660
661 109
            if (!isset($relations[$fullName])) {
662 8
                $relations[$fullName] = $relation = $primaryModel->getRelation($name);
663
664 109
                if ($callback !== null) {
665
                    $callback($relation);
666
                }
667 109
668
                if ($relation instanceof ActiveQueryInterface && !empty($relation->getJoinWith())) {
669
                    $relation->buildJoinWith();
670
                }
671
672
                if ($relation instanceof ActiveQueryInterface) {
673
                    $this->joinWithRelation($parent, $relation, $this->getJoinType($joinType, $fullName));
674
                }
675
            }
676
        }
677
    }
678
679 84
    /**
680
     * Returns the join type based on the given join type parameter and the relation name.
681 84
     *
682 84
     * @param array|string $joinType The given join type(s).
683
     * @param string $name The relation name.
684 84
     *
685
     * @return string The real join type.
686 12
     */
687 12
    private function getJoinType(array|string $joinType, string $name): string
688
    {
689 12
        if (is_array($joinType) && isset($joinType[$name])) {
690
            return $joinType[$name];
691
        }
692 84
693
        return is_string($joinType) ? $joinType : 'INNER JOIN';
694 28
    }
695 28
696
    /**
697 28
     * Returns the table name and the table alias for {@see arClass}.
698
     *
699
     * @throws CircularReferenceException
700 84
     * @throws InvalidConfigException
701 84
     * @throws NotFoundException
702
     * @throws NotInstantiableException
703 84
     */
704 84
    private function getTableNameAndAlias(): array
705 84
    {
706
        if (empty($this->from)) {
707
            $tableName = $this->getPrimaryTableName();
708 84
        } else {
709 84
            $tableName = '';
710
711
            foreach ($this->from as $alias => $tableName) {
712 84
                if (is_string($alias)) {
713
                    return [$tableName, $alias];
714 84
                }
715 84
                break;
716
            }
717
        }
718 84
719
        if (preg_match('/^(.*?)\s+({{\w+}}|\w+)$/', $tableName, $matches)) {
720 84
            $alias = $matches[2];
721 84
        } else {
722
            $alias = $tableName;
723
        }
724
725
        return [$tableName, $alias];
726
    }
727 84
728
    /**
729 84
     * Joins a parent query with a child query.
730 28
     *
731
     * The current query object will be modified so.
732
     *
733 84
     * @param ActiveQueryInterface $parent The parent query.
734
     * @param ActiveQueryInterface $child The child query.
735
     * @param string $joinType The join type.
736
     *
737 84
     * @throws CircularReferenceException
738 32
     * @throws NotFoundException
739
     * @throws NotInstantiableException
740
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
741 84
     */
742
    private function joinWithRelation(ActiveQueryInterface $parent, ActiveQueryInterface $child, string $joinType): void
743
    {
744
        $via = $child->getVia();
745 84
        /** @var ActiveQuery $child */
746
        $child->via = null;
747
748
        if ($via instanceof self) {
749 84
            // via table
750 12
            $this->joinWithRelation($parent, $via, $joinType);
751 12
            $this->joinWithRelation($via, $child, $joinType);
752
753
            return;
754
        }
755 84
756
        if (is_array($via)) {
757
            // via relation
758
            $this->joinWithRelation($parent, $via[1], $joinType);
759
            $this->joinWithRelation($via[1], $child, $joinType);
760 84
761
            return;
762
        }
763
764
        /** @var ActiveQuery $parent */
765
        [$parentTable, $parentAlias] = $parent->getTableNameAndAlias();
766
        [$childTable, $childAlias] = $child->getTableNameAndAlias();
767
768
        if (!empty($child->getLink())) {
769
            if (!str_contains($parentAlias, '{{')) {
770
                $parentAlias = '{{' . $parentAlias . '}}';
771
            }
772
773
            if (!str_contains($childAlias, '{{')) {
774
                $childAlias = '{{' . $childAlias . '}}';
775
            }
776
777
            $on = [];
778
779
            foreach ($child->getLink() as $childColumn => $parentColumn) {
780
                $on[] = "$parentAlias.[[$parentColumn]] = $childAlias.[[$childColumn]]";
781
            }
782
783
            $on = implode(' AND ', $on);
784
785
            if (!empty($child->getOn())) {
786
                $on = ['and', $on, $child->getOn()];
787
            }
788 29
        } else {
789
            $on = $child->getOn();
790 29
        }
791
792 29
        $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

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