diff --git a/README.md b/README.md index 36e907d7..1accaa48 100644 --- a/README.md +++ b/README.md @@ -12,21 +12,15 @@ _Provides additional, opinionated features to the [PHP 8.1+ native enums](https: as specific integrations with frameworks and libraries._ ```php +#[ReadableEnum(prefix: 'suit.')] enum Suit: string implements ReadableEnumInterface { use ReadableEnumTrait; - #[EnumCase('suit.hearts')] - case Hearts = 'H'; - - #[EnumCase('suit.diamonds')] - case Diamonds = 'D'; - - #[EnumCase('suit.clubs')] - case Clubs = 'C'; - - #[EnumCase('suit.spades')] - case Spades = 'S'; + case Hearts = '♥︎'; + case Diamonds = '♦︎'; + case Clubs = '♣︎'; + case Spades = '︎♠︎'; } ``` @@ -85,20 +79,20 @@ enum Suit: string implements ReadableEnumInterface use ReadableEnumTrait; #[EnumCase('suit.hearts')] - case Hearts = 'H'; + case Hearts = '♥︎'; #[EnumCase('suit.diamonds')] - case Diamonds = 'D'; + case Diamonds = '♦︎'; #[EnumCase('suit.clubs')] - case Clubs = 'C'; + case Clubs = '♣︎'; #[EnumCase('suit.spades')] - case Spades = 'S'; + case Spades = '︎♠︎'; } ``` -The following snippet shows how to get the human readable value of an enum: +The following snippet shows how to get the human-readable value of an enum: ```php Suit::Hearts->getReadable(); // returns 'suit.hearts' @@ -126,6 +120,46 @@ $enum = Suit::Hearts; $translator->trans($enum->getReadable(), locale: 'fr'); // returns 'Coeurs' ``` +### Configure suffix/prefix & default value + +As a shorcut, you can also use the [`ReadableEnum`](src/Attribute/ReadableEnum.php) attribute to define the +common `suffix` and `prefix` to use, as well as defaulting on the enum case name or value, if not provided explicitly: + +```php +#[ReadableEnum(prefix: 'suit.')] +enum Suit: string implements ReadableEnumInterface +{ + use ReadableEnumTrait; + + #[EnumCase('hearts︎')] + case Hearts = '♥︎'; + case Diamonds = '♦︎'; + case Clubs = '♣︎'; + case Spades = '︎♠︎'; +} + +Suit::Hearts->getReadable(); // returns 'suit.hearts' +Suit::Clubs->getReadable(); // returns 'suit.Clubs' +``` + +using the case value (only for string backed enums): + +```php +#[ReadableEnum(prefix: 'suit.', useValueAsDefault: true)] +enum Suit: string implements ReadableEnumInterface +{ + use ReadableEnumTrait; + + case Hearts = 'hearts'; + case Diamonds = 'diamonds'; + case Clubs = 'clubs︎'; + case Spades = '︎spades'; +} + +Suit::Hearts->getReadable(); // returns 'suit.hearts' +Suit::Clubs->getReadable(); // returns 'suit.clubs' +``` + ## Extra values The `EnumCase` attributes also provides you a way to configure some extra attributes on your cases and access these easily with the `ExtrasTrait`: diff --git a/src/Attribute/ReadableEnum.php b/src/Attribute/ReadableEnum.php new file mode 100644 index 00000000..fb1ddfc1 --- /dev/null +++ b/src/Attribute/ReadableEnum.php @@ -0,0 +1,27 @@ + + */ + +namespace Elao\Enum\Attribute; + +/** + * Autoconfigure a readable enum cases' labels, using the name or value + allow to configure a prefix and/or suffix for the key. + */ +#[\Attribute(\Attribute::TARGET_CLASS)] +class ReadableEnum +{ + public function __construct( + public readonly ?string $prefix = null, + public readonly ?string $suffix = null, + public readonly bool $useValueAsDefault = false + ) { + } +} diff --git a/src/ReadableEnumTrait.php b/src/ReadableEnumTrait.php index 56a57bd9..eac646b0 100644 --- a/src/ReadableEnumTrait.php +++ b/src/ReadableEnumTrait.php @@ -13,6 +13,7 @@ namespace Elao\Enum; use Elao\Enum\Attribute\EnumCase; +use Elao\Enum\Attribute\ReadableEnum; use Elao\Enum\Exception\LogicException; use Elao\Enum\Exception\NameException; @@ -69,42 +70,60 @@ public function getReadable(): string /** * {@inheritdoc} * - * Implements readables using PHP 8 attributes, expecting an {@link EnumCase} on each case, with a label. + * Implements readables using PHP 8 attributes, expecting an {@link EnumCase} on each case, with a label, + * or uses the value as label if {@link ReadableEnum} is used on the class. */ public static function readables(): iterable { static $readables; if (!isset($readables)) { + $readableEnumAttribute = static::getReadableEnumAttribute(); $readables = new \SplObjectStorage(); + $r = new \ReflectionEnum(static::class); + + if (($readableEnumAttribute?->useValueAsDefault ?? false) && 'string' !== (string) $r->getBackingType()) { + throw new LogicException(sprintf( + 'Cannot use "useValueAsDefault" with "#[%s]" attribute on enum "%s" as it\'s not a string backed enum.', + ReadableEnum::class, + static::class, + )); + } /** @var static $case */ foreach (static::cases() as $case) { $attribute = $case->getEnumCaseAttribute(); - if (null === $attribute) { + if (null === $attribute && null === $readableEnumAttribute) { throw new LogicException(sprintf( - 'enum "%s" using the "%s" trait must define a "%s" attribute on every cases. Case "%s" is missing one. Alternatively, override the "%s()" method', + 'enum "%s" using the "%s" trait must define a "%s" attribute on every cases. Case "%s" is missing one. Alternatively, override the "%s()" method, or use the "%s" attribute on the enum class to use the value as default.', static::class, ReadableEnumTrait::class, EnumCase::class, $case->name, __METHOD__, + ReadableEnum::class, )); } - if (null === $attribute->label) { + if (null === $attribute?->label && null === $readableEnumAttribute) { throw new LogicException(sprintf( - 'enum "%s" using the "%s" trait must define a label using the "%s" attribute on every cases. Case "%s" is missing a label. Alternatively, override the "%s()" method', + 'enum "%s" using the "%s" trait must define a label using the "%s" attribute on every cases. Case "%s" is missing a label. Alternatively, override the "%s()" method, or use the "#[%s]" attribute on the enum class to use the value as default.', static::class, ReadableEnumTrait::class, EnumCase::class, $case->name, __METHOD__, + ReadableEnum::class, )); } - $readables[$case] = $attribute->label; + $readables[$case] = sprintf( + '%s%s%s', + $readableEnumAttribute?->prefix, + $attribute?->label ?? ($readableEnumAttribute->useValueAsDefault ? $case->value : $case->name), + $readableEnumAttribute?->suffix, + ); } } @@ -114,6 +133,20 @@ public static function readables(): iterable } } + /** + * @internal + */ + private static function getReadableEnumAttribute(): ?ReadableEnum + { + $r = new \ReflectionEnum(static::class); + + if (null === $rAttr = $r->getAttributes(ReadableEnum::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null) { + return null; + } + + return $rAttr->newInstance(); + } + /** * As objects, {@link https://wiki.php.net/rfc/enumerations#splobjectstorage_and_weakmaps Enum cases cannot be used as keys in an array}. * However, they can be used as keys in a SplObjectStorage or WeakMap. diff --git a/tests/Unit/ReadableEnumAttributeTest.php b/tests/Unit/ReadableEnumAttributeTest.php new file mode 100644 index 00000000..6c573ab7 --- /dev/null +++ b/tests/Unit/ReadableEnumAttributeTest.php @@ -0,0 +1,136 @@ + + */ + +namespace Elao\Enum\Tests\Unit; + +use Elao\Enum\Attribute\EnumCase; +use Elao\Enum\Attribute\ReadableEnum; +use Elao\Enum\Exception\LogicException; +use Elao\Enum\ReadableEnumInterface; +use Elao\Enum\ReadableEnumTrait; +use PHPUnit\Framework\TestCase; + +class ReadableEnumAttributeTest extends TestCase +{ + public function testReadableEnumAttribute(): void + { + self::assertSame( + 'suit.Clubs.label', + ReadableAttributeSuit::readableForValue(ReadableAttributeSuit::Clubs->value), + 'uses the name as default, with suffix and prefix', + ); + self::assertSame( + 'suit.hearts.label', + ReadableAttributeSuit::readableForValue(ReadableAttributeSuit::Hearts->value), + 'uses the explicit label value, with suffix and prefix', + ); + } + + public function testReadableEnumAttributeWithoutSuffixPrefix(): void + { + self::assertSame( + 'Clubs', + ReadableAttributeSuitWithoutSuffixPrefix::readableForValue(ReadableAttributeSuitWithoutSuffixPrefix::Clubs->value), + 'uses the name as default, without any suffix or prefix', + ); + self::assertSame( + 'hearts', + ReadableAttributeSuitWithoutSuffixPrefix::readableForValue(ReadableAttributeSuitWithoutSuffixPrefix::Hearts->value), + 'uses the explicit label value, without any suffix or prefix', + ); + } + + public function testReadableEnumAttributeWithValueAsDefault(): void + { + self::assertSame( + 'suit.♣︎.label', + ReadableSuitAttributeWithValueAsDefault::readableForValue(ReadableSuitAttributeWithValueAsDefault::Clubs->value), + 'uses the value as default, with suffix and prefix', + ); + self::assertSame( + 'suit.hearts.label', + ReadableSuitAttributeWithValueAsDefault::readableForValue(ReadableSuitAttributeWithValueAsDefault::Hearts->value), + 'uses the explicit label value, with suffix and prefix', + ); + } + + public function testReadableEnumAttributeWithValueAsDefaultThrowsOnPureEnum(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot use "useValueAsDefault" with "#[Elao\Enum\Attribute\ReadableEnum]" attribute on enum "Elao\Enum\Tests\Unit\PureEnumWithReadableAttribute" as it\'s not a string backed enum.'); + + PureEnumWithReadableAttribute::Foo->getReadable(); + } + + public function testReadableEnumAttributeWithValueAsDefaultThrowsOnIntBackedEnum(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot use "useValueAsDefault" with "#[Elao\Enum\Attribute\ReadableEnum]" attribute on enum "Elao\Enum\Tests\Unit\IntBackedEnumWithReadableAttribute" as it\'s not a string backed enum.'); + + IntBackedEnumWithReadableAttribute::Foo->getReadable(); + } +} + +#[ReadableEnum(prefix: 'suit.', suffix: '.label')] +enum ReadableAttributeSuit: string implements ReadableEnumInterface +{ + use ReadableEnumTrait; + + #[EnumCase('hearts')] + case Hearts = '♥︎'; + + case Diamonds = '♦︎'; + case Clubs = '♣︎'; + case Spades = '︎♠︎'; +} + +#[ReadableEnum] +enum ReadableAttributeSuitWithoutSuffixPrefix: string implements ReadableEnumInterface +{ + use ReadableEnumTrait; + + #[EnumCase('hearts')] + case Hearts = '♥︎'; + + case Diamonds = '♦︎'; + case Clubs = '♣︎'; + case Spades = '︎♠︎'; +} + +#[ReadableEnum(prefix: 'suit.', suffix: '.label', useValueAsDefault: true)] +enum ReadableSuitAttributeWithValueAsDefault: string implements ReadableEnumInterface +{ + use ReadableEnumTrait; + + #[EnumCase('hearts')] + case Hearts = '♥︎'; + + case Diamonds = '♦︎'; + case Clubs = '♣︎'; + case Spades = '︎♠︎'; +} + +#[ReadableEnum(useValueAsDefault: true)] +enum PureEnumWithReadableAttribute implements ReadableEnumInterface +{ + use ReadableEnumTrait; + + case Foo; +} + +#[ReadableEnum(useValueAsDefault: true)] +enum IntBackedEnumWithReadableAttribute: int implements ReadableEnumInterface +{ + use ReadableEnumTrait; + + case Foo = 1; +}