Passed
Pull Request — master (#239)
by Wilmer
02:42
created

ActiveQuery::prepare()   C

Complexity

Conditions 15
Paths 192

Size

Total Lines 102
Code Lines 72

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 31
CRAP Score 16.9411

Importance

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

609
                    $this->joinWithRelation($parent, /** @scrutinizer ignore-type */ $relation, $this->getJoinType($joinType, $fullName));
Loading history...
610
                } else {
611 84
                    $relation = $relations[$fullName];
612 48
                }
613
614
                $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

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

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

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

1150
        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...
1151
    }
1152
}
1153