Lines 97.43% 380 / 390
Methods 88.88% 56 / 63
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 16 / 16 100.00% 1 / 1 5
 addConstraints 100.00% 6 / 6 100.00% 1 / 1 1
 eagerLoadChunk 96.87% 31 / 32 0.00% 0 / 1 3
 pivotQuery 100.00% 5 / 5 100.00% 1 / 1 1
 stampRow 100.00% 2 / 2 100.00% 1 / 1 1
 attach 91.66% 11 / 12 0.00% 0 / 1 3.01
 pool 92.85% 13 / 14 0.00% 0 / 1 4.01
 isInversePool 100.00% 1 / 1 100.00% 1 / 1 1
 poolAliases 100.00% 14 / 14 100.00% 1 / 1 5
 validatedPoolClass 63.63% 7 / 11 0.00% 0 / 1 6.20
 poolQueryFor 100.00% 25 / 25 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Relations\BelongsToMany] resolvePivotTable 100.00% 8 / 8 100.00% 1 / 1 4
 [BlueprintAU\Radiant\Relations\BelongsToMany] singlePrimaryKeyOf 100.00% 7 / 7 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Relations\BelongsToMany] getPivotTable 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\BelongsToMany] getForeignPivotKey 0.00% 0 / 1 0.00% 0 / 1 2
 [BlueprintAU\Radiant\Relations\BelongsToMany] getRelatedPivotKey 0.00% 0 / 1 0.00% 0 / 1 2
 [BlueprintAU\Radiant\Relations\BelongsToMany] withPivot 100.00% 5 / 5 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Relations\BelongsToMany] withTimestamps 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\BelongsToMany] qualify 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\BelongsToMany] readQuery 100.00% 7 / 7 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Relations\BelongsToMany] executeResults 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\BelongsToMany] eagerLoad 100.00% 9 / 9 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Relations\BelongsToMany] match 100.00% 15 / 15 100.00% 1 / 1 5
 [BlueprintAU\Radiant\Relations\BelongsToMany] sqlConnection 100.00% 3 / 3 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\BelongsToMany] detach 100.00% 6 / 6 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Relations\BelongsToMany] sync 100.00% 36 / 36 100.00% 1 / 1 12
 [BlueprintAU\Radiant\Relations\BelongsToMany] syncWithoutDetaching 100.00% 1 / 1 100.00% 1 / 1 1
 [BlueprintAU\Radiant\Relations\BelongsToMany] toggle 100.00% 18 / 18 100.00% 1 / 1 3
 [BlueprintAU\Radiant\Relations\BelongsToMany] currentPivotRows 100.00% 7 / 7 100.00% 1 / 1 2
 [BlueprintAU\Radiant\Relations\BelongsToMany] 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
28class MorphToMany extends BelongsToMany
29{
30    /**
31     * The type-discriminator column on the pivot table.
32     *
33     * @var string
34     */
35    protected readonly string $morphTypeColumn;
36
37    /**
38     * The morph alias this side filters (and writes) â€” the parent's FQCN
39     * in the direct direction, the related's in the inverse.
40     *
41     * @var string
42     */
43    protected readonly string $morphAlias;
44
45    /**
46     * The pivot column carrying the morph key on THIS side.
47     *
48     * @var string
49     */
50    protected readonly string $morphKeyColumn;
51
52    /**
53     * The optional pool allowlist â€” the classes `pool()` may resolve.
54     *
55     * @var list<class-string<Model>>|null
56     */
57    protected readonly array|null $poolTypes;
58
59    /**
60     * Create a polymorphic many-to-many relation.
61     *
62     * @param  Model  $parent
63     * @param  class-string<TRelated>  $related
64     * @param  string  $morphName
65     * @param  string|class-string<Model>|null  $table
66     * @param  bool  $inverse  True for `morphedByMany`.
67     * @param  list<class-string<TPool>>|null  $poolTypes  The pool allowlist for the inverse side's `pool()` read.
68     * @throws \InvalidArgumentException
69     */
70    public function __construct(
71        Model $parent,
72        string $related,
73        string $morphName,
74        ?string $table = null,
75        bool $inverse = false,
76        array|null $poolTypes = null,
77    ) {
78        $this->morphTypeColumn = $morphName . '_type';
79        $this->morphKeyColumn = $morphName . '_id';
80        $this->morphAlias = $inverse ? $related : $parent::class;
81        $this->poolTypes = $poolTypes === null || $poolTypes === [] ? null : $poolTypes;
82
83        // The direct direction: the pivot's morph columns point at the
84        // parent (Post), the related table's id column at the related
85        // model (Tag). The INVERSE swaps the roles: the morph columns
86        // point at the related (Post â€” the morph parent side), and the
87        // PARENT's (Tag's) table id column is the other side.
88        $foreignPivotKey = $this->morphKeyColumn;
89        $relatedPivotKey = $related::table() . '_id';
90
91        if ($inverse) {
92            $foreignPivotKey = $parent::table() . '_id';
93            $relatedPivotKey = $this->morphKeyColumn;
94        }
95
96        parent::__construct(
97            $parent,
98            $related,
99            self::resolvePivotTable($table, 'pivot') ?? $morphName,
100            $foreignPivotKey,
101            $relatedPivotKey,
102        );
103    }
104
105    /**
106     * Constrain the query: the join + parent key filter PLUS the morph
107     * type filter.
108     *
109     * @return void
110     */
111    #[\Override]
112    protected function addConstraints(): void
113    {
114        parent::addConstraints();
115
116        // The type filter rides AFTER the base constraint â€” the pivot
117        // join is already in place, so the column resolves unambiguously.
118        $this->query = $this->query->where(
119            self::qualify($this->pivotTable, $this->morphTypeColumn),
120            '=',
121            $this->morphAlias,
122        );
123    }
124
125    /**
126     * Run one eager-load query for a CHUNK of parent keys â€” the base join
127     * plus the morph type filter.
128     *
129     * @param  list<int|string>  $parentKeys
130     * @return EagerResult<TRelated>
131     */
132    #[\Override]
133    protected function eagerLoadChunk(array $parentKeys): EagerResult
134    {
135        $relatedTable = $this->related::table();
136        $parentFk = 'radiant_pivot_parent_' . $this->pivotTable;
137
138        $builder = $this->related::newQuery()
139            ->join(
140                $this->pivotTable,
141                self::qualify($relatedTable, $this->relatedKey),
142                '=',
143                self::qualify($this->pivotTable, $this->relatedPivotKey),
144            )
145            ->where(
146                self::qualify($this->pivotTable, $this->morphTypeColumn),
147                '=',
148                $this->morphAlias,
149            )
150            ->whereIn(
151                self::qualify($this->pivotTable, $this->foreignPivotKey),
152                $parentKeys,
153            );
154
155        $selects = [
156            self::qualify($this->pivotTable, $this->foreignPivotKey) . ' as ' . $parentFk,
157        ];
158
159        foreach ($this->pivotColumns as $column) {
160            $selects[] = self::qualify($this->pivotTable, $column) . ' as radiant_pivot_' . $column;
161        }
162
163        $selects[] = "{$relatedTable}.*";
164
165        $builder = $builder->select(...$selects);
166
167        $rows = $builder->getRaw();
168
169        $keys = [];
170        $models = [];
171
172        foreach ($rows->all() as $row) {
173            $keys[] = $row->{$parentFk} ?? null;
174            $models[] = $this->related::fromRow($row);
175        }
176
177        return new EagerResult(EagerResult::listToCollection($models), $keys);
178    }
179
180    /**
181     * Scope every pivot READ/DELETE/UPDATE path to the morph alias â€” the
182     * {@see BelongsToMany::pivotQuery()} hook.
183     *
184     * @param  SqlConnection  $connection
185     * @return QueryBuilder
186     */
187    #[\Override]
188    protected function pivotQuery(SqlConnection $connection): QueryBuilder
189    {
190        return parent::pivotQuery($connection)->where(
191            self::qualify($this->pivotTable, $this->morphTypeColumn),
192            '=',
193            $this->morphAlias,
194        );
195    }
196
197    /**
198     * Stamp the morph alias onto every pivot row the write API inserts â€”
199     * the {@see BelongsToMany::stampRow()} hook.
200     *
201     * @param  array<string, mixed>  $row
202     * @return array<string, mixed>
203     */
204    #[\Override]
205    protected function stampRow(array $row): array
206    {
207        $row[$this->morphTypeColumn] = $this->morphAlias;
208
209        return $row;
210    }
211
212    /**
213     * Attach related models â€” every inserted row carries the morph alias.
214     *
215     * @param  int|string|list<int|string>|array<string, mixed>  $ids
216     * @param  array<string, mixed>  $pivotAttributes
217     * @return void
218     */
219    #[\Override]
220    final public function attach(int|string|array $ids, array $pivotAttributes = []): void
221    {
222        $connection = $this->sqlConnection();
223
224        $rows = [];
225
226        foreach ($this->normalizeIds($ids) as $id => $attributes) {
227            $rows[] = $this->stampRow([
228                $this->foreignPivotKey => $this->parent->attribute($this->parentKey),
229                $this->relatedPivotKey => $id,
230                ...$pivotAttributes,
231                ...$attributes,
232            ]);
233        }
234
235        if ($rows === []) {
236            return;
237        }
238
239        $connection->table($this->pivotTable)->insert($rows);
240    }
241
242    // ---- The cross-type pool read ----
243
244    /**
245     * Read the shared pivot pool across every morph type.
246     *
247     * An allowlist restricts the read to exactly those classes and
248     * ignores every other stored alias; the same list narrows the
249     * static bound. Without an allowlist every stored alias resolves
250     * and validates â€” an unknown type value fails fast. The read is
251     * always fresh: it never serves the `with()` cache, never composes
252     * the relation's filters, and pivot values ride along per query.
253     *
254     * @return Collection<int, TPool>
255     * @throws \InvalidArgumentException
256     * @throws \LogicException
257     */
258    public function pool(): Collection
259    {
260        if (!$this->isInversePool()) {
261            throw new \LogicException(
262                'pool() reads the shared pivot pool across morph types â€” available only on '
263                . 'the inverse direction (morphedByMany), where this side\'s pivot columns '
264                . 'carry a (type, key) pair. The direct direction resolves one static class.'
265            );
266        }
267
268        $parentKey = $this->parent->attribute($this->parentKey);
269
270        if ($parentKey === null) {
271            return Collection::make([]);
272        }
273
274        $models = [];
275
276        foreach ($this->poolAliases($parentKey) as $alias) {
277            $class = $this->validatedPoolClass($alias);
278
279            array_push($models, ...$this->poolQueryFor($class, $parentKey)->get()->all());
280        }
281
282        return Collection::make($models);
283    }
284
285    /**
286     * Whether this side's pivot columns carry the morph (type, key) pair.
287     *
288     * @return bool
289     */
290    private function isInversePool(): bool
291    {
292        return $this->foreignPivotKey === $this->parent::table() . '_id';
293    }
294
295    /**
296     * The morph aliases this pool read covers, in query order.
297     *
298     * The declared allowlist when present; otherwise every distinct type
299     * value stored under this parent's pivot rows.
300     *
301     * @param  int|string  $parentKey
302     * @return list<string>
303     */
304    private function poolAliases(int|string $parentKey): array
305    {
306        if ($this->poolTypes !== null) {
307            return $this->poolTypes;
308        }
309
310        $rows = $this->sqlConnection()
311            ->table($this->pivotTable)
312            ->select($this->morphTypeColumn)
313            ->distinct()
314            ->where($this->foreignPivotKey, '=', $parentKey)
315            ->get();
316
317        $aliases = [];
318
319        foreach ($rows as $row) {
320            $alias = $row->{$this->morphTypeColumn} ?? null;
321
322            if (is_string($alias) && $alias !== '') {
323                $aliases[$alias] = true;
324            }
325        }
326
327        return array_keys($aliases);
328    }
329
330    /**
331     * Validate one resolved morph alias into a model class-string.
332     *
333     * @param  string  $alias
334     * @return class-string<TPool>
335     * @throws \InvalidArgumentException
336     */
337    private function validatedPoolClass(string $alias): string
338    {
339        if ($this->poolTypes !== null && !in_array($alias, $this->poolTypes, true)) {
340            throw new \InvalidArgumentException(
341                'Morph type [' . $alias . '] on pivot [' . $this->pivotTable
342                . '] is not in the pool allowlist.'
343            );
344        }
345
346        if (!class_exists($alias) || !is_a($alias, Model::class, true)) {
347            throw new \InvalidArgumentException(
348                'Morph type [' . $alias . '] on pivot [' . $this->pivotTable
349                . '] does not resolve to an existing model class.'
350            );
351        }
352
353        // The runtime checks back the template bound â€” the same inline
354        // narrowing the MorphTo marker trick uses.
355        /** @var class-string<TPool> */
356        return $alias;
357    }
358
359    /**
360     * Build one morph type's pool query.
361     *
362     * @param  class-string<TPool>  $class
363     * @param  int|string  $parentKey
364     * @return \BlueprintAU\Radiant\ModelQueryBuilder<TPool>
365     */
366    private function poolQueryFor(string $class, int|string $parentKey): \BlueprintAU\Radiant\ModelQueryBuilder
367    {
368        $typeTable = $class::table();
369
370        $builder = $class::newQuery()
371            ->join(
372                $this->pivotTable,
373                self::qualify($typeTable, 'id'),
374                '=',
375                self::qualify($this->pivotTable, $this->relatedPivotKey),
376            )
377            ->where(
378                self::qualify($this->pivotTable, $this->morphTypeColumn),
379                '=',
380                $class,
381            )
382            ->where(
383                self::qualify($this->pivotTable, $this->foreignPivotKey),
384                '=',
385                $parentKey,
386            );
387
388        if ($this->pivotColumns === []) {
389            return $builder;
390        }
391
392        $selects = [];
393
394        foreach ($this->pivotColumns as $column) {
395            $selects[] = self::qualify($this->pivotTable, $column) . ' as radiant_pivot_' . $column;
396        }
397
398        $selects[] = "{$typeTable}.*";
399
400        /** @var \BlueprintAU\Radiant\ModelQueryBuilder<TPool> */
401        return $builder->select(...$selects);
402    }
403}

Inherited from BlueprintAU\Radiant\Relations\BelongsToMany

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    }
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    }
181    final public function getPivotTable(): string
182    {
183        return $this->pivotTable;
184    }
191    final public function getForeignPivotKey(): string
192    {
193        return $this->foreignPivotKey;
194    }
201    final public function getRelatedPivotKey(): string
202    {
203        return $this->relatedPivotKey;
204    }
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    }
235    final public function withTimestamps(): static
236    {
237        return $this->withPivot('created_at', 'updated_at');
238    }
280    final protected static function qualify(string $table, string $column): string
281    {
282        return $table . '.' . $column;
283    }
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    }
315    protected function executeResults(): Collection
316    {
317        return $this->readQuery()->get();
318    }
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    }
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    }
449    protected function sqlConnection(): SqlConnection
450    {
451        $connection = $this->parent::connection();
452
453        SqlConnection::assertSql($connection);
454
455        return $connection;
456    }
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    }
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    }
605    final public function syncWithoutDetaching(array $ids): array
606    {
607        return $this->sync($ids, detaching: false);
608    }
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    }
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    }
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    }

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    }