Lines 94.89% 558 / 588
Functions and Methods 62.50% 15 / 24
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
MetadataFactory 94.89% 558 / 588 62.50% 15 / 24 167.57 0.00% 0 / 1
 for 100.00% 1 / 1 100.00% 1 / 1 1
 tryFor 100.00% 3 / 3 100.00% 1 / 1 2
 clear 100.00% 4 / 4 100.00% 1 / 1 2
 tables 100.00% 7 / 7 100.00% 1 / 1 5
 build 100.00% 37 / 37 100.00% 1 / 1 6
 collectTraitScopes 100.00% 35 / 35 100.00% 1 / 1 12
 collectWriteHooks 100.00% 18 / 18 100.00% 1 / 1 8
 collectRowHooks 100.00% 23 / 23 100.00% 1 / 1 10
 traitsOf 85.71% 12 / 14 0.00% 0 / 1 7.14
 collectProperties 100.00% 56 / 56 100.00% 1 / 1 9
 applySoftDeletes 92.68% 38 / 41 0.00% 0 / 1 8.03
 applyTimestamps 97.77% 44 / 45 0.00% 0 / 1 10
 columnsFromHelperShape 100.00% 18 / 18 100.00% 1 / 1 2
 applyMorphs 100.00% 20 / 20 100.00% 1 / 1 5
 injectMorphColumn 100.00% 30 / 30 100.00% 1 / 1 6
 resolveTableName 78.12% 50 / 64 0.00% 0 / 1 20.03
 collectConstraints 100.00% 55 / 55 100.00% 1 / 1 12
 validateConstraintColumns 100.00% 10 / 10 100.00% 1 / 1 4
 validateNoFlagDuplicates 96.15% 25 / 26 0.00% 0 / 1 11
 usesTrait 87.50% 7 / 8 0.00% 0 / 1 6.07
 traitUses 85.71% 6 / 7 0.00% 0 / 1 5.07
 nearestAncestorTable 60.00% 6 / 10 0.00% 0 / 1 5.02
 defaultTableName 100.00% 6 / 6 100.00% 1 / 1 3
 deriveMtiChildKey 94.00% 47 / 50 0.00% 0 / 1 9.02
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Metadata;
6
7use BlueprintAU\Radiant\Attributes\Backfill;
8use BlueprintAU\Radiant\Attributes\Check;
9use BlueprintAU\Radiant\Attributes\Column;
10use BlueprintAU\Radiant\Attributes\Hook;
11use BlueprintAU\Radiant\Attributes\ModelScope;
12use BlueprintAU\Radiant\Attributes\RowHook;
13use BlueprintAU\Radiant\Attributes\WriteHook;
14use BlueprintAU\Radiant\Database\Schema\Blueprint;
15use BlueprintAU\Radiant\Database\Schema\Enums\ColumnType;
16use BlueprintAU\Radiant\Attributes\ForeignKey;
17use BlueprintAU\Radiant\Attributes\Morphs;
18use BlueprintAU\Radiant\Model;
19use BlueprintAU\Radiant\ScopeCondition;
20use BlueprintAU\Radiant\SoftDeletes;
21use BlueprintAU\Radiant\Timestamps;
22use BlueprintAU\Radiant\Attributes\Index;
23use BlueprintAU\Radiant\Attributes\Table;
24use BlueprintAU\Radiant\Attributes\Unique;
25
26/**
27 * Caches per-class metadata (attributes + `ReflectionProperty`s +
28 * inheritance chains).
29 *
30 * The static cache is justified: class metadata is immutable, so it is
31 * built at most once per class per process and never invalidated. A
32 * class's metadata is derived only from its own reflection plus its
33 * ancestors', never its descendants; whichever class of a chain is touched
34 * first builds exactly itself, and the engine does the chain merge inside
35 * {@see MetadataFactory::build()}.
36 */
37final class MetadataFactory
38{
39    /**
40     * Per-class metadata cache.
41     *
42     * @var array<class-string, ClassMetadata>
43     */
44    private static array $metadataCache = [];
45
46    /**
47     * The single entry point: a class's (cached) metadata.
48     *
49     * @param  class-string<Model>  $class
50     * @return ClassMetadata
51     */
52    public static function for(string $class): ClassMetadata
53    {
54        return self::$metadataCache[$class] ??= self::build($class);
55    }
56
57    /**
58     * Resolve a class's metadata, returning null instead of throwing when
59     * it cannot be built.
60     *
61     * A failed build is never cached, so a later strict {@see for()} call
62     * on the same class still throws.
63     *
64     * @param  class-string<Model>  $class
65     * @return ClassMetadata|null  Null when the class is missing or its metadata fails validation.
66     */
67    public static function tryFor(string $class): ?ClassMetadata
68    {
69        try {
70            return self::for($class);
71        } catch (\Throwable) {
72            return null;
73        }
74    }
75
76    /**
77     * Invalidate cached metadata.
78     *
79     * @param  string|null  $class  Null clears the whole cache.
80     * @return void
81     */
82    public static function clear(?string $class = null): void
83    {
84        if ($class === null) {
85            self::$metadataCache = [];
86            return;
87        }
88
89        unset(self::$metadataCache[$class]);
90    }
91
92    /**
93     * The canonical table inventory: every table-owning model class mapped
94     * to its resolved table name.
95     *
96     * @param  list<class-string<Model>>  $models
97     * @param  bool  $skipBroken  Whether classes whose metadata cannot be resolved are skipped instead of throwing.
98     * @return array<class-string<Model>, string>
99     * @throws \InvalidArgumentException
100     * @throws \LogicException
101     */
102    public static function tables(array $models, bool $skipBroken = false): array
103    {
104        $tables = [];
105
106        foreach ($models as $model) {
107            $metadata = $skipBroken ? self::tryFor($model) : self::for($model);
108
109            if ($metadata === null || $metadata->tableName === null) {
110                continue; // unresolvable, or no columns of its own â€” no table (rule 4)
111            }
112
113            $tables[$model] = $metadata->tableName;
114        }
115
116        return $tables;
117    }
118
119    /**
120     * Build a class's metadata from its reflection.
121     *
122     * ONE pass over the leaf's `getProperties()` â€” which returns inherited
123     * properties too, each carrying its true declaring class. Leaf-wins is
124     * structural: a redeclared property surfaces exactly once here, as the
125     * leaf's property with the leaf's attributes.
126     *
127     * @param  class-string<Model>  $class
128     * @return ClassMetadata
129     * @throws \InvalidArgumentException
130     */
131    private static function build(string $class): ClassMetadata
132    {
133        $reflection = new \ReflectionClass($class);
134
135        $properties = self::collectProperties($reflection, $class);
136        $softDeleteColumn = self::applySoftDeletes($reflection, $class, $properties);
137        self::applyTimestamps($reflection, $class, $properties);
138        self::applyMorphs($reflection, $class, $properties);
139        [$tableName, $parentModel] = self::resolveTableName($reflection, $class, $properties);
140
141        [$uniques, $indexes, $foreignKeys, $checks] = self::collectConstraints($reflection, $class, $properties);
142
143        if ($parentModel !== null) {
144            $properties = self::deriveMtiChildKey($reflection, $class, $properties, $parentModel);
145        }
146
147        // The column â†’ owning-table partition map, precomputed HERE rather
148        // than in the ClassMetadata constructor: resolving an owner's table
149        // consults the metadata cache, and the class's OWN entry is not
150        // seeded until construction returns â€” a constructor-side lookup for
151        // a self-owned column would recurse infinitely. The factory already
152        // knows `$tableName`, so the self-reference resolves locally and
153        // only genuinely foreign owners hit the cache (their entries are
154        // complete by construction order â€” ancestors build before or
155        // independently of descendants).
156        $partitions = [];
157
158        foreach ($properties as $mapping) {
159            if ($mapping->owner === $class) {
160                if ($tableName !== null) {
161                    $partitions[$mapping->columnName] = $tableName;
162                }
163                continue;
164            }
165
166            $table = self::for($mapping->owner)->tableName;
167
168            if ($table === null) {
169                continue; // abstract owner â€” merged into a descendant's table
170            }
171
172            $partitions[$mapping->columnName] = $table;
173        }
174
175        return new ClassMetadata(
176            tableName: $tableName,
177            properties: $properties,
178            primaryKeys: array_values(array_map(
179                fn (PropertyMapping $mapping) => $mapping->column,
180                array_filter($properties, fn (PropertyMapping $m) => $m->column->primaryKey),
181            )),
182            uniques: $uniques,
183            indexes: $indexes,
184            foreignKeys: $foreignKeys,
185            checks: $checks,
186            softDeleteColumn: $softDeleteColumn,
187            parentModel: $parentModel,
188            tablePartitions: $partitions,
189            traitScopes: self::collectTraitScopes($reflection, $class, $properties),
190            writeHooks: self::collectWriteHooks($reflection, $class),
191            rowHooks: self::collectRowHooks($reflection, $class),
192        );
193    }
194
195    /**
196     * Collect the trait-declared query scopes for a class.
197     *
198     * Walks the class's traits recursively (declaration order, then
199     * ancestors) and invokes every `#[ModelScope]`-annotated static
200     * method. Columns are validated against the merged metadata â€” an
201     * unknown scope column fails fast at build.
202     *
203     * @param  \ReflectionClass<Model>  $reflection
204     * @param  class-string<Model>  $class
205     * @param  PropertyMapping[]  $properties  The class's merged column mappings (collected earlier in build()) â€” validating against these avoids a self::for() call, which would recurse (build() is what invokes this).
206     * @return list<array{trait: class-string, condition: \BlueprintAU\Radiant\ScopeCondition}>
207     * @throws \InvalidArgumentException
208     */
209    private static function collectTraitScopes(\ReflectionClass $reflection, string $class, array $properties): array
210    {
211        $scopes = [];
212
213        foreach (self::traitsOf($reflection) as $trait) {
214            foreach ($trait->getMethods() as $method) {
215                $attributes = $method->getAttributes(ModelScope::class);
216
217                if ($attributes === []) {
218                    continue;
219                }
220
221                if (!$method->isStatic() || $method->getNumberOfParameters() > 0) {
222                    throw new \InvalidArgumentException(
223                        "The #[ModelScope] method [{$trait->name}::{$method->name}] must be static "
224                        . 'and take no parameters.'
225                    );
226                }
227
228                // Invoke through the MODEL class, not the trait â€” the
229                // method's `self::` calls must late-bind to the using class
230                // (e.g. SoftDeletes::deletedAtColumn() overrides).
231                $conditions = $class::{$method->name}();
232
233                if (!is_array($conditions)) {
234                    throw new \InvalidArgumentException(
235                        "The #[ModelScope] method [{$trait->name}::{$method->name}] must return an array "
236                        . 'of ScopeCondition instances.'
237                    );
238                }
239
240                foreach ($conditions as $condition) {
241                    if (!$condition instanceof ScopeCondition) {
242                        throw new \InvalidArgumentException(
243                            "The #[ModelScope] method [{$trait->name}::{$method->name}] must return an array "
244                            . 'of ScopeCondition instances; got ' . get_debug_type($condition) . '.'
245                        );
246                    }
247
248                    $known = false;
249
250                    foreach ($properties as $mapping) {
251                        if ($mapping->columnName === $condition->column) {
252                            $known = true;
253                            break;
254                        }
255                    }
256
257                    if (!$known) {
258                        throw new \InvalidArgumentException(
259                            "The #[ModelScope] on [{$trait->name}] declares the column [{$condition->column}]"
260                            . ", which does not exist on model [{$class}]."
261                        );
262                    }
263
264                    $scopes[] = ['trait' => $trait->name, 'condition' => $condition];
265                }
266            }
267        }
268
269        return $scopes;
270    }
271
272    /**
273     * Collect the trait-declared write hooks for a class.
274     *
275     * Walks the class's traits recursively (declaration order, then
276     * ancestors). Within one trait, methods run in declaration order.
277     * `Hook::Destroy` methods must return void â€” the hard DELETE is
278     * unclaimable.
279     *
280     * @param  \ReflectionClass<Model>  $reflection
281     * @param  class-string<Model>  $class
282     * @return list<array{trait: class-string, hook: Hook, method: string}>
283     * @throws \InvalidArgumentException
284     */
285    private static function collectWriteHooks(\ReflectionClass $reflection, string $class): array
286    {
287        $hooks = [];
288
289        foreach (self::traitsOf($reflection) as $trait) {
290            foreach ($trait->getMethods() as $method) {
291                foreach ($method->getAttributes(WriteHook::class) as $attribute) {
292                    /** @var WriteHook $writeHook */
293                    $writeHook = $attribute->newInstance();
294
295                    if ($method->isStatic()) {
296                        throw new \InvalidArgumentException(
297                            "The #[WriteHook] method [{$trait->name}::{$method->name}] must be an instance method."
298                        );
299                    }
300
301                    if (
302                        $writeHook->hook === Hook::Destroy
303                        && $method->hasReturnType()
304                        && (string) $method->getReturnType() !== 'void'
305                    ) {
306                        throw new \InvalidArgumentException(
307                            "The #[WriteHook(Hook::Destroy)] method [{$trait->name}::{$method->name}] must "
308                            . 'return void â€” the hard DELETE is unclaimable.'
309                        );
310                    }
311
312                    $hooks[] = ['trait' => $trait->name, 'hook' => $writeHook->hook, 'method' => $method->name];
313                }
314            }
315        }
316
317        return $hooks;
318    }
319
320    /**
321     * Collect the trait-declared bulk-write hooks for a class.
322     *
323     * Walks the class's traits recursively (declaration order, then
324     * ancestors). Within one trait, methods run in declaration order.
325     * Methods must be static and declare a void or bool return type â€”
326     * `Hook::Delete` and `Hook::Destroy` have no bulk path.
327     *
328     * @param  \ReflectionClass<Model>  $reflection
329     * @param  class-string<Model>  $class
330     * @return list<array{trait: class-string, hook: Hook, method: string}>
331     * @throws \InvalidArgumentException
332     */
333    private static function collectRowHooks(\ReflectionClass $reflection, string $class): array
334    {
335        $hooks = [];
336
337        foreach (self::traitsOf($reflection) as $trait) {
338            foreach ($trait->getMethods() as $method) {
339                foreach ($method->getAttributes(RowHook::class) as $attribute) {
340                    /** @var RowHook $rowHook */
341                    $rowHook = $attribute->newInstance();
342
343                    if (!$method->isStatic()) {
344                        throw new \InvalidArgumentException(
345                            "The #[RowHook] method [{$trait->name}::{$method->name}] must be a static method."
346                        );
347                    }
348
349                    $returnType = $method->hasReturnType() ? (string) $method->getReturnType() : null;
350
351                    if ($returnType !== 'void' && $returnType !== 'bool') {
352                        $declared = $returnType ?? 'none';
353
354                        throw new \InvalidArgumentException(
355                            "The #[RowHook] method [{$trait->name}::{$method->name}] must declare a void or "
356                            . "bool return type; got {$declared}."
357                        );
358                    }
359
360                    if ($rowHook->hook !== Hook::Insert && $rowHook->hook !== Hook::Update) {
361                        throw new \InvalidArgumentException(
362                            "The #[RowHook(Hook::{$rowHook->hook->value})] on [{$trait->name}::{$method->name}] "
363                            . 'is invalid â€” bulk hooks support Hook::Insert and Hook::Update only.'
364                        );
365                    }
366
367                    $hooks[] = ['trait' => $trait->name, 'hook' => $rowHook->hook, 'method' => $method->name];
368                }
369            }
370        }
371
372        return $hooks;
373    }
374
375    /**
376     * The class's traits, recursively â€” declaration order on the class,
377     * then ancestors. Deduplicated.
378     *
379     * @param  \ReflectionClass<Model>  $reflection
380     * @return list<\ReflectionClass<object>>
381     */
382    private static function traitsOf(\ReflectionClass $reflection): array
383    {
384        $traits = [];
385        $seen = [];
386
387        for ($current = $reflection; $current !== false; $current = $current->getParentClass()) {
388            foreach ($current->getTraitNames() as $traitName) {
389                if (isset($seen[$traitName])) {
390                    continue;
391                }
392
393                $seen[$traitName] = true;
394                $traits[] = new \ReflectionClass($traitName);
395
396                // Traits used BY the trait count too.
397                foreach (class_uses($traitName) ?: [] as $nested) {
398                    if (isset($seen[$nested])) {
399                        continue;
400                    }
401
402                    $seen[$nested] = true;
403                    $traits[] = new \ReflectionClass($nested);
404                }
405            }
406        }
407
408        return $traits;
409    }
410
411    /**
412     * Collect the merged column mappings for a class.
413     *
414     * @param  \ReflectionClass<Model>  $reflection
415     * @param  class-string<Model>  $class
416     * @return PropertyMapping[]
417     * @throws \InvalidArgumentException
418     */
419    private static function collectProperties(\ReflectionClass $reflection, string $class): array
420    {
421        $properties = [];
422
423        foreach ($reflection->getProperties() as $property) {
424            $columnAttr = $property->getAttributes(Column::class)[0] ?? null;
425
426            if ($columnAttr === null) {
427                // A #[Backfill] without a #[Column] can never be consumed â€”
428                // a backfill rides the column's ADD. Fail fast here.
429                if ($property->getAttributes(Backfill::class) !== []) {
430                    throw new \InvalidArgumentException(
431                        "Model [{$class}] property [{$property->getName()}] declares #[Backfill] "
432                        . 'without a #[Column]; a backfill only applies to a declared column â€” '
433                        . 'add #[Column] or drop #[Backfill].'
434                    );
435                }
436
437                continue;
438            }
439
440            $column = $columnAttr->newInstance();
441
442            // The cast is driven by the PHP property type â€” capture the type
443            // NAME here, not the ReflectionType. Unions/intersections on a
444            // #[Column] property are a metadata error â€” fail fast here
445            // rather than at first hydration.
446            $type = $property->getType();
447
448            if ($type !== null && !($type instanceof \ReflectionNamedType)) {
449                throw new \InvalidArgumentException(
450                    "Model [{$class}] property [{$property->getName()}] has a "
451                    . 'union/intersection type; a column property must be a single named type.'
452                );
453            }
454
455            $propertyType = $type?->getName();
456
457            // Fail fast when the column type cannot store the field type â€”
458            // an array on an int column would decode garbage; an untyped
459            // property has no cast contract. Also enforces the string
460            // column's required length.
461            $column->assertTypeCompatible($propertyType, $class, $property->getName());
462
463            // Fail fast when a PHP property default would silently shadow
464            // the declared column default â€” an initialized property is
465            // always INSERTed explicitly, so a divergent pair writes one
466            // value from models and another from raw SQL.
467            $column->assertDefaultConsistent($property, $class);
468
469            // Resolve the DB column name BEFORE constructing the mapping, so
470            // every consumer downstream (primary-key handling, DDL
471            // emission, query building) reads a concrete `$column->name`
472            // instead of re-deriving the property-name default. The
473            // resolution lands on a REBUILT attribute (explicit construction
474            // â€” the attribute is immutable after construction), so
475            // `primaryKeys` consumers reading `$column->name` see the
476            // resolved name too.
477            $columnName = $column->name ?? $property->getName();
478
479            // The reserved `radiant_` prefix is the ORM's internal alias
480            // namespace â€” a declared column with it would collide with the
481            // row lift the moment the column rides an alias-bearing select.
482            // Fail fast HERE, at build, not at first pivot load.
483            Model::assertNotReservedPrefix($columnName, 'column');
484
485            $column = new Column(
486                type: $column->type,
487                name: $columnName,
488                primaryKey: $column->primaryKey,
489                autoIncrement: $column->autoIncrement,
490                nullable: $column->nullable,
491                unique: $column->unique,
492                index: $column->index,
493                default: $column->default,
494                length: $column->length,
495                precision: $column->precision,
496                foreign: $column->foreign,
497                onDelete: $column->onDelete,
498                onUpdate: $column->onUpdate,
499            );
500
501            $backfillAttr = $property->getAttributes(Backfill::class)[0] ?? null;
502
503            if ($backfillAttr !== null && $backfillAttr->newInstance()->value === null) {
504                throw new \InvalidArgumentException(
505                    "Model [{$class}] property [{$property->getName()}] declares #[Backfill(null)] â€” "
506                    . 'null is not a backfill value: it cannot fill a NOT NULL column and is a '
507                    . 'no-op on a nullable one. Drop #[Backfill] or declare a real value.'
508                );
509            }
510
511            $mapping = new PropertyMapping(
512                propertyName: $property->getName(),
513                columnName: $columnName,
514                column: $column,
515                property: $property,
516                owner: $property->getDeclaringClass()->getName(),
517                propertyType: $propertyType,
518                backfill: $backfillAttr === null ? null : $backfillAttr->newInstance()->value,
519            );
520
521            $properties[$mapping->propertyName] = $mapping;
522        }
523
524        return $properties;
525    }
526
527    /**
528     * Auto-declare the soft-delete column when the class uses SoftDeletes.
529     *
530     * A user-declared `#[Column]` of the same name wins; a non-null
531     * `deletedAtColumn()` override must match a declared column, and a
532     * non-datetime declared type is a fail-fast error.
533     *
534     * @param  \ReflectionClass<Model>  $reflection
535     * @param  class-string<Model>  $class
536     * @param  PropertyMapping[]  $properties
537     * @return string|null
538     * @throws \InvalidArgumentException
539     */
540    private static function applySoftDeletes(\ReflectionClass $reflection, string $class, array &$properties): ?string
541    {
542        if (!self::usesTrait($class, SoftDeletes::class)) {
543            return null;
544        }
545
546        // The trait guarantees the method, but $class is typed
547        // class-string<Model> and deletedAtColumn() lives on the trait â€”
548        // assert the method exists so PHPStan sees a verified call.
549        if (!\method_exists($class, 'deletedAtColumn')) {
550            throw new \LogicException(
551                "Model [{$class}] uses SoftDeletes but defines no deletedAtColumn()."
552            );
553        }
554
555        /** @var callable(): (string|null) $resolver */
556        $resolver = [$class, 'deletedAtColumn'];
557        // Resolve ONCE â€” the raw value distinguishes "default" (null) from
558        // "override" (non-null) and the resolved name is derived from it;
559        // calling the resolver again would re-invoke user code and could
560        // disagree with the name used for the lookup.
561        $override = $resolver();
562        $columnName = $override ?? 'deleted_at';
563
564        $declared = null;
565
566        foreach ($properties as $mapping) {
567            if ($mapping->columnName === $columnName) {
568                $declared = $mapping;
569                break;
570            }
571        }
572
573        if ($declared === null) {
574            if ($override !== null) {
575                // An override is an explicit claim that the column is
576                // declared â€” a renamed column with no matching #[Column]
577                // would otherwise be silently injected as a phantom.
578                throw new \InvalidArgumentException(
579                    "Model [{$class}] overrides deletedAtColumn() to [{$columnName}] "
580                    . "but declares no #[Column] with that name â€” declare it "
581                    . "(datetime, nullable) or return null for the default."
582                );
583            }
584
585            // No PHP property exists for a synthetic column, so the cast
586            // pipeline has no property type to drive from â€” pin it to the
587            // column type so consumers see a consistent datetime column.
588            // The shape comes from the softDeletes() helper itself, so the
589            // trait and the DDL helper cannot drift.
590            $properties[$columnName] = new PropertyMapping(
591                propertyName: $columnName,
592                columnName: $columnName,
593                column: self::columnsFromHelperShape(
594                    fn (Blueprint $blueprint) => $blueprint->softDeletes(),
595                )['deleted_at']
596                    ?? throw new \LogicException(
597                        'The softDeletes() helper emitted no [deleted_at] column; the '
598                        . 'trait auto-declaration contract is broken.'
599                    ),
600                property: null,
601                owner: $class,
602                propertyType: ColumnType::DateTime->value,
603            );
604        } elseif ($declared->column->type !== ColumnType::DateTime) {
605            throw new \InvalidArgumentException(
606                "Model [{$class}] declares soft-delete column [{$columnName}] "
607                . "as [{$declared->column->type->value}], but SoftDeletes requires datetime."
608            );
609        }
610
611        return $columnName;
612    }
613
614    /**
615     * Auto-declare the stamp columns when the class uses Timestamps.
616     *
617     * Mirrors {@see applySoftDeletes()}: a user-declared `#[Column]` of the
618     * same name wins; a non-null `createdAtColumn()`/`updatedAtColumn()`
619     * override must match a declared column; a non-datetime declared type
620     * is a fail-fast error. Undeclared columns are injected as synthetic
621     * NOT NULL datetime mappings shaped by the `timestamps()` helper.
622     *
623     * @param  \ReflectionClass<Model>  $reflection
624     * @param  class-string<Model>  $class
625     * @param  PropertyMapping[]  $properties
626     * @return void
627     * @throws \InvalidArgumentException
628     */
629    private static function applyTimestamps(\ReflectionClass $reflection, string $class, array &$properties): void
630    {
631        if (!self::usesTrait($class, Timestamps::class)) {
632            return;
633        }
634
635        foreach (['createdAtColumn', 'updatedAtColumn'] as $method) {
636            if (!\method_exists($class, $method)) {
637                throw new \LogicException("Model [{$class}] uses Timestamps but defines no {$method}().");
638            }
639        }
640
641        /** @var callable(): (string|null) $createdResolver */
642        $createdResolver = [$class, 'createdAtColumn'];
643        /** @var callable(): (string|null) $updatedResolver */
644        $updatedResolver = [$class, 'updatedAtColumn'];
645
646        $overrides = [
647            'createdAtColumn' => ['created_at', $createdResolver()],
648            'updatedAtColumn' => ['updated_at', $updatedResolver()],
649        ];
650
651        $stamps = self::columnsFromHelperShape(
652            fn (Blueprint $blueprint) => $blueprint->timestamps(),
653        );
654
655        foreach ($overrides as $method => [$defaultName, $override]) {
656            $columnName = $override ?? $defaultName;
657
658            $declared = null;
659
660            foreach ($properties as $mapping) {
661                if ($mapping->columnName === $columnName) {
662                    $declared = $mapping;
663                    break;
664                }
665            }
666
667            if ($declared === null) {
668                if ($override !== null) {
669                    throw new \InvalidArgumentException(
670                        "Model [{$class}] overrides {$method}() to [{$columnName}] "
671                        . "but declares no #[Column] with that name â€” declare it "
672                        . '(datetime, nullable) or return null for the default.'
673                    );
674                }
675
676                $properties[$columnName] = new PropertyMapping(
677                    propertyName: $columnName,
678                    columnName: $columnName,
679                    column: $stamps[$defaultName]
680                        ?? throw new \LogicException(
681                            "The timestamps() helper emitted no [{$defaultName}] column; the "
682                            . 'trait auto-declaration contract is broken.'
683                        ),
684                    property: null,
685                    owner: $class,
686                    propertyType: ColumnType::DateTime->value,
687                );
688            } elseif ($declared->column->type !== ColumnType::DateTime) {
689                throw new \InvalidArgumentException(
690                    "Model [{$class}] declares stamp column [{$columnName}] "
691                    . "as [{$declared->column->type->value}], but Timestamps requires datetime."
692                );
693            }
694        }
695    }
696
697    /**
698     * The columns a Blueprint helper emits, keyed by column name.
699     *
700     * The helper is invoked on a scratch blueprint and each emitted column
701     * shape becomes a synthetic `Column` â€” the traits' auto-declared
702     * columns and the DDL helpers share one source of truth, so they
703     * cannot drift. Every shape field is carried over, so a helper that
704     * grows a default, length, or flag keeps it on the synthetic column.
705     *
706     * @param  callable(Blueprint): Blueprint  $helper  Invoked with a scratch blueprint; must append the helper columns.
707     * @return array<string, Column>
708     */
709    private static function columnsFromHelperShape(callable $helper): array
710    {
711        $columns = [];
712
713        foreach ($helper(new Blueprint('radiant_helper'))->getColumns() as $shape) {
714            $columns[$shape['name']] = new Column(
715                type: $shape['type'],
716                name: $shape['name'],
717                primaryKey: $shape['primaryKey'],
718                autoIncrement: $shape['autoIncrement'],
719                nullable: $shape['nullable'],
720                unique: $shape['unique'],
721                index: $shape['index'],
722                default: $shape['default'],
723                length: $shape['length'],
724                precision: $shape['precision'],
725                foreign: $shape['foreign'],
726                onDelete: $shape['onDelete'],
727                onUpdate: $shape['onUpdate'],
728            );
729        }
730
731        return $columns;
732    }
733
734    /**
735     * Inject the synthetic morph columns declared by class-level
736     * `#[Morphs]` attributes.
737     *
738     * Each attribute emits `{name}_type` (string) and `{name}_id` (the
739     * attribute's keyType). A user-declared `#[Column]` with the same name
740     * wins, but a declared column whose type cannot hold the morph value
741     * fails fast.
742     *
743     * @param  \ReflectionClass<Model>  $reflection
744     * @param  class-string<Model>  $class
745     * @param  PropertyMapping[]  $properties
746     * @return void
747     * @throws \InvalidArgumentException
748     */
749    private static function applyMorphs(\ReflectionClass $reflection, string $class, array &$properties): void
750    {
751        $attributes = $reflection->getAttributes(Morphs::class);
752
753        if ($attributes === []) {
754            return;
755        }
756
757        $seen = [];
758
759        foreach ($attributes as $attribute) {
760            /** @var Morphs $morphs */
761            $morphs = $attribute->newInstance();
762
763            if ($morphs->name === '') {
764                throw new \InvalidArgumentException(
765                    "Model [{$class}] declares #[Morphs] with an empty name; "
766                    . 'a morph pair requires a non-empty name.'
767                );
768            }
769
770            if (isset($seen[$morphs->name])) {
771                throw new \InvalidArgumentException(
772                    "Model [{$class}] declares #[Morphs(name: '{$morphs->name}')] twice; "
773                    . 'a morph name must be unique per class.'
774                );
775            }
776
777            $morphs->assertKeyTypeCapable();
778
779            $seen[$morphs->name] = true;
780
781            self::injectMorphColumn($class, $properties, $morphs->typeColumn(), ColumnType::String, 255, 'string', $morphs->nullable);
782            self::injectMorphColumn($class, $properties, $morphs->keyColumn(), $morphs->keyType, null, $morphs->keyType->value, $morphs->nullable);
783        }
784    }
785
786    /**
787     * Inject (or validate) ONE morph column on the class.
788     *
789     * @param  class-string<Model>  $class
790     * @param  PropertyMapping[]  $properties
791     * @param  string  $columnName
792     * @param  ColumnType  $type
793     * @param  int|null  $length
794     * @param  string  $typeLabel
795     * @param  bool  $nullable
796     * @return void
797     * @throws \InvalidArgumentException
798     */
799    private static function injectMorphColumn(
800        string $class,
801        array &$properties,
802        string $columnName,
803        ColumnType $type,
804        ?int $length,
805        string $typeLabel,
806        bool $nullable,
807    ): void {
808        foreach ($properties as $mapping) {
809            if ($mapping->columnName !== $columnName) {
810                continue;
811            }
812
813            // A user declaration wins â€” but only if it can actually hold
814            // the morph value. A morph relation writes class-strings and
815            // PK values through these columns; a wrong type would corrupt
816            // every round-trip.
817            if ($mapping->column->type !== $type) {
818                throw new \InvalidArgumentException(
819                    "Model [{$class}] declares column [{$columnName}] as "
820                    . "[{$mapping->column->type->value}], but the #[Morphs] pair "
821                    . "requires {$typeLabel}."
822                );
823            }
824
825            if ($type === ColumnType::String && ($mapping->column->length ?? 0) < ($length ?? 0)) {
826                throw new \InvalidArgumentException(
827                    "Model [{$class}] declares column [{$columnName}] with length "
828                    . "[{$mapping->column->length}]; the #[Morphs] type column requires "
829                    . "a length of at least {$length} (a full class-string must fit)."
830                );
831            }
832
833            return;
834        }
835
836        // No PHP property exists for a synthetic column â€” pin the property
837        // type to the column type so the cast pipeline sees a consistent
838        // scalar (the same convention applySoftDeletes() applies).
839        //
840        // The morph name is caller-supplied (`#[Morphs(name: ...)]`) â€” the
841        // same reserved-prefix guard as declared columns applies.
842        Model::assertNotReservedPrefix($columnName, 'morph column');
843
844        $properties[$columnName] = new PropertyMapping(
845            propertyName: $columnName,
846            columnName: $columnName,
847            column: new Column(
848                type: $type,
849                name: $columnName,
850                nullable: $nullable,
851                length: $length,
852            ),
853            property: null,
854            owner: $class,
855            propertyType: $type->value,
856        );
857    }
858
859    /**
860     * Resolve the table name for a class (rules 1–5).
861     *
862     * Rule 4 â€” a class with no columns anywhere in its chain, and abstract
863     * classes, own no table. Rule 2 â€” a concrete subclass of a table-owning
864     * ancestor that adds columns but declares no `#[Table]` is a build
865     * error. Rule 3 â€” a concrete subclass declaring its own `#[Table]` is a
866     * multi-table-inheritance child. Rule 1 â€” a behavior-only subclass
867     * inherits its ancestor's table. Rule 5 â€” otherwise the snake-cased
868     * plural of the short class name.
869     *
870     * @param  \ReflectionClass<Model>  $reflection
871     * @param  class-string<Model>  $class
872     * @param  PropertyMapping[]  $properties
873     * @return array{string|null, class-string<Model>|null}
874     * @throws \InvalidArgumentException
875     */
876    private static function resolveTableName(
877        \ReflectionClass $reflection,
878        string $class,
879        array $properties,
880    ): array {
881        // Rule 4 â€” abstract classes are never instantiated; their columns
882        // belong to the first concrete descendant.
883        if ($reflection->isAbstract()) {
884            return [null, null];
885        }
886
887        // An explicit #[Table(name)] on a column-less concrete model IS a
888        // declaration: the class names a table without owning columns â€”
889        // pivot-table pointers (belongsToMany(table: Pivot::class)) above
890        // all. Empty-name stays a mis-declaration.
891        /** @var \ReflectionAttribute<Table>|null $explicitTable */
892        $explicitTable = $reflection->getAttributes(Table::class)[0] ?? null;
893
894        if ($properties === [] && $explicitTable !== null) {
895            $name = $explicitTable->newInstance()->name;
896
897            if ($name === '') {
898                throw new \InvalidArgumentException(
899                    "Model [{$class}] declares #[Table(name: '')] â€” an empty name is "
900                    . 'a mis-declaration; omit the argument to keep the convention.'
901                );
902            }
903
904            return [$name, null];
905        }
906
907        // Rule 4 â€” no columns anywhere in the chain, no table (abstract or
908        // a deliberately concrete organizational base alike).
909        if ($properties === []) {
910            return [null, null];
911        }
912
913        $ownColumns = array_filter($properties, fn (PropertyMapping $m) => $m->owner === $class);
914        $declaresOwnColumns = $ownColumns !== [];
915
916        // Is there a concrete, TABLE-OWNING ancestor? A table-owning
917        // ancestor makes this class a subclass of a table; an abstract
918        // chain above makes it the first table owner in its chain.
919        $ancestorTable = self::nearestAncestorTable($class);
920
921        if ($ancestorTable !== null) {
922            // Rule 2 â€” inheriting a table while adding columns of your own,
923            // with no `#[Table]` to say where the columns go.
924            if ($declaresOwnColumns) {
925                $ownTable = $reflection->getAttributes(Table::class)[0] ?? null;
926
927                if ($ownTable === null) {
928                    throw new \InvalidArgumentException(
929                        "Model [{$class}] inherits columns from an ancestor and declares columns of its own. "
930                        . 'Declare #[Table(name: ...)] to give the new columns a table of their own '
931                        . '(multi-table inheritance), model the link with composition (two '
932                        . 'independent models + a plain FK column), or make the subclass '
933                        . 'behavior-only (no new columns, no #[Table]).'
934                    );
935                }
936            }
937
938            $ownTable = $reflection->getAttributes(Table::class)[0] ?? null;
939
940            // Rule 3 â€” MTI: own columns + own #[Table] = the child's own
941            // table. A behavior-only subclass declaring #[Table] (a second
942            // table with nothing in it) stays a build error.
943            if ($ownTable !== null) {
944                if (!$declaresOwnColumns) {
945                    throw new \InvalidArgumentException(
946                        "Model [{$class}] inherits ALL columns from an ancestor and declares its own #[Table]. "
947                        . 'A second table holding none of the columns is a mis-modeling: '
948                        . 'drop the #[Table] to share the ancestor\'s table, or add the columns '
949                        . 'that belong on the new table.'
950                    );
951                }
952
953                /** @var Table $tableAttribute */
954                $tableAttribute = $ownTable->newInstance();
955                $name = $tableAttribute->name;
956
957                if ($name === '') {
958                    throw new \InvalidArgumentException(
959                        "Model [{$class}] declares #[Table(name: '')] â€” an empty name is "
960                        . 'a mis-declaration; omit the argument to keep the convention.'
961                    );
962                }
963
964                /** @var class-string<Model> $parentModel */
965                $parentModel = get_parent_class($class);
966
967                while (!is_a($parentModel, Model::class, true)
968                    || self::for($parentModel)->tableName === null) {
969                    $parentModel = get_parent_class($parentModel);
970
971                    if ($parentModel === false) {
972                        throw new \LogicException(
973                            "Model [{$class}] resolved ancestor table [{$ancestorTable}] but no "
974                            . 'table-owning ancestor model to pair it with.'
975                        );
976                    }
977                }
978
979                return [$name, $parentModel];
980            }
981
982            // Rule 1 â€” behavior-only subclass: provably interchangeable
983            // with its ancestor; share its table.
984            return [$ancestorTable, null];
985        }
986
987        // Intelephense mis-resolves the ReflectionAttribute template to the
988        // reflection TARGET (Model) instead of the ATTRIBUTE class (Table) â€”
989        // so newInstance() looks like it returns Model, which has no $name.
990        // The @var pins the template to the attribute class; the runtime
991        // truth is unchanged.
992        /** @var \ReflectionAttribute<Table>|null $ownTable */
993        $ownTable = $reflection->getAttributes(Table::class)[0] ?? null;
994
995        if ($ownTable !== null) {
996            // An explicit #[Table(name: '...')] wins. The name is optional â€”
997            // #[Table] with no name keeps the convention â€” but an explicitly
998            // EMPTY name is a mis-declaration: fail fast.
999            $name = $ownTable->newInstance()->name;
1000
1001            if ($name === '') {
1002                throw new \InvalidArgumentException(
1003                    "Model [{$class}] declares #[Table(name: '')] â€” an empty name is "
1004                    . 'a mis-declaration; omit the argument to keep the convention.'
1005                );
1006            }
1007
1008            return [$name ?? self::defaultTableName($reflection->getShortName()), null];
1009        }
1010
1011        return [self::defaultTableName($reflection->getShortName()), null]; // rule 5
1012    }
1013
1014    /**
1015     * Collect and validate the composite constraints from the class
1016     * hierarchy, most-derived first.
1017     *
1018     * @param  \ReflectionClass<Model>  $reflection
1019     * @param  class-string<Model>  $class
1020     * @param  PropertyMapping[]  $properties
1021     * @return array{list<Unique>, list<Index>, list<ForeignKey>, list<Check>}
1022     * @throws \InvalidArgumentException
1023     */
1024    private static function collectConstraints(
1025        \ReflectionClass $reflection,
1026        string $class,
1027        array $properties,
1028    ): array {
1029        $uniques = [];
1030        $indexes = [];
1031        $foreignKeys = [];
1032        $checks = [];
1033
1034        for ($current = $class; $current !== false; $current = get_parent_class($current)) {
1035            $level = new \ReflectionClass($current);
1036
1037            foreach ($level->getAttributes(Unique::class) as $attribute) {
1038                /** @var Unique $unique */
1039                $unique = $attribute->newInstance();
1040                self::validateConstraintColumns($unique->columns, $properties, $class, 'unique');
1041                self::validateNoFlagDuplicates($unique->columns, $properties, $class, 'unique');
1042                $uniques[] = $unique;
1043            }
1044
1045            foreach ($level->getAttributes(Index::class) as $attribute) {
1046                /** @var Index $index */
1047                $index = $attribute->newInstance();
1048                self::validateConstraintColumns($index->columns, $properties, $class, 'index');
1049                self::validateNoFlagDuplicates($index->columns, $properties, $class, 'index');
1050                $indexes[] = $index;
1051            }
1052
1053            foreach ($level->getAttributes(ForeignKey::class) as $attribute) {
1054                /** @var ForeignKey $foreignKey */
1055                $foreignKey = $attribute->newInstance();
1056
1057                if ($foreignKey->columns === []) {
1058                    throw new \InvalidArgumentException(
1059                        "Model [{$class}] declares a #[ForeignKey] with an empty column list; "
1060                        . 'a foreign key requires at least one column.'
1061                    );
1062                }
1063
1064                if ($foreignKey->referencesColumns !== null && $foreignKey->referencesColumns === []) {
1065                    throw new \InvalidArgumentException(
1066                        "Model [{$class}] declares a #[ForeignKey] with an empty references column list; "
1067                        . 'a foreign key requires at least one referenced column.'
1068                    );
1069                }
1070
1071                $resolvedReferencesColumns = $foreignKey->resolvedReferencesColumns();
1072
1073                if ($resolvedReferencesColumns === []) {
1074                    throw new \InvalidArgumentException(
1075                        "Model [{$class}] declares a #[ForeignKey] referencing model "
1076                        . "[{$foreignKey->references}], which declares no primary key; a model "
1077                        . 'reference resolves its columns from the target PK â€” declare the '
1078                        . 'target\'s key, or reference a table name with explicit columns.'
1079                    );
1080                }
1081
1082                if (count($foreignKey->columns) !== count($resolvedReferencesColumns)) {
1083                    throw new \InvalidArgumentException(
1084                        "Model [{$class}] declares a #[ForeignKey] whose columns and references "
1085                        . 'must have matching arity; got ' . count($foreignKey->columns) . ' and '
1086                        . count($resolvedReferencesColumns) . '.'
1087                    );
1088                }
1089
1090                self::validateConstraintColumns($foreignKey->columns, $properties, $class, 'foreign key');
1091                self::validateNoFlagDuplicates($foreignKey->columns, $properties, $class, 'foreign');
1092                $foreignKey->resolvedReferences(); // resolves + validates model-class references
1093                $foreignKeys[] = $foreignKey;
1094            }
1095
1096            foreach ($level->getAttributes(Check::class) as $attribute) {
1097                /** @var Check $check */
1098                $check = $attribute->newInstance();
1099
1100                if (trim($check->expression) === '') {
1101                    throw new \InvalidArgumentException(
1102                        "Model [{$class}] declares a #[Check] with an empty expression; "
1103                        . 'a CHECK constraint requires a non-empty predicate.'
1104                    );
1105                }
1106
1107                $checks[] = $check;
1108            }
1109        }
1110
1111        return [$uniques, $indexes, $foreignKeys, $checks];
1112    }
1113
1114    /**
1115     * Assert every constraint column name resolves to a declared column.
1116     *
1117     * @param  list<string>  $columns
1118     * @param  PropertyMapping[]  $properties
1119     * @param  class-string<Model>  $class
1120     * @param  string  $constraintKind
1121     * @throws \InvalidArgumentException
1122     */
1123    private static function validateConstraintColumns(
1124        array $columns,
1125        array $properties,
1126        string $class,
1127        string $constraintKind,
1128    ): void {
1129        $declared = [];
1130
1131        foreach ($properties as $mapping) {
1132            $declared[$mapping->columnName] = true;
1133        }
1134
1135        foreach ($columns as $column) {
1136            if (!isset($declared[$column])) {
1137                throw new \InvalidArgumentException(
1138                    "Model [{$class}] declares a {$constraintKind} constraint on unknown column "
1139                    . "[{$column}]. A constraint's columns must match the model's declared "
1140                    . 'column names.'
1141                );
1142            }
1143        }
1144    }
1145
1146    /**
1147     * Assert a single-column attribute does not duplicate a `#[Column]` flag.
1148     *
1149     * The check walks the ancestor chain too: a redeclared column hides the
1150     * ancestor's mapping in `$properties`, but the ancestor's flag still
1151     * declares the constraint at its level.
1152     *
1153     * @param  list<string>  $columns
1154     * @param  PropertyMapping[]  $properties
1155     * @param  class-string<Model>  $class
1156     * @param  string  $flag
1157     * @throws \InvalidArgumentException
1158     */
1159    private static function validateNoFlagDuplicates(
1160        array $columns,
1161        array $properties,
1162        string $class,
1163        string $flag,
1164    ): void {
1165        if (count($columns) !== 1) {
1166            return; // multi-column constraints have no flag equivalent.
1167        }
1168
1169        foreach ($properties as $mapping) {
1170            if (
1171                $mapping->columnName === $columns[0]
1172                && $mapping->column->{$flag} === true
1173            ) {
1174                throw new \InvalidArgumentException(
1175                    "Model [{$class}] declares column [{$columns[0]}] with `{$flag}: true` AND a "
1176                    . "class-level attribute covering the same column â€” a duplicate declaration. "
1177                    . "Use one mechanism: the flag for the simple single-column case, or the "
1178                    . 'attribute when you need a name/composite/actions.'
1179                );
1180            }
1181        }
1182
1183        // Ancestor flags: a redeclared column hides the ancestor's mapping
1184        // in $properties, but the ancestor's `#[Column]` flag still declares
1185        // the constraint at its level. Walk the chain and check the raw
1186        // property attributes there too.
1187        for ($ancestor = get_parent_class($class); $ancestor !== false; $ancestor = get_parent_class($ancestor)) {
1188            if (!is_a($ancestor, Model::class, true)) {
1189                continue;
1190            }
1191
1192            $level = new \ReflectionClass($ancestor);
1193
1194            foreach ($level->getProperties() as $property) {
1195                foreach ($property->getAttributes(Column::class) as $attribute) {
1196                    /** @var Column $columnAttr */
1197                    $columnAttr = $attribute->newInstance();
1198
1199                    $columnName = $columnAttr->name ?? $property->getName();
1200
1201                    if ($columnName === $columns[0] && $columnAttr->{$flag} === true) {
1202                        throw new \InvalidArgumentException(
1203                            "Model [{$class}] declares a class-level {$flag} constraint on column [{$columns[0]}], "
1204                            . "but ancestor [{$ancestor}] already declares the same column with `{$flag}: true` â€” "
1205                            . 'a duplicate declaration across the inheritance chain. Keep the flag on the '
1206                            . 'declaring ancestor, or the attribute on the child, not both.'
1207                        );
1208                    }
1209                }
1210            }
1211        }
1212    }
1213
1214    /**
1215     * Whether a class (or any ancestor) uses a trait â€” directly or nested
1216     * inside another trait.
1217     *
1218     * @param  class-string<Model>  $class
1219     * @param  string  $trait
1220     * @return bool
1221     */
1222    private static function usesTrait(string $class, string $trait): bool
1223    {
1224        for ($current = $class; $current !== false; $current = get_parent_class($current)) {
1225            $traits = class_uses($current);
1226
1227            if ($traits === false) {
1228                continue;
1229            }
1230
1231            foreach ($traits as $used) {
1232                if ($used === $trait || self::traitUses($used, $trait)) {
1233                    return true;
1234                }
1235            }
1236        }
1237
1238        return false;
1239    }
1240
1241    /**
1242     * Whether a trait (directly or via another trait) uses the given trait.
1243     *
1244     * @param  string  $trait
1245     * @param  string  $target
1246     * @return bool
1247     */
1248    private static function traitUses(string $trait, string $target): bool
1249    {
1250        $traits = class_uses($trait);
1251
1252        if ($traits === false) {
1253            return false;
1254        }
1255
1256        foreach ($traits as $nested) {
1257            if ($nested === $target || self::traitUses($nested, $target)) {
1258                return true;
1259            }
1260        }
1261
1262        return false;
1263    }
1264
1265    /**
1266     * Walk up the parent chain for the nearest resolved table name (rule 1).
1267     *
1268     * @param  class-string<Model>  $class
1269     * @return string|null
1270     */
1271    private static function nearestAncestorTable(string $class): ?string
1272    {
1273        for ($current = get_parent_class($class); $current !== false; $current = get_parent_class($current)) {
1274            // Invariant: every ancestor of a Model IS a Model subclass â€”
1275            // the guard makes the type flow provable instead of assumed.
1276            if (!is_a($current, Model::class, true)) {
1277                throw new \LogicException(
1278                    "Model [{$class}] extends [{$current}], which is not a "
1279                    . Model::class . ' subclass; the metadata engine requires it.'
1280                );
1281            }
1282
1283            $table = self::for($current)->tableName;
1284
1285            if ($table !== null) {
1286                return $table;
1287            }
1288        }
1289
1290        return null;
1291    }
1292
1293    /**
1294     * The default table name for a class: snake-cased plural of its short
1295     * name (`User` â†’ `users`).
1296     *
1297     * @param  string  $shortName
1298     * @return string
1299     */
1300    private static function defaultTableName(string $shortName): string
1301    {
1302        // camelCase / PascalCase â†’ snake_case: insert _ before each
1303        // uppercase that follows a lowercase or digit, then lowercase all.
1304        $snake = strtolower((string) preg_replace('/(?<=[a-z0-9])([A-Z])/', '_$1', $shortName));
1305
1306        // Naive plural: -es after s, x, z, ch, sh; consonant+y â†’ -ies;
1307        // otherwise -s.
1308        if (preg_match('/(s|x|z|ch|sh)$/', $snake) === 1) {
1309            return $snake . 'es';
1310        }
1311
1312        if (preg_match('/[^aeiou]y$/', $snake) === 1) {
1313            return substr($snake, 0, -1) . 'ies';
1314        }
1315
1316        return $snake . 's';
1317    }
1318
1319    // ---- Multi-table inheritance (MTI) derivation ----
1320
1321    /**
1322     * Derive an MTI child's primary-key mapping from its parent's.
1323     *
1324     * The child declares no key of its own â€” the shared PK is the link
1325     * between the two tables. The parent's PK mapping is cloned per-class
1326     * with `autoIncrement` overridden to false: only the root table
1327     * generates the id.
1328     *
1329     * @param  \ReflectionClass<Model>  $reflection
1330     * @param  class-string<Model>  $class
1331     * @param  PropertyMapping[]  $properties
1332     * @param  class-string<Model>  $parentModel
1333     * @return PropertyMapping[]
1334     * @throws \InvalidArgumentException
1335     */
1336    private static function deriveMtiChildKey(
1337        \ReflectionClass $reflection,
1338        string $class,
1339        array $properties,
1340        string $parentModel,
1341    ): array {
1342        $parentMetadata = self::for($parentModel);
1343        $parentKeys = $parentMetadata->primaryKeys;
1344
1345        if (count($parentKeys) !== 1 || $parentKeys[0]->name === null) {
1346            throw new \InvalidArgumentException(
1347                "Model [{$class}] extends table-owning [{$parentModel}], whose primary key is "
1348                . 'composite or unnamed. Multi-table inheritance requires a single named '
1349                . 'primary key on the root table.'
1350            );
1351        }
1352
1353        $pkName = $parentKeys[0]->name;
1354
1355        // The parent's key column must not already exist as a child-side
1356        // declaration â€” the child inherits the property through PHP and
1357        // the derived mapping below IS the child's record of it.
1358        foreach ($properties as $mapping) {
1359            if ($mapping->owner !== $class) {
1360                continue;
1361            }
1362
1363            if ($mapping->columnName === $pkName) {
1364                throw new \InvalidArgumentException(
1365                    "Model [{$class}] redeclares primary-key column [{$pkName}]. The key is "
1366                    . "derived from [{$parentModel}] â€” the shared PK IS the table link; "
1367                    . 'declare only the new columns.'
1368                );
1369            }
1370        }
1371
1372        // Explicit construction replaces the historical `clone $parentKeys[0]`
1373        // + post-construction `$derived->autoIncrement = false` write â€” the
1374        // attribute is immutable after construction.
1375        $derived = new Column(
1376            type: $parentKeys[0]->type,
1377            name: $pkName,
1378            primaryKey: $parentKeys[0]->primaryKey,
1379            autoIncrement: false,
1380            nullable: $parentKeys[0]->nullable,
1381            unique: $parentKeys[0]->unique,
1382            index: $parentKeys[0]->index,
1383            default: $parentKeys[0]->default,
1384            length: $parentKeys[0]->length,
1385            foreign: $parentKeys[0]->foreign,
1386            onDelete: $parentKeys[0]->onDelete,
1387            onUpdate: $parentKeys[0]->onUpdate,
1388        );
1389
1390        $parentMapping = null;
1391
1392        foreach ($parentMetadata->properties as $mapping) {
1393            if ($mapping->columnName === $pkName) {
1394                $parentMapping = $mapping;
1395                break;
1396            }
1397        }
1398
1399        if ($parentMapping === null) {
1400            throw new \LogicException(
1401                "Model [{$parentModel}] declares primary key [{$pkName}] with no matching mapping."
1402            );
1403        }
1404
1405        $properties[$parentMapping->propertyName] = new PropertyMapping(
1406            propertyName: $parentMapping->propertyName,
1407            columnName: $pkName,
1408            column: $derived,
1409            property: $parentMapping->property,
1410            owner: $class,
1411        );
1412
1413        ksort($properties);
1414
1415        return $properties;
1416    }
1417}