Проблема


Как известно, в 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 been already 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 и все остальное. Ну и мы поступим так же — на каждый класс-Еnum будем создавать прокси-класс, который наследуется от 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. Надеюсь, что эта реализация может кому-нибудь или вдохновит на поиск новых решений.
Поделиться с друзьями
-->

Комментарии (10)


  1. Radik_Wind
    01.11.2016 11:31
    +2

    А чем этот вариант не устроил https://github.com/fre5h/DoctrineEnumBundle?


    1. lewbor
      01.11.2016 20:01

      Это хороший бандл, но у него есть недостатки, важные для нас. В частности, значения перечисления там — строки. И при использовании в шаблонах нужно указывать тип перечисления, например readable_enum('BasketballPositionType'). Что создает проблемы при рефакторинге — нужно не забыть изменить шаблоны. А если значения перечисления — объекты, то можно не думать об этом и избежать ошибок, особенно в перечислениях с похожими значениями.


      1. Radik_Wind
        01.11.2016 22:52

        Если честно не совсем понял, зачем вы в шаблонах указываете тип я обычно делаю, что то типа такого:

        {{ item.type|readable_enum|trans }}

        И этого вполне хватает, в случае когда мне нужен в шаблоне список значений enum я явно передаю эти данные в шаблон, что соответственно избавляет меня от такого рода проблем.


        1. lewbor
          02.11.2016 05:52

          А если есть 2 Enum-а с одинаковыми значениями, но разным переводом? Тогда в любом случае нужно указать его тип. А если где-то уже есть {{ item.type|readable_enum|trans }}, то и не забыть указать тип там.


          1. Radik_Wind
            02.11.2016 11:11

            Уважаемый, все ваши проблемы надуманные :)) К примеру типичная реализация enum:

            <?php
            
            namespace ApiBundle\DBAL\Types;
            
            use Fresh\DoctrineEnumBundle\DBAL\Types\AbstractEnumType;
            
            class EventStatusEnumType extends AbstractEnumType
            {
                const
                    DRAFT = 'draft',
                    STARTED = 'started',
                    PAUSED = 'paused',
                    COMPLETED = 'completed',
                    FINISHED = 'finished'
                ;
            
                protected static $choices = [
                    self::DRAFT => 'enum.eventStatus.draft',
                    self::STARTED => 'enum.eventStatus.started',
                    self::PAUSED => 'enum.eventStatus.paused',
                    self::COMPLETED => 'enum.eventStatus.completed',
                    self::FINISHED => 'enum.eventStatus.finished',
                ];
            }
            


            Как видите с таким подходом ни какие переводы не пересекаются. Все в одном месте, рефакторить можно без проблем.


            1. lewbor
              02.11.2016 12:09

              Это если у вас ни в каком перечислении нету таких же значений. А если есть, к примеру,

              class EventQueueStatusEnum extends AbstractEnumType
              {
                  const
                      DRAFT = 'draft',
                      STARTED = 'started',
              
               protected static $choices = [
                      self::DRAFT => 'enum.eventQueueStatus.draft',
                      self::STARTED => 'enum.eventQueueStatus.started',
              }
              

              то нужно явно указывать тип в readable_enum. Самое плохое то, что если раньше EventQueueStatusEnum не было, а потом его добавили, то все, что использовало EventStatusEnumType в виде tem.type|readable_enum|trans, сломается


  1. vvasilenok
    01.11.2016 18:25

    А с миграциями как-то удалось решить проблему? когда добавляется/удаляется поле в ENUM type? Сейчас приходится менять вручную


    1. lewbor
      01.11.2016 20:06

      Пока нет, приходится вручную менять комментарий к полю. Наверное, можно написать свой компаратор.


  1. LAV45
    02.11.2016 05:53

    В Yii2 это все реализуется в помощью валидатора и без лишних заморочек ).

    class Post extends ActiveRecord
    {
        const STATUS_DISABLE = 1;
    
        const STATUS_ACTIVE = 10;
    
        public function rules()
        {
            return [
                [['status'], 'in', 'range' => array_keys($this->getStatusList())],
            ];
        }
    
        public function getStatusList()
        {
            return [
                self::STATUS_ACTIVE => Yii::t('app', 'Activate'),
                self::STATUS_DISABLE => Yii::t('app', 'Deactivate'),
            ];
        }
    }
    


    1. lewbor
      02.11.2016 06:03

      Все зависит от задачи. В вашем случае, например, нету описания типа для разных БД. А значит, нет проверки значения на стороне БД.