diff --git a/README.md b/README.md index 15cee4a..9da179f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A collection of enum helpers for PHP. - [`Values`](#values) - [`Options`](#options) - [`From`](#from) +- [`Metadata`](#metadata) 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. @@ -211,7 +212,7 @@ enum TaskStatus: int enum Role { use From; - + case ADMINISTRATOR; case SUBSCRIBER; case GUEST; @@ -246,6 +247,125 @@ Role::tryFromName('GUEST'); // Role::GUEST Role::tryFromName('TESTER'); // null ``` +### Metadata + +This trait lets you add metadata to enum cases. + +#### Apply the trait on your enum +```php +use ArchTech\Enums\Metadata; +use ArchTech\Enums\Meta\Meta; +use App\Enums\MetaProperties\{Description, Color}; + +#[Meta(Description::class, Color::class)] +enum TaskStatus: int +{ + use Metadata; + + #[Description('Incomplete Task')] #[Color('red')] + case INCOMPLETE = 0; + + #[Description('Completed Task')] #[Color('green')] + case COMPLETED = 1; + + #[Description('Canceled Task')] #[Color('gray')] + case CANCELED = 2; +} +``` + +Explanation: +- `Description` and `Color` are userland class attributes — meta properties +- The `#[Meta]` call enables those two meta properties on the enum +- Each case must have a defined description & color (in this example) + +#### Access the metadata + +```php +TaskStatus::INCOMPLETE->description(); // 'Incomplete Task' +TaskStatus::COMPLETED->color(); // 'green' +``` + +#### Creating meta properties + +Each meta property (= attribute used on a case) needs to exist as a class. +```php +#[Attribute] +class Color extends MetaProperty {} + +#[Attribute] +class Description extends MetaProperty {} +``` + +Inside the class, you can customize a few things. For instance, you may want to use a different method name than the one derived from the class name (`Description` becomes `description()` by default). To do that, override the `method()` method on the meta property: +```php +#[Attribute] +class Description extends MetaProperty +{ + public static function method(): string + { + return 'note'; + } +} +``` + +With the code above, the description of a case will be accessible as `TaskStatus::INCOMPLETE->note()`. + +Another thing you can customize is the passed value. For instance, to wrap a color name like `text-{$color}-500`, you'd add the following `transform()` method: +```php +#[Attribute] +class Color extends MetaProperty +{ + protected function transform(mixed $value): mixed + { + return "text-{$color}-500"; + } +} +``` + +And now the returned color will be correctly transformed: +```php +TaskStatus::COMPLETED->color(); // 'text-green-500' +``` + +#### Use the `fromMeta()` method +```php +TaskStatus::fromMeta(Color::make('green')); // TaskStatus::COMPLETED +TaskStatus::fromMeta(Color::make('blue')); // Error: ValueError +``` + +#### Use the `tryFromMeta()` method +```php +TaskStatus::tryFromMeta(Color::make('green')); // TaskStatus::COMPLETED +TaskStatus::tryFromMeta(Color::make('blue')); // null +``` + +#### Recommendation: use annotations and traits + +If you'd like to add better IDE support for the metadata getter methods, you can use `@method` annotations: + +```php +/** + * @method string description() + * @method string color() + */ +#[Meta(Description::class, Color::class)] +enum TaskStatus: int +{ + use Metadata; + + #[Description('Incomplete Task')] #[Color('red')] + case INCOMPLETE = 0; + + #[Description('Completed Task')] #[Color('green')] + case COMPLETED = 1; + + #[Description('Canceled Task')] #[Color('gray')] + case CANCELED = 2; +} +``` + +And if you're using the same meta property in multiple enums, you can create a dedicated trait that includes this `@method` annotation. + ## Development Run all checks locally: diff --git a/phpstan.neon b/phpstan.neon index 5b8dfd1..84e6bde 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -11,6 +11,10 @@ parameters: - Illuminate\Routing\Route ignoreErrors: + - '#Access to an undefined static property static\(ArchTech\\Enums\\Meta\\MetaProperty\)\:\:\$method#' + - '#invalid typehint type ArchTech\\Enums\\Metadata#' + - '#invalid typehint type Enum#' + - '#on an unknown class Enum#' # - # message: '#Offset (.*?) does not exist on array\|null#' # paths: diff --git a/src/Meta/Meta.php b/src/Meta/Meta.php new file mode 100644 index 0000000..ce88f76 --- /dev/null +++ b/src/Meta/Meta.php @@ -0,0 +1,25 @@ +metaProperties = $metaProperties; + } +} diff --git a/src/Meta/MetaProperty.php b/src/Meta/MetaProperty.php new file mode 100644 index 0000000..e4fb44e --- /dev/null +++ b/src/Meta/MetaProperty.php @@ -0,0 +1,38 @@ +value = $this->transform($value); + } + + public static function make(mixed $value): static + { + return new static($value); + } + + protected function transform(mixed $value): mixed + { + // Feel free to override this to transform the value during instantiation + + return $value; + } + + /** Get the name of the accessor method */ + public static function method(): string + { + if (property_exists(static::class, 'method')) { + return static::${'method'}; + } + + $parts = explode('\\', static::class); + + return lcfirst(end($parts)); + } +} diff --git a/src/Meta/Reflection.php b/src/Meta/Reflection.php new file mode 100644 index 0000000..02aa405 --- /dev/null +++ b/src/Meta/Reflection.php @@ -0,0 +1,63 @@ +> + */ + public static function metaProperties(mixed $enum): array + { + $reflection = new ReflectionObject($enum); + + // Attributes of the `Meta` type + $attributes = array_values(array_filter( + $reflection->getAttributes(), + fn (ReflectionAttribute $attr) => $attr->getName() === Meta::class, + )); + + if ($attributes) { + return $attributes[0]->newInstance()->metaProperties; + } + + return []; + } + + /** + * Get the value of a meta property on the provided enum. + * + * @param \Enum $enum + */ + public static function metaValue(string $metaProperty, mixed $enum): mixed + { + // Find the case used by $enum + $reflection = new ReflectionEnumUnitCase($enum::class, $enum->name); + $attributes = $reflection->getAttributes(); + + // Instantiate each ReflectionAttribute + /** @var MetaProperty[] $properties */ + $properties = array_map(fn (ReflectionAttribute $attr) => $attr->newInstance(), $attributes); + + // Find the property that matches the $metaProperty class + $properties = array_filter($properties, fn (MetaProperty $property) => $property::class === $metaProperty); + + // Reset array index + $properties = array_values($properties); + + if ($properties) { + return $properties[0]->value; + } + + return null; + } +} diff --git a/src/Metadata.php b/src/Metadata.php new file mode 100644 index 0000000..9232e94 --- /dev/null +++ b/src/Metadata.php @@ -0,0 +1,45 @@ +value) { + return $case; + } + } + + return null; + } + + /** Get the first case with this meta property value. */ + public static function fromMeta(MetaProperty $metaProperty): static + { + return static::tryFromMeta($metaProperty) ?? throw new ValueError( + 'Enum ' . static::class . ' does not have a case with a meta property "' . + $metaProperty::class . '" of value "' . $metaProperty->value . '"' + ); + } + + public function __call(string $property, $arguments): mixed + { + $metaProperties = Meta\Reflection::metaProperties($this); + + foreach ($metaProperties as $metaProperty) { + if ($metaProperty::method() === $property) { + return Meta\Reflection::metaValue($metaProperty, $this); + } + } + + return null; + } +} diff --git a/tests/Pest.php b/tests/Pest.php index a80cbb7..47f9676 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,67 +1,63 @@ in('Pest'); -/* -|-------------------------------------------------------------------------- -| Expectations -|-------------------------------------------------------------------------- -| -| When you're writing tests, you often need to check that values meet certain conditions. The -| "expect()" function gives you access to a set of "expectations" methods that you can use -| to assert different things. Of course, you may extend the Expectation API at any time. -| -*/ +#[Attribute] +class Color extends MetaProperty {} -expect()->extend('toBeOne', function () { - return $this->toBe(1); -}); - -/* -|-------------------------------------------------------------------------- -| Functions -|-------------------------------------------------------------------------- -| -| While Pest is very powerful out-of-the-box, you may have some testing code specific to your -| project that you don't want to repeat in every file. Here you can also expose helpers as -| global functions to help you to reduce the number of lines of code in your test files. -| -*/ - -function something() +#[Attribute] +class Desc extends MetaProperty { - // .. + public static function method(): string + { + return 'description'; + } } +#[Attribute] +class Instructions extends MetaProperty +{ + public static string $method = 'help'; + + protected function transform(mixed $value): mixed + { + return 'Help: ' . $value; + } +} + +/** + * @method string description() + * @method string color() + */ +#[Meta(Color::class, Desc::class)] // variadic syntax enum Status: int { - use InvokableCases, Options, Names, Values, From; + use InvokableCases, Options, Names, Values, From, Metadata; + #[Color('orange')] #[Desc('Incomplete task')] case PENDING = 0; + + #[Color('green')] #[Desc('Completed task')] + #[Instructions('Illegal meta property — not enabled on the enum')] case DONE = 1; } +#[Meta([Color::class, Desc::class, Instructions::class])] // array enum Role { - use InvokableCases, Options, Names, Values, From; + use InvokableCases, Options, Names, Values, From, Metadata; + #[Color('indigo')] + #[Desc('Administrator')] + #[Instructions('Administrators can manage the entire account')] case ADMIN; + + #[Color('gray')] + #[Desc('Read-only guest')] + #[Instructions('Guest users can only view the existing records')] case GUEST; } diff --git a/tests/Pest/MetadataTest.php b/tests/Pest/MetadataTest.php new file mode 100644 index 0000000..d15bbb6 --- /dev/null +++ b/tests/Pest/MetadataTest.php @@ -0,0 +1,63 @@ +color())->toBe('indigo'); + expect(Role::GUEST->color())->toBe('gray'); + + expect(Role::ADMIN->description())->toBe('Administrator'); + expect(Role::GUEST->description())->toBe('Read-only guest'); + + expect(Role::ADMIN->help())->toBe('Help: Administrators can manage the entire account'); + expect(Role::GUEST->help())->toBe('Help: Guest users can only view the existing records'); +}); + +test('backed enums can have metadata on cases', function () { + expect(Status::DONE->color())->toBe('green'); + expect(Status::PENDING->color())->toBe('orange'); + + expect(Status::PENDING->description())->toBe('Incomplete task'); + expect(Status::DONE->description())->toBe('Completed task'); +}); + +test('meta properties must be enabled on the enum to be usable on cases', function () { + expect(Role::ADMIN->help())->not()->toBeNull(); // enabled + expect(Status::DONE->help())->toBeNull(); // not enabled +}); + +test('meta properties can transform arguments', function () { + expect( + Instructions::make('Administrators can manage the entire account')->value + )->toStartWith('Help: '); +}); + +test('meta properties can customize the method name using a method', function () { + expect(Desc::method())->toBe('description'); + expect(Status::DONE->desc())->toBeNull(); + expect(Status::DONE->description())->not()->toBeNull(); +}); + +test('meta properties can customize the method name using a property', function () { + expect(Instructions::method())->toBe('help'); + expect(Role::ADMIN->instructions())->toBeNull(); + expect(Role::ADMIN->help())->not()->toBeNull(); +}); + +test('enums can be instantiated from metadata', function () { + expect(Role::fromMeta(Color::make('indigo')))->toBe(Role::ADMIN); + expect(Role::fromMeta(Color::make('gray')))->toBe(Role::GUEST); + + expect(Status::fromMeta(Desc::make('Incomplete task')))->toBe(Status::PENDING); + expect(Status::fromMeta(Desc::make('Completed task')))->toBe(Status::DONE); +}); + +test('enums can be instantiated from metadata using tryFromMeta') + ->expect(Role::tryFromMeta(Color::make('indigo'))) + ->toBe(Role::ADMIN); + +test('fromMeta throws an exception when the enum cannot be instantiated', function () { + Role::fromMeta(Color::make('foobar')); +})->throws(ValueError::class, 'Enum Role does not have a case with a meta property "Color" of value "foobar"'); + +test('tryFromMeta silently fails when the enum cannot be instantiated') + ->expect(Role::tryFromMeta(Color::make('foobar'))) + ->toBe(null);