diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f7cc8f..fce533ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ExpressionValues` to enable resolution of route parameter values via expressions by @HypeMC in https://github.com/sofascore/purgatory-bundle/pull/112 +- Ability to pass a static method callable as a `DynamicValues` provider by @HypeMC + in https://github.com/sofascore/purgatory-bundle/pull/137 ### Changed @@ -18,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 in https://github.com/sofascore/purgatory-bundle/pull/130 - Method `AbstractValues::getValues()` is now `protected` by @Brajk19 in https://github.com/sofascore/purgatory-bundle/pull/130 +- Rename first constructor argument in `DynamicValues` to `$provider` by @HypeMC + in https://github.com/sofascore/purgatory-bundle/pull/137 - Rename second constructor argument in `DynamicValues` to `$propertyPath` by @Brajk19 in https://github.com/sofascore/purgatory-bundle/pull/130 diff --git a/docs/complex-route-params.md b/docs/complex-route-params.md index da7fcff9..c4eb6478 100644 --- a/docs/complex-route-params.md +++ b/docs/complex-route-params.md @@ -140,10 +140,10 @@ public function listAction(Author $author) You can also add [custom Expression Language functions](custom-expression-language-functions.md) to extend the available expression syntax. -### Using Values Provided by a Service +### Using Values Provided by a Service or Static Method -As an alternative to expressions, route parameter values can be provided dynamically by a service. This is particularly -useful when you need route parameters that depend on context or runtime information: +As an alternative to expressions, route parameter values can be provided dynamically by a service or a static method. +This is particularly useful when you need route parameters that depend on context or runtime information: ```php use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\DynamicValues; @@ -155,12 +155,11 @@ public function listAction() } ``` -By default, the entire entity being purged is passed to the route parameter service. -If your service only needs a specific part of the entity, you can limit what is passed by providing a second argument to -`DynamicValues`. +By default, the entire entity being purged is passed to the route parameter provider. If your provider only needs a +specific part of the entity, you can limit what is passed by providing a second argument to `DynamicValues`. This argument is a **Symfony PropertyAccess property path** and will be resolved against the entity before being passed -to the service: +to the provider: ```php use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\DynamicValues; @@ -172,7 +171,7 @@ public function listAction() } ``` -To make the service available for resolving route parameter values, ensure it is tagged correctly in the service +To make a service available for resolving route parameter values, ensure it is tagged correctly in the service configuration: ```yaml @@ -197,4 +196,24 @@ class MyService } ``` +You can also reference a static method directly instead of a service: + +```php +use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\DynamicValues; + +#[Route('/posts/{type}', name: 'posts_list', methods: 'GET')] +#[PurgeOn(Post::class, routeParams: ['type' => new DynamicValues([MyClass::class, 'getValue'])])] +public function listAction() +{ +} + +final class MyClass +{ + public static function getValue(Post $post) + { + // Return the desired value for the route parameter + } +} +``` + [0]: https://github.com/sofascore/purgatory-bundle/blob/2.x/src/Attribute/AsRouteParamService.php diff --git a/docs/purge-subscriptions-using-yaml.md b/docs/purge-subscriptions-using-yaml.md index 4beeda76..64203f31 100644 --- a/docs/purge-subscriptions-using-yaml.md +++ b/docs/purge-subscriptions-using-yaml.md @@ -75,4 +75,16 @@ posts_list: class: App\Entity\Post route_params: type: !dynamic [ my_service, prop ] + +# Using values provided by a static method +posts_list: + class: App\Entity\Post + route_params: + type: !dynamic 'App\\MyClass::getValue' + +# Using values provided by a static method with a property path +posts_list: + class: App\Entity\Post + route_params: + type: !dynamic [ 'App\\MyClass::getValue', prop ] ``` diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c84157d6..6d361a01 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,11 @@ parameters: ignoreErrors: + - + message: '#^Method Sofascore\\PurgatoryBundle\\Attribute\\RouteParamValue\\DynamicValues\:\:normalizeProvider\(\) should return \(array\&callable\(\)\: mixed\)\|string but returns non\-empty\-array\&callable\(\)\: mixed\.$#' + identifier: return.type + count: 1 + path: src/Attribute/RouteParamValue/DynamicValues.php + - message: '#^Call to function is_a\(\) with arguments class\-string\, ''BackedEnum'' and true will always evaluate to true\.$#' identifier: function.alreadyNarrowedType @@ -25,31 +31,31 @@ parameters: path: src/Cache/PropertyResolver/AssociationResolver.php - - message: '#^Parameter \#1 \$alias of class Sofascore\\PurgatoryBundle\\Attribute\\RouteParamValue\\DynamicValues constructor expects string, bool\|float\|int\|string\|Symfony\\Component\\Yaml\\Tag\\TaggedValue given\.$#' + message: '#^Parameter \#1 \$callback of function array_map expects \(callable\(bool\|float\|int\|string\|Symfony\\Component\\Yaml\\Tag\\TaggedValue\)\: mixed\)\|null, Closure\(non\-empty\-list\\|string\|Symfony\\Component\\Yaml\\Tag\\TaggedValue\)\: \(non\-empty\-list\\|Sofascore\\PurgatoryBundle\\Attribute\\RouteParamValue\\ValuesInterface\|string\) given\.$#' identifier: argument.type count: 1 path: src/Cache/RouteMetadata/YamlMetadataProvider.php - - message: '#^Parameter \#1 \$callback of function array_map expects \(callable\(bool\|float\|int\|string\|Symfony\\Component\\Yaml\\Tag\\TaggedValue\)\: mixed\)\|null, Closure\(non\-empty\-list\\|string\|Symfony\\Component\\Yaml\\Tag\\TaggedValue\)\: \(non\-empty\-list\\|Sofascore\\PurgatoryBundle\\Attribute\\RouteParamValue\\ValuesInterface\|string\) given\.$#' + message: '#^Parameter \#1 \$enum of class Sofascore\\PurgatoryBundle\\Attribute\\RouteParamValue\\EnumValues constructor expects class\-string\, bool\|float\|int\|list\\|string given\.$#' identifier: argument.type count: 1 path: src/Cache/RouteMetadata/YamlMetadataProvider.php - - message: '#^Parameter \#1 \$enum of class Sofascore\\PurgatoryBundle\\Attribute\\RouteParamValue\\EnumValues constructor expects class\-string\, bool\|float\|int\|list\\|string given\.$#' + message: '#^Parameter \#1 \$expression of class Sofascore\\PurgatoryBundle\\Attribute\\RouteParamValue\\ExpressionValues constructor expects string\|Symfony\\Component\\ExpressionLanguage\\Expression, bool\|float\|int\|list\\|string given\.$#' identifier: argument.type count: 1 path: src/Cache/RouteMetadata/YamlMetadataProvider.php - - message: '#^Parameter \#1 \$expression of class Sofascore\\PurgatoryBundle\\Attribute\\RouteParamValue\\ExpressionValues constructor expects string\|Symfony\\Component\\ExpressionLanguage\\Expression, bool\|float\|int\|list\\|string given\.$#' + message: '#^Parameter \#1 \$property of class Sofascore\\PurgatoryBundle\\Attribute\\RouteParamValue\\PropertyValues constructor expects string, bool\|float\|int\|string\|Symfony\\Component\\Yaml\\Tag\\TaggedValue given\.$#' identifier: argument.type count: 1 path: src/Cache/RouteMetadata/YamlMetadataProvider.php - - message: '#^Parameter \#1 \$property of class Sofascore\\PurgatoryBundle\\Attribute\\RouteParamValue\\PropertyValues constructor expects string, bool\|float\|int\|string\|Symfony\\Component\\Yaml\\Tag\\TaggedValue given\.$#' + message: '#^Parameter \#1 \$provider of class Sofascore\\PurgatoryBundle\\Attribute\\RouteParamValue\\DynamicValues constructor expects \(array\&callable\(\)\: mixed\)\|string, bool\|float\|int\|string\|Symfony\\Component\\Yaml\\Tag\\TaggedValue given\.$#' identifier: argument.type count: 1 path: src/Cache/RouteMetadata/YamlMetadataProvider.php diff --git a/src/Attribute/RouteParamValue/DynamicValues.php b/src/Attribute/RouteParamValue/DynamicValues.php index 47d04980..ed7dca19 100644 --- a/src/Attribute/RouteParamValue/DynamicValues.php +++ b/src/Attribute/RouteParamValue/DynamicValues.php @@ -7,24 +7,55 @@ final class DynamicValues extends AbstractValues { /** - * @param string $alias Alias defined in {@see AsRouteParamService} attribute + * @var string|callable-array + */ + public readonly string|array $provider; + + /** + * @param string|callable-array $provider Alias defined in {@see AsRouteParamService} attribute or static method callable */ public function __construct( - public readonly string $alias, + string|array $provider, public readonly ?string $propertyPath = null, ) { + $this->provider = self::normalizeProvider($provider); } /** - * @return non-empty-list + * @return array|null> */ protected function getValues(): array { - return [$this->alias, $this->propertyPath]; + return [$this->provider, $this->propertyPath]; } public static function type(): string { return 'dynamic'; } + + /** + * @param string|callable-array $provider + * + * @return string|callable-array + */ + private static function normalizeProvider(string|array $provider): string|array + { + if (\is_string($provider)) { + if (!str_contains($provider, '::')) { + return $provider; + } + $provider = explode('::', $provider); + } + + if (!\is_callable($provider)) { + throw new \ValueError('Only static method callables are supported.'); + } + + if (!\is_string($provider[0])) { + throw new \ValueError('Object callables are not supported.'); + } + + return $provider; + } } diff --git a/src/Cache/PropertyResolver/InverseValuesBuilder/DynamicInverseValuesBuilder.php b/src/Cache/PropertyResolver/InverseValuesBuilder/DynamicInverseValuesBuilder.php index b2e43385..1912587c 100644 --- a/src/Cache/PropertyResolver/InverseValuesBuilder/DynamicInverseValuesBuilder.php +++ b/src/Cache/PropertyResolver/InverseValuesBuilder/DynamicInverseValuesBuilder.php @@ -20,7 +20,7 @@ public static function for(): string public function build(ValuesInterface $values, string $associationClass, string $associationTarget): ValuesInterface { return new DynamicValues( - alias: $values->alias, + provider: $values->provider, propertyPath: null !== $values->propertyPath ? \sprintf('%s?.%s', $associationTarget, $values->propertyPath) : $associationTarget, ); } diff --git a/src/RouteParamValueResolver/DynamicValuesResolver.php b/src/RouteParamValueResolver/DynamicValuesResolver.php index 2efb85a4..e88484ee 100644 --- a/src/RouteParamValueResolver/DynamicValuesResolver.php +++ b/src/RouteParamValueResolver/DynamicValuesResolver.php @@ -11,7 +11,7 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; /** - * @implements ValuesResolverInterface + * @implements ValuesResolverInterface, 1: ?string}> */ final class DynamicValuesResolver implements ValuesResolverInterface { @@ -34,23 +34,27 @@ public static function for(): string */ public function resolve(array $unresolvedValues, object $entity): array { - [$alias, $propertyPath] = $unresolvedValues; - - try { - /** @var \Closure $routeParamService */ - $routeParamService = $this->routeParamServiceLocator->get($alias); - } catch (ServiceNotFoundException $e) { - throw new RuntimeException(\sprintf( - 'A route parameter resolver service with the alias "%s" was not found. Did you forget to use the #[AsPurgatoryResolver] attribute on your service?', - $alias, - ), previous: $e); + [$provider, $propertyPath] = $unresolvedValues; + + if (\is_array($provider)) { + $routeParamProvider = $provider(...); + } else { + try { + /** @var \Closure $routeParamProvider */ + $routeParamProvider = $this->routeParamServiceLocator->get($provider); + } catch (ServiceNotFoundException $e) { + throw new RuntimeException(\sprintf( + 'A route parameter resolver service with the alias "%s" was not found. Did you forget to use the #[AsPurgatoryResolver] attribute on your service?', + $provider, + ), previous: $e); + } } /** @var object|scalar|array $arg */ $arg = null === $propertyPath ? $entity : $this->propertyAccessor->getValue($entity, $propertyPath); /** @var scalar|list|null $values */ - $values = $routeParamService($arg); + $values = $routeParamProvider($arg); return \is_array($values) ? $values : [$values]; } diff --git a/tests/Application/ApplicationTest.php b/tests/Application/ApplicationTest.php index aa8682a3..15261dd9 100644 --- a/tests/Application/ApplicationTest.php +++ b/tests/Application/ApplicationTest.php @@ -479,6 +479,8 @@ public function testDynamicValues(): void self::assertUrlIsPurged('/animal/for-rating/106'); // __invoke self::assertUrlIsPurged('/animal/for-rating/126'); // __invoke self::assertUrlIsPurged('/animal/for-rating/32'); // getOwnerRating + self::assertUrlIsPurged('/animal/for-rating/600'); // getOtherRating + self::assertUrlIsPurged('/animal/for-rating/375'); // getOtherRating self::clearPurger(); diff --git a/tests/Application/ConfigurationTest.php b/tests/Application/ConfigurationTest.php index 0b660a3f..0d3c2d44 100644 --- a/tests/Application/ConfigurationTest.php +++ b/tests/Application/ConfigurationTest.php @@ -23,6 +23,7 @@ use Sofascore\PurgatoryBundle\Tests\Functional\TestApplication\Entity\Person; use Sofascore\PurgatoryBundle\Tests\Functional\TestApplication\Entity\Vehicle; use Sofascore\PurgatoryBundle\Tests\Functional\TestApplication\Enum\Country; +use Sofascore\PurgatoryBundle\Tests\Functional\TestApplication\Service\AnimalRatingCalculator; final class ConfigurationTest extends AbstractKernelTestCase { @@ -393,6 +394,10 @@ public static function configurationWithTargetProvider(): iterable 'type' => DynamicValues::type(), 'values' => ['purgatory.animal_rating3', 'owner'], ], + [ + 'type' => DynamicValues::type(), + 'values' => [[AnimalRatingCalculator::class, 'getOtherRating'], null], + ], ], ], ], diff --git a/tests/Attribute/RouteParamValue/DynamicValuesTest.php b/tests/Attribute/RouteParamValue/DynamicValuesTest.php new file mode 100644 index 00000000..90336fb6 --- /dev/null +++ b/tests/Attribute/RouteParamValue/DynamicValuesTest.php @@ -0,0 +1,52 @@ +provider); + } + + public function testNonStaticStringCallableIsRejected(): void + { + $this->expectException(\ValueError::class); + $this->expectExceptionMessage('Only static method callables are supported.'); + + new DynamicValues(DummyInstanceCallable::class.'::handle'); + } + + public function testObjectCallableIsRejected(): void + { + $this->expectException(\ValueError::class); + $this->expectExceptionMessage('Object callables are not supported.'); + + new DynamicValues([new DummyInstanceCallable(), 'handle']); + } +} + +final class DummyStaticCallable +{ + public static function handle(): void + { + } +} + +final class DummyInstanceCallable +{ + public function handle(): void + { + } +} diff --git a/tests/Cache/PropertyResolver/InverseValuesBuildersTest.php b/tests/Cache/PropertyResolver/InverseValuesBuildersTest.php index f9904f65..8cd1533f 100644 --- a/tests/Cache/PropertyResolver/InverseValuesBuildersTest.php +++ b/tests/Cache/PropertyResolver/InverseValuesBuildersTest.php @@ -64,7 +64,7 @@ public function testBuild(): void expected: new CompoundValues( new DynamicValues('alias', propertyPath: 'association'), new DynamicValues( - alias: 'alias', + provider: 'alias', propertyPath: 'association?.obj', ), new EnumValues(DummyIntEnum::class), diff --git a/tests/Cache/RouteMetadata/Fixtures/DummyClass.php b/tests/Cache/RouteMetadata/Fixtures/DummyClass.php new file mode 100644 index 00000000..d5154f57 --- /dev/null +++ b/tests/Cache/RouteMetadata/Fixtures/DummyClass.php @@ -0,0 +1,12 @@ + new DynamicValues('foo'), 'param6' => new DynamicValues('foo', 'bar'), - 'param7' => new ExpressionValues('constant("App\\\\Foo::MAP")[obj.geValue()]'), + 'param7' => new DynamicValues([DummyClass::class, 'getValues']), + 'param8' => new DynamicValues([DummyClass::class, 'getValues'], 'bar'), + 'param9' => new DynamicValues([DummyClass::class, 'getValues']), + 'param10' => new DynamicValues([DummyClass::class, 'getValues'], 'bar'), + 'param11' => new ExpressionValues('constant("App\\\\Foo::MAP")[obj.geValue()]'), ], $metadata[0]->purgeOn->routeParams); self::assertNull($metadata[0]->purgeOn->if); self::assertNull($metadata[0]->purgeOn->actions); diff --git a/tests/Functional/TestApplication/Controller/AnimalController.php b/tests/Functional/TestApplication/Controller/AnimalController.php index 044a79dc..b27a751b 100644 --- a/tests/Functional/TestApplication/Controller/AnimalController.php +++ b/tests/Functional/TestApplication/Controller/AnimalController.php @@ -15,6 +15,7 @@ use Sofascore\PurgatoryBundle\Tests\Functional\TestApplication\Entity\Animal; use Sofascore\PurgatoryBundle\Tests\Functional\TestApplication\Entity\Person; use Sofascore\PurgatoryBundle\Tests\Functional\TestApplication\Enum\Country; +use Sofascore\PurgatoryBundle\Tests\Functional\TestApplication\Service\AnimalRatingCalculator; use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\Routing\Attribute\Route; @@ -113,9 +114,10 @@ public function tagAction(string $tag) target: ['measurements'], routeParams: [ 'rating' => new CompoundValues( - new DynamicValues(alias: 'purgatory.animal_rating2'), - new DynamicValues(alias: 'purgatory.animal_rating1'), - new DynamicValues(alias: 'purgatory.animal_rating3', propertyPath: 'owner'), + new DynamicValues(provider: 'purgatory.animal_rating2'), + new DynamicValues(provider: 'purgatory.animal_rating1'), + new DynamicValues(provider: 'purgatory.animal_rating3', propertyPath: 'owner'), + new DynamicValues(provider: [AnimalRatingCalculator::class, 'getOtherRating']), ), ], )] @@ -123,7 +125,7 @@ public function tagAction(string $tag) target: ['pets'], routeParams: [ 'rating' => new CompoundValues( - new DynamicValues(alias: 'purgatory.animal_rating3'), + new DynamicValues(provider: 'purgatory.animal_rating3'), ), ], )] diff --git a/tests/Functional/TestApplication/Service/AnimalRatingCalculator.php b/tests/Functional/TestApplication/Service/AnimalRatingCalculator.php index b63e4341..7c2e81c4 100644 --- a/tests/Functional/TestApplication/Service/AnimalRatingCalculator.php +++ b/tests/Functional/TestApplication/Service/AnimalRatingCalculator.php @@ -31,4 +31,9 @@ public function getOwnerRating(Person $owner): int initial: 0, ); } + + public static function getOtherRating(Animal $animal): int + { + return (int) (($animal->measurements->height ?? 1) * ($animal->measurements->weight ?? 1) / ($animal->measurements->width ?? 1) * 100); + } } diff --git a/tests/RouteParamValueResolver/DynamicValuesResolverTest.php b/tests/RouteParamValueResolver/DynamicValuesResolverTest.php index 6efecb39..ec08265a 100644 --- a/tests/RouteParamValueResolver/DynamicValuesResolverTest.php +++ b/tests/RouteParamValueResolver/DynamicValuesResolverTest.php @@ -43,7 +43,7 @@ protected function tearDown(): void #[DataProvider('valuesProvider')] public function testResolve( - string $id, + string|array $id, ?string $path, object $entity, array $expectedResult, @@ -96,6 +96,14 @@ public static function valuesProvider(): iterable 'entity' => $foo, 'expectedResult' => [5, 1, 2, 3], ]; + + $foo = clone $foo; + yield 'static callable' => [ + 'id' => [DummyServiceTwo::class, 'getOtherValueToPurge'], + 'path' => null, + 'entity' => $foo, + 'expectedResult' => [205], + ]; } public function testExceptionIsThrownWhenServiceIsNotFound(): void diff --git a/tests/RouteParamValueResolver/Fixtures/DummyServiceTwo.php b/tests/RouteParamValueResolver/Fixtures/DummyServiceTwo.php index 9b0eb2b0..b3d595f4 100644 --- a/tests/RouteParamValueResolver/Fixtures/DummyServiceTwo.php +++ b/tests/RouteParamValueResolver/Fixtures/DummyServiceTwo.php @@ -15,4 +15,9 @@ public function getArrayValueToPurge(Foo $foo): array { return [$foo->id ?? 0, 1, 2, 3]; } + + public static function getOtherValueToPurge(Foo $foo): int + { + return ($foo->id ?? 0) + 200; + } }