diff --git a/packages/database/src/Entity/EntityMetadataFactory.php b/packages/database/src/Entity/EntityMetadataFactory.php index 683fd5f2..a69e6ede 100644 --- a/packages/database/src/Entity/EntityMetadataFactory.php +++ b/packages/database/src/Entity/EntityMetadataFactory.php @@ -18,6 +18,7 @@ use ReflectionException; use ReflectionNamedType; use ReflectionProperty; +use ReflectionUnionType; /** * Parses entity classes and extracts metadata from attributes. @@ -95,13 +96,24 @@ public function parse( $columnName = $columnAttr->name ?? $this->camelToSnakeCase($propertyName); $type = $property->getType(); - if (!$type instanceof ReflectionNamedType) { + if ($type instanceof ReflectionNamedType) { + $phpType = $type->getName(); + $dbType = $columnAttr->type ?? $this->inferDatabaseType($phpType); + $nullable = $type->allowsNull(); + } elseif ($type instanceof ReflectionUnionType) { + // A union type (e.g. a polymorphic foreign key declared as `int|string`) + // has no single reflection type to infer a database column type from, so + // an explicit #[Column(type: ...)] is required to resolve the ambiguity. + if ($columnAttr->type === null) { + throw EntityException::unionTypeRequiresColumnType($entityClass, $propertyName); + } + + $phpType = (string) $type; + $dbType = $columnAttr->type; + $nullable = $type->allowsNull(); + } else { throw EntityException::missingTypeDeclaration($entityClass, $propertyName); } - - $phpType = $type->getName(); - $dbType = $columnAttr->type ?? $this->inferDatabaseType($phpType); - $nullable = $type->allowsNull(); $default = $property->hasDefaultValue() ? $property->getDefaultValue() : null; // Convert BackedEnum default values to their backing value for database storage diff --git a/packages/database/src/Exceptions/EntityException.php b/packages/database/src/Exceptions/EntityException.php index 948d9a2f..a5525e63 100644 --- a/packages/database/src/Exceptions/EntityException.php +++ b/packages/database/src/Exceptions/EntityException.php @@ -78,6 +78,21 @@ public static function missingTypeDeclaration( ); } + /** + * @param class-string $entityClass + */ + public static function unionTypeRequiresColumnType( + string $entityClass, + string $property, + ): self { + return new self( + message: "Property '$property' in entity '$entityClass' has a union type " + . 'and must declare an explicit column type', + context: "Parsing column '$property' in entity '$entityClass'", + suggestion: "Add an explicit type to the #[Column] attribute (e.g., #[Column(type: 'varchar')])", + ); + } + /** * @param class-string $entityClass */ diff --git a/packages/database/tests/Entity/EntityMetadataFactoryTest.php b/packages/database/tests/Entity/EntityMetadataFactoryTest.php index 304d512e..f7726e76 100644 --- a/packages/database/tests/Entity/EntityMetadataFactoryTest.php +++ b/packages/database/tests/Entity/EntityMetadataFactoryTest.php @@ -311,6 +311,38 @@ class UntypedPropertyEntity extends Entity $this->factory->parse($className); })->throws(EntityException::class, 'must have a type declaration'); +it('parses a union-typed property when an explicit column type is declared', function (): void { + $entity = new #[Table('attachments')] class () extends Entity + { + #[Column(primaryKey: true, autoIncrement: true)] + public ?int $id = null; + + #[Column(type: 'varchar', length: 255)] + public int|string $attachableId = 0; + }; + + $metadata = $this->factory->parse($entity::class); + + expect($metadata->properties['attachableId']->type)->toContain('int') + ->and($metadata->properties['attachableId']->type)->toContain('string') + ->and($metadata->properties['attachableId']->columnType)->toBe('varchar') + ->and($metadata->columns[1]->type)->toBe('varchar') + ->and($metadata->columns[1]->nullable)->toBeFalse(); +}); + +it('throws EntityException for a union-typed property without an explicit column type', function (): void { + $entity = new #[Table('attachments')] class () extends Entity + { + #[Column(primaryKey: true, autoIncrement: true)] + public ?int $id = null; + + #[Column(length: 255)] + public int|string $attachableId = 0; + }; + + $this->factory->parse($entity::class); +})->throws(EntityException::class, 'must declare an explicit column type'); + it('handles leading uppercase sequences correctly (HTMLParser becomes html_parser)', function (): void { $entity = new #[Table('records')] class () extends Entity { diff --git a/packages/docs-markdown/docs/packages/database.md b/packages/docs-markdown/docs/packages/database.md index 18307b83..05a2b35f 100644 --- a/packages/docs-markdown/docs/packages/database.md +++ b/packages/docs-markdown/docs/packages/database.md @@ -92,8 +92,22 @@ Marko infers database types from PHP types: | `DateTimeImmutable` | TIMESTAMP | | `BackedEnum` | ENUM with cases as values | | `array` or `?array` with `type: 'json'` | JSON (MySQL) / JSONB (PostgreSQL) | +| Union type (e.g. `int\|string`) | No inference — requires an explicit `type:` | | Default values | From property initializers | +### Union-Typed Columns + +A union type has no single PHP type to infer a column type from, so it must declare one explicitly. This is how polymorphic foreign keys are modeled — an attachment can point at entities whose primary keys are `int` or `string`: + +```php +#[Column(type: 'varchar', length: 255)] +public int|string $attachableId = 0; +``` + +Without an explicit `type:`, metadata parsing throws `EntityException` — Marko will not guess which side of the union wins. + +> A varchar-backed union always hydrates to a PHP `string`, even when the value was written as an `int`. Compare loosely or cast explicitly rather than strict-comparing against an integer primary key. + ### String and UUID Primary Keys Primary keys are not limited to integers. Any property marked `#[Column(primaryKey: true)]` serves as the primary key. `find()` and `findOrFail()` accept `int|string`. diff --git a/packages/media/src/Entity/MediaAttachment.php b/packages/media/src/Entity/MediaAttachment.php index 83893cdb..e98f78f9 100644 --- a/packages/media/src/Entity/MediaAttachment.php +++ b/packages/media/src/Entity/MediaAttachment.php @@ -20,6 +20,6 @@ class MediaAttachment extends Entity #[Column(length: 255)] public string $attachableType = ''; - #[Column(length: 255)] + #[Column(type: 'varchar', length: 255)] public int|string $attachableId = 0; } diff --git a/packages/media/tests/Entity/MediaAttachmentTest.php b/packages/media/tests/Entity/MediaAttachmentTest.php new file mode 100644 index 00000000..511b9058 --- /dev/null +++ b/packages/media/tests/Entity/MediaAttachmentTest.php @@ -0,0 +1,22 @@ +parse(MediaAttachment::class); + + expect($metadata->tableName)->toBe('media_attachments'); +}); + +it('maps the polymorphic attachableId union type to a varchar column', function (): void { + $metadata = (new EntityMetadataFactory())->parse(MediaAttachment::class); + + expect($metadata->properties['attachableId']->type)->toContain('int') + ->and($metadata->properties['attachableId']->type)->toContain('string') + ->and($metadata->properties['attachableId']->columnType)->toBe('varchar'); +});