Skip to content

Commit

Permalink
Support for PHP native enums #9951
Browse files Browse the repository at this point in the history
DBAL enum must be explicitly declared like so:

```php
class LanguageType extends PhpEnumType
{
    protected function getEnumType(): string
    {
        return Language::class;
    }
}
```

GraphQL enums are automatically generated as long as `EnumAbstractFactory`
is configured and enum lives in `Application\Enum` namespace.

Then the model can use them like so:

```php
#[ORM\Entity]
class MyModel
{
    #[ORM\Column(type: 'Language', options: ['default' => Language::fr])]
    private Language $language = Language::fr;

    public function getLanguage(): Language
    {
        return $this->language;
    }

    public function setLanguage(Language $language): void
    {
        $this->language = $language;
    }
}
```
  • Loading branch information
PowerKiKi committed Nov 9, 2023
1 parent 0ef5dad commit 6c8847f
Show file tree
Hide file tree
Showing 11 changed files with 401 additions and 5 deletions.
20 changes: 20 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
@@ -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\\<string\\> but returns array\\<int\\|string\\>\\.$#"
count: 1
path: src/DBAL/Types/PhpEnumType.php

-
message: "#^Property Ecodev\\\\Felix\\\\Service\\\\DataRestorer\\:\\:\\$allRelationTables \\(array\\<string, array\\{table1\\: class\\-string, table2\\: class\\-string\\}\\>\\) does not accept array\\<int\\|string, array\\<string, mixed\\>\\>\\.$#"
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
Expand Down
64 changes: 64 additions & 0 deletions src/Api/Enum/EnumAbstractFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace Ecodev\Felix\Api\Enum;

use BackedEnum;
use Exception;
use Laminas\ServiceManager\Factory\AbstractFactoryInterface;
use Psr\Container\ContainerInterface;

/**
* Will create GraphQL Enums for a short name or a FQCN of a PHP native backed enum.
*
* The PHP enum must live in `Application\Enum` namespace.
*/
class EnumAbstractFactory implements AbstractFactoryInterface
{
/**
* @var array<class-string<BackedEnum>, 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<BackedEnum>
*/
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;
}
}
18 changes: 18 additions & 0 deletions src/Api/Enum/LocalizedPhpEnumType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Ecodev\Felix\Api\Enum;

use BackedEnum;

/**
* An enum that has a localized description for each case to be shown to end-user.
*/
interface LocalizedPhpEnumType extends BackedEnum
{
/**
* Returns the user-friendly, localized description for the case.
*/
public function getDescription(): string;
}
26 changes: 26 additions & 0 deletions src/Api/Enum/PhpEnumType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Ecodev\Felix\Api\Enum;

use ReflectionClass;
use ReflectionClassConstant;

/**
* Like `\GraphQL\Type\Definition\PhpEnumType` with added support for `LocalizedPhpEnumType`.
*/
final class PhpEnumType extends \GraphQL\Type\Definition\PhpEnumType
{
protected function extractDescription(ReflectionClassConstant|ReflectionClass $reflection): ?string
{
if ($reflection instanceof ReflectionClassConstant) {
$value = $reflection->getValue();
if ($value instanceof LocalizedPhpEnumType) {
return $reflection->getValue()->getDescription();
}
}

return parent::extractDescription($reflection);
}
}
13 changes: 8 additions & 5 deletions src/DBAL/Types/EnumType.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Ecodev\Felix\DBAL\Types;

use BackedEnum;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;
use Exception;
Expand All @@ -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;
Expand Down
57 changes: 57 additions & 0 deletions src/DBAL/Types/PhpEnumType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Ecodev\Felix\DBAL\Types;

use BackedEnum;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use GraphQL\Utils\Utils;
use InvalidArgumentException;

/**
* Enum based on native PHP backed enum.
*/
abstract class PhpEnumType extends EnumType
{
/**
* Returns the FQCN of the native PHP enum.
*
* @return class-string<BackedEnum>
*/
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;
}
}
23 changes: 23 additions & 0 deletions tests/Api/Enum/PhpEnumTypeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace EcodevTests\Felix\Api\Enum;

use Ecodev\Felix\Api\Enum\PhpEnumType;
use EcodevTests\Felix\Service\OtherTestEnum;
use EcodevTests\Felix\Service\TestEnum;
use PHPUnit\Framework\TestCase;

class PhpEnumTypeTest extends TestCase
{
public function testLocalizedDescription(): void
{
$type = new PhpEnumType(TestEnum::class);
self::assertSame('custom description for key 1', $type->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');
}
}
83 changes: 83 additions & 0 deletions tests/DBAL/Types/PhpEnumTypeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace EcodevTests\Felix\DBAL\Types;

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Ecodev\Felix\DBAL\Types\PhpEnumType;
use EcodevTests\Felix\Service\OtherTestEnum;
use EcodevTests\Felix\Service\TestEnum;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use ValueError;

class PhpEnumTypeTest extends TestCase
{
private PhpEnumType $type;

private AbstractPlatform $platform;

protected function setUp(): void
{
$this->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);
}
}
Loading

0 comments on commit 6c8847f

Please sign in to comment.