Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Database/Model/CastType.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ enum CastType: string
{
case DateTime = 'datetime';
case DateTimeImmutable = 'datetime_immutable';
case Json = 'json';
}
2 changes: 1 addition & 1 deletion src/Database/Model/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
52 changes: 52 additions & 0 deletions src/Database/Model/ModelValueCaster.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use DateTimeImmutable;
use DateTimeInterface;
use InvalidArgumentException;
use JsonException;

final class ModelValueCaster
{
Expand All @@ -32,6 +33,7 @@ public function castAttributeValue(string $name, mixed $value): mixed
$cast->format,
DateTimeImmutable::class,
),
CastType::Json => $this->castToJson($name, $value),
};
}

Expand All @@ -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<DateTime|DateTimeImmutable> $dateTimeClass
*/
Expand Down Expand Up @@ -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,
));
}
}
}
32 changes: 29 additions & 3 deletions src/Database/Model/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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

Expand Down
34 changes: 31 additions & 3 deletions src/Database/Schema/ReverseEngineering/ModelGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;';
}
Expand Down Expand Up @@ -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')) {
Expand All @@ -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<string>
*/
Expand Down Expand Up @@ -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',
};

Expand Down
11 changes: 11 additions & 0 deletions src/Mongo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
67 changes: 67 additions & 0 deletions tests/Unit/Database/ModelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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'
Expand Down
3 changes: 3 additions & 0 deletions tests/Unit/Database/SchemaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions tests/Unit/Mongo/MongoTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Loading