Passed
Pull Request — master (#228)
by Def
02:49
created

ActiveQuery::createQueryHelper()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

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

618
                    $this->joinWithRelation($parent, /** @scrutinizer ignore-type */ $relation, $this->getJoinType($joinType, $fullName));
Loading history...
619 84
                } else {
620
                    $relation = $relations[$fullName];
621
                }
622 84
623
                $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

623
                /** @scrutinizer ignore-call */ 
624
                $primaryModel = $relation->getARInstance();
Loading history...
624
625
                $parent = $relation;
626
                $prefix = $fullName;
627
                $name = $childName;
628
            }
629
630
            $fullName = $prefix === '' ? $name : "$prefix.$name";
631
632 84
            if (!isset($relations[$fullName])) {
633
                $relations[$fullName] = $relation = $primaryModel->getRelation($name);
634 84
635
                if ($callback !== null) {
636
                    $callback($relation);
637
                }
638 84
639
                if (!empty($relation->getJoinWith())) {
640
                    $relation->buildJoinWith();
641
                }
642
643
                $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

643
                $this->joinWithRelation(/** @scrutinizer ignore-type */ $parent, $relation, $this->getJoinType($joinType, $fullName));
Loading history...
644
            }
645
        }
646 113
    }
647
648 113
    /**
649 104
     * Returns the join type based on the given join type parameter and the relation name.
650
     *
651 89
     * @param array|string $joinType the given join type(s).
652
     * @param string $name relation name.
653 89
     *
654 89
     * @return string the real join type.
655 28
     */
656
    private function getJoinType(array|string $joinType, string $name): string
657 81
    {
658
        if (is_array($joinType) && isset($joinType[$name])) {
659
            return $joinType[$name];
660
        }
661 109
662 8
        return is_string($joinType) ? $joinType : 'INNER JOIN';
663
    }
664 109
665
    /**
666
     * Returns the table name and the table alias for {@see arClass}.
667 109
     *
668
     * @throws CircularReferenceException
669
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
670
     * @throws NotFoundException
671
     * @throws NotInstantiableException
672
     */
673
    private function getTableNameAndAlias(): array
674
    {
675
        if (empty($this->from)) {
676
            $tableName = $this->getPrimaryTableName();
677
        } else {
678
            $tableName = '';
679 84
680
            foreach ($this->from as $alias => $tableName) {
681 84
                if (is_string($alias)) {
682 84
                    return [$tableName, $alias];
683
                }
684 84
                break;
685
            }
686 12
        }
687 12
688
        if (preg_match('/^(.*?)\s+({{\w+}}|\w+)$/', $tableName, $matches)) {
689 12
            $alias = $matches[2];
690
        } else {
691
            $alias = $tableName;
692 84
        }
693
694 28
        return [$tableName, $alias];
695 28
    }
696
697 28
    /**
698
     * Joins a parent query with a child query.
699
     *
700 84
     * The current query object will be modified accordingly.
701 84
     *
702
     * @param ActiveQuery $parent
703 84
     * @param ActiveQuery $child
704 84
     * @param string $joinType
705 84
     *
706
     * @throws CircularReferenceException
707
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
708 84
     * @throws NotFoundException
709 84
     * @throws NotInstantiableException
710
     */
711
    private function joinWithRelation(ActiveQueryInterface $parent, ActiveQueryInterface $child, string $joinType): void
712 84
    {
713
        $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...
714 84
        $child->via = null;
715 84
716
        if ($via instanceof self) {
717
            /** via table */
718 84
            $this->joinWithRelation($parent, $via, $joinType);
719
            $this->joinWithRelation($via, $child, $joinType);
720 84
721 84
            return;
722
        }
723
724
        if (is_array($via)) {
725
            /** via relation */
726
            $this->joinWithRelation($parent, $via[1], $joinType);
727 84
            $this->joinWithRelation($via[1], $child, $joinType);
728
729 84
            return;
730 28
        }
731
732
        [$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

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

1160
        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...
1161
    }
1162
}
1163