├── .gitignore ├── .gitlab-ci.yml ├── LICENSE ├── README.md ├── composer.json ├── phpstan.neon ├── src ├── AutoInstances.php ├── Enum.php ├── Internal │ ├── ConsistencyChecker.php │ ├── InstanceRegister.php │ └── Meta.php └── exceptions.php └── tests ├── Basic ├── accessing-scalar-value.phpt ├── autoinstances.phpt ├── equals.phpt ├── non-strict-value-comparison.phpt ├── stateMachine.phpt └── strict-value-comparison.phpt ├── Consistency ├── finalAndAbstractCheck.phpt ├── methodAnnotations.missing.phpt └── missingInstance.phpt ├── Example ├── AddingBehaviourToEnum │ ├── readme.md │ ├── step-1.phpt │ ├── step-2.phpt │ └── step-3.phpt ├── LoyaltyProgramExample │ └── example.phpt ├── MigratingLegacyCode │ ├── readme.md │ ├── step0.phpt │ ├── step1.phpt │ ├── step2.phpt │ └── step3.phpt └── OrderState │ ├── readme.md │ ├── refactoring-1.phpt │ ├── refactoring-2.phpt │ ├── refactoring-3.phpt │ ├── refactoring-4.phpt │ └── refactoring-5.phpt ├── Reflection ├── constantNames.inherited.phpt ├── constantNames.phpt └── getAvailableValues.phpt ├── Regression ├── access-to-non-existing-value-using-static-method-should-throw-an-exception.phpt ├── forgotten-constructor-call.phpt ├── fromScalar-non-existing-values-should-throw-exception.phpt ├── full-classes-as-values.phpt ├── loose-comparison-across-types.phpt └── mixed-key-type-test.phpt ├── bootstrap.php └── php-windows.ini /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | composer.lock 3 | .idea 4 | **/output/* -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: grifart/php8.1-with-all-modules-and-various-tools 2 | 3 | # STAGES 4 | 5 | stages: 6 | - build 7 | - test 8 | 9 | 10 | 11 | # BUILDS 12 | 13 | # composer 14 | 15 | .build-composer-template: &build-composer-template 16 | stage: build 17 | 18 | artifacts: 19 | expire_in: 2 hours 20 | name: "${CI_BUILD_REF_NAME}_${CI_BUILD_NAME}" 21 | paths: 22 | - vendor 23 | 24 | build.composer.dev: 25 | <<: *build-composer-template 26 | 27 | script: 28 | - composer install --no-interaction --ansi 29 | 30 | build.composer: 31 | <<: *build-composer-template 32 | 33 | script: 34 | - composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --ansi 35 | 36 | 37 | 38 | 39 | # TESTS 40 | 41 | 42 | # php lint 43 | 44 | test.php-syntax-check: 45 | stage: test 46 | 47 | script: 48 | - composer require php-parallel-lint/php-parallel-lint 49 | - vendor/bin/parallel-lint src 50 | 51 | 52 | # php stan 53 | 54 | test.phpstan: 55 | stage: test 56 | 57 | dependencies: 58 | - build.composer.dev 59 | 60 | script: 61 | - composer run phpstan 62 | 63 | 64 | # tests 65 | 66 | .test.tests: &test-tests 67 | stage: test 68 | 69 | dependencies: [] 70 | needs: [] 71 | 72 | before_script: 73 | - composer install --no-interaction --ansi 74 | 75 | script: 76 | - composer run test 77 | 78 | artifacts: 79 | expire_in: 15 minutes 80 | paths: 81 | - log 82 | - src # can contain assertion diffs 83 | when: on_failure 84 | 85 | 86 | test.tests.php72: 87 | <<: *test-tests 88 | image: grifart/php7.2-with-gulp-and-all-php-modules 89 | 90 | test.tests.php72.oldDeps: 91 | <<: *test-tests 92 | before_script: 93 | - composer update --prefer-lowest --no-interaction --ansi 94 | image: grifart/php7.2-with-gulp-and-all-php-modules 95 | 96 | 97 | 98 | test.tests.php73: 99 | <<: *test-tests 100 | image: grifart/php7.3-with-gulp-and-all-php-modules 101 | 102 | test.tests.php73.oldDeps: 103 | <<: *test-tests 104 | before_script: 105 | - composer update --prefer-lowest --no-interaction --ansi 106 | image: grifart/php7.3-with-gulp-and-all-php-modules 107 | 108 | 109 | 110 | test.tests.php74: 111 | <<: *test-tests 112 | image: grifart/php7.4-with-gulp-and-all-php-modules 113 | 114 | test.tests.php74.oldDeps: 115 | <<: *test-tests 116 | before_script: 117 | - composer update --prefer-lowest --no-interaction --ansi 118 | image: grifart/php7.4-with-gulp-and-all-php-modules 119 | 120 | 121 | 122 | test.tests.php80: 123 | <<: *test-tests 124 | image: grifart/php8.0-with-all-modules-and-various-tools 125 | 126 | test.tests.php80.oldDeps: 127 | <<: *test-tests 128 | before_script: 129 | - composer update --prefer-lowest --no-interaction --ansi 130 | image: grifart/php8.0-with-all-modules-and-various-tools 131 | 132 | 133 | 134 | test.tests.php81: 135 | <<: *test-tests 136 | image: grifart/php8.1-with-all-modules-and-various-tools 137 | 138 | test.tests.php81.oldDeps: 139 | <<: *test-tests 140 | before_script: 141 | - composer update --prefer-lowest --no-interaction --ansi 142 | image: grifart/php8.1-with-all-modules-and-various-tools -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 GRIFART spol. s r.o. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # grifart/enum 2 | 3 | Enumeration value object. Enumerate values and behaviour with type-safety. 4 | 5 | Repositories [gitlab.grifart.cz](https://gitlab.grifart.cz/jkuchar1/grifart-enum) 6 | and [github.com](https://github.com/grifart/enum). 7 | [![pipeline status](https://gitlab.grifart.cz/jkuchar1/grifart-enum/badges/master/pipeline.svg)](https://gitlab.grifart.cz/jkuchar1/grifart-enum/commits/master) 8 | 9 | Sponsored by [grifart.com](https://grifart.com). 10 | 11 | Do you like video 📺 instead of reading? Here is one in [english](http://bit.ly/better-enum-for-php) and one in [czech](http://bit.ly/lepsi-enum-pro-php). 12 | 13 | ## Introduction 14 | 15 | Enums represent predefined set of values. The available values are defined statically by each enum class. Each value is represented by an instance of this class in a flyweight manner. 16 | 17 | - This enum allows you to add individual behaviour for every enum value (as in Java). This allows you to transform your `switch`es/`if`s into more readable composition. (see example bellow) 18 | - Checks enum annotations if phpdoc-declared methods are properly declared (will generate docblock for you when not specified or incorrect) 19 | - `===`, `==` and usage of `switch`es is supported 20 | - string or integer scalar keys are supported 21 | - Easily access scalar value of enum `DayOfWeek::MONDAY()->toScalar()` or `(string) DayOfWeek::MONDAY()` 22 | 23 | Also includes: 24 | 25 | - It is type safe. By annotating your enumeration type, you are guaranteed that there will be no other values then you declared. `function translateTo(DayOfWeek $day) { ...` 26 | - You can get a list of all the possible values `Enum::getAvailableValues()` 27 | 28 | ## Installation 29 | 30 | ```bash 31 | composer require grifart/enum 32 | ``` 33 | 34 | This library uses [**semantic versioning 2.0**](https://semver.org/spec/v2.0.0.html). 35 | You can safely use `^` constrain in you `composer.json`. 36 | 37 | ## Requirements 38 | 39 | This library requires PHP 7.1 and later. 40 | 41 | ## Project status & release process 42 | 43 | While this library is still under development, it is well tested and should be stable enough to use in production environments. 44 | 45 | The current releases are numbered 0.x.y. When a non-breaking change is introduced (adding new methods, optimizing existing code, etc.), y is incremented. 46 | 47 | When a breaking change is introduced, a new 0.x version cycle is always started. 48 | 49 | It is therefore safe to lock your project to a given release cycle, such as 0.1.*. 50 | 51 | If you need to upgrade to a newer release cycle, check the release history for a list of changes introduced by each further 0.x.0 version. 52 | 53 | ## Overview 54 | 55 | ### Static methods 56 | 57 | - fromScalar() - returns enum instance (value) for given scalar 58 | - getAvailableValues() - returns all values for given type 59 | - provideInstances() - implement to return enum instances or automatically implemented by `Grifart\Enum\AutoInstances` trait. 60 | 61 | ### Instance methods 62 | 63 | - toScalar() - return scalar value identifier 64 | - equals() - returns true if the same enum value is passed 65 | - scalarEquals() - returns true if passed scalar value is equal to current value 66 | 67 | ### Simplest enumeration 68 | 69 | ```php 70 | /** 71 | * @method static DayOfWeek MONDAY() 72 | * @method static DayOfWeek TUESDAY() 73 | */ 74 | final class DayOfWeek extends \Grifart\Enum\Enum 75 | { 76 | use Grifart\Enum\AutoInstances; 77 | private const MONDAY = 'monday'; 78 | private const TUESDAY = 'tuesday'; 79 | } 80 | 81 | $monday = DayOfWeek::MONDAY(); 82 | function process(DayOfWeek $day): void { /* ... */ } 83 | ```` 84 | 85 | ### Values with behaviour 86 | 87 | This way conditions can be replaced by composition. 88 | 89 | This example originally comes from loyalty program domain, [continue to full code sample with context](tests/Example/LoyaltyProgramExample/example.phpt). 90 | 91 | ```php 92 | /** 93 | * @method static ExpirationType ASSIGNMENT() 94 | * @method static ExpirationType FIXED() 95 | */ 96 | abstract class ExpirationType extends \Grifart\Enum\Enum 97 | { 98 | protected const ASSIGNMENT = 'assignment'; 99 | protected const FIXED = 'fixed'; 100 | 101 | abstract public function computeExpiration(Offer $offer): \DateTimeImmutable; 102 | 103 | protected static function provideInstances() : array { 104 | return [ 105 | new class(self::ASSIGNMENT) extends ExpirationType { 106 | public function computeExpiration(Offer $offer): \DateTimeImmutable { 107 | return /* behaviour A */; 108 | } 109 | }, 110 | new class(self::FIXED) extends ExpirationType { 111 | public function computeExpiration(Offer $offer): \DateTimeImmutable { 112 | return /* behaviour B */; 113 | } 114 | }, 115 | ]; 116 | } 117 | } 118 | ```` 119 | 120 | ### Migrating from class constants [[source code](tests/Example/MigratingLegacyCode/readme.md)] 121 | 122 | This guide show how to migrate from classes with constants to `\Grifart\Enum` in few simple steps. [Continue to example](tests/Example/MigratingLegacyCode/readme.md) 123 | 124 | ### Adding behaviour to values [[source code](tests/Example/AddingBehaviourToEnum/readme.md)] 125 | 126 | This guide show how to slowly add behaviour to enum values. Step by step. [Continue to example](tests/Example/AddingBehaviourToEnum/readme.md) 127 | 128 | ### Complex showcase: order lifecycle tracking [[source code](tests/Example/OrderState/readme.md)] 129 | 130 | This example contains 5 ways of implementing order state. [Continue to example](tests/Example/OrderState/readme.md). 131 | 132 | ## Big thanks 133 | 134 | - [David Grudl](https://github.com/dg) for making [Nette Tester](https://github.com/nette/tester) 135 | - [Ondřej Mirtes](https://github.com/ondrejmirtes) for making [PHP Stan](https://github.com/phpstan/phpstan). 136 | 137 | ## More reading 138 | 139 | - [consistence/consistence enum](https://github.com/consistence/consistence/blob/master/docs/Enum/enums.md) 140 | - [myclabs/php-enum](https://github.com/myclabs/php-enum) 141 | - [Java enum](https://docs.oracle.com/javase/tutorial/java/javaOO/enum.html) (see planet example) 142 | - [great talk about "crafting wicked domain models" from Jimmy Bogard](https://vimeo.com/43598193) 143 | - [more my notes on DDD topic](https://gitlab.grifart.cz/jkuchar1/eventsourcing-cqrs-simple-app/blob/master/README.md) 144 | 145 | 146 | 147 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grifart/enum", 3 | "description": "Provides bullet proof enums with behaviours.", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Jan Kuchař", 9 | "email": "honza.kuchar@grifart.cz" 10 | } 11 | ], 12 | 13 | "scripts": { 14 | "verify": [ 15 | "@phpstan", 16 | "@test" 17 | ], 18 | "phpstan": "vendor/bin/phpstan analyze -c phpstan.neon --error-format compact --no-interaction --ansi --no-progress -- src", 19 | "test": "vendor/bin/tester tests --colors 1" 20 | }, 21 | 22 | "require": { 23 | "php": ">=7.2.0" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "Grifart\\Enum\\": "src" 28 | }, 29 | "classmap": [ 30 | "src/exceptions.php" 31 | ] 32 | }, 33 | 34 | "require-dev": { 35 | "nette/tester": "~2.4.2", 36 | "phpstan/phpstan": "~1.6.9", 37 | "phpstan/phpstan-strict-rules": "~1.2.3", 38 | "grifart/phpstan-oneline": "~v0.4.2" 39 | }, 40 | "autoload-dev": { 41 | "files": [ 42 | "src/exceptions.php" 43 | ] 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/phpstan/phpstan-strict-rules/rules.neon 3 | - vendor/grifart/phpstan-oneline/config.neon 4 | 5 | parameters: 6 | level: 9 7 | ignoreErrors: 8 | -------------------------------------------------------------------------------- /src/AutoInstances.php: -------------------------------------------------------------------------------- 1 | getValues(); 35 | } 36 | 37 | /** 38 | * @return array 39 | */ 40 | protected static function getConstantToScalar(): array 41 | { 42 | try { 43 | /** @var array $constants */ 44 | $constants = (new \ReflectionClass(static::class))->getConstants(); 45 | return $constants; 46 | } catch (\ReflectionException $e) { 47 | throw new ReflectionFailedException($e); 48 | } 49 | } 50 | 51 | /** 52 | * Builds enumeration from its scalar value. 53 | * @param TScalarValue $scalar 54 | * @return static 55 | * @throws MissingValueDeclarationException if there is no value for given scalar 56 | */ 57 | public static function fromScalar($scalar): Enum 58 | { 59 | return self::getMeta()->getValueForScalar($scalar); 60 | } 61 | 62 | /** 63 | * Provides access to values using ::CONSTANT_NAME() interface. 64 | * @param array{} $arguments And empty array, arguments not used. 65 | * @return static 66 | * @throws MissingValueDeclarationException 67 | */ 68 | public static function __callStatic(string $constantName, array $arguments): Enum 69 | { 70 | $value = self::getMeta(FALSE)->getValueForConstantName($constantName); 71 | if($value === NULL) { 72 | throw new \Error('Call to undefined method ' . static::class . '::' . $constantName . '(). Please check that you have provided constant, annotation and value.'); 73 | } 74 | return $value; 75 | } 76 | 77 | /** 78 | * @return Meta 79 | */ 80 | private static function getMeta(bool $checkIfAccessingRootDirectly = true): Meta 81 | { 82 | $rootClass = self::getRootClass(); 83 | if ($checkIfAccessingRootDirectly && $rootClass !== static::class) { 84 | throw new UsageException( 85 | 'You have accessed static enum method on non-root class ' 86 | . "('$rootClass' is a root class)" 87 | ); 88 | } 89 | 90 | return InstanceRegister::get( 91 | $rootClass, 92 | function () use ($rootClass): Meta { 93 | /** @var Meta $meta */ 94 | $meta = Meta::from( 95 | $rootClass, 96 | static::getConstantToScalar(), 97 | static::provideInstances() 98 | ); 99 | return $meta; 100 | } 101 | ); 102 | } 103 | 104 | /** 105 | * @return class-string 106 | */ 107 | private static function getRootClass(): string 108 | { 109 | try { 110 | $rootClassName = (new \ReflectionClass(static::class)) 111 | ->getMethod('provideInstances') 112 | ->getDeclaringClass() 113 | ->getName(); 114 | /** @var class-string $rootClassName */ 115 | return $rootClassName; 116 | 117 | } catch (\ReflectionException $e) { 118 | throw new ReflectionFailedException($e); 119 | } 120 | } 121 | 122 | 123 | 124 | // -------- INSTANCE IMPLEMENTATION --------- 125 | 126 | /** @var ?TScalarValue */ 127 | private $scalarValue; 128 | 129 | /** 130 | * @param TScalarValue $scalarValue 131 | */ 132 | protected function __construct($scalarValue) 133 | { 134 | $this->scalarValue = $scalarValue; 135 | } 136 | 137 | /** 138 | * Returns scalar representation of enum value. 139 | * @return TScalarValue 140 | */ 141 | public function toScalar() 142 | { 143 | if ($this->scalarValue === NULL) { 144 | $rootClassName = self::getRootClass(); 145 | throw new UsageException( 146 | "Parent constructor has not been called while constructing one of {$rootClassName} enum values." 147 | ); 148 | } 149 | 150 | return $this->scalarValue; 151 | } 152 | 153 | public function __toString(): string 154 | { 155 | // as enum does not allow mixed key types (all must be int or all string), 156 | // we can safely convert integers to strings without worrying introducing 157 | // value conflicts 158 | return (string) $this->toScalar(); 159 | } 160 | 161 | /** 162 | * Retrieves constant name that is used to access enum value. 163 | * 164 | * @internal Do not depend on this values, as it can change anytime. This value can be 165 | * subject of refactorings of user-defined enums. 166 | */ 167 | public function getConstantName(): string 168 | { 169 | return $this::getMeta(FALSE)->getConstantNameForScalar( 170 | $this->toScalar() 171 | ); 172 | } 173 | 174 | /** 175 | * @param Enum $that the other object we are comparing to 176 | * @return bool if current value equals to the other value 177 | * If value is non-enum value, returns false (as they are also not equal). 178 | */ 179 | public function equals(Enum $that): bool 180 | { 181 | return $this === $that; 182 | } 183 | 184 | /** 185 | * @param TScalarValue $theOtherScalarValue 186 | * @return bool true if current scalar representation of value equals to given scalar value 187 | */ 188 | public function scalarEquals($theOtherScalarValue): bool 189 | { 190 | return $this->toScalar() === $theOtherScalarValue; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/Internal/ConsistencyChecker.php: -------------------------------------------------------------------------------- 1 | $enumMeta 15 | */ 16 | public static function checkAnnotations(Meta $enumMeta): void 17 | { 18 | self::checkCallStaticAnnotations($enumMeta); 19 | self::checkAllInstancesProvided($enumMeta); 20 | self::checkAbstractAndFinal($enumMeta); 21 | } 22 | 23 | /** 24 | * @param Meta $enumMeta 25 | */ 26 | private static function checkCallStaticAnnotations(Meta $enumMeta): void 27 | { 28 | $enumReflection = $enumMeta->getClassReflection(); 29 | 30 | $docBlock = $enumReflection->getDocComment(); 31 | $className = $enumReflection->getShortName(); 32 | if ($docBlock === false) { 33 | $docBlock = ''; 34 | } 35 | 36 | $missingAnnotations = []; 37 | foreach ($enumMeta->getConstantNames() as $constantName) { 38 | $desiredAnnotation = "@method static $className $constantName()"; 39 | if (stripos($docBlock, $desiredAnnotation) === false) { 40 | $missingAnnotations[] = $desiredAnnotation; 41 | } 42 | } 43 | 44 | if (\count($missingAnnotations) !== 0) { 45 | $properDoc = "/**\n * " . implode("\n * ", $missingAnnotations) . "\n */\n"; 46 | throw new UsageException("You have forgotten to add @method annotations for enum '{$enumReflection->getName()}'. Documentation block should contain: \n$properDoc"); 47 | } 48 | // todo: @method annotations without constants 49 | } 50 | 51 | /** 52 | * @param Meta $enumMeta 53 | */ 54 | private static function checkAllInstancesProvided(Meta $enumMeta): void 55 | { 56 | // todo: instances without constants 57 | 58 | foreach ($enumMeta->getScalarValues() as $scalarValue) { 59 | if (!$enumMeta->hasValueForScalar($scalarValue)) { 60 | $constantName = $enumMeta->getConstantNameForScalar($scalarValue); 61 | throw new UsageException("You have forgotten to provide instance for $constantName."); 62 | } 63 | } 64 | } 65 | 66 | /** 67 | * @param Meta $enumMeta 68 | */ 69 | private static function checkAbstractAndFinal(Meta $enumMeta): void 70 | { 71 | $enumReflection = $enumMeta->getClassReflection(); 72 | 73 | if (!$enumReflection->isFinal() && !$enumReflection->isAbstract()) { 74 | throw new UsageException( 75 | "Enum root class must be either abstract or final.\n" 76 | . "Final is used when one type is enough for all enum instance values.\n" 77 | . 'Abstract is used when enum values are always instances of child classes of enum root class.' 78 | ); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Internal/InstanceRegister.php: -------------------------------------------------------------------------------- 1 | [] */ 13 | private static $instances = []; 14 | 15 | /** 16 | * @template TEnum of Enum 17 | * @param class-string $enumClass 18 | * @param callable():Meta $registrar 19 | * @return Meta 20 | */ 21 | public static function get(string $enumClass, callable $registrar): Meta 22 | { 23 | if (!isset(self::$instances[$enumClass])) { 24 | self::register($enumClass, $registrar()); 25 | } 26 | /** @var Meta $meta */ 27 | $meta = self::$instances[$enumClass]; 28 | return $meta; 29 | } 30 | 31 | /** 32 | * @template TEnum of \Grifart\Enum\Enum 33 | * @param class-string $className 34 | * @param Meta $meta 35 | * @return void 36 | */ 37 | public static function register(string $className, Meta $meta): void 38 | { 39 | \assert($meta->getClass() === $className, 'Provided Meta object is for different enum class that was originally registered.'); 40 | 41 | // check consistency of enum when assertions are enabled (typically non-production code) 42 | // @phpstan-ignore-next-line as "Call to function assert() with true will always evaluate to true." is intentional 43 | assert( 44 | (function () use ($meta): bool { 45 | ConsistencyChecker::checkAnnotations($meta); 46 | return true; 47 | })() 48 | ); 49 | self::$instances[$className] = $meta; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Internal/Meta.php: -------------------------------------------------------------------------------- 1 | */ 18 | private $class; 19 | 20 | /** @var array */ 21 | private $constantToScalar; 22 | 23 | /** @var array */ 24 | private $scalarToValue; 25 | 26 | /** 27 | * @param class-string $class 28 | * @param array $constantToScalar 29 | * @param TEnum[] $values 30 | */ 31 | private function __construct(string $class, array $constantToScalar, array $values) 32 | { 33 | $this->class = $class; 34 | $this->constantToScalar = $constantToScalar; 35 | $this->scalarToValue = $this->buildScalarToValueMapping($values); // requires constantToScalar to be already set! 36 | } 37 | 38 | /** 39 | * @param Enum[] $values 40 | * @phpstan-param TEnum[] $values 41 | * @return array 42 | */ 43 | private function buildScalarToValueMapping(array $values): array { 44 | $scalarToValues = []; 45 | 46 | // check type of all scalar values 47 | $keyType = null; 48 | foreach($values as $value) { 49 | $scalar = $value->toScalar(); 50 | if ($keyType === NULL) { 51 | $keyType = \gettype($scalar); 52 | } 53 | if ($keyType !== \gettype($scalar)) { 54 | throw new UsageException('Mixed types of scalar value. Keys must either all string or all int.'); 55 | } 56 | } 57 | 58 | foreach($values as $value) { 59 | $scalar = $value->toScalar(); 60 | 61 | 62 | 63 | if (isset($scalarToValues[$scalar])) { 64 | throw new UsageException('You have provided duplicated scalar values.'); 65 | } 66 | if(!$this->hasConstantForScalar($scalar)) { 67 | throw new UsageException("Provided instance contains scalar value '$scalar'. But no corresponding constant was found."); 68 | } 69 | $scalarToValues[$scalar] = $value; 70 | 71 | } 72 | return $scalarToValues; 73 | } 74 | 75 | /** 76 | * @param class-string $class 77 | * @param array $constantToScalar 78 | * @param TEnum[] $values 79 | * @return self 80 | */ 81 | public static function from(string $class, array $constantToScalar, array $values): self 82 | { 83 | return new self($class, $constantToScalar, $values); 84 | } 85 | 86 | /** 87 | * @return class-string 88 | */ 89 | public function getClass(): string 90 | { 91 | return $this->class; 92 | } 93 | 94 | /** 95 | * @return \ReflectionClass 96 | */ 97 | public function getClassReflection(): \ReflectionClass 98 | { 99 | try { 100 | return new \ReflectionClass($this->getClass()); 101 | } catch (\ReflectionException $e) { 102 | throw new ReflectionFailedException($e); 103 | } 104 | } 105 | 106 | /** 107 | * @return TConstantName[] 108 | */ 109 | public function getConstantNames(): array 110 | { 111 | return \array_keys($this->constantToScalar); 112 | } 113 | 114 | /** 115 | * @return array 116 | */ 117 | public function getScalarValues(): array 118 | { 119 | return \array_values($this->constantToScalar); 120 | } 121 | 122 | /** 123 | * @return TEnum[] 124 | */ 125 | public function getValues(): array 126 | { 127 | return \array_values($this->scalarToValue); 128 | } 129 | 130 | /** 131 | * @param TConstantName $constantName 132 | * @return ?TEnum 133 | * @throws MissingValueDeclarationException 134 | */ 135 | public function getValueForConstantName($constantName): ?Enum 136 | { 137 | if(!isset($this->constantToScalar[$constantName])) { 138 | return NULL; 139 | } 140 | $scalar = $this->constantToScalar[$constantName]; 141 | return $this->getValueForScalar($scalar); 142 | } 143 | 144 | /** 145 | * @param TScalarValue $scalarValue 146 | */ 147 | public function hasValueForScalar($scalarValue): bool 148 | { 149 | return isset($this->scalarToValue[$scalarValue]); 150 | } 151 | 152 | /** 153 | * @param TScalarValue $scalarValue 154 | */ 155 | public function getConstantNameForScalar($scalarValue): string 156 | { 157 | $result = \array_search($scalarValue, $this->constantToScalar, true); 158 | if ($result === false) { 159 | throw new UsageException("Could not find constant name for $scalarValue."); 160 | } 161 | return $result; 162 | } 163 | 164 | /** 165 | * @return TScalarValue 166 | */ 167 | public function getScalarForValue(Enum $enum) 168 | { 169 | $result = \array_search($enum, $this->scalarToValue, true); 170 | if ($result === false) { 171 | throw new UsageException("Could not find scalar for given instance."); 172 | } 173 | return $result; 174 | } 175 | 176 | /** 177 | * @param TScalarValue $scalar 178 | * @return TEnum 179 | * @throws MissingValueDeclarationException if there is no value for given scalar 180 | */ 181 | public function getValueForScalar($scalar): Enum 182 | { 183 | if (!isset($this->scalarToValue[$scalar])) { 184 | throw new MissingValueDeclarationException("There is no value for enum '{$this->class}' and scalar value '$scalar'."); 185 | } 186 | return $this->scalarToValue[$scalar]; 187 | } 188 | 189 | /** 190 | * @param TScalarValue $scalar 191 | */ 192 | private function hasConstantForScalar($scalar): bool 193 | { 194 | return \in_array($scalar, $this->constantToScalar, TRUE); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/exceptions.php: -------------------------------------------------------------------------------- 1 | toScalar()); 19 | \Tester\Assert::same('value1', (string) EnumString::VALUE1()); 20 | 21 | \Tester\Assert::same('value2', EnumString::VALUE2()->toScalar()); 22 | \Tester\Assert::same('value2', (string) EnumString::VALUE2()); 23 | 24 | 25 | /** 26 | * @method static EnumInt VALUE1() 27 | * @method static EnumInt VALUE2() 28 | */ 29 | final class EnumInt extends \Grifart\Enum\Enum 30 | { 31 | use Grifart\Enum\AutoInstances; 32 | 33 | protected const VALUE1 = 1; 34 | protected const VALUE2 = 2; 35 | } 36 | 37 | \Tester\Assert::same(1, EnumInt::VALUE1()->toScalar()); 38 | \Tester\Assert::same('1', (string) EnumInt::VALUE1()); 39 | 40 | \Tester\Assert::same(2, EnumInt::VALUE2()->toScalar()); 41 | \Tester\Assert::same('2', (string) EnumInt::VALUE2()); 42 | -------------------------------------------------------------------------------- /tests/Basic/autoinstances.phpt: -------------------------------------------------------------------------------- 1 | toScalar()); 27 | \Tester\Assert::same(OrderState::ACTIVE(), OrderState::fromScalar('active')); 28 | -------------------------------------------------------------------------------- /tests/Basic/equals.phpt: -------------------------------------------------------------------------------- 1 | equals(EqualsState::NEW())); 18 | \Tester\Assert::false(EqualsState::NEW()->equals(EqualsState::ACTIVE())); 19 | 20 | \Tester\Assert::true(EqualsState::NEW()->scalarEquals('new')); 21 | \Tester\Assert::false(EqualsState::NEW()->scalarEquals('active')); 22 | -------------------------------------------------------------------------------- /tests/Basic/non-strict-value-comparison.phpt: -------------------------------------------------------------------------------- 1 | canDoTransitionTo(StateMachine::STATE_B())); 44 | \Tester\Assert::true(StateMachine::STATE_B()->canDoTransitionTo(StateMachine::STATE_A())); 45 | \Tester\Assert::false(StateMachine::STATE_A()->canDoTransitionTo(StateMachine::STATE_A())); 46 | \Tester\Assert::false(StateMachine::STATE_B()->canDoTransitionTo(StateMachine::STATE_B())); 47 | 48 | -------------------------------------------------------------------------------- /tests/Basic/strict-value-comparison.phpt: -------------------------------------------------------------------------------- 1 | nextDay(); 80 | ``` 81 | 82 | Public API now looks much better! Asking monday, what is the next day and it knows answer. ✅ 83 | 84 | However I'm still worried about code of `nextDay()` function. 85 | 86 | - ❌ There are still ugly `if`s which are hard to read. 87 | - ❌ I have to worry about case when **someone adds new value** to this enum and forgets to update `nextDay()` method. 88 | 89 | Both of these can be solved by **using composition** instead of just value and switches. 90 | 91 | What if every value of enum would be separate class? Then we can write behaviour for each day individually making code very simple. And if someone adds new value, type-system will force him to add all required behaviour. And `grifart/enum` makes this easy, you do not have declare separate class for every value, just use anonymous classes. 92 | 93 | ```php 94 | /** 95 | * @method static DayOfWeek MONDAY() 96 | * @method static DayOfWeek TUESDAY() 97 | */ 98 | abstract class DayOfWeek extends \Grifart\Enum\Enum 99 | { 100 | 101 | protected const MONDAY = 'monday'; 102 | protected const TUESDAY = 'tuesday'; 103 | // ... 104 | 105 | abstract public function nextDay(): self; 106 | 107 | 108 | /** @return static[] */ 109 | protected static function provideInstances(): array 110 | { 111 | return [ 112 | new class(self::MONDAY) extends DayOfWeek 113 | { 114 | public function nextDay(): DayOfWeek 115 | { 116 | return DayOfWeek::TUESDAY(); 117 | } 118 | }, 119 | 120 | new class(self::TUESDAY) extends DayOfWeek 121 | { 122 | public function nextDay(): DayOfWeek 123 | { 124 | return DayOfWeek::WEDNESDAY(); 125 | } 126 | }, 127 | ]; 128 | } 129 | } 130 | ``` 131 | 132 | Now type-system knows that every enum value must have method `nextDay()` with return type of self ✅. Please note that this is completely internal thing - **public API haven't changed**! And we got rid of all `if`s and `switch`es ✅. 133 | 134 | This approach is very useful when one wants to implement anything state-machine related (see tests for more examples, they are simple and easy to read). 135 | -------------------------------------------------------------------------------- /tests/Example/AddingBehaviourToEnum/step-1.phpt: -------------------------------------------------------------------------------- 1 | nextDay() 37 | ); -------------------------------------------------------------------------------- /tests/Example/AddingBehaviourToEnum/step-3.phpt: -------------------------------------------------------------------------------- 1 | nextDay() 45 | ); -------------------------------------------------------------------------------- /tests/Example/LoyaltyProgramExample/example.phpt: -------------------------------------------------------------------------------- 1 | assignedAt() 61 | ->modify('+' . $offer->type()->daysValid() . ' days'); 62 | } 63 | }, 64 | new class(self::FIXED) extends ExpirationType { 65 | public function computeExpiration(Offer $offer): \DateTimeImmutable { 66 | $beginDate = $offer->type()->beginDate(); 67 | \assert($beginDate !== NULL); 68 | return $beginDate->modify('+' . $offer->type()->daysValid() . ' days'); 69 | } 70 | }, 71 | ]; 72 | } 73 | } 74 | 75 | 76 | // just checking if it compiles 77 | Assert::type(ExpirationType::class, ExpirationType::ASSIGNMENT()); -------------------------------------------------------------------------------- /tests/Example/MigratingLegacyCode/readme.md: -------------------------------------------------------------------------------- 1 | # Migrating legacy code to `\Grifart\Enum` 2 | 3 | This is step-by-step guide how to migrate you legacy code to `\Grifart\Enum`. 4 | 5 | We will start with non-type safe enum represented by class with constants. [[full source code](step0.phpt)] 6 | 7 | ```php 8 | class OrderState { 9 | public const NEW = 'new'; 10 | public const PROCESSING = 'processing'; 11 | } 12 | ``` 13 | 14 | Our business logic is this: 15 | 16 | ```php 17 | $result = ''; 18 | switch ($state) { 19 | // your business logic 20 | case OrderState::NEW: 21 | $result = 'new'; 22 | break; 23 | case OrderState::PROCESSING: 24 | $result = 'processing'; 25 | break; 26 | } 27 | ``` 28 | 29 | ## Step 1: add new type-safe API [[source](step1.phpt)] 30 | 31 | This is done by 32 | 33 | - extending `\Grifart\Enum\Enum` class 34 | - by automatically implementing enum values by including `use \Grifart\Enum\AutoInstances;` trait 35 | - and by adding magic methods annotations 36 | 37 | There is not backward incompatible change introduced. And now you can use new APIs! 38 | 39 | 40 | ```php 41 | /** 42 | * @method static OrderState NEW() 43 | * @method static OrderState PROCESSING() 44 | */ 45 | class OrderState extends \Grifart\Enum\Enum { 46 | use \Grifart\Enum\AutoInstances; 47 | public const NEW = 'new'; 48 | public const PROCESSING = 'processing'; 49 | } 50 | ``` 51 | 52 | ## Step 2: Migrating existing code to new API [[source](step2.phpt)] 53 | 54 | Migrating old code to new API is usually easy, just add parenthesis `()` when you access value. 55 | 56 | ```php 57 | $state = OrderState::NEW(); 58 | 59 | $result = ''; 60 | switch ($state) { 61 | // your business logic 62 | case OrderState::NEW(): 63 | $result = 'new'; 64 | break; 65 | case OrderState::PROCESSING(): 66 | $result = 'processing'; 67 | break; 68 | } 69 | 70 | Assert::same('new', $result); 71 | ``` 72 | 73 | Please note, that you will need to handle some cases manually as `OrderState::NEW()` returns object, enum instance, not a string. 74 | 75 | #### Removing old API 76 | 77 | So when you are finally ready to remove old API, just change constant visibility to `private`. 78 | 79 | ```php 80 | /** 81 | * @method static OrderState NEW() 82 | * @method static OrderState PROCESSING() 83 | */ 84 | class OrderState extends \Grifart\Enum\Enum { 85 | use \Grifart\Enum\AutoInstances; 86 | private const NEW = 'new'; 87 | private const PROCESSING = 'processing'; 88 | } 89 | ``` 90 | 91 | ## Step 3: Enjoy new features [[source](step3.phpt)] 92 | 93 | Now, when you decided that you what to move your business logic inside enum declaration. You are now free to do so. And there are many more options, see other examples. 94 | 95 | 96 | -------------------------------------------------------------------------------- /tests/Example/MigratingLegacyCode/step0.phpt: -------------------------------------------------------------------------------- 1 | doBusinessLogic(); 34 | Assert::same('new', $result); 35 | -------------------------------------------------------------------------------- /tests/Example/OrderState/readme.md: -------------------------------------------------------------------------------- 1 | # Order state example 2 | 3 | In order state example I would like to demonstrate that there are more then one solution of domain problem of order state which can transition into another states. 4 | 5 | ## 1. Class constants 6 | 7 | [source code](refactoring-1.phpt) 8 | 9 | There are public constants on class and you should figure out that you should put them into `canDoTransition()` method. There is nothing on type-level that helps you with that. Please note that all logic is in `OrderService`. 10 | 11 | ## 2. Dumb type-safe enum 12 | 13 | [source code](refactoring-2.phpt) 14 | 15 | This test shows usage of explicitly-declared dumb-enum. 16 | 17 | I have explicitly declared type for `OrderState`. It is not possible anymore to pass non-sense values into `OrderService`. That is because `OrderState` enum provides no interface for creating non-sense values. So they simply cannot exists. 18 | 19 | All logic has been kept in `OrderService`. We still need to handle cas when someone added new value to enum, which we do not count with. (the exception in default case). 20 | 21 | ## 3. Logic moved into enum 22 | 23 | [source code](refactoring-3.phpt) 24 | 25 | Here I have moved `OrderService::canDoTransition()` method into enum itself. 26 | 27 | Nice thing is that we do not need anymore external service for asking `OrderState`-related questions. 28 | 29 | Remaining problem is that there are still lot of ifs and we still need to handle case where someone adds new value into enum which we do not count with. 30 | 31 | ## 4. Separate instance for each value 32 | 33 | [source code](refactoring-4.phpt) 34 | 35 | When there is behaviour same for all values of enum, it can be safely placed on enum class. Behaviour can be parametrized by providing necessary information in enum-value constructor. 36 | 37 | ## 5. Separate class implementation for each value 38 | 39 | [source code](refactoring-5.phpt) 40 | 41 | Now, new domain requirement: 42 | 43 | > I would like to remove person who has been assigned to work on order, when order changes state to cancelled or finished. 44 | 45 | 1. I have rewritten each value as separate class (as behaviour is different for different values) 46 | 2. I have implemented doTransition() on enum parent class as it is only proper way of changing enum value 47 | 3. I have added `onActivation(Order $order)` method, which is called whenever state transition occurs. 48 | 3. I have overridden `onActivation()` on enum values with desired behaviour. 49 | -------------------------------------------------------------------------------- /tests/Example/OrderState/refactoring-1.phpt: -------------------------------------------------------------------------------- 1 | canDoTransition( 53 | OrderService::STATE_RECEIVED, 54 | OrderService::STATE_PROCESSING 55 | ) 56 | ); 57 | Assert::true( 58 | $orderService->canDoTransition( 59 | OrderService::STATE_PROCESSING, 60 | OrderService::STATE_FINISHED 61 | ) 62 | ); 63 | 64 | // Cancellation order flow 65 | Assert::true( 66 | $orderService->canDoTransition( 67 | OrderService::STATE_RECEIVED, 68 | OrderService::STATE_CANCELLED 69 | ) 70 | ); 71 | 72 | // Reflexivity test 73 | Assert::true( 74 | $orderService->canDoTransition( 75 | OrderService::STATE_CANCELLED, 76 | OrderService::STATE_CANCELLED 77 | ) 78 | ); 79 | 80 | 81 | 82 | 83 | // --- NEGATIVE TESTS --- 84 | 85 | // Invalid order flow 86 | Assert::false( 87 | $orderService->canDoTransition( 88 | OrderService::STATE_RECEIVED, 89 | OrderService::STATE_FINISHED 90 | ) 91 | ); 92 | Assert::false( 93 | $orderService->canDoTransition( 94 | OrderService::STATE_PROCESSING, 95 | OrderService::STATE_CANCELLED 96 | ) 97 | ); 98 | Assert::false( 99 | $orderService->canDoTransition( 100 | OrderService::STATE_FINISHED, 101 | OrderService::STATE_CANCELLED 102 | ) 103 | ); 104 | 105 | // check for completely invalid arguments 106 | Assert::exception(function () use ($orderService) { 107 | $orderService->canDoTransition('invalid', 'non-existing'); 108 | }, \LogicException::class); 109 | 110 | -------------------------------------------------------------------------------- /tests/Example/OrderState/refactoring-2.phpt: -------------------------------------------------------------------------------- 1 | canDoTransition( 68 | OrderState::RECEIVED(), 69 | OrderState::PROCESSING() 70 | ) 71 | ); 72 | Assert::true( 73 | $orderService->canDoTransition( 74 | OrderState::PROCESSING(), 75 | OrderState::FINISHED() 76 | ) 77 | ); 78 | 79 | // Cancellation order flow 80 | Assert::true( 81 | $orderService->canDoTransition( 82 | OrderState::RECEIVED(), 83 | OrderState::CANCELLED() 84 | ) 85 | ); 86 | 87 | // Reflexivity test 88 | Assert::false( 89 | $orderService->canDoTransition( 90 | OrderState::CANCELLED(), 91 | OrderState::CANCELLED() 92 | ) 93 | ); 94 | 95 | 96 | 97 | 98 | // --- NEGATIVE TESTS --- 99 | 100 | // Invalid order flow 101 | Assert::false( 102 | $orderService->canDoTransition( 103 | OrderState::RECEIVED(), 104 | OrderState::FINISHED() 105 | ) 106 | ); 107 | Assert::false( 108 | $orderService->canDoTransition( 109 | OrderState::PROCESSING(), 110 | OrderState::CANCELLED() 111 | ) 112 | ); 113 | Assert::false( 114 | $orderService->canDoTransition( 115 | OrderState::FINISHED(), 116 | OrderState::CANCELLED() 117 | ) 118 | ); 119 | 120 | -------------------------------------------------------------------------------- /tests/Example/OrderState/refactoring-3.phpt: -------------------------------------------------------------------------------- 1 | canDoTransition( 62 | OrderState::PROCESSING() 63 | ) 64 | ); 65 | Assert::true( 66 | OrderState::PROCESSING()->canDoTransition( 67 | OrderState::FINISHED() 68 | ) 69 | ); 70 | 71 | // Cancellation order flow 72 | Assert::true( 73 | OrderState::RECEIVED()->canDoTransition( 74 | OrderState::CANCELLED() 75 | ) 76 | ); 77 | 78 | // Non-reflexivity test 79 | Assert::false( 80 | OrderState::CANCELLED()->canDoTransition( 81 | OrderState::CANCELLED() 82 | ) 83 | ); 84 | 85 | 86 | 87 | 88 | // --- NEGATIVE TESTS --- 89 | 90 | // Invalid order flow 91 | Assert::false( 92 | OrderState::RECEIVED()->canDoTransition( 93 | OrderState::FINISHED() 94 | ) 95 | ); 96 | Assert::false( 97 | OrderState::PROCESSING()->canDoTransition( 98 | OrderState::CANCELLED() 99 | ) 100 | ); 101 | Assert::false( 102 | OrderState::FINISHED()->canDoTransition( 103 | OrderState::CANCELLED() 104 | ) 105 | ); 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | $state1 = OrderState::RECEIVED(); 116 | $state2 = OrderState::RECEIVED(); 117 | Assert::true($state1 === $state2); 118 | 119 | $state3 = OrderState::PROCESSING(); 120 | Assert::true($state1 !== $state3); 121 | Assert::true($state2 !== $state3); 122 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /tests/Example/OrderState/refactoring-4.phpt: -------------------------------------------------------------------------------- 1 | nextAllowedStates = $nextAllowedStates; 45 | } 46 | 47 | public function canDoTransition(OrderState $nextState): bool 48 | { 49 | return \in_array($nextState->toScalar(), $this->nextAllowedStates, TRUE); 50 | } 51 | 52 | 53 | /** @return self[] */ 54 | final protected static function provideInstances(): array 55 | { 56 | // please not that we cannot reference self::PREPARING() 57 | // as it returns class instance, and this will call provideInstances() 58 | // again and you will get infinite loop. 59 | 60 | return [ 61 | new self(self::RECEIVED, [self::PROCESSING, self::CANCELLED]), 62 | new self(self::PROCESSING, [self::FINISHED]), 63 | new self(self::FINISHED, []), 64 | new self(self::CANCELLED, []), 65 | ]; 66 | } 67 | } 68 | 69 | 70 | 71 | // Standard order flow: 72 | Assert::true( 73 | OrderState::RECEIVED()->canDoTransition( 74 | OrderState::PROCESSING() 75 | ) 76 | ); 77 | Assert::true( 78 | OrderState::PROCESSING()->canDoTransition( 79 | OrderState::FINISHED() 80 | ) 81 | ); 82 | 83 | // Cancellation order flow 84 | Assert::true( 85 | OrderState::RECEIVED()->canDoTransition( 86 | OrderState::CANCELLED() 87 | ) 88 | ); 89 | 90 | // Non-reflexivity test 91 | Assert::false( 92 | OrderState::CANCELLED()->canDoTransition( 93 | OrderState::CANCELLED() 94 | ) 95 | ); 96 | 97 | 98 | 99 | // --- NEGATIVE TESTS --- 100 | 101 | // Invalid order flow 102 | Assert::false( 103 | OrderState::RECEIVED()->canDoTransition( 104 | OrderState::FINISHED() 105 | ) 106 | ); 107 | Assert::false( 108 | OrderState::PROCESSING()->canDoTransition( 109 | OrderState::CANCELLED() 110 | ) 111 | ); 112 | Assert::false( 113 | OrderState::FINISHED()->canDoTransition( 114 | OrderState::CANCELLED() 115 | ) 116 | ); 117 | 118 | -------------------------------------------------------------------------------- /tests/Example/OrderState/refactoring-5.phpt: -------------------------------------------------------------------------------- 1 | state = OrderState::RECEIVED(); 25 | } 26 | 27 | public function unassignEmployee(): void { 28 | $this->employee = null; 29 | } 30 | 31 | public function getEmployee(): ?string { 32 | return $this->employee; 33 | } 34 | 35 | /** 36 | * @throws InvalidTransitionException 37 | */ 38 | public function changeState(OrderState $desiredState): void 39 | { 40 | $this->state = 41 | $this->state->doTransition($this, $desiredState); 42 | } 43 | } 44 | 45 | /** 46 | * @method static OrderState RECEIVED() 47 | * @method static OrderState PROCESSING() 48 | * @method static OrderState FINISHED() 49 | * @method static OrderState CANCELLED() 50 | */ 51 | abstract class OrderState extends Enum { 52 | 53 | protected const 54 | RECEIVED = 'received', 55 | PROCESSING = 'processing', 56 | FINISHED = 'finished', 57 | 58 | CANCELLED = 'cancelled'; // domain logic: can be cancelled before preparation is started 59 | 60 | /** 61 | * @throws InvalidTransitionException 62 | */ 63 | final public function doTransition(Order $order, OrderState $desiredState): self 64 | { 65 | if ($desiredState !== $this && !$this->canDoTransition($desiredState)) { 66 | throw new InvalidTransitionException(); 67 | } 68 | $desiredState->onActivation($order); 69 | return $desiredState; 70 | } 71 | 72 | 73 | abstract public function canDoTransition(OrderState $nextState): bool; 74 | 75 | protected function onActivation(Order $order): void { /* override me */} 76 | 77 | 78 | /** @return self[] */ 79 | final protected static function provideInstances(): array 80 | { 81 | return [ 82 | new class(self::RECEIVED) extends OrderState { 83 | 84 | public function canDoTransition(OrderState $nextState): bool 85 | { 86 | return $nextState === $this::PROCESSING() || $nextState === $this::CANCELLED(); 87 | } 88 | 89 | }, 90 | 91 | 92 | new class(self::PROCESSING) extends OrderState { 93 | public function canDoTransition(OrderState $nextState): bool 94 | { 95 | return $nextState === $this::FINISHED(); 96 | } 97 | }, 98 | 99 | 100 | new class(self::FINISHED) extends OrderState { 101 | 102 | public function canDoTransition(OrderState $nextState): bool 103 | { 104 | return FALSE; 105 | } 106 | 107 | protected function onActivation(Order $order): void 108 | { 109 | $order->unassignEmployee(); 110 | } 111 | 112 | }, 113 | 114 | 115 | new class(self::CANCELLED) extends OrderState { 116 | 117 | public function canDoTransition(OrderState $nextState): bool 118 | { 119 | return FALSE; 120 | } 121 | 122 | protected function onActivation(Order $order): void 123 | { 124 | $order->unassignEmployee(); 125 | } 126 | }, 127 | ]; 128 | } 129 | } 130 | 131 | // Standard order flow: 132 | (function() { 133 | $order = new Order(); 134 | Assert::same('employee', $order->getEmployee()); 135 | $order->changeState(OrderState::PROCESSING()); 136 | Assert::same('employee', $order->getEmployee()); 137 | $order->changeState(OrderState::FINISHED()); 138 | Assert::null($order->getEmployee()); 139 | })(); 140 | 141 | // Cancellation order flow 142 | (function() { 143 | $order = new Order(); 144 | Assert::same('employee', $order->getEmployee()); 145 | $order->changeState(OrderState::CANCELLED()); 146 | Assert::null($order->getEmployee()); 147 | })(); 148 | 149 | 150 | // --- NEGATIVE TESTS --- 151 | 152 | // Invalid order flow 153 | Assert::exception(function () { 154 | $order = new Order(); 155 | $order->changeState(OrderState::FINISHED()); // not allowed 156 | }, InvalidTransitionException::class); 157 | 158 | Assert::exception(function () { 159 | $order = new Order(); 160 | $order->changeState(OrderState::PROCESSING()); 161 | $order->changeState(OrderState::CANCELLED()); // not allowed 162 | }, InvalidTransitionException::class); 163 | 164 | Assert::exception(function () { 165 | $order = new Order(); 166 | $order->changeState(OrderState::PROCESSING()); 167 | $order->changeState(OrderState::FINISHED()); 168 | 169 | $order->changeState(OrderState::CANCELLED()); // not allowed 170 | }, InvalidTransitionException::class); 171 | 172 | 173 | 174 | -------------------------------------------------------------------------------- /tests/Reflection/constantNames.inherited.phpt: -------------------------------------------------------------------------------- 1 | toScalar()); 33 | \Tester\Assert::same('active', ReflectionConstantNames2::ACTIVE()->toScalar()); 34 | -------------------------------------------------------------------------------- /tests/Reflection/constantNames.phpt: -------------------------------------------------------------------------------- 1 | getConstantName()); 18 | \Tester\Assert::same('ACTIVE', ReflectionConstantNames::ACTIVE()->getConstantName()); 19 | -------------------------------------------------------------------------------- /tests/Reflection/getAvailableValues.phpt: -------------------------------------------------------------------------------- 1 | toScalar(), 1); 32 | Assert::equal(FullClassesAsValuesEnum::VALUE2()->toScalar(), 2); 33 | 34 | Assert::type(Value1::class, FullClassesAsValuesEnum::VALUE1()); 35 | Assert::type(Value2::class, FullClassesAsValuesEnum::VALUE2()); 36 | 37 | Assert::same( 38 | [ 39 | FullClassesAsValuesEnum::VALUE1(), 40 | FullClassesAsValuesEnum::VALUE2() 41 | ], 42 | FullClassesAsValuesEnum::getAvailableValues() 43 | ); 44 | 45 | Assert::same(FullClassesAsValuesEnum::VALUE1(), FullClassesAsValuesEnum::fromScalar(1)); 46 | 47 | 48 | // ## Wrong usage & edge-cases 49 | 50 | // wrong usage: 51 | $expectNonRootAccess = function(callable $fn) { 52 | Assert::exception( 53 | $fn, 54 | UsageException::class, 55 | "You have accessed static enum method on non-root class ('TestFullClasses\\FullClassesAsValuesEnum' is a root class)" 56 | ); 57 | }; 58 | $expectNonRootAccess(function () { 59 | Value1::getAvailableValues(); 60 | }); 61 | $expectNonRootAccess(function () { 62 | Value1::fromScalar('1'); 63 | }); 64 | //$expectNonRootAccess(function () { 65 | // Value1::VALUE1(); 66 | //}); 67 | //$expectNonRootAccess(function () { 68 | // Value1::VALUE2(); 69 | //}); 70 | Assert::type(Value1::class, Value1::VALUE1()); 71 | Assert::type(Value1::class, Value2::VALUE1()); 72 | Assert::type(Value2::class, Value1::VALUE2()); 73 | Assert::type(Value2::class, Value2::VALUE2()); 74 | 75 | // valid edge case: this is valid and accesses registers the same way as calls above 76 | Assert::same( 77 | 'VALUE1', 78 | FullClassesAsValuesEnum::VALUE1() 79 | ->getConstantName() 80 | ); 81 | 82 | -------------------------------------------------------------------------------- /tests/Regression/loose-comparison-across-types.phpt: -------------------------------------------------------------------------------- 1 | toScalar() === Enum2::VALUE()->toScalar()); 26 | 27 | // everything same, but type of value is different 28 | \Tester\Assert::false(Enum1::VALUE() === Enum2::VALUE()); 29 | /** @noinspection PhpNonStrictObjectEqualityInspection TypeUnsafeComparisonInspection */ 30 | \Tester\Assert::false(Enum1::VALUE() == Enum2::VALUE()); 31 | -------------------------------------------------------------------------------- /tests/Regression/mixed-key-type-test.phpt: -------------------------------------------------------------------------------- 1 |