diff --git a/README.md b/README.md index 6e08fa1..55437b9 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,24 @@ class AddServiceKey extends Command } ``` +## Attribute extractor + +The package contains the `Attr` helper that receives a target class, object, function, and allows to retrieve all or one attribute. Better yet, you can directly call a method or retrieve an attribute property. + +```php +use Laragear\Meta\Attr;use Vendor\Package\Attributes\MyCustomAttribute; + +#[MyCustomAttribute(color: 'blue')] +class Car +{ + // +} + +$car = new Car; + +echo Attr::of($car)->get(MyCustomAttribute::class, 'color'); // "blue" +``` + ## Laravel Octane compatibility - There are no singletons using a stale application instance. diff --git a/phpunit.xml b/phpunit.xml index 66bae56..1af24d5 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,10 +1,5 @@ - - - - - - + tests diff --git a/src/Attr.php b/src/Attr.php new file mode 100644 index 0000000..c4e62ed --- /dev/null +++ b/src/Attr.php @@ -0,0 +1,158 @@ +reflection = $target; + } elseif (is_string($target)) { + if (function_exists($target)) { + $this->reflection = new ReflectionFunction($target); + } elseif (str_contains($target, '::')) { + $this->reflection = new ReflectionMethod($target); + } elseif (class_exists($target)) { + $this->reflection = new ReflectionClass($target); + } + } elseif (is_object($target)) { + if ($target instanceof Closure) { + $this->reflection = new ReflectionFunction($target); + } else { + $this->reflection = new ReflectionClass($target); + } + } elseif (is_array($target)) { + if (method_exists($target[0], $target[1])) { + $this->reflection = new ReflectionMethod($target[0], $target[1]); + } + + if (property_exists($target[0], $target[1])) { + $this->reflection = new ReflectionProperty($target[0], $target[1]); + } + } else { + throw new InvalidArgumentException( + 'The target must be a class, object, callable, or class-property array.' + ); + } + } + + /** + * Retrieve a collection of all Reflection Attributes. + * + * @param class-string|null $attribute + * @return \Illuminate\Support\Collection + */ + protected function collect(?string $attribute): Collection + { + return new Collection( + $this->reflection->getAttributes($attribute, ReflectionAttribute::IS_INSTANCEOF), // @phpstan-ignore-line + ); + } + + /** + * Retrieves all the instanced attributes of the target, optionally filtered by the given classes. + * + * @template TAttribute of object + * + * @param class-string|null $attribute + * @return ($attribute is empty ? \Illuminate\Support\Collection : \Illuminate\Support\Collection) + */ + public function all(?string $attribute = null): Collection + { + return $this->collect($attribute)->map(static function (ReflectionAttribute $attribute): object { + return $attribute->newInstance(); + }); + } + + /** + * Retrieves the first instanced attribute value from a class, method, or property. + * + * @template TAttribute of object + * + * @param class-string|null $attribute + * @return TAttribute|null + */ + public function first(?string $attribute = null): ?object + { + return $this->collect($attribute)->first()?->newInstance(); + } + + /** + * Executes a method from the first instanced attribute. + * + * @template TAttribute of object + * + * @param class-string $attribute + */ + public function call(string $attribute, string $method, mixed ...$arguments): mixed + { + return $this->first($attribute)?->{$method}(...$arguments); + } + + /** + * Retrieves a value from the first instanced attribute. + * + * @template TAttribute of object + * + * @param class-string $attribute + */ + public function get(string $attribute, string $property): mixed + { + return $this->first($attribute)?->$property; + } + + /** + * @inheritDoc + */ + public function count(): int + { + return $this->collect(null)->count(); + } + + /** + * Creates a new Attributes instance for the given target. + * + * @param \Closure|object|class-string|callable-string|array{0: string|object, 1: string} $target + */ + public static function of(mixed $target): static + { + return new static($target); + } +} diff --git a/src/Attributes/RegisterRule.php b/src/Attributes/RegisterRule.php new file mode 100644 index 0000000..d9dfbaf --- /dev/null +++ b/src/Attributes/RegisterRule.php @@ -0,0 +1,17 @@ + + */ + protected static array $cachedValidationRules = []; + /** * Extends a manager-like service. * @@ -53,7 +64,7 @@ protected function withValidationRule( string $rule, callable|string $callback, callable|string|null $message = null, - bool $implicit = false + bool $implicit = false, ): void { $this->callAfterResolving( 'validator', @@ -63,7 +74,50 @@ static function (Factory $validator, Application $app) use ($message, $callback, $implicit ? $validator->extendImplicit($rule, $callback, $message) : $validator->extend($rule, $callback, $message); - } + }, + ); + } + + /** + * Registers all Validation Rules found in a directory with a message from a translation prefix. + * + * @param class-string|class-string[] $classes + * @param string $keyPrefix If you register a translation key as "my-package", the validation + * rules will use "my-package::validation.{rule}". + */ + protected function withValidationRulesFrom(string|array $classes, string $keyPrefix): void + { + $this->callAfterResolving( + 'validator', + static function (Factory $validator) use ($classes, $keyPrefix): void { + foreach ((array) $classes as $class) { + if (! isset(static::$cachedValidationRules[$class])) { + static::$cachedValidationRules[$class] = []; + + foreach ((new ReflectionClass($class))->getMethods() as $method) { + /** @var \Laragear\Meta\Attributes\RegisterRule|null $attribute */ + if ( + $method->isPublic() && $method->isStatic() && + $attribute = Attr::of($method)->first(RegisterRule::class) + ) { + static::$cachedValidationRules[$class][$attribute->name] = [ + $method->getClosure(), + "$keyPrefix::validation.".( + $attribute->translationKey ?: Str::snake($method->getName()) + ), + $attribute->implicit, + ]; + } + } + } + + foreach (static::$cachedValidationRules[$class] as $name => [$callback, $message, $implicit]) { + $implicit + ? $validator->extendImplicit($name, $callback, $message) + : $validator->extend($name, $callback, $message); + } + } + }, ); } @@ -78,7 +132,7 @@ static function (Factory $validator, Application $app) use ($message, $callback, protected function withMiddleware(string $class): MiddlewareDeclaration { return new MiddlewareDeclaration( - $this->app->make(Router::class), $this->app->make(KernelContract::class), $class + $this->app->make(Router::class), $this->app->make(KernelContract::class), $class, ); } @@ -137,7 +191,6 @@ protected function withPolicy(string $model, string $policy): void * Schedule a Job or Command using a callback. * * @param callable(\Illuminate\Console\Scheduling\Schedule):mixed $callback - * @return void * * @see https://www.laravelpackage.com/06-artisan-commands/#scheduling-a-command-in-the-service-provider */ @@ -160,8 +213,57 @@ protected function withPublishableMigrations(array|string $directories, array|st $directories = (array) $directories; $this->publishesMigrations(array_fill_keys( - $directories, array_fill(0, count($directories), $this->app->databasePath('migrations')) + $directories, array_fill(0, count($directories), $this->app->databasePath('migrations')), ), $groups); } } + + /** + * Registers a simple Blade directive. + * + * @param array|string $name + * @param ($name is string ? callable : null) $handler + */ + protected function withBladeDirectives(string|array $name, ?callable $handler = null): void + { + $name = $handler ? [$name => $handler] : $name; + + $this->callAfterResolving( + BladeCompiler::class, + static function (BladeCompiler $blade) use ($name): void { + foreach ($name as $key => $handler) { + $blade->directive($key, $handler); + } + }, + ); + } + + /** + * Registers a directory of Blade components under a prefix. + */ + protected function withBladeComponents(string $path, string $prefix): void + { + $this->callAfterResolving( + BladeCompiler::class, + static function (BladeCompiler $blade) use ($path, $prefix): void { + $blade->componentNamespace($path, $prefix); + }, + ); + } + + /** + * Returns the cached validation rules. + */ + public static function cachedValidationRules(): array + { + return static::$cachedValidationRules; + } + + /** + * Flushes cached validation rules retrieved by reflection. + */ + public static function flushCachedValidationRules(): void + { + static::$cachedValidationRules = []; + } } diff --git a/stubs/components/stub-component.blade.php b/stubs/components/stub-component.blade.php new file mode 100644 index 0000000..b3d9bbc --- /dev/null +++ b/stubs/components/stub-component.blade.php @@ -0,0 +1 @@ +all(TestAttribute::class); + + static::assertCount(1, $result); + static::assertEquals('class', $result->first()->value); + } + + public function test_resolves_from_object_instance(): void + { + $attr = Attr::of(new StubClass()); + $result = $attr->all(TestAttribute::class); + + static::assertCount(1, $result); + static::assertEquals('class', $result->first()->value); + } + + public function test_resolves_from_method_string(): void + { + $target = StubClass::class.'::stubMethod'; + $attr = Attr::of($target); + $result = $attr->all(TestAttribute::class); + + static::assertCount(1, $result); + static::assertEquals('method', $result->first()->value); + } + + public function test_resolves_method_from_callable_array(): void + { + $attr = Attr::of([StubClass::class, 'stubMethod']); + $result = $attr->all(TestAttribute::class); + + static::assertCount(1, $result); + static::assertEquals('method', $result->first()->value); + } + + public function test_resolves_property_from_property_array(): void + { + $attr = Attr::of([StubClass::class, 'property']); + $result = $attr->all(TestAttribute::class); + + static::assertCount(1, $result); + static::assertEquals('property', $result->first()->value); + } + + public function test_resolves_from_function_string(): void + { + $attr = Attr::of('Tests\stubFunction'); + $result = $attr->all(TestAttribute::class); + + static::assertCount(1, $result); + static::assertEquals('function', $result->first()->value); + } + + public function test_resolves_from_closure(): void + { + $closure = #[TestAttribute('closure')] function () { + }; + + $attr = Attr::of($closure); + $result = $attr->all(TestAttribute::class); + + static::assertCount(1, $result); + static::assertEquals('closure', $result->first()->value); + } + + public function test_throws_exception_on_invalid_target(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The target must be a class, object, callable, or class-property array.'); + + Attr::of(12345)->all(); + } + + public function test_first_returns_single_attribute_instance(): void + { + $attr = Attr::of(StubClass::class); + $instance = $attr->first(TestAttribute::class); + + static::assertInstanceOf(TestAttribute::class, $instance); + static::assertEquals('class', $instance->value); + } + + public function test_throws_error_if_attribute_class_does_not_exist(): void + { + $attr = Attr::of(StubClass::class); + + $this->expectException(Error::class); + + $attr->first('NonExistentAttribute'); + } + + public function test_get_retrieves_property_from_attribute(): void + { + $attr = Attr::of(StubClass::class); + $value = $attr->get(TestAttribute::class, 'value'); + + static::assertEquals('class', $value); + } + + public function test_call_executes_method_on_attribute(): void + { + $attr = Attr::of(StubClass::class); + $value = $attr->call(TestAttribute::class, 'getValue'); + + static::assertEquals('class', $value); + } + + public function test_all_returns_collection_of_all_attributes_when_empty_params(): void + { + $attr = Attr::of(StubClass::class); + $results = $attr->all(); + + static::assertNotEmpty($results); + } + + public function test_counts(): void + { + $attr = Attr::of(StubClass::class); + + static::assertCount(1, $attr); + } +} + +#[Attribute] +class TestAttribute +{ + public function __construct(public string $value = 'default') + { + } + + public function getValue(): string + { + return $this->value; + } +} + +#[TestAttribute('class')] +class StubClass +{ + #[TestAttribute('property')] + protected $property = 'value'; + + #[TestAttribute('method')] + public function stubMethod() + { + } +} + +#[TestAttribute('function')] +function stubFunction() +{ +} diff --git a/tests/BootHelperTest.php b/tests/BootHelperTest.php index 7ce5fa2..1a592f1 100644 --- a/tests/BootHelperTest.php +++ b/tests/BootHelperTest.php @@ -9,6 +9,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Manager; use Illuminate\Support\ServiceProvider; +use Laragear\Meta\Attributes\RegisterRule; use Laragear\Meta\BootHelpers; use Orchestra\Testbench\Attributes\DefineEnvironment; use Orchestra\Testbench\Http\Kernel; @@ -23,6 +24,13 @@ protected function getPackageProviders($app): array return [TestServiceProvider::class]; } + protected function setUp(): void + { + TestServiceProvider::flushCachedValidationRules(); + + parent::setUp(); + } + public function test_with_driver(): void { static::assertSame('bar', $this->app->make('test-manager-foo')->driver('foo')); @@ -95,6 +103,51 @@ public function test_with_validation_rule_with_message_callback(): void static::assertSame('test-bar-message', $validator->getMessageBag()->first()); } + public function test_registers_validation_rules_from_class(): void + { + $factory = $this->app->make('validator'); + $extensions = $factory->make([], [])->extensions; + + static::assertArrayHasKey('good', $extensions); + static::assertArrayHasKey('good_implicit', $extensions); + static::assertArrayHasKey('good_key', $extensions); + static::assertArrayNotHasKey('do_not_register_protected', $extensions); + static::assertArrayNotHasKey('do_not_register_private', $extensions); + static::assertArrayNotHasKey('do_not_register_protected_static', $extensions); + + $validator = $factory->make(['pass' => 'passes'], ['pass' => 'good']); + + static::assertFalse($validator->fails()); + static::assertEmpty($validator->getMessageBag()->first()); + + $validator = $factory->make(['pass' => ''], ['pass' => 'good']); + + static::assertFalse($validator->fails()); + static::assertEmpty($validator->getMessageBag()->first()); + + $validator = $factory->make(['pass' => ''], ['pass' => 'good_implicit']); + + static::assertTrue($validator->fails()); + static::assertSame('testprefix::validation.validate_good_implicit', $validator->getMessageBag()->first()); + + $validator = $factory->make(['pass' => 'nope'], ['pass' => 'good_key']); + + static::assertTrue($validator->fails()); + static::assertSame('testprefix::validation.test-translation-key', $validator->getMessageBag()->first()); + } + + public function test_with_blade_directives(): void + { + static::assertArrayHasKey('test', $this->app->make('blade.compiler')->getCustomDirectives()); + } + + public function test_with_blade_components(): void + { + static::assertArrayHasKey( + 'test-components-prefix', $this->app->make('blade.compiler')->getClassComponentNamespaces() + ); + } + public function test_with_middleware_does_not_edit_middleware_in_router(): void { static::assertSame( @@ -167,6 +220,19 @@ public function test_with_publishable_migrations(): void ], $files); } } + + public function test_flushes_cached_validation_rules(): void + { + static::assertEmpty(TestServiceProvider::cachedValidationRules()); + + $this->app->make('validator'); + + static::assertNotEmpty(TestServiceProvider::cachedValidationRules()); + + TestServiceProvider::flushCachedValidationRules(); + + static::assertEmpty(TestServiceProvider::cachedValidationRules()); + } } class TestServiceProvider extends ServiceProvider @@ -208,6 +274,8 @@ public function boot(): void $this->withValidationRule('bar', fn ($key, $value) => $value === 'test_bar', 'test-bar-message', true); $this->withValidationRule('baz', fn ($key, $value) => $value === 'test_baz', fn () => 'test-baz-message', true); + $this->withValidationRulesFrom(TestValidationClass::class, 'testprefix'); + $this->withMiddleware(\Tests\Stubs\TestMiddleware::class); $this->withListener('test-event', \Tests\Stubs\TestEventListener::class); @@ -222,6 +290,11 @@ public function boot(): void }); $this->withPublishableMigrations(__DIR__.'/../stubs/migrations'); + + $this->withValidationRulesFrom(TestValidationClass::class, 'testprefix'); + + $this->withBladeDirectives(['test' => fn () => 'true']); + $this->withBladeComponents(__DIR__.'/../stubs/components', 'test-components-prefix'); } } @@ -233,3 +306,58 @@ public function subscribe(Dispatcher $events): void $events->listen('test-event-bar', \Tests\Stubs\TestEventBarListener::class); } } + +class TestValidationClass +{ + public function foo() + { + return true; + } + + #[RegisterRule('do_not_register_protected')] + protected function bar() + { + return true; + } + + #[RegisterRule('do_not_register_private')] + private function quz() + { + return true; + } + + public static function baz() + { + return true; + } + + #[RegisterRule('do_not_register_protected_static')] + protected static function validateStaticProtected() + { + return true; + } + + #[RegisterRule('do_not_register_protected_static')] + private static function validateStaticPrivate() + { + return true; + } + + #[RegisterRule('good')] + public static function validateGood(string $attribute, mixed $value) + { + return $value === 'passes'; + } + + #[RegisterRule('good_implicit', implicit: true)] + public static function validateGoodImplicit(string $attribute, mixed $value) + { + return $value === 'passes'; + } + + #[RegisterRule('good_key', translationKey: 'test-translation-key')] + public static function validateGoodTranslated(string $attribute, mixed $value) + { + return $value === 'passes'; + } +}