Lines 98.60% 212 / 215
Functions and Methods 88.00% 22 / 25
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
BelongsToMany 98.60% 212 / 215 88.00% 22 / 25 70 0.00% 0 / 1
 __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
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Relations;
6
7use BlueprintAU\Radiant\Collection;
8use BlueprintAU\Radiant\Database\Connections\SqlConnection;
9use BlueprintAU\Radiant\Database\Query\QueryBuilder;
10use BlueprintAU\Radiant\Database\Query\Enums\WhereOperator;
11use BlueprintAU\Radiant\Database\Exceptions\UnsupportedFeatureException;
12use BlueprintAU\Radiant\Metadata\MetadataFactory;
13use BlueprintAU\Radiant\Model;
14use BlueprintAU\Radiant\ModelQueryBuilder;
15
16/**
17 * Many-to-many: the parent and the related model link THROUGH a pivot
18 * table (`Post` ↔ `Tag` via `posts_tags`).
19 *
20 * The relation query INNER JOINs the pivot: a parent with no pivot rows
21 * legitimately has no related models, so INNER is the honest semantics
22 * (the same call {@see HasManyThrough} makes). Joins are SQL-only — a
23 * non-SQL connection throws
24 * {@see \BlueprintAU\Radiant\Database\Exceptions\UnsupportedFeatureException}
25 * at execution.
26 *
27 * The pivot table default is `{parentTable}_{relatedTable}` — deterministic
28 * concatenation, no singularization guessing (Radiant's pluralizer is
29 * naive; a reverse singularizer would mangle irregulars). Pass an explicit
30 * `$table` for any other name. The pivot key columns default to
31 * `{parentTable}_id` / `{relatedTable}_id`.
32 *
33 * Pivot data rides the eager select: `withPivot()` adds pivot columns to
34 * the select (aliased `radiant_pivot_{column}`), readable per related
35 * model through `pivotValue()`. `withTimestamps()` is sugar for the
36 * created_at/updated_at pair.
37 *
38 * The write API — attach/detach/sync/toggle — operates on the pivot table
39 * directly through the connection's plain builder (no hydration, no
40 * events); `sync()` wraps its diff in a transaction.
41 *
42 * @template TRelated of Model
43 * @extends Relation<TRelated>
44 * @phpstan-import-type KeyValue from \BlueprintAU\Radiant\Model
45 */
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}