diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f449dd9..c9c47c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,14 +12,24 @@ on: jobs: pest: - name: Tests (Pest) L${{ matrix.laravel }} + name: Tests (Pest) PHP${{ matrix.php }} L${{ matrix.laravel }} runs-on: ubuntu-latest strategy: matrix: - laravel: [9, 10] + include: + - laravel: 10 + php: 8.1 + - laravel: 10 + php: 8.2 + - laravel: 10 + php: 8.3 steps: - uses: actions/checkout@v2 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{matrix.php}} - name: Install composer dependencies run: composer require "illuminate/support:^${{ matrix.laravel }}.0" - name: Run tests diff --git a/README.md b/README.md index 8350831..395ff12 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A collection of enum helpers for PHP. - [`Options`](#options) - [`From`](#from) - [`Metadata`](#metadata) +- [`Comparable`](#comparable) You can read more about the idea on [Twitter](https://twitter.com/archtechx/status/1495158228757270528). I originally wanted to include the `InvokableCases` helper in [`archtechx/helpers`](https://github.com/archtechx/helpers), but it makes more sense to make it a separate dependency and use it *inside* the other package. @@ -366,6 +367,65 @@ enum TaskStatus: int And if you're using the same meta property in multiple enums, you can create a dedicated trait that includes this `@method` annotation. +### Comparable + +This helper lets you compare enums by `is()`, `isNot()`, `in()` and `notIn()` operators. + +#### Apply the trait on your enum +```php +use ArchTech\Enums\Comparable; + +enum TaskStatus: int +{ + use Comparable; + + case INCOMPLETE = 0; + case COMPLETED = 1; + case CANCELED = 2; +} + +enum Role +{ + use Comparable; + + case ADMINISTRATOR; + case SUBSCRIBER; + case GUEST; +} +``` + +#### Use the `is()` method +```php +TaskStatus::INCOMPLETE->is(TaskStatus::INCOMPLETE); // true +TaskStatus::INCOMPLETE->is(TaskStatus::COMPLETED); // false +Role::ADMINISTRATOR->is(Role::ADMINISTRATOR); // true +Role::ADMINISTRATOR->is(Role::NOBODY); // false +``` + +#### Use the `isNot()` method +```php +TaskStatus::INCOMPLETE->isNot(TaskStatus::INCOMPLETE); // false +TaskStatus::INCOMPLETE->isNot(TaskStatus::COMPLETED); // true +Role::ADMINISTRATOR->isNot(Role::ADMINISTRATOR); // false +Role::ADMINISTRATOR->isNot(Role::NOBODY); // true +``` + +#### Use the `in()` method +```php +TaskStatus::INCOMPLETE->in([TaskStatus::INCOMPLETE, TaskStatus::COMPLETED]); // true +TaskStatus::INCOMPLETE->in([TaskStatus::COMPLETED, TaskStatus::CANCELED]); // false +Role::ADMINISTRATOR->in([Role::ADMINISTRATOR, Role::GUEST]); // true +Role::ADMINISTRATOR->in([Role::SUBSCRIBER, Role::GUEST]); // false +``` + +#### Use the `notIn()` method +```php +TaskStatus::INCOMPLETE->notIn([TaskStatus::INCOMPLETE, TaskStatus::COMPLETED]); // false +TaskStatus::INCOMPLETE->notIn([TaskStatus::COMPLETED, TaskStatus::CANCELED]); // true +Role::ADMINISTRATOR->notIn([Role::ADMINISTRATOR, Role::GUEST]); // false +Role::ADMINISTRATOR->notIn([Role::SUBSCRIBER, Role::GUEST]); // true +``` + ## PHPStan To assist PHPStan when using invokable cases, you can include the PHPStan extensions into your own `phpstan.neon` file: diff --git a/composer.json b/composer.json index 73cd2d4..01ce6b7 100644 --- a/composer.json +++ b/composer.json @@ -23,10 +23,10 @@ "php": "^8.1" }, "require-dev": { - "orchestra/testbench": "^7.0|^8.0", - "pestphp/pest": "^1.2|^2.0", - "pestphp/pest-plugin-laravel": "^1.0|^2.0", - "larastan/larastan": "^1.0|^2.7.0" + "orchestra/testbench": "^8.0", + "larastan/larastan": "^2.4", + "pestphp/pest": "^2.0", + "pestphp/pest-plugin-laravel": "^2.0" }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/phpunit.xml b/phpunit.xml index d26232e..49b212d 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,9 +1,6 @@ - - - - ./src - + + @@ -22,12 +19,15 @@ - - + + + ./src + + diff --git a/src/Comparable.php b/src/Comparable.php new file mode 100644 index 0000000..c655912 --- /dev/null +++ b/src/Comparable.php @@ -0,0 +1,50 @@ +is($enum); + } + + public function in(array|object $enums): bool + { + $iterator = $enums; + + if (! is_array($enums)) { + if ($enums instanceof Iterator) { + $iterator = $enums; + } elseif ($enums instanceof IteratorAggregate) { + $iterator = $enums->getIterator(); + } else { + throw new Exception('in() expects an iterable value'); + } + } + + foreach ($iterator as $item) { + if ($item === $this) { + return true; + } + } + + return false; + } + + public function notIn(array|object $enums): bool + { + return ! $this->in($enums); + } +} diff --git a/src/From.php b/src/From.php index 916b12f..2ac8853 100644 --- a/src/From.php +++ b/src/From.php @@ -37,7 +37,7 @@ public static function tryFrom(string $case): ?static */ public static function fromName(string $case): static { - return static::tryFromName($case) ?? throw new ValueError('"' . $case . '" is not a valid name for enum "' . static::class . '"'); + return static::tryFromName($case) ?? throw new ValueError('"' . $case . '" is not a valid name for enum ' . static::class . ''); } /** diff --git a/tests/Pest.php b/tests/Pest.php index 47f9676..e29ceac 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,6 +1,6 @@ is(Status::PENDING))->toBeTrue(); + expect(Status::PENDING->is(Status::DONE))->toBeFalse(); + expect(Role::ADMIN->is(Role::ADMIN))->toBeTrue(); + + expect(Role::ADMIN->is(Role::GUEST))->toBeFalse(); + expect(Role::ADMIN->is('admin'))->toBeFalse(); +}); + +it('the isNot method checks for inequality', function () { + expect(Status::PENDING->isNot(Status::DONE))->toBeTrue(); + expect(Status::PENDING->isNot(Status::PENDING))->toBeFalse(); + expect(Status::PENDING->isNot(Role::ADMIN))->toBeTrue(); + expect(Role::ADMIN->isNot(Role::GUEST))->toBeTrue(); + + expect(Role::ADMIN->isNot(Role::ADMIN))->toBeFalse(); + expect(Role::ADMIN->isNot('admin'))->toBeTrue(); +}); + +it('the in method checks for presence in an array', function () { + expect(Status::PENDING->in([Status::PENDING, Status::DONE]))->toBeTrue(); + expect(Role::ADMIN->in([Role::ADMIN]))->toBeTrue(); + + expect(Status::PENDING->in([Status::DONE]))->toBeFalse(); + expect(Status::PENDING->in([Role::ADMIN, Role::GUEST]))->toBeFalse(); +}); + +it('the not in method checks for absence in an array', function () { + expect(Status::PENDING->notIn([Status::DONE]))->toBeTrue(); + expect(Role::ADMIN->notIn([Role::GUEST]))->toBeTrue(); + + expect(Status::PENDING->notIn([Status::PENDING, Status::DONE]))->toBeFalse(); + expect(Role::ADMIN->notIn([Role::ADMIN, Role::GUEST]))->toBeFalse(); +}); + +test('the in and notIn methods work with Laravel collections', function () { + expect(Status::PENDING->in(collect([Status::PENDING, Status::DONE])))->toBeTrue(); + expect(Role::ADMIN->in(collect([Status::PENDING, Role::GUEST])))->toBeFalse(); + + expect(Status::DONE->notIn(collect([Status::PENDING])))->toBeTrue(); + expect(Role::ADMIN->notIn(collect([Role::ADMIN, Status::PENDING])))->toBeFalse(); +}); diff --git a/tests/Pest/FromTest.php b/tests/Pest/FromTest.php index 9ec5ce3..af9605a 100644 --- a/tests/Pest/FromTest.php +++ b/tests/Pest/FromTest.php @@ -4,9 +4,12 @@ ->expect(Status::from(0)) ->toBe(Status::PENDING); +// Shortened exception message due to inconsistency between PHP 8.1 and 8.2+ +// 8.1: 2 is not a valid backing value for enum "Status" +// 8.2+: 2 is not a valid backing value for enum Status it('does not override the default BackedEnum from method with errors', function () { Status::from(2); -})->throws(ValueError::class, '2 is not a valid backing value for enum "Status"'); +})->throws(ValueError::class, '2 is not a valid backing value for enum'); it('does not override the default BackedEnum tryFrom method') ->expect(Status::tryFrom(1)) @@ -22,7 +25,7 @@ it('throws a value error when selecting a non-existent case with from() for pure enums', function () { Role::from('NOBODY'); -})->throws(ValueError::class, '"NOBODY" is not a valid name for enum "Role"'); +})->throws(ValueError::class, '"NOBODY" is not a valid name for enum Role'); it('can select a case by name with tryFrom() for pure enums') ->expect(Role::tryFrom('GUEST')) @@ -38,7 +41,7 @@ it('throws a value error when selecting a non-existent case by name with fromName() for pure enums', function () { Role::fromName('NOBODY'); -})->throws(ValueError::class, '"NOBODY" is not a valid name for enum "Role"'); +})->throws(ValueError::class, '"NOBODY" is not a valid name for enum Role'); it('can select a case by name with tryFromName() for pure enums') ->expect(Role::tryFromName('GUEST')) @@ -54,7 +57,7 @@ it('throws a value error when selecting a non-existent case by name with fromName() for backed enums', function () { Status::fromName('NOTHING'); -})->throws(ValueError::class, '"NOTHING" is not a valid name for enum "Status"'); +})->throws(ValueError::class, '"NOTHING" is not a valid name for enum Status'); it('can select a case by name with tryFromName() for backed enums') ->expect(Status::tryFromName('DONE'))