Lines 96.53% 279 / 289
Methods 93.33% 42 / 45
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 19 / 19 100.00% 1 / 1 7
 addConstraints 100.00% 41 / 41 100.00% 1 / 1 7
 qualify 100.00% 1 / 1 100.00% 1 / 1 1
 intermediateKeys 46.66% 7 / 15 0.00% 0 / 1 11.46
 secondKeyList 100.00% 1 / 1 100.00% 1 / 1 2
 getSecondKeys 80.00% 4 / 5 0.00% 0 / 1 2.03
 executeResults 100.00% 1 / 1 100.00% 1 / 1 1
 eagerLoad 100.00% 9 / 9 100.00% 1 / 1 3
 eagerLoadChunk 100.00% 70 / 70 100.00% 1 / 1 15
 throughParentAlias 100.00% 1 / 1 100.00% 1 / 1 1
 match 100.00% 18 / 18 100.00% 1 / 1 7
 [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] readQuery 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\Relation] relatedClass 100.00% 1 / 1 100.00% 1 / 1 1
 [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] eagerKeyColumn 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
27class HasManyThrough extends Relation
28{
29    /**
30     * The intermediate model bridging parent and related.
31     *
32     * @var class-string<Model>
33     */
34    protected readonly string $through;
35
36    /**
37     * FK on the intermediate table pointing back at the parent.
38     *
39     * @var string|list<string>
40     */
41    protected readonly string|array $firstKey;
42
43    /**
44     * FK on the related table pointing at the intermediate.
45     *
46     * @var string|list<string>
47     */
48    protected readonly string|array $secondKey;
49
50    /**
51     * Create a through relation.
52     *
53     * @param  Model  $parent
54     * @param  class-string<TRelated>  $related
55     * @param  class-string<Model>  $through
56     * @param  string|list<string>  $firstKey
57     * @param  string|list<string>  $secondKey
58     * @param  string|list<string>  $localKey
59     * @throws \InvalidArgumentException
60     */
61    public function __construct(
62        Model $parent,
63        string $related,
64        string $through,
65        string|array $firstKey,
66        string|array $secondKey,
67        string|array $localKey,
68    ) {
69        // The base ctor validates foreignKey↔localKey agreement; the two
70        // hop keys must additionally agree with each other (both scalar or
71        // both composite, matching arity), or the join is unbuildable.
72        if (is_array($firstKey) !== is_array($secondKey)) {
73            throw new \InvalidArgumentException(
74                "A through relation's first and second keys must be BOTH single columns or "
75                . 'BOTH composite column lists; got one of each.'
76            );
77        }
78
79        if (is_array($firstKey) && is_array($secondKey)) {
80            if ($firstKey === [] || $secondKey === []) {
81                throw new \InvalidArgumentException(
82                    "A through relation's composite keys require at least one column; got an empty list."
83                );
84            }
85
86            if (count($firstKey) !== count($secondKey)) {
87                throw new \InvalidArgumentException(
88                    "A through relation's composite first and second keys must have matching "
89                    . 'arity; got ' . count($firstKey) . ' and ' . count($secondKey) . '.'
90                );
91            }
92        }
93
94        $this->through = $through;
95        $this->firstKey = $firstKey;
96        $this->secondKey = $secondKey;
97
98        parent::__construct($parent, $related, $firstKey, $localKey);
99    }
100
101    /**
102     * Constrain the query: join the intermediate table, filter by the
103     * parent's key.
104     *
105     * @return void
106     */
107    protected function addConstraints(): void
108    {
109        $throughTable = $this->through::table();
110        $relatedTable = $this->related::table();
111
112        // The through relation stores the PARENT-side link in the base
113        // relation's key slots: foreignKey = the through table's FK back to
114        // the parent (firstKey), localKey = the parent's own key. The
115        // related→through hop rides $secondKey.
116        $firstKeys = $this->isComposite() ? $this->getForeignKeys() : [$this->getForeignKey()];
117        $secondKeys = $this->secondKeyList();
118        $intermediateKeys = $this->intermediateKeys($secondKeys);
119
120        $this->query = $this->query->join(
121            $throughTable,
122            self::qualify($relatedTable, $secondKeys[0]),
123            '=',
124            self::qualify($throughTable, $intermediateKeys[0]),
125        );
126
127        foreach (array_slice($secondKeys, 1) as $i => $secondKey) {
128            $this->query = $this->query->on(
129                self::qualify($relatedTable, $secondKey),
130                '=',
131                self::qualify($throughTable, $intermediateKeys[$i + 1]),
132            );
133        }
134
135        if (!$this->isComposite()) {
136            $parentKey = $this->parent->attribute($this->getLocalKey());
137
138            if ($parentKey === null) {
139                // Null parent key â†’ no results, without compiling a
140                // meaningless query (BelongsTo's convention).
141                $this->query = $this->query->whereRaw('1 = 0', []);
142                return;
143            }
144
145            $this->query = $this->query->where(
146                self::qualify($throughTable, $firstKeys[0]),
147                '=',
148                $parentKey,
149            );
150
151            return;
152        }
153
154        // The tuple lands inside a whereNested GROUP â€” the parent filter is
155        // ONE constraint unit: a caller's later `->orWhere(...)` must OR at
156        // the constraint's EDGES, never against the tuple's PARTS. The
157        // values come from the parent's LOCAL key columns (positionally
158        // paired with the first keys) â€” the first-key names are the THROUGH
159        // table's FK columns and do not exist on the parent.
160        $localKeys = $this->getLocalKeys();
161
162        $this->query = $this->query->whereNested(
163            function (WhereBuilder $nested) use ($throughTable, $firstKeys, $localKeys): WhereBuilder {
164                foreach ($firstKeys as $i => $firstKey) {
165                    $value = $this->parent->attribute($localKeys[$i]);
166                    $nested = $nested->where(
167                        self::qualify($throughTable, $firstKey),
168                        $value === null ? WhereOperator::Null : WhereOperator::Eq,
169                        $value,
170                    );
171                }
172
173                return $nested;
174            }
175        );
176    }
177
178    /**
179     * Qualify a column to its table â€” `table.column`.
180     *
181     * @param  string  $table
182     * @param  string  $column
183     * @return string
184     */
185    final protected static function qualify(string $table, string $column): string
186    {
187        return $table . '.' . $column;
188    }
189
190    /**
191     * The intermediate table's PK column(s) that the related table's FK
192     * points at.
193     *
194     * @param  list<string>  $secondKeys
195     * @return list<string>
196     * @throws \InvalidArgumentException
197     */
198    protected function intermediateKeys(array $secondKeys): array
199    {
200        $metadata = MetadataFactory::for($this->through);
201        $names = [];
202
203        foreach ($metadata->primaryKeys as $primaryKey) {
204            if ($primaryKey->name === null) {
205                throw new \InvalidArgumentException(
206                    "Through-relation intermediate [{$this->through}] must have a named primary key."
207                );
208            }
209
210            $names[] = $primaryKey->name;
211        }
212
213        if ($names === [] || $secondKeys === [] || count($names) !== count($secondKeys)) {
214            throw new \InvalidArgumentException(
215                "Through-relation intermediate [{$this->through}] has a primary key of "
216                . count($names) . ' column(s); the second key declares ' . count($secondKeys)
217                . ' â€” the join must pair every key column.'
218            );
219        }
220
221        return $names;
222    }
223
224    /**
225     * The related→through FK columns as a plain list.
226     *
227     * @return list<string>
228     */
229    private function secondKeyList(): array
230    {
231        return is_array($this->secondKey) ? $this->secondKey : [$this->secondKey];
232    }
233
234    /**
235     * The composite form of the related→through FK columns ($secondKey).
236     *
237     * @return list<string>
238     * @throws \LogicException
239     */
240    final public function getSecondKeys(): array
241    {
242        return is_array($this->secondKey)
243            ? $this->secondKey
244            : throw new \LogicException(
245                'This through relation uses a single second key.'
246            );
247    }
248
249    /**
250     * Run the constrained query.
251     *
252     * @return Collection<int, TRelated>
253     */
254    #[\Override]
255    protected function executeResults(): Collection
256    {
257        return $this->query->get();
258    }
259
260    /**
261     * Run the eager query: join the intermediate table for ALL parents at
262     * once, selecting the parent key alongside the related columns.
263     *
264     * @param  list<KeyValue>  $parentKeys
265     * @return EagerResult<TRelated>
266     */
267    #[\Override]
268    public function eagerLoad(array $parentKeys): EagerResult
269    {
270        if ($parentKeys === []) {
271            return EagerResult::fromModels([]);
272        }
273
274        // Chunked: SQL size grows O(parents Ã— arity); driver caps (SQLite
275        // 999 placeholders, MySQL max_allowed_packet) turn an oversized
276        // single query into a hard failure. One query per chunk, merged.
277        $models = [];
278        $parentKeysOut = [];
279
280        foreach (array_chunk($parentKeys, self::EAGER_KEY_CHUNK) as $chunk) {
281            $chunkResult = $this->eagerLoadChunk($chunk);
282            array_push($models, ...$chunkResult->models->all());
283            array_push($parentKeysOut, ...($chunkResult->parentKeys ?? []));
284        }
285
286        return new EagerResult(EagerResult::listToCollection($models), $parentKeysOut);
287    }
288
289    /**
290     * Run one eager-load query for a CHUNK of parent keys.
291     *
292     * @param  list<KeyValue>  $parentKeys
293     * @return EagerResult<TRelated>
294     */
295    #[\Override]
296    protected function eagerLoadChunk(array $parentKeys): EagerResult
297    {
298        $throughTable = $this->through::table();
299        $relatedTable = $this->related::table();
300        $parentFk = self::throughParentAlias($this->related);
301
302        $firstKeys = $this->isComposite() ? $this->getForeignKeys() : [$this->getForeignKey()];
303        $localKeys = $this->isComposite() ? $this->getLocalKeys() : [$this->getLocalKey()];
304        $secondKeys = $this->secondKeyList();
305        $intermediateKeys = $this->intermediateKeys($secondKeys);
306
307        $builder = $this->related::newQuery()
308            ->join(
309                $throughTable,
310                self::qualify($relatedTable, $secondKeys[0]),
311                '=',
312                self::qualify($throughTable, $intermediateKeys[0]),
313            );
314
315        // Subclass ordering hook (HasOneThrough): applies the related-PK
316        // order so first-wins matching stays deterministic, exactly like
317        // the lazy path. No-op for the base many-row relation.
318        $builder = $this->applyEagerOrdering($builder);
319
320        foreach (array_slice($secondKeys, 1) as $i => $secondKey) {
321            $builder = $builder->on(
322                self::qualify($relatedTable, $secondKey),
323                '=',
324                self::qualify($throughTable, $intermediateKeys[$i + 1]),
325            );
326        }
327
328        if (!$this->isComposite()) {
329            $builder = $builder->whereIn(
330                self::qualify($throughTable, $firstKeys[0]),
331                $parentKeys,
332            );
333        } else {
334            // The OR-of-groups lands INSIDE one outer AND-group: the key
335            // set is ONE constraint unit. The related builder auto-applies
336            // trait scopes (e.g. soft-delete `deleted_at IS NULL`) as
337            // leading AND-groups â€” flat top-level ORs would compile to
338            // `(scope) OR (fk = ? AND ...) OR ...` and let a scope-excluded
339            // row back in whenever its key matched. Grouped, the scope
340            // ANDs against the whole set.
341            $builder = $builder->whereNested(
342                function (WhereBuilder $nested) use ($throughTable, $firstKeys, $localKeys, $parentKeys): WhereBuilder {
343                    $grouped = $nested;
344
345                    foreach ($parentKeys as $parentKey) {
346                        if (!is_array($parentKey)) {
347                            throw new \InvalidArgumentException(
348                                'A composite through-relation key requires column => value key maps '
349                                . 'for eager loading; got ' . get_debug_type($parentKey) . '.'
350                            );
351                        }
352
353                        $grouped = $grouped->orWhereNested(
354                            function (WhereBuilder $keyGroup) use ($throughTable, $firstKeys, $localKeys, $parentKey): WhereBuilder {
355                                foreach ($firstKeys as $i => $firstKey) {
356                                    // The key map is keyed by the parent's
357                                    // LOCAL key columns (what the loader
358                                    // collects); the constraint targets the
359                                    // THROUGH table's first-key columns.
360                                    $value = $parentKey[$localKeys[$i]] ?? null;
361                                    $keyGroup = $keyGroup->where(
362                                        self::qualify($throughTable, $firstKey),
363                                        $value === null ? WhereOperator::Null : WhereOperator::Eq,
364                                        $value,
365                                    );
366                                }
367
368                                return $keyGroup;
369                            }
370                        );
371                    }
372
373                    return $grouped;
374                }
375            );
376        }
377
378        // Columns are plain (qualified) specs with the standard `as`
379        // alias â€” the Grammar wraps them like any other column list. The
380        // parent-key select carries EVERY first-key column, aliased to
381        // the namespaced synthetic alias per column when composite.
382        $selects = [];
383
384        foreach ($firstKeys as $firstKey) {
385            $selects[] = self::qualify($throughTable, $firstKey)
386                . ' as '
387                . ($firstKeys[0] === $firstKey ? $parentFk : $parentFk . '_' . $firstKey);
388        }
389
390        $selects[] = "{$relatedTable}.*";
391
392        $builder = $builder->select(...$selects);
393
394        $rows = $builder->getRaw();
395
396        $keys = [];
397        $models = [];
398
399        foreach ($rows->all() as $row) {
400            if (!$this->isComposite()) {
401                $keys[] = $row->{$parentFk} ?? null;
402            } else {
403                $tuple = [];
404
405                foreach ($firstKeys as $firstKey) {
406                    $column = $firstKeys[0] === $firstKey ? $parentFk : $parentFk . '_' . $firstKey;
407                    $tuple[] = $row->{$column} ?? null;
408                }
409
410                $keys[] = $tuple;
411            }
412
413            $models[] = $this->related::fromRow($row);
414        }
415
416        return new EagerResult(EagerResult::listToCollection($models), $keys);
417    }
418
419    /**
420     * The synthetic alias carrying the parent key through the join.
421     *
422     * @param  class-string<Model>  $related
423     * @return string
424     */
425    private static function throughParentAlias(string $related): string
426    {
427        return 'radiant_through_parent_' . $related::table();
428    }
429
430    /**
431     * Distribute eager results onto parents, grouped by the parent key
432     * carried on the {@see EagerResult}.
433     *
434     * @param  list<Model>  $parents
435     * @param  Collection<int, TRelated>  $results
436     * @param  string  $name
437     * @param  list<int|string|null|list<int|string|null>>|null  $eagerParentKeys
438     * @return void
439     */
440    public function match(array $parents, Collection $results, string $name, ?array $eagerParentKeys = null): void
441    {
442        if ($eagerParentKeys === null) {
443            // A caller matched WITHOUT the eager-load context â€” the per-row
444            // keys are unavailable. Fail loudly: silently matching by
445            // re-querying (or matching nothing) would hide the contract.
446            throw new \LogicException(
447                static::class . '::match() requires the EagerResult parent keys; '
448                . 'call it with the array returned by eagerLoad(), not the models alone.'
449            );
450        }
451
452        $grouped = [];
453
454        foreach ($results as $i => $model) {
455            $parentKey = $eagerParentKeys[$i] ?? null;
456
457            if ($parentKey === null) {
458                continue;
459            }
460
461            $grouped[self::serializeKey($parentKey)][] = $model;
462        }
463
464        $localKeys = $this->isComposite() ? $this->getLocalKeys() : [$this->getLocalKey()];
465
466        foreach ($parents as $parent) {
467            $key = $this->isComposite()
468                ? self::tupleValues($parent, $localKeys)
469                : $parent->attribute($localKeys[0]);
470            // The bag's items came off $results (TRelated) â€” every one IS
471            // a Model; setRelation accepts Collection<int, Model> and the item
472            // template is not covariant.
473            /** @var Collection<int, Model> $bag */
474            $bag = Collection::make($grouped[self::serializeKey($key)] ?? []);
475            $parent->setRelation($name, $bag);
476        }
477    }
478}

Inherited from BlueprintAU\Radiant\Relations\Relation

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    }
212    protected function readQuery(): ModelQueryBuilder
213    {
214        return $this->getQuery();
215    }
222    protected function relatedClass(): string
223    {
224        return $this->related;
225    }
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    }
685    public function eagerKeyColumn(): string|array
686    {
687        return $this->localKey;
688    }
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    }