Enum в PHP

Проблема
Як відомо, в PHP немає вбудованого типу перерахувань, і в проектах зі складною предметною областю цей факт створює безліч проблем. Коли в черговому Symfony-проекті з'явилася необхідність у списки, було вирішено створити свою реалізацію.

Від перерахувань потрібна гнучкість і можливість використання у різних компонентах програми. Завдання, які повинні були вирішувати перерахування, наступні:

  • мати можливість отримати список значень перерахувань
  • інтеграція з Doctrine для використання перерахування в якості типу поля
  • інтеграція з Form для використання перерахувань як поле у формі для вибору потрібного елемента
  • інтеграція з Twig для перекладу значень перерахування

Є декілька реалізацій перерахувань, наприклад, myclabs/php-enum, іноді досить дивних, в тому числі — SplEnum. Але при інтеграції їх з іншими частинами програми (doctrine, twig) виникають проблеми, особливо при використанні Doctrine.

Особливість системи типів Doctrine полягає в тому, що всі типи повинні успадковуватися від класу Type, який має private final конструктор. Тобто ми не можемо успадковуватися від нього і перевантажити конструктор, щоб він приймав значення перерахування. Тим не менш, цю проблему вдалося вирішити, хоч і дещо нестандартним способом.

Реалізація
Enum — базовий клас перерахувань

Enum.php
<?php

namespace AppBundle\System\Component\Enum;

use Doctrine\DBAL\Platforms\MySqlPlatform;
use Doctrine\DBAL\Platforms\PostgreSqlPlatform;
use Doctrine\DBAL\Platforms\SqlitePlatform;
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Platforms\AbstractPlatform;

class Enum
{
private static $values = [];
private static $valueMap = [];

private $value;

public function __construct($value)
{
$this->value = $value;
}

public function getValue()
{
return $this->value;
}

public function __toString()
{
return $this->value;
}


/**
* @return Enum[]
* @throws \Exception
*/
public static function getValues()
{
$className = get_called_class();
if (!array_key_exists($className, self::$values)) {
throw new \Exception(sprintf("Enum is not initialized, enum=%s", $className));
}
return self::$values[$className];
}

public static function getEnumObject($value)
{
if (empty($value)) {
return null;
}
$className = get_called_class();
return self::$valueMap[$className][$value];
}

public static function init()
{
$className = get_called_class();
$class = new \ReflectionClass($className);

if (array_key_exists($className, self::$values)) {
throw new \Exception(sprintf("Enum has already been initialized, enum=%s", $className));
}
self::$values[$className] = [];
self::$valueMap[$className] = [];


/** @var Enum[] $enumFields */
$enumFields = array_filter($class->getStaticProperties(), function ($property) {
return $property instanceof Enum;
});
if (count($enumFields) == 0) {
throw new \Exception(sprintf("Enum has not values, enum=%s", $className));
}

foreach ($enumFields as $property) {
if (array_key_exists($property->getValue(), self::$valueMap[$className])) {
throw new \Exception(sprintf("Duplicate enum value %s from enum %s", $property->getValue(), $className));
}

self::$values[$className][] = $property;
self::$valueMap[$className][$property->getValue()] = $property;
}
}

}


Конкретний Enum може виглядати так:

class Format extends Enum
{
public static $WEB;
public static $GOST;
}

Format::$WEB = new Format('web');
Format::$GOST = new Format('gost');
Format::init();

На жаль, в php можна використовувати вирази для статичних полів, тому створення об'єктів доводиться виносити за межі класу.

Інтеграція з Doctrine
Завдяки закритому конструктору, Enum не може успадковуватись успадковується від Type доктрини. Але як же зробити, щоб перерахування були Type-ми? Відповідь прийшла у процесі вивчення того, як Doctrine створює проксі-класи для сутностей. На кожну сутність Doctrine генерує проксі-клас, який успадковується від класу суті, в якому реалізує lazy loading і все інше. Ну і ми вчинимо так само — на кожен клас-Епим будемо створювати проксі-клас, який успадковується від Type і реалізує логіку, потрібну для визначення типу. Ці класи потім можна зберегти в кеш і довантажувати при необхідності.

DoctrineEnumAbstractType, в якому реалізована базова логіка Type

DoctrineEnumAbstractType.php
class DoctrineEnumAbstractType extends Type
{
/** @var Enum $enum */
protected static $enumClass = null;

public function getSqlDeclaration(array $fieldDeclaration, AbstractPlatform $platform)
{
$enum = static::$enumClass;
$values = implode(
", ",
array_map(function (Enum $enum) {
return "'" . $enum->getValue() . "'";
}, $enum::getValues()));

if ($platform instanceof MysqlPlatform) {
return sprintf('ENUM(%s)', $values);
} elseif ($platform instanceof SqlitePlatform) {
return sprintf('TEXT CHECK(%s IN (%s))', $fieldDeclaration['name'], $values);
} elseif ($platform instanceof PostgreSqlPlatform) {
return sprintf('VARCHAR(255) CHECK(%s IN (%s))', $fieldDeclaration['name'], $values);
} else {
throw new \Exception(sprintf("Sorry, platform %s currently not supported enums", $platform->getName()));
}

}

public function getName()
{
$enum = static::$enumClass;
return (new \ReflectionClass($enum))->getShortName();
}

public function convertToPHPValue($value, AbstractPlatform $platform)
{
$enum = static::$enumClass;
return $enum::getEnumObject($value);
}

public function convertToDatabaseValue($enum, AbstractPlatform $platform)
{
/** @var Enum $enum */
return $enum->getValue();
}

public function requiresSQLCommentHint(AbstractPlatform $platform)
{
return true;
}

}


DoctrineEnumProxyClassGenerator, який генерує проксі-класи для перерахувань.

DoctrineEnumProxyClassGenerator.php
class DoctrineEnumProxyClassGenerator
{
public function proxyClassName($enumClass)
{
$enumClassName = (new \ReflectionClass($enumClass))->getShortName();
return $enumClassName . 'DoctrineEnum';
}

public function proxyClassFullName($namespace, $enumClass) {
return $namespace . '\\' . $this->proxyClassName($enumClass);
}

public function generateProxyClass($enumClass, $namespace)
{
$proxyClassTemplate = <<<EOF
<?php

namespace <namespace>; 

class <proxyClassName> extends \<proxyClassBase> {
protected static \$enumClass = '\<enumClass>';
}
EOF;
$placeholders = [
'namespace' => $namespace,
'proxyClassName' => self::proxyClassName($enumClass),
'proxyClassBase' => DoctrineEnumAbstractType::class,
'enumClass' => $enumClass,
];

return $this->generateCode($proxyClassTemplate, $placeholders);
}

private function generateCode($classTemplate, array $placeholders)
{
$placeholderNames = array_map(function ($placeholderName) {
return '<' . $placeholderName . '>';
}, array_keys($placeholders));
$placeHolderValues = array_values($placeholders);

return str_replace($placeholderNames, $placeHolderValues, $classTemplate);
}
}


На кожне перерахування ProxyClassGenerator генерує проксі-клас, який потім можна використовувати в Doctrine, щоб поля сутностей були справжніми перерахуваннями.

Висновок
В результаті ми отримали Enum, який може бути використаний з різними компонентами Symfony-додатка — Doctrine, Form, Twig. Сподіваюся, що ця реалізація може кому-небудь або надихне на пошук нових рішень.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.