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

ActiveQuery::joinWithRelation()   F

Complexity

Conditions 18
Paths 1730

Size

Total Lines 79
Code Lines 42

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 23.7539

Importance

Changes 0
Metric Value
cc 18
eloc 42
c 0
b 0
f 0
nc 1730
nop 3
dl 0
loc 79
ccs 17
cts 23
cp 0.7391
crap 23.7539
rs 0.7

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

630
                    $this->joinWithRelation($parent, /** @scrutinizer ignore-type */ $relation, $this->getJoinType($joinType, $fullName));
Loading history...
631
                } else {
632 84
                    $relation = $relations[$fullName];
633
                }
634 84
635
                $primaryModel = $relation->getARInstance();
0 ignored issues
show
Bug introduced by
The method getARInstance() does not exist on Yiisoft\ActiveRecord\ActiveQueryInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Yiisoft\ActiveRecord\ActiveQueryInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

635
                /** @scrutinizer ignore-call */ 
636
                $primaryModel = $relation->getARInstance();
Loading history...
636
637
                $parent = $relation;
638 84
                $prefix = $fullName;
639
                $name = $childName;
640
            }
641
642
            $fullName = $prefix === '' ? $name : "$prefix.$name";
643
644
            if (!isset($relations[$fullName])) {
645
                $relations[$fullName] = $relation = $primaryModel->getRelation($name);
646 113
647
                if ($callback !== null) {
648 113
                    $callback($relation);
649 104
                }
650
651 89
                if (!empty($relation->getJoinWith())) {
652
                    $relation->buildJoinWith();
653 89
                }
654 89
655 28
                $this->joinWithRelation($parent, $relation, $this->getJoinType($joinType, $fullName));
0 ignored issues
show
Bug introduced by
It seems like $parent can also be of type null; however, parameter $parent of Yiisoft\ActiveRecord\Act...ery::joinWithRelation() does only seem to accept Yiisoft\ActiveRecord\ActiveQueryInterface, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1172
        return $this->arFactory->/** @scrutinizer ignore-call */ createAR($this->arClass, $this->tableName, $this->db);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
1173
    }
1174
}
1175