Lines 100.00% 27 / 27
Functions and Methods 100.00% 4 / 4
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
HasOneThrough 100.00% 27 / 27 100.00% 4 / 4 16 100.00% 1 / 1
 addConstraints 100.00% 4 / 4 100.00% 1 / 1 3
 executeResults 100.00% 2 / 2 100.00% 1 / 1 2
 applyEagerOrdering 100.00% 4 / 4 100.00% 1 / 1 3
 match 100.00% 17 / 17 100.00% 1 / 1 8
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Relations;
6
7use BlueprintAU\Radiant\Collection;
8use BlueprintAU\Radiant\Metadata\MetadataFactory;
9use BlueprintAU\Radiant\Model;
10use BlueprintAU\Radiant\ModelQueryBuilder;
11
12/**
13 * One-to-one through an intermediate model.
14 *
15 * Same join shape as {@see HasManyThrough}; the results reduce to one row
16 * per parent (the first match per parent key from the eager query, stably
17 * ordered by the related PK — the uniqueness contract is the schema's job,
18 * mirroring {@see HasOne}).
19 *
20 * @template TRelated of Model
21 * @extends HasManyThrough<TRelated>
22 */
23final class HasOneThrough extends HasManyThrough
24{
25    /**
26     * Constrain the related query, stably ordered by the related PK — the
27     * first-row guarantee for the lazy path.
28     *
29     * @return void
30     */
31    protected function addConstraints(): void
32    {
33        parent::addConstraints();
34
35        $primaryKeys = MetadataFactory::for($this->related)->primaryKeys;
36
37        if (count($primaryKeys) === 1 && $primaryKeys[0]->name !== null) {
38            $this->query = $this->query->orderBy($primaryKeys[0]->name);
39        }
40    }
41
42    /**
43     * Run the constrained query and keep only the first match.
44     *
45     * @return Collection<int, TRelated>
46     */
47    #[\Override]
48    protected function executeResults(): Collection
49    {
50        $first = $this->query->first();
51
52        return Collection::make($first === null ? [] : [$first]);
53    }
54
55    /**
56     * Order the eager-load query stably, mirroring the lazy path.
57     *
58     * @param  ModelQueryBuilder<TRelated>  $query
59     * @return ModelQueryBuilder<TRelated>
60     */
61    protected function applyEagerOrdering(ModelQueryBuilder $query): ModelQueryBuilder
62    {
63        $primaryKeys = MetadataFactory::for($this->related)->primaryKeys;
64
65        if (count($primaryKeys) === 1 && $primaryKeys[0]->name !== null) {
66            $query = $query->orderBy($primaryKeys[0]->name);
67        }
68
69        return $query;
70    }
71
72    /**
73     * Distribute eager results — first match per parent key.
74     *
75     * @param  list<Model>  $parents
76     * @param  Collection<int, TRelated>  $results
77     * @param  string  $name
78     * @param  list<int|string|null|list<int|string|null>>|null  $eagerParentKeys
79     * @return void
80     */
81    public function match(array $parents, Collection $results, string $name, ?array $eagerParentKeys = null): void
82    {
83        if ($eagerParentKeys === null) {
84            throw new \LogicException(
85                static::class . '::match() requires the EagerResult parent keys; '
86                . 'call it with the array returned by eagerLoad(), not the models alone.'
87            );
88        }
89
90        $first = [];
91
92        foreach ($results as $i => $model) {
93            $parentKey = $eagerParentKeys[$i] ?? null;
94
95            if ($parentKey === null || isset($first[self::serializeKey($parentKey)])) {
96                continue;
97            }
98
99            $first[self::serializeKey($parentKey)] = $model;
100        }
101
102        $localKeys = $this->isComposite() ? $this->getLocalKeys() : [$this->getLocalKey()];
103
104        foreach ($parents as $parent) {
105            $key = $this->isComposite()
106                ? self::tupleValues($parent, $localKeys)
107                : $parent->attribute($localKeys[0]);
108            $parent->setRelation($name, $first[self::serializeKey($key)] ?? null);
109        }
110    }
111}