Lines 99.08% 108 / 109
Functions and Methods 92.85% 13 / 14
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
MorphTo 99.08% 108 / 109 92.85% 13 / 14 43 0.00% 0 / 1
 __construct 100.00% 4 / 4 100.00% 1 / 1 1
 defersConstraints 100.00% 1 / 1 100.00% 1 / 1 1
 relatedClasses 100.00% 1 / 1 100.00% 1 / 1 1
 getTypeColumn 100.00% 1 / 1 100.00% 1 / 1 1
 addConstraints 100.00% 4 / 4 100.00% 1 / 1 1
 aliasOf 100.00% 20 / 20 100.00% 1 / 1 8
 assertKeyMatches 100.00% 16 / 16 100.00% 1 / 1 4
 readQuery 100.00% 4 / 4 100.00% 1 / 1 2
 relatedClass 100.00% 1 / 1 100.00% 1 / 1 1
 queryFor 100.00% 4 / 4 100.00% 1 / 1 2
 eagerKeyColumn 100.00% 1 / 1 100.00% 1 / 1 1
 eagerLoad 96.42% 27 / 28 0.00% 0 / 1 10
 match 100.00% 19 / 19 100.00% 1 / 1 7
 executeResults 100.00% 5 / 5 100.00% 1 / 1 3
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\Metadata\MetadataFactory;
10use BlueprintAU\Radiant\Model;
11use BlueprintAU\Radiant\ModelQueryBuilder;
12
13/**
14 * The inverse polymorphic relation: the parent holds the (type, key) pair
15 * and the related class is resolved per row from the type column.
16 *
17 * The constrained builder is built lazily per resolved type; eager loading
18 * runs one chunked `IN` query per distinct type and merges the results
19 * into a single mixed-class {@see EagerResult} dispatched by
20 * {@see MorphTo::match()}. An optional `$types` allowlist restricts which
21 * classes may resolve — and narrows the template statically to exactly
22 * those classes; without one the honest bound is `Model`.
23 *
24 * @template TRelated of Model The classes the allowlist admits (Model
25 *         when no allowlist is declared).
26 * @extends Relation<TRelated>
27 * @phpstan-import-type KeyValue from \BlueprintAU\Radiant\Model
28 */
29final class MorphTo extends Relation
30{
31    /**
32     * The type-discriminator column on the parent's table.
33     *
34     * @var string
35     */
36    protected readonly string $typeColumn;
37
38    /**
39     * The optional morph-alias allowlist.
40     *
41     * @var list<class-string<Model>>|null
42     */
43    private array|null $types;
44
45    /**
46     * Create the inverse polymorphic relation.
47     *
48     * The base constructor's `$related` slot is filled with the abstract
49     * {@see Model::class} marker — the real class resolves per parent from
50     * the type column, and the base's constraint pass is skipped via
51     * {@see Relation::defersConstraints()}.
52     *
53     * @param  Model  $parent
54     * @param  string  $typeColumn
55     * @param  string  $foreignKey
56     * @param  string  $ownerKey
57     * @param  list<class-string<TRelated>>|null  $types
58     */
59    public function __construct(
60        Model $parent,
61        string $typeColumn,
62        string $foreignKey,
63        string $ownerKey,
64        array|null $types = null,
65    ) {
66        // The marker slot: no fixed related class exists — the real class
67        // resolves per parent from the type column, and the runtime allowlist
68        // validates every resolution (aliasOf). Statically, Model::class does
69        // not satisfy class-string<TRelated> for a narrowed allowlist, so the
70        // marker narrows through an inline var: the declaration is backed by
71        // the runtime check, exactly the conditional-return pattern the rest
72        // of the ORM uses for dynamic types.
73        /** @var class-string<TRelated> $marker */
74        $marker = Model::class;
75
76        parent::__construct($parent, $marker, $foreignKey, $ownerKey);
77
78        $this->typeColumn = $typeColumn;
79        $this->types = $types;
80    }
81
82    /**
83     * The base constructor's query build + constraint pass cannot run —
84     * the related class resolves per parent from the type column.
85     *
86     * @return bool
87     */
88    #[\Override]
89    protected function defersConstraints(): bool
90    {
91        return true;
92    }
93
94    /**
95     * The related classes a dotted path's DEEPER segments resolve against.
96     *
97     * @return list<class-string<Model>>
98     */
99    #[\Override]
100    public function relatedClasses(): array
101    {
102        return [];
103    }
104
105    /**
106     * The type-discriminator column on the parent's table.
107     *
108     * @return string
109     */
110    final public function getTypeColumn(): string
111    {
112        return $this->typeColumn;
113    }
114
115    /**
116     * Unused — the related class is dynamic; constraints build lazily.
117     *
118     * @return void
119     */
120    #[\Override]
121    protected function addConstraints(): void
122    {
123        throw new \LogicException(
124            'MorphTo builds its constraints lazily per resolved type; addConstraints() '
125            . 'must not be called directly.'
126        );
127    }
128
129    /**
130     * Resolve ONE parent's morph alias — the shared validation path.
131     *
132     * @param  Model  $parent
133     * @return class-string<Model>|null
134     * @throws \InvalidArgumentException
135     */
136    private function aliasOf(Model $parent): string|null
137    {
138        $alias = $parent->attribute($this->typeColumn);
139
140        if ($alias === null) {
141            return null;
142        }
143
144        if (!is_string($alias) || $alias === '') {
145            throw new \InvalidArgumentException(
146                'Morph type column [' . $this->typeColumn . '] on [' . $parent::class
147                . '] holds a non-string value; the morph alias must be a model class-string.'
148            );
149        }
150
151        if ($this->types !== null && !in_array($alias, $this->types, true)) {
152            throw new \InvalidArgumentException(
153                'Morph type [' . $alias . '] on [' . $parent::class
154                . '] is not in the relation\'s allowlist.'
155            );
156        }
157
158        if (!class_exists($alias) || !is_a($alias, Model::class, true)) {
159            throw new \InvalidArgumentException(
160                'Morph type [' . $alias . '] on [' . $parent::class
161                . '] does not resolve to an existing model class.'
162            );
163        }
164
165        $this->assertKeyMatches($alias);
166
167        return $alias;
168    }
169
170    /**
171     * Fail fast when the resolved target's primary-key type cannot be
172     * held by this parent's morph key column.
173     *
174     * @param  class-string<Model>  $alias
175     * @return void
176     * @throws \InvalidArgumentException
177     */
178    private function assertKeyMatches(string $alias): void
179    {
180        $declared = MetadataFactory::for($this->parent::class)
181            ->mappingFor($this->getForeignKey())->column->type;
182        $primaryKey = MetadataFactory::for($alias)->primaryKeys;
183
184        if (count($primaryKey) !== 1 || $primaryKey[0]->name === null) {
185            throw new \LogicException(
186                'A morph target requires a single named primary key; model [' . $alias
187                . '] declares none, a composite key, or an unnamed key.'
188            );
189        }
190
191        if ($primaryKey[0]->type !== $declared) {
192            throw new \InvalidArgumentException(
193                'The morph key column [' . $this->getForeignKey() . '] on [' . $this->parent::class
194                . '] is [' . $declared->value . '], but [' . $alias . ']\'s primary key is ['
195                . $primaryKey[0]->type->value . ']. A morph pair can only point at models whose '
196                . 'primary-key type matches the key column — declare the pair with a matching '
197                . 'keyType (or uuidMorphs()).'
198            );
199        }
200    }
201
202    /**
203     * The resolved type's constrained query — the row reads' target.
204     *
205     * @return ModelQueryBuilder<TRelated>
206     */
207    #[\Override]
208    protected function readQuery(): ModelQueryBuilder
209    {
210        $alias = $this->aliasOf($this->parent);
211
212        if ($alias === null) {
213            // Null morph pair → no results, without compiling a meaningless
214            // query (BelongsTo's convention). The no-match query builds on
215            // the PARENT's class — the Model::class marker cannot (its
216            // table() throws), and a `1 = 0` query never hydrates a row.
217            /** @var ModelQueryBuilder<TRelated> */
218            return ($this->parent)::newQuery()->whereRaw('1 = 0', []);
219        }
220
221        // aliasOf() validated the alias against the allowlist (or the
222        // bound IS Model without one) — the resolved builder's rows are
223        // all TRelated.
224        /** @var ModelQueryBuilder<TRelated> */
225        return $this->queryFor($alias);
226    }
227
228    /**
229     * The related model class — the fail-fast exceptions' identity.
230     *
231     * The resolved alias when the morph pair resolves; the parent's own
232     * class when it does not (a null pair has no related class — the
233     * no-match query builds on the parent for the same reason).
234     *
235     * @return class-string<Model>
236     */
237    #[\Override]
238    protected function relatedClass(): string
239    {
240        return $this->aliasOf($this->parent) ?? $this->parent::class;
241    }
242
243    /**
244     * Build the constrained query for ONE resolved type.
245     *
246     * @param  class-string<Model>  $alias
247     * @return ModelQueryBuilder<Model>
248     */
249    private function queryFor(string $alias): ModelQueryBuilder
250    {
251        $fkValue = $this->parent->attribute($this->getForeignKey());
252
253        if ($fkValue === null) {
254            // Null FK → no results, without compiling a meaningless query
255            // (BelongsTo's convention).
256            return $alias::newQuery()->whereRaw('1 = 0', []);
257        }
258
259        return $alias::newQuery()->where($this->getLocalKey(), WhereOperator::Eq, $fkValue);
260    }
261
262    /**
263     * The parent column(s) the eager loader collects key values from.
264     *
265     * @return list<string>
266     */
267    #[\Override]
268    public function eagerKeyColumn(): array
269    {
270        return [$this->typeColumn, $this->getForeignKey()];
271    }
272
273    /**
274     * Run the eager queries — ONE chunked `IN` per distinct type.
275     *
276     * @param  list<KeyValue>  $parentKeys  The parents' [type, FK] tuples.
277     * @return EagerResult<Model>
278     */
279    #[\Override]
280    public function eagerLoad(array $parentKeys): EagerResult
281    {
282        if ($parentKeys === []) {
283            return EagerResult::fromModels([]);
284        }
285
286        // Group the wanted keys by resolved type; within a type, index by
287        // the serialized FK so each loaded row maps straight back to its
288        // referencing parents.
289        $byAlias = [];
290
291        foreach ($parentKeys as $pair) {
292            $alias = $pair[$this->typeColumn] ?? null;
293            $fk = $pair[$this->getForeignKey()] ?? null;
294
295            if (!is_string($alias) || $alias === '' || $fk === null) {
296                throw new \InvalidArgumentException(
297                    'A MorphTo eager load requires the (type, key) tuple keyed by its column '
298                        . 'names [' . $this->typeColumn . ', ' . $this->getForeignKey() . ']; got '
299                        . get_debug_type($pair) . '.'
300                );
301            }
302
303            $byAlias[$alias][self::serializeKey($fk)] = true;
304        }
305
306        /** @var list<Model> $models */
307        $models = [];
308        $pairs = [];
309
310        foreach ($byAlias as $alias => $wantedKeys) {
311            foreach (array_chunk(array_keys($wantedKeys), self::EAGER_KEY_CHUNK) as $chunk) {
312                $rows = $alias::newQuery()
313                    ->whereIn($this->getLocalKey(), $chunk)
314                    ->get();
315
316                foreach ($rows as $model) {
317                    $key = self::serializeKey($model->attribute($this->getLocalKey()));
318
319                    if (!isset($wantedKeys[$key])) {
320                        continue; // a row no parent in THIS load references
321                    }
322
323                    $models[] = $model;
324                    $pairs[] = [$alias, $key];
325                }
326            }
327        }
328
329        $collection = Collection::make($models);
330
331        return new EagerResult($collection, $pairs);
332    }
333
334    /**
335     * Distribute eager results onto parents by (alias, key) pair.
336     *
337     * @param  list<Model>  $parents
338     * @param  Collection<int, Model>  $results
339     * @param  string  $name
340     * @param  list<array{string, string}>|null  $eagerParentKeys
341     * @return void
342     */
343    #[\Override]
344    public function match(array $parents, Collection $results, string $name, ?array $eagerParentKeys = null): void
345    {
346        if ($eagerParentKeys === null) {
347            throw new \LogicException(
348                static::class . '::match() requires the EagerResult (alias, key) pairs; '
349                . 'call it with the array returned by eagerLoad(), not the models alone.'
350            );
351        }
352
353        $byPair = [];
354
355        foreach ($results as $i => $model) {
356            $pair = $eagerParentKeys[$i] ?? null;
357
358            if ($pair === null) {
359                continue;
360            }
361
362            $byPair[$pair[0] . '|' . $pair[1]] = $model;
363        }
364
365        foreach ($parents as $parent) {
366            $alias = $parent->attribute($this->typeColumn);
367            $fk = $parent->attribute($this->getForeignKey());
368
369            if (!is_string($alias) || $fk === null) {
370                // No (type, key) pair → the single-valued relation loads
371                // as NULL (an empty collection would lie about cardinality).
372                $parent->setRelation($name, null);
373                continue;
374            }
375
376            $model = $byPair[$alias . '|' . self::serializeKey($fk)] ?? null;
377            $parent->setRelation($name, $model);
378        }
379    }
380
381    /**
382     * Run the constrained query against the parent's resolved type.
383     *
384     * @return Collection<int, Model>
385     */
386    #[\Override]
387    protected function executeResults(): Collection
388    {
389        $alias = $this->aliasOf($this->parent);
390
391        if ($alias === null) {
392            return Collection::make([]);
393        }
394
395        $first = $this->queryFor($alias)->first();
396
397        return Collection::make($first === null ? [] : [$first]);
398    }
399}