From ae2ad986103d127b7c0bfa6f94757fdce91d2407 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Wed, 24 Jun 2026 20:47:16 +0200 Subject: [PATCH] Introduce `EnumCast` Behavior This behavior helps to convert backed-enum database values to and from PHP enum instances --- src/Behavior/EnumCast.php | 97 ++++++++++++++++++++++++++++ tests/Behavior/EnumCastTest.php | 109 ++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 src/Behavior/EnumCast.php create mode 100644 tests/Behavior/EnumCastTest.php diff --git a/src/Behavior/EnumCast.php b/src/Behavior/EnumCast.php new file mode 100644 index 0000000..36a50ab --- /dev/null +++ b/src/Behavior/EnumCast.php @@ -0,0 +1,97 @@ +add(new EnumCast(Status::class, ['status'])); + * ``` + */ +class EnumCast extends PropertyBehavior +{ + /** + * The fully qualified class name of the target-backed enum + * + * @var class-string + */ + protected string $enum; + + /** + * @param class-string $enum Fully-qualified class name of the target backed enum + * @param array $properties Property names to process, as values + */ + public function __construct(string $enum, array $properties) + { + if (! is_subclass_of($enum, BackedEnum::class)) { + throw new InvalidArgumentException("$enum must be a BackedEnum"); + } + + $this->enum = $enum; + + parent::__construct($properties); + } + + /** + * @param int|string|null $value + * + * @return ?BackedEnum + * + * @throws ValueConversionException + */ + public function fromDb($value, $key, $_) + { + if ($value === null || $value instanceof $this->enum) { + return $value; + } + + try { + return $this->enum::from($value); + } catch (ValueError $e) { + throw new ValueConversionException(sprintf( + "%s (key: '%s')", + $e->getMessage(), + $key + )); + } + } + + /** + * @param BackedEnum|int|string|null $value + * + * @return int|string|null + * + * @throws ValueConversionException + */ + public function toDb($value, $key, $_) + { + if ($value === null) { + return null; + } + + if ($value instanceof $this->enum) { + return $value->value; + } + + try { + return $this->enum::from($value)->value; + } catch (ValueError $e) { + throw new ValueConversionException(sprintf( + "%s (key: '%s')", + $e->getMessage(), + $key + )); + } + } +} diff --git a/tests/Behavior/EnumCastTest.php b/tests/Behavior/EnumCastTest.php new file mode 100644 index 0000000..cb6f24b --- /dev/null +++ b/tests/Behavior/EnumCastTest.php @@ -0,0 +1,109 @@ +behavior = new EnumCast(Color::class, ['color']); + } + + public function testConstructorThrowsForNonBackedEnumTypes(): void + { + $this->expectException(InvalidArgumentException::class); + + new EnumCast(stdClass::class, ['col']); + } + + public function testFromDbConvertsScalarToEnumCase(): void + { + $this->assertSame(Color::Red, $this->behavior->fromDb('red', 'color', null)); + } + + public function testFromDbPreservesNull(): void + { + $this->assertNull($this->behavior->fromDb(null, 'color', null)); + } + + public function testToDbUnwrapsEnumCaseToBackingValue(): void + { + $this->assertSame('red', $this->behavior->toDb(Color::Red, 'color', null)); + } + + public function testToDbPassesThroughValidBackingValue(): void + { + $this->assertSame('red', $this->behavior->toDb('red', 'color', null)); + } + + public function testToDbThrowsForInvalidBackingValue(): void + { + $this->expectException(ValueConversionException::class); + + $this->behavior->toDb('invalid', 'color', null); + } + + public function testToDbThrowsForIncompatibleEnumInstance(): void + { + $this->expectException(TypeError::class); + $this->behavior->toDb(Priority::Low, 'color', null); + } + + public function testToDbPassesThroughNull(): void + { + $this->assertNull($this->behavior->toDb(null, 'color', null)); + } + + public function testFromDbThrowsForUnknownBackingValue(): void + { + $this->expectException(ValueConversionException::class); + + $this->behavior->fromDb('purple', 'color', null); + } + + public function testFromDbIsIdempotentWhenValueIsAlreadyAnEnumInstance(): void + { + $this->assertSame(Color::Red, $this->behavior->fromDb(Color::Red, 'color', null)); + } + + public function testToDbIsIdempotentWhenValueIsAlreadyABackingScalar(): void + { + $scalar = $this->behavior->toDb(Color::Red, 'color', null); + $this->assertSame($scalar, $this->behavior->toDb($scalar, 'color', null)); + } + + public function testFromDbConvertsIntBackedEnumCase(): void + { + $behavior = new EnumCast(Priority::class, ['priority']); + $this->assertSame(Priority::Low, $behavior->fromDb(1, 'priority', null)); + } + + public function testToDbUnwrapsIntBackedEnumCaseToBackingValue(): void + { + $behavior = new EnumCast(Priority::class, ['priority']); + $this->assertSame(1, $behavior->toDb(Priority::Low, 'priority', null)); + } +}