diff --git a/.gitattributes b/.gitattributes index cd7364e..2140930 100755 --- a/.gitattributes +++ b/.gitattributes @@ -4,6 +4,7 @@ .gitattributes export-ignore .github export-ignore .gitignore export-ignore -.travis.yml export-ignore tests/ export-ignore phpunit.xml export-ignore +psalm.xml export-ignore +static-analysis/ export-ignore diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index d20d41a..9b8c2d6 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,6 +1,6 @@ # These are supported funding model platforms -github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +github: mnapoli # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..7863acf --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,46 @@ +name: "CI" + +on: + pull_request: + push: + branches: + - "master" + +jobs: + phpunit: + name: "PHPUnit" + runs-on: "ubuntu-20.04" + + strategy: + matrix: + php-version: + - "7.3" + - "7.4" + - "8.0" + - "8.1" + - "8.2" + dependencies: + - "highest" + include: + - dependencies: "lowest" + php-version: "7.3" + + steps: + - name: "Checkout" + uses: "actions/checkout@v4" + with: + fetch-depth: 2 + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + php-version: "${{ matrix.php-version }}" + ini-values: "zend.assertions=1" + + - name: "Install dependencies with Composer" + uses: "ramsey/composer-install@v1" + with: + dependency-versions: "${{ matrix.dependencies }}" + + - name: "Run PHPUnit" + run: "vendor/bin/phpunit" diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml new file mode 100644 index 0000000..017d4a6 --- /dev/null +++ b/.github/workflows/static-analysis.yaml @@ -0,0 +1,31 @@ +name: "Static Analysis" + +on: + pull_request: + push: + branches: + - "master" + +jobs: + static-analysis-psalm: + name: "Static Analysis with Psalm" + runs-on: "ubuntu-20.04" + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Psalm + uses: docker://vimeo/psalm-github-actions:4.9.3 + with: + args: --shepherd + composer_ignore_platform_reqs: true + composer_require_dev: true + security_analysis: true + report_file: results.sarif + env: + CHECK_PLATFORM_REQUIREMENTS: "false" + - name: Upload Security Analysis results to GitHub + uses: github/codeql-action/upload-sarif@v1 + with: + sarif_file: results.sarif diff --git a/.travis.yml b/.travis.yml deleted file mode 100755 index 99e7178..0000000 --- a/.travis.yml +++ /dev/null @@ -1,24 +0,0 @@ -language: php - -php: - - '7.1' - - '7.2' - - '7.3' - - '7.4snapshot' - -matrix: - fast_finish: true - -cache: - directories: - - $HOME/.composer/cache - -before_script: - - travis_retry composer install -n - -script: - - vendor/bin/phpunit - -# Use Travis' new container-based infrastructure. -# See http://docs.travis-ci.com/user/migrating-from-legacy/#How-can-I-use-container-based-infrastructure%3F -sudo: false diff --git a/README.md b/README.md index 7bdd4f8..2bf98cd 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # PHP Enum implementation inspired from SplEnum -[![Build Status](https://travis-ci.org/myclabs/php-enum.png?branch=master)](https://travis-ci.org/myclabs/php-enum) -[![Latest Stable Version](https://poser.pugx.org/myclabs/php-enum/version.png)](https://packagist.org/packages/myclabs/php-enum) -[![Total Downloads](https://poser.pugx.org/myclabs/php-enum/downloads.png)](https://packagist.org/packages/myclabs/php-enum) +[![GitHub Actions][GA Image]][GA Link] +[![Latest Stable Version](https://poser.pugx.org/myclabs/php-enum/version.svg)](https://packagist.org/packages/myclabs/php-enum) +[![Total Downloads](https://poser.pugx.org/myclabs/php-enum/downloads.svg)](https://packagist.org/packages/myclabs/php-enum) +[![Psalm Shepherd][Shepherd Image]][Shepherd Link] Maintenance for this project is [supported via Tidelift](https://tidelift.com/subscription/pkg/packagist-myclabs-php-enum?utm_source=packagist-myclabs-php-enum&utm_medium=referral&utm_campaign=readme). @@ -12,7 +13,8 @@ First, and mainly, `SplEnum` is not integrated to PHP, you have to install the e Using an enum instead of class constants provides the following advantages: -- You can type-hint: `function setAction(Action $action) {` +- You can use an enum as a parameter type: `function setAction(Action $action) {` +- You can use an enum as a return type: `function getAction() : Action {` - You can enrich the enum with methods (e.g. `format`, `parse`, …) - You can extend the enum to add new values (make your enum `final` to prevent it) - You can get a list of all the possible values (see below) @@ -32,8 +34,10 @@ use MyCLabs\Enum\Enum; /** * Action enum + * + * @extends Enum */ -class Action extends Enum +final class Action extends Enum { private const VIEW = 'view'; private const EDIT = 'edit'; @@ -48,12 +52,14 @@ $action = Action::VIEW(); // or with a dynamic key: $action = Action::$key(); // or with a dynamic value: +$action = Action::from($value); +// or $action = new Action($value); ``` As you can see, static methods are automatically implemented to provide quick access to an enum value. -One advantage over using class constants is to be able to type-hint enum values: +One advantage over using class constants is to be able to use an enum as a parameter type: ```php function setAction(Action $action) { @@ -71,17 +77,19 @@ function setAction(Action $action) { Static methods: +- `from()` Creates an Enum instance, checking that the value exist in the enum - `toArray()` method Returns all possible values as an array (constant name in key, constant value in value) - `keys()` Returns the names (keys) of all constants in the Enum class - `values()` Returns instances of the Enum class of all Enum constants (constant name in key, Enum instance in value) - `isValid()` Check if tested value is valid on enum set - `isValidKey()` Check if tested key is valid on enum set +- `assertValidValue()` Assert the value is valid on enum set, throwing exception otherwise - `search()` Return key for searched value ### Static methods ```php -class Action extends Enum +final class Action extends Enum { private const VIEW = 'view'; private const EDIT = 'edit'; @@ -97,7 +105,7 @@ Static method helpers are implemented using [`__callStatic()`](http://www.php.ne If you care about IDE autocompletion, you can either implement the static methods yourself: ```php -class Action extends Enum +final class Action extends Enum { private const VIEW = 'view'; @@ -117,15 +125,72 @@ or you can use phpdoc (this is supported in PhpStorm for example): * @method static Action VIEW() * @method static Action EDIT() */ -class Action extends Enum +final class Action extends Enum { private const VIEW = 'view'; private const EDIT = 'edit'; } ``` +## Native enums and migration +Native enum arrived to PHP in version 8.1: https://www.php.net/enumerations +If your project is running PHP 8.1+ or your library has it as a minimum requirement you should use it instead of this library. + +When migrating from `myclabs/php-enum`, the effort should be small if the usage was in the recommended way: +- private constants +- final classes +- no method overridden + +Changes for migration: +- Class definition should be changed from +```php +/** + * @method static Action VIEW() + * @method static Action EDIT() + */ +final class Action extends Enum +{ + private const VIEW = 'view'; + private const EDIT = 'edit'; +} +``` + to +```php +enum Action: string +{ + case VIEW = 'view'; + case EDIT = 'edit'; +} +``` +All places where the class was used as a type will continue to work. + +Usages and the change needed: + +| Operation | myclabs/php-enum | native enum | +|----------------------------------------------------------------|----------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Obtain an instance will change from | `$enumCase = Action::VIEW()` | `$enumCase = Action::VIEW` | +| Create an enum from a backed value | `$enumCase = new Action('view')` | `$enumCase = Action::from('view')` | +| Get the backed value of the enum instance | `$enumCase->getValue()` | `$enumCase->value` | +| Compare two enum instances | `$enumCase1 == $enumCase2`
or
`$enumCase1->equals($enumCase2)` | `$enumCase1 === $enumCase2` | +| Get the key/name of the enum instance | `$enumCase->getKey()` | `$enumCase->name` | +| Get a list of all the possible instances of the enum | `Action::values()` | `Action::cases()` | +| Get a map of possible instances of the enum mapped by name | `Action::values()` | `array_combine(array_map(fn($case) => $case->name, Action::cases()), Action::cases())`
or
`(new ReflectionEnum(Action::class))->getConstants()` | +| Get a list of all possible names of the enum | `Action::keys()` | `array_map(fn($case) => $case->name, Action::cases())` | +| Get a list of all possible backed values of the enum | `Action::toArray()` | `array_map(fn($case) => $case->value, Action::cases())` | +| Get a map of possible backed values of the enum mapped by name | `Action::toArray()` | `array_combine(array_map(fn($case) => $case->name, Action::cases()), array_map(fn($case) => $case->value, Action::cases()))`
or
`array_map(fn($case) => $case->value, (new ReflectionEnum(Action::class))->getConstants()))` | + ## Related projects +- [PHP 8.1+ native enum](https://www.php.net/enumerations) - [Doctrine enum mapping](https://github.com/acelaya/doctrine-enum-type) - [Symfony ParamConverter integration](https://github.com/Ex3v/MyCLabsEnumParamConverter) - [PHPStan integration](https://github.com/timeweb/phpstan-enum) + + +[GA Image]: https://github.com/myclabs/php-enum/workflows/CI/badge.svg + +[GA Link]: https://github.com/myclabs/php-enum/actions?query=workflow%3A%22CI%22+branch%3Amaster + +[Shepherd Image]: https://shepherd.dev/github/myclabs/php-enum/coverage.svg + +[Shepherd Link]: https://shepherd.dev/github/myclabs/php-enum diff --git a/composer.json b/composer.json index 871156d..eab6263 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "type": "library", "description": "PHP Enum implementation", "keywords": ["enum"], - "homepage": "http://github.com/myclabs/php-enum", + "homepage": "https://github.com/myclabs/php-enum", "license": "MIT", "authors": [ { @@ -14,7 +14,10 @@ "autoload": { "psr-4": { "MyCLabs\\Enum\\": "src/" - } + }, + "classmap": [ + "stubs/Stringable.php" + ] }, "autoload-dev": { "psr-4": { @@ -22,11 +25,12 @@ } }, "require": { - "php": ">=7.1", + "php": "^7.3 || ^8.0", "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "^4.8.35|^5.7|^6.0", - "squizlabs/php_codesniffer": "1.*" + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "1.*", + "vimeo/psalm": "^4.6.2 || ^5.2" } } diff --git a/phpunit.xml b/phpunit.xml index 67a61e1..33b8f67 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,20 +1,19 @@ - - + ./tests + + + + src + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..ff06b66 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Enum.php b/src/Enum.php index 3bce0dc..1bd5592 100644 --- a/src/Enum.php +++ b/src/Enum.php @@ -14,45 +14,99 @@ * @author Matthieu Napoli * @author Daniel Costa * @author Mirosław Filip + * + * @psalm-template T + * @psalm-immutable + * @psalm-consistent-constructor */ -abstract class Enum implements \JsonSerializable +abstract class Enum implements \JsonSerializable, \Stringable { /** * Enum value * * @var mixed + * @psalm-var T */ protected $value; + /** + * Enum key, the constant name + * + * @var string + */ + private $key; + /** * Store existing constants in a static cache per object. * + * * @var array + * @psalm-var array> */ protected static $cache = []; + /** + * Cache of instances of the Enum class + * + * @var array + * @psalm-var array> + */ + protected static $instances = []; + /** * Creates a new value of some type * + * @psalm-pure * @param mixed $value * + * @psalm-param T $value * @throws \UnexpectedValueException if incompatible type is given. */ public function __construct($value) { if ($value instanceof static) { + /** @psalm-var T */ $value = $value->getValue(); } - if (!$this->isValid($value)) { - throw new \UnexpectedValueException("Value '$value' is not part of the enum " . \get_called_class()); - } + /** @psalm-suppress ImplicitToStringCast assertValidValueReturningKey returns always a string but psalm has currently an issue here */ + $this->key = static::assertValidValueReturningKey($value); + /** @psalm-var T */ $this->value = $value; } /** + * This method exists only for the compatibility reason when deserializing a previously serialized version + * that didn't had the key property + */ + public function __wakeup() + { + /** @psalm-suppress DocblockTypeContradiction key can be null when deserializing an enum without the key */ + if ($this->key === null) { + /** + * @psalm-suppress InaccessibleProperty key is not readonly as marked by psalm + * @psalm-suppress PossiblyFalsePropertyAssignmentValue deserializing a case that was removed + */ + $this->key = static::search($this->value); + } + } + + /** + * @param mixed $value + * @return static + */ + public static function from($value): self + { + $key = static::assertValidValueReturningKey($value); + + return self::__callStatic($key, []); + } + + /** + * @psalm-pure * @return mixed + * @psalm-return T */ public function getValue() { @@ -62,14 +116,17 @@ public function getValue() /** * Returns the enum key (i.e. the constant name). * - * @return mixed + * @psalm-pure + * @return string */ public function getKey() { - return static::search($this->value); + return $this->key; } /** + * @psalm-pure + * @psalm-suppress InvalidCast * @return string */ public function __toString() @@ -83,18 +140,22 @@ public function __toString() * * This method is final, for more information read https://github.com/myclabs/php-enum/issues/4 * + * @psalm-pure + * @psalm-param mixed $variable * @return bool */ final public function equals($variable = null): bool { return $variable instanceof self && $this->getValue() === $variable->getValue() - && \get_called_class() === \get_class($variable); + && static::class === \get_class($variable); } /** * Returns the names (keys) of all constants in the Enum class * + * @psalm-pure + * @psalm-return list * @return array */ public static function keys() @@ -105,13 +166,17 @@ public static function keys() /** * Returns instances of the Enum class of all Enum constants * + * @psalm-pure + * @psalm-return array * @return static[] Constant name in key, Enum instance in value */ public static function values() { $values = array(); + /** @psalm-var T $value */ foreach (static::toArray() as $key => $value) { + /** @psalm-suppress UnsafeGenericInstantiation */ $values[$key] = new static($value); } @@ -121,13 +186,20 @@ public static function values() /** * Returns all possible values as an array * + * @psalm-pure + * @psalm-suppress ImpureStaticProperty + * + * @psalm-return array * @return array Constant name in key, constant value in value */ public static function toArray() { - $class = \get_called_class(); + $class = static::class; + if (!isset(static::$cache[$class])) { + /** @psalm-suppress ImpureMethodCall this reflection API usage has no side-effects here */ $reflection = new \ReflectionClass($class); + /** @psalm-suppress ImpureMethodCall this reflection API usage has no side-effects here */ static::$cache[$class] = $reflection->getConstants(); } @@ -138,7 +210,9 @@ public static function toArray() * Check if is valid enum value * * @param $value - * + * @psalm-param mixed $value + * @psalm-pure + * @psalm-assert-if-true T $value * @return bool */ public static function isValid($value) @@ -146,11 +220,41 @@ public static function isValid($value) return \in_array($value, static::toArray(), true); } + /** + * Asserts valid enum value + * + * @psalm-pure + * @psalm-assert T $value + * @param mixed $value + */ + public static function assertValidValue($value): void + { + self::assertValidValueReturningKey($value); + } + + /** + * Asserts valid enum value + * + * @psalm-pure + * @psalm-assert T $value + * @param mixed $value + * @return string + */ + private static function assertValidValueReturningKey($value): string + { + if (false === ($key = static::search($value))) { + throw new \UnexpectedValueException("Value '$value' is not part of the enum " . static::class); + } + + return $key; + } + /** * Check if is valid enum key * * @param $key - * + * @psalm-param string $key + * @psalm-pure * @return bool */ public static function isValidKey($key) @@ -163,9 +267,11 @@ public static function isValidKey($key) /** * Return key for value * - * @param $value + * @param mixed $value * - * @return mixed + * @psalm-param mixed $value + * @psalm-pure + * @return string|false */ public static function search($value) { @@ -180,15 +286,22 @@ public static function search($value) * * @return static * @throws \BadMethodCallException + * + * @psalm-pure */ public static function __callStatic($name, $arguments) { - $array = static::toArray(); - if (isset($array[$name]) || \array_key_exists($name, $array)) { - return new static($array[$name]); + $class = static::class; + if (!isset(self::$instances[$class][$name])) { + $array = static::toArray(); + if (!isset($array[$name]) && !\array_key_exists($name, $array)) { + $message = "No static method or enum constant '$name' in class " . static::class; + throw new \BadMethodCallException($message); + } + /** @psalm-suppress UnsafeGenericInstantiation */ + return self::$instances[$class][$name] = new static($array[$name]); } - - throw new \BadMethodCallException("No static method or enum constant '$name' in class " . \get_called_class()); + return clone self::$instances[$class][$name]; } /** @@ -198,6 +311,7 @@ public static function __callStatic($name, $arguments) * @return mixed * @link http://php.net/manual/en/jsonserializable.jsonserialize.php */ + #[\ReturnTypeWillChange] public function jsonSerialize() { return $this->getValue(); diff --git a/src/PHPUnit/Comparator.php b/src/PHPUnit/Comparator.php index 302bf80..7c65e4e 100644 --- a/src/PHPUnit/Comparator.php +++ b/src/PHPUnit/Comparator.php @@ -43,7 +43,7 @@ public function assertEquals($expected, $actual, $delta = 0.0, $canonicalize = f ); } - private function formatEnum(Enum $enum = null) + private function formatEnum(?Enum $enum = null) { if ($enum === null) { return "null"; diff --git a/static-analysis/EnumIsPure.php b/static-analysis/EnumIsPure.php new file mode 100644 index 0000000..5875fd8 --- /dev/null +++ b/static-analysis/EnumIsPure.php @@ -0,0 +1,33 @@ + + */ +final class PureEnum extends Enum +{ + const A = 'A'; + const C = 'C'; +} + +/** @psalm-pure */ +function enumFetchViaMagicMethodIsPure(): PureEnum +{ + return PureEnum::A(); +} + +/** @psalm-pure */ +function enumFetchViaExplicitMagicCallIsPure(): PureEnum +{ + return PureEnum::__callStatic('A', []); +} diff --git a/stubs/Stringable.php b/stubs/Stringable.php new file mode 100644 index 0000000..4811af7 --- /dev/null +++ b/stubs/Stringable.php @@ -0,0 +1,11 @@ +assertNotEquals('BA', $value->getKey()); } + /** @dataProvider invalidValueProvider */ + public function testCreatingEnumWithInvalidValue($value) + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('is not part of the enum ' . EnumFixture::class); + + new EnumFixture($value); + } + /** * @dataProvider invalidValueProvider - * @expectedException \UnexpectedValueException - * @expectedExceptionMessage is not part of the enum MyCLabs\Tests\Enum\EnumFixture + * @param mixed $value */ - public function testCreatingEnumWithInvalidValue($value) + public function testFailToCreateEnumWithInvalidValueThroughNamedConstructor($value): void { - new EnumFixture($value); + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('is not part of the enum MyCLabs\Tests\Enum\EnumFixture'); + + EnumFixture::from($value); + } + + public function testFailToCreateEnumWithEnumItselfThroughNamedConstructor(): void + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage("Value 'foo' is not part of the enum " . EnumFixture::class); + + EnumFixture::from(EnumFixture::FOO()); } /** @@ -143,15 +162,14 @@ public function testStaticAccess() $this->assertEquals(new EnumFixture(EnumFixture::FOO), EnumFixture::FOO()); $this->assertEquals(new EnumFixture(EnumFixture::BAR), EnumFixture::BAR()); $this->assertEquals(new EnumFixture(EnumFixture::NUMBER), EnumFixture::NUMBER()); + $this->assertNotSame(EnumFixture::NUMBER(), EnumFixture::NUMBER()); } - /** - * @expectedException \BadMethodCallException - * @expectedExceptionMessage No static method or enum constant 'UNKNOWN' in class - * UnitTest\MyCLabs\Enum\Enum\EnumFixture - */ public function testBadStaticAccess() { + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('No static method or enum constant \'UNKNOWN\' in class ' . EnumFixture::class); + EnumFixture::UNKNOWN(); } @@ -303,12 +321,13 @@ public function testSerialize() { // split string for Pretty CI: "Line exceeds 120 characters" $bin = '4f3a33303a224d79434c6162735c54657374735c456e756d5c456e756d4669787'. - '4757265223a313a7b733a383a22002a0076616c7565223b733a333a22666f6f223b7d'; + '4757265223a323a7b733a383a22002a0076616c7565223b733a333a22666f6f223b73'. + '3a32323a22004d79434c6162735c456e756d5c456e756d006b6579223b733a333a22464f4f223b7d'; $this->assertEquals($bin, bin2hex(serialize(EnumFixture::FOO()))); } - public function testUnserialize() + public function testUnserializeVersionWithoutKey() { // split string for Pretty CI: "Line exceeds 120 characters" $bin = '4f3a33303a224d79434c6162735c54657374735c456e756d5c456e756d4669787'. @@ -319,6 +338,22 @@ public function testUnserialize() $this->assertEquals(EnumFixture::FOO, $value->getValue()); $this->assertTrue(EnumFixture::FOO()->equals($value)); + $this->assertTrue(EnumFixture::FOO() == $value); + } + + public function testUnserialize() + { + // split string for Pretty CI: "Line exceeds 120 characters" + $bin = '4f3a33303a224d79434c6162735c54657374735c456e756d5c456e756d4669787'. + '4757265223a323a7b733a383a22002a0076616c7565223b733a333a22666f6f223b73'. + '3a32323a22004d79434c6162735c456e756d5c456e756d006b6579223b733a333a22464f4f223b7d'; + + /* @var $value EnumFixture */ + $value = unserialize(pack('H*', $bin)); + + $this->assertEquals(EnumFixture::FOO, $value->getValue()); + $this->assertTrue(EnumFixture::FOO()->equals($value)); + $this->assertTrue(EnumFixture::FOO() == $value); } /** @@ -331,4 +366,19 @@ public function testEnumValuesInheritance() $inheritedEnumFixture = InheritedEnumFixture::VALUE(); new EnumFixture($inheritedEnumFixture); } + + /** + * @dataProvider isValidProvider + */ + public function testAssertValidValue($value, $isValid): void + { + if (!$isValid) { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage("Value '$value' is not part of the enum " . EnumFixture::class); + } + + EnumFixture::assertValidValue($value); + + self::assertTrue(EnumFixture::isValid($value)); + } }