Passed
Pull Request — master (#245)
by Alexander
04:32 queued 01:51
created

ActiveQuery::createInstance()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 18
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 16
c 1
b 0
f 0
dl 0
loc 18
ccs 0
cts 0
cp 0
rs 9.7333
cc 1
nc 1
nop 0
crap 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\ActiveRecord;
6
7
use Closure;
8
use ReflectionException;
9
use Throwable;
10
use Yiisoft\Db\Command\CommandInterface;
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\Helper\ArrayHelper;
18
use Yiisoft\Db\Query\BatchQueryResultInterface;
19
use Yiisoft\Db\Query\Query;
20
use Yiisoft\Db\Query\QueryInterface;
21
use Yiisoft\Db\QueryBuilder\QueryBuilderInterface;
22
use Yiisoft\Definitions\Exception\CircularReferenceException;
23
use Yiisoft\Definitions\Exception\NotInstantiableException;
24
use Yiisoft\Factory\NotFoundException;
25
26
use function array_merge;
27
use function array_values;
28
use function count;
29
use function implode;
30
use function in_array;
31
use function is_array;
32
use function is_int;
33
use function is_string;
34
use function preg_match;
35
use function reset;
36
use function serialize;
37
use function strpos;
38
use function substr;
39
40
/**
41
 * ActiveQuery represents a db query associated with an Active Record class.
42
 *
43
 * An ActiveQuery can be a normal query or be used in a relational context.
44
 *
45
 * ActiveQuery instances are usually created by {@see findOne()}, {@see findBySql()}, {@see findAll()}.
46
 *
47
 * Relational queries are created by {@see ActiveRecord::hasOne()} and {@see ActiveRecord::hasMany()}.
48
 *
49
 * Normal Query
50
 * ------------
51
 *
52
 * ActiveQuery mainly provides the following methods to retrieve the query results:
53
 *
54
 * - {@see one()}: returns a single record populated with the first row of data.
55
 * - {@see all()}: returns all records based on the query results.
56
 * - {@see count()}: returns the number of records.
57
 * - {@see sum()}: returns the sum over the specified column.
58
 * - {@see average()}: returns the average over the specified column.
59
 * - {@see min()}: returns the min over the specified column.
60
 * - {@see max()}: returns the max over the specified column.
61
 * - {@see scalar()}: returns the value of the first column in the first row of the query result.
62
 * - {@see column()}: returns the value of the first column in the query result.
63
 * - {@see exists()}: returns a value indicating whether the query result has data or not.
64
 *
65
 * Because ActiveQuery extends from {@see Query}, one can use query methods, such as {@see where()}, {@see orderBy()} to
66
 * customize the query options.
67
 *
68
 * ActiveQuery also provides the following more query options:
69
 *
70
 * - {@see with()}: list of relations that this query should be performed with.
71
 * - {@see joinWith()}: reuse a relation query definition to add a join to a query.
72
 * - {@see indexBy()}: the name of the column by which the query result should be indexed.
73
 * - {@see asArray()}: whether to return each record as an array.
74
 *
75
 * These options can be configured using methods of the same name. For example:
76
 *
77
 * ```php
78
 * $customerQuery = new ActiveQuery(Customer::class, $db);
79
 * $query = $customerQuery->with('orders')->asArray()->all();
80
 * ```
81
 *
82
 * Relational query
83
 * ----------------
84
 *
85
 * In relational context ActiveQuery represents a relation between two Active Record classes.
86
 *
87
 * Relational ActiveQuery instances are usually created by calling {@see ActiveRecord::hasOne()} and
88
 * {@see ActiveRecord::hasMany()}. An Active Record class declares a relation by defining a getter method which calls
89
 * one of the above methods and returns the created ActiveQuery object.
90
 *
91
 * A relation is specified by {@see link()} which represents the association between columns of different tables; and
92
 * the multiplicity of the relation is indicated by {@see multiple()}.
93
 *
94
 * If a relation involves a junction table, it may be specified by {@see via()} or {@see viaTable()} method.
95
 *
96
 * These methods may only be called in a relational context. Same is true for {@see inverseOf()}, which marks a relation
97
 * as inverse of another relation and {@see onCondition()} which adds a condition that's to be added to relational
98
 * query join condition.
99
 */
100
class ActiveQuery extends Query implements ActiveQueryInterface
101
{
102
    use ActiveQueryTrait;
103
    use ActiveRelationTrait;
104
105
    private string|null $sql = null;
106
    private array|string|null $on = null;
107
    private array $joinWith = [];
108
    private ActiveRecordInterface|null $arInstance = null;
109 720
110
    public function __construct(
111 720
        protected string $arClass,
112 720
        protected ConnectionInterface $db,
113 720
        private ActiveRecordFactory|null $arFactory = null,
114
        private string $tableName = ''
115 720
    ) {
116 720
        parent::__construct($db);
117
    }
118
119
    /**
120
     * Executes a query and returns all results as an array.
121
     *
122
     * If null, the db connection returned by {@see arClass} will be used.
123
     *
124
     * @throws Exception
125
     * @throws InvalidConfigException
126
     * @throws Throwable
127 244
     *
128
     * @return array The query results. If the query results in nothing, an empty array will be returned.
129 244
     *
130
     * @psalm-return ActiveRecord[]|array
131
     */
132
    public function all(): array
133
    {
134
        if ($this->shouldEmulateExecution()) {
135
            return [];
136
        }
137
138
        return $this->populate($this->createCommand()->queryAll(), $this->indexBy);
139
    }
140
141
    public function batch(int $batchSize = 100): BatchQueryResultInterface
142
    {
143
        return parent::batch($batchSize)->setPopulatedMethod(fn ($rows, $indexBy) => $this->populate($rows, $indexBy));
144
    }
145
146 497
    public function each(int $batchSize = 100): BatchQueryResultInterface
147
    {
148
        return parent::each($batchSize)->setPopulatedMethod(fn ($rows, $indexBy) => $this->populate($rows, $indexBy));
149
    }
150
151
    /**
152
     * @throws CircularReferenceException
153 497
     * @throws Exception
154 80
     * @throws InvalidConfigException
155
     * @throws NotFoundException
156 80
     * @throws NotInstantiableException
157
     * @throws Throwable
158
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
159 497
     */
160 489
    public function prepare(QueryBuilderInterface $builder): QueryInterface
161
    {
162
        /**
163 497
         * NOTE: Because the same ActiveQuery may be used to build different SQL statements, one for count query, the
164 76
         * other for row data query, it's important to make sure the same ActiveQuery can be used to build SQL
165
         * statements many times.
166 76
         */
167
        if (!empty($this->joinWith)) {
168
            $this->buildJoinWith();
169 497
            /**
170
             * Clean it up to avoid issue @link https://github.com/yiisoft/yii2/issues/2687
171 489
             */
172
            $this->joinWith = [];
173
        }
174 113
175
        if (empty($this->getFrom())) {
176 113
            $this->from = [$this->getPrimaryTableName()];
177
        }
178 20
179
        if (empty($this->getSelect()) && !empty($this->getJoin())) {
180 20
            [, $alias] = $this->getTableNameAndAlias();
181 101
182
            $this->select(["$alias.*"]);
183
        }
184
185
        if ($this->primaryModel === null) {
186
            $query = $this->createInstance();
187 28
        } else {
188
            $where = $this->getWhere();
189 28
190 28
            if ($this->via instanceof self) {
191 20
                /** via junction table */
192 8
                $viaModels = $this->via->findJunctionRows([$this->primaryModel]);
193
194
                $this->filterByModels($viaModels);
195 8
            } elseif (is_array($this->via)) {
196 28
                [$viaName, $viaQuery, $viaCallableUsed] = $this->via;
197
198
                if ($viaQuery->getMultiple()) {
199
                    if ($viaCallableUsed) {
200
                        $viaModels = $viaQuery->all();
201
                    } elseif ($this->primaryModel->isRelationPopulated($viaName)) {
202
                        $viaModels = $this->primaryModel->$viaName;
203
                    } else {
204
                        $viaModels = $viaQuery->all();
205
                        $this->primaryModel->populateRelation($viaName, $viaModels);
206
                    }
207
                } else {
208
                    if ($viaCallableUsed) {
209 28
                        $model = $viaQuery->onePopulate();
210
                    } elseif ($this->primaryModel->isRelationPopulated($viaName)) {
211 101
                        $model = $this->primaryModel->$viaName;
212
                    } else {
213
                        $model = $viaQuery->onePopulate();
214 113
                        $this->primaryModel->populateRelation($viaName, $model);
215 113
                    }
216
                    $viaModels = $model === null ? [] : [$model];
217
                }
218 497
                $this->filterByModels($viaModels);
219 24
            } else {
220
                $this->filterByModels([$this->primaryModel]);
221
            }
222 497
223
            $query = $this->createInstance();
224
            $this->where($where);
225
        }
226
227
        if (!empty($this->on)) {
228
            $query->andWhere($this->on);
229
        }
230
231
        return $query;
232
    }
233
234
    /**
235
     * Converts the raw query results into the format as specified by this query.
236
     *
237 419
     * This method is internally used to convert the data fetched from a database into the format as required by this
238
     * query.
239 419
     *
240 74
     * @param array $rows The raw query result from a database.
241
     *
242
     * @throws Exception
243 406
     * @throws InvalidArgumentException
244
     * @throws InvalidConfigException
245 406
     * @throws NotSupportedException
246 64
     * @throws ReflectionException
247
     * @throws Throwable
248
     *
249 406
     * @return array The converted query result.
250 131
     */
251
    public function populate(array $rows, Closure|string|null $indexBy = null): array
252
    {
253 406
        if (empty($rows)) {
254 16
            return [];
255
        }
256
257 406
        $models = $this->createModels($rows);
258
259
        if (!empty($this->join) && $this->getIndexBy() === null) {
260
            $models = $this->removeDuplicatedModels($models);
261
        }
262
263
        if (!empty($this->with)) {
264
            $this->findWith($this->with, $models);
265
        }
266
267
        if ($this->inverseOf !== null) {
268
            $this->addInverseRelations($models);
269
        }
270
271 64
        return ArrayHelper::populate($models, $indexBy);
272
    }
273 64
274
    /**
275 64
     * Removes duplicated models by checking their primary key values.
276
     *
277 64
     * This method is mainly called when a join query is performed, which may cause duplicated rows being returned.
278
     *
279 8
     * @param array $models The models to be checked.
280 8
     *
281 8
     * @throws CircularReferenceException
282 8
     * @throws Exception
283
     * @throws InvalidConfigException
284 4
     * @throws NotFoundException
285
     * @throws NotInstantiableException
286 7
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
287
     *
288
     * @return array The distinctive models.
289 4
     */
290
    private function removeDuplicatedModels(array $models): array
291 4
    {
292
        $hash = [];
293
294 4
        $pks = $this->getARInstance()->primaryKey();
295
296
        if (count($pks) > 1) {
297 60
            // Composite primary key.
298
            foreach ($models as $i => $model) {
299
                $key = [];
300
                foreach ($pks as $pk) {
301 60
                    if (!isset($model[$pk])) {
302
                        // Don't continue if the primary key isn't part of the result set.
303 60
                        break 2;
304 60
                    }
305
                    $key[] = $model[$pk];
306 4
                }
307
308
                $key = serialize($key);
309 56
310
                if (isset($hash[$key])) {
311 56
                    unset($models[$i]);
312 28
                } else {
313 56
                    $hash[$key] = true;
314 56
                }
315
            }
316
        } elseif (empty($pks)) {
317
            throw new InvalidConfigException("Primary key of '$this->arClass' can not be empty.");
318
        } else {
319 64
            // Single column primary key.
320
            $pk = reset($pks);
321
322
            foreach ($models as $i => $model) {
323
                if (!isset($model[$pk])) {
324
                    // Don't continue if the primary key isn't part of the result set.
325
                    break;
326
                }
327
328
                $key = $model[$pk];
329
330
                if (isset($hash[$key])) {
331
                    unset($models[$i]);
332 305
                } else {
333
                    $hash[$key] = true;
334 305
                }
335
            }
336 305
        }
337 301
338
        return array_values($models);
339 301
    }
340
341
    /**
342 32
     * @throws Exception
343
     * @throws InvalidArgumentException
344
     * @throws InvalidConfigException
345
     * @throws NotSupportedException
346
     * @throws ReflectionException
347
     * @throws Throwable
348
     */
349
    public function allPopulate(): array
350
    {
351
        $rows = $this->all();
352 449
353
        if ($rows !== []) {
354 449
            $rows = $this->populate($rows, $this->indexBy);
355 445
        }
356
357 4
        return $rows;
358 4
    }
359
360
    /**
361 449
     * @throws Exception
362
     * @throws InvalidArgumentException
363 449
     * @throws InvalidConfigException
364
     * @throws NotSupportedException
365 449
     * @throws ReflectionException
366
     * @throws Throwable
367
     */
368
    public function onePopulate(): array|ActiveRecordInterface|null
369
    {
370
        $row = $this->one();
371
372
        if ($row !== null) {
373
            $activeRecord = $this->populate([$row], $this->indexBy);
374
            $row = reset($activeRecord) ?: null;
375
        }
376
377
        return $row;
378
    }
379 61
380
    /**
381 61
     * Creates a db command that can be used to execute this query.
382 57
     *
383
     * @throws Exception
384
     */
385 4
    public function createCommand(): CommandInterface
386 4
    {
387 4
        if ($this->sql === null) {
388 4
            [$sql, $params] = $this->db->getQueryBuilder()->build($this);
389
        } else {
390 4
            $sql = $this->sql;
391
            $params = $this->params;
392 4
        }
393
394
        return $this->db->createCommand($sql, $params);
395
    }
396
397
    /**
398
     * Queries a scalar value by setting {@see select()} first.
399
     *
400
     * Restores the value of select to make this query reusable.
401
     *
402
     * @param ExpressionInterface|string $selectExpression The expression to be selected.
403
     *
404
     * @throws Exception
405
     * @throws InvalidArgumentException
406
     * @throws InvalidConfigException
407
     * @throws NotSupportedException
408
     * @throws Throwable
409
     */
410
    protected function queryScalar(string|ExpressionInterface $selectExpression): bool|string|null|int|float
411
    {
412
        if ($this->sql === null) {
413
            return parent::queryScalar($selectExpression);
414
        }
415
416
        $command = (new Query($this->db))->select([$selectExpression])
417
            ->from(['c' => "($this->sql)"])
418
            ->params($this->params)
419
            ->createCommand();
420
421
        return $command->queryScalar();
422
    }
423
424
    /**
425
     * Joins with the specified relations.
426
     *
427
     * This method allows you to reuse existing relation definitions to perform JOIN queries. Based on the definition of
428
     * the specified relation(s), the method will append one or many JOIN statements to the current query.
429
     *
430
     * If the `$eagerLoading` parameter is true, the method will also perform eager loading for the specified relations,
431
     * which is equal to calling {@see with()} using the specified relations.
432
     *
433
     * Note: That because a JOIN query will be performed, you're responsible for disambiguated column names.
434
     *
435
     * This method differs from {@see with()} in that it will build up and execute a JOIN SQL statement for the primary
436
     * table. And when `$eagerLoading` is true, it will call {@see with()} in addition with the specified relations.
437
     *
438
     * @param array|string $with The relations to be joined. This can either be a string, representing a relation name
439
     * or an array with the following semantics:
440
     *
441
     * - Each array element represents a single relation.
442
     * - You may specify the relation name as the array key and give anonymous functions that can be used to change the
443
     *   relation queries on-the-fly as the array value.
444
     * - If a relation query doesn't need modification, you may use the relation name as the array value.
445
     *
446
     * The relation name may optionally contain an alias for the relation table (e.g. `books b`).
447
     *
448
     * Sub-relations can also be specified, see {@see with()} for the syntax.
449
     *
450
     * In the following you find some examples:
451 99
     *
452
     * ```php
453 99
     * // Find all orders that contain books, and eager loading "books".
454
     * $orderQuery = new ActiveQuery(Order::class, $db);
455 99
     * $orderQuery->joinWith('books', true, 'INNER JOIN')->all();
456 99
     *
457 95
     * // find all orders, eager loading "books", and sort the orders and books by the book names.
458 95
     * $orderQuery = new ActiveQuery(Order::class, $db);
459
     * $orderQuery->joinWith([
460
     *     'books' => function (ActiveQuery $query) {
461 99
     *         $query->orderBy('item.name');
462
     *     }
463 20
     * ])->all();
464
     *
465 20
     * // Find all orders that contain books of the category 'Science fiction', using the alias "b" for the book table.
466
     * $order = new ActiveQuery(Order::class, $db);
467 20
     * $orderQuery->joinWith(['books b'], true, 'INNER JOIN')->where(['b.category' => 'Science fiction'])->all();
468
     * ```
469 20
     * @param array|bool $eagerLoading Whether to eager load the relations specified in `$with`. When this is boolean.
470
     * It applies to all relations specified in `$with`. Use an array to explicitly list which relations in `$with` a
471 20
     * need to be eagerly loaded.
472 16
     * Note: That this doesn't mean that the relations are populated from the query result. An
473
     * extra query will still be performed to bring in the related data. Defaults to `true`.
474 20
     * @param array|string $joinType The join type of the relations specified in `$with`.  When this is a string, it
475
     * applies to all relations specified in `$with`. Use an array in the format of `relationName => joinType` to
476
     * specify different join types for different relations.
477 99
     */
478 95
    public function joinWith(
479
        array|string $with,
480 48
        array|bool $eagerLoading = true,
481
        array|string $joinType = 'LEFT JOIN'
482
    ): self {
483
        $relations = [];
484 99
485
        foreach ((array) $with as $name => $callback) {
486 99
            if (is_int($name)) {
487
                $name = $callback;
488
                $callback = null;
489 84
            }
490
491 84
            if (preg_match('/^(.*?)(?:\s+AS\s+|\s+)(\w+)$/i', $name, $matches)) {
492
                /** The relation is defined with an alias, adjust callback to apply alias */
493 84
                [, $relation, $alias] = $matches;
494
495 84
                $name = $relation;
496
497 84
                $callback = static function (self $query) use ($callback, $alias) {
498 84
                    $query->alias($alias);
499
500 84
                    if ($callback !== null) {
501
                        $callback($query);
502
                    }
503
                };
504
            }
505
506
            if ($callback === null) {
507
                $relations[] = $name;
508
            } else {
509
                $relations[$name] = $callback;
510 84
            }
511 16
        }
512
513
        $this->joinWith[] = [$relations, $eagerLoading, $joinType];
514 84
515
        return $this;
516
    }
517
518
    /**
519
     * @throws CircularReferenceException
520
     * @throws InvalidConfigException
521 84
     * @throws NotFoundException
522
     * @throws NotInstantiableException
523 84
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
524 84
     */
525
    public function buildJoinWith(): void
526 84
    {
527
        $join = $this->join;
528
529 84
        $this->join = [];
530
531 84
        $arClass = $this->getARInstance();
532 84
533 84
        foreach ($this->joinWith as [$with, $eagerLoading, $joinType]) {
534 84
            $this->joinWithRelations($arClass, $with, $joinType);
535
536
            if (is_array($eagerLoading)) {
537
                foreach ($with as $name => $callback) {
538 84
                    if (is_int($name)) {
539
                        if (!in_array($callback, $eagerLoading, true)) {
540 84
                            unset($with[$name]);
541
                        }
542
                    } elseif (!in_array($name, $eagerLoading, true)) {
543
                        unset($with[$name]);
544 84
                    }
545
                }
546
            } elseif (!$eagerLoading) {
547
                $with = [];
548
            }
549
550
            $this->with($with);
551
        }
552
553
        /**
554
         * Remove duplicated joins added by joinWithRelations that may be added, for example, when joining a relation
555
         * and a via relation at the same time.
556
         */
557
        $uniqueJoins = [];
558
559
        foreach ($this->join as $j) {
560
            $uniqueJoins[serialize($j)] = $j;
561 53
        }
562
        $this->join = array_values($uniqueJoins);
563 53
564
        /**
565
         * @link https://github.com/yiisoft/yii2/issues/16092
566
         */
567
        $uniqueJoinsByTableName = [];
568
569
        foreach ($this->join as $config) {
570
            $tableName = serialize($config[1]);
571
            if (!array_key_exists($tableName, $uniqueJoinsByTableName)) {
572
                $uniqueJoinsByTableName[$tableName] = $config;
573 84
            }
574
        }
575 84
576
        $this->join = array_values($uniqueJoinsByTableName);
577 84
578 84
        if (!empty($join)) {
579 80
            /**
580 80
             * Append explicit join to {@see joinWith()} {@link https://github.com/yiisoft/yii2/issues/2880}
581
             */
582
            $this->join = empty($this->join) ? $join : array_merge($this->join, $join);
583 84
        }
584 84
    }
585 84
586
    /**
587 84
     * Inner joins with the specified relations.
588 20
     *
589 20
     * This is a shortcut method to {@see joinWith()} with the join type set as "INNER JOIN".
590 20
     *
591
     * Please refer to {@see joinWith()} for detailed usage of this method.
592 20
     *
593 12
     * @param array|string $with The relations to be joined with.
594 12
     * @param array|bool $eagerLoading Whether to eager load the relations.
595
     * Note: That this doesn't mean that the relations are populated from the query result.
596 8
     * An extra query will still be performed to bring in the related data.
597
     *
598
     * @see joinWith()
599 20
     */
600
    public function innerJoinWith(array|string $with, array|bool $eagerLoading = true): self
601 20
    {
602 20
        return $this->joinWith($with, $eagerLoading, 'INNER JOIN');
603 20
    }
604
605
    /**
606 84
     * Modifies the current query by adding join fragments based on the given relations.
607
     *
608 84
     * @param ActiveRecordInterface $arClass The primary model.
609 84
     * @param array $with The relations to be joined.
610
     * @param array|string $joinType The join type.
611 84
     *
612 48
     * @throws CircularReferenceException
613
     * @throws InvalidConfigException
614
     * @throws NotFoundException
615 84
     * @throws NotInstantiableException
616 12
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
617
     */
618
    private function joinWithRelations(ActiveRecordInterface $arClass, array $with, array|string $joinType): void
619 84
    {
620
        $relations = [];
621
622 84
        foreach ($with as $name => $callback) {
623
            if (is_int($name)) {
624
                $name = $callback;
625
                $callback = null;
626
            }
627
628
            $primaryModel = $arClass;
629
            $parent = $this;
630
            $prefix = '';
631
632 84
            while (($pos = strpos($name, '.')) !== false) {
633
                $childName = substr($name, $pos + 1);
634 84
                $name = substr($name, 0, $pos);
635
                $fullName = $prefix === '' ? $name : "$prefix.$name";
636
637
                if (!isset($relations[$fullName])) {
638 84
                    $relations[$fullName] = $relation = $primaryModel->getRelation($name);
639
                    $this->joinWithRelation($parent, $relation, $this->getJoinType($joinType, $fullName));
0 ignored issues
show
Bug introduced by
It seems like $relation can also be of type null; however, parameter $child of Yiisoft\ActiveRecord\Act...ery::joinWithRelation() does only seem to accept Yiisoft\ActiveRecord\ActiveQueryInterface, maybe add an additional type check? ( Ignorable by Annotation )

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

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

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

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

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

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

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

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

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

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

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

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

Loading history...
1174
    }
1175
1176
    private function createInstance(): self
1177
    {
1178
        return (new self($this->arClass, $this->db))
1179
            ->where($this->getWhere())
1180
            ->limit($this->getLimit())
1181
            ->offset($this->getOffset())
1182
            ->orderBy($this->getOrderBy())
1183
            ->indexBy($this->getIndexBy())
1184
            ->select($this->select)
1185
            ->selectOption($this->selectOption)
1186
            ->distinct($this->distinct)
1187
            ->from($this->from)
1188
            ->groupBy($this->groupBy)
1189
            ->setJoin($this->join)
1190
            ->having($this->having)
1191
            ->setUnion($this->union)
1192
            ->params($this->params)
1193
            ->withQueries($this->withQueries);
1194
    }
1195
}
1196