Passed
Push — master ( ab6a5b...75b09b )
by Alexander
05:44
created

ActiveQuery::getJoinWith()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 3
ccs 2
cts 2
cp 1
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 0
crap 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\ActiveRecord;
6
7
use ReflectionException;
8
use Throwable;
9
use Yiisoft\Arrays\ArrayHelper;
10
use Yiisoft\Db\Command\Command;
11
use Yiisoft\Db\Connection\ConnectionInterface;
12
use Yiisoft\Db\Exception\Exception;
13
use Yiisoft\Db\Exception\InvalidArgumentException;
14
use Yiisoft\Db\Exception\InvalidConfigException;
15
use Yiisoft\Db\Exception\NotSupportedException;
16
use Yiisoft\Db\Expression\ExpressionInterface;
17
use Yiisoft\Db\Query\Query;
18
use Yiisoft\Db\Query\QueryBuilder;
19
20
use function array_merge;
21
use function array_values;
22
use function count;
23
use function get_class;
24
use function implode;
25
use function in_array;
26
use function is_array;
27
use function is_int;
28
use function is_string;
29
use function preg_match;
30
use function reset;
31
use function serialize;
32
use function strpos;
33
use function substr;
34
35
/**
36
 * ActiveQuery represents a DB query associated with an Active Record class.
37
 *
38
 * An ActiveQuery can be a normal query or be used in a relational context.
39
 *
40
 * ActiveQuery instances are usually created by {@see ActiveQuery::findOne()}, {@see ActiveQuery::findBySql()},
41
 * {@see ActiveQuery::findAll()}
42
 *
43
 * Relational queries are created by {@see ActiveRecord::hasOne()} and {@see ActiveRecord::hasMany()}.
44
 *
45
 * Normal Query
46
 * ------------
47
 *
48
 * ActiveQuery mainly provides the following methods to retrieve the query results:
49
 *
50
 * - {@see one()}: returns a single record populated with the first row of data.
51
 * - {@see all()}: returns all records based on the query results.
52
 * - {@see count()}: returns the number of records.
53
 * - {@see sum()}: returns the sum over the specified column.
54
 * - {@see average()}: returns the average over the specified column.
55
 * - {@see min()}: returns the min over the specified column.
56
 * - {@see max()}: returns the max over the specified column.
57
 * - {@see scalar()}: returns the value of the first column in the first row of the query result.
58
 * - {@see column()}: returns the value of the first column in the query result.
59
 * - {@see exists()}: returns a value indicating whether the query result has data or not.
60
 *
61
 * Because ActiveQuery extends from {@see Query}, one can use query methods, such as {@see where()}, {@see orderBy()} to
62
 * customize the query options.
63
 *
64
 * ActiveQuery also provides the following additional query options:
65
 *
66
 * - {@see with()}: list of relations that this query should be performed with.
67
 * - {@see joinWith()}: reuse a relation query definition to add a join to a query.
68
 * - {@see indexBy()}: the name of the column by which the query result should be indexed.
69
 * - {@see asArray()}: whether to return each record as an array.
70
 *
71
 * These options can be configured using methods of the same name. For example:
72
 *
73
 * ```php
74
 * $customerQuery = new ActiveQuery(Customer::class, $db);
75
 * $query = $customerQuery->with('orders')->asArray()->all();
76
 * ```
77
 *
78
 * Relational query
79
 * ----------------
80
 *
81
 * In relational context ActiveQuery represents a relation between two Active Record classes.
82
 *
83
 * Relational ActiveQuery instances are usually created by calling {@see ActiveRecord::hasOne()} and
84
 * {@see ActiveRecord::hasMany()}. An Active Record class declares a relation by defining a getter method which calls
85
 * one of the above methods and returns the created ActiveQuery object.
86
 *
87
 * A relation is specified by {@see link} which represents the association between columns of different tables; and the
88
 * multiplicity of the relation is indicated by {@see multiple}.
89
 *
90
 * If a relation involves a junction table, it may be specified by {@see via()} or {@see viaTable()} method.
91
 *
92
 * These methods may only be called in a relational context. Same is true for {@see inverseOf()}, which marks a relation
93
 * as inverse of another relation and {@see onCondition()} which adds a condition that is to be added to relational
94
 * query join condition.
95
 */
96
class ActiveQuery extends Query implements ActiveQueryInterface
97
{
98
    use ActiveQueryTrait;
99
    use ActiveRelationTrait;
100
101
    protected string $arClass;
102
    protected ConnectionInterface $db;
103
    private ?string $sql = null;
104
    private $on;
105
    private array $joinWith = [];
106
    private ?ActiveRecordInterface $arInstance = null;
107
    private ?ActiveRecordFactory $arFactory;
108
109 719
    public function __construct(string $modelClass, ConnectionInterface $db, ActiveRecordFactory $arFactory = null)
110
    {
111 719
        $this->arClass = $modelClass;
112 719
        $this->arFactory = $arFactory;
113 719
        $this->db = $db;
114
115 719
        parent::__construct($db);
116 719
    }
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|InvalidConfigException|Throwable
124
     *
125
     * @return array|ActiveRecord[] the query results. If the query results in nothing, an empty array will be returned.
126
     */
127 244
    public function all(): array
128
    {
129 244
        return parent::all();
130
    }
131
132
    /**
133
     * Prepares for building SQL.
134
     *
135
     * This method is called by {@see QueryBuilder} when it starts to build SQL from a query object.
136
     *
137
     * You may override this method to do some final preparation work when converting a query into a SQL statement.
138
     *
139
     * @param QueryBuilder $builder
140
     *
141
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException|ReflectionException
142
     * @throws Throwable
143
     *
144
     * @return Query a prepared query instance which will be used by {@see QueryBuilder} to build the SQL.
145
     */
146 496
    public function prepare(QueryBuilder $builder): Query
147
    {
148
        /**
149
         * NOTE: because the same ActiveQuery may be used to build different SQL statements, one for count query, the
150
         * other for row data query, it is important to make sure the same ActiveQuery can be used to build SQL
151
         * statements multiple times.
152
         */
153 496
        if (!empty($this->joinWith)) {
154 80
            $this->buildJoinWith();
155
            /** clean it up to avoid issue {@see https://github.com/yiisoft/yii2/issues/2687} */
156 80
            $this->joinWith = [];
157
        }
158
159 496
        if (empty($this->getFrom())) {
160 488
            $this->from = [$this->getPrimaryTableName()];
161
        }
162
163 496
        if (empty($this->getSelect()) && !empty($this->getJoin())) {
164 76
            [, $alias] = $this->getTableNameAndAlias();
165
166 76
            $this->select(["$alias.*"]);
167
        }
168
169 496
        if ($this->primaryModel === null) {
170
            /** eager loading */
171 488
            $query = Query::create($this->db, $this);
172
        } else {
173
            /** lazy loading of a relation */
174 112
            $where = $this->getWhere();
175
176 112
            if ($this->via instanceof self) {
177
                /** via junction table */
178 20
                $viaModels = $this->via->findJunctionRows([$this->primaryModel]);
179
180 20
                $this->filterByModels($viaModels);
181 100
            } elseif (is_array($this->via)) {
182
                /**
183
                 * via relation
184
                 *
185
                 * @var $viaQuery ActiveQuery
186
                 */
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)) {
0 ignored issues
show
Bug introduced by
The method isRelationPopulated() does not exist on Yiisoft\ActiveRecord\ActiveRecordInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Yiisoft\ActiveRecord\ActiveRecordInterface. ( Ignorable by Annotation )

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

192
                    } elseif ($this->primaryModel->/** @scrutinizer ignore-call */ isRelationPopulated($viaName)) {
Loading history...
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 100
                $this->filterByModels([$this->primaryModel]);
212
            }
213
214 112
            $query = Query::create($this->db, $this);
215 112
            $this->where($where);
216
        }
217
218 496
        if (!empty($this->on)) {
219 24
            $query->andWhere($this->on);
220
        }
221
222 496
        return $query;
223
    }
224
225
    /**
226
     * Converts the raw query results into the format as specified by this query.
227
     *
228
     * This method is internally used to convert the data fetched from database into the format as required by this
229
     * query.
230
     *
231
     * @param array $rows the raw query result from database.
232
     *
233
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException|ReflectionException
234
     *
235
     * @return array the converted query result.
236
     */
237 418
    public function populate(array $rows): array
238
    {
239 418
        if (empty($rows)) {
240 74
            return [];
241
        }
242
243 405
        $models = $this->createModels($rows);
244
245 405
        if (!empty($this->join) && $this->getIndexBy() === null) {
0 ignored issues
show
introduced by
The condition $this->getIndexBy() === null is always false.
Loading history...
246 64
            $models = $this->removeDuplicatedModels($models);
247
        }
248
249 405
        if (!empty($this->with)) {
250 131
            $this->findWith($this->with, $models);
251
        }
252
253 405
        if ($this->inverseOf !== null) {
254 16
            $this->addInverseRelations($models);
255
        }
256
257 405
        return parent::populate($models);
258
    }
259
260
    /**
261
     * Removes duplicated models by checking their primary key values.
262
     *
263
     * This method is mainly called when a join query is performed, which may cause duplicated rows being returned.
264
     *
265
     * @param array $models the models to be checked.
266
     *
267
     * @throws Exception|InvalidConfigException
268
     *
269
     * @return array the distinctive models.
270
     */
271 64
    private function removeDuplicatedModels(array $models): array
272
    {
273 64
        $hash = [];
274
275 64
        $pks = $this->getARInstance()->primaryKey();
276
277 64
        if (count($pks) > 1) {
278
            /** composite primary key */
279 8
            foreach ($models as $i => $model) {
280 8
                $key = [];
281 8
                foreach ($pks as $pk) {
282 8
                    if (!isset($model[$pk])) {
283
                        /** do not continue if the primary key is not part of the result set */
284 4
                        break 2;
285
                    }
286 7
                    $key[] = $model[$pk];
287
                }
288
289 4
                $key = serialize($key);
290
291 4
                if (isset($hash[$key])) {
292
                    unset($models[$i]);
293
                } else {
294 4
                    $hash[$key] = true;
295
                }
296
            }
297 60
        } elseif (empty($pks)) {
298
            throw new InvalidConfigException("Primary key of '{$this->getARInstance()}' can not be empty.");
299
        } else {
300
            /** single column primary key */
301 60
            $pk = reset($pks);
302
303 60
            foreach ($models as $i => $model) {
304 60
                if (!isset($model[$pk])) {
305
                    /** do not continue if the primary key is not part of the result set */
306 4
                    break;
307
                }
308
309 56
                $key = $model[$pk];
310
311 56
                if (isset($hash[$key])) {
312 28
                    unset($models[$i]);
313 56
                } elseif ($key !== null) {
314 56
                    $hash[$key] = true;
315
                }
316
            }
317
        }
318
319 64
        return array_values($models);
320
    }
321
322
    /**
323
     * Executes query and returns a single row of result.
324
     *
325
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException|ReflectionException
326
     * @throws Throwable
327
     *
328
     * @return ActiveRecord|array|null a single row of query result. Depending on the setting of {@see asArray}, the
329
     * query result may be either an array or an ActiveRecord object. `null` will be returned if the query results in
330
     * nothing.
331
     */
332 304
    public function one()
333
    {
334 304
        $row = parent::one();
335
336 304
        if ($row !== false) {
0 ignored issues
show
introduced by
The condition $row !== false is always true.
Loading history...
337 300
            $models = $this->populate([$row]);
338
339 300
            return reset($models) ?: null;
340
        }
341
342 32
        return null;
343
    }
344
345
    /**
346
     * Creates a DB command that can be used to execute this query.
347
     *
348
     * @throws Exception|InvalidConfigException
349
     *
350
     * @return Command the created DB command instance.
351
     */
352 448
    public function createCommand(): Command
353
    {
354 448
        if ($this->sql === null) {
355 444
            [$sql, $params] = $this->db->getQueryBuilder()->build($this);
356
        } else {
357 4
            $sql = $this->sql;
358 4
            $params = $this->params;
359
        }
360
361 448
        $command = $this->db->createCommand($sql, $params);
362
363 448
        $this->setCommandCache($command);
364
365 448
        return $command;
366
    }
367
368
    /**
369
     * Queries a scalar value by setting {@see select} first.
370
     *
371
     * Restores the value of select to make this query reusable.
372
     *
373
     * @param string|ExpressionInterface $selectExpression
374
     *
375
     * @throws Exception|InvalidConfigException|Throwable
376
     *
377
     * @return bool|null|string
378
     */
379 61
    protected function queryScalar($selectExpression)
380
    {
381 61
        if ($this->sql === null) {
382 57
            return parent::queryScalar($selectExpression);
383
        }
384
385 4
        $command = (new Query($this->db))->select([$selectExpression])
386 4
            ->from(['c' => "({$this->sql})"])
387 4
            ->params($this->params)
388 4
            ->createCommand();
389
390 4
        $this->setCommandCache($command);
391
392 4
        return $command->queryScalar();
393
    }
394
395
    /**
396
     * Joins with the specified relations.
397
     *
398
     * This method allows you to reuse existing relation definitions to perform JOIN queries. Based on the definition of
399
     * the specified relation(s), the method will append one or multiple JOIN statements to the current query.
400
     *
401
     * If the `$eagerLoading` parameter is true, the method will also perform eager loading for the specified relations,
402
     * which is equivalent to calling {@see with()} using the specified relations.
403
     *
404
     * Note that because a JOIN query will be performed, you are responsible to disambiguate column names.
405
     *
406
     * This method differs from {@see with()} in that it will build up and execute a JOIN SQL statement  for the primary
407
     * table. And when `$eagerLoading` is true, it will call {@see with()} in addition with the specified relations.
408
     *
409
     * @param string|array $with the relations to be joined. This can either be a string, representing a relation name
410
     * or an array with the following semantics:
411
     *
412
     * - Each array element represents a single relation.
413
     * - You may specify the relation name as the array key and provide an anonymous functions that can be used to
414
     *   modify the relation queries on-the-fly as the array value.
415
     * - If a relation query does not need modification, you may use the relation name as the array value.
416
     *
417
     * The relation name may optionally contain an alias for the relation table (e.g. `books b`).
418
     *
419
     * Sub-relations can also be specified, see {@see with()} for the syntax.
420
     *
421
     * In the following you find some examples:
422
     *
423
     * ```php
424
     * // find all orders that contain books, and eager loading "books".
425
     * $orderQuery = new ActiveQuery(Order::class, $db);
426
     * $orderQuery->joinWith('books', true, 'INNER JOIN')->all();
427
     *
428
     * // find all orders, eager loading "books", and sort the orders and books by the book names.
429
     * $orderQuery = new ActiveQuery(Order::class, $db);
430
     * $orderQuery->joinWith([
431
     *     'books' => function (ActiveQuery $query) {
432
     *         $query->orderBy('item.name');
433
     *     }
434
     * ])->all();
435
     *
436
     * // find all orders that contain books of the category 'Science fiction', using the alias "b" for the books table.
437
     * $order = new ActiveQuery(Order::class, $db);
438
     * $orderQuery->joinWith(['books b'], true, 'INNER JOIN')->where(['b.category' => 'Science fiction'])->all();
439
     * ```
440
     *
441
     * @param bool|array $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 string|array $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($with, $eagerLoading = true, $joinType = 'LEFT JOIN'): self
452
    {
453 99
        $relations = [];
454
455 99
        foreach ((array) $with as $name => $callback) {
456 99
            if (is_int($name)) {
457 95
                $name = $callback;
458 95
                $callback = null;
459
            }
460
461 99
            if (preg_match('/^(.*?)(?:\s+AS\s+|\s+)(\w+)$/i', $name, $matches)) {
462
                /** relation is defined with an alias, adjust callback to apply alias */
463 20
                [, $relation, $alias] = $matches;
464
465 20
                $name = $relation;
466
467 20
                $callback = static function ($query) use ($callback, $alias) {
468
                    /** @var $query ActiveQuery */
469 20
                    $query->alias($alias);
470
471 20
                    if ($callback !== null) {
472 16
                        $callback($query);
473
                    }
474 20
                };
475
            }
476
477 99
            if ($callback === null) {
478 95
                $relations[] = $name;
479
            } else {
480 48
                $relations[$name] = $callback;
481
            }
482
        }
483
484 99
        $this->joinWith[] = [$relations, $eagerLoading, $joinType];
485
486 99
        return $this;
487
    }
488
489 84
    private function buildJoinWith(): void
490
    {
491 84
        $join = $this->join;
492
493 84
        $this->join = [];
494
495 84
        $arClass = $this->getARInstance();
496
497 84
        foreach ($this->joinWith as [$with, $eagerLoading, $joinType]) {
498 84
            $this->joinWithRelations($arClass, $with, $joinType);
499
500 84
            if (is_array($eagerLoading)) {
501
                foreach ($with as $name => $callback) {
502
                    if (is_int($name)) {
503
                        if (!in_array($callback, $eagerLoading, true)) {
504
                            unset($with[$name]);
505
                        }
506
                    } elseif (!in_array($name, $eagerLoading, true)) {
507
                        unset($with[$name]);
508
                    }
509
                }
510 84
            } elseif (!$eagerLoading) {
511 16
                $with = [];
512
            }
513
514 84
            $this->with($with);
515
        }
516
517
        /**
518
         * Remove duplicated joins added by joinWithRelations that may be added e.g. when joining a relation and a via
519
         * relation at the same time.
520
         */
521 84
        $uniqueJoins = [];
522
523 84
        foreach ($this->join as $j) {
524 84
            $uniqueJoins[serialize($j)] = $j;
525
        }
526 84
        $this->join = array_values($uniqueJoins);
527
528
        /** {@see https://github.com/yiisoft/yii2/issues/16092 } */
529 84
        $uniqueJoinsByTableName = [];
530
531 84
        foreach ($this->join as $config) {
532 84
            $tableName = serialize($config[1]);
533 84
            if (!array_key_exists($tableName, $uniqueJoinsByTableName)) {
534 84
                $uniqueJoinsByTableName[$tableName] = $config;
535
            }
536
        }
537
538 84
        $this->join = array_values($uniqueJoinsByTableName);
539
540 84
        if (!empty($join)) {
541
            /** Append explicit join to joinWith() {@see https://github.com/yiisoft/yii2/issues/2880} */
542
            $this->join = empty($this->join) ? $join : array_merge($this->join, $join);
543
        }
544 84
    }
545
546
    /**
547
     * Inner joins with the specified relations.
548
     *
549
     * This is a shortcut method to {@see joinWith()} with the join type set as "INNER JOIN". Please refer to
550
     * {@see joinWith()} for detailed usage of this method.
551
     *
552
     * @param string|array $with the relations to be joined with.
553
     * @param bool|array $eagerLoading whether to eager load the relations. Note, that this does not mean, that the
554
     * relations are populated from the query result. An extra query will still be performed to bring in the related
555
     * data.
556
     *
557
     * @return $this the query object itself.
558
     *
559
     * {@see joinWith()}
560
     */
561 53
    public function innerJoinWith($with, $eagerLoading = true): self
562
    {
563 53
        return $this->joinWith($with, $eagerLoading, 'INNER JOIN');
564
    }
565
566
    /**
567
     * Modifies the current query by adding join fragments based on the given relations.
568
     *
569
     * @param ActiveRecordInterface $arClass the primary model.
570
     * @param array $with the relations to be joined.
571
     * @param string|array $joinType the join type.
572
     */
573 84
    private function joinWithRelations(ActiveRecordInterface $arClass, array $with, $joinType): void
574
    {
575 84
        $relations = [];
576
577 84
        foreach ($with as $name => $callback) {
578 84
            if (is_int($name)) {
579 80
                $name = $callback;
580 80
                $callback = null;
581
            }
582
583 84
            $primaryModel = $arClass;
584 84
            $parent = $this;
585 84
            $prefix = '';
586
587 84
            while (($pos = strpos($name, '.')) !== false) {
588 20
                $childName = substr($name, $pos + 1);
589 20
                $name = substr($name, 0, $pos);
590 20
                $fullName = $prefix === '' ? $name : "$prefix.$name";
591
592 20
                if (!isset($relations[$fullName])) {
593 12
                    $relations[$fullName] = $relation = $primaryModel->getRelation($name);
594 12
                    $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\ActiveQuery, 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

594
                    $this->joinWithRelation($parent, /** @scrutinizer ignore-type */ $relation, $this->getJoinType($joinType, $fullName));
Loading history...
595
                } else {
596 8
                    $relation = $relations[$fullName];
597
                }
598
599
                /** @var $relationModelClass ActiveRecordInterface */
600 20
                $relationModelClass = $relation->arClass;
0 ignored issues
show
Bug introduced by
Accessing arClass on the interface Yiisoft\ActiveRecord\ActiveQueryInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
601
602 20
                $primaryModel = new $relationModelClass($this->db);
0 ignored issues
show
Unused Code introduced by
The call to Yiisoft\ActiveRecord\Act...nterface::__construct() has too many arguments starting with $this->db. ( Ignorable by Annotation )

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

602
                $primaryModel = /** @scrutinizer ignore-call */ new $relationModelClass($this->db);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
603
604 20
                $parent = $relation;
605 20
                $prefix = $fullName;
606 20
                $name = $childName;
607
            }
608
609 84
            $fullName = $prefix === '' ? $name : "$prefix.$name";
610
611 84
            if (!isset($relations[$fullName])) {
612 84
                $relations[$fullName] = $relation = $primaryModel->getRelation($name);
613
614 84
                if ($callback !== null) {
615 48
                    $callback($relation);
616
                }
617
618 84
                if (!empty($relation->joinWith)) {
0 ignored issues
show
Bug introduced by
Accessing joinWith on the interface Yiisoft\ActiveRecord\ActiveQueryInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
619 12
                    $relation->buildJoinWith();
0 ignored issues
show
Bug introduced by
The method buildJoinWith() 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

619
                    $relation->/** @scrutinizer ignore-call */ 
620
                               buildJoinWith();
Loading history...
620
                }
621
622 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\ActiveQuery, 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

622
                $this->joinWithRelation(/** @scrutinizer ignore-type */ $parent, $relation, $this->getJoinType($joinType, $fullName));
Loading history...
623
            }
624
        }
625 84
    }
626
627
    /**
628
     * Returns the join type based on the given join type parameter and the relation name.
629
     *
630
     * @param string|array $joinType the given join type(s).
631
     * @param string $name relation name.
632
     *
633
     * @return string the real join type.
634
     */
635 84
    private function getJoinType($joinType, string $name): string
636
    {
637 84
        if (is_array($joinType) && isset($joinType[$name])) {
638
            return $joinType[$name];
639
        }
640
641 84
        return is_string($joinType) ? $joinType : 'INNER JOIN';
642
    }
643
644
    /**
645
     * Returns the table name and the table alias for {@see arClass}.
646
     *
647
     * @return array the table name and the table alias.
648
     */
649 113
    private function getTableNameAndAlias(): array
650
    {
651 113
        if (empty($this->from)) {
652 104
            $tableName = $this->getPrimaryTableName();
653
        } else {
654 89
            $tableName = '';
655
656 89
            foreach ($this->from as $alias => $tableName) {
657 89
                if (is_string($alias)) {
658 28
                    return [$tableName, $alias];
659
                }
660 81
                break;
661
            }
662
        }
663
664 109
        if (preg_match('/^(.*?)\s+({{\w+}}|\w+)$/', $tableName, $matches)) {
665 8
            $alias = $matches[2];
666
        } else {
667 109
            $alias = $tableName;
668
        }
669
670 109
        return [$tableName, $alias];
671
    }
672
673
    /**
674
     * Joins a parent query with a child query.
675
     *
676
     * The current query object will be modified accordingly.
677
     *
678
     * @param ActiveQuery $parent
679
     * @param ActiveQuery $child
680
     * @param string $joinType
681
     */
682 84
    private function joinWithRelation(ActiveQuery $parent, ActiveQuery $child, string $joinType): void
683
    {
684 84
        $via = $child->via;
685 84
        $child->via = null;
686
687 84
        if ($via instanceof self) {
688
            /** via table */
689 12
            $this->joinWithRelation($parent, $via, $joinType);
690 12
            $this->joinWithRelation($via, $child, $joinType);
691
692 12
            return;
693
        }
694
695 84
        if (is_array($via)) {
696
            /** via relation */
697 28
            $this->joinWithRelation($parent, $via[1], $joinType);
698 28
            $this->joinWithRelation($via[1], $child, $joinType);
699
700 28
            return;
701
        }
702
703 84
        [$parentTable, $parentAlias] = $parent->getTableNameAndAlias();
704 84
        [$childTable, $childAlias] = $child->getTableNameAndAlias();
705
706 84
        if (!empty($child->link)) {
707 84
            if (strpos($parentAlias, '{{') === false) {
708 84
                $parentAlias = '{{' . $parentAlias . '}}';
709
            }
710
711 84
            if (strpos($childAlias, '{{') === false) {
712 84
                $childAlias = '{{' . $childAlias . '}}';
713
            }
714
715 84
            $on = [];
716
717 84
            foreach ($child->link as $childColumn => $parentColumn) {
718 84
                $on[] = "$parentAlias.[[$parentColumn]] = $childAlias.[[$childColumn]]";
719
            }
720
721 84
            $on = implode(' AND ', $on);
722
723 84
            if (!empty($child->on)) {
724 84
                $on = ['and', $on, $child->on];
725
            }
726
        } else {
727
            $on = $child->on;
728
        }
729
730 84
        $this->join($joinType, empty($child->getFrom()) ? $childTable : $child->getFrom(), $on);
731
732 84
        if (!empty($child->getWhere())) {
733 28
            $this->andWhere($child->getWhere());
734
        }
735
736 84
        if (!empty($child->getHaving())) {
737
            $this->andHaving($child->getHaving());
738
        }
739
740 84
        if (!empty($child->getOrderBy())) {
741 32
            $this->addOrderBy($child->getOrderBy());
742
        }
743
744 84
        if (!empty($child->getGroupBy())) {
745
            $this->addGroupBy($child->getGroupBy());
746
        }
747
748 84
        if (!empty($child->getParams())) {
749
            $this->addParams($child->getParams());
750
        }
751
752 84
        if (!empty($child->getJoin())) {
753 12
            foreach ($child->getJoin() as $join) {
754 12
                $this->join[] = $join;
755
            }
756
        }
757
758 84
        if (!empty($child->getUnion())) {
759
            foreach ($child->getUnion() as $union) {
760
                $this->union[] = $union;
761
            }
762
        }
763 84
    }
764
765
    /**
766
     * Sets the ON condition for a relational query.
767
     *
768
     * The condition will be used in the ON part when {@see ActiveQuery::joinWith()} is called.
769
     *
770
     * Otherwise, the condition will be used in the WHERE part of a query.
771
     *
772
     * Use this method to specify additional conditions when declaring a relation in the {@see ActiveRecord} class:
773
     *
774
     * ```php
775
     * public function getActiveUsers(): ActiveQuery
776
     * {
777
     *     return $this->hasMany(User::class, ['id' => 'user_id'])->onCondition(['active' => true]);
778
     * }
779
     * ```
780
     *
781
     * Note that this condition is applied in case of a join as well as when fetching the related records. This only
782
     * fields of the related table can be used in the condition. Trying to access fields of the primary record will
783
     * cause an error in a non-join-query.
784
     *
785
     * @param string|array $condition the ON condition. Please refer to {@see Query::where()} on how to specify this
786
     * parameter.
787
     * @param array $params the parameters (name => value) to be bound to the query.
788
     *
789
     * @return $this the query object itself
790
     */
791 29
    public function onCondition($condition, array $params = []): self
792
    {
793 29
        $this->on = $condition;
794
795 29
        $this->addParams($params);
796
797 29
        return $this;
798
    }
799
800
    /**
801
     * Adds an additional ON condition to the existing one.
802
     *
803
     * The new condition and the existing one will be joined using the 'AND' operator.
804
     *
805
     * @param string|array $condition the new ON condition. Please refer to {@see 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
     * {@see onCondition()}
812
     * {@see orOnCondition()}
813
     */
814 10
    public function andOnCondition($condition, array $params = []): self
815
    {
816 10
        if ($this->on === null) {
817 5
            $this->on = $condition;
818
        } else {
819 5
            $this->on = ['and', $this->on, $condition];
820
        }
821
822 10
        $this->addParams($params);
823
824 10
        return $this;
825
    }
826
827
    /**
828
     * Adds an additional ON condition to the existing one.
829
     *
830
     * The new condition and the existing one will be joined using the 'OR' operator.
831
     *
832
     * @param string|array $condition the new ON condition. Please refer to {@see where()} on how to specify this
833
     * parameter.
834
     * @param array $params the parameters (name => value) to be bound to the query.
835
     *
836
     * @return $this the query object itself.
837
     *
838
     * {@see onCondition()}
839
     * {@see andOnCondition()}
840
     */
841 10
    public function orOnCondition($condition, array $params = []): self
842
    {
843 10
        if ($this->on === null) {
844 5
            $this->on = $condition;
845
        } else {
846 5
            $this->on = ['or', $this->on, $condition];
847
        }
848
849 10
        $this->addParams($params);
850
851 10
        return $this;
852
    }
853
854
    /**
855
     * Specifies the junction table for a relational query.
856
     *
857
     * Use this method to specify a junction table when declaring a relation in the {@see ActiveRecord} class:
858
     *
859
     * ```php
860
     * public function getItems()
861
     * {
862
     *     return $this->hasMany(Item::class, ['id' => 'item_id'])->viaTable('order_item', ['order_id' => 'id']);
863
     * }
864
     * ```
865
     *
866
     * @param string $tableName the name of the junction table.
867
     * @param array $link the link between the junction table and the table associated with {@see primaryModel}.
868
     * The keys of the array represent the columns in the junction table, and the values represent the columns in the
869
     * {@see primaryModel} table.
870
     * @param callable|null $callable a PHP callback for customizing the relation associated with the junction table.
871
     * Its signature should be `function($query)`, where `$query` is the query to be customized.
872
     *
873
     * @return $this the query object itself.
874
     *
875
     * {@see via()}
876
     */
877 32
    public function viaTable(string $tableName, array $link, callable $callable = null): self
878
    {
879 32
        $arClass = $this->primaryModel ? get_class($this->primaryModel) : $this->arClass;
880
881 32
        $arClassInstance = new self($arClass, $this->db);
882
883 32
        $relation = $arClassInstance->from([$tableName])->link($link)->multiple(true)->asArray(true);
884
885 32
        $this->via = $relation;
886
887 32
        if ($callable !== null) {
888 8
            $callable($relation);
889
        }
890
891 32
        return $this;
892
    }
893
894
    /**
895
     * Define an alias for the table defined in {@see arClass}.
896
     *
897
     * This method will adjust {@see from} so that an already defined alias will be overwritten. If none was defined,
898
     * {@see from} will be populated with the given alias.
899
     *
900
     * @param string $alias the table alias.
901
     *
902
     * @return $this the query object itself.
903
     */
904 41
    public function alias(string $alias): self
905
    {
906 41
        if (empty($this->from) || count($this->from) < 2) {
907 41
            [$tableName] = $this->getTableNameAndAlias();
908 41
            $this->from = [$alias => $tableName];
909
        } else {
910 4
            $tableName = $this->getPrimaryTableName();
911
912 4
            foreach ($this->from as $key => $table) {
913 4
                if ($table === $tableName) {
914 4
                    unset($this->from[$key]);
915 4
                    $this->from[$alias] = $tableName;
916
                }
917
            }
918
        }
919
920 41
        return $this;
921
    }
922
923
    /**
924
     * Returns table names used in {@see from} indexed by aliases.
925
     *
926
     * Both aliases and names are enclosed into {{ and }}.
927
     *
928
     * @throws InvalidArgumentException|InvalidConfigException
929
     *
930
     * @return array table names indexed by aliases.
931
     */
932 168
    public function getTablesUsedInFrom(): array
933
    {
934 168
        if (empty($this->from)) {
935 136
            return $this->cleanUpTableNames([$this->getPrimaryTableName()]);
936
        }
937
938 44
        return parent::getTablesUsedInFrom();
939
    }
940
941 552
    protected function getPrimaryTableName(): string
942
    {
943 552
        return $this->getARInstance()->tableName();
0 ignored issues
show
Bug introduced by
The method tableName() does not exist on Yiisoft\ActiveRecord\ActiveRecordInterface. It seems like you code against a sub-type of Yiisoft\ActiveRecord\ActiveRecordInterface such as Yiisoft\ActiveRecord\ActiveRecord. ( Ignorable by Annotation )

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

943
        return $this->getARInstance()->/** @scrutinizer ignore-call */ tableName();
Loading history...
944
    }
945
946
    /**
947
     * @return string|array the join condition to be used when this query is used in a relational context.
948
     *
949
     * The condition will be used in the ON part when {@see ActiveQuery::joinWith()} is called. Otherwise, the condition
950
     * will be used in the WHERE part of a query.
951
     *
952
     * Please refer to {@see Query::where()} on how to specify this parameter.
953
     *
954
     * {@see onCondition()}
955
     */
956 52
    public function getOn()
957
    {
958 52
        return $this->on;
959
    }
960
961
    /**
962
     * @return array $value a list of relations that this query should be joined with.
963
     */
964 204
    public function getJoinWith(): array
965
    {
966 204
        return $this->joinWith;
967
    }
968
969
    /**
970
     * @return string|null the SQL statement to be executed for retrieving AR records.
971
     *
972
     * This is set by {@see ActiveRecord::findBySql()}.
973
     */
974 4
    public function getSql(): ?string
975
    {
976 4
        return $this->sql;
977
    }
978
979 5
    public function getARClass(): ?string
980
    {
981 5
        return $this->arClass;
982
    }
983
984
    /**
985
     * @param mixed $condition primary key value or a set of column values.
986
     *
987
     * @throws InvalidConfigException
988
     *
989
     * @return ActiveRecordInterface|null ActiveRecord instance matching the condition, or `null` if nothing matches.
990
     */
991 228
    public function findOne($condition): ?ActiveRecordInterface
992
    {
993 228
        return $this->findByCondition($condition)->one();
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->findByCondition($condition)->one() could return the type array which is incompatible with the type-hinted return Yiisoft\ActiveRecord\ActiveRecordInterface|null. Consider adding an additional type-check to rule them out.
Loading history...
994
    }
995
996
    /**
997
     * @param mixed $condition primary key value or a set of column values.
998
     *
999
     * @throws InvalidConfigException
1000
     *
1001
     * @return array of ActiveRecord instance, or an empty array if nothing matches.
1002
     */
1003 5
    public function findAll($condition): array
1004
    {
1005 5
        return $this->findByCondition($condition)->all();
1006
    }
1007
1008
    /**
1009
     * Finds ActiveRecord instance(s) by the given condition.
1010
     *
1011
     * This method is internally called by {@see findOne()} and {@see findAll()}.
1012
     *
1013
     * @param mixed $condition please refer to {@see findOne()} for the explanation of this parameter.
1014
     *
1015
     * @throws InvalidConfigException if there is no primary key defined.
1016
     *
1017
     * @return ActiveQueryInterface the newly created {@see QueryInterface} instance.
1018
     */
1019 285
    protected function findByCondition($condition): ActiveQueryInterface
1020
    {
1021 285
        $arInstance = $this->getARInstance();
1022
1023 285
        if (!is_array($condition)) {
1024 185
            $condition = [$condition];
1025
        }
1026
1027 285
        if (!ArrayHelper::isAssociative($condition) && !$condition instanceof ExpressionInterface) {
1028
            /** query by primary key */
1029 189
            $primaryKey = $arInstance->primaryKey();
1030
1031 189
            if (isset($primaryKey[0])) {
1032 189
                $pk = $primaryKey[0];
1033
1034 189
                if (!empty($this->getJoin()) || !empty($this->getJoinWith())) {
1035
                    $pk = $arInstance->tableName() . '.' . $pk;
1036
                }
1037
1038
                /**
1039
                 * if condition is scalar, search for a single primary key, if it is array, search for multiple primary
1040
                 * key values
1041
                 */
1042 189
                $condition = [$pk => is_array($condition) ? array_values($condition) : $condition];
0 ignored issues
show
introduced by
The condition is_array($condition) is always true.
Loading history...
1043
            } else {
1044 189
                throw new InvalidConfigException('"' . get_class($arInstance) . '" must have a primary key.');
1045
            }
1046 108
        } elseif (is_array($condition)) {
0 ignored issues
show
introduced by
The condition is_array($condition) is always true.
Loading history...
1047 108
            $aliases = $arInstance->filterValidAliases($this);
0 ignored issues
show
Bug introduced by
The method filterValidAliases() does not exist on Yiisoft\ActiveRecord\ActiveRecordInterface. It seems like you code against a sub-type of Yiisoft\ActiveRecord\ActiveRecordInterface such as Yiisoft\ActiveRecord\ActiveRecord. ( Ignorable by Annotation )

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

1047
            /** @scrutinizer ignore-call */ 
1048
            $aliases = $arInstance->filterValidAliases($this);
Loading history...
1048 108
            $condition = $arInstance->filterCondition($condition, $aliases);
0 ignored issues
show
Bug introduced by
The method filterCondition() does not exist on Yiisoft\ActiveRecord\ActiveRecordInterface. It seems like you code against a sub-type of Yiisoft\ActiveRecord\ActiveRecordInterface such as Yiisoft\ActiveRecord\ActiveRecord. ( Ignorable by Annotation )

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

1048
            /** @scrutinizer ignore-call */ 
1049
            $condition = $arInstance->filterCondition($condition, $aliases);
Loading history...
1049
        }
1050
1051 253
        return $this->where($condition);
1052
    }
1053
1054
    /**
1055
     * Creates an {@see ActiveQuery} instance with a given SQL statement.
1056
     *
1057
     * Note that because the SQL statement is already specified, calling additional query modification methods (such as
1058
     * `where()`, `order()`) on the created {@see ActiveQuery} instance will have no effect. However, calling `with()`,
1059
     * `asArray()` or `indexBy()` is still fine.
1060
     *
1061
     * Below is an example:
1062
     *
1063
     * ```php
1064
     * $customerQuery = new ActiveQuery(Customer::class, $db);
1065
     * $customers = $customerQuery->findBySql('SELECT * FROM customer')->all();
1066
     * ```
1067
     *
1068
     * @param string $sql the SQL statement to be executed.
1069
     * @param array $params parameters to be bound to the SQL statement during execution.
1070
     *
1071
     * @return Query the newly created {@see ActiveQuery} instance
1072
     */
1073 8
    public function findBySql(string $sql, array $params = []): Query
1074
    {
1075 8
        return $this->sql($sql)->params($params);
1076
    }
1077
1078 15
    public function on($value): self
1079
    {
1080 15
        $this->on = $value;
1081 15
        return $this;
1082
    }
1083
1084 12
    public function sql(?string $value): self
1085
    {
1086 12
        $this->sql = $value;
1087 12
        return $this;
1088
    }
1089
1090 624
    public function getARInstance(): ActiveRecordInterface
1091
    {
1092 624
        if ($this->arFactory !== null) {
1093
            return $this->getARInstanceFactory();
1094
        }
1095
1096 624
        $class = $this->arClass;
1097
1098 624
        return new $class($this->db);
1099
    }
1100
1101
    public function getARInstanceFactory(): ActiveRecordInterface
1102
    {
1103
        $this->arFactory->withConnection($this->db);
0 ignored issues
show
Bug introduced by
The method withConnection() 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

1103
        $this->arFactory->/** @scrutinizer ignore-call */ 
1104
                          withConnection($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...
1104
1105
        return $this->arFactory->createAR($this->arClass);
1106
    }
1107
}
1108