Lines 100.00% 293 / 293
Methods 100.00% 22 / 22
Classes 100.00% 1 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 assertTypeCompatible 100.00% 43 / 43 100.00% 1 / 1 10
 enumCompatibility 100.00% 8 / 8 100.00% 1 / 1 4
 objectCompatibility 100.00% 5 / 5 100.00% 1 / 1 4
 resolvedEnumValues 100.00% 15 / 15 100.00% 1 / 1 5
 assertPrecisionCompatible 100.00% 15 / 15 100.00% 1 / 1 6
 assertDefaultConsistent 100.00% 22 / 22 100.00% 1 / 1 5
 typeCompatibility 100.00% 25 / 25 100.00% 1 / 1 1
 decode 100.00% 17 / 17 100.00% 1 / 1 16
 decodeDatetime 100.00% 11 / 11 100.00% 1 / 1 5
 decodeUnixTimestamp 100.00% 11 / 11 100.00% 1 / 1 4
 encodeJson 100.00% 15 / 15 100.00% 1 / 1 6
 encodeJsonObject 100.00% 8 / 8 100.00% 1 / 1 3
 decodeJson 100.00% 8 / 8 100.00% 1 / 1 2
 decodeJsonObject 100.00% 12 / 12 100.00% 1 / 1 3
 encode 100.00% 23 / 23 100.00% 1 / 1 17
 encodeUuid 100.00% 3 / 3 100.00% 1 / 1 2
 isEnumPropertyType 100.00% 1 / 1 100.00% 1 / 1 1
 encodeEnum 100.00% 22 / 22 100.00% 1 / 1 14
 decodeEnum 100.00% 21 / 21 100.00% 1 / 1 13
 assertUuid 100.00% 4 / 4 100.00% 1 / 1 2
 encodePrecisionDatetime 100.00% 3 / 3 100.00% 1 / 1 1
23final class Column
24{
25    /**
26     * Create a column declaration.
27     *
28     * @param  ColumnType  $type
29     * @param  string|null  $name
30     * @param  bool  $primaryKey
31     * @param  bool  $autoIncrement
32     * @param  bool  $nullable
33     * @param  bool  $unique
34     * @param  bool  $index
35     * @param  int|null  $length
36     * @param  int|null  $precision  Fractional-seconds digits (1–6) or null (store whole seconds) for datetime columns; total digits (1–65) for decimal columns.
37     * @param  int|null  $scale  Fractional digits for a decimal column (0–`precision`).
38     * @param  list<string>|class-string<\UnitEnum>|null  $values  The allowed values for an enum column — a literal list, or an enum class-string resolved to its cases at metadata build (the column always matches the enum; migrations keep literal lists so schema history stays reproducible).
39     * @param  mixed  $default
40     * @param  string|null  $foreign
41     * @param  ForeignKeyAction|string|null  $onDelete
42     * @param  ForeignKeyAction|string|null  $onUpdate
43     */
44    final public function __construct(
45        public ColumnType $type,
46        public ?string $name = null,
47        public bool $primaryKey = false,
48        public bool $autoIncrement = false,
49        public bool $nullable = false,
50        public bool $unique = false,
51        public bool $index = false,
52        public mixed $default = null,
53        public ?int $length = null,
54        public ?int $precision = null,
55        public ?int $scale = null,
56        public array|string|null $values = null,
57        public ?string $foreign = null,
58        public ForeignKeyAction|string|null $onDelete = null,
59        public ForeignKeyAction|string|null $onUpdate = null,
60    ) {
61    }
62
63
64    // ---- Type compatibility (fail-fast at metadata build) ----
65
66    /**
67     * Assert the column type can store the field type — and that the
68     * column carries everything the field needs.
69     *
70     * @param  string|null  $propertyType
71     * @param  class-string<\BlueprintAU\Radiant\Model>  $class
72     * @param  string  $property
73     * @return void
74     * @throws \InvalidArgumentException
75     */
76    public function assertTypeCompatible(?string $propertyType, string $class, string $property): void
77    {
78        if ($propertyType === null) {
79            throw new \InvalidArgumentException(
80                "Model [{$class}] property [{$property}] is untyped; a #[Column] property "
81                . 'must declare a single named type so the cast pipeline has a contract.'
82            );
83        }
84
85        if ($this->primaryKey && !$this->type->primaryKeyCapable()) {
86            throw new \InvalidArgumentException(
87                "Model [{$class}] property [{$property}] declares a [{$this->type->value}] "
88                . 'column as the primary key; a primary key requires an integer, string, char, '
89                . 'or uuid type.'
90            );
91        }
92
93        $compatible = self::typeCompatibility()[$propertyType]
94            ?? $this->enumCompatibility($propertyType)
95            ?? $this->objectCompatibility($propertyType)
96            ?? [];
97
98        if (in_array($this->type, $compatible, true)) {
99            if ($this->type === ColumnType::String && $this->length === null) {
100                throw new \InvalidArgumentException(
101                    "Model [{$class}] property [{$property}] declares a string "
102                    . 'column without a length; declare `length:` (mirroring the schema '
103                    . 'layer, where a string column requires one).'
104                );
105            }
106
107            // An enum column's storage length defaults to its longest
108            // value — mirroring Blueprint::enum(). A class-string values
109            // source resolves to its case values (backed) or names (unit)
110            // at metadata build, so the column always matches the enum.
111            if ($this->type === ColumnType::Enum) {
112                $values = $this->resolvedEnumValues();
113
114                if ($values === []) {
115                    throw new \InvalidArgumentException(
116                        "Model [{$class}] property [{$property}] declares an enum "
117                        . 'column without values; declare `values:` with the allowed strings '
118                        . 'or an enum class-string.'
119                    );
120                }
121
122                $this->length ??= max(array_map(strlen(...), $values));
123            }
124
125            $this->assertPrecisionCompatible($propertyType, $class, $property);
126
127            return;
128        }
129
130        throw new \InvalidArgumentException(sprintf(
131            "Model [%s] property [%s] declares a [%s] column, which cannot store the "
132                . "field type [%s]. Compatible column types for [%s]: %s.",
133            $class,
134            $property,
135            $this->type->value,
136            $propertyType,
137            $propertyType,
138            $compatible === [] ? 'none' : implode(', ', array_map(fn (ColumnType $t) => $t->value, $compatible)),
139        ));
140    }
141
142    /**
143     * The compatible column types for a PHP enum property type — an
144     * int-backed enum stores in an integer column, a string-backed or
145     * unit enum in a string-family column.
146     *
147     * @param  string  $propertyType
148     * @return list<ColumnType>|null Null when the type is not an enum.
149     */
150    private function enumCompatibility(string $propertyType): array|null
151    {
152        if (!enum_exists($propertyType)) {
153            return null;
154        }
155
156        if (is_a($propertyType, \BackedEnum::class, true)) {
157            $backing = $propertyType::cases()[0]->value;
158
159            return is_int($backing)
160                ? [ColumnType::Int, ColumnType::BigInt]
161                : [ColumnType::String, ColumnType::Char, ColumnType::Enum];
162        }
163
164        return [ColumnType::String, ColumnType::Char, ColumnType::Enum];
165    }
166
167    /**
168     * The compatible column types for an object property type — a
169     * JsonStorable class-string stores in a Json column.
170     *
171     * @param  string  $propertyType
172     * @return list<ColumnType>|null Null when the type is not a class.
173     */
174    private function objectCompatibility(string $propertyType): array|null
175    {
176        if (!class_exists($propertyType) || interface_exists($propertyType)) {
177            return null;
178        }
179
180        if (!is_a($propertyType, \BlueprintAU\Radiant\Database\Query\JsonStorable::class, true)) {
181            return null;
182        }
183
184        return [ColumnType::Json];
185    }
186
187    /**
188     * The enum column's allowed values, resolved from the declared
189     * source — a literal list passes through; an enum class-string
190     * resolves to its case values (backed) or case names (unit).
191     *
192     * @return list<string>
193     * @throws \InvalidArgumentException
194     */
195    public function resolvedEnumValues(): array
196    {
197        if (is_string($this->values)) {
198            if (!enum_exists($this->values)) {
199                throw new \InvalidArgumentException(
200                    "The enum column values source [{$this->values}] is not an enum class-string."
201                );
202            }
203
204            $cases = $this->values::cases();
205
206            if ($cases === []) {
207                throw new \InvalidArgumentException(
208                    "The enum [{$this->values}] declares no cases; an enum column needs at least one value."
209                );
210            }
211
212            return array_map(
213                fn (\UnitEnum $case): string => $case instanceof \BackedEnum ? (string) $case->value : $case->name,
214                $cases,
215            );
216        }
217
218        return $this->values ?? [];
219    }
220
221    /**
222     * Assert a declared fractional-seconds precision is usable.
223     *
224     * Unix timestamps are whole seconds, so precision is meaningless on an
225     * int-typed Timestamp column; no portable dialect stores more than 6
226     * fractional digits.
227     *
228     * @param  string|null  $propertyType
229     * @param  class-string<\BlueprintAU\Radiant\Model>  $class
230     * @param  string  $property
231     * @return void
232     * @throws \InvalidArgumentException
233     */
234    private function assertPrecisionCompatible(?string $propertyType, string $class, string $property): void
235    {
236        if ($this->precision === null) {
237            return;
238        }
239
240        if ($this->precision < 1 || $this->precision > 6) {
241            throw new \InvalidArgumentException(
242                "Model [{$class}] property [{$property}] declares datetime precision "
243                . "[{$this->precision}], which is out of range; use null for whole seconds "
244                . 'or an integer between 1 and 6 for fractional seconds.'
245            );
246        }
247
248        if ($this->type === ColumnType::Timestamp && $propertyType === 'int') {
249            throw new \InvalidArgumentException(
250                "Model [{$class}] property [{$property}] declares precision on an int "
251                . 'Unix-timestamp column; Unix timestamps are whole seconds, so fractional '
252                . 'precision is meaningless there. Use a datetime column with a '
253                . 'DateTimeInterface-typed property to store fractional seconds.'
254            );
255        }
256    }
257
258    /**
259     * Assert the PHP property default does not silently shadow the column
260     * default.
261     *
262     * A property with a PHP default is always initialized after `new`, so
263     * its value is INSERTed explicitly and the column default is never
264     * reached — when the two differ, the schema and the model's inserts
265     * disagree silently. Comparison is strict (`===`); a `null` attribute
266     * default means "no declared default" and never conflicts.
267     *
268     * @param  \ReflectionProperty  $property
269     * @param  class-string<\BlueprintAU\Radiant\Model>  $class
270     * @return void
271     * @throws \InvalidArgumentException
272     */
273    public function assertDefaultConsistent(\ReflectionProperty $property, string $class): void
274    {
275        if (!$property->hasDefaultValue()) {
276            return; // uninitialized after `new` — the DB default applies
277        }
278
279        if ($this->default === null) {
280            return; // no declared column default — nothing to shadow
281        }
282
283        $phpDefault = $property->getDefaultValue();
284
285        if ($phpDefault === $this->default) {
286            return; // redundant but harmless — the two agree
287        }
288
289        throw new \InvalidArgumentException(sprintf(
290            "Model [%s] property [%s] declares a PHP default [%s] that differs from the "
291                . "declared column default [%s]. A property with a PHP default is always "
292                . "initialized after `new`, so its value is INSERTed explicitly and the "
293                . "column default is never reached — the two silently diverge for raw SQL "
294                . "and other clients. Either drop the PHP default (letting the column "
295                . "default apply), align it with the column default, or drop the column "
296                . "default.",
297            $class,
298            $property->getName(),
299            var_export($phpDefault, true),
300            $this->default instanceof \BlueprintAU\Radiant\Database\Query\Expression
301                ? $this->default->value
302                : var_export($this->default, true),
303        ));
304    }
305
306    /**
307     * The field-type → compatible-column-types matrix.
308     *
309     * @return array<string, list<ColumnType>>
310     */
311    private static function typeCompatibility(): array
312    {
313        static $dateTimeTypes = [ColumnType::DateTime, ColumnType::Timestamp];
314
315        /** @var array<string, list<ColumnType>> */
316        static $matrix = [
317            'int' => [ColumnType::Int, ColumnType::BigInt, ColumnType::Timestamp],
318            'float' => [ColumnType::Float],
319            'string' => [
320                ColumnType::String,
321                ColumnType::Char,
322                ColumnType::Text,
323                ColumnType::Decimal,
324                ColumnType::Date,
325                ColumnType::DateTime,
326                ColumnType::Timestamp,
327                ColumnType::Json,
328                ColumnType::Enum,
329                ColumnType::Binary,
330                ColumnType::Uuid,
331            ],
332            'bool' => [ColumnType::Boolean],
333            'array' => [ColumnType::Json],
334            'Carbon\Carbon' => [...$dateTimeTypes, ColumnType::Date],
335            'Carbon\CarbonImmutable' => [...$dateTimeTypes, ColumnType::Date],
336            'DateTime' => [...$dateTimeTypes, ColumnType::Date],
337            'DateTimeImmutable' => [...$dateTimeTypes, ColumnType::Date],
338        ];
339
340        return $matrix;
341    }
342
343    // ---- Casting (owned by the field type) ----
344
345    /**
346     * Bindable value → typed property value (read path; DB-agnostic).
347     *
348     * Driven by the PHP property type; the column type disambiguates and
349     * validates. Null passes through. A Timestamp column accepts both
350     * storages — datetime strings (the bound form) and legacy integer
351     * cells written before the encoder converted them.
352     *
353     * @param  mixed  $value
354     * @param  string|null  $propertyType
355     * @return mixed
356     */
357    public function decode(mixed $value, ?string $propertyType = null): mixed
358    {
359        if ($value === null) {
360            return null;
361        }
362
363        if ($propertyType !== null && $this->isEnumPropertyType($propertyType)) {
364            return $this->decodeEnum($value, $propertyType);
365        }
366
367        if (
368            $propertyType !== null
369            && !in_array($propertyType, ['int', 'float', 'bool', 'string', 'array'], true)
370            && is_a($propertyType, \DateTimeInterface::class, true)
371        ) {
372            return $this->decodeDatetime($value, $propertyType);
373        }
374
375        return match ($propertyType) {
376            'int' => $this->type === ColumnType::Timestamp ? $this->decodeUnixTimestamp($value) : (int) $value,
377            'float' => (float) $value,
378            'bool' => (bool) $value,
379            'array' => $this->decodeJson($value),
380            // A string property (or an undeclared one) keeps the cell
381            // verbatim — the property type drives the cast, and the
382            // stored form IS the property's form. A re-parse here would
383            // hand a Carbon back to a string-typed slot and dirty the
384            // snapshot.
385            'string', null => $value,
386            default => $this->type === ColumnType::Json && class_exists($propertyType)
387                ? $this->decodeJsonObject($value, $propertyType)
388                : $value,
389        };
390    }
391
392    /**
393     * Decode a temporal column cell to a Carbon — the DateTimeInterface
394     * arm.
395     *
396     * A Timestamp column may deliver the cell as unix seconds (SQLite's
397     * integer storage), which `Carbon::parse` rejects — numeric cells
398     * reconstitute from the epoch. Datetime-string cells (the MySQL
399     * driver's form) parse as UTC — the codec normalized the binding to
400     * UTC wall-clock on the way in, and the instant is timezone-tagged
401     * before it is interpreted, so the re-hydrated Carbon carries the
402     * offset instead of silently inheriting the host's `date.timezone`.
403     * Failures throw with the column named — an un-actionable Carbon
404     * exception from deep inside hydration violates the fail-fast
405     * contract.
406     *
407     * @param  mixed  $value
408     * @param  string  $propertyType
409     * @return \Carbon\Carbon
410     * @throws \RuntimeException
411     */
412    private function decodeDatetime(mixed $value, string $propertyType): \Carbon\Carbon
413    {
414        if ($this->type === ColumnType::Timestamp && is_numeric($value)) {
415            return \Carbon\Carbon::createFromTimestamp((int) $value);
416        }
417
418        try {
419            return \Carbon\Carbon::parse($value, 'UTC');
420        } catch (\Throwable $e) {
421            throw new \RuntimeException(
422                'Column [' . ($this->name ?? $propertyType) . '] could not decode the value ['
423                . (is_scalar($value) ? var_export($value, true) : get_debug_type($value))
424                . '] as a datetime: ' . $e->getMessage(),
425                0,
426                $e,
427            );
428        }
429    }
430
431    /**
432     * Decode a Timestamp column cell for an int-typed property.
433     *
434     * Numeric cells (legacy storage — the encoder used to bind unix
435     * seconds untouched) cast directly; datetime strings (the form every
436     * dialect's native temporal type delivers) parse as UTC — both
437     * storages decode to the same integer. The strings are UTC wall-clock
438     * by construction: the int arm of {@see encode()} formats them with
439     * `Carbon::createFromTimestamp()`, and the codec normalizes
440     * `DateTimeInterface` bindings the same way, so parsing them in the
441     * host's `date.timezone` would drift every round-trip by the host's
442     * UTC offset.
443     *
444     * Unparseable cells throw naming the column — the parser's own
445     * exception surfaces as the same RuntimeException the numeric arm's
446     * contract established.
447     *
448     * @param  mixed  $value
449     * @return int
450     * @throws \RuntimeException
451     */
452    private function decodeUnixTimestamp(mixed $value): int
453    {
454        if (is_numeric($value)) {
455            return (int) $value;
456        }
457
458        try {
459            return \Carbon\Carbon::parse((string) $value, 'UTC')->getTimestamp();
460        } catch (\Throwable $e) {
461            throw new \RuntimeException(
462                'Column [' . ($this->name ?? 'timestamp') . '] could not decode the value ['
463                . (is_scalar($value) ? var_export($value, true) : get_debug_type($value))
464                . '] as a Unix timestamp.',
465                0,
466                $e,
467            );
468        }
469    }
470
471    /**
472     * Encode a Json column cell — strict, with the failure named.
473     *
474     * An array encodes directly; a JsonSerializable object encodes via
475     * jsonSerialize(); an already-encoded string passes through.
476     *
477     * @param  mixed  $value
478     * @param  string|null  $propertyType
479     * @return string
480     * @throws \RuntimeException
481     */
482    private function encodeJson(mixed $value, ?string $propertyType = null): string
483    {
484        if (is_string($value)) {
485            return $value;
486        }
487
488        if ($propertyType !== null && class_exists($propertyType) && !$value instanceof \JsonSerializable) {
489            throw new \InvalidArgumentException(
490                'Column [' . ($this->name ?? $propertyType) . '] expects a '
491                    . $propertyType . ' value; got ' . get_debug_type($value) . '.'
492            );
493        }
494
495        try {
496            return json_encode($value, JSON_THROW_ON_ERROR);
497        } catch (\JsonException $e) {
498            throw new \RuntimeException(
499                'Column [' . ($this->name ?? $propertyType) . '] could not encode the value ['
500                    . get_debug_type($value) . '] as JSON: ' . $e->getMessage(),
501                0,
502                $e,
503            );
504        }
505    }
506
507    /**
508     * Encode an object value for a Json column — the value must be an
509     * instance of the property's class.
510     *
511     * @param  mixed  $value
512     * @param  string  $propertyType
513     * @return string
514     * @throws \InvalidArgumentException
515     */
516    private function encodeJsonObject(mixed $value, string $propertyType): string
517    {
518        // Idempotent on already-encoded input — the builder's write path
519        // re-encodes values that getColumnValues() already encoded (the
520        // same trade encodeJson() makes: an encoded string cell is
521        // indistinguishable from a raw one).
522        if (is_string($value)) {
523            return $value;
524        }
525
526        if (!$value instanceof $propertyType) {
527            throw new \InvalidArgumentException(
528                'Column [' . ($this->name ?? $propertyType) . '] expects a '
529                    . $propertyType . ' value; got ' . get_debug_type($value) . '.'
530            );
531        }
532
533        return $this->encodeJson($value, $propertyType);
534    }
535
536    /**
537     * Decode a JSON column cell — strict, with the failure named.
538     *
539     * @param  mixed  $value
540     * @param  bool  $associative  True returns arrays for objects; false returns stdClass instances.
541     * @return mixed
542     * @throws \RuntimeException
543     */
544    private function decodeJson(mixed $value, bool $associative = true): mixed
545    {
546        try {
547            return json_decode((string) $value, $associative, 512, JSON_THROW_ON_ERROR);
548        } catch (\JsonException $e) {
549            throw new \RuntimeException(
550                'Column [' . ($this->name ?? 'json') . '] could not decode the value ['
551                    . var_export($value, true) . '] as JSON: ' . $e->getMessage(),
552                0,
553                $e,
554            );
555        }
556    }
557
558    /**
559     * Decode a JSON column cell to the property's JsonStorable type.
560     *
561     * @param  mixed  $value
562     * @param  string  $propertyType
563     * @return object
564     * @throws \RuntimeException
565     * @throws \InvalidArgumentException
566     */
567    private function decodeJsonObject(mixed $value, string $propertyType): object
568    {
569        if (!is_a($propertyType, \BlueprintAU\Radiant\Database\Query\JsonStorable::class, true)) {
570            throw new \InvalidArgumentException(
571                'Column [' . ($this->name ?? $propertyType) . '] declares the object type ['
572                    . $propertyType . '], which does not implement JsonStorable.'
573            );
574        }
575
576        // The shape check rides the same decode the hydration uses: a JSON
577        // object yields stdClass, a JSON array yields a list.
578        $payload = $this->decodeJson($value, associative: false);
579
580        if (!$payload instanceof \stdClass) {
581            throw new \RuntimeException(
582                'Column [' . ($this->name ?? $propertyType) . '] holds the value ['
583                    . var_export($value, true) . '], which does not decode to a JSON object.'
584            );
585        }
586
587        return $propertyType::jsonDeserialize($payload);
588    }
589
590    /**
591     * Typed property value → bindable value (write path; DB-agnostic).
592     *
593     * A column with declared precision formats its own datetime string
594     * (UTC, exactly `$precision` fractional digits) — the codec has no
595     * per-column knowledge, so a `datetime(3)` column would otherwise
596     * receive a second-precision string and lose its milliseconds.
597     *
598     * @param  mixed  $value
599     * @param  string|null  $propertyType
600     * @return mixed
601     */
602    public function encode(mixed $value, ?string $propertyType = null): mixed
603    {
604        if ($value === null) {
605            return null;
606        }
607
608        if ($propertyType !== null && $this->isEnumPropertyType($propertyType)) {
609            return $this->encodeEnum($value, $propertyType);
610        }
611
612        return match ($this->type) {
613            ColumnType::Date => $value instanceof \DateTimeInterface
614                // A date column stores the calendar day — UTC midnight,
615                // `Y-m-d`, no time component.
616                ? \DateTimeImmutable::createFromInterface($value)
617                    ->setTimezone(new \DateTimeZone('UTC'))
618                    ->format('Y-m-d')
619                : $value,
620            ColumnType::DateTime, ColumnType::Timestamp => match (true) {
621                $value instanceof \DateTimeInterface && $this->precision !== null
622                    => $this->encodePrecisionDatetime($value),
623                // A Timestamp column binds a datetime string — the cell
624                // form every dialect accepts (SQLite's NUMERIC affinity
625                // would store a raw unix int, but MySQL and Postgres
626                // reject one on a temporal column). Numeric values
627                // (int-typed Unix-timestamp properties) convert to the
628                // instant and format; DateTimeInterface values pass
629                // through — the connection's codec formats both to the
630                // dialect's datetime string.
631                //
632                // The int arm formats HERE (not in the codec) because the
633                // write-path snapshots compare encoded values strictly —
634                // a Carbon instance never equals another instance, so
635                // every save would flag the column dirty.
636                $this->type === ColumnType::Timestamp && is_numeric($value)
637                    => \Carbon\Carbon::createFromTimestamp((int) $value)
638                        ->format('Y-m-d H:i:s'),
639                default => $value,
640            },
641            ColumnType::Json => $propertyType !== null && class_exists($propertyType)
642                ? $this->encodeJsonObject($value, $propertyType)
643                : $this->encodeJson($value),
644            ColumnType::Uuid => $this->encodeUuid($value),
645            default => $value,
646        };
647    }
648
649    /**
650     * Encode a Uuid column cell — a string value passes the RFC 4122
651     * guard and binds verbatim.
652     *
653     * @param  mixed  $value
654     * @return mixed
655     * @throws \InvalidArgumentException
656     */
657    private function encodeUuid(mixed $value): mixed
658    {
659        if (is_string($value)) {
660            $this->assertUuid($value);
661        }
662
663        return $value;
664    }
665
666    /**
667     * Whether a property type is a PHP enum class-string.
668     *
669     * @param  string  $propertyType
670     * @return bool
671     */
672    private function isEnumPropertyType(string $propertyType): bool
673    {
674        return enum_exists($propertyType);
675    }
676
677    /**
678     * Encode a PHP enum value to its storable form — a backed enum's
679     * backing value, a unit enum's case name.
680     *
681     * @param  mixed  $value
682     * @param  string  $propertyType
683     * @return int|string
684     * @throws \InvalidArgumentException
685     */
686    private function encodeEnum(mixed $value, string $propertyType): int|string
687    {
688        // Idempotent on already-encoded input — the builder's write path
689        // re-encodes values that getColumnValues() already encoded (the
690        // same trade encodeJson() makes: an encoded string cell is
691        // indistinguishable from a raw one).
692        if (is_a($propertyType, \BackedEnum::class, true)) {
693            if ($value instanceof \BackedEnum) {
694                return $value->value;
695            }
696
697            // An int-backed enum's already-encoded value may arrive as a
698            // numeric string (a caller passing a stored cell back in);
699            // coerce to the backing type before tryFrom(), else the typed
700            // method TypeErrors instead of the named throw.
701            $backing = $propertyType::cases()[0]->value;
702
703            if (is_int($backing)) {
704                if (is_int($value) || (is_string($value) && is_numeric($value))) {
705                    $value = (int) $value;
706
707                    if ($propertyType::tryFrom($value) !== null) {
708                        return $value;
709                    }
710                }
711            } elseif (is_string($value)) {
712                if ($propertyType::tryFrom($value) !== null) {
713                    return $value;
714                }
715            }
716        } else {
717            if ($value instanceof \UnitEnum) {
718                return $value->name;
719            }
720
721            if (is_string($value)) {
722                foreach ($propertyType::cases() as $case) {
723                    if ($case->name === $value) {
724                        return $value;
725                    }
726                }
727            }
728        }
729
730        throw new \InvalidArgumentException(
731            'Column [' . ($this->name ?? $propertyType) . '] expects an enum value of type ['
732            . $propertyType . ']; got ' . get_debug_type($value) . '.'
733        );
734    }
735
736    /**
737     * Decode a stored value back to its enum case — fail-fast with the
738     * column named when the stored value matches no case.
739     *
740     * @param  mixed  $value
741     * @param  string  $propertyType
742     * @return \BackedEnum|\UnitEnum
743     * @throws \InvalidArgumentException
744     */
745    private function decodeEnum(mixed $value, string $propertyType): \BackedEnum|\UnitEnum
746    {
747        if (is_a($propertyType, \BackedEnum::class, true)) {
748            // A string-backed enum may receive an int cell and an
749            // int-backed enum a numeric-string cell (driver stringification
750            // on the read path) — coerce to the backing type before
751            // tryFrom(), else the typed method TypeErrors instead of the
752            // named throw.
753            $backing = $propertyType::cases()[0]->value;
754
755            $coerced = match (true) {
756                is_int($backing) => is_numeric($value) ? (int) $value : null,
757                default => is_scalar($value) || $value === null ? (string) $value : null,
758            };
759
760            if ($coerced !== null) {
761                $case = $propertyType::tryFrom($coerced);
762
763                if ($case !== null) {
764                    return $case;
765                }
766            }
767
768            throw new \InvalidArgumentException(
769                'Column [' . ($this->name ?? $propertyType) . '] holds the value ['
770                . (is_scalar($value) ? var_export($value, true) : get_debug_type($value))
771                . '], which is not a case of the enum [' . $propertyType . '].'
772            );
773        }
774
775        foreach ($propertyType::cases() as $case) {
776            if ($case->name === $value) {
777                return $case;
778            }
779        }
780
781        throw new \InvalidArgumentException(
782            'Column [' . ($this->name ?? $propertyType) . '] holds the value ['
783            . (is_scalar($value) ? var_export($value, true) : get_debug_type($value))
784            . '], which is not a case of the enum [' . $propertyType . '].'
785        );
786    }
787
788    /**
789     * Assert a value is a well-formed RFC 4122 UUID.
790     *
791     * @param  string  $value
792     * @return void
793     * @throws \InvalidArgumentException
794     */
795    private function assertUuid(string $value): void
796    {
797        if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value) !== 1) {
798            throw new \InvalidArgumentException(
799                'Column [' . ($this->name ?? 'uuid') . '] requires a valid RFC 4122 UUID; got [' . $value . '].'
800            );
801        }
802    }
803
804    /**
805     * Format a datetime to the column's declared precision — UTC, exactly
806     * `$precision` fractional digits.
807     *
808     * @param  \DateTimeInterface  $value
809     * @return string
810     */
811    private function encodePrecisionDatetime(\DateTimeInterface $value): string
812    {
813        $utc = \DateTimeImmutable::createFromInterface($value)
814            ->setTimezone(new \DateTimeZone('UTC'));
815
816        // One format pass — 'Y-m-d H:i:s.u' is fixed-width (26 chars: 19
817        // date chars + the dot + 6 fraction digits), so truncating to
818        // 20 + precision keeps the fraction exact and never cuts the date.
819        return substr($utc->format('Y-m-d H:i:s.u'), 0, 20 + ($this->precision ?? 6));
820    }
821}