Lines 100.00% 35 / 35
Functions and Methods 100.00% 4 / 4
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
HasOne 100.00% 35 / 35 100.00% 4 / 4 16 100.00% 1 / 1
 addConstraints 100.00% 16 / 16 100.00% 1 / 1 5
 executeResults 100.00% 2 / 2 100.00% 1 / 1 2
 applyEagerOrdering 100.00% 4 / 4 100.00% 1 / 1 3
 match 100.00% 13 / 13 100.00% 1 / 1 6
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\Metadata\MetadataFactory;
11use BlueprintAU\Radiant\Model;
12use BlueprintAU\Radiant\ModelQueryBuilder;
13
14/**
15 * One-to-one: HasMany's first row, stably ordered by the related PK.
16 *
17 * Multiple matching rows are a data concern, not a relation error — the
18 * first row wins (ordered by the related model's PK for determinism) and
19 * uniqueness is the schema's job (`#[Column(unique: true)]` on the FK).
20 *
21 * @template TRelated of Model
22 * @extends HasMany<TRelated>
23 */
24final class HasOne extends HasMany
25{
26    /**
27     * Constrain the related query to the parent's key, stably ordered.
28     *
29     * @return void
30     */
31    protected function addConstraints(): void
32    {
33        $primaryKeys = MetadataFactory::for($this->related)->primaryKeys;
34
35        if (count($primaryKeys) === 1 && $primaryKeys[0]->name !== null) {
36            $this->query = $this->query->orderBy($primaryKeys[0]->name);
37        }
38
39        if ($this->isComposite()) {
40            // The tuple lands inside a whereNested GROUP — the relation
41            // constraint is ONE unit: the parts AND within the parens, and
42            // a caller's later `->orWhere(...)` ORs at the constraint's
43            // edges instead of against the tuple's PARTS.
44            $this->query = $this->query->whereNested(fn (WhereBuilder $nested): WhereBuilder => self::applyKeyTuple(
45                $nested,
46                $this->getForeignKeys(),
47                $this->getLocalKeys(),
48                $this->parentKeyValues($this->getLocalKeys()),
49            ));
50
51            return;
52        }
53
54        $parentKey = $this->parent->attribute($this->getLocalKey());
55
56        if ($parentKey === null) {
57            // Null parent key → no results, without compiling a meaningless
58            // query (BelongsTo's convention; `fk = NULL` matches no rows).
59            $this->query = $this->query->whereRaw('1 = 0', []);
60            return;
61        }
62
63        $this->query = $this->query->where($this->getForeignKey(), WhereOperator::Eq, $parentKey);
64    }
65
66    /**
67     * Run the constrained query and keep only the first match.
68     *
69     * @return Collection<int, TRelated>
70     */
71    #[\Override]
72    protected function executeResults(): Collection
73    {
74        $first = $this->query->first();
75
76        return Collection::make($first === null ? [] : [$first]);
77    }
78
79    /**
80     * Order the eager-load query stably, mirroring the lazy path.
81     *
82     * @param  ModelQueryBuilder<TRelated>  $query
83     * @return ModelQueryBuilder<TRelated>
84     */
85    protected function applyEagerOrdering(ModelQueryBuilder $query): ModelQueryBuilder
86    {
87        $primaryKeys = MetadataFactory::for($this->related)->primaryKeys;
88
89        if (count($primaryKeys) === 1 && $primaryKeys[0]->name !== null) {
90            $query = $query->orderBy($primaryKeys[0]->name);
91        }
92
93        return $query;
94    }
95
96    /**
97     * Distribute eager results onto parents — first match per FK value.
98     *
99     * @param  list<Model>  $parents
100     * @param  Collection<int, TRelated>  $results
101     * @param  string  $name
102     * @param  list<int|string|null|list<int|string|null>>|null  $eagerParentKeys  Unused.
103     * @return void
104     */
105    public function match(array $parents, Collection $results, string $name, ?array $eagerParentKeys = null): void
106    {
107        $first = [];
108
109        foreach ($results as $related) {
110            $key = $this->isComposite()
111                ? self::serializeKey(self::tupleValues($related, $this->getForeignKeys()))
112                : $related->attribute($this->getForeignKey());
113
114            if (!isset($first[self::serializeKey($key)])) {
115                $first[self::serializeKey($key)] = $related;
116            }
117        }
118
119        foreach ($parents as $parent) {
120            $key = $this->isComposite()
121                ? self::tupleValues($parent, $this->getLocalKeys())
122                : $parent->attribute($this->getLocalKey());
123            $related = $first[self::serializeKey($key)] ?? null;
124            $parent->setRelation($name, $related);
125        }
126    }
127}