diff --git a/README.md b/README.md index 9d73191..1556ee8 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Please see the [upgrade.md](upgrade.md) file. - **Construct enum by name or value**: `wrap()`, `from()`, `tryFrom()`, `fromName()`, `tryFromName()`, `fromValue()`, `tryFromValue()` methods - **Enums Inspection**: `isPure()`, `isBacked()`, `has()`, `hasName()`, `hasValue()` methods - **Enums Equality**: `is()`, `isNot()`, `in()`, `notIn()` methods +- **Comparison**: `Comparable` contract with default `compare()` implementations by value (backed enums) or by name (pure enums) - **Names**: methods to have a list of case names (`names()`, `namesByValue()`) - **Values**: methods to have a list of case values (`values()`, `valuesByName()`) - **Serialization**: get an unique identifier from instance or instance from identifier (`serialize()`, `unserialize()`) @@ -91,6 +92,7 @@ The package works with cases written in UPPER_CASE, snake_case and PascalCase. - [From](#from-fromName) - [Enums Inspection](#inspection) - [Enums Equality](#equality) +- [Comparison](#comparison) - [Names](#names) - [Values](#values) - [Serialization](#serialization) @@ -356,6 +358,60 @@ StringBackedEnum::PENDING->in(['P', 'D']); // true StringBackedEnum::PENDING->notIn(['A','D']); // true ``` +### Comparison +The `Comparable` contract and the `ComparesByValue` / `ComparesByName` traits are a minimal default implementation to compare and sort enum cases by value (`BackedEnum`) or by name (pure enums). +They are not included in `EnumHelper`: if you need a different ordering (e.g. a custom priority) implement `compare()` by yourself or define your own contract. +If you implement `Comparable` without the trait, `compare()` parameters must be typed as `Comparable` (PHP doesn't allow narrowing them to your enum): check the actual type inside the method. + +`compare()` returns `-1`, `0` or `1`: int values are compared numerically, string values with `strcmp()` (so `'10'` comes before `'9'`). +Comparing cases of different enums throws an `InvalidArgumentException`. +```php +use Datomatic\EnumHelper\Contracts\Comparable; +use Datomatic\EnumHelper\Traits\ComparesByValue; + +enum StringBackedEnum: string implements Comparable +{ + use EnumHelper; + use ComparesByValue; + + case PENDING = 'P'; + case ACCEPTED = 'A'; + case DISCARDED = 'D'; + case NO_RESPONSE = 'N'; +} + +IntBackedEnum::compare(IntBackedEnum::PENDING, IntBackedEnum::ACCEPTED); // -1 +StringBackedEnum::compare(StringBackedEnum::PENDING, StringBackedEnum::ACCEPTED); // 1 +StringBackedEnum::compare(StringBackedEnum::PENDING, StringBackedEnum::PENDING); // 0 + +$cases = StringBackedEnum::cases(); +usort($cases, StringBackedEnum::compare(...)); // [ACCEPTED, DISCARDED, NO_RESPONSE, PENDING] +``` + +For pure enums use `ComparesByName`: case names are compared with `strcmp()` (case-sensitive, so `'Z'` comes before `'a'`). +```php +use Datomatic\EnumHelper\Contracts\Comparable; +use Datomatic\EnumHelper\Traits\ComparesByName; + +enum PureEnum implements Comparable +{ + use EnumHelper; + use ComparesByName; + + case PENDING; + case ACCEPTED; + case DISCARDED; + case NO_RESPONSE; +} + +PureEnum::compare(PureEnum::ACCEPTED, PureEnum::PENDING); // -1 +PureEnum::compare(PureEnum::PENDING, PureEnum::DISCARDED); // 1 +PureEnum::compare(PureEnum::PENDING, PureEnum::PENDING); // 0 + +$cases = PureEnum::cases(); +usort($cases, PureEnum::compare(...)); // [ACCEPTED, DISCARDED, NO_RESPONSE, PENDING] +``` + ### Names diff --git a/src/Contracts/Comparable.php b/src/Contracts/Comparable.php new file mode 100644 index 0000000..e1426ec --- /dev/null +++ b/src/Contracts/Comparable.php @@ -0,0 +1,13 @@ + $b. + */ + public static function compare(Comparable $a, Comparable $b): int; +} diff --git a/src/Traits/ComparesByName.php b/src/Traits/ComparesByName.php new file mode 100644 index 0000000..0cd7414 --- /dev/null +++ b/src/Traits/ComparesByName.php @@ -0,0 +1,27 @@ +name, $b->name) <=> 0; + } +} diff --git a/src/Traits/ComparesByValue.php b/src/Traits/ComparesByValue.php new file mode 100644 index 0000000..fd3ea04 --- /dev/null +++ b/src/Traits/ComparesByValue.php @@ -0,0 +1,40 @@ +value) && is_string($b->value)) { + return strcmp($a->value, $b->value) <=> 0; + } + + return $a->value <=> $b->value; + } +} diff --git a/tests/ComparesByNameTest.php b/tests/ComparesByNameTest.php new file mode 100644 index 0000000..9d7181c --- /dev/null +++ b/tests/ComparesByNameTest.php @@ -0,0 +1,33 @@ +toBe($result); +})->with([ + [PureEnum::ACCEPTED, PureEnum::PENDING, -1], + [PureEnum::PENDING, PureEnum::DISCARDED, 1], + [PureEnum::PENDING, PureEnum::PENDING, 0], +]); + +it('can sort enum cases using compare as usort callback', function () { + $cases = PureEnum::cases(); + usort($cases, PureEnum::compare(...)); + + expect($cases)->toBe([ + PureEnum::ACCEPTED, + PureEnum::DISCARDED, + PureEnum::NO_RESPONSE, + PureEnum::PENDING, + ]); +}); + +it('throws an exception comparing cases of different enums', function ($a, $b) { + PureEnum::compare($a, $b); +})->with([ + [PureEnum::PENDING, IntBackedEnum::PENDING], + [IntBackedEnum::PENDING, PureEnum::PENDING], +])->throws(InvalidArgumentException::class); diff --git a/tests/ComparesByValueTest.php b/tests/ComparesByValueTest.php new file mode 100644 index 0000000..d27a6b7 --- /dev/null +++ b/tests/ComparesByValueTest.php @@ -0,0 +1,40 @@ +toBe($result); +})->with([ + [IntBackedEnum::PENDING, IntBackedEnum::ACCEPTED, -1], + [IntBackedEnum::NO_RESPONSE, IntBackedEnum::ACCEPTED, 1], + [IntBackedEnum::ACCEPTED, IntBackedEnum::ACCEPTED, 0], + [StringBackedEnum::ACCEPTED, StringBackedEnum::PENDING, -1], + [StringBackedEnum::PENDING, StringBackedEnum::DISCARDED, 1], + [StringBackedEnum::PENDING, StringBackedEnum::PENDING, 0], + [NumericStringBackedEnum::TEN, NumericStringBackedEnum::NINE, -1], + [NumericStringBackedEnum::NINE, NumericStringBackedEnum::TEN, 1], +]); + +it('can sort enum cases using compare as usort callback', function () { + $cases = StringBackedEnum::cases(); + usort($cases, StringBackedEnum::compare(...)); + + expect($cases)->toBe([ + StringBackedEnum::ACCEPTED, + StringBackedEnum::DISCARDED, + StringBackedEnum::NO_RESPONSE, + StringBackedEnum::PENDING, + ]); +}); + +it('throws an exception comparing cases of different enums', function ($a, $b) { + StringBackedEnum::compare($a, $b); +})->with([ + [StringBackedEnum::PENDING, IntBackedEnum::PENDING], + [IntBackedEnum::PENDING, StringBackedEnum::PENDING], + [NumericStringBackedEnum::NINE, NumericStringBackedEnum::TEN], +])->throws(InvalidArgumentException::class); diff --git a/tests/Support/Enums/IntBackedEnum.php b/tests/Support/Enums/IntBackedEnum.php index 44e7fdb..27f9ea0 100644 --- a/tests/Support/Enums/IntBackedEnum.php +++ b/tests/Support/Enums/IntBackedEnum.php @@ -4,7 +4,9 @@ namespace Datomatic\EnumHelper\Tests\Support\Enums; +use Datomatic\EnumHelper\Contracts\Comparable; use Datomatic\EnumHelper\EnumHelper; +use Datomatic\EnumHelper\Traits\ComparesByValue; use Datomatic\EnumHelper\Traits\EnumDescription; use Datomatic\EnumHelper\Traits\EnumLabel; use Datomatic\EnumHelper\Traits\EnumSerialization; @@ -17,8 +19,9 @@ * @method static string NO_RESPONSE() * @method static string NoResponse() */ -enum IntBackedEnum: int +enum IntBackedEnum: int implements Comparable { + use ComparesByValue; use EnumDescription; use EnumHelper; use EnumLabel; diff --git a/tests/Support/Enums/NumericStringBackedEnum.php b/tests/Support/Enums/NumericStringBackedEnum.php new file mode 100644 index 0000000..2ec6c5e --- /dev/null +++ b/tests/Support/Enums/NumericStringBackedEnum.php @@ -0,0 +1,17 @@ +