Lines 96.15% 200 / 208
Methods 92.98% 53 / 57
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __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
 [BlueprintAU\Radiant\Concerns\FiltersQuery] orderBy n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersQuery] limit n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersQuery] offset n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersQuery] select n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersQuery] groupBy n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FiltersQuery] having n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FetchesResults] readQuery n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FetchesResults] compositionQuery n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FetchesResults] eagerCache n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FetchesResults] servesCache n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FetchesResults] relatedClass n/a 0 / 0 n/a 0 / 0 0
 [BlueprintAU\Radiant\Concerns\FetchesResults] first 100.00% 3 / 3 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Concerns\FetchesResults] find 100.00% 3 / 3 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Concerns\FetchesResults] findOrFail 100.00% 4 / 4 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Concerns\FetchesResults] firstOrFail 100.00% 4 / 4 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Concerns\FetchesResults] sole 100.00% 9 / 9 100.00% 1 / 1 5
 [BlueprintAU\Radiant\Concerns\FetchesResults] firstOrCreate 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FetchesResults] findOrCreate 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FetchesResults] count 100.00% 3 / 3 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Concerns\FetchesResults] exists 100.00% 3 / 3 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Concerns\FetchesResults] cursor 100.00% 4 / 4 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Concerns\FetchesResults] value 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FetchesResults] pluck 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FetchesResults] max 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FetchesResults] min 0.00% 0 / 1 0.00% 0 / 1 2
 [BlueprintAU\Radiant\Concerns\FetchesResults] sum 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FetchesResults] avg 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FetchesResults] aggregates 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FetchesResults] aggregateBy 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Concerns\FetchesResults] countBy 100.00% 1 / 1 100.00% 1 / 1 1
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}

From BlueprintAU\Radiant\Concerns\FiltersQuery

19trait FiltersQuery
20{
21    use FiltersWhere;
22
23    /**
24     * Add an order-by clause.
25     *
26     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression  $column
27     * @param  SortDirection|string  $direction
28     * @return static
29     */
30    abstract public function orderBy(string|\BlueprintAU\Radiant\Database\Query\Expression $column, SortDirection|string $direction = SortDirection::Asc): static;
31
32    /**
33     * Set the maximum number of rows to return.
34     *
35     * @param  int  $limit
36     * @return static
37     */
38    abstract public function limit(int $limit): static;
39
40    /**
41     * Set the number of rows to skip.
42     *
43     * @param  int  $offset
44     * @return static
45     */
46    abstract public function offset(int $offset): static;
47
48    /**
49     * Set an explicit column selection.
50     *
51     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression|\BlueprintAU\Radiant\Database\Query\Aggregate  ...$columns
52     * @return static
53     */
54    abstract public function select(string|\BlueprintAU\Radiant\Database\Query\Expression|\BlueprintAU\Radiant\Database\Query\Aggregate ...$columns): static;
55
56    /**
57     * Group by columns (for aggregate + select combos).
58     *
59     * @param  string|array<int, string>  $columns
60     * @return static
61     */
62    abstract public function groupBy(string|array $columns): static;
63
64    /**
65     * Filter groups after aggregation (HAVING).
66     *
67     * @param  string|\BlueprintAU\Radiant\Database\Query\Expression|\BlueprintAU\Radiant\Database\Query\Aggregate  $column
68     * @param  WhereOperator|string  $operator
69     * @param  mixed  $value
70     * @return static
71     */
72    abstract public function having(string|\BlueprintAU\Radiant\Database\Query\Expression|\BlueprintAU\Radiant\Database\Query\Aggregate $column, WhereOperator|string $operator, mixed $value): static;
73}

From BlueprintAU\Radiant\Concerns\FetchesResults

22trait FetchesResults
23{
24    /**
25     * The query the row reads run against.
26     *
27     * @return ModelQueryBuilder<TRelated>
28     */
29    abstract protected function readQuery(): ModelQueryBuilder;
30
31    /**
32     * The query the scalar reads run against.
33     *
34     * A wrapper whose query is built lazily per row (MorphTo) throws
35     * here — the scalar reads have no stable table to target.
36     *
37     * @return ModelQueryBuilder<TRelated>
38     */
39    abstract protected function compositionQuery(): ModelQueryBuilder;
40
41    /**
42     * The eagerly-loaded result — the row reads' cache path.
43     *
44     * @return Collection<int, TRelated>
45     */
46    abstract protected function eagerCache(): Collection;
47
48    /**
49     * Whether the eagerly-loaded result can serve the row reads.
50     *
51     * @return bool
52     */
53    abstract protected function servesCache(): bool;
54
55    /**
56     * The related model class — the fail-fast exceptions' identity.
57     *
58     * @return class-string<TRelated>
59     */
60    abstract protected function relatedClass(): string;
61
62    /**
63     * Run the query and hydrate the first related model.
64     *
65     * Served from an eagerly-loaded result when one applies — the
66     * relation was named, no filter composed, and the parent carries the
67     * relation loaded. Pass `fresh: true` to always run the query.
68     *
69     * @param  bool  $fresh  Bypass the eagerly-loaded result and run the query.
70     * @return TRelated|null
71     */
72    final public function first(bool $fresh = false): ?Model
73    {
74        if (!$fresh && $this->servesCache()) {
75            /** @var TRelated|null */
76            return $this->eagerCache()->first();
77        }
78
79        return $this->readQuery()->first();
80    }
81
82    /**
83     * Find a related model by its primary key, scoped to the relation's
84     * constraint.
85     *
86     * Served from an eagerly-loaded result when one applies (the lookup
87     * is then membership of the loaded set). Pass `fresh: true` to
88     * always run the query.
89     *
90     * @param  KeyValue  $id  The primary-key value, or a column => value map for a composite key.
91     * @param  bool  $fresh  Bypass the eagerly-loaded result and run the query.
92     * @return TRelated|null
93     */
94    final public function find(int|string|null|array $id, bool $fresh = false): ?Model
95    {
96        if (!$fresh && $this->servesCache()) {
97            /** @var TRelated|null */
98            return $this->eagerCache()->find($id);
99        }
100
101        return $this->readQuery()->find($id);
102    }
103
104    /**
105     * Find a related model by its primary key or throw if it does not
106     * exist.
107     *
108     * Served from an eagerly-loaded result when one applies — a miss is
109     * then the loaded set holding no match. Pass `fresh: true` to
110     * always run the query.
111     *
112     * @param  KeyValue  $id  The primary-key value, or a column => value map for a composite key.
113     * @param  bool  $fresh  Bypass the eagerly-loaded result and run the query.
114     * @return TRelated
115     *
116     * @throws \BlueprintAU\Radiant\Exceptions\ModelNotFoundException
117     */
118    final public function findOrFail(int|string|null|array $id, bool $fresh = false): Model
119    {
120        $model = $this->find($id, $fresh);
121
122        if ($model === null) {
123            throw new ModelNotFoundException($this->relatedClass(), $id);
124        }
125
126        return $model;
127    }
128
129    /**
130     * Get the first related model or throw if no related models exist.
131     *
132     * Served from an eagerly-loaded result when one applies — a miss is
133     * then the loaded set being empty. Pass `fresh: true` to always run
134     * the query.
135     *
136     * @param  bool  $fresh  Bypass the eagerly-loaded result and run the query.
137     * @return TRelated
138     *
139     * @throws \BlueprintAU\Radiant\Exceptions\ModelNotFoundException
140     */
141    final public function firstOrFail(bool $fresh = false): Model
142    {
143        $model = $this->first($fresh);
144
145        if ($model === null) {
146            throw new ModelNotFoundException($this->relatedClass());
147        }
148
149        return $model;
150    }
151
152    /**
153     * Require the relation to match exactly one related model.
154     *
155     * Served from an eagerly-loaded result when one applies — the loaded
156     * set's size decides (zero throws, more than one throws). Pass
157     * `fresh: true` to always run the query.
158     *
159     * @param  bool  $fresh  Bypass the eagerly-loaded result and run the query.
160     * @return TRelated
161     *
162     * @throws \BlueprintAU\Radiant\Exceptions\ModelNotFoundException
163     * @throws \BlueprintAU\Radiant\Exceptions\MultipleRecordsFoundException
164     */
165    final public function sole(bool $fresh = false): Model
166    {
167        if (!$fresh && $this->servesCache()) {
168            $models = $this->eagerCache();
169            $count = $models->count();
170
171            if ($count === 0) {
172                throw new ModelNotFoundException($this->relatedClass());
173            }
174
175            if ($count > 1) {
176                throw new MultipleRecordsFoundException($count, $this->relatedClass());
177            }
178
179            /** @var TRelated */
180            return $models->first();
181        }
182
183        return $this->readQuery()->sole();
184    }
185
186    /**
187     * Return the first related model, or create one carrying the
188     * relation's constraint.
189     *
190     * The constraint's equality clauses (the FK pointing at the parent, a
191     * morph pair's key) become fills, so the created model satisfies the
192     * match that failed to find it. Not served from an eagerly-loaded
193     * result — a create must consult the database. For MorphTo, the
194     * resolved pair's key equality inverts into the fill, so the created
195     * target's primary key equals the parent's morph key; an unresolved
196     * pair fails the audit.
197     *
198     * @param  array<string, mixed>  $values  Extra column values for the created model.
199     * @return TRelated
200     * @throws \InvalidArgumentException
201     * @throws \BlueprintAU\Radiant\Exceptions\WriteVetoException
202     */
203    final public function firstOrCreate(array $values = []): Model
204    {
205        return $this->readQuery()->firstOrCreate($values);
206    }
207
208    /**
209     * Find a related model by its primary key, or create one carrying
210     * that key and the relation's constraint.
211     *
212     * The constraint Eqs become fills exactly as in
213     * {@see self::firstOrCreate()}; a hit reads the model's
214     * `wasRecentlyCreated` as false, a miss as true.
215     *
216     * @param  KeyValue  $id  The primary-key value, or a column => value map for a composite key.
217     * @param  array<string, mixed>  $values  Extra column values for the created model.
218     * @return TRelated
219     * @throws \InvalidArgumentException
220     * @throws \BlueprintAU\Radiant\Exceptions\WriteVetoException
221     */
222    final public function findOrCreate(int|string|null|array $id, array $values = []): Model
223    {
224        return $this->readQuery()->findOrCreate($id, $values);
225    }
226
227    /**
228     * Count the related rows matching the relation's constraint.
229     *
230     * Served from an eagerly-loaded result when one applies — the count
231     * is then the loaded set's size (a snapshot). Pass `fresh: true` to
232     * always run the query.
233     *
234     * @param  bool  $fresh  Bypass the eagerly-loaded result and run the query.
235     * @return int
236     */
237    final public function count(bool $fresh = false): int
238    {
239        if (!$fresh && $this->servesCache()) {
240            return $this->eagerCache()->count();
241        }
242
243        return $this->readQuery()->count();
244    }
245
246    /**
247     * Whether any related row matches the relation's constraint.
248     *
249     * Served from an eagerly-loaded result when one applies. Pass
250     * `fresh: true` to always run the query.
251     *
252     * @param  bool  $fresh  Bypass the eagerly-loaded result and run the query.
253     * @return bool
254     */
255    final public function exists(bool $fresh = false): bool
256    {
257        if (!$fresh && $this->servesCache()) {
258            return $this->eagerCache()->count() !== 0;
259        }
260
261        return $this->readQuery()->exists();
262    }
263
264    /**
265     * Stream the related models, hydrating each row as it arrives.
266     *
267     * Served from an eagerly-loaded result when one applies. Pass
268     * `fresh: true` to always run the query.
269     *
270     * @param  bool  $fresh  Bypass the eagerly-loaded result and run the query.
271     * @return \Generator<int, TRelated>
272     */
273    final public function cursor(bool $fresh = false): \Generator
274    {
275        if (!$fresh && $this->servesCache()) {
276            yield from $this->eagerCache();
277
278            return;
279        }
280
281        // A body holding yield makes this a generator — the live path
282        // forwards the builder's stream item by item (a plain return
283        // would terminate this generator empty).
284        yield from $this->readQuery()->cursor();
285    }
286
287    /**
288     * The value of a single column from the first related row, decoded
289     * through the column's cast.
290     *
291     * @param  string|Aggregate  $column
292     * @return mixed
293     */
294    final public function value(string|Aggregate $column): mixed
295    {
296        return $this->compositionQuery()->value($column);
297    }
298
299    /**
300     * A collection of a single column's values, decoded through the casts.
301     *
302     * @param  string  $column
303     * @return BaseCollection<int, mixed>
304     */
305    final public function pluck(string $column): BaseCollection
306    {
307        return $this->compositionQuery()->pluck($column);
308    }
309
310    /**
311     * The maximum value of a column, decoded through the cast for
312     * declared columns.
313     *
314     * @param  string  $column
315     * @return mixed
316     */
317    final public function max(string $column): mixed
318    {
319        return $this->compositionQuery()->max($column);
320    }
321
322    /**
323     * The minimum value of a column, decoded through the cast for
324     * declared columns.
325     *
326     * @param  string  $column
327     * @return mixed
328     */
329    final public function min(string $column): mixed
330    {
331        return $this->compositionQuery()->min($column);
332    }
333
334    /**
335     * The sum of a column's values, decoded through the cast for
336     * declared columns.
337     *
338     * @param  string  $column
339     * @return mixed
340     */
341    final public function sum(string $column): mixed
342    {
343        return $this->compositionQuery()->sum($column);
344    }
345
346    /**
347     * The average of a column's values, decoded through the cast for
348     * declared columns.
349     *
350     * @param  string  $column
351     * @return mixed
352     */
353    final public function avg(string $column): mixed
354    {
355        return $this->compositionQuery()->avg($column);
356    }
357
358    /**
359     * Multiple aggregates in one query, decoded through each aggregate's
360     * column cast.
361     *
362     * @param  Aggregate  ...$aggregates
363     * @return \stdClass
364     */
365    final public function aggregates(Aggregate ...$aggregates): \stdClass
366    {
367        return $this->compositionQuery()->aggregates(...$aggregates);
368    }
369
370    /**
371     * Run one aggregate per group of the related rows — a grouped
372     * aggregate in a single query.
373     *
374     * The FK constraint rides along automatically: the groups only ever
375     * cover THIS parent's related rows. The result is keyed by the group
376     * column's value, so the aggregate's own alias is ignored here (it
377     * matters only for the multi-aggregate row shape of the builder's
378     * aggregates()).
379     *
380     * The value type follows the aggregate: `count` yields int;
381     * `sum`/`avg` over numeric columns yield int|float; `min`/`max` yield
382     * the column's decoded type (a datetime column yields Carbon); custom
383     * functions and Expression arguments yield the raw driver value. For
384     * a guaranteed-numeric grouped count, use {@see self::countBy()}.
385     *
386     * @param  Aggregate  $aggregate
387     * @param  string  $groupBy
388     * @return BaseCollection<string, mixed>
389     *
390     * @throws \LogicException
391     */
392    final public function aggregateBy(Aggregate $aggregate, string $groupBy): BaseCollection
393    {
394        return $this->compositionQuery()->aggregateBy($aggregate, $groupBy);
395    }
396
397    /**
398     * Count the related rows per group of a column — in a single query.
399     *
400     * The FK constraint rides along automatically: the counts only ever
401     * cover THIS parent's related rows. The result is keyed by the group
402     * column's value with int counts.
403     *
404     * The optional seed lists group values that must appear even when the
405     * database has no rows for them — each seeded key absent from the
406     * result becomes 0. The seed is ADDITIVE: database rows always win,
407     * and group values found in the data but missing from the seed still
408     * appear. (Only counts can be seeded — an absent group has no honest
409     * min, max, or average.)
410     *
411     * @param  string  $column
412     * @param  list<int|string>|null  $seed  Group values guaranteed to appear (0 when absent).
413     * @return BaseCollection<string, int>
414     *
415     * @throws \LogicException
416     */
417    final public function countBy(string $column, ?array $seed = null): BaseCollection
418    {
419        return $this->compositionQuery()->countBy($column, $seed);
420    }
421}