Lines 96.03% 194 / 202
Methods 90.24% 37 / 41
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 addConstraints 100.00% 15 / 15 100.00% 1 / 1 4
 hasResolvableKey 100.00% 3 / 3 100.00% 1 / 1 2
 readQuery 66.66% 2 / 3 0.00% 0 / 1 2.15
 executeResults 100.00% 4 / 4 100.00% 1 / 1 3
 eagerLoadChunk 86.20% 25 / 29 0.00% 0 / 1 4.04
 eagerKeyColumn 100.00% 1 / 1 100.00% 1 / 1 1
 match 100.00% 12 / 12 100.00% 1 / 1 5
 [BlueprintAU\Radiant\Relations\Relation] __construct 100.00% 18 / 18 100.00% 1 / 1 8
 [BlueprintAU\Radiant\Relations\Relation] defersConstraints 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] withName 100.00% 5 / 5 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Relations\Relation] markComposed 100.00% 3 / 3 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] servesCache 100.00% 1 / 1 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Relations\Relation] eagerCache 66.66% 2 / 3 0.00% 0 / 1 2.15
 [BlueprintAU\Radiant\Relations\Relation] compositionQuery 100.00% 6 / 6 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Relations\Relation] relatedClass 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] eagerLoad 81.81% 9 / 11 0.00% 0 / 1 4.10
 [BlueprintAU\Radiant\Relations\Relation] applyEagerOrdering 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] get 100.00% 3 / 3 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Relations\Relation] wrapCached 100.00% 3 / 3 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Relations\Relation] getQuery 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] where 100.00% 4 / 4 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] whereNested 100.00% 4 / 4 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] whereExists 100.00% 4 / 4 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] whereInQuery 100.00% 4 / 4 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] orderBy 100.00% 4 / 4 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] limit 100.00% 4 / 4 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] offset 100.00% 4 / 4 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] select 100.00% 4 / 4 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] groupBy 100.00% 4 / 4 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] having 100.00% 4 / 4 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] getRelated 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] relatedClasses 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] getForeignKey 100.00% 5 / 5 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Relations\Relation] getLocalKey 100.00% 5 / 5 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Relations\Relation] getForeignKeys 100.00% 5 / 5 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Relations\Relation] getLocalKeys 100.00% 5 / 5 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Relations\Relation] isComposite 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] applyKeyTuple 100.00% 4 / 4 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Relations\Relation] parentKeyValues 100.00% 4 / 4 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Relations\Relation] tupleValues 100.00% 4 / 4 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Relations\Relation] serializeKey 100.00% 3 / 3 100.00% 1 / 1 2
24final class BelongsTo extends Relation
25{
26    /**
27     * Constrain the related query to the parent's FK value.
28     *
29     * @return void
30     */
31    protected function addConstraints(): void
32    {
33        if ($this->isComposite()) {
34            $values = $this->parentKeyValues($this->getForeignKeys());
35
36            if (!in_array(null, $values, true)) {
37                // The tuple lands inside a whereNested GROUP â€” the relation
38                // constraint is ONE unit. Flat, a caller's later
39                // `->orWhere(...)` would OR against the tuple's PARTS
40                // ((fk1 = ? AND fk2 = ?) OR x â€” matching the wrong rows);
41                // grouped, the parts AND within the parens and the caller's
42                // OR stays at the constraint's edges.
43                //
44                // local side = the RELATED table's owner columns, foreign
45                // side = THIS table's FK columns.
46                $this->query = $this->query->whereNested(fn (WhereBuilder $nested): WhereBuilder => self::applyKeyTuple(
47                    $nested,
48                    $this->getLocalKeys(),
49                    $this->getForeignKeys(),
50                    $values,
51                ));
52            } else {
53                // Null FK component â†’ no results, without compiling a
54                // meaningless query.
55                $this->query = $this->query->whereRaw('1 = 0', []);
56            }
57
58            return;
59        }
60
61        $fkValue = $this->parent->attribute($this->getForeignKey());
62
63        if ($fkValue !== null) {
64            $this->query = $this->query->where($this->getLocalKey(), WhereOperator::Eq, $fkValue);
65        } else {
66            // Null FK â†’ no results, without compiling a meaningless query.
67            $this->query = $this->query->whereRaw('1 = 0', []);
68        }
69    }
70
71    /**
72     * Whether the parent's FK currently resolves â€” every component
73     * non-null.
74     *
75     * @return bool
76     */
77    private function hasResolvableKey(): bool
78    {
79        if ($this->isComposite()) {
80            return !in_array(null, $this->parentKeyValues($this->getForeignKeys()), true);
81        }
82
83        return $this->parent->attribute($this->getForeignKey()) !== null;
84    }
85
86    /**
87     * The constrained query â€” a no-match query when the FK is null.
88     *
89     * @return ModelQueryBuilder<TRelated>
90     */
91    #[\Override]
92    protected function readQuery(): ModelQueryBuilder
93    {
94        if (!$this->hasResolvableKey()) {
95            return $this->getQuery()->whereRaw('1 = 0', []);
96        }
97
98        return $this->getQuery();
99    }
100
101    /**
102     * Run the constrained query â€” a single model or none.
103     *
104     * @return Collection<int, TRelated>
105     */
106    #[\Override]
107    protected function executeResults(): Collection
108    {
109        if (!$this->hasResolvableKey()) {
110            return Collection::make([]);
111        }
112
113        $first = $this->query->first();
114
115        return Collection::make($first === null ? [] : [$first]);
116    }
117
118    /**
119     * Run the eager query â€” the FK lives on the PARENT, so the IN clause
120     * targets the related table's owner key ({@see BelongsTo::$localKey}).
121     * A composite key widens to an OR of AND-groups.
122     *
123     * @param  list<KeyValue>  $parentKeys
124     * @return EagerResult<TRelated>
125     */
126    #[\Override]
127    protected function eagerLoadChunk(array $parentKeys): EagerResult
128    {
129        if (!$this->isComposite()) {
130            return EagerResult::fromCollection(
131                $this->related::newQuery()
132                    ->whereIn($this->getLocalKey(), $parentKeys)
133                    ->get(),
134            );
135        }
136
137        $localKeys = $this->getLocalKeys();
138        $foreignKeys = $this->getForeignKeys();
139        $query = $this->related::newQuery();
140
141        // The OR-of-groups lands INSIDE one outer AND-group: the key set
142        // is ONE constraint unit. The related builder auto-applies trait
143        // scopes (e.g. soft-delete `deleted_at IS NULL`) as leading
144        // AND-groups â€” flat top-level ORs would compile to
145        // `(scope) OR (fk = ? AND ...) OR ...` and let a scope-excluded
146        // row back in whenever its key matched. Grouped, the scope ANDs
147        // against the whole set.
148        return EagerResult::fromCollection($query->whereNested(
149            function (WhereBuilder $nested) use ($localKeys, $foreignKeys, $parentKeys): WhereBuilder {
150                $grouped = $nested;
151
152                foreach ($parentKeys as $parentKey) {
153                    if (!is_array($parentKey)) {
154                        throw new \InvalidArgumentException(
155                            'A composite relation key requires column => value key maps for eager loading; '
156                            . 'got ' . get_debug_type($parentKey) . '.'
157                        );
158                    }
159
160                    $grouped = $grouped->orWhereNested(
161                        fn (WhereBuilder $keyGroup): WhereBuilder => self::applyKeyTuple(
162                            $keyGroup,
163                            $localKeys,
164                            $foreignKeys,
165                            $parentKey,
166                        )
167                    );
168                }
169
170                return $grouped;
171            }
172        )->get());
173    }
174
175    /**
176     * The parent column(s) the eager loader collects key values from.
177     *
178     * @return string|list<string>
179     */
180    public function eagerKeyColumn(): string|array
181    {
182        return $this->foreignKey;
183    }
184
185    /**
186     * Distribute eager results onto parents by FK value.
187     *
188     * @param  list<Model>  $parents
189     * @param  Collection<int, TRelated>  $results
190     * @param  string  $name
191     * @param  list<int|string|null|list<int|string|null>>|null  $eagerParentKeys  Unused.
192     * @return void
193     */
194    public function match(array $parents, Collection $results, string $name, ?array $eagerParentKeys = null): void
195    {
196        $byKey = [];
197
198        foreach ($results as $related) {
199            $key = $this->isComposite()
200                ? self::tupleValues($related, $this->getLocalKeys())
201                : $related->attribute($this->getLocalKey());
202            $byKey[self::serializeKey($key)] = $related;
203        }
204
205        foreach ($parents as $parent) {
206            $key = $this->isComposite()
207                ? self::tupleValues($parent, $this->getForeignKeys())
208                : $parent->attribute($this->getForeignKey());
209            $related = $byKey[self::serializeKey($key)] ?? null;
210            $parent->setRelation($name, $related);
211        }
212    }
213}

Inherited from BlueprintAU\Radiant\Relations\Relation

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    }
123    protected function defersConstraints(): bool
124    {
125        return false;
126    }
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    }
158    protected function markComposed(): static
159    {
160        $clone = clone $this;
161        $clone->composed = true;
162
163        return $clone;
164    }
171    final protected function servesCache(): bool
172    {
173        return $this->name !== null && !$this->composed && $this->parent->relationLoaded($this->name);
174    }
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    }
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    }
222    protected function relatedClass(): string
223    {
224        return $this->related;
225    }
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    }
342    protected function applyEagerOrdering(ModelQueryBuilder $query): ModelQueryBuilder
343    {
344        // No default ordering.
345        return $query;
346    }
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    }
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    }
404    final public function getQuery(): ModelQueryBuilder
405    {
406        return $this->query;
407    }
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    }
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    }
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    }
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    }
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    }
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    }
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    }
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    }
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    }
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    }
592    final public function getRelated(): string
593    {
594        return $this->related;
595    }
605    public function relatedClasses(): array
606    {
607        return [$this->related];
608    }
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    }
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    }
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    }
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    }
675    final public function isComposite(): bool
676    {
677        return is_array($this->foreignKey);
678    }
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    }
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    }
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    }
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    }