Skip to content

Commit 94f7fa3

Browse files
committed
feat(orm): Add support for BackedEnum in Entity column
Convert from and to the value in the database Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Carl Schwan <carl@carlschwan.eu>
1 parent b85f383 commit 94f7fa3

5 files changed

Lines changed: 288 additions & 8 deletions

File tree

lib/private/AppFramework/ORM/EntityInfo.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ final class EntityInfo {
2424
/** @var array<string, ColumnType> */
2525
public array $mappingColumnToTypes = [];
2626

27+
/** @var array<string, class-string<\BackedEnum>> */
28+
public array $mappingColumnToEnumType = [];
29+
2730
/** @var array<string, string> */
2831
public array $mappingColumnToProperty = [];
2932

@@ -72,6 +75,9 @@ public function __construct(
7275
$this->mappingColumnToTypes[$instance->name] = $instance->type;
7376
$this->mappingColumnToProperty[$instance->name] = $property->getName();
7477
$this->mappingPropertyToColumn[$property->getName()] = $instance->name;
78+
if ($instance->enumType !== null) {
79+
$this->mappingColumnToEnumType[$instance->name] = $instance->enumType;
80+
}
7581
} elseif ($instance instanceof Id) {
7682
$propertyAttributes->id = $instance;
7783
$this->idProperties[] = $property;
@@ -88,6 +94,10 @@ public function __construct(
8894
throw new \RuntimeException($this->entityClass . ' has an Id attribute on ' . $property->getName() . ' but not the corresponding required Column attribute.');
8995
}
9096

97+
if ($propertyAttributes->column instanceof Column && $propertyAttributes->column->enumType !== null) {
98+
$this->validateEnumType($property, $propertyAttributes->column);
99+
}
100+
91101
if ($propertyAttributes->oneToOne instanceof OneToOne
92102
&& $propertyAttributes->oneToOne->mappedBy !== null
93103
&& $propertyAttributes->joinColumn instanceof JoinColumn
@@ -165,4 +175,34 @@ private function validateMappedBy(\ReflectionProperty $property, OneToOne $oneTo
165175
throw new \RuntimeException($prefix . $oneToOne->targetEntity . '::' . $mappedBy . ' has no JoinColumn attribute.');
166176
}
167177
}
178+
179+
private function validateEnumType(\ReflectionProperty $property, Column $column): void {
180+
/** @var class-string $enumType */
181+
$enumType = $column->enumType;
182+
$prefix = $this->entityClass . '::' . $property->getName() . " declares enumType: {$enumType}, but ";
183+
184+
if (!enum_exists($enumType)) {
185+
throw new \RuntimeException($prefix . 'that class is not an enum.');
186+
}
187+
188+
if (!is_a($enumType, \BackedEnum::class, true)) {
189+
throw new \RuntimeException($prefix . 'that enum is not backed. Only backed enums (`enum Foo: string` or `enum Foo: int`) can be mapped to a column.');
190+
}
191+
192+
$propertyType = $property->getType();
193+
if ($propertyType instanceof \ReflectionNamedType && ltrim($propertyType->getName(), '\\') !== ltrim($enumType, '\\')) {
194+
throw new \RuntimeException($prefix . 'the property is typed as ' . $propertyType->getName() . ' instead.');
195+
}
196+
197+
$backingType = (new \ReflectionEnum($enumType))->getBackingType();
198+
$backingTypeName = $backingType instanceof \ReflectionNamedType ? $backingType->getName() : null;
199+
$compatibleColumnTypes = match ($backingTypeName) {
200+
'int' => [ColumnType::Bigint, ColumnType::Smallint, ColumnType::Integer],
201+
'string' => [ColumnType::Binary, ColumnType::Decimal, ColumnType::Text, ColumnType::String],
202+
default => [],
203+
};
204+
if (!in_array($column->type, $compatibleColumnTypes, true)) {
205+
throw new \RuntimeException($prefix . "its column type ({$column->type->name}) cannot hold a(n) {$backingTypeName}-backed enum's value.");
206+
}
207+
}
168208
}

lib/private/AppFramework/ORM/EntityManager.php

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ public function insert(object $entity): object {
135135

136136
if ($propertyAttributes->column !== null) {
137137
$type = $this->getParameterType($propertyAttributes->column->type, false);
138-
$values[$propertyAttributes->column->name] = $insert->createNamedParameter($property->getValue($entity), $type);
138+
$values[$propertyAttributes->column->name] = $insert->createNamedParameter($this->toParameterValue($property->getValue($entity)), $type);
139139
}
140140
}
141141

@@ -198,7 +198,7 @@ public function update(object $entity): object {
198198

199199
if ($propertyAttributes->column !== null) {
200200
$type = $this->getParameterType($propertyAttributes->column->type, false);
201-
$update->set($propertyAttributes->column->name, $update->createNamedParameter($value, $type));
201+
$update->set($propertyAttributes->column->name, $update->createNamedParameter($this->toParameterValue($value), $type));
202202
}
203203
}
204204

@@ -279,6 +279,18 @@ public function getParameterType(ColumnType $type, bool $isArray): string|int {
279279
};
280280
}
281281

282+
public function toParameterValue(mixed $value): mixed {
283+
if ($value instanceof \BackedEnum) {
284+
return $value->value;
285+
}
286+
287+
if (is_array($value)) {
288+
return array_map($this->toParameterValue(...), $value);
289+
}
290+
291+
return $value;
292+
}
293+
282294
/**
283295
* @internal Only for unit tests.
284296
*
@@ -326,7 +338,11 @@ private function createProperty(EntityInfo $entityInfo, PropertyAttributes $attr
326338
}
327339

328340
if ($columnAttribute->default !== null) {
329-
$options['default'] = $columnAttribute->default;
341+
// Column::$default is documented as scalar|\BackedEnum, so unwrapping a \BackedEnum
342+
// case here always yields a scalar.
343+
/** @var scalar $default */
344+
$default = $this->toParameterValue($columnAttribute->default);
345+
$options['default'] = $default;
330346
}
331347

332348
// A composite primary key can't rely on a single autoincrement column; see insert().

lib/public/AppFramework/ORM/Attribute/Column.php

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,24 @@
2222
* }
2323
* ```
2424
*
25+
* A property can be typed as a backed enum instead of a plain scalar by setting `enumType` to
26+
* the enum's class-string. The column itself still stores the enum's scalar backing value
27+
* (declare `type`/`length` accordingly), but the property is hydrated to and persisted from the
28+
* enum case itself:
29+
*
30+
* ```php
31+
* enum Status: string {
32+
* case Draft = 'draft';
33+
* case Published = 'published';
34+
* }
35+
*
36+
* #[Entity(name: 'my_entity')]
37+
* final class MyEntity {
38+
* #[Column(name: 'status', type: ColumnType::String, length: 32, enumType: Status::class)]
39+
* public Status $status = Status::Draft;
40+
* }
41+
* ```
42+
*
2543
* @since 35.0.0
2644
*/
2745
#[Attribute(Attribute::TARGET_PROPERTY)]
@@ -37,8 +55,14 @@ public function __construct(
3755
public ?int $length = null,
3856
/** @var bool Whether the column is nullable in the database */
3957
public bool $nullable = false,
40-
/** @var scalar|null The default value for the column in the database. */
58+
/** @var scalar|\BackedEnum|null The default value for the column in the database. */
4159
public mixed $default = null,
60+
/**
61+
* @var class-string<\BackedEnum>|null The backed enum the property is typed as. The
62+
* column keeps storing the enum's scalar backing
63+
* value; only the PHP property is the enum case.
64+
*/
65+
public ?string $enumType = null,
4266
) {
4367
}
4468
}

lib/public/AppFramework/ORM/Repository.php

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,14 @@ private function hydrateRow(string $entityClass, mixed $row): object {
109109
ColumnType::Blob => $value,
110110
};
111111

112+
$enumType = $entityInfo->mappingColumnToEnumType[$column] ?? null;
113+
if ($enumType !== null) {
114+
if (!is_string($value) && !is_int($value)) {
115+
throw new \LogicException("Can only convert int and string to enum");
116+
}
117+
$value = $enumType::from($value);
118+
}
119+
112120
$entity->$property = $value;
113121
}
114122

@@ -384,7 +392,7 @@ public function insertOrUpdate(object $entity): object {
384392
/**
385393
* Finds entities by a set of criteria, keyed by property name.
386394
*
387-
* @param array<string, int|float|string|null|\DateTime|list<int|float|string>> $criteria
395+
* @param array<string, int|float|string|null|\DateTime|\BackedEnum|list<int|float|string|\BackedEnum>> $criteria
388396
* @param array<string, \SortDirection> $orderBy
389397
* @return \Generator<T>
390398
* @since 35.0.0
@@ -404,7 +412,7 @@ public function findBy(array $criteria, array $orderBy = [], ?int $limit = null,
404412
}
405413

406414
/**
407-
* @param array<string, int|float|string|null|\DateTime|list<int|float|string>> $criteria
415+
* @param array<string, int|float|string|null|\DateTime|\BackedEnum|list<int|float|string|\BackedEnum>> $criteria
408416
* @return int The number of rows deleted
409417
* @throws Exception
410418
* @since 35.0.0
@@ -417,6 +425,8 @@ public function deleteBy(array $criteria, ?int $limit = null): int {
417425

418426
foreach ($criteria as $property => $value) {
419427
$column = $entityInfo->mappingPropertyToColumn[$property];
428+
/** @psalm-suppress MixedAssignment can be anything */
429+
$value = $this->entityManager->toParameterValue($value);
420430
$type = $this->entityManager->getParameterType($entityInfo->mappingColumnToTypes[$column], is_array($value));
421431
if ($value === null) {
422432
$qb->andWhere($qb->expr()->isNull($column));
@@ -439,7 +449,7 @@ public function deleteBy(array $criteria, ?int $limit = null): int {
439449
/**
440450
* Finds a single entity by a set of criteria, keyed by property name.
441451
*
442-
* @param array<string, int|float|string|null|\DateTime|list<int|float|string>> $criteria
452+
* @param array<string, int|float|string|null|\DateTime|\BackedEnum|list<int|float|string|\BackedEnum>> $criteria
443453
* @param array<string, \SortDirection> $orderBy
444454
* @return T
445455
* @throws DoesNotExistException
@@ -454,7 +464,7 @@ public function findOneBy(array $criteria, array $orderBy = []): object {
454464
}
455465

456466
/**
457-
* @param array<string, int|float|string|null|\DateTime|list<int|float|string>> $criteria
467+
* @param array<string, int|float|string|null|\DateTime|\BackedEnum|list<int|float|string|\BackedEnum>> $criteria
458468
* @param array<string, \SortDirection> $orderBy
459469
* @return array{0: IQueryBuilder, 1: array<string, array{attributes: PropertyAttributes, entityInfo: EntityInfo}>}
460470
*/
@@ -464,6 +474,8 @@ private function getJoinedSelectQueryBuilder(array $criteria, array $orderBy = [
464474

465475
foreach ($criteria as $property => $value) {
466476
$column = $entityInfo->mappingPropertyToColumn[$property];
477+
/** @psalm-suppress MixedAssignment $value is caller-supplied criteria, unwrapped of any \BackedEnum case. */
478+
$value = $this->entityManager->toParameterValue($value);
467479
$type = $this->entityManager->getParameterType($entityInfo->mappingColumnToTypes[$column], is_array($value));
468480
if ($value === null) {
469481
$qb->andWhere($qb->expr()->isNull('e.' . $column));

0 commit comments

Comments
 (0)