Lines 100.00% 18 / 18
Functions and Methods 100.00% 4 / 4
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
MorphOne 100.00% 18 / 18 100.00% 4 / 4 12 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% 8 / 8 100.00% 1 / 1 4
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 polymorphic: MorphOneOrMany's first row, stably ordered.
14 *
15 * `Post::image()` → `Image::newQuery()->where(imageable_id, $post->id)
16 * ->where(imageable_type, Post::class)`. Multiple matching rows are a data
17 * concern — the first row wins, ordered by the related PK for determinism
18 * (the same contract {@see \BlueprintAU\Radiant\Relations\HasOne} applies).
19 *
20 * @template TRelated of Model
21 * @extends MorphOneOrMany<TRelated>
22 */
23final class MorphOne extends MorphOneOrMany
24{
25    /**
26     * Order the lazy query stably by the related PK — first-wins must be
27     * deterministic.
28     *
29     * @return void
30     */
31    #[\Override]
32    protected function addConstraints(): void
33    {
34        $primaryKeys = MetadataFactory::for($this->getRelated())->primaryKeys;
35
36        if (count($primaryKeys) === 1 && $primaryKeys[0]->name !== null) {
37            $this->query = $this->query->orderBy($primaryKeys[0]->name);
38        }
39
40        parent::addConstraints();
41    }
42
43    /**
44     * Run the constrained query and keep only the first match.
45     *
46     * @return Collection<int, TRelated>
47     */
48    #[\Override]
49    protected function executeResults(): Collection
50    {
51        $first = $this->query->first();
52
53        return Collection::make($first === null ? [] : [$first]);
54    }
55
56    /**
57     * Order the eager-load query stably, mirroring the lazy path.
58     *
59     * @param  ModelQueryBuilder<TRelated>  $query
60     * @return ModelQueryBuilder<TRelated>
61     */
62    #[\Override]
63    protected function applyEagerOrdering(ModelQueryBuilder $query): ModelQueryBuilder
64    {
65        $primaryKeys = MetadataFactory::for($this->getRelated())->primaryKeys;
66
67        if (count($primaryKeys) === 1 && $primaryKeys[0]->name !== null) {
68            $query = $query->orderBy($primaryKeys[0]->name);
69        }
70
71        return parent::applyEagerOrdering($query);
72    }
73
74    /**
75     * Distribute eager results onto parents — first match per FK value.
76     *
77     * @param  list<Model>  $parents
78     * @param  Collection<int, TRelated>  $results
79     * @param  string  $name
80     * @param  list<int|string|null|list<int|string|null>>|null  $eagerParentKeys  Unused.
81     * @return void
82     */
83    #[\Override]
84    public function match(array $parents, Collection $results, string $name, ?array $eagerParentKeys = null): void
85    {
86        $first = [];
87
88        foreach ($results as $related) {
89            $key = self::serializeKey($related->attribute($this->getForeignKey()));
90
91            if (!isset($first[$key])) {
92                $first[$key] = $related;
93            }
94        }
95
96        foreach ($parents as $parent) {
97            $key = self::serializeKey($parent->attribute($this->getLocalKey()));
98            $parent->setRelation($name, $first[$key] ?? null);
99        }
100    }
101}