Lines 94.92% 131 / 138
Functions and Methods 63.63% 7 / 11
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
MorphToMany 94.92% 131 / 138 63.63% 7 / 11 32.13 0.00% 0 / 1
 __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
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\Model;
11
12/**
13 * Many-to-many polymorphic: the pivot's parent-side key is a (type, key)
14 * pair, so models of any class share the same related pool through one
15 * pivot table.
16 *
17 * Every query — lazy and eager — filters the type column to this side's
18 * morph alias (the FQCN convention {@see MorphOneOrMany} writes), and the
19 * write API stamps the alias on every inserted row. `morphedByMany()` is
20 * the inverse direction: the constructor's `$inverse` flag swaps which
21 * side's alias filters the type column and which side's key the queries
22 * filter on.
23 *
24 * @template TRelated of Model
25 * @template TPool of Model
26 * @extends BelongsToMany<TRelated>
27 */
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}