Issues (272)

src/ActiveRelationTrait.php (1 issue)

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\Exception\Exception;
11
use Yiisoft\Db\Exception\InvalidArgumentException;
12
use Yiisoft\Db\Exception\InvalidConfigException;
13
use Yiisoft\Db\Exception\NotSupportedException;
14
15
use function array_column;
16
use function array_combine;
17
use function array_diff_key;
18
use function array_fill_keys;
19
use function array_filter;
20
use function array_intersect_key;
21
use function array_keys;
22
use function array_merge;
23
use function array_unique;
24
use function count;
25
use function is_array;
26
use function is_object;
27
use function is_string;
28
use function key;
29
use function reset;
30
use function serialize;
31
32
/**
33
 * ActiveRelationTrait implements the common methods and properties for active record relational queries.
34
 */
35
trait ActiveRelationTrait
36
{
37
    private bool $multiple = false;
38
    private ActiveRecordInterface|null $primaryModel = null;
39
    /** @psalm-var string[] */
40
    private array $link = [];
41
    /**
42
     * @var string|null the name of the relation that is the inverse of this relation.
43
     *
44
     * For example, an order has a customer, which means the inverse of the "customer" relation is the "orders", and the
45
     * inverse of the "orders" relation is the "customer". If this property is set, the primary record(s) will be
46
     * referenced through the specified relation.
47
     *
48
     * For example, `$customer->orders[0]->customer` and `$customer` will be the same object, and accessing the customer
49
     * of an order will not trigger new DB query.
50
     *
51
     * This property is only used in relational context.
52
     *
53
     * {@see inverseOf()}
54
     */
55
    private string|null $inverseOf = null;
56
    private array|ActiveQuery|null $via = null;
57
    private array $viaMap = [];
58
59
    /**
60
     * Clones internal objects.
61
     */
62
    public function __clone()
63
    {
64
        /** make a clone of "via" object so that the same query object can be reused multiple times */
65
        if (is_object($this->via)) {
66
            $this->via = clone $this->via;
67
        } elseif (is_array($this->via)) {
68
            $this->via = [$this->via[0], clone $this->via[1], $this->via[2]];
69
        }
70
    }
71
72
    /**
73
     * Specifies the relation associated with the junction table.
74
     *
75
     * Use this method to specify a pivot record/table when declaring a relation in the {@see ActiveRecord} class:
76
     *
77
     * ```php
78
     * class Order extends ActiveRecord
79
     * {
80
     *    public function getOrderItems() {
81
     *        return $this->hasMany(OrderItem::class, ['order_id' => 'id']);
82
     *    }
83
     *
84
     *    public function getItems() {
85
     *        return $this->hasMany(Item::class, ['id' => 'item_id'])->via('orderItems');
86
     *    }
87
     * }
88
     * ```
89
     *
90
     * @param string $relationName the relation name. This refers to a relation declared in {@see primaryModel}.
91
     * @param callable|null $callable a PHP callback for customizing the relation associated with the junction table.
92
     * Its signature should be `function($query)`, where `$query` is the query to be customized.
93
     *
94
     * @return static the relation object itself.
95
     */
96
    public function via(string $relationName, callable $callable = null): static
97
    {
98
        $relation = $this->primaryModel?->relationQuery($relationName);
99
        $callableUsed = $callable !== null;
100
        $this->via = [$relationName, $relation, $callableUsed];
101
102 107
        if ($callableUsed) {
103
            $callable($relation);
104 107
        }
105 107
106 107
        return $this;
107
    }
108 107
109 73
    /**
110
     * Sets the name of the relation that is the inverse of this relation.
111
     *
112 107
     * For example, a customer has orders, which means the inverse of the "orders" relation is the "customer".
113
     *
114
     * If this property is set, the primary record(s) will be referenced through the specified relation.
115
     *
116
     * For example, `$customer->orders[0]->customer` and `$customer` will be the same object, and accessing the customer
117
     * of an order will not trigger a new DB query.
118
     *
119
     * Use this method when declaring a relation in the {@see ActiveRecord} class, e.g. in Customer model:
120
     *
121
     * ```php
122
     * public function getOrders()
123
     * {
124
     *     return $this->hasMany(Order::class, ['customer_id' => 'id'])->inverseOf('customer');
125
     * }
126
     * ```
127
     *
128
     * This also may be used for Order model, but with caution:
129
     *
130
     * ```php
131
     * public function getCustomer()
132
     * {
133
     *     return $this->hasOne(Customer::class, ['id' => 'customer_id'])->inverseOf('orders');
134
     * }
135
     * ```
136
     *
137
     * in this case result will depend on how order(s) was loaded.
138
     * Let's suppose customer has several orders. If only one order was loaded:
139
     *
140
     * ```php
141
     * $orderQuery = new ActiveQuery(Order::class, $db);
142
     * $orders = $orderQuery->where(['id' => 1])->all();
143
     * $customerOrders = $orders[0]->customer->orders;
144
     * ```
145
     *
146
     * variable `$customerOrders` will contain only one order. If orders was loaded like this:
147
     *
148
     * ```php
149
     * $orderQuery = new ActiveQuery(Order::class, $db);
150
     * $orders = $orderQuery->with('customer')->where(['customer_id' => 1])->all();
151
     * $customerOrders = $orders[0]->customer->orders;
152
     * ```
153
     *
154
     * variable `$customerOrders` will contain all orders of the customer.
155
     *
156
     * @param string $relationName the name of the relation that is the inverse of this relation.
157
     *
158
     * @return static the relation object itself.
159
     */
160
    public function inverseOf(string $relationName): static
161
    {
162
        $this->inverseOf = $relationName;
163
164
        return $this;
165
    }
166 16
167
    /**
168 16
     * Returns query records depends on {@see $multiple} .
169
     *
170 16
     * This method is invoked when a relation of an ActiveRecord is being accessed in a lazy fashion.
171
     *
172
     * @throws Exception
173
     * @throws InvalidArgumentException
174
     * @throws InvalidConfigException
175
     * @throws ReflectionException
176
     * @throws Throwable if the relation is invalid.
177
     *
178
     * @return ActiveRecordInterface|array|null the related record(s).
179
     */
180
    public function relatedRecords(): ActiveRecordInterface|array|null
181
    {
182
        return $this->multiple ? $this->all() : $this->onePopulate();
183
    }
184
185
    /**
186 101
     * If applicable, populate the query's primary model into the related records' inverse relationship.
187
     *
188 101
     * @param array $result the array of related records as generated by {@see populate()}
189 101
     *
190 101
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
191 101
     */
192
    private function addInverseRelations(array &$result): void
193
    {
194
        if ($this->inverseOf === null) {
195
            return;
196
        }
197
198
        $relatedModel = reset($result);
199 101
200
        if ($relatedModel instanceof ActiveRecordInterface) {
201
            $inverseRelation = $relatedModel->relationQuery($this->inverseOf);
202
            $primaryModel = $inverseRelation->getMultiple() ? [$this->primaryModel] : $this->primaryModel;
203
204
            foreach ($result as $relatedModel) {
205
                $relatedModel->populateRelation($this->inverseOf, $primaryModel);
206
            }
207 16
        } else {
208
            $inverseRelation = $this->getARInstance()->relationQuery($this->inverseOf);
209 16
            $primaryModel = $inverseRelation->getMultiple() ? [$this->primaryModel] : $this->primaryModel;
210
211
            foreach ($result as &$relatedModel) {
212
                $relatedModel[$this->inverseOf] = $primaryModel;
213 16
            }
214 16
        }
215 16
    }
216 16
217
    /**
218 16
     * Finds the related records and populates them into the primary models.
219 16
     *
220 16
     * @param string $name the relation name
221
     * @param array $primaryModels primary models
222
     *
223 8
     * @throws InvalidArgumentException|InvalidConfigException|NotSupportedException|Throwable if {@see link()} is
224 8
     * invalid.
225
     * @throws Exception
226
     *
227 8
     * @return array the related models
228 8
     */
229
    public function populateRelation(string $name, array &$primaryModels): array
230
    {
231 16
        if ($this->via instanceof self) {
232
            $viaQuery = $this->via;
233
            $viaModels = $viaQuery->findJunctionRows($primaryModels);
234
            $this->filterByModels($viaModels);
235
        } elseif (is_array($this->via)) {
236
            [$viaName, $viaQuery] = $this->via;
237
238
            if ($viaQuery->asArray === null) {
239
                /** inherit asArray from primary query */
240
                $viaQuery->asArray($this->asArray);
241
            }
242
243
            $viaQuery->primaryModel = null;
244
            $viaModels = $viaQuery->populateRelation($viaName, $primaryModels);
245 139
            $this->filterByModels($viaModels);
246
        } else {
247 139
            $this->filterByModels($primaryModels);
248
        }
249
250
        if (!$this->multiple && count($primaryModels) === 1) {
251 139
            $model = $this->onePopulate();
252
            $primaryModel = reset($primaryModels);
253
254
            if ($primaryModel instanceof ActiveRecordInterface) {
255
                $primaryModel->populateRelation($name, $model);
256
            } else {
257 12
                $primaryModels[key($primaryModels)][$name] = $model;
258 12
            }
259 12
260 139
            if ($this->inverseOf !== null) {
261
                $this->populateInverseRelation($primaryModels, [$model], $name, $this->inverseOf);
262
            }
263
264
            return [$model];
265
        }
266 72
267
        /**
268 72
         * {@see https://github.com/yiisoft/yii2/issues/3197}
269
         *
270 72
         * delay indexing related models after buckets are built.
271
         */
272
        $indexBy = $this->getIndexBy();
273 72
        $this->indexBy(null);
274 72
        $models = $this->all();
275 72
276
        if (isset($viaModels, $viaQuery)) {
277 139
            $buckets = $this->buildBuckets($models, $viaModels, $viaQuery);
278
        } else {
279
            $buckets = $this->buildBuckets($models);
280 139
        }
281 28
282 28
        $this->indexBy($indexBy);
283
284 28
        if ($indexBy !== null && $this->multiple) {
285 28
            $buckets = $this->indexBuckets($buckets, $indexBy);
286
        }
287 4
288
        if (isset($viaQuery)) {
289
            $deepViaQuery = $viaQuery;
290 28
291 8
            while ($deepViaQuery->via) {
292
                $deepViaQuery = is_array($deepViaQuery->via) ? $deepViaQuery->via[1] : $deepViaQuery->via;
293
            }
294 28
295
            $link = $deepViaQuery->link;
296
        } else {
297
            $link = $this->link;
298
        }
299
300
        foreach ($primaryModels as $i => $primaryModel) {
301
            $keys = null;
302 123
303 123
            if ($this->multiple && count($link) === 1) {
304 123
                $primaryModelKey = reset($link);
305
306 123
                if ($primaryModel instanceof ActiveRecordInterface) {
307 72
                    $keys = $primaryModel->getAttribute($primaryModelKey);
308
                } else {
309 123
                    $keys = $primaryModel[$primaryModelKey] ?? null;
310
                }
311
            }
312 123
313
            if (is_array($keys)) {
314 123
                $value = [];
315 17
316
                foreach ($keys as $key) {
317
                    $key = (string) $key;
318 123
319 123
                    if (isset($buckets[$key])) {
320 72
                        $value[] = $buckets[$key];
321
                    }
322 72
                }
323 5
324
                if ($indexBy !== null) {
325
                    /** if indexBy is set, array_merge will cause renumbering of numeric array */
326 72
                    $value = array_replace(...$value);
327
                } else {
328
                    $value = array_merge(...$value);
329 123
                }
330 123
            } else {
331
                $key = $this->getModelKey($primaryModel, $link);
332
                $value = $buckets[$key] ?? ($this->multiple ? [] : null);
333
            }
334
335
            if ($primaryModel instanceof ActiveRecordInterface) {
336
                $primaryModel->populateRelation($name, $value);
337
            } else {
338
                $primaryModels[$i][$name] = $value;
339
            }
340
        }
341
342
        if ($this->inverseOf !== null) {
343
            $this->populateInverseRelation($primaryModels, $models, $name, $this->inverseOf);
344
        }
345
346 123
        return $models;
347 123
    }
348
349
    /**
350 123
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
351 123
     */
352
    private function populateInverseRelation(
353 9
        array &$primaryModels,
354
        array $models,
355
        string $primaryName,
356 123
        string $name
357 8
    ): void {
358
        if (empty($models) || empty($primaryModels)) {
359
            return;
360 123
        }
361
362
        $model = reset($models);
363 12
364
        if ($model instanceof ActiveRecordInterface) {
365
            $this->populateInverseRelationToModels($models, $primaryModels, $name);
366
            return;
367
        }
368
369 12
        $primaryModel = reset($primaryModels);
370
371
        if ($primaryModel instanceof ActiveRecordInterface) {
372 12
            if ($this->multiple) {
373
                foreach ($primaryModels as $primaryModel) {
374
                    $models = $primaryModel->relation($primaryName);
375 12
                    if (!empty($models)) {
376 12
                        $this->populateInverseRelationToModels($models, $primaryModels, $name);
377
                        $primaryModel->populateRelation($primaryName, $models);
378 4
                    }
379
                }
380
            } else {
381 12
                foreach ($primaryModels as $primaryModel) {
382 8
                    $model = $primaryModel->relation($primaryName);
383 8
                    if (!empty($model)) {
384 8
                        $models = [$model];
385 8
                        $this->populateInverseRelationToModels($models, $primaryModels, $name);
386 8
                        $primaryModel->populateRelation($primaryName, $models[0]);
387
                    }
388
                }
389 8
            }
390 4
        } else {
391
            if ($this->multiple) {
392
                foreach ($primaryModels as &$primaryModel) {
393
                    if (!empty($primaryModel[$primaryName])) {
394
                        $this->populateInverseRelationToModels($primaryModel[$primaryName], $primaryModels, $name);
395 4
                    }
396 4
                }
397 4
            } else {
398
                foreach ($primaryModels as &$primaryModel) {
399
                    if (!empty($primaryModel[$primaryName])) {
400
                        $models = [$primaryModel[$primaryName]];
401 8
                        $this->populateInverseRelationToModels($models, $primaryModels, $name);
402 8
                        $primaryModel[$primaryName] = $models[0];
403 8
                    }
404 8
                }
405 8
            }
406
        }
407 4
    }
408
409
    private function populateInverseRelationToModels(array &$models, array $primaryModels, string $name): void
410
    {
411
        $model = reset($models);
412
        $isArray = is_array($model);
413
414
        /** @var ActiveQuery $relation */
415
        $relation = $isArray ? $this->getARInstance()->relationQuery($name) : $model->relationQuery($name);
416
        $buckets = $relation->buildBuckets($primaryModels);
417
        $link = $relation->getLink();
418
        $default = $relation->getMultiple() ? [] : null;
419
420 12
        if ($isArray) {
421
            /** @var array $model */
422 127
            foreach ($models as &$model) {
423
                $key = $this->getModelKey($model, $link);
424
                $model[$name] = $buckets[$key] ?? $default;
425
            }
426
        } else {
427
            /** @var ActiveRecordInterface $model */
428
            foreach ($models as $model) {
429 127
                $key = $this->getModelKey($model, $link);
430 72
                $model->populateRelation($name, $buckets[$key] ?? $default);
431 72
            }
432 72
        }
433 72
    }
434
435 72
    private function buildBuckets(
436 71
        array $models,
437 71
        array $viaModels = null,
438 71
        self $viaQuery = null
439
    ): array {
440
        if ($viaModels !== null) {
441 72
            $map = [];
442
            $linkValues = $this->link;
443 72
            $viaLink = $viaQuery->link ?? [];
444 72
            $viaLinkKeys = array_keys($viaLink);
445 5
            $viaVia = null;
446 5
447
            foreach ($viaModels as $viaModel) {
448 5
                $key1 = $this->getModelKey($viaModel, $viaLinkKeys);
449
                $key2 = $this->getModelKey($viaModel, $linkValues);
450
                $map[$key2][$key1] = true;
451
            }
452 127
453 127
            if ($viaQuery !== null) {
454
                $viaQuery->viaMap = $map;
455 127
                $viaVia = $viaQuery->getVia();
456 72
            }
457 71
458 71
            while ($viaVia) {
459 71
                /**
460 71
                 * @var ActiveQuery $viaViaQuery
461
                 *
462
                 * @psalm-suppress RedundantCondition
463
                 */
464
                $viaViaQuery = is_array($viaVia) ? $viaVia[1] : $viaVia;
465 127
                $map = $this->mapVia($map, $viaViaQuery->viaMap);
466 127
467 127
                $viaVia = $viaViaQuery->getVia();
468
            }
469
        }
470
471 127
        $buckets = [];
472 56
        $linkKeys = array_keys($this->link);
473 56
474
        if (isset($map)) {
475
            foreach ($models as $model) {
476
                $key = $this->getModelKey($model, $linkKeys);
477 127
                if (isset($map[$key])) {
478
                    foreach (array_keys($map[$key]) as $key2) {
479
                        /** @psalm-suppress InvalidArrayOffset */
480 5
                        $buckets[$key2][] = $model;
481
                    }
482 5
                }
483
            }
484 5
        } else {
485 5
            foreach ($models as $model) {
486 5
                $key = $this->getModelKey($model, $linkKeys);
487
                $buckets[$key][] = $model;
488
            }
489
        }
490 5
491
        if (!$this->multiple) {
492
            return array_combine(
493
                array_keys($buckets),
494
                array_column($buckets, 0)
495
            );
496
        }
497
498
        return $buckets;
499
    }
500
501
    private function mapVia(array $map, array $viaMap): array
502 17
    {
503
        $resultMap = [];
504 17
505
        foreach ($map as $key => $linkKeys) {
506 17
            $resultMap[$key] = [];
507 17
            foreach (array_keys($linkKeys) as $linkKey) {
508 17
                /** @psalm-suppress InvalidArrayOffset */
509 17
                $resultMap[$key] += $viaMap[$linkKey];
510 17
            }
511
        }
512
513
        return $resultMap;
514 17
    }
515
516
    /**
517
     * Indexes buckets by a column name.
518
     *
519
     * @param Closure|string $indexBy the name of the column by which the query results should be indexed by. This can
520
     * also be a {@see Closure} that returns the index value based on the given models data.
521
     */
522 236
    private function indexBuckets(array $buckets, Closure|string $indexBy): array
523
    {
524 236
        foreach ($buckets as &$models) {
525 36
            $models = ArArrayHelper::index($models, $indexBy);
526 12
        }
527
528 32
        return $buckets;
529 32
    }
530 32
531
    /**
532 32
     * @param array $attributes the attributes to prefix.
533
     *
534
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
535
     */
536 36
    private function prefixKeyColumns(array $attributes): array
537 36
    {
538 36
        if (!empty($this->join) || !empty($this->joinWith)) {
539
            if (empty($this->from)) {
540
                $alias = $this->getARInstance()->getTableName();
541
            } else {
542
                foreach ($this->from as $alias => $table) {
543 236
                    if (!is_string($alias)) {
544
                        $alias = $table;
545
                    }
546 236
                    break;
547
                }
548 236
            }
549
550 236
            if (isset($alias)) {
551
                foreach ($attributes as $i => $attribute) {
552 236
                    $attributes[$i] = "$alias.$attribute";
553 236
                }
554
            }
555 232
        }
556 232
557 232
        return $attributes;
558 228
    }
559
560 228
    /**
561
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
562
     */
563 228
    protected function filterByModels(array $models): void
564
    {
565
        $attributes = array_keys($this->link);
566
        $attributes = $this->prefixKeyColumns($attributes);
567
568 232
        $model = reset($models);
569 232
        $values = [];
570
571
        if (count($attributes) === 1) {
572
            /** single key */
573
            $attribute = reset($this->link);
574
575 8
            if ($model instanceof ActiveRecordInterface) {
576
                foreach ($models as $model) {
577 8
                    $value = $model->getAttribute($attribute);
578 8
579
                    if ($value !== null) {
580 8
                        if (is_array($value)) {
581 8
                            $values = [...$values, ...$value];
582
                        } else {
583
                            $values[] = $value;
584 8
                        }
585
                    }
586 8
                }
587
            } else {
588
                foreach ($models as $model) {
589
                    if (isset($model[$attribute])) {
590
                        $value = $model[$attribute];
591
592 236
                        if (is_array($value)) {
593 232
                            $values = [...$values, ...$value];
594 232
                        } else {
595 232
                            $values[] = $value;
596 232
                        }
597 228
                    }
598
                }
599 8
            }
600
601
            if (!empty($values)) {
602
                $scalarValues = array_filter($values, 'is_scalar');
603 232
                $nonScalarValues = array_diff_key($values, $scalarValues);
604 232
605
                $scalarValues = array_unique($scalarValues);
606
                $values = [...$scalarValues, ...$nonScalarValues];
607 236
            }
608 236
        } else {
609
            $nulls = array_fill_keys($this->link, null);
610
611
            if ($model instanceof ActiveRecordInterface) {
612
                foreach ($models as $model) {
613
                    $value = $model->getAttributes($this->link);
614
615
                    if (!empty($value)) {
616 127
                        $values[] = array_combine($attributes, array_merge($nulls, $value));
617
                    }
618 127
                }
619
            } else {
620 127
                foreach ($models as $model) {
621 127
                    $value = array_intersect_key($model, $nulls);
622
623
                    if (!empty($value)) {
624 127
                        $values[] = array_combine($attributes, array_merge($nulls, $value));
625
                    }
626
                }
627
            }
628 127
        }
629
630 127
        if (empty($values)) {
631
            $this->emulateExecution();
632
            $this->andWhere('1=0');
633
            return;
634
        }
635
636
        $this->andWhere(['in', $attributes, $values]);
637
    }
638 127
639
    private function getModelKey(ActiveRecordInterface|array $activeRecord, array $attributes): string
640 127
    {
641
        $key = [];
642
643
        if (is_array($activeRecord)) {
644
            foreach ($attributes as $attribute) {
645
                if (isset($activeRecord[$attribute])) {
646
                    $key[] = (string) $activeRecord[$attribute];
647
                }
648 127
            }
649
        } else {
650
            foreach ($attributes as $attribute) {
651
                $value = $activeRecord->getAttribute($attribute);
652
653
                if ($value !== null) {
654
                    $key[] = (string) $value;
655
                }
656 28
            }
657
        }
658 28
659
        return match (count($key)) {
660
            0 => '',
661
            1 => $key[0],
662 28
            default => serialize($key),
663
        };
664
    }
665 28
666
    /**
667 28
     * @param array $primaryModels either array of AR instances or arrays.
668
     *
669
     * @throws Exception
670
     * @throws Throwable
671
     * @throws \Yiisoft\Definitions\Exception\InvalidConfigException
672 28
     */
673
    private function findJunctionRows(array $primaryModels): array
674
    {
675
        if (empty($primaryModels)) {
676
            return [];
677
        }
678
679
        $this->filterByModels($primaryModels);
680
681
        /* @var $primaryModel ActiveRecord */
682 39
        $primaryModel = reset($primaryModels);
683
684 39
        if (!$primaryModel instanceof ActiveRecordInterface) {
685
            /** when primaryModels are array of arrays (asArray case) */
686
            $primaryModel = $this->arClass;
0 ignored issues
show
The assignment to $primaryModel is dead and can be removed.
Loading history...
687
        }
688
689
        return $this->asArray()->all();
690
    }
691
692 63
    public function getMultiple(): bool
693
    {
694 63
        return $this->multiple;
695
    }
696
697
    /**
698
     * @return ActiveRecordInterface|null the primary model of a relational query.
699
     *
700
     * This is used only in lazy loading with dynamic query options.
701
     */
702
    public function getPrimaryModel(): ActiveRecordInterface|null
703
    {
704
        return $this->primaryModel;
705
    }
706 113
707
    /**
708 113
     * @psalm-return string[]
709
     */
710
    public function getLink(): array
711
    {
712
        return $this->link;
713
    }
714
715
    public function getVia(): array|ActiveQueryInterface|null
716
    {
717
        return $this->via;
718
    }
719 117
720
    public function multiple(bool $value): self
721 117
    {
722
        $this->multiple = $value;
723
724 252
        return $this;
725
    }
726 252
727
    public function primaryModel(ActiveRecordInterface $value): self
728 252
    {
729
        $this->primaryModel = $value;
730
731 252
        return $this;
732
    }
733 252
734
    public function link(array $value): self
735 252
    {
736
        $this->link = $value;
737
738 252
        return $this;
739
    }
740
}
741