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
22 changes: 17 additions & 5 deletions packages/database/src/Entity/EntityMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use ReflectionException;
use ReflectionNamedType;
use ReflectionProperty;
use ReflectionUnionType;

/**
* Parses entity classes and extracts metadata from attributes.
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions packages/database/src/Exceptions/EntityException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
32 changes: 32 additions & 0 deletions packages/database/tests/Entity/EntityMetadataFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
14 changes: 14 additions & 0 deletions packages/docs-markdown/docs/packages/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion packages/media/src/Entity/MediaAttachment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
22 changes: 22 additions & 0 deletions packages/media/tests/Entity/MediaAttachmentTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Marko\Media\Tests\Entity;

use Marko\Database\Entity\EntityMetadataFactory;
use Marko\Media\Entity\MediaAttachment;

it('builds full entity metadata for MediaAttachment without throwing', function (): void {
$metadata = (new EntityMetadataFactory())->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');
});