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

ActiveQuery::joinWith()   A

Complexity

Conditions 6
Paths 9

Size

Total Lines 38
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 6

Importance

Changes 0
Metric Value
cc 6
eloc 18
nc 9
nop 3
dl 0
loc 38
ccs 19
cts 19
cp 1
crap 6
rs 9.0444
c 0
b 0
f 0
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