Passed
Push — master ( d01bf5...5a7f24 )
by Alexander
14:14
created

ActiveQuery   F

Complexity

Total Complexity 135

Size/Duplication

Total Lines 1010
Duplicated Lines 0 %

Test Coverage

Coverage 90.44%

Importance

Changes 0
Metric Value
eloc 318
dl 0
loc 1010
ccs 265
cts 293
cp 0.9044
rs 2
c 0
b 0
f 0
wmc 135

34 Methods

Rating   Name   Duplication   Size   Complexity  
A innerJoinWith() 0 3 1
B findByCondition() 0 33 9
A populate() 0 21 6
A orOnCondition() 0 11 2
A __construct() 0 7 1
A getPrimaryTableName() 0 3 1
A alias() 0 17 5
A all() 0 3 1
A getJoinType() 0 7 4
A getTableNameAndAlias() 0 22 5
A sql() 0 4 1
F joinWithRelation() 0 79 18
A getTablesUsedInFrom() 0 7 2
B joinWithRelations() 0 50 10
A andOnCondition() 0 11 2
A findOne() 0 3 1
A findBySql() 0 3 1
A findAll() 0 3 1
A one() 0 11 3
A joinWith() 0 36 6
A getARClass() 0 3 1
A queryScalar() 0 14 2
A viaTable() 0 15 3
C buildJoinWith() 0 54 13
B removeDuplicatedModels() 0 49 11
A getOn() 0 3 1
A createCommand() 0 14 2
C prepare() 0 77 15
A getSql() 0 3 1
A on() 0 4 1
A getJoinWith() 0 3 1
A onCondition() 0 7 1
A getARInstanceFactory() 0 5 1
A getARInstance() 0 9 2

How to fix   Complexity   

Complex Class

Complex classes like ActiveQuery often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ActiveQuery, and based on these observations, apply Extract Interface, too.

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 = ActiveQuery(new 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 645
101
    protected string $arClass;
102 645
    protected ConnectionInterface $db;
103
    private ?string $sql = null;
104 645
    private $on;
105 645
    private array $joinWith = [];
106
    private ?ActiveRecordInterface $arInstance = null;
107
    private ?ActiveRecordFactory $arFactory;
108
109
    public function __construct(string $modelClass, ConnectionInterface $db, ?ActiveRecordFactory $arFactory = null)
110
    {
111
        $this->arClass = $modelClass;
112
        $this->arFactory = $arFactory;
113
        $this->db = $db;
114
115
        parent::__construct($db);
116
    }
117
118
    /**
119 228
     * Executes query and returns all results as an array.
120
     *
121 228
     * 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
    public function all(): array
128
    {
129
        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 428
     * @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
    public function prepare(QueryBuilder $builder): Query
147
    {
148 428
        /**
149 52
         * 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 52
         * statements multiple times.
152
         */
153
        if (!empty($this->joinWith)) {
154 428
            $this->buildJoinWith();
155 400
            /** clean it up to avoid issue {@see https://github.com/yiisoft/yii2/issues/2687} */
156
            $this->joinWith = [];
157
        }
158 428
159 48
        if (empty($this->getFrom())) {
160
            $this->from = [$this->getPrimaryTableName()];
161 48
        }
162
163
        if (empty($this->getSelect()) && !empty($this->getJoin())) {
164 428
            [, $alias] = $this->getTableNameAndAlias();
165
166 420
            $this->select(["$alias.*"]);
167
        }
168
169 128
        if ($this->primaryModel === null) {
170
            /** eager loading */
171 128
            $query = Query::create($this->db, $this);
172
        } else {
173 20
            /** lazy loading of a relation */
174
            $where = $this->getWhere();
175 20
176 116
            if ($this->via instanceof self) {
177
                /** via junction table */
178
                $viaModels = $this->via->findJunctionRows([$this->primaryModel]);
179
180
                $this->filterByModels($viaModels);
181
            } elseif (is_array($this->via)) {
182 36
                /**
183
                 * via relation
184 36
                 *
185 36
                 * @var $viaQuery ActiveQuery
186 24
                 */
187 12
                [$viaName, $viaQuery, $viaCallableUsed] = $this->via;
188
189
                if ($viaQuery->getMultiple()) {
190 12
                    if ($viaCallableUsed) {
191 36
                        $viaModels = $viaQuery->all();
192
                    } 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
                        $viaModels = $viaQuery->all();
196
                        $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 36
                        $model = $viaQuery->one();
205
                        $this->primaryModel->populateRelation($viaName, $model);
206 116
                    }
207
                    $viaModels = $model === null ? [] : [$model];
208
                }
209 128
                $this->filterByModels($viaModels);
210 128
            } else {
211
                $this->filterByModels([$this->primaryModel]);
212
            }
213 428
214 24
            $query = Query::create($this->db, $this);
215
            $this->where($where);
216
        }
217 428
218
        if (!empty($this->on)) {
219
            $query->andWhere($this->on);
220
        }
221
222
        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 350
     */
237
    public function populate(array $rows): array
238 350
    {
239 70
        if (empty($rows)) {
240
            return [];
241
        }
242 337
243
        $models = $this->createModels($rows);
244 337
245 36
        if (!empty($this->join) && $this->getIndexBy() === null) {
0 ignored issues
show
introduced by
The condition $this->getIndexBy() === null is always false.
Loading history...
246
            $models = $this->removeDuplicatedModels($models);
247
        }
248 337
249 99
        if (!empty($this->with)) {
250
            $this->findWith($this->with, $models);
251
        }
252 337
253 16
        if ($this->inverseOf !== null) {
254
            $this->addInverseRelations($models);
255
        }
256 337
257
        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 36
    private function removeDuplicatedModels(array $models): array
272
    {
273 36
        $hash = [];
274
275
        $pks = $this->getARInstance()->primaryKey();
276 36
277
        if (count($pks) > 1) {
278 36
            /** composite primary key */
279
            foreach ($models as $i => $model) {
280 36
                $key = [];
281
                foreach ($pks as $pk) {
282 8
                    if (!isset($model[$pk])) {
283 8
                        /** do not continue if the primary key is not part of the result set */
284 8
                        break 2;
285 8
                    }
286
                    $key[] = $model[$pk];
287 4
                }
288
289 7
                $key = serialize($key);
290
291
                if (isset($hash[$key])) {
292 4
                    unset($models[$i]);
293
                } else {
294 4
                    $hash[$key] = true;
295
                }
296
            }
297 4
        } elseif (empty($pks)) {
298
            throw new InvalidConfigException("Primary key of '{$this->getARInstance()}' can not be empty.");
299
        } else {
300 32
            /** single column primary key */
301
            $pk = reset($pks);
302
303
            foreach ($models as $i => $model) {
304 32
                if (!isset($model[$pk])) {
305
                    /** do not continue if the primary key is not part of the result set */
306 32
                    break;
307 32
                }
308
309 4
                $key = $model[$pk];
310
311
                if (isset($hash[$key])) {
312 28
                    unset($models[$i]);
313
                } elseif ($key !== null) {
314 28
                    $hash[$key] = true;
315 16
                }
316 28
            }
317 28
        }
318
319
        return array_values($models);
320
    }
321
322 36
    /**
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
    public function one()
333
    {
334
        $row = parent::one();
335
336
        if ($row !== false) {
0 ignored issues
show
introduced by
The condition $row !== false is always true.
Loading history...
337
            $models = $this->populate([$row]);
338 264
339
            return reset($models) ?: null;
340 264
        }
341
342 264
        return null;
343 260
    }
344
345 260
    /**
346
     * Creates a DB command that can be used to execute this query.
347
     *
348 32
     * @throws Exception|InvalidConfigException
349
     *
350
     * @return Command the created DB command instance.
351
     */
352
    public function createCommand(): Command
353
    {
354
        if ($this->sql === null) {
355
            [$sql, $params] = $this->db->getQueryBuilder()->build($this);
356 380
        } else {
357
            $sql = $this->sql;
358 380
            $params = $this->params;
359 376
        }
360
361 4
        $command = $this->db->createCommand($sql, $params);
362 4
363
        $this->setCommandCache($command);
364
365 380
        return $command;
366
    }
367 380
368
    /**
369 380
     * 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|string
378
     */
379
    protected function queryScalar($selectExpression)
380
    {
381
        if ($this->sql === null) {
382
            return parent::queryScalar($selectExpression);
383
        }
384
385
        $command = (new Query($this->db))->select([$selectExpression])
386
            ->from(['c' => "({$this->sql})"])
387 61
            ->params($this->params)
388
            ->createCommand();
389 61
390 57
        $this->setCommandCache($command);
391
392
        return $command->queryScalar();
393 4
    }
394 4
395 4
    /**
396 4
     * Joins with the specified relations.
397
     *
398 4
     * 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 4
     *
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
    public function joinWith($with, $eagerLoading = true, $joinType = 'LEFT JOIN'): self
452
    {
453
        $relations = [];
454
455 67
        foreach ((array) $with as $name => $callback) {
456
            if (is_int($name)) {
457 67
                $name = $callback;
458
                $callback = null;
459 67
            }
460 67
461 67
            if (preg_match('/^(.*?)(?:\s+AS\s+|\s+)(\w+)$/i', $name, $matches)) {
462 67
                /** relation is defined with an alias, adjust callback to apply alias */
463
                [, $relation, $alias] = $matches;
464
465 67
                $name = $relation;
466
467 12
                $callback = static function ($query) use ($callback, $alias) {
468
                    /** @var $query ActiveQuery */
469 12
                    $query->alias($alias);
470
471 12
                    if ($callback !== null) {
472
                        $callback($query);
473 12
                    }
474
                };
475 12
            }
476 12
477
            if ($callback === null) {
478 12
                $relations[] = $name;
479
            } else {
480
                $relations[$name] = $callback;
481 67
            }
482 67
        }
483
484 20
        $this->joinWith[] = [$relations, $eagerLoading, $joinType];
485
486
        return $this;
487
    }
488 67
489
    private function buildJoinWith(): void
490 67
    {
491
        $join = $this->join;
492
493 52
        $this->join = [];
494
495 52
        $arClass = $this->getARInstance();
496
497 52
        foreach ($this->joinWith as [$with, $eagerLoading, $joinType]) {
498
            $this->joinWithRelations($arClass, $with, $joinType);
499
500 52
            if (is_array($eagerLoading)) {
501
                foreach ($with as $name => $callback) {
502 52
                    if (is_int($name)) {
503
                        if (!in_array($callback, $eagerLoading, true)) {
504 52
                            unset($with[$name]);
505 52
                        }
506
                    } elseif (!in_array($name, $eagerLoading, true)) {
507 52
                        unset($with[$name]);
508
                    }
509
                }
510
            } elseif (!$eagerLoading) {
511
                $with = [];
512
            }
513
514
            $this->with($with);
515
        }
516
517 52
        /**
518 16
         * 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 52
        $uniqueJoins = [];
522
523
        foreach ($this->join as $j) {
524
            $uniqueJoins[serialize($j)] = $j;
525
        }
526
        $this->join = array_values($uniqueJoins);
527
528 52
        /** {@see https://github.com/yiisoft/yii2/issues/16092 } */
529
        $uniqueJoinsByTableName = [];
530 52
531 52
        foreach ($this->join as $config) {
532
            $tableName = serialize($config[1]);
533
            if (!array_key_exists($tableName, $uniqueJoinsByTableName)) {
534 52
                $uniqueJoinsByTableName[$tableName] = $config;
535
            }
536 52
        }
537
538
        $this->join = array_values($uniqueJoinsByTableName);
539
540
        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 52
    }
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 17
    public function innerJoinWith($with, $eagerLoading = true): self
562
    {
563 17
        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
    private function joinWithRelations(ActiveRecordInterface $arClass, array $with, $joinType): void
574
    {
575
        $relations = [];
576 52
577
        foreach ($with as $name => $callback) {
578 52
            if (is_int($name)) {
579
                $name = $callback;
580 52
                $callback = null;
581 52
            }
582 52
583 52
            $primaryModel = $arClass;
584
            $parent = $this;
585
            $prefix = '';
586 52
587 52
            while (($pos = strpos($name, '.')) !== false) {
588 52
                $childName = substr($name, $pos + 1);
589
                $name = substr($name, 0, $pos);
590 52
                $fullName = $prefix === '' ? $name : "$prefix.$name";
591 8
592 8
                if (!isset($relations[$fullName])) {
593 8
                    $relations[$fullName] = $relation = $primaryModel->getRelation($name);
594
                    $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 8
                } else {
596
                    $relation = $relations[$fullName];
597
                }
598
599 8
                /** @var $relationModelClass ActiveRecordInterface */
600
                $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
                $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 8
604
                $parent = $relation;
605 8
                $prefix = $fullName;
606
                $name = $childName;
607 8
            }
608 8
609 8
            $fullName = $prefix === '' ? $name : "$prefix.$name";
610
611
            if (!isset($relations[$fullName])) {
612 52
                $relations[$fullName] = $relation = $primaryModel->getRelation($name);
613
614 52
                if ($callback !== null) {
615 52
                    $callback($relation);
616
                }
617 52
618 20
                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
                    $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 52
622 8
                $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 52
    }
626
627
    /**
628 52
     * 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
    private function getJoinType($joinType, string $name): string
636
    {
637
        if (is_array($joinType) && isset($joinType[$name])) {
638 52
            return $joinType[$name];
639
        }
640 52
641
        return is_string($joinType) ? $joinType : 'INNER JOIN';
642
    }
643
644 52
    /**
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
    private function getTableNameAndAlias(): array
650
    {
651
        if (empty($this->from)) {
652 117
            $tableName = $this->getPrimaryTableName();
653
        } else {
654 117
            $tableName = '';
655 108
656
            foreach ($this->from as $alias => $tableName) {
657 61
                if (is_string($alias)) {
658
                    return [$tableName, $alias];
659 61
                }
660 61
                break;
661 20
            }
662
        }
663 53
664
        if (preg_match('/^(.*?)\s+({{\w+}}|\w+)$/', $tableName, $matches)) {
665
            $alias = $matches[2];
666
        } else {
667 113
            $alias = $tableName;
668 4
        }
669
670 113
        return [$tableName, $alias];
671
    }
672
673 113
    /**
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
    private function joinWithRelation(ActiveQuery $parent, ActiveQuery $child, string $joinType): void
683
    {
684
        $via = $child->via;
685 52
        $child->via = null;
686
687 52
        if ($via instanceof self) {
688 52
            /** via table */
689
            $this->joinWithRelation($parent, $via, $joinType);
690 52
            $this->joinWithRelation($via, $child, $joinType);
691
692 12
            return;
693 12
        }
694
695 12
        if (is_array($via)) {
696
            /** via relation */
697
            $this->joinWithRelation($parent, $via[1], $joinType);
698 52
            $this->joinWithRelation($via[1], $child, $joinType);
699
700 16
            return;
701 16
        }
702
703 16
        [$parentTable, $parentAlias] = $parent->getTableNameAndAlias();
704
        [$childTable, $childAlias] = $child->getTableNameAndAlias();
705
706 52
        if (!empty($child->link)) {
707 52
            if (strpos($parentAlias, '{{') === false) {
708
                $parentAlias = '{{' . $parentAlias . '}}';
709 52
            }
710 52
711 52
            if (strpos($childAlias, '{{') === false) {
712
                $childAlias = '{{' . $childAlias . '}}';
713
            }
714 52
715 52
            $on = [];
716
717
            foreach ($child->link as $childColumn => $parentColumn) {
718 52
                $on[] = "$parentAlias.[[$parentColumn]] = $childAlias.[[$childColumn]]";
719
            }
720 52
721 52
            $on = implode(' AND ', $on);
722
723
            if (!empty($child->on)) {
724 52
                $on = ['and', $on, $child->on];
725
            }
726 52
        } else {
727 52
            $on = $child->on;
728
        }
729
730
        $this->join($joinType, empty($child->getFrom()) ? $childTable : $child->getFrom(), $on);
731
732
        if (!empty($child->getWhere())) {
733 52
            $this->andWhere($child->getWhere());
734
        }
735 52
736 8
        if (!empty($child->getHaving())) {
737
            $this->andHaving($child->getHaving());
738
        }
739 52
740
        if (!empty($child->getOrderBy())) {
741
            $this->addOrderBy($child->getOrderBy());
742
        }
743 52
744 16
        if (!empty($child->getGroupBy())) {
745
            $this->addGroupBy($child->getGroupBy());
746
        }
747 52
748
        if (!empty($child->getParams())) {
749
            $this->addParams($child->getParams());
750
        }
751 52
752
        if (!empty($child->getJoin())) {
753
            foreach ($child->getJoin() as $join) {
754
                $this->join[] = $join;
755 52
            }
756 8
        }
757 8
758
        if (!empty($child->getUnion())) {
759
            foreach ($child->getUnion() as $union) {
760
                $this->union[] = $union;
761 52
            }
762
        }
763
    }
764
765
    /**
766 52
     * 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
    public function onCondition($condition, array $params = []): self
792
    {
793
        $this->on = $condition;
794
795 29
        $this->addParams($params);
796
797 29
        return $this;
798
    }
799 29
800
    /**
801 29
     * 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
    public function andOnCondition($condition, array $params = []): self
815
    {
816
        if ($this->on === null) {
817
            $this->on = $condition;
818 10
        } else {
819
            $this->on = ['and', $this->on, $condition];
820 10
        }
821 5
822
        $this->addParams($params);
823 5
824
        return $this;
825
    }
826 10
827
    /**
828 10
     * 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
    public function orOnCondition($condition, array $params = []): self
842
    {
843
        if ($this->on === null) {
844
            $this->on = $condition;
845 10
        } else {
846
            $this->on = ['or', $this->on, $condition];
847 10
        }
848 5
849
        $this->addParams($params);
850 5
851
        return $this;
852
    }
853 10
854
    /**
855 10
     * 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
    public function viaTable(string $tableName, array $link, callable $callable = null): self
878
    {
879
        $arClass = $this->primaryModel ? get_class($this->primaryModel) : $this->arClass;
880
881
        $arClassInstance = new self($arClass, $this->db);
882 32
883
        $relation = $arClassInstance->from([$tableName])->link($link)->multiple(true)->asArray(true);
884 32
885
        $this->via = $relation;
886 32
887
        if ($callable !== null) {
888 32
            $callable($relation);
889
        }
890 32
891 8
        return $this;
892
    }
893
894 32
    /**
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
    public function alias(string $alias): self
905
    {
906
        if (empty($this->from) || count($this->from) < 2) {
907 69
            [$tableName] = $this->getTableNameAndAlias();
908
            $this->from = [$alias => $tableName];
909 69
        } else {
910 69
            $tableName = $this->getPrimaryTableName();
911 69
912
            foreach ($this->from as $key => $table) {
913 4
                if ($table === $tableName) {
914
                    unset($this->from[$key]);
915 4
                    $this->from[$alias] = $tableName;
916 4
                }
917 4
            }
918 4
        }
919
920
        return $this;
921
    }
922
923 69
    /**
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
    public function getTablesUsedInFrom(): array
933
    {
934
        if (empty($this->from)) {
935
            return $this->cleanUpTableNames([$this->getPrimaryTableName()]);
936 164
        }
937
938 164
        return parent::getTablesUsedInFrom();
939 96
    }
940
941
    protected function getPrimaryTableName(): string
942 68
    {
943
        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 480
946
    /**
947 480
     * @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
    public function getOn()
957
    {
958
        return $this->on;
959
    }
960 52
961
    /**
962 52
     * @return array $value a list of relations that this query should be joined with.
963
     */
964
    public function getJoinWith(): array
965
    {
966
        return $this->joinWith;
967
    }
968 176
969
    /**
970 176
     * @return string|null the SQL statement to be executed for retrieving AR records.
971
     *
972
     * This is set by {@see ActiveRecord::findBySql()}.
973
     */
974
    public function getSql(): ?string
975
    {
976
        return $this->sql;
977
    }
978
979
    public function getARClass(): ?string
980
    {
981
        return $this->arClass;
982
    }
983 101
984
    /**
985 101
     * @param mixed $condition primary key value or a set of column values.
986
     *
987
     * @throws InvalidConfigException
988 15
     *
989
     * @return ActiveRecordInterface|null ActiveRecord instance matching the condition, or `null` if nothing matches.
990 15
     */
991
    public function findOne($condition): ?ActiveRecordInterface
992 15
    {
993
        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 8
996
    /**
997 8
     * @param mixed $condition primary key value or a set of column values.
998
     *
999 8
     * @throws InvalidConfigException
1000
     *
1001
     * @return array of ActiveRecord instance, or an empty array if nothing matches.
1002
     */
1003
    public function findAll($condition): array
1004
    {
1005
        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
    protected function findByCondition($condition): ActiveQueryInterface
1020
    {
1021
        $arInstance = $this->getARInstance();
1022
1023
        if (!is_array($condition)) {
1024
            $condition = [$condition];
1025
        }
1026
1027
        if (!ArrayHelper::isAssociative($condition) && !$condition instanceof ExpressionInterface) {
1028
            /** query by primary key */
1029
            $primaryKey = $arInstance->primaryKey();
1030
1031
            if (isset($primaryKey[0])) {
1032
                $pk = $primaryKey[0];
1033
1034
                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
                $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
                throw new InvalidConfigException('"' . get_class($arInstance) . '" must have a primary key.');
1045
            }
1046
        } elseif (is_array($condition)) {
0 ignored issues
show
introduced by
The condition is_array($condition) is always true.
Loading history...
1047
            $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
            $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
        return $this->andWhere($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
    public function findBySql(string $sql, array $params = []): Query
1074
    {
1075
        return $this->sql($sql)->params($params);
1076
    }
1077
1078
    public function on($value): self
1079
    {
1080
        $this->on = $value;
1081
        return $this;
1082
    }
1083
1084
    public function sql(?string $value): self
1085
    {
1086
        $this->sql = $value;
1087
        return $this;
1088
    }
1089
1090
    public function getARInstance(): ActiveRecordInterface
1091
    {
1092
        if ($this->arFactory !== null) {
1093
            return $this->getARInstanceFactory();
1094
        }
1095
1096
        $new = clone $this;
1097
        $class = $new->arClass;
1098
        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