diff --git a/lib/private/AppFramework/ORM/EntityInfo.php b/lib/private/AppFramework/ORM/EntityInfo.php index 6a5eaab0859be..ecea25c17c320 100644 --- a/lib/private/AppFramework/ORM/EntityInfo.php +++ b/lib/private/AppFramework/ORM/EntityInfo.php @@ -24,6 +24,9 @@ final class EntityInfo { /** @var array */ public array $mappingColumnToTypes = []; + /** @var array> */ + public array $mappingColumnToEnumType = []; + /** @var array */ public array $mappingColumnToProperty = []; @@ -72,6 +75,9 @@ public function __construct( $this->mappingColumnToTypes[$instance->name] = $instance->type; $this->mappingColumnToProperty[$instance->name] = $property->getName(); $this->mappingPropertyToColumn[$property->getName()] = $instance->name; + if ($instance->enumType !== null) { + $this->mappingColumnToEnumType[$instance->name] = $instance->enumType; + } } elseif ($instance instanceof Id) { $propertyAttributes->id = $instance; $this->idProperties[] = $property; @@ -88,6 +94,10 @@ public function __construct( throw new \RuntimeException($this->entityClass . ' has an Id attribute on ' . $property->getName() . ' but not the corresponding required Column attribute.'); } + if ($propertyAttributes->column instanceof Column && $propertyAttributes->column->enumType !== null) { + $this->validateEnumType($property, $propertyAttributes->column); + } + if ($propertyAttributes->oneToOne instanceof OneToOne && $propertyAttributes->oneToOne->mappedBy !== null && $propertyAttributes->joinColumn instanceof JoinColumn @@ -165,4 +175,34 @@ private function validateMappedBy(\ReflectionProperty $property, OneToOne $oneTo throw new \RuntimeException($prefix . $oneToOne->targetEntity . '::' . $mappedBy . ' has no JoinColumn attribute.'); } } + + private function validateEnumType(\ReflectionProperty $property, Column $column): void { + /** @var class-string $enumType */ + $enumType = $column->enumType; + $prefix = $this->entityClass . '::' . $property->getName() . " declares enumType: {$enumType}, but "; + + if (!enum_exists($enumType)) { + throw new \RuntimeException($prefix . 'that class is not an enum.'); + } + + if (!is_a($enumType, \BackedEnum::class, true)) { + 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.'); + } + + $propertyType = $property->getType(); + if ($propertyType instanceof \ReflectionNamedType && ltrim($propertyType->getName(), '\\') !== ltrim($enumType, '\\')) { + throw new \RuntimeException($prefix . 'the property is typed as ' . $propertyType->getName() . ' instead.'); + } + + $backingType = (new \ReflectionEnum($enumType))->getBackingType(); + $backingTypeName = $backingType instanceof \ReflectionNamedType ? $backingType->getName() : null; + $compatibleColumnTypes = match ($backingTypeName) { + 'int' => [ColumnType::Bigint, ColumnType::Smallint, ColumnType::Integer], + 'string' => [ColumnType::Binary, ColumnType::Decimal, ColumnType::Text, ColumnType::String], + default => [], + }; + if (!in_array($column->type, $compatibleColumnTypes, true)) { + throw new \RuntimeException($prefix . "its column type ({$column->type->name}) cannot hold a(n) {$backingTypeName}-backed enum's value."); + } + } } diff --git a/lib/private/AppFramework/ORM/EntityManager.php b/lib/private/AppFramework/ORM/EntityManager.php index 29a22eadf2bee..1df5d0071b7d2 100644 --- a/lib/private/AppFramework/ORM/EntityManager.php +++ b/lib/private/AppFramework/ORM/EntityManager.php @@ -135,7 +135,7 @@ public function insert(object $entity): object { if ($propertyAttributes->column !== null) { $type = $this->getParameterType($propertyAttributes->column->type, false); - $values[$propertyAttributes->column->name] = $insert->createNamedParameter($property->getValue($entity), $type); + $values[$propertyAttributes->column->name] = $insert->createNamedParameter($this->toParameterValue($property->getValue($entity)), $type); } } @@ -198,7 +198,7 @@ public function update(object $entity): object { if ($propertyAttributes->column !== null) { $type = $this->getParameterType($propertyAttributes->column->type, false); - $update->set($propertyAttributes->column->name, $update->createNamedParameter($value, $type)); + $update->set($propertyAttributes->column->name, $update->createNamedParameter($this->toParameterValue($value), $type)); } } @@ -279,6 +279,18 @@ public function getParameterType(ColumnType $type, bool $isArray): string|int { }; } + public function toParameterValue(mixed $value): mixed { + if ($value instanceof \BackedEnum) { + return $value->value; + } + + if (is_array($value)) { + return array_map($this->toParameterValue(...), $value); + } + + return $value; + } + /** * @internal Only for unit tests. * @@ -326,7 +338,11 @@ private function createProperty(EntityInfo $entityInfo, PropertyAttributes $attr } if ($columnAttribute->default !== null) { - $options['default'] = $columnAttribute->default; + // Column::$default is documented as scalar|\BackedEnum, so unwrapping a \BackedEnum + // case here always yields a scalar. + /** @var scalar $default */ + $default = $this->toParameterValue($columnAttribute->default); + $options['default'] = $default; } // A composite primary key can't rely on a single autoincrement column; see insert(). diff --git a/lib/public/AppFramework/ORM/Attribute/Column.php b/lib/public/AppFramework/ORM/Attribute/Column.php index fe605c469bd11..f0e63bbe2e4f4 100644 --- a/lib/public/AppFramework/ORM/Attribute/Column.php +++ b/lib/public/AppFramework/ORM/Attribute/Column.php @@ -22,6 +22,24 @@ * } * ``` * + * A property can be typed as a backed enum instead of a plain scalar by setting `enumType` to + * the enum's class-string. The column itself still stores the enum's scalar backing value + * (declare `type`/`length` accordingly), but the property is hydrated to and persisted from the + * enum case itself: + * + * ```php + * enum Status: string { + * case Draft = 'draft'; + * case Published = 'published'; + * } + * + * #[Entity(name: 'my_entity')] + * final class MyEntity { + * #[Column(name: 'status', type: ColumnType::String, length: 32, enumType: Status::class)] + * public Status $status = Status::Draft; + * } + * ``` + * * @since 35.0.0 */ #[Attribute(Attribute::TARGET_PROPERTY)] @@ -37,8 +55,14 @@ public function __construct( public ?int $length = null, /** @var bool Whether the column is nullable in the database */ public bool $nullable = false, - /** @var scalar|null The default value for the column in the database. */ + /** @var scalar|\BackedEnum|null The default value for the column in the database. */ public mixed $default = null, + /** + * @var class-string<\BackedEnum>|null The backed enum the property is typed as. The + * column keeps storing the enum's scalar backing + * value; only the PHP property is the enum case. + */ + public ?string $enumType = null, ) { } } diff --git a/lib/public/AppFramework/ORM/Repository.php b/lib/public/AppFramework/ORM/Repository.php index 3a007a9194c83..7cef0141566df 100644 --- a/lib/public/AppFramework/ORM/Repository.php +++ b/lib/public/AppFramework/ORM/Repository.php @@ -109,6 +109,14 @@ private function hydrateRow(string $entityClass, mixed $row): object { ColumnType::Blob => $value, }; + $enumType = $entityInfo->mappingColumnToEnumType[$column] ?? null; + if ($enumType !== null) { + if (!is_string($value) && !is_int($value)) { + throw new \LogicException("Can only convert int and string to enum"); + } + $value = $enumType::from($value); + } + $entity->$property = $value; } @@ -384,7 +392,7 @@ public function insertOrUpdate(object $entity): object { /** * Finds entities by a set of criteria, keyed by property name. * - * @param array> $criteria + * @param array> $criteria * @param array $orderBy * @return \Generator * @since 35.0.0 @@ -404,7 +412,7 @@ public function findBy(array $criteria, array $orderBy = [], ?int $limit = null, } /** - * @param array> $criteria + * @param array> $criteria * @return int The number of rows deleted * @throws Exception * @since 35.0.0 @@ -417,6 +425,8 @@ public function deleteBy(array $criteria, ?int $limit = null): int { foreach ($criteria as $property => $value) { $column = $entityInfo->mappingPropertyToColumn[$property]; + /** @psalm-suppress MixedAssignment can be anything */ + $value = $this->entityManager->toParameterValue($value); $type = $this->entityManager->getParameterType($entityInfo->mappingColumnToTypes[$column], is_array($value)); if ($value === null) { $qb->andWhere($qb->expr()->isNull($column)); @@ -439,7 +449,7 @@ public function deleteBy(array $criteria, ?int $limit = null): int { /** * Finds a single entity by a set of criteria, keyed by property name. * - * @param array> $criteria + * @param array> $criteria * @param array $orderBy * @return T * @throws DoesNotExistException @@ -454,7 +464,7 @@ public function findOneBy(array $criteria, array $orderBy = []): object { } /** - * @param array> $criteria + * @param array> $criteria * @param array $orderBy * @return array{0: IQueryBuilder, 1: array} */ @@ -464,6 +474,8 @@ private function getJoinedSelectQueryBuilder(array $criteria, array $orderBy = [ foreach ($criteria as $property => $value) { $column = $entityInfo->mappingPropertyToColumn[$property]; + /** @psalm-suppress MixedAssignment $value is caller-supplied criteria, unwrapped of any \BackedEnum case. */ + $value = $this->entityManager->toParameterValue($value); $type = $this->entityManager->getParameterType($entityInfo->mappingColumnToTypes[$column], is_array($value)); if ($value === null) { $qb->andWhere($qb->expr()->isNull('e.' . $column)); diff --git a/tests/lib/AppFramework/ORM/RepositoryTest.php b/tests/lib/AppFramework/ORM/RepositoryTest.php index 99bda83ff7be9..c8e144ae91d70 100644 --- a/tests/lib/AppFramework/ORM/RepositoryTest.php +++ b/tests/lib/AppFramework/ORM/RepositoryTest.php @@ -29,6 +29,78 @@ class NoPrimaryKey { public ?int $id = null; } +enum OrderStatus: string { + case Draft = 'draft'; + case Placed = 'placed'; + case Shipped = 'shipped'; +} + +enum Priority: int { + case Low = 1; + case High = 2; +} + +enum NotBacked { + case Foo; +} + +#[Entity(name: 'repository_enum_order')] +final class EnumOrder { + #[Id] + #[Column(name: 'id', type: ColumnType::Bigint)] + public ?int $id = null; + + #[Column(name: 'status', type: ColumnType::String, length: 32, enumType: OrderStatus::class, default: OrderStatus::Draft)] + public OrderStatus $status = OrderStatus::Draft; + + #[Column(name: 'priority', type: ColumnType::Integer, enumType: Priority::class)] + public Priority $priority; + + #[Column(name: 'previous_status', type: ColumnType::String, length: 32, nullable: true, enumType: OrderStatus::class)] + public ?OrderStatus $previousStatus = null; +} + +#[Entity(name: 'repository_enum_unknown_class')] +final class EnumUnknownClass { + #[Id] + #[Column(name: 'id', type: ColumnType::Bigint)] + public ?int $id = null; + + #[Column(name: 'status', type: ColumnType::String, enumType: 'OCP\AppFramework\ORM\Attribute\ThisClassDoesNotExist')] + public string $status; +} + +#[Entity(name: 'repository_enum_not_backed')] +final class EnumNotBacked { + #[Id] + #[Column(name: 'id', type: ColumnType::Bigint)] + public ?int $id = null; + + #[Column(name: 'status', type: ColumnType::String, enumType: NotBacked::class)] + public NotBacked $status; +} + +#[Entity(name: 'repository_enum_type_mismatch')] +final class EnumTypeMismatch { + #[Id] + #[Column(name: 'id', type: ColumnType::Bigint)] + public ?int $id = null; + + #[Column(name: 'status', type: ColumnType::String, enumType: OrderStatus::class)] + public string $status; +} + +#[Entity(name: 'repository_enum_column_mismatch')] +final class EnumColumnTypeMismatch { + #[Id] + #[Column(name: 'id', type: ColumnType::Bigint)] + public ?int $id = null; + + // Priority is int-backed, but the column is declared as a string. + #[Column(name: 'priority', type: ColumnType::String, enumType: Priority::class)] + public Priority $priority; +} + #[Entity(name: 'repository_test_test2')] class PrimaryKey { #[Id] @@ -196,6 +268,7 @@ class RepositoryTest extends TestCase { CascadeChild::class, Merchant::class, Order::class, + EnumOrder::class, ]; public static function setUpBeforeClass(): void { @@ -644,4 +717,119 @@ public function testEntityWithoutRelationsGeneratesNoJoin(): void { $this->normalizeSql($qb->getSQL()), ); } + + public function testEnumColumnRoundTrip(): void { + $repo = $this->getRepository(EnumOrder::class); + + $order = new EnumOrder(); + $order->status = OrderStatus::Placed; + $order->priority = Priority::High; + $repo->insert($order); + $this->assertNotNull($order->id); + + $saved = $repo->findOneBy(['id' => $order->id]); + $this->assertSame(OrderStatus::Placed, $saved->status); + $this->assertSame(Priority::High, $saved->priority); + $this->assertNull($saved->previousStatus); + + $repo->delete($saved); + } + + public function testEnumColumnDefault(): void { + $repo = $this->getRepository(EnumOrder::class); + + $order = new EnumOrder(); + $order->priority = Priority::Low; + $this->assertSame(OrderStatus::Draft, $order->status); + + $repo->insert($order); + $saved = $repo->findOneBy(['id' => $order->id]); + $this->assertSame(OrderStatus::Draft, $saved->status); + + $repo->delete($saved); + } + + public function testEnumColumnUpdate(): void { + $repo = $this->getRepository(EnumOrder::class); + + $order = new EnumOrder(); + $order->status = OrderStatus::Draft; + $order->priority = Priority::Low; + $repo->insert($order); + + $order->previousStatus = $order->status; + $order->status = OrderStatus::Shipped; + $repo->update($order); + + $saved = $repo->findOneBy(['id' => $order->id]); + $this->assertSame(OrderStatus::Shipped, $saved->status); + $this->assertSame(OrderStatus::Draft, $saved->previousStatus); + + $repo->delete($saved); + } + + public function testFindByEnumCriteria(): void { + $repo = $this->getRepository(EnumOrder::class); + + $placed = new EnumOrder(); + $placed->status = OrderStatus::Placed; + $placed->priority = Priority::High; + $repo->insert($placed); + + $shipped = new EnumOrder(); + $shipped->status = OrderStatus::Shipped; + $shipped->priority = Priority::Low; + $repo->insert($shipped); + + $found = $repo->findOneBy(['status' => OrderStatus::Placed]); + $this->assertSame($placed->id, $found->id); + + // IN (...) form with a list of enum cases + $both = iterator_to_array($repo->findBy(['status' => [OrderStatus::Placed, OrderStatus::Shipped]])); + $this->assertCount(2, $both); + + $repo->delete($placed); + $repo->delete($shipped); + } + + public function testDeleteByEnumCriteria(): void { + $repo = $this->getRepository(EnumOrder::class); + + $order = new EnumOrder(); + $order->status = OrderStatus::Draft; + $order->priority = Priority::Low; + $repo->insert($order); + + $repo->deleteBy(['status' => OrderStatus::Draft]); + + $this->assertCount(0, iterator_to_array($repo->findBy(['id' => $order->id]))); + } + + public function testEnumTypeMustExist(): void { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('that class is not an enum'); + + Server::get(EntityManager::class)->getEntityInfo(EnumUnknownClass::class); + } + + public function testEnumTypeMustBeBacked(): void { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('that enum is not backed'); + + Server::get(EntityManager::class)->getEntityInfo(EnumNotBacked::class); + } + + public function testEnumTypeMustMatchPropertyType(): void { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('the property is typed as string instead'); + + Server::get(EntityManager::class)->getEntityInfo(EnumTypeMismatch::class); + } + + public function testEnumBackingTypeMustMatchColumnType(): void { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage("cannot hold a(n) int-backed enum's value"); + + Server::get(EntityManager::class)->getEntityInfo(EnumColumnTypeMismatch::class); + } }