Skip to content
Open
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
40 changes: 40 additions & 0 deletions lib/private/AppFramework/ORM/EntityInfo.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ final class EntityInfo {
/** @var array<string, ColumnType> */
public array $mappingColumnToTypes = [];

/** @var array<string, class-string<\BackedEnum>> */
public array $mappingColumnToEnumType = [];

/** @var array<string, string> */
public array $mappingColumnToProperty = [];

Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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.");
}
}
}
22 changes: 19 additions & 3 deletions lib/private/AppFramework/ORM/EntityManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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));
}
}

Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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().
Expand Down
26 changes: 25 additions & 1 deletion lib/public/AppFramework/ORM/Attribute/Column.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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,
) {
}
}
20 changes: 16 additions & 4 deletions lib/public/AppFramework/ORM/Repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

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

/**
* @param array<string, int|float|string|null|\DateTime|list<int|float|string>> $criteria
* @param array<string, int|float|string|null|\DateTime|\BackedEnum|list<int|float|string|\BackedEnum>> $criteria
* @return int The number of rows deleted
* @throws Exception
* @since 35.0.0
Expand All @@ -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));
Expand All @@ -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<string, int|float|string|null|\DateTime|list<int|float|string>> $criteria
* @param array<string, int|float|string|null|\DateTime|\BackedEnum|list<int|float|string|\BackedEnum>> $criteria
* @param array<string, \SortDirection> $orderBy
* @return T
* @throws DoesNotExistException
Expand All @@ -454,7 +464,7 @@ public function findOneBy(array $criteria, array $orderBy = []): object {
}

/**
* @param array<string, int|float|string|null|\DateTime|list<int|float|string>> $criteria
* @param array<string, int|float|string|null|\DateTime|\BackedEnum|list<int|float|string|\BackedEnum>> $criteria
* @param array<string, \SortDirection> $orderBy
* @return array{0: IQueryBuilder, 1: array<string, array{attributes: PropertyAttributes, entityInfo: EntityInfo}>}
*/
Expand All @@ -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));
Expand Down
Loading
Loading