From 64a842795d51dfff1b0d268e554f9023a6de336b Mon Sep 17 00:00:00 2001 From: Wojtek Date: Mon, 20 Apr 2026 21:54:17 +0200 Subject: [PATCH] JSON Caster --- src/Database/Model/CastType.php | 1 + src/Database/Model/Model.php | 2 +- src/Database/Model/ModelValueCaster.php | 52 ++++++++++++++ src/Database/Model/README.md | 32 ++++++++- .../ReverseEngineering/ModelGenerator.php | 34 +++++++++- src/Mongo/README.md | 11 +++ tests/Unit/Database/ModelTest.php | 67 +++++++++++++++++++ tests/Unit/Database/SchemaTest.php | 3 + tests/Unit/Mongo/MongoTest.php | 28 ++++++++ 9 files changed, 223 insertions(+), 7 deletions(-) diff --git a/src/Database/Model/CastType.php b/src/Database/Model/CastType.php index 2ac8f54..7009f40 100644 --- a/src/Database/Model/CastType.php +++ b/src/Database/Model/CastType.php @@ -8,4 +8,5 @@ enum CastType: string { case DateTime = 'datetime'; case DateTimeImmutable = 'datetime_immutable'; + case Json = 'json'; } diff --git a/src/Database/Model/Model.php b/src/Database/Model/Model.php index 443755c..039a721 100644 --- a/src/Database/Model/Model.php +++ b/src/Database/Model/Model.php @@ -781,7 +781,7 @@ private function filterPersistableAttributes(array $attributes): array $value = $value->value; } - $value = $valueCaster->serializeAttributeValue($key, $value); + $value = $valueCaster->serializeAttributeForStorage($key, $value); if (is_string($value) || is_int($value) || is_float($value) || is_bool($value) || $value === null) { $persisted[$key] = $value; diff --git a/src/Database/Model/ModelValueCaster.php b/src/Database/Model/ModelValueCaster.php index afed510..f81b2a3 100644 --- a/src/Database/Model/ModelValueCaster.php +++ b/src/Database/Model/ModelValueCaster.php @@ -8,6 +8,7 @@ use DateTimeImmutable; use DateTimeInterface; use InvalidArgumentException; +use JsonException; final class ModelValueCaster { @@ -32,6 +33,7 @@ public function castAttributeValue(string $name, mixed $value): mixed $cast->format, DateTimeImmutable::class, ), + CastType::Json => $this->castToJson($name, $value), }; } @@ -46,6 +48,17 @@ public function serializeAttributeValue(string $name, mixed $value): mixed return $value->format($format); } + public function serializeAttributeForStorage(string $name, mixed $value): mixed + { + $cast = $this->metadata->castForProperty($name); + + if ($cast?->type === CastType::Json && $value !== null) { + return $this->serializeJson($name, $value); + } + + return $this->serializeAttributeValue($name, $value); + } + /** * @param class-string $dateTimeClass */ @@ -105,4 +118,43 @@ private function castToDateTime( return $dateTime; } + + private function castToJson(string $name, mixed $value): mixed + { + if (is_array($value)) { + return $value; + } + + if (!is_string($value)) { + throw new InvalidArgumentException(sprintf( + 'Cannot cast non-string value for property "%s" on model %s to JSON.', + $name, + $this->modelClass, + )); + } + + try { + return json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + throw new InvalidArgumentException(sprintf( + 'Cannot cast value "%s" for property "%s" on model %s to JSON.', + $value, + $name, + $this->modelClass, + )); + } + } + + private function serializeJson(string $name, mixed $value): string + { + try { + return json_encode($value, JSON_THROW_ON_ERROR); + } catch (JsonException) { + throw new InvalidArgumentException(sprintf( + 'Cannot serialize value for property "%s" on model %s as JSON.', + $name, + $this->modelClass, + )); + } + } } diff --git a/src/Database/Model/README.md b/src/Database/Model/README.md index 46d98af..ad948c6 100644 --- a/src/Database/Model/README.md +++ b/src/Database/Model/README.md @@ -204,7 +204,13 @@ final class SecureUser extends Model ## Casting -The built-in casts currently support datetime values. +Models support property-level casts through the `#[Cast(...)]` attribute. + +The built-in casts currently support these types: + +- `CastType::DateTime` +- `CastType::DateTimeImmutable` +- `CastType::Json` ```php use DateTimeImmutable; @@ -229,8 +235,28 @@ final class User extends Model Behavior: - hydrated string values are cast into `DateTime` or `DateTimeImmutable` -- serialized output converts them back to strings -- the cast format controls both parsing and serialization +- JSON strings are decoded during hydration when using `CastType::Json` +- serialized output converts datetime values back to strings +- JSON-cast attributes stay decoded in `toArray()` / `toJson()` +- SQL persistence stores JSON-cast attributes as JSON strings +- the cast format controls datetime parsing and serialization + +```php +use DateTimeImmutable; +use Myxa\Database\Attributes\Cast; +use Myxa\Database\Model\CastType; + +final class Event extends Model +{ + protected string $table = 'events'; + + #[Cast(CastType::DateTimeImmutable, format: DATE_ATOM)] + protected ?DateTimeImmutable $published_at = null; + + #[Cast(CastType::Json)] + protected ?array $payload = null; +} +``` ## Extra Attributes diff --git a/src/Database/Schema/ReverseEngineering/ModelGenerator.php b/src/Database/Schema/ReverseEngineering/ModelGenerator.php index 0e3e900..6f66e01 100644 --- a/src/Database/Schema/ReverseEngineering/ModelGenerator.php +++ b/src/Database/Schema/ReverseEngineering/ModelGenerator.php @@ -32,8 +32,11 @@ public function generate( $imports[] = 'use Myxa\\Database\\Model\\HasTimestamps;'; } - if ($this->requiresDateCastImport($table, $usesTimestamps)) { + if ($this->requiresDateTypeImport($table, $usesTimestamps)) { $imports[] = 'use DateTimeImmutable;'; + } + + if ($this->requiresCastImport($table, $usesTimestamps)) { $imports[] = 'use Myxa\\Database\\Attributes\\Cast;'; $imports[] = 'use Myxa\\Database\\Model\\CastType;'; } @@ -160,7 +163,7 @@ private function usesTimestampsTrait(TableSchema $table): bool && $this->isDateColumn($updatedAt); } - private function requiresDateCastImport(TableSchema $table, bool $usesTimestamps): bool + private function requiresDateTypeImport(TableSchema $table, bool $usesTimestamps): bool { foreach ($table->columns() as $column) { if ($usesTimestamps && ($column->name() === 'created_at' || $column->name() === 'updated_at')) { @@ -175,6 +178,21 @@ private function requiresDateCastImport(TableSchema $table, bool $usesTimestamps return false; } + private function requiresCastImport(TableSchema $table, bool $usesTimestamps): bool + { + foreach ($table->columns() as $column) { + if ($usesTimestamps && ($column->name() === 'created_at' || $column->name() === 'updated_at')) { + continue; + } + + if ($this->isDateColumn($column) || $column->type() === 'json') { + return true; + } + } + + return false; + } + /** * @return list */ @@ -243,11 +261,21 @@ private function propertyDefinition(ColumnSchema $column): array ]; } + if ($column->type() === 'json') { + return [ + 'array', + $column->isNullable(), + $column->hasDefault() + ? $this->exportDefaultValue($column->defaultValue(), 'array') + : ($column->isNullable() ? 'null' : null), + '#[Cast(CastType::Json)]', + ]; + } + $type = match ($column->type()) { 'integer', 'bigInteger' => 'int', 'decimal', 'float' => 'float', 'boolean' => 'bool', - 'json' => 'array', default => 'string', }; diff --git a/src/Mongo/README.md b/src/Mongo/README.md index 6d0d695..4ba2b66 100644 --- a/src/Mongo/README.md +++ b/src/Mongo/README.md @@ -188,6 +188,17 @@ final class SecureUserDocument extends MongoModel } ``` +Cast behavior is the same as SQL models: + +- `CastType::DateTime`, `CastType::DateTimeImmutable`, and `CastType::Json` are supported +- `null` values stay `null` +- strings are hydrated into datetime objects +- JSON strings are decoded during hydration when using `CastType::Json` +- `DateTimeInterface` values are accepted directly +- serialization writes datetime values back to strings using the declared format, or `DATE_ATOM` when no format is provided +- JSON-cast attributes remain decoded arrays in serialized model output +- invalid values throw `InvalidArgumentException` + ### Dirty Tracking and Serialization `MongoModel` supports the same style of helpers as SQL models: diff --git a/tests/Unit/Database/ModelTest.php b/tests/Unit/Database/ModelTest.php index c5a6fff..3b6ee01 100644 --- a/tests/Unit/Database/ModelTest.php +++ b/tests/Unit/Database/ModelTest.php @@ -92,6 +92,18 @@ final class CastedUser extends Model protected ?DateTimeImmutable $updated_at = null; } +final class JsonUser extends Model +{ + protected string $table = 'users'; + + protected string $email = ''; + + protected string $status = ''; + + #[Cast(CastType::Json)] + protected ?array $meta = null; +} + final class InternalPropertyUser extends Model { protected string $table = 'users'; @@ -541,6 +553,60 @@ public function testInvalidDateTimeCastInputThrowsHelpfulException(): void ]); } + public function testJsonCastHydratesToArrayAndSerializesForOutput(): void + { + $this->makeManager()->insert( + 'INSERT INTO users (email, status, meta) VALUES (?, ?, ?)', + ['json-casted@example.com', 'active', '{"notifications":{"email":true},"tags":["vip","beta"]}'], + ); + + $user = JsonUser::findOrFail(1); + + self::assertSame( + [ + 'notifications' => ['email' => true], + 'tags' => ['vip', 'beta'], + ], + $user->meta, + ); + self::assertSame($user->meta, $user->toArray()['meta']); + self::assertStringContainsString('"meta":{"notifications":{"email":true},"tags":["vip","beta"]}', $user->toJson()); + } + + public function testJsonCastPersistsArraysAsJsonStrings(): void + { + $user = new JsonUser([ + 'email' => 'persist-json@example.com', + 'status' => 'active', + 'meta' => [ + 'notifications' => ['email' => true, 'sms' => false], + 'tags' => ['pro'], + ], + ]); + + self::assertTrue($user->save()); + self::assertSame( + '{"notifications":{"email":true,"sms":false},"tags":["pro"]}', + $this->makeManager()->select('SELECT meta FROM users WHERE id = ?', [$user->getKey()])[0]['meta'], + ); + } + + public function testInvalidJsonCastInputThrowsHelpfulException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'Cannot cast value "%s" for property "meta" on model %s to JSON.', + '{invalid', + JsonUser::class, + )); + + new JsonUser([ + 'email' => 'broken-json@example.com', + 'status' => 'active', + 'meta' => '{invalid', + ]); + } + public function testToJsonEncodesSerializedAttributes(): void { $user = new SecureUser([ @@ -1020,6 +1086,7 @@ private function makeInMemoryConnection(): PdoConnection . 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' . 'email TEXT NOT NULL, ' . 'status TEXT NOT NULL, ' + . 'meta TEXT NULL, ' . 'password_hash TEXT NULL, ' . 'created_at TEXT NULL, ' . 'updated_at TEXT NULL' diff --git a/tests/Unit/Database/SchemaTest.php b/tests/Unit/Database/SchemaTest.php index a494bcb..89eee57 100644 --- a/tests/Unit/Database/SchemaTest.php +++ b/tests/Unit/Database/SchemaTest.php @@ -1239,8 +1239,11 @@ public function testModelGeneratorMapsNormalizedSchemaTypesToProperties(): void ], [], []), 'App\\Models\\Event'); self::assertStringContainsString('namespace App\\Models;', $source); + self::assertStringContainsString('use Myxa\\Database\\Attributes\\Cast;', $source); + self::assertStringContainsString('use Myxa\\Database\\Model\\CastType;', $source); self::assertStringContainsString('protected ?int $id = null;', $source); self::assertStringContainsString('protected bool $is_active = true;', $source); + self::assertStringContainsString('#[Cast(CastType::Json)]', $source); self::assertStringContainsString('protected ?array $payload = null;', $source); self::assertStringContainsString('#[Cast(CastType::DateTimeImmutable)]', $source); self::assertStringContainsString('protected ?DateTimeImmutable $published_at = null;', $source); diff --git a/tests/Unit/Mongo/MongoTest.php b/tests/Unit/Mongo/MongoTest.php index 54dbdd6..86b7404 100644 --- a/tests/Unit/Mongo/MongoTest.php +++ b/tests/Unit/Mongo/MongoTest.php @@ -63,6 +63,17 @@ final class CastedUserDocument extends MongoModel protected ?DateTimeImmutable $created_at = null; } +final class JsonUserDocument extends MongoModel +{ + protected string $collection = 'users'; + + // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore -- Mongo uses _id as the default document key. + protected string|int|null $_id = null; + + #[Cast(CastType::Json)] + protected ?array $payload = null; +} + final class ConnectedUserDocument extends MongoModel { protected string $collection = 'users'; @@ -377,6 +388,23 @@ public function testMongoModelSupportsCastingAndUnset(): void self::assertNull($user->created_at); } + public function testMongoModelSupportsJsonCastingFromStringsAndArrays(): void + { + $user = new JsonUserDocument([ + 'payload' => '{"tags":["mongo"],"active":true}', + ]); + + self::assertSame(['tags' => ['mongo'], 'active' => true], $user->payload); + self::assertSame(['_id' => null, 'payload' => ['tags' => ['mongo'], 'active' => true]], $user->toArray()); + + $hydrated = JsonUserDocument::hydrate([ + '_id' => 'doc-2', + 'payload' => ['tags' => ['hydrated'], 'active' => false], + ]); + + self::assertSame(['tags' => ['hydrated'], 'active' => false], $hydrated->payload); + } + public function testMongoModelSupportsMakeHydrateAndConnectionMetadata(): void { $manager = new MongoManager(self::CONNECTION_ALIAS);