Lines 97.47% 232 / 238
Methods 93.18% 41 / 44
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 4 / 4 100.00% 1 / 1 1
 defersConstraints 100.00% 1 / 1 100.00% 1 / 1 1
 relatedClasses 100.00% 1 / 1 100.00% 1 / 1 1
 getTypeColumn 100.00% 1 / 1 100.00% 1 / 1 1
 addConstraints 100.00% 4 / 4 100.00% 1 / 1 1
 aliasOf 100.00% 20 / 20 100.00% 1 / 1 8
 assertKeyMatches 100.00% 16 / 16 100.00% 1 / 1 4
 readQuery 100.00% 4 / 4 100.00% 1 / 1 2
 relatedClass 100.00% 1 / 1 100.00% 1 / 1 1
 queryFor 100.00% 4 / 4 100.00% 1 / 1 2
 eagerKeyColumn 100.00% 1 / 1 100.00% 1 / 1 1
 eagerLoad 96.42% 27 / 28 0.00% 0 / 1 10
 match 100.00% 19 / 19 100.00% 1 / 1 7
 executeResults 100.00% 5 / 5 100.00% 1 / 1 3
 [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] eagerLoadChunk 84.61% 22 / 26 0.00% 0 / 1 4.06
 [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] 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] 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
29final class MorphTo extends Relation
30{
31    /**
32     * The type-discriminator column on the parent's table.
33     *
34     * @var string
35     */
36    protected readonly string $typeColumn;
37
38    /**
39     * The optional morph-alias allowlist.
40     *
41     * @var list<class-string<Model>>|null
42     */
43    private array|null $types;
44
45    /**
46     * Create the inverse polymorphic relation.
47     *
48     * The base constructor's `$related` slot is filled with the abstract
49     * {@see Model::class} marker â€” the real class resolves per parent from
50     * the type column, and the base's constraint pass is skipped via
51     * {@see Relation::defersConstraints()}.
52     *
53     * @param  Model  $parent
54     * @param  string  $typeColumn
55     * @param  string  $foreignKey
56     * @param  string  $ownerKey
57     * @param  list<class-string<TRelated>>|null  $types
58     */
59    public function __construct(
60        Model $parent,
61        string $typeColumn,
62        string $foreignKey,
63        string $ownerKey,
64        array|null $types = null,
65    ) {
66        // The marker slot: no fixed related class exists â€” the real class
67        // resolves per parent from the type column, and the runtime allowlist
68        // validates every resolution (aliasOf). Statically, Model::class does
69        // not satisfy class-string<TRelated> for a narrowed allowlist, so the
70        // marker narrows through an inline var: the declaration is backed by
71        // the runtime check, exactly the conditional-return pattern the rest
72        // of the ORM uses for dynamic types.
73        /** @var class-string<TRelated> $marker */
74        $marker = Model::class;
75
76        parent::__construct($parent, $marker, $foreignKey, $ownerKey);
77
78        $this->typeColumn = $typeColumn;
79        $this->types = $types;
80    }
81
82    /**
83     * The base constructor's query build + constraint pass cannot run â€”
84     * the related class resolves per parent from the type column.
85     *
86     * @return bool
87     */
88    #[\Override]
89    protected function defersConstraints(): bool
90    {
91        return true;
92    }
93
94    /**
95     * The related classes a dotted path's DEEPER segments resolve against.
96     *
97     * @return list<class-string<Model>>
98     */
99    #[\Override]
100    public function relatedClasses(): array
101    {
102        return [];
103    }
104
105    /**
106     * The type-discriminator column on the parent's table.
107     *
108     * @return string
109     */
110    final public function getTypeColumn(): string
111    {
112        return $this->typeColumn;
113    }
114
115    /**
116     * Unused â€” the related class is dynamic; constraints build lazily.
117     *
118     * @return void
119     */
120    #[\Override]
121    protected function addConstraints(): void
122    {
123        throw new \LogicException(
124            'MorphTo builds its constraints lazily per resolved type; addConstraints() '
125            . 'must not be called directly.'
126        );
127    }
128
129    /**
130     * Resolve ONE parent's morph alias â€” the shared validation path.
131     *
132     * @param  Model  $parent
133     * @return class-string<Model>|null
134     * @throws \InvalidArgumentException
135     */
136    private function aliasOf(Model $parent): string|null
137    {
138        $alias = $parent->attribute($this->typeColumn);
139
140        if ($alias === null) {
141            return null;
142        }
143
144        if (!is_string($alias) || $alias === '') {
145            throw new \InvalidArgumentException(
146                'Morph type column [' . $this->typeColumn . '] on [' . $parent::class
147                . '] holds a non-string value; the morph alias must be a model class-string.'
148            );
149        }
150
151        if ($this->types !== null && !in_array($alias, $this->types, true)) {
152            throw new \InvalidArgumentException(
153                'Morph type [' . $alias . '] on [' . $parent::class
154                . '] is not in the relation\'s allowlist.'
155            );
156        }
157
158        if (!class_exists($alias) || !is_a($alias, Model::class, true)) {
159            throw new \InvalidArgumentException(
160                'Morph type [' . $alias . '] on [' . $parent::class
161                . '] does not resolve to an existing model class.'
162            );
163        }
164
165        $this->assertKeyMatches($alias);
166
167        return $alias;
168    }
169
170    /**
171     * Fail fast when the resolved target's primary-key type cannot be
172     * held by this parent's morph key column.
173     *
174     * @param  class-string<Model>  $alias
175     * @return void
176     * @throws \InvalidArgumentException
177     */
178    private function assertKeyMatches(string $alias): void
179    {
180        $declared = MetadataFactory::for($this->parent::class)
181            ->mappingFor($this->getForeignKey())->column->type;
182        $primaryKey = MetadataFactory::for($alias)->primaryKeys;
183
184        if (count($primaryKey) !== 1 || $primaryKey[0]->name === null) {
185            throw new \LogicException(
186                'A morph target requires a single named primary key; model [' . $alias
187                . '] declares none, a composite key, or an unnamed key.'
188            );
189        }
190
191        if ($primaryKey[0]->type !== $declared) {
192            throw new \InvalidArgumentException(
193                'The morph key column [' . $this->getForeignKey() . '] on [' . $this->parent::class
194                . '] is [' . $declared->value . '], but [' . $alias . ']\'s primary key is ['
195                . $primaryKey[0]->type->value . ']. A morph pair can only point at models whose '
196                . 'primary-key type matches the key column â€” declare the pair with a matching '
197                . 'keyType (or uuidMorphs()).'
198            );
199        }
200    }
201
202    /**
203     * The resolved type's constrained query â€” the row reads' target.
204     *
205     * @return ModelQueryBuilder<TRelated>
206     */
207    #[\Override]
208    protected function readQuery(): ModelQueryBuilder
209    {
210        $alias = $this->aliasOf($this->parent);
211
212        if ($alias === null) {
213            // Null morph pair â†’ no results, without compiling a meaningless
214            // query (BelongsTo's convention). The no-match query builds on
215            // the PARENT's class â€” the Model::class marker cannot (its
216            // table() throws), and a `1 = 0` query never hydrates a row.
217            /** @var ModelQueryBuilder<TRelated> */
218            return ($this->parent)::newQuery()->whereRaw('1 = 0', []);
219        }
220
221        // aliasOf() validated the alias against the allowlist (or the
222        // bound IS Model without one) â€” the resolved builder's rows are
223        // all TRelated.
224        /** @var ModelQueryBuilder<TRelated> */
225        return $this->queryFor($alias);
226    }
227
228    /**
229     * The related model class â€” the fail-fast exceptions' identity.
230     *
231     * The resolved alias when the morph pair resolves; the parent's own
232     * class when it does not (a null pair has no related class â€” the
233     * no-match query builds on the parent for the same reason).
234     *
235     * @return class-string<Model>
236     */
237    #[\Override]
238    protected function relatedClass(): string
239    {
240        return $this->aliasOf($this->parent) ?? $this->parent::class;
241    }
242
243    /**
244     * Build the constrained query for ONE resolved type.
245     *
246     * @param  class-string<Model>  $alias
247     * @return ModelQueryBuilder<Model>
248     */
249    private function queryFor(string $alias): ModelQueryBuilder
250    {
251        $fkValue = $this->parent->attribute($this->getForeignKey());
252
253        if ($fkValue === null) {
254            // Null FK â†’ no results, without compiling a meaningless query
255            // (BelongsTo's convention).
256            return $alias::newQuery()->whereRaw('1 = 0', []);
257        }
258
259        return $alias::newQuery()->where($this->getLocalKey(), WhereOperator::Eq, $fkValue);
260    }
261
262    /**
263     * The parent column(s) the eager loader collects key values from.
264     *
265     * @return list<string>
266     */
267    #[\Override]
268    public function eagerKeyColumn(): array
269    {
270        return [$this->typeColumn, $this->getForeignKey()];
271    }
272
273    /**
274     * Run the eager queries â€” ONE chunked `IN` per distinct type.
275     *
276     * @param  list<KeyValue>  $parentKeys  The parents' [type, FK] tuples.
277     * @return EagerResult<Model>
278     */
279    #[\Override]
280    public function eagerLoad(array $parentKeys): EagerResult
281    {
282        if ($parentKeys === []) {
283            return EagerResult::fromModels([]);
284        }
285
286        // Group the wanted keys by resolved type; within a type, index by
287        // the serialized FK so each loaded row maps straight back to its
288        // referencing parents.
289        $byAlias = [];
290
291        foreach ($parentKeys as $pair) {
292            $alias = $pair[$this->typeColumn] ?? null;
293            $fk = $pair[$this->getForeignKey()] ?? null;
294
295            if (!is_string($alias) || $alias === '' || $fk === null) {
296                throw new \InvalidArgumentException(
297                    'A MorphTo eager load requires the (type, key) tuple keyed by its column '
298                        . 'names [' . $this->typeColumn . ', ' . $this->getForeignKey() . ']; got '
299                        . get_debug_type($pair) . '.'
300                );
301            }
302
303            $byAlias[$alias][self::serializeKey($fk)] = true;
304        }
305
306        /** @var list<Model> $models */
307        $models = [];
308        $pairs = [];
309
310        foreach ($byAlias as $alias => $wantedKeys) {
311            foreach (array_chunk(array_keys($wantedKeys), self::EAGER_KEY_CHUNK) as $chunk) {
312                $rows = $alias::newQuery()
313                    ->whereIn($this->getLocalKey(), $chunk)
314                    ->get();
315
316                foreach ($rows as $model) {
317                    $key = self::serializeKey($model->attribute($this->getLocalKey()));
318
319                    if (!isset($wantedKeys[$key])) {
320                        continue; // a row no parent in THIS load references
321                    }
322
323                    $models[] = $model;
324                    $pairs[] = [$alias, $key];
325                }
326            }
327        }
328
329        $collection = Collection::make($models);
330
331        return new EagerResult($collection, $pairs);
332    }
333
334    /**
335     * Distribute eager results onto parents by (alias, key) pair.
336     *
337     * @param  list<Model>  $parents
338     * @param  Collection<int, Model>  $results
339     * @param  string  $name
340     * @param  list<array{string, string}>|null  $eagerParentKeys
341     * @return void
342     */
343    #[\Override]
344    public function match(array $parents, Collection $results, string $name, ?array $eagerParentKeys = null): void
345    {
346        if ($eagerParentKeys === null) {
347            throw new \LogicException(
348                static::class . '::match() requires the EagerResult (alias, key) pairs; '
349                . 'call it with the array returned by eagerLoad(), not the models alone.'
350            );
351        }
352
353        $byPair = [];
354
355        foreach ($results as $i => $model) {
356            $pair = $eagerParentKeys[$i] ?? null;
357
358            if ($pair === null) {
359                continue;
360            }
361
362            $byPair[$pair[0] . '|' . $pair[1]] = $model;
363        }
364
365        foreach ($parents as $parent) {
366            $alias = $parent->attribute($this->typeColumn);
367            $fk = $parent->attribute($this->getForeignKey());
368
369            if (!is_string($alias) || $fk === null) {
370                // No (type, key) pair â†’ the single-valued relation loads
371                // as NULL (an empty collection would lie about cardinality).
372                $parent->setRelation($name, null);
373                continue;
374            }
375
376            $model = $byPair[$alias . '|' . self::serializeKey($fk)] ?? null;
377            $parent->setRelation($name, $model);
378        }
379    }
380
381    /**
382     * Run the constrained query against the parent's resolved type.
383     *
384     * @return Collection<int, Model>
385     */
386    #[\Override]
387    protected function executeResults(): Collection
388    {
389        $alias = $this->aliasOf($this->parent);
390
391        if ($alias === null) {
392            return Collection::make([]);
393        }
394
395        $first = $this->queryFor($alias)->first();
396
397        return Collection::make($first === null ? [] : [$first]);
398    }
399}

Inherited from BlueprintAU\Radiant\Relations\Relation

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    }
286    protected function eagerLoadChunk(array $parentKeys): EagerResult
287    {
288        $query = $this->related::newQuery();
289
290        $query = $this->applyEagerOrdering($query);
291
292        if ($this->isComposite()) {
293            $foreignKeys = $this->getForeignKeys();
294            $localKeys = $this->getLocalKeys();
295
296            // The OR-of-groups lands INSIDE one outer AND-group: the key
297            // set is ONE constraint unit. The related builder auto-applies
298            // trait scopes (e.g. soft-delete `deleted_at IS NULL`) as
299            // leading AND-groups â€” flat top-level ORs would compile to
300            // `(scope) OR (fk = ? AND ...) OR ...` and let a scope-excluded
301            // row back in whenever its key matched. Grouped, the scope
302            // ANDs against the whole set.
303            return EagerResult::fromCollection($query->whereNested(
304                function (WhereBuilder $nested) use ($foreignKeys, $localKeys, $parentKeys): WhereBuilder {
305                    $grouped = $nested;
306
307                    foreach ($parentKeys as $parentKey) {
308                        if (!is_array($parentKey)) {
309                            throw new \InvalidArgumentException(
310                                'A composite relation key requires column => value key maps for eager loading; '
311                                    . 'got ' . get_debug_type($parentKey) . '.'
312                            );
313                        }
314
315                        $grouped = $grouped->orWhereNested(
316                            fn (WhereBuilder $keyGroup): WhereBuilder => self::applyKeyTuple(
317                                $keyGroup,
318                                $foreignKeys,
319                                $localKeys,
320                                $parentKey,
321                            )
322                        );
323                    }
324
325                    return $grouped;
326                }
327            )->get());
328        }
329
330        return EagerResult::fromCollection($query->whereIn($this->getForeignKey(), $parentKeys)->get());
331    }
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    }
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    }
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    }