Lines 100.00% 26 / 26
Functions and Methods 100.00% 3 / 3
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
HasMany 100.00% 26 / 26 100.00% 3 / 3 9 100.00% 1 / 1
 addConstraints 100.00% 13 / 13 100.00% 1 / 1 3
 executeResults 100.00% 1 / 1 100.00% 1 / 1 1
 match 100.00% 12 / 12 100.00% 1 / 1 5
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Relations;
6
7use BlueprintAU\Radiant\Collection;
8use BlueprintAU\Radiant\Database\Query\Enums\WhereOperator;
9use BlueprintAU\Radiant\Database\Query\WhereBuilder;
10use BlueprintAU\Radiant\Model;
11
12/**
13 * One-to-many: the parent's key is referenced by the related table's FK.
14 *
15 * `User::posts()` → `Post::newQuery()->where('user_id', '=', $user->id)`.
16 * The FK lives on the RELATED table; the local key lives on the parent.
17 *
18 * @template TRelated of Model
19 * @extends Relation<TRelated>
20 */
21class HasMany extends Relation
22{
23    /**
24     * Constrain the related query to the parent's key.
25     *
26     * @return void
27     */
28    protected function addConstraints(): void
29    {
30        if ($this->isComposite()) {
31            // The tuple lands inside a whereNested GROUP — the relation
32            // constraint is ONE unit: the parts AND within the parens, and
33            // a caller's later `->orWhere(...)` ORs at the constraint's
34            // edges instead of against the tuple's PARTS.
35            $this->query = $this->query->whereNested(fn (WhereBuilder $nested): WhereBuilder => self::applyKeyTuple(
36                $nested,
37                $this->getForeignKeys(),
38                $this->getLocalKeys(),
39                $this->parentKeyValues($this->getLocalKeys()),
40            ));
41
42            return;
43        }
44
45        $parentKey = $this->parent->attribute($this->getLocalKey());
46
47        if ($parentKey === null) {
48            // Null parent key → no results, without compiling a meaningless
49            // query (BelongsTo's convention; `fk = NULL` matches no rows).
50            $this->query = $this->query->whereRaw('1 = 0', []);
51            return;
52        }
53
54        $this->query = $this->query->where($this->getForeignKey(), WhereOperator::Eq, $parentKey);
55    }
56
57    /**
58     * Run the constrained query.
59     *
60     * @return Collection<int, TRelated>
61     */
62    #[\Override]
63    protected function executeResults(): Collection
64    {
65        return $this->query->get();
66    }
67
68    /**
69     * Distribute eager results onto parents, keyed by the FK value.
70     *
71     * Parents with no matching children get an empty collection — the
72     * relation is loaded either way.
73     *
74     * @param  list<Model>  $parents
75     * @param  Collection<int, TRelated>  $results
76     * @param  string  $name
77     * @param  list<int|string|null|list<int|string|null>>|null  $eagerParentKeys  Unused.
78     * @return void
79     */
80    public function match(array $parents, Collection $results, string $name, ?array $eagerParentKeys = null): void
81    {
82        $grouped = [];
83
84        foreach ($results as $related) {
85            $key = $this->isComposite()
86                ? self::serializeKey(self::tupleValues($related, $this->getForeignKeys()))
87                : $related->attribute($this->getForeignKey());
88            $grouped[self::serializeKey($key)][] = $related;
89        }
90
91        foreach ($parents as $parent) {
92            $key = $this->isComposite()
93                ? self::tupleValues($parent, $this->getLocalKeys())
94                : $parent->attribute($this->getLocalKey());
95            // The bag's items came off $results (TRelated) — every one IS
96            // a Model; setRelation accepts Collection<int, Model> and the item
97            // template is not covariant.
98            /** @var Collection<int, Model> $bag */
99            $bag = Collection::make($grouped[self::serializeKey($key)] ?? []);
100            $parent->setRelation($name, $bag);
101        }
102    }
103}