Lines 98.75% 318 / 322
Methods 93.10% 54 / 58
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 13 / 13 100.00% 1 / 1 3
 resolvePivotTable 100.00% 8 / 8 100.00% 1 / 1 4
 singlePrimaryKeyOf 100.00% 7 / 7 100.00% 1 / 1 3
 getPivotTable 100.00% 1 / 1 100.00% 1 / 1 1
 getForeignPivotKey 0.00% 0 / 1 0.00% 0 / 1 2
 getRelatedPivotKey 0.00% 0 / 1 0.00% 0 / 1 2
 withPivot 100.00% 5 / 5 100.00% 1 / 1 2
 withTimestamps 100.00% 1 / 1 100.00% 1 / 1 1
 addConstraints 100.00% 16 / 16 100.00% 1 / 1 2
 qualify 100.00% 1 / 1 100.00% 1 / 1 1
 readQuery 100.00% 7 / 7 100.00% 1 / 1 3
 executeResults 100.00% 1 / 1 100.00% 1 / 1 1
 eagerLoad 100.00% 9 / 9 100.00% 1 / 1 3
 eagerLoadChunk 96.29% 26 / 27 0.00% 0 / 1 3
 match 100.00% 15 / 15 100.00% 1 / 1 5
 sqlConnection 100.00% 3 / 3 100.00% 1 / 1 1
 stampRow 100.00% 1 / 1 100.00% 1 / 1 1
 attach 100.00% 12 / 12 100.00% 1 / 1 3
 pivotQuery 100.00% 1 / 1 100.00% 1 / 1 1
 detach 100.00% 6 / 6 100.00% 1 / 1 3
 sync 100.00% 36 / 36 100.00% 1 / 1 12
 syncWithoutDetaching 100.00% 1 / 1 100.00% 1 / 1 1
 toggle 100.00% 18 / 18 100.00% 1 / 1 3
 currentPivotRows 100.00% 7 / 7 100.00% 1 / 1 2
 normalizeIds 100.00% 17 / 17 100.00% 1 / 1 9
 [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] 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
46class BelongsToMany extends Relation
47{
48    /**
49     * The pivot table name.
50     *
51     * @var string
52     */
53    protected readonly string $pivotTable;
54
55    /**
56     * The pivot column pointing at the parent.
57     *
58     * @var string
59     */
60    protected readonly string $foreignPivotKey;
61
62    /**
63     * The pivot column pointing at the related model.
64     *
65     * @var string
66     */
67    protected readonly string $relatedPivotKey;
68
69    /**
70     * The parent-side key column (the parent's PK by default).
71     *
72     * @var string
73     */
74    protected readonly string $parentKey;
75
76    /**
77     * The related-side key column (the related model's PK by default).
78     *
79     * @var string
80     */
81    protected readonly string $relatedKey;
82
83    /**
84     * The pivot columns selected onto the related models.
85     *
86     * @var list<string>
87     */
88    protected array $pivotColumns = [];
89
90    /**
91     * Create a many-to-many relation.
92     *
93     * @param  Model  $parent
94     * @param  class-string<TRelated>  $related
95     * @param  string|class-string<Model>|null  $table
96     * @param  string|null  $foreignPivotKey
97     * @param  string|null  $relatedPivotKey
98     * @param  string|null  $parentKey
99     * @param  string|null  $relatedKey
100     * @throws \InvalidArgumentException
101     */
102    public function __construct(
103        Model $parent,
104        string $related,
105        ?string $table = null,
106        ?string $foreignPivotKey = null,
107        ?string $relatedPivotKey = null,
108        ?string $parentKey = null,
109        ?string $relatedKey = null,
110    ) {
111        $pivotTable = self::resolvePivotTable($table, 'pivot');
112
113        $this->pivotTable = $pivotTable ?? $parent::table() . '_' . $related::table();
114        $this->foreignPivotKey = $foreignPivotKey ?? $parent::table() . '_id';
115        $this->relatedPivotKey = $relatedPivotKey ?? $related::table() . '_id';
116        $this->parentKey = $parentKey ?? self::singlePrimaryKeyOf($parent::class, 'parent');
117        $this->relatedKey = $relatedKey ?? self::singlePrimaryKeyOf($related, 'related');
118
119        if ($this->pivotTable === $parent::table() || $this->pivotTable === $related::table()) {
120            throw new \InvalidArgumentException(
121                'The pivot table [' . $this->pivotTable . '] collides with the parent ['
122                . $parent::table() . '] or related [' . $related::table() . '] table â€” '
123                . 'joining the pivot to itself is ambiguous. Pick another pivot table name.'
124            );
125        }
126
127        parent::__construct($parent, $related, $this->relatedPivotKey, $this->relatedKey);
128    }
129
130    /**
131     * Resolve a caller-supplied pivot table name.
132     *
133     * @param  string|null  $table
134     * @param  string  $role  The caller's name for the table, used in error messages.
135     * @return string|null Null passes the derivation duty back to the caller.
136     * @throws \InvalidArgumentException
137     */
138    final protected static function resolvePivotTable(?string $table, string $role): ?string
139    {
140        if ($table === null || !str_contains($table, '\\')) {
141            return $table;
142        }
143
144        if (!is_a($table, Model::class, true)) {
145            throw new \InvalidArgumentException(
146                "The {$role} table [{$table}] resolves to no model class â€” pass a plain "
147                . 'table name or a model class-string.'
148            );
149        }
150
151        return $table::table();
152    }
153
154    /**
155     * A model's SINGLE primary-key column â€” pivot keys are scalar-only.
156     *
157     * @param  class-string<Model>  $class
158     * @param  string  $side
159     * @return string
160     * @throws \InvalidArgumentException
161     */
162    private static function singlePrimaryKeyOf(string $class, string $side): string
163    {
164        $keys = MetadataFactory::for($class)->primaryKeys;
165
166        if (count($keys) !== 1 || $keys[0]->name === null) {
167            throw new \InvalidArgumentException(
168                "A belongsToMany relation requires a single named primary key on the {$side} "
169                . "model [{$class}]; pivot keys are scalar-only."
170            );
171        }
172
173        return $keys[0]->name;
174    }
175
176    /**
177     * The pivot table name.
178     *
179     * @return string
180     */
181    final public function getPivotTable(): string
182    {
183        return $this->pivotTable;
184    }
185
186    /**
187     * The pivot column pointing at the parent.
188     *
189     * @return string
190     */
191    final public function getForeignPivotKey(): string
192    {
193        return $this->foreignPivotKey;
194    }
195
196    /**
197     * The pivot column pointing at the related model.
198     *
199     * @return string
200     */
201    final public function getRelatedPivotKey(): string
202    {
203        return $this->relatedPivotKey;
204    }
205
206    /**
207     * Select pivot columns onto the related models.
208     *
209     * Each column is selected aliased `radiant_pivot_{column}` and readable
210     * through `pivotValue()` on the related model. Calling it again
211     * replaces the list.
212     *
213     * @param  string  ...$columns
214     * @return static
215     * @throws \InvalidArgumentException
216     */
217    final public function withPivot(string ...$columns): static
218    {
219        foreach ($columns as $column) {
220            Model::assertNotReservedPrefix($column, 'pivot column');
221        }
222
223        $clone = clone $this;
224        $clone->pivotColumns = array_values($columns);
225
226        return $clone->markComposed();
227    }
228
229    /**
230     * Carry the pivot's created_at/updated_at pair â€” sugar for
231     * `withPivot('created_at', 'updated_at')`.
232     *
233     * @return static
234     */
235    final public function withTimestamps(): static
236    {
237        return $this->withPivot('created_at', 'updated_at');
238    }
239
240    /**
241     * Constrain the query: join the pivot, filter by the parent's key.
242     *
243     * @return void
244     */
245    #[\Override]
246    protected function addConstraints(): void
247    {
248        $relatedTable = $this->related::table();
249
250        $this->query = $this->query->join(
251            $this->pivotTable,
252            self::qualify($relatedTable, $this->relatedKey),
253            '=',
254            self::qualify($this->pivotTable, $this->relatedPivotKey),
255        );
256
257        $parentKey = $this->parent->attribute($this->parentKey);
258
259        if ($parentKey === null) {
260            // Null parent key â†’ no results, without compiling a meaningless
261            // query (BelongsTo's convention).
262            $this->query = $this->query->whereRaw('1 = 0', []);
263            return;
264        }
265
266        $this->query = $this->query->where(
267            self::qualify($this->pivotTable, $this->foreignPivotKey),
268            '=',
269            $parentKey,
270        );
271    }
272
273    /**
274     * Qualify a column to its table â€” `table.column`.
275     *
276     * @param  string  $table
277     * @param  string  $column
278     * @return string
279     */
280    final protected static function qualify(string $table, string $column): string
281    {
282        return $table . '.' . $column;
283    }
284
285    /**
286     * The constrained query â€” with the pivot select when pivot columns
287     * are declared.
288     *
289     * @return ModelQueryBuilder<TRelated>
290     */
291    #[\Override]
292    protected function readQuery(): ModelQueryBuilder
293    {
294        if ($this->pivotColumns === []) {
295            return $this->getQuery();
296        }
297
298        $selects = [];
299
300        foreach ($this->pivotColumns as $column) {
301            $selects[] = self::qualify($this->pivotTable, $column) . ' as radiant_pivot_' . $column;
302        }
303
304        $selects[] = $this->related::table() . '.*';
305
306        return $this->getQuery()->select(...$selects);
307    }
308
309    /**
310     * Run the constrained query.
311     *
312     * @return Collection<int, TRelated>
313     */
314    #[\Override]
315    protected function executeResults(): Collection
316    {
317        return $this->readQuery()->get();
318    }
319
320    /**
321     * Run the eager query: join the pivot for ALL parents at once,
322     * selecting the parent key alongside the related columns.
323     *
324     * @param  list<KeyValue>  $parentKeys
325     * @return EagerResult<TRelated>
326     */
327    #[\Override]
328    public function eagerLoad(array $parentKeys): EagerResult
329    {
330        if ($parentKeys === []) {
331            return EagerResult::fromModels([]);
332        }
333
334        $models = [];
335        $parentKeysOut = [];
336
337        foreach (array_chunk($parentKeys, self::EAGER_KEY_CHUNK) as $chunk) {
338            $chunkResult = $this->eagerLoadChunk($chunk);
339            array_push($models, ...$chunkResult->models->all());
340            array_push($parentKeysOut, ...($chunkResult->parentKeys ?? []));
341        }
342
343        return new EagerResult(EagerResult::listToCollection($models), $parentKeysOut);
344    }
345
346    /**
347     * Run one eager-load query for a CHUNK of parent keys.
348     *
349     * @param  list<KeyValue>  $parentKeys
350     * @return EagerResult<TRelated>
351     */
352    #[\Override]
353    protected function eagerLoadChunk(array $parentKeys): EagerResult
354    {
355        $relatedTable = $this->related::table();
356        $parentFk = 'radiant_pivot_parent_' . $this->pivotTable;
357
358        $builder = $this->related::newQuery()
359            ->join(
360                $this->pivotTable,
361                self::qualify($relatedTable, $this->relatedKey),
362                '=',
363                self::qualify($this->pivotTable, $this->relatedPivotKey),
364            )
365            ->whereIn(
366                self::qualify($this->pivotTable, $this->foreignPivotKey),
367                $parentKeys,
368            );
369
370        // The parent-key select carries the synthetic alias; the pivot
371        // columns ride alongside (aliased per column).
372        $selects = [
373            self::qualify($this->pivotTable, $this->foreignPivotKey) . ' as ' . $parentFk,
374        ];
375
376        foreach ($this->pivotColumns as $column) {
377            $selects[] = self::qualify($this->pivotTable, $column) . ' as radiant_pivot_' . $column;
378        }
379
380        $selects[] = "{$relatedTable}.*";
381
382        $builder = $builder->select(...$selects);
383
384        $rows = $builder->getRaw();
385
386        $keys = [];
387        $models = [];
388
389        foreach ($rows->all() as $row) {
390            $keys[] = $row->{$parentFk} ?? null;
391            $models[] = $this->related::fromRow($row);
392        }
393
394        return new EagerResult(EagerResult::listToCollection($models), $keys);
395    }
396
397    /**
398     * Distribute eager results onto parents, grouped by the parent key
399     * carried on the EagerResult.
400     *
401     * @param  list<Model>  $parents
402     * @param  Collection<int, TRelated>  $results
403     * @param  string  $name
404     * @param  list<int|string|null|list<int|string|null>>|null  $eagerParentKeys
405     * @return void
406     */
407    #[\Override]
408    final public function match(array $parents, Collection $results, string $name, ?array $eagerParentKeys = null): void
409    {
410        if ($eagerParentKeys === null) {
411            throw new \LogicException(
412                static::class . '::match() requires the EagerResult parent keys; '
413                . 'call it with the array returned by eagerLoad(), not the models alone.'
414            );
415        }
416
417        $grouped = [];
418
419        foreach ($results as $i => $model) {
420            $parentKey = $eagerParentKeys[$i] ?? null;
421
422            if ($parentKey === null) {
423                continue;
424            }
425
426            $grouped[self::serializeKey($parentKey)][] = $model;
427        }
428
429        foreach ($parents as $parent) {
430            $key = $parent->attribute($this->parentKey);
431            // The bag's items came off $results (TRelated) â€” every one IS
432            // a Model; setRelation accepts Collection<int, Model> and the item
433            // template is not covariant.
434            /** @var Collection<int, Model> $bag */
435            $bag = Collection::make($grouped[self::serializeKey($key)] ?? []);
436            $parent->setRelation($name, $bag);
437        }
438    }
439
440    // ---- Pivot write API (SQL-only) ----
441
442    /**
443     * The parent's SQL connection â€” the pivot writes run on the plain
444     * builder.
445     *
446     * @return SqlConnection
447     * @throws UnsupportedFeatureException
448     */
449    protected function sqlConnection(): SqlConnection
450    {
451        $connection = $this->parent::connection();
452
453        SqlConnection::assertSql($connection);
454
455        return $connection;
456    }
457
458    /**
459     * Stamp a pivot row about to be INSERTed â€” the subclass hook for
460     * relation-specific columns.
461     *
462     * @param  array<string, mixed>  $row
463     * @return array<string, mixed>
464     */
465    protected function stampRow(array $row): array
466    {
467        return $row;
468    }
469
470    /**
471     * Attach related models to the parent â€” INSERT into the pivot.
472     *
473     * @param  int|string|list<int|string>|array<string, mixed>  $ids
474     * @param  array<string, mixed>  $pivotAttributes
475     * @return void
476     * @throws \RuntimeException
477     */
478    public function attach(int|string|array $ids, array $pivotAttributes = []): void
479    {
480        $connection = $this->sqlConnection();
481
482        $rows = [];
483
484        foreach ($this->normalizeIds($ids) as $id => $attributes) {
485            $rows[] = $this->stampRow([
486                $this->foreignPivotKey => $this->parent->attribute($this->parentKey),
487                $this->relatedPivotKey => $id,
488                ...$pivotAttributes,
489                ...$attributes,
490            ]);
491        }
492
493        if ($rows === []) {
494            return;
495        }
496
497        $connection->table($this->pivotTable)->insert($rows);
498    }
499
500    /**
501     * A query builder on the pivot table â€” the shared entry point for
502     * every pivot READ/DELETE/UPDATE path.
503     *
504     * @param  SqlConnection  $connection
505     * @return QueryBuilder
506     */
507    protected function pivotQuery(SqlConnection $connection): QueryBuilder
508    {
509        return $connection->table($this->pivotTable);
510    }
511
512    /**
513     * Detach related models from the parent â€” DELETE from the pivot.
514     *
515     * @param  int|string|list<int|string>|null  $ids  Null detaches all.
516     * @return int
517     * @throws \RuntimeException
518     */
519    final public function detach(int|string|array|null $ids = null): int
520    {
521        $connection = $this->sqlConnection();
522
523        $query = $this->pivotQuery($connection)
524            ->where($this->foreignPivotKey, WhereOperator::Eq, $this->parent->attribute($this->parentKey));
525
526        if ($ids !== null) {
527            $query = $query->whereIn($this->relatedPivotKey, is_array($ids) ? $ids : [$ids]);
528        }
529
530        return $query->delete();
531    }
532
533    /**
534     * Sync the pivot to exactly the given ids â€” attach the missing,
535     * detach the extra, update the shared.
536     *
537     * Runs inside a transaction.
538     *
539     * @param  list<int|string>|array<string, mixed>  $ids
540     * @param  bool  $detaching
541     * @return array{attached: list<int|string>, detached: list<int|string>, updated: list<int|string>}
542     * @throws \RuntimeException
543     */
544    final public function sync(array $ids, bool $detaching = true): array
545    {
546        $connection = $this->sqlConnection();
547
548        $desired = $this->normalizeIds($ids);
549        $current = $this->currentPivotRows($connection);
550
551        $attached = [];
552        $detached = [];
553        $updated = [];
554
555        $isList = !in_array(true, array_map(is_array(...), $ids), true);
556
557        $sharedAttributes = $isList ? ($desired === [] ? [] : reset($desired)) : [];
558        $perIdAttributes = $isList ? [] : $desired;
559
560        $connection->transaction(function () use ($connection, $desired, $current, $sharedAttributes, $perIdAttributes, $isList, $detaching, &$attached, &$detached, &$updated): void {
561            $table = $this->pivotQuery($connection);
562
563            foreach ($desired as $id => $attributes) {
564                $attributes = $isList ? $sharedAttributes : ($perIdAttributes[$id] ?? []);
565
566                if (!isset($current[$id])) {
567                    $table->insert([$this->stampRow([
568                        $this->foreignPivotKey => $this->parent->attribute($this->parentKey),
569                        $this->relatedPivotKey => $id,
570                        ...$attributes,
571                    ])]);
572                    $attached[] = $id;
573                } elseif ($attributes !== [] && $current[$id] !== $attributes) {
574                    $table
575                        ->where($this->foreignPivotKey, WhereOperator::Eq, $this->parent->attribute($this->parentKey))
576                        ->where($this->relatedPivotKey, WhereOperator::Eq, $id)
577                        ->update($attributes);
578                    $updated[] = $id;
579                }
580            }
581
582            if ($detaching) {
583                foreach (array_keys($current) as $id) {
584                    if (!isset($desired[$id])) {
585                        $table
586                            ->where($this->foreignPivotKey, WhereOperator::Eq, $this->parent->attribute($this->parentKey))
587                            ->where($this->relatedPivotKey, WhereOperator::Eq, $id)
588                            ->delete();
589                        $detached[] = $id;
590                    }
591                }
592            }
593        });
594
595        return ['attached' => $attached, 'detached' => $detached, 'updated' => $updated];
596    }
597
598    /**
599     * Sync without detaching the ids not in the list â€” attach the missing
600     * only.
601     *
602     * @param  list<int|string>|array<string, mixed>  $ids
603     * @return array{attached: list<int|string>, detached: list<int|string>, updated: list<int|string>}
604     */
605    final public function syncWithoutDetaching(array $ids): array
606    {
607        return $this->sync($ids, detaching: false);
608    }
609
610    /**
611     * Toggle the given ids â€” attach the ones not attached, detach the
612     * ones attached.
613     *
614     * @param  list<int|string>  $ids
615     * @return array{attached: list<int|string>, detached: list<int|string>}
616     * @throws \RuntimeException
617     */
618    final public function toggle(array $ids): array
619    {
620        $connection = $this->sqlConnection();
621        $current = $this->currentPivotRows($connection);
622
623        $attached = [];
624        $detached = [];
625
626        $table = $this->pivotQuery($connection);
627
628        foreach ($ids as $id) {
629            if (isset($current[$id])) {
630                $table
631                    ->where($this->foreignPivotKey, WhereOperator::Eq, $this->parent->attribute($this->parentKey))
632                    ->where($this->relatedPivotKey, WhereOperator::Eq, $id)
633                    ->delete();
634                $detached[] = $id;
635            } else {
636                $table->insert([$this->stampRow([
637                    $this->foreignPivotKey => $this->parent->attribute($this->parentKey),
638                    $this->relatedPivotKey => $id,
639                ])]);
640                $attached[] = $id;
641            }
642        }
643
644        return ['attached' => $attached, 'detached' => $detached];
645    }
646
647    /**
648     * The parent's current pivot rows, keyed by related id.
649     *
650     * @param  SqlConnection  $connection
651     * @return array<int|string, array<string, mixed>>
652     */
653    private function currentPivotRows(SqlConnection $connection): array
654    {
655        $rows = $this->pivotQuery($connection)
656            ->where($this->foreignPivotKey, WhereOperator::Eq, $this->parent->attribute($this->parentKey))
657            ->get();
658
659        $current = [];
660
661        foreach ($rows as $row) {
662            $current[$row->{$this->relatedPivotKey}] = (array) $row;
663        }
664
665        return $current;
666    }
667
668    /**
669     * Normalize the attach/sync id input to a map of id => attributes.
670     *
671     * The list/map distinction is value-based, not key-based: any array
672     * value marks the input as a map.
673     *
674     * @param  int|string|list<int|string>|array<string, mixed>  $ids
675     * @return array<int|string, array<string, mixed>>
676     */
677    protected function normalizeIds(int|string|array $ids): array
678    {
679        if (is_int($ids) || is_string($ids)) {
680            return [$ids => []];
681        }
682
683        $isMap = false;
684
685        foreach ($ids as $value) {
686            if (is_array($value)) {
687                $isMap = true;
688                break;
689            }
690        }
691
692        $normalized = [];
693
694        if (!$isMap) {
695            foreach ($ids as $id) {
696                $normalized[$id] = [];
697            }
698
699            return $normalized;
700        }
701
702        foreach ($ids as $key => $value) {
703            if (is_array($value)) {
704                $normalized[$key] = $value;
705            } else {
706                $normalized[$value] = []; // bare id in a mixed map
707            }
708        }
709
710        return $normalized;
711    }
712}

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    }
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    }