diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b17a180..fb7a224 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,10 +1,30 @@ parameters: ignoreErrors: + - + message: "#^Cannot call method getDescription\\(\\) on mixed\\.$#" + count: 1 + path: src/Api/Enum/PhpEnumType.php + + - + message: "#^Method Ecodev\\\\Felix\\\\DBAL\\\\Types\\\\PhpEnumType\\:\\:convertToDatabaseValue\\(\\) should return string\\|null but returns int\\|string\\.$#" + count: 1 + path: src/DBAL/Types/PhpEnumType.php + + - + message: "#^Method Ecodev\\\\Felix\\\\DBAL\\\\Types\\\\PhpEnumType\\:\\:getPossibleValues\\(\\) should return array\\ but returns array\\\\.$#" + count: 1 + path: src/DBAL/Types/PhpEnumType.php + - message: "#^Property Ecodev\\\\Felix\\\\Service\\\\DataRestorer\\:\\:\\$allRelationTables \\(array\\\\) does not accept array\\\\>\\.$#" count: 1 path: src/Service/DataRestorer.php + - + message: "#^Parameter \\#1 \\$value of method Ecodev\\\\Felix\\\\DBAL\\\\Types\\\\PhpEnumType\\:\\:convertToPHPValue\\(\\) expects string\\|null, int given\\.$#" + count: 1 + path: tests/DBAL/Types/PhpEnumTypeTest.php + - message: "#^Parameter \\#1 \\$input of method OTPHP\\\\OTPInterface\\:\\:at\\(\\) expects int\\<0, max\\>, int\\<\\-26, max\\> given\\.$#" count: 1 diff --git a/src/Api/Enum/EnumAbstractFactory.php b/src/Api/Enum/EnumAbstractFactory.php new file mode 100644 index 0000000..535018e --- /dev/null +++ b/src/Api/Enum/EnumAbstractFactory.php @@ -0,0 +1,64 @@ +, PhpEnumType> + */ + private array $cache = []; + + public function canCreate(ContainerInterface $container, $requestedName) + { + $class = $this->getClass($requestedName); + + return (bool) $class; + } + + public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null) + { + $class = $this->getClass($requestedName); + if (!$class) { + throw new Exception('Cannot create a PhpEnumType for a name not matching a backed enum: ' . $requestedName); + } + + // Share the same instance between short name and FQCN + if (!array_key_exists($class, $this->cache)) { + $this->cache[$class] = new PhpEnumType($class); + } + + return $this->cache[$class]; + } + + /** + * @return null|class-string + */ + private function getClass(string $requestedName): ?string + { + $possibilities = [ + $requestedName, + 'Application\Enum\\' . $requestedName, + ]; + + foreach ($possibilities as $class) { + if (class_exists($class) && is_a($class, BackedEnum::class, true)) { + return $class; + } + } + + return null; + } +} diff --git a/src/Api/Enum/LocalizedPhpEnumType.php b/src/Api/Enum/LocalizedPhpEnumType.php new file mode 100644 index 0000000..fb7ec98 --- /dev/null +++ b/src/Api/Enum/LocalizedPhpEnumType.php @@ -0,0 +1,18 @@ +getValue(); + if ($value instanceof LocalizedPhpEnumType) { + return $reflection->getValue()->getDescription(); + } + } + + return parent::extractDescription($reflection); + } +} diff --git a/src/DBAL/Types/EnumType.php b/src/DBAL/Types/EnumType.php index 0bbd7b6..098f338 100644 --- a/src/DBAL/Types/EnumType.php +++ b/src/DBAL/Types/EnumType.php @@ -4,6 +4,7 @@ namespace Ecodev\Felix\DBAL\Types; +use BackedEnum; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Types\Type; use Exception; @@ -12,17 +13,19 @@ abstract class EnumType extends Type { - public function getSqlDeclaration(array $column, AbstractPlatform $platform): string + final public function getQuotedPossibleValues(): string { - $possibleValues = $this->getPossibleValues(); - $quotedPossibleValues = implode(', ', array_map(fn (string $str) => "'" . $str . "'", $possibleValues)); + return implode(', ', array_map(fn (string $str) => "'" . $str . "'", $this->getPossibleValues())); + } - $sql = 'ENUM(' . $quotedPossibleValues . ')'; + public function getSqlDeclaration(array $column, AbstractPlatform $platform): string + { + $sql = 'ENUM(' . $this->getQuotedPossibleValues() . ')'; return $sql; } - public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?string + public function convertToPHPValue(mixed $value, AbstractPlatform $platform): null|string|BackedEnum { if ($value === null || '' === $value) { return null; diff --git a/src/DBAL/Types/PhpEnumType.php b/src/DBAL/Types/PhpEnumType.php new file mode 100644 index 0000000..6051ce2 --- /dev/null +++ b/src/DBAL/Types/PhpEnumType.php @@ -0,0 +1,57 @@ + + */ + abstract protected function getEnumType(): string; + + protected function getPossibleValues(): array + { + return array_map(fn (BackedEnum $str) => $str->value, $this->getEnumType()::cases()); + } + + /** + * @param ?string $value + */ + public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?BackedEnum + { + if ($value === null || '' === $value) { + return null; + } + + if (!is_string($value)) { + throw new InvalidArgumentException("Invalid '" . Utils::printSafe($value) . "' value fetched from database for enum " . $this->getName()); + } + + return $this->getEnumType()::from($value); + } + + public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): ?string + { + if ($value === null) { + return null; + } + + if (!is_object($value) || !is_a($value, $this->getEnumType())) { + throw new InvalidArgumentException("Invalid '" . Utils::printSafe($value) . "' value to be stored in database for enum " . $this->getName()); + } + + return $value->value; + } +} diff --git a/tests/Api/Enum/PhpEnumTypeTest.php b/tests/Api/Enum/PhpEnumTypeTest.php new file mode 100644 index 0000000..c9ce1be --- /dev/null +++ b/tests/Api/Enum/PhpEnumTypeTest.php @@ -0,0 +1,23 @@ +getValues()[0]->description); + self::assertSame('other for key 2', $type->getValues()[1]->description); + + $normalType = new PhpEnumType(OtherTestEnum::class); + self::assertSame('static description via webonyx/graphql', $normalType->getValues()[0]->description, 'base features are still working'); + } +} diff --git a/tests/DBAL/Types/PhpEnumTypeTest.php b/tests/DBAL/Types/PhpEnumTypeTest.php new file mode 100644 index 0000000..24248dc --- /dev/null +++ b/tests/DBAL/Types/PhpEnumTypeTest.php @@ -0,0 +1,83 @@ +type = new class() extends PhpEnumType { + protected function getEnumType(): string + { + return TestEnum::class; + } + }; + + $this->platform = new MySQLPlatform(); + } + + public function testEnum(): void + { + self::assertSame("ENUM('value1', 'value2')", $this->type->getSqlDeclaration(['foo'], $this->platform)); + + // Should always return string + self::assertSame(TestEnum::key1, $this->type->convertToPHPValue('value1', $this->platform)); + + // Should support null values or empty string + self::assertNull($this->type->convertToPHPValue(null, $this->platform)); + self::assertNull($this->type->convertToPHPValue('', $this->platform)); + self::assertNull($this->type->convertToDatabaseValue(null, $this->platform)); + + self::assertTrue($this->type->requiresSQLCommentHint($this->platform)); + } + + public function testConvertToPHPValueThrowsWithInvalidValue(): void + { + $this->expectException(ValueError::class); + + $this->type->convertToPHPValue('foo', $this->platform); + } + + public function testConvertToDatabaseValueThrowsWithInvalidValue(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->type->convertToDatabaseValue('foo', $this->platform); + } + + public function testConvertToDatabaseValueThrowsWithInvalidEnum(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->type->convertToDatabaseValue(OtherTestEnum::key1, $this->platform); + } + + public function testConvertToPHPValueThrowsWithZero(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->type->convertToPHPValue(0, $this->platform); + } + + public function testConvertToDatabaseValueThrowsWithZero(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->type->convertToDatabaseValue(0, $this->platform); + } +} diff --git a/tests/Service/EnumAutoMigratorTest.php b/tests/Service/EnumAutoMigratorTest.php new file mode 100644 index 0000000..881356b --- /dev/null +++ b/tests/Service/EnumAutoMigratorTest.php @@ -0,0 +1,68 @@ + 'val1']; + } + }; + + $enumType2 = new class() extends EnumType { + protected function getPossibleValues(): array + { + return ['key1' => 'val1']; + } + }; + + $phpEnumType = new class() extends PhpEnumType { + protected function getEnumType(): string + { + return TestEnum::class; + } + }; + + $col1 = new Column('col1', new StringType()); + $col2 = new Column('col2', $enumType1); + $col3 = new Column('col3', $enumType2); + $col3bis = new Column('col3bis', $enumType2); + $col4 = new Column('col4', $phpEnumType); + + $event = new GenerateSchemaEventArgs( + $this->createMock(EntityManager::class), + new Schema([ + new Table('foo', [$col1, $col2, $col3, $col3bis, $col4]), + ]) + ); + + $enumAutoMigrator->postGenerateSchema($event); + + self::assertNull($col1->getComment()); + self::assertSame('(FelixEnum:59be1fe78104fed1c6b2e6aada4faf62)', $col2->getComment()); + self::assertSame($col2->getComment(), $col3->getComment(), 'different enum that happen to have same definition have same hash, because it makes no difference for DB'); + self::assertSame('(FelixEnum:59be1fe78104fed1c6b2e6aada4faf62)', $col3bis->getComment(), 'different column with exact same type must also have same hash'); + self::assertSame('(FelixEnum:fa38e8669a8a21493a62a0d493a28ad0)', $col4->getComment(), 'native PHP enum are supported too'); + } +} diff --git a/tests/Service/OtherTestEnum.php b/tests/Service/OtherTestEnum.php new file mode 100644 index 0000000..91b3fd2 --- /dev/null +++ b/tests/Service/OtherTestEnum.php @@ -0,0 +1,13 @@ + 'custom description for key 1', + TestEnum::key2 => 'other for key 2', + }; + } +}