jaroslavtyc/doctrineum-scalar

View on GitHub
Doctrineum/Scalar/ScalarEnumType.php

Summary

Maintainability
A
0 mins
Test Coverage
<?php
declare(strict_types=1);

namespace Doctrineum\Scalar;

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrineum\SelfRegisteringType\AbstractSelfRegisteringType;
use Granam\Scalar\Tools\ToScalar;
use Granam\ScalarEnum\Exceptions\CanNotCreateInstanceOfAbstractEnum;
use Granam\ScalarEnum\ScalarEnum;
use Granam\ScalarEnum\ScalarEnumInterface;
use Granam\Tools\ValueDescriber;

/**
 * @method static ScalarEnumType getType($name),
 */
class ScalarEnumType extends AbstractSelfRegisteringType
{

    public const SCALAR_ENUM = 'scalar_enum';

    /** @var string[][] */
    private static $enumSubTypesMap = [];

    /**
     * You can register a class just once.
     *
     * @param string $subTypeEnumClass
     * @param string $subTypeEnumValueRegexp
     * @return bool
     * @throws \Doctrineum\Scalar\Exceptions\SubTypeEnumIsAlreadyRegistered
     * @throws \Doctrineum\Scalar\Exceptions\SubTypeEnumClassNotFound
     * @throws \Doctrineum\Scalar\Exceptions\SubTypeEnumHasToBeEnum
     * @throws \Doctrineum\Scalar\Exceptions\InvalidRegexpFormat
     */
    public static function registerSubTypeEnum(string $subTypeEnumClass, string $subTypeEnumValueRegexp): bool
    {
        if (!static::hasSubTypeEnum($subTypeEnumClass, $subTypeEnumValueRegexp)) {
            // registering same subtype enum class but with different regexp cause exception in following method
            return static::addSubTypeEnum($subTypeEnumClass, $subTypeEnumValueRegexp);
        }

        return false;
    }

    /**
     * @param $subTypeClassName
     * @param string|null $subTypeEnumValueRegexp
     * @return bool
     * @throws \Doctrineum\Scalar\Exceptions\InvalidRegexpFormat
     */
    public static function hasSubTypeEnum(string $subTypeClassName, string $subTypeEnumValueRegexp = null): bool
    {
        return
            (self::$enumSubTypesMap[static::getSubTypeEnumInnerNamespace()][$subTypeClassName] ?? null) !== null
            && (
                $subTypeEnumValueRegexp === null
                || (self::guardRegexpValid($subTypeEnumValueRegexp)
                    && self::$enumSubTypesMap[static::getSubTypeEnumInnerNamespace()][$subTypeClassName] === $subTypeEnumValueRegexp
                )
            );
    }

    /**
     * @return string
     */
    protected static function getSubTypeEnumInnerNamespace(): string
    {
        return static::class;
    }

    /**
     * Warning: Behave of registering more classes on same regexp (or simply matching same string) is undefined.
     *
     * @param string $subTypeEnumClass
     * @param string $subTypeEnumValueRegexp
     * @return bool
     * @throws \Doctrineum\Scalar\Exceptions\SubTypeEnumIsAlreadyRegistered
     * @throws \Doctrineum\Scalar\Exceptions\SubTypeEnumClassNotFound
     * @throws \Doctrineum\Scalar\Exceptions\SubTypeEnumHasToBeEnum
     * @throws \Doctrineum\Scalar\Exceptions\InvalidRegexpFormat
     */
    public static function addSubTypeEnum(string $subTypeEnumClass, string $subTypeEnumValueRegexp): bool
    {
        if (static::hasSubTypeEnum($subTypeEnumClass)) {
            throw new Exceptions\SubTypeEnumIsAlreadyRegistered(
                'SubType enum ' . ValueDescriber::describe($subTypeEnumClass) . ' is already registered with regexp '
                . self::$enumSubTypesMap[static::getSubTypeEnumInnerNamespace()][$subTypeEnumClass]
                . ' (requested to register with regexp ' . ValueDescriber::describe($subTypeEnumValueRegexp) . ')'
            );
        }
        /** The class has to be self-registering to by-pass enum and enum type bindings, @see ScalarEnum::createEnum */
        static::checkIfKnownEnum($subTypeEnumClass);
        static::guardRegexpValid($subTypeEnumValueRegexp);
        self::$enumSubTypesMap[static::getSubTypeEnumInnerNamespace()][$subTypeEnumClass] = $subTypeEnumValueRegexp;

        return true;
    }

    /**
     * @param string $subTypeClassName
     * @throws \Doctrineum\Scalar\Exceptions\SubTypeEnumClassNotFound
     * @throws \Doctrineum\Scalar\Exceptions\SubTypeEnumHasToBeEnum
     * @throws \Doctrineum\Scalar\Exceptions\SubTypeEnumHasToHaveFactoryMethod
     */
    protected static function checkIfKnownEnum(string $subTypeClassName): void
    {
        if (!\class_exists($subTypeClassName)) {
            throw new Exceptions\SubTypeEnumClassNotFound(
                'Sub-type class ' . ValueDescriber::describe($subTypeClassName) . ' has not been found'
            );
        }
        if (!\is_a($subTypeClassName, ScalarEnumInterface::class, true)) {
            throw new Exceptions\SubTypeEnumHasToBeEnum(
                'Sub-type class ' . ValueDescriber::describe($subTypeClassName) . ' has to be child of ' . ScalarEnumInterface::class
            );
        }
        if (!\method_exists($subTypeClassName, 'getEnum')) {
            throw new Exceptions\SubTypeEnumHasToHaveFactoryMethod(
                'Sub-type class ' . ValueDescriber::describe($subTypeClassName) . ' has to have public static method getEnum($enumValue)'
            );
        }
    }

    /**
     * @param string $regexp
     * @return bool
     * @throws \Doctrineum\Scalar\Exceptions\InvalidRegexpFormat
     */
    private static function guardRegexpValid(string $regexp): bool
    {
        if (!\preg_match('~^(.).*\1$~', $regexp)) {
            // the regexp does not start and end with same characters
            throw new Exceptions\InvalidRegexpFormat(
                'The given regexp is not enclosed by same delimiters and therefore is not valid: '
                . ValueDescriber::describe($regexp)
            );
        }

        return true;
    }

    /**
     * @param string $subTypeEnumClass
     * @return bool
     * @throws \Doctrineum\Scalar\Exceptions\SubTypeEnumIsNotRegistered
     */
    public static function removeSubTypeEnum(string $subTypeEnumClass): bool
    {
        if (!static::hasSubTypeEnum($subTypeEnumClass)) {
            throw new Exceptions\SubTypeEnumIsNotRegistered(
                'Sub-type ' . ValueDescriber::describe($subTypeEnumClass) . ' is not registered'
            );
        }
        unset(self::$enumSubTypesMap[static::getSubTypeEnumInnerNamespace()][$subTypeEnumClass]);

        return true;
    }

    /**
     * Gets the strongly recommended name of this type.
     * Its used at @see \Doctrine\DBAL\Platforms\AbstractPlatform::getDoctrineTypeComment
     * Note: also PhpStorm use it for click-through via @Column(type="foo-bar") notation,
     * if and only if is the value a constant (direct return of a string or constant).
     *
     * @return string
     */
    public function getName(): string
    {
        return self::SCALAR_ENUM;
    }

    /**
     * Gets the SQL declaration snippet for a field of this type.
     *
     * @param array $fieldDeclaration The field declaration.
     * @param \Doctrine\DBAL\Platforms\AbstractPlatform $platform The currently used database platform.
     * @return string
     */
    public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform): string
    {
        return 'VARCHAR(' . $this->getDefaultLength($platform) . ')';
    }

    /**
     * @param AbstractPlatform $platform
     * @return int
     * @deprecated Rely on information provided by the platform instead.
     */
    public function getDefaultLength(AbstractPlatform $platform): int
    {
        return 64;
    }

    /**
     * Convert enum instance to database string (or null) value
     *
     * @param ScalarEnumInterface $value
     * @param \Doctrine\DBAL\Platforms\AbstractPlatform $platform
     * @throws Exceptions\UnexpectedValueToDatabaseValue
     * @return string|int|float|bool|null
     */
    public function convertToDatabaseValue($value, AbstractPlatform $platform)
    {
        if ($value === null) {
            return null;
        }
        if (!\is_object($value) || !\is_a($value, ScalarEnumInterface::class)) {
            throw new Exceptions\UnexpectedValueToDatabaseValue(
                'Expected NULL or instance of ' . ScalarEnumInterface::class . ', got ' . ValueDescriber::describe($value)
            );
        }

        return $value->getValue();
    }

    /**
     * Convert database string value to Enum instance
     * This does NOT cast non-string scalars into string (integers, floats etc).
     * Even null remains null in returned Enum.
     * (But saving the value into database and pulling it back probably will do the to-string conversion)
     *
     * @param string|int|float|bool|null $value
     * @param \Doctrine\DBAL\Platforms\AbstractPlatform $platform
     * @return ScalarEnum|null
     * @throws \Doctrineum\Scalar\Exceptions\UnexpectedValueToEnum
     * @throws \Doctrineum\Scalar\Exceptions\CouldNotDetermineEnumClass
     * @throws \Doctrineum\Scalar\Exceptions\EnumClassNotFound
     * @throws \Granam\ScalarEnum\Exceptions\CanNotCreateInstanceOfAbstractEnum
     */
    public function convertToPHPValue($value, AbstractPlatform $platform): ?ScalarEnumInterface
    {
        return $value === null
            ? null
            : $this->convertToEnum($value);
    }

    /**
     * @param string|int|float|bool $enumValue
     * @return ScalarEnum|ScalarEnumInterface
     * @throws \Doctrineum\Scalar\Exceptions\UnexpectedValueToEnum
     * @throws \Doctrineum\Scalar\Exceptions\CouldNotDetermineEnumClass
     * @throws \Doctrineum\Scalar\Exceptions\EnumClassNotFound
     * @throws \Granam\ScalarEnum\Exceptions\CanNotCreateInstanceOfAbstractEnum
     */
    protected function convertToEnum($enumValue): ScalarEnumInterface
    {
        $enumValue = $this->sanitizeValueForEnumClass($enumValue);
        // class of main enum or its registered sub-type, according to enum type and current value
        $enumClass = static::getEnumClass($enumValue);
        $enumValue = $this->prepareValueForEnum($enumValue);

        try {
            return $enumClass::getEnum($enumValue);
        } catch (CanNotCreateInstanceOfAbstractEnum $canNotCreateInstanceOfAbstractEnum) {
            throw new CanNotCreateInstanceOfAbstractEnum(
                'Enum value ' . ValueDescriber::describe($enumValue) . ' is paired with enum class ' . $enumClass
                . ', but creating an enum by it causes: ' . $canNotCreateInstanceOfAbstractEnum->getMessage()
                . "\nRegistered sub-types are " . (self::$enumSubTypesMap ? \var_export(self::$enumSubTypesMap, true) : "'none'")
                . ' and default enum class for given value ' . ValueDescriber::describe($enumValue)
                . ' is ' . static::getDefaultEnumClass($enumValue)
            );
        }
    }

    /**
     * @param $valueForEnum
     * @return float|int|string|bool
     * @throws \Doctrineum\Scalar\Exceptions\UnexpectedValueToEnum
     */
    protected function sanitizeValueForEnumClass($valueForEnum)
    {
        try {
            return ToScalar::toScalar($valueForEnum, true /* strict, without null */);
        } catch (\Granam\Scalar\Tools\Exceptions\WrongParameterType $exception) {
            throw new Exceptions\UnexpectedValueToEnum(
                'Unexpected value to convert. Expected scalar or null, got '
                . ValueDescriber::describe($valueForEnum),
                $exception->getCode(),
                $exception
            );
        }
    }

    /**
     * @param float|int|string|bool $valueForEnum
     * @return float|int|string|bool
     */
    protected function prepareValueForEnum($valueForEnum)
    {
        return $valueForEnum; // nothing to change here - intentioned for overload
    }

    /**
     * @param int|float|string|bool $enumValue
     * @return string|ScalarEnum Enum class absolute name
     * @throws \Doctrineum\Scalar\Exceptions\CouldNotDetermineEnumClass
     * @throws \Doctrineum\Scalar\Exceptions\EnumClassNotFound
     */
    protected static function getEnumClass($enumValue): string
    {
        if (!\array_key_exists(static::getSubTypeEnumInnerNamespace(), self::$enumSubTypesMap)
            || \count(self::$enumSubTypesMap[static::getSubTypeEnumInnerNamespace()]) === 0
        ) {
            // no subtype is registered at all
            return static::getDefaultEnumClass($enumValue);
        }

        foreach (self::$enumSubTypesMap[static::getSubTypeEnumInnerNamespace()] as $subTypeEnumClass => $subTypeEnumValueRegexp) {
            if (\preg_match($subTypeEnumValueRegexp, (string)$enumValue)) {
                return $subTypeEnumClass;
            }
        }

        // no subtype matched
        return static::getDefaultEnumClass($enumValue);
    }

    /**
     * @param int|float|string|bool $enumValue
     * @return string
     * @throws \Doctrineum\Scalar\Exceptions\CouldNotDetermineEnumClass
     * @throws \Doctrineum\Scalar\Exceptions\EnumClassNotFound
     */
    protected static function getDefaultEnumClass($enumValue): string
    {
        $enumTypeClass = static::class;
        $enumInSameNamespace = \preg_replace('~Type$~', '', $enumTypeClass);
        if ($enumInSameNamespace === $enumTypeClass) {
            throw new Exceptions\CouldNotDetermineEnumClass('Enum class could not be parsed from enum type class ' . $enumTypeClass);
        }
        if (\class_exists($enumInSameNamespace)) {
            return $enumInSameNamespace;
        }

        $inParentNamespace = \preg_replace('~\\\(\w+)\\\(\w+)$~', '\\\$2', $enumInSameNamespace);
        if (\class_exists($inParentNamespace)) {
            return $inParentNamespace;
        }

        throw new Exceptions\EnumClassNotFound(
            'Default enum class not found for enum type ' . static::class
            . ' (potential sub-types have not matched enum value ' . ValueDescriber::describe($enumValue) . ')'
        );
    }

    /**
     * If this Doctrine Type maps to an already mapped database type,
     * reverse schema engineering can't take them apart. You need to mark
     * one of those types as commented, which will have Doctrine use an SQL
     * comment to type-hint the actual Doctrine Type.
     *
     * @param \Doctrine\DBAL\Platforms\AbstractPlatform $platform
     * @return boolean
     */
    public function requiresSQLCommentHint(AbstractPlatform $platform): bool
    {
        return true;
    }
}