Lines 95.73% 157 / 164
Functions and Methods 92.10% 35 / 38
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
Relation 95.73% 157 / 164 92.10% 35 / 38 68 0.00% 0 / 1
 __construct 100.00% 18 / 18 100.00% 1 / 1 8
 defersConstraints 100.00% 1 / 1 100.00% 1 / 1 1
 withName 100.00% 5 / 5 100.00% 1 / 1 2
 markComposed 100.00% 3 / 3 100.00% 1 / 1 1
 servesCache 100.00% 1 / 1 100.00% 1 / 1 3
 eagerCache 66.66% 2 / 3 0.00% 0 / 1 2.15
 compositionQuery 100.00% 6 / 6 100.00% 1 / 1 2
 readQuery 100.00% 1 / 1 100.00% 1 / 1 1
 relatedClass 100.00% 1 / 1 100.00% 1 / 1 1
 addConstraints n/a 0 / 0 n/a 0 / 0 0
 match n/a 0 / 0 n/a 0 / 0 0
 eagerLoad 81.81% 9 / 11 0.00% 0 / 1 4.10
 eagerLoadChunk 84.61% 22 / 26 0.00% 0 / 1 4.06
 applyEagerOrdering 100.00% 1 / 1 100.00% 1 / 1 1
 get 100.00% 3 / 3 100.00% 1 / 1 3
 wrapCached 100.00% 3 / 3 100.00% 1 / 1 2
 executeResults 100.00% 1 / 1 100.00% 1 / 1 1
 getQuery 100.00% 1 / 1 100.00% 1 / 1 1
 where 100.00% 4 / 4 100.00% 1 / 1 1
 whereNested 100.00% 4 / 4 100.00% 1 / 1 1
 whereExists 100.00% 4 / 4 100.00% 1 / 1 1
 whereInQuery 100.00% 4 / 4 100.00% 1 / 1 1
 orderBy 100.00% 4 / 4 100.00% 1 / 1 1
 limit 100.00% 4 / 4 100.00% 1 / 1 1
 offset 100.00% 4 / 4 100.00% 1 / 1 1
 select 100.00% 4 / 4 100.00% 1 / 1 1
 groupBy 100.00% 4 / 4 100.00% 1 / 1 1
 having 100.00% 4 / 4 100.00% 1 / 1 1
 getRelated 100.00% 1 / 1 100.00% 1 / 1 1
 relatedClasses 100.00% 1 / 1 100.00% 1 / 1 1
 getForeignKey 100.00% 5 / 5 100.00% 1 / 1 2
 getLocalKey 100.00% 5 / 5 100.00% 1 / 1 2
 getForeignKeys 100.00% 5 / 5 100.00% 1 / 1 2
 getLocalKeys 100.00% 5 / 5 100.00% 1 / 1 2
 isComposite 100.00% 1 / 1 100.00% 1 / 1 1
 eagerKeyColumn 100.00% 1 / 1 100.00% 1 / 1 1
 applyKeyTuple 100.00% 4 / 4 100.00% 1 / 1 3
 parentKeyValues 100.00% 4 / 4 100.00% 1 / 1 2
 tupleValues 100.00% 4 / 4 100.00% 1 / 1 2
 serializeKey 100.00% 3 / 3 100.00% 1 / 1 2
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Relations;
6
7use BlueprintAU\Radiant\Collection;
8use BlueprintAU\Radiant\Concerns\FetchesResults;
9use BlueprintAU\Radiant\Concerns\FiltersQuery;
10use BlueprintAU\Radiant\Database\Query\Aggregate;
11use BlueprintAU\Radiant\Database\Query\Expression;
12use BlueprintAU\Radiant\Database\Query\WhereBuilder;
13use BlueprintAU\Radiant\Model;
14use BlueprintAU\Radiant\ModelQueryBuilder;
15use BlueprintAU\Radiant\Database\Query\Enums\SortDirection;
16use BlueprintAU\Radiant\Database\Query\Enums\WhereBoolean;
17use BlueprintAU\Radiant\Database\Query\Enums\WhereOperator;
18
19/**
20 * A relation between two models.
21 *
22 * A relation is a lazily-executed query: constructing it runs nothing;
23 * {@see Relation::get()} runs it. The FK constraint against the
24 * parent's key is applied in the constructor, so any filters you add are
25 * on top of it. Eager loading matches all parents' keys in one `IN` query
26 * instead of joining.
27 *
28 * Keys are scalar by default. A relation over a composite key declares
29 * both sides as column lists (`['region_id', 'country']`) â€” the constraint
30 * compiles as per-column `=` wheres and the eager load as an OR of AND
31 * groups. Scalar and composite are mutually exclusive.
32 *
33 * @template TRelated of Model
34 * @phpstan-import-type KeyValue from \BlueprintAU\Radiant\Model
35 */
36abstract class Relation
37{
38    use FiltersQuery;
39
40    /** @use FetchesResults<TRelated> */
41    use FetchesResults;
42
43    /**
44     * The maximum number of parent keys per eager-load query.
45     *
46     * Databases cap how many values a single query can hold. A larger
47     * load simply runs a few queries instead of failing.
48     */
49    protected const EAGER_KEY_CHUNK = 500;
50
51    /**
52     * The query builder for the related model.
53     *
54     * @var ModelQueryBuilder<TRelated>
55     */
56    protected ModelQueryBuilder $query;
57
58    /**
59     * The relation method name this relation was built from.
60     *
61     * @var string|null
62     */
63    private ?string $name = null;
64
65    /**
66     * Whether a filter has been added to this relation.
67     *
68     * @var bool
69     */
70    private bool $composed = false;
71
72    /**
73     * Create a relation.
74     *
75     * @param  Model  $parent
76     * @param  class-string<TRelated>  $related
77     * @param  string|list<string>  $foreignKey
78     * @param  string|list<string>  $localKey
79     * @throws \InvalidArgumentException
80     */
81    public function __construct(
82        protected readonly Model $parent,
83        protected readonly string $related,
84        protected readonly string|array $foreignKey,
85        protected readonly string|array $localKey,
86    ) {
87        if (is_array($foreignKey) !== is_array($localKey)) {
88            throw new \InvalidArgumentException(
89                "A relation's foreign key and local key must be BOTH single columns or BOTH "
90                    . "composite column lists; got one of each on [{$related}]."
91            );
92        }
93
94        if ($foreignKey === [] || $localKey === []) {
95            throw new \InvalidArgumentException(
96                'A composite relation key requires at least one column; got an empty list.'
97            );
98        }
99
100        if (is_array($foreignKey) && is_array($localKey) && count($foreignKey) !== count($localKey)) {
101            throw new \InvalidArgumentException(
102                "A composite relation key's foreign and local columns must have matching "
103                    . 'arity; got ' . count($foreignKey) . ' and ' . count($localKey) . '.'
104            );
105        }
106
107        // MorphTo resolves its related model per row, so it has no query
108        // to build here â€” it handles that itself, lazily.
109        if ($this->defersConstraints()) {
110            return;
111        }
112
113        $this->query = $this->related::newQuery();
114        $this->addConstraints();
115    }
116
117    /**
118     * Whether the related model is resolved per row (MorphTo) rather than
119     * fixed at construction.
120     *
121     * @return bool
122     */
123    protected function defersConstraints(): bool
124    {
125        return false;
126    }
127
128    /**
129     * Name this relation after the model method that created it.
130     *
131     * This lets `get()` reuse an eagerly-loaded result when one
132     * exists. Passing null does nothing.
133     *
134     * @param  string|null  $name
135     * @return static
136     */
137    final public function withName(?string $name): static
138    {
139        if ($name === null) {
140            return $this;
141        }
142
143        $clone = clone $this;
144        $clone->name = $name;
145
146        return $clone;
147    }
148
149    /**
150     * Return a copy of this relation marked as modified.
151     *
152     * Used by configurators like {@see BelongsToMany::withPivot()} that
153     * change what a fresh read returns without adding a where clause. A
154     * modified relation never reuses an eagerly-loaded result.
155     *
156     * @return static
157     */
158    protected function markComposed(): static
159    {
160        $clone = clone $this;
161        $clone->composed = true;
162
163        return $clone;
164    }
165
166    /**
167     * Whether an eagerly-loaded result can serve this relation's reads.
168     *
169     * @return bool
170     */
171    final protected function servesCache(): bool
172    {
173        return $this->name !== null && !$this->composed && $this->parent->relationLoaded($this->name);
174    }
175
176    /**
177     * The eagerly-loaded result as a collection.
178     *
179     * @return Collection<int, TRelated>
180     */
181    protected function eagerCache(): Collection
182    {
183        if ($this->name === null) {
184            return Collection::make([]);
185        }
186
187        return $this->wrapCached($this->parent->cachedRelation($this->name));
188    }
189
190    /**
191     * The query builder that filters are added to.
192     *
193     * @return ModelQueryBuilder<TRelated>
194     */
195    protected function compositionQuery(): ModelQueryBuilder
196    {
197        if (!isset($this->query)) {
198            throw new \LogicException(
199                static::class . ' cannot compose filters â€” its query is built lazily per '
200                . 'resolved type; read the results with get() instead.'
201            );
202        }
203
204        return $this->query;
205    }
206
207    /**
208     * The query the row reads run against.
209     *
210     * @return ModelQueryBuilder<TRelated>
211     */
212    protected function readQuery(): ModelQueryBuilder
213    {
214        return $this->getQuery();
215    }
216
217    /**
218     * The related model class â€” the fail-fast exceptions' identity.
219     *
220     * @return class-string<TRelated>
221     */
222    protected function relatedClass(): string
223    {
224        return $this->related;
225    }
226
227    /**
228     * Apply the relation's FK constraint to the query.
229     *
230     * Called once from the constructor.
231     *
232     * @return void
233     */
234    abstract protected function addConstraints(): void;
235
236    /**
237     * Distribute eagerly-loaded results onto their parents.
238     *
239     * @param  list<Model>  $parents
240     * @param  Collection<int, TRelated>  $results
241     * @param  string  $name
242     * @param  list<int|string|null|list<int|string|null>>|null  $eagerParentKeys
243     * @return void
244     */
245    abstract public function match(array $parents, Collection $results, string $name, ?array $eagerParentKeys = null): void;
246
247    /**
248     * Run the eager query for many parents at once.
249     *
250     * Instead of one query per parent, this fetches everything in a
251     * single `IN (...)` query and lets {@see match()} hand each parent its
252     * own results. Very large key lists are split into batches of
253     * {@see EAGER_KEY_CHUNK} so no database limit is ever hit.
254     *
255     * @param  list<KeyValue>  $parentKeys
256     * @return EagerResult<TRelated>
257     */
258    public function eagerLoad(array $parentKeys): EagerResult
259    {
260        if ($parentKeys === []) {
261            return EagerResult::fromModels([]);
262        }
263
264        $models = [];
265        $parentKeysOut = null;
266
267        foreach (array_chunk($parentKeys, self::EAGER_KEY_CHUNK) as $chunk) {
268            $chunkResult = $this->eagerLoadChunk($chunk);
269            array_push($models, ...$chunkResult->models->all());
270
271            if ($chunkResult->parentKeys !== null) {
272                $parentKeysOut ??= [];
273                array_push($parentKeysOut, ...$chunkResult->parentKeys);
274            }
275        }
276
277        return new EagerResult(EagerResult::listToCollection($models), $parentKeysOut);
278    }
279
280    /**
281     * Run one eager-load query for a batch of parent keys.
282     *
283     * @param  list<KeyValue>  $parentKeys
284     * @return EagerResult<TRelated>
285     */
286    protected function eagerLoadChunk(array $parentKeys): EagerResult
287    {
288        $query = $this->related::newQuery();
289
290        $query = $this->applyEagerOrdering($query);
291
292        if ($this->isComposite()) {
293            $foreignKeys = $this->getForeignKeys();
294            $localKeys = $this->getLocalKeys();
295
296            // The OR-of-groups lands INSIDE one outer AND-group: the key
297            // set is ONE constraint unit. The related builder auto-applies
298            // trait scopes (e.g. soft-delete `deleted_at IS NULL`) as
299            // leading AND-groups â€” flat top-level ORs would compile to
300            // `(scope) OR (fk = ? AND ...) OR ...` and let a scope-excluded
301            // row back in whenever its key matched. Grouped, the scope
302            // ANDs against the whole set.
303            return EagerResult::fromCollection($query->whereNested(
304                function (WhereBuilder $nested) use ($foreignKeys, $localKeys, $parentKeys): WhereBuilder {
305                    $grouped = $nested;
306
307                    foreach ($parentKeys as $parentKey) {
308                        if (!is_array($parentKey)) {
309                            throw new \InvalidArgumentException(
310                                'A composite relation key requires column => value key maps for eager loading; '
311                                    . 'got ' . get_debug_type($parentKey) . '.'
312                            );
313                        }
314
315                        $grouped = $grouped->orWhereNested(
316                            fn (WhereBuilder $keyGroup): WhereBuilder => self::applyKeyTuple(
317                                $keyGroup,
318                                $foreignKeys,
319                                $localKeys,
320                                $parentKey,
321                            )
322                        );
323                    }
324
325                    return $grouped;
326                }
327            )->get());
328        }
329
330        return EagerResult::fromCollection($query->whereIn($this->getForeignKey(), $parentKeys)->get());
331    }
332
333    /**
334     * Apply the eager query's ordering.
335     *
336     * One-to-one relations override this to order by the related model's
337     * primary key, so "take the first match" always picks the same row.
338     *
339     * @param  ModelQueryBuilder<TRelated>  $query
340     * @return ModelQueryBuilder<TRelated>
341     */
342    protected function applyEagerOrdering(ModelQueryBuilder $query): ModelQueryBuilder
343    {
344        // No default ordering.
345        return $query;
346    }
347
348    /**
349     * Get the related models â€” reusing an eagerly-loaded result when one
350     * applies.
351     *
352     * The loaded result is reused only when the relation was named, no
353     * filter has been added, and the parent actually has the relation
354     * loaded. Everything else runs a fresh query.
355     *
356     * Unlike the query builder's `get()`, this may return a cached
357     * eagerly-loaded result without running SQL. Pass `fresh: true` to
358     * bypass the cache and always run the query.
359     *
360     * @param  bool  $fresh  Bypass the eagerly-loaded result and run the query.
361     * @return Collection<int, TRelated>
362     */
363    final public function get(bool $fresh = false): Collection
364    {
365        if (!$fresh && $this->servesCache()) {
366            return $this->eagerCache();
367        }
368
369        return $this->executeResults();
370    }
371
372    /**
373     * Wrap a cached relation value into the collection shape.
374     *
375     * @param  Model|Collection<int, Model>|null  $value
376     * @return Collection<int, TRelated>
377     */
378    private function wrapCached(Model|Collection|null $value): Collection
379    {
380        if ($value instanceof Model) {
381            /** @var Collection<int, TRelated> */
382            return Collection::make([$value]);
383        }
384
385        /** @var Collection<int, TRelated> */
386        return $value ?? Collection::make([]);
387    }
388
389    /**
390     * Run the query and return the related models.
391     *
392     * @return Collection<int, TRelated>
393     */
394    protected function executeResults(): Collection
395    {
396        return $this->query->get();
397    }
398
399    /**
400     * The underlying query builder for the related model.
401     *
402     * @return ModelQueryBuilder<TRelated>
403     */
404    final public function getQuery(): ModelQueryBuilder
405    {
406        return $this->query;
407    }
408
409    /**
410     * Add a where clause to the relation's query.
411     *
412     * @param  string|Expression  $column
413     * @param  WhereOperator|string  $operator
414     * @param  mixed  $value
415     * @param  WhereBoolean  $boolean
416     * @return static
417     */
418    final public function where(
419        string|Expression $column,
420        WhereOperator|string $operator,
421        mixed $value,
422        WhereBoolean $boolean = WhereBoolean::And,
423    ): static {
424        $clone = clone $this;
425        $clone->composed = true;
426        $clone->query = $this->compositionQuery()->where($column, $operator, $value, $boolean);
427
428        return $clone;
429    }
430
431    /**
432     * Add a nested (parenthesized) where group to the relation's query.
433     *
434     * The callback receives the group's builder and must return it.
435     *
436     * @param  callable(\BlueprintAU\Radiant\Database\Query\WhereBuilder): \BlueprintAU\Radiant\Database\Query\WhereBuilder  $callback
437     * @param  WhereBoolean  $boolean
438     * @return static
439     */
440    final public function whereNested(
441        callable $callback,
442        WhereBoolean $boolean = WhereBoolean::And,
443    ): static {
444        $clone = clone $this;
445        $clone->composed = true;
446        $clone->query = $this->compositionQuery()->whereNested($callback, $boolean);
447
448        return $clone;
449    }
450
451    /**
452     * Add an `EXISTS (subquery)` clause to the relation's query.
453     *
454     * @param  \BlueprintAU\Radiant\Database\Query\QueryBuilder  $query  The existential subquery.
455     * @param  WhereBoolean  $boolean
456     * @param  bool  $negated  True renders `NOT EXISTS`.
457     * @return static
458     */
459    final public function whereExists(
460        \BlueprintAU\Radiant\Database\Query\QueryBuilder $query,
461        WhereBoolean $boolean = WhereBoolean::And,
462        bool $negated = false,
463    ): static {
464        $clone = clone $this;
465        $clone->composed = true;
466        $clone->query = $this->compositionQuery()->whereExists($query, $boolean, $negated);
467
468        return $clone;
469    }
470
471    /**
472     * Add a `column IN (subquery)` clause to the relation's query.
473     *
474     * @param  string  $column  The outer column the IN constrains.
475     * @param  \BlueprintAU\Radiant\Database\Query\QueryBuilder  $query  The single-column value subquery.
476     * @param  WhereBoolean  $boolean
477     * @param  bool  $negated  True renders `NOT IN`.
478     * @return static
479     */
480    final public function whereInQuery(
481        string $column,
482        \BlueprintAU\Radiant\Database\Query\QueryBuilder $query,
483        WhereBoolean $boolean = WhereBoolean::And,
484        bool $negated = false,
485    ): static {
486        $clone = clone $this;
487        $clone->composed = true;
488        $clone->query = $this->compositionQuery()->whereInQuery($column, $query, $boolean, $negated);
489
490        return $clone;
491    }
492
493    /**
494     * Add an "order by" clause to the relation's query.
495     *
496     * @param  string|Expression  $column
497     * @param  SortDirection|string  $direction
498     * @return static
499     */
500    final public function orderBy(string|Expression $column, SortDirection|string $direction = SortDirection::Asc): static
501    {
502        $clone = clone $this;
503        $clone->composed = true;
504        $clone->query = $this->compositionQuery()->orderBy($column, $direction);
505
506        return $clone;
507    }
508
509    /**
510     * Set the "limit" value of the relation's query.
511     *
512     * @param  int  $limit
513     * @return static
514     */
515    final public function limit(int $limit): static
516    {
517        $clone = clone $this;
518        $clone->composed = true;
519        $clone->query = $this->compositionQuery()->limit($limit);
520
521        return $clone;
522    }
523
524    /**
525     * Set the "offset" value of the relation's query.
526     *
527     * @param  int  $offset
528     * @return static
529     */
530    final public function offset(int $offset): static
531    {
532        $clone = clone $this;
533        $clone->composed = true;
534        $clone->query = $this->compositionQuery()->offset($offset);
535
536        return $clone;
537    }
538
539    /**
540     * Set the columns to be selected.
541     *
542     * @param  string|Expression|Aggregate  ...$columns
543     * @return static
544     */
545    final public function select(string|Expression|Aggregate ...$columns): static
546    {
547        // No args â†’ the default `['*']` select.
548        $clone = clone $this;
549        $clone->composed = true;
550        $clone->query = $this->compositionQuery()->select(...$columns);
551
552        return $clone;
553    }
554
555    /**
556     * Add a "group by" clause to the relation's query.
557     *
558     * @param  string|array<int, string>  $columns
559     * @return static
560     */
561    final public function groupBy(string|array $columns): static
562    {
563        $clone = clone $this;
564        $clone->composed = true;
565        $clone->query = $this->compositionQuery()->groupBy($columns);
566
567        return $clone;
568    }
569
570    /**
571     * Add a "having" clause to the relation's query.
572     *
573     * @param  string|Expression|Aggregate  $column
574     * @param  WhereOperator|string  $operator
575     * @param  mixed  $value
576     * @return static
577     */
578    final public function having(string|Expression|Aggregate $column, WhereOperator|string $operator, mixed $value): static
579    {
580        $clone = clone $this;
581        $clone->composed = true;
582        $clone->query = $this->compositionQuery()->having($column, $operator, $value);
583
584        return $clone;
585    }
586
587    /**
588     * The related model class.
589     *
590     * @return class-string<TRelated>
591     */
592    final public function getRelated(): string
593    {
594        return $this->related;
595    }
596
597    /**
598     * The related classes a dotted eager-load path's deeper segments
599     * resolve against.
600     *
601     * An empty list means the related model varies per row (MorphTo).
602     *
603     * @return list<class-string<Model>>
604     */
605    public function relatedClasses(): array
606    {
607        return [$this->related];
608    }
609
610    /**
611     * The scalar form of a relation key.
612     *
613     * @return string
614     * @throws \LogicException
615     */
616    final public function getForeignKey(): string
617    {
618        return is_string($this->foreignKey)
619            ? $this->foreignKey
620            : throw new \LogicException(
621                'This relation uses a composite foreign key; call getForeignKeys() instead.'
622            );
623    }
624
625    /**
626     * The single parent-side key column.
627     *
628     * @return string
629     * @throws \LogicException
630     */
631    final public function getLocalKey(): string
632    {
633        return is_string($this->localKey)
634            ? $this->localKey
635            : throw new \LogicException(
636                'This relation uses a composite local key; call getLocalKeys() instead.'
637            );
638    }
639
640    /**
641     * The composite foreign key columns.
642     *
643     * @return list<string>
644     * @throws \LogicException
645     */
646    final public function getForeignKeys(): array
647    {
648        return is_array($this->foreignKey)
649            ? $this->foreignKey
650            : throw new \LogicException(
651                'This relation uses a single foreign key; call getForeignKey() instead.'
652            );
653    }
654
655    /**
656     * The composite parent-side key columns.
657     *
658     * @return list<string>
659     * @throws \LogicException
660     */
661    final public function getLocalKeys(): array
662    {
663        return is_array($this->localKey)
664            ? $this->localKey
665            : throw new \LogicException(
666                'This relation uses a single local key; call getLocalKey() instead.'
667            );
668    }
669
670    /**
671     * Whether the relation is keyed by a composite key.
672     *
673     * @return bool
674     */
675    final public function isComposite(): bool
676    {
677        return is_array($this->foreignKey);
678    }
679
680    /**
681     * The parent column(s) the eager loader collects key values from.
682     *
683     * @return string|list<string>
684     */
685    public function eagerKeyColumn(): string|array
686    {
687        return $this->localKey;
688    }
689
690    /**
691     * Apply one composite key match to a builder.
692     *
693     * Every FK column must equal the corresponding parent value; a null
694     * component becomes IS NULL (SQL `= NULL` never matches).
695     *
696     * @param  WhereBuilder  $query
697     * @param  list<string>  $foreignKeys
698     * @param  list<string>  $localKeys
699     * @param  array<string, int|string|null>  $values
700     * @return WhereBuilder
701     */
702    final protected static function applyKeyTuple(
703        WhereBuilder $query,
704        array $foreignKeys,
705        array $localKeys,
706        array $values,
707    ): WhereBuilder {
708        foreach ($foreignKeys as $i => $foreignKey) {
709            $value = $values[$localKeys[$i]] ?? null;
710            $query = $query->where($foreignKey, $value === null ? WhereOperator::Null : WhereOperator::Eq, $value);
711        }
712
713        return $query;
714    }
715
716    /**
717     * Collect the parent's key tuple as a column => value map.
718     *
719     * @param  list<string>  $localKeys
720     * @return array<string, int|string|null>
721     */
722    final protected function parentKeyValues(array $localKeys): array
723    {
724        $values = [];
725
726        foreach ($localKeys as $localKey) {
727            $values[$localKey] = $this->parent->attribute($localKey);
728        }
729
730        return $values;
731    }
732
733    /**
734     * Read a model's composite key tuple as a positional value list.
735     *
736     * Matching is position-based: the related side's values are keyed by
737     * FK column names while the parent's are keyed by local column names,
738     * so name-based comparison would never match.
739     *
740     * @param  Model  $model
741     * @param  list<string>  $columns
742     * @return list<int|string|null>
743     */
744    final protected static function tupleValues(Model $model, array $columns): array
745    {
746        $values = [];
747
748        foreach ($columns as $column) {
749            $values[] = $model->attribute($column);
750        }
751
752        return $values;
753    }
754
755    /**
756     * Serialize a key value to a stable string for array indexing.
757     *
758     * Scalars and column => value maps are the {@see KeyValue} shapes;
759     * through relations serialize POSITIONAL key tuples as well.
760     *
761     * @param  KeyValue|list<int|string|null>  $key
762     * @return string
763     *
764     * @throws \JsonException
765     */
766    final protected static function serializeKey(int|string|null|array $key): string
767    {
768        if (!is_array($key)) {
769            return (string) $key;
770        }
771
772        return json_encode($key, JSON_THROW_ON_ERROR);
773    }
774}