Lines 92.53% 62 / 67
Functions and Methods 71.42% 5 / 7
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
BelongsTo 92.53% 62 / 67 71.42% 5 / 7 21.18 0.00% 0 / 1
 addConstraints 100.00% 15 / 15 100.00% 1 / 1 4
 hasResolvableKey 100.00% 3 / 3 100.00% 1 / 1 2
 readQuery 66.66% 2 / 3 0.00% 0 / 1 2.15
 executeResults 100.00% 4 / 4 100.00% 1 / 1 3
 eagerLoadChunk 86.20% 25 / 29 0.00% 0 / 1 4.04
 eagerKeyColumn 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;
11use BlueprintAU\Radiant\ModelQueryBuilder;
12
13/**
14 * The inverse one-to-one/one-to-many: the PARENT table holds the FK.
15 *
16 * `Post::author()` → `User::newQuery()->where('id', '=', $post->user_id)`.
17 * A null FK (a legitimately optional relation) resolves to no results —
18 * standard SQL semantics, not an error.
19 *
20 * @template TRelated of Model
21 * @extends Relation<TRelated>
22 * @phpstan-import-type KeyValue from \BlueprintAU\Radiant\Model
23 */
24final class BelongsTo extends Relation
25{
26    /**
27     * Constrain the related query to the parent's FK value.
28     *
29     * @return void
30     */
31    protected function addConstraints(): void
32    {
33        if ($this->isComposite()) {
34            $values = $this->parentKeyValues($this->getForeignKeys());
35
36            if (!in_array(null, $values, true)) {
37                // The tuple lands inside a whereNested GROUP — the relation
38                // constraint is ONE unit. Flat, a caller's later
39                // `->orWhere(...)` would OR against the tuple's PARTS
40                // ((fk1 = ? AND fk2 = ?) OR x — matching the wrong rows);
41                // grouped, the parts AND within the parens and the caller's
42                // OR stays at the constraint's edges.
43                //
44                // local side = the RELATED table's owner columns, foreign
45                // side = THIS table's FK columns.
46                $this->query = $this->query->whereNested(fn (WhereBuilder $nested): WhereBuilder => self::applyKeyTuple(
47                    $nested,
48                    $this->getLocalKeys(),
49                    $this->getForeignKeys(),
50                    $values,
51                ));
52            } else {
53                // Null FK component → no results, without compiling a
54                // meaningless query.
55                $this->query = $this->query->whereRaw('1 = 0', []);
56            }
57
58            return;
59        }
60
61        $fkValue = $this->parent->attribute($this->getForeignKey());
62
63        if ($fkValue !== null) {
64            $this->query = $this->query->where($this->getLocalKey(), WhereOperator::Eq, $fkValue);
65        } else {
66            // Null FK → no results, without compiling a meaningless query.
67            $this->query = $this->query->whereRaw('1 = 0', []);
68        }
69    }
70
71    /**
72     * Whether the parent's FK currently resolves — every component
73     * non-null.
74     *
75     * @return bool
76     */
77    private function hasResolvableKey(): bool
78    {
79        if ($this->isComposite()) {
80            return !in_array(null, $this->parentKeyValues($this->getForeignKeys()), true);
81        }
82
83        return $this->parent->attribute($this->getForeignKey()) !== null;
84    }
85
86    /**
87     * The constrained query — a no-match query when the FK is null.
88     *
89     * @return ModelQueryBuilder<TRelated>
90     */
91    #[\Override]
92    protected function readQuery(): ModelQueryBuilder
93    {
94        if (!$this->hasResolvableKey()) {
95            return $this->getQuery()->whereRaw('1 = 0', []);
96        }
97
98        return $this->getQuery();
99    }
100
101    /**
102     * Run the constrained query — a single model or none.
103     *
104     * @return Collection<int, TRelated>
105     */
106    #[\Override]
107    protected function executeResults(): Collection
108    {
109        if (!$this->hasResolvableKey()) {
110            return Collection::make([]);
111        }
112
113        $first = $this->query->first();
114
115        return Collection::make($first === null ? [] : [$first]);
116    }
117
118    /**
119     * Run the eager query — the FK lives on the PARENT, so the IN clause
120     * targets the related table's owner key ({@see BelongsTo::$localKey}).
121     * A composite key widens to an OR of AND-groups.
122     *
123     * @param  list<KeyValue>  $parentKeys
124     * @return EagerResult<TRelated>
125     */
126    #[\Override]
127    protected function eagerLoadChunk(array $parentKeys): EagerResult
128    {
129        if (!$this->isComposite()) {
130            return EagerResult::fromCollection(
131                $this->related::newQuery()
132                    ->whereIn($this->getLocalKey(), $parentKeys)
133                    ->get(),
134            );
135        }
136
137        $localKeys = $this->getLocalKeys();
138        $foreignKeys = $this->getForeignKeys();
139        $query = $this->related::newQuery();
140
141        // The OR-of-groups lands INSIDE one outer AND-group: the key set
142        // is ONE constraint unit. The related builder auto-applies trait
143        // scopes (e.g. soft-delete `deleted_at IS NULL`) as leading
144        // AND-groups — flat top-level ORs would compile to
145        // `(scope) OR (fk = ? AND ...) OR ...` and let a scope-excluded
146        // row back in whenever its key matched. Grouped, the scope ANDs
147        // against the whole set.
148        return EagerResult::fromCollection($query->whereNested(
149            function (WhereBuilder $nested) use ($localKeys, $foreignKeys, $parentKeys): WhereBuilder {
150                $grouped = $nested;
151
152                foreach ($parentKeys as $parentKey) {
153                    if (!is_array($parentKey)) {
154                        throw new \InvalidArgumentException(
155                            'A composite relation key requires column => value key maps for eager loading; '
156                            . 'got ' . get_debug_type($parentKey) . '.'
157                        );
158                    }
159
160                    $grouped = $grouped->orWhereNested(
161                        fn (WhereBuilder $keyGroup): WhereBuilder => self::applyKeyTuple(
162                            $keyGroup,
163                            $localKeys,
164                            $foreignKeys,
165                            $parentKey,
166                        )
167                    );
168                }
169
170                return $grouped;
171            }
172        )->get());
173    }
174
175    /**
176     * The parent column(s) the eager loader collects key values from.
177     *
178     * @return string|list<string>
179     */
180    public function eagerKeyColumn(): string|array
181    {
182        return $this->foreignKey;
183    }
184
185    /**
186     * Distribute eager results onto parents by FK value.
187     *
188     * @param  list<Model>  $parents
189     * @param  Collection<int, TRelated>  $results
190     * @param  string  $name
191     * @param  list<int|string|null|list<int|string|null>>|null  $eagerParentKeys  Unused.
192     * @return void
193     */
194    public function match(array $parents, Collection $results, string $name, ?array $eagerParentKeys = null): void
195    {
196        $byKey = [];
197
198        foreach ($results as $related) {
199            $key = $this->isComposite()
200                ? self::tupleValues($related, $this->getLocalKeys())
201                : $related->attribute($this->getLocalKey());
202            $byKey[self::serializeKey($key)] = $related;
203        }
204
205        foreach ($parents as $parent) {
206            $key = $this->isComposite()
207                ? self::tupleValues($parent, $this->getForeignKeys())
208                : $parent->attribute($this->getForeignKey());
209            $related = $byKey[self::serializeKey($key)] ?? null;
210            $parent->setRelation($name, $related);
211        }
212    }
213}