From fd7cd4a4d372f040fdf0b0e329ee5c5b75decfd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sun, 26 Oct 2025 17:51:58 +0100 Subject: [PATCH 1/8] add optional dependency on opis/closure --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 79170b33..bacf0228 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,7 @@ }, "require-dev": { "doctrine/common": "^3.2", + "opis/closure": "^4.3", "phpunit/phpunit": "^11.5", "symfony/cache": "^5.4 || ^6.4 || ^7.0", "symfony/doctrine-messenger": "^5.4 || ^6.4 || ^7.0", From 2d2d7df21c38c66a771c1fe0f2a45a4da99c3529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sun, 26 Oct 2025 17:52:49 +0100 Subject: [PATCH 2/8] allow using closures in PurgeOn::if --- src/Attribute/PurgeOn.php | 4 +- src/Cache/Configuration/Configuration.php | 1 + .../Configuration/ConfigurationLoader.php | 8 +++- src/Cache/Configuration/Subscriptions.php | 3 ++ src/Cache/Subscription/PurgeSubscription.php | 2 +- .../PurgeSubscriptionProvider.php | 42 ++++++++++++++++++- .../AbstractEntityRouteProvider.php | 11 ++++- 7 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/Attribute/PurgeOn.php b/src/Attribute/PurgeOn.php index 1a40b925..04841014 100644 --- a/src/Attribute/PurgeOn.php +++ b/src/Attribute/PurgeOn.php @@ -18,7 +18,7 @@ final class PurgeOn public readonly ?TargetInterface $target; /** @var ?non-empty-array */ public readonly ?array $routeParams; - public readonly ?Expression $if; + public readonly \Closure|Expression|null $if; /** @var ?non-empty-list */ public readonly ?array $route; /** @var ?non-empty-list */ @@ -35,7 +35,7 @@ public function __construct( public readonly string $class, string|array|TargetInterface|null $target = null, ?array $routeParams = null, - string|Expression|null $if = null, + \Closure|string|Expression|null $if = null, string|array|null $route = null, string|array|Action|null $actions = null, ) { diff --git a/src/Cache/Configuration/Configuration.php b/src/Cache/Configuration/Configuration.php index e9c0c200..88ae2832 100644 --- a/src/Cache/Configuration/Configuration.php +++ b/src/Cache/Configuration/Configuration.php @@ -13,6 +13,7 @@ final class Configuration implements \Countable * routeName: string, * routeParams?: array, optional?: true}>, * if?: string, + * closureIf?: true, * actions?: non-empty-list, * }>> $configuration */ diff --git a/src/Cache/Configuration/ConfigurationLoader.php b/src/Cache/Configuration/ConfigurationLoader.php index aad9e2d9..a63780d2 100644 --- a/src/Cache/Configuration/ConfigurationLoader.php +++ b/src/Cache/Configuration/ConfigurationLoader.php @@ -7,6 +7,7 @@ use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\ValuesInterface; use Sofascore\PurgatoryBundle\Cache\Subscription\PurgeSubscriptionProviderInterface; use Symfony\Component\Routing\Route; +use function Opis\Closure\serialize; final class ConfigurationLoader implements ConfigurationLoaderInterface { @@ -38,7 +39,12 @@ public function load(): Configuration } if (null !== $subscription->if) { - $config['if'] = (string) $subscription->if; + if($subscription->if instanceof \Closure) { + $config['if'] = serialize($subscription->if); + $config['closureIf'] = true; + } else { + $config['if'] = (string) $subscription->if; + } } if (null !== $subscription->actions) { diff --git a/src/Cache/Configuration/Subscriptions.php b/src/Cache/Configuration/Subscriptions.php index de145eb4..124c4fde 100644 --- a/src/Cache/Configuration/Subscriptions.php +++ b/src/Cache/Configuration/Subscriptions.php @@ -11,6 +11,7 @@ * routeName: string, * routeParams?: array, optional?: true}>, * if?: string, + * closureIf?: true, * actions?: non-empty-list, * }> */ @@ -22,6 +23,7 @@ final class Subscriptions implements \IteratorAggregate, \Countable * routeName: string, * routeParams?: array, optional?: true}>, * if?: string, + * closureIf?: true, * actions?: non-empty-list, * }> $subscriptions */ @@ -54,6 +56,7 @@ public function key(): string * routeName: string, * routeParams?: array, optional?: true}>, * if?: string, + * closureIf?: true, * actions?: non-empty-list, * }> */ diff --git a/src/Cache/Subscription/PurgeSubscription.php b/src/Cache/Subscription/PurgeSubscription.php index 15b14dd8..db8416db 100644 --- a/src/Cache/Subscription/PurgeSubscription.php +++ b/src/Cache/Subscription/PurgeSubscription.php @@ -23,7 +23,7 @@ public function __construct( public readonly string $routeName, public readonly Route $route, public readonly ?array $actions, - public readonly ?Expression $if = null, + public readonly \Closure|Expression|null $if = null, ) { } } diff --git a/src/Cache/Subscription/PurgeSubscriptionProvider.php b/src/Cache/Subscription/PurgeSubscriptionProvider.php index e615fef4..121b5be0 100644 --- a/src/Cache/Subscription/PurgeSubscriptionProvider.php +++ b/src/Cache/Subscription/PurgeSubscriptionProvider.php @@ -5,6 +5,7 @@ namespace Sofascore\PurgatoryBundle\Cache\Subscription; use Doctrine\Persistence\ManagerRegistry; +use Opis\Closure\ReflectionClosure; use Psr\Container\ContainerInterface; use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\PropertyValues; use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\ValuesInterface; @@ -16,10 +17,12 @@ use Sofascore\PurgatoryBundle\Exception\EntityMetadataNotFoundException; use Sofascore\PurgatoryBundle\Exception\InvalidIfExpressionException; use Sofascore\PurgatoryBundle\Exception\MissingRequiredRouteParametersException; +use Sofascore\PurgatoryBundle\Exception\RuntimeException; use Sofascore\PurgatoryBundle\Exception\TargetSubscriptionNotResolvableException; use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\ExpressionLanguage\SyntaxError; +use function Opis\Closure\{serialize, unserialize}; /** * @internal Used during cache warmup @@ -58,7 +61,7 @@ private function provideFromMetadata(RouteMetadataProviderInterface $routeMetada $purgeOn = $routeMetadata->purgeOn; if (null !== $purgeOn->if) { - $this->validateIfExpression($purgeOn->if, $routeMetadata->routeName); + $this->validateIf($purgeOn->if, $routeMetadata->routeName, $purgeOn->class); } // if route parameters are not specified, they are same as path variables @@ -140,6 +143,43 @@ private function validateRouteParams(array $routeParams, RouteMetadata $routeMet } } + private function validateIf(\Closure|Expression $expression, string $routeName, string $entity): void + { + if($expression instanceof \Closure) { + $this->validateIfClosure($expression, $routeName, $entity); + return; + } + + $this->validateIfExpression($expression, $routeName); + } + + private function validateIfClosure(\Closure $expression, string $routeName, string $entity): void + { + $reflection = new ReflectionClosure($expression); + + $returnType = $reflection->getReturnType(); + + if(!$returnType instanceof \ReflectionNamedType + || $returnType->allowsNull() + || !in_array($returnType->getName(), ['bool', 'true', 'false']) + ) { + throw new RuntimeException('Return type of PurgeOn::if closure must be bool'); + } + + if(1 !== $reflection->getNumberOfParameters()) { + throw new RuntimeException('PurgeOn::if closure must have exactly 1 parameter'); + } + + $parameterType = $reflection->getParameters()[0]->getType(); + + if(!$parameterType instanceof \ReflectionNamedType + || $parameterType->allowsNull() + || !is_a($entity, $parameterType->getName(), true) + ) { + throw new RuntimeException("Parameter in PurgeOn::if closure must be of type $entity"); + } + } + private function validateIfExpression(Expression $expression, string $routeName): void { try { diff --git a/src/RouteProvider/AbstractEntityRouteProvider.php b/src/RouteProvider/AbstractEntityRouteProvider.php index 19e3643f..00181e03 100644 --- a/src/RouteProvider/AbstractEntityRouteProvider.php +++ b/src/RouteProvider/AbstractEntityRouteProvider.php @@ -4,6 +4,7 @@ namespace Sofascore\PurgatoryBundle\RouteProvider; +use Opis\Closure\Box; use Psr\Container\ContainerInterface; use Sofascore\PurgatoryBundle\Cache\Configuration\Configuration; use Sofascore\PurgatoryBundle\Cache\Configuration\ConfigurationLoaderInterface; @@ -13,6 +14,7 @@ use Sofascore\PurgatoryBundle\Listener\Enum\Action; use Sofascore\PurgatoryBundle\RouteParamValueResolver\ValuesResolverInterface; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use function Opis\Closure\unserialize; /** * @internal @@ -73,7 +75,14 @@ private function processValidSubscriptions(Subscriptions $subscriptions, array $ } if (isset($subscription['if'])) { - $result = $this->getExpressionLanguage()->evaluate($subscription['if'], ['obj' => $entity]); + if(isset($subscription['closureIf'])) { + /** @var \Closure $closure */ + $closure = unserialize($subscription['if'], options: ['allowed_classes' => [Box::class]]); + $result = $closure($entity); + } else { + $result = $this->getExpressionLanguage()->evaluate($subscription['if'], ['obj' => $entity]); + } + if (!\is_bool($result)) { throw new InvalidIfExpressionResultException($subscription['routeName'], $subscription['if'], $result); } From 52d9bbb9b24ec7d6fcac0747ba12c16bd29b49fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sun, 26 Oct 2025 17:53:00 +0100 Subject: [PATCH 3/8] add tests --- tests/Application/Php85ApplicationTest.php | 55 ++++++ tests/Application/Php85ConfigurationTest.php | 83 +++++++++ .../Configuration/ConfigurationLoaderTest.php | 43 +++++ .../Subscription/Fixtures/DummyEntity.php | 13 ++ .../PurgeSubscriptionProviderTest.php | 158 ++++++++++++++++++ .../Controller/PlantController.php | 28 ++++ .../Php85TestApplication/Entity/Plant.php | 41 +++++ .../config/app_config.yaml | 7 + .../UpdatedEntityRouteProviderTest.php | 65 +++++++ 9 files changed, 493 insertions(+) create mode 100644 tests/Application/Php85ApplicationTest.php create mode 100644 tests/Application/Php85ConfigurationTest.php create mode 100644 tests/Cache/Subscription/Fixtures/DummyEntity.php create mode 100644 tests/Functional/Php85TestApplication/Controller/PlantController.php create mode 100644 tests/Functional/Php85TestApplication/Entity/Plant.php create mode 100644 tests/Functional/Php85TestApplication/config/app_config.yaml diff --git a/tests/Application/Php85ApplicationTest.php b/tests/Application/Php85ApplicationTest.php new file mode 100644 index 00000000..92485814 --- /dev/null +++ b/tests/Application/Php85ApplicationTest.php @@ -0,0 +1,55 @@ += 8.5')] +#[RequiresFunction('\Opis\Closure\serialize')] +final class Php85ApplicationTest extends AbstractKernelTestCase +{ + use InteractsWithPurgatory; + + private EntityManagerInterface $entityManager; + + protected function setUp(): void + { + self::initializeApplication(['test_case' => 'Php85TestApplication', 'config' => 'app_config.yaml']); + + $this->entityManager = self::getContainer()->get('doctrine.orm.entity_manager'); + } + + protected function tearDown(): void + { + unset($this->entityManager); + + parent::tearDown(); + } + + /** + * @see PlantController::dryPlantsAction + */ + public function testIfWithClosure(): void + { + $plant = new Plant(waterLevel: 0); + $this->entityManager->persist($plant); + $this->entityManager->flush(); + + self::assertUrlIsPurged('/plants/dry'); + self::clearPurger(); + + $plant = new Plant(waterLevel: 1); + $this->entityManager->persist($plant); + $this->entityManager->flush(); + + self::assertUrlIsNotPurged('/plants/dry'); + } +} diff --git a/tests/Application/Php85ConfigurationTest.php b/tests/Application/Php85ConfigurationTest.php new file mode 100644 index 00000000..fc5f3c1e --- /dev/null +++ b/tests/Application/Php85ConfigurationTest.php @@ -0,0 +1,83 @@ += 8.5')] +#[RequiresFunction('\Opis\Closure\serialize')] +class Php85ConfigurationTest extends AbstractKernelTestCase +{ + private static ?Configuration $configuration; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + self::initializeApplication(['test_case' => 'Php85TestApplication', 'config' => 'app_config.yaml']); + + self::$configuration = self::getContainer()->get('sofascore.purgatory.configuration_loader')->load(); + + self::ensureKernelShutdown(); + } + + public static function tearDownAfterClass(): void + { + self::$configuration = null; + + parent::tearDownAfterClass(); + } + + #[DataProvider('configurationProvider')] + public function testConfiguration(string $entity, array $subscription): void + { + self::assertSubscriptionExists( + key: $entity, + subscription: $subscription, + ); + } + + public static function configurationProvider(): iterable + { + $expectedIf = <<<'EOF' +O:16:"Opis\Closure\Box":2:{i:0;i:1;i:1;a:1:{s:4:"info";a:4:{s:3:"key";s:32:"2521276d9b695876a33347478e0d2b3d";s:6:"header";s:167:"namespace Sofascore\PurgatoryBundle\Tests\Functional\Php85TestApplication\Controller; +use Sofascore\PurgatoryBundle\Tests\Functional\Php85TestApplication\Entity\Plant;";s:4:"body";s:98:"static function (Plant $plant): bool { + return $plant->getWaterLevel() === 0; + }";s:5:"flags";i:2;}}} +EOF; + + /* @see PlantController::dryPlantsAction */ + yield [ + 'entity' => Plant::class, + 'subscription' => [ + 'routeName' => 'dry_plants_list', + 'if' => $expectedIf, + 'closureIf' => true, + 'actions' => [Action::Create] + ], + ]; + } + + private static function assertSubscriptionExists(string $key, array $subscription): void + { + self::assertTrue( + condition: self::$configuration->has($key), + message: \sprintf('Failed asserting that the configuration contains a subscription for "%s".', $key), + ); + + self::assertContains( + needle: $subscription, + haystack: self::$configuration->get($key), + message: \sprintf('Failed asserting that the configuration contains the subscription "%s" for the key "%s".', json_encode($subscription), $key), + ); + } +} diff --git a/tests/Cache/Configuration/ConfigurationLoaderTest.php b/tests/Cache/Configuration/ConfigurationLoaderTest.php index 66cb52f7..e480b42d 100644 --- a/tests/Cache/Configuration/ConfigurationLoaderTest.php +++ b/tests/Cache/Configuration/ConfigurationLoaderTest.php @@ -4,8 +4,10 @@ namespace Sofascore\PurgatoryBundle\Tests\Cache\Configuration; +use Opis\Closure\ReflectionClosure; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresMethod; use PHPUnit\Framework\TestCase; use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\CompoundValues; use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\EnumValues; @@ -17,6 +19,7 @@ use Sofascore\PurgatoryBundle\Cache\Subscription\PurgeSubscriptionProviderInterface; use Sofascore\PurgatoryBundle\Listener\Enum\Action; use Sofascore\PurgatoryBundle\Tests\Fixtures\DummyStringEnum; +use stdClass; use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\Routing\Route; @@ -228,4 +231,44 @@ class: 'Foo', ], ]; } + + #[RequiresMethod(ReflectionClosure::class, '__construct')] + #[DataProvider('purgeSubscriptionProviderPhp85')] + public function testSubscriptionsWithPhp85Features(array $purgeSubscriptions, array $expectedConfiguration): void + { + $purgeSubscriptionProvider = $this->createMock(PurgeSubscriptionProviderInterface::class); + $purgeSubscriptionProvider->method('provide') + ->willReturn($purgeSubscriptions); + + $loader = new ConfigurationLoader($purgeSubscriptionProvider); + + self::assertInstanceOf(Configuration::class, $configuration = $loader->load()); + self::assertSame($expectedConfiguration, $configuration->toArray()); + } + public static function purgeSubscriptionProviderPhp85(): iterable + { + yield 'purge subscription without property' => [ + 'purgeSubscriptions' => [ + new PurgeSubscription( + class: stdClass::class, + property: null, + routeParams: [], + routeName: 'app_route_foo', + route: new Route('/foo'), + actions: Action::cases(), + if: static function (\stdClass $entity): bool {return true;}, + ), + ], + 'expectedConfiguration' => [ + 'stdClass' => [ + [ + 'routeName' => 'app_route_foo', + 'if' => 'O:16:"Opis\Closure\Box":2:{i:0;i:1;i:1;a:1:{s:4:"info";a:4:{s:3:"key";s:32:"2a27e41247bbdcbf2bae2d27231c100b";s:6:"header";s:62:"namespace Sofascore\PurgatoryBundle\Tests\Cache\Configuration;";s:4:"body";s:56:"static function (\stdClass $entity): bool {return true;}";s:5:"flags";i:2;}}}', + 'closureIf' => true, + 'actions' => Action::cases(), + ], + ], + ], + ]; + } } diff --git a/tests/Cache/Subscription/Fixtures/DummyEntity.php b/tests/Cache/Subscription/Fixtures/DummyEntity.php new file mode 100644 index 00000000..9c849c96 --- /dev/null +++ b/tests/Cache/Subscription/Fixtures/DummyEntity.php @@ -0,0 +1,13 @@ + ['foo', 'baz'], ]; } + + #[RequiresMethod(ReflectionClosure::class, '__construct')] + #[DataProvider('providerRouteMetadataWithPhp85Features')] + public function testWithClosures(RouteMetadata $routeMetadata, array $expectedSubscriptions): void + { + $routeMetadataProvider = $this->createMock(RouteMetadataProviderInterface::class); + $routeMetadataProvider->method('provide') + ->willReturnCallback(function () use ($routeMetadata) { + yield $routeMetadata; + }); + + $targetResolverLocator = $this->createMock(ContainerInterface::class); + $targetResolverLocator->expects($this->never())->method('get'); + + $purgeSubscriptionProvider = new PurgeSubscriptionProvider( + subscriptionResolvers: [], + routeMetadataProviders: [$routeMetadataProvider], + managerRegistry: $this->createMock(ManagerRegistry::class), + targetResolverLocator: $targetResolverLocator, + expressionLanguage: null, + ); + + /** @var PurgeSubscription[] $propertySubscriptions */ + $propertySubscriptions = [...$purgeSubscriptionProvider->provide()]; + + self::assertCount(\count($expectedSubscriptions), $propertySubscriptions); + self::assertEquals($expectedSubscriptions, $propertySubscriptions); + } + + public static function providerRouteMetadataWithPhp85Features(): iterable + { + $route = new Route('/foo'); + yield 'PurgeOn with closure' => [ + 'routeMetadata' => new RouteMetadata( + routeName: 'foo', + route: $route, + purgeOn: new PurgeOn( + class: DummyEntity::class, + if: static function (DummyEntity $entity): bool { + return $entity->getData() > 0; + } + ), + reflectionMethod: new \ReflectionMethod(DummyController::class, 'barAction'), + ), + 'expectedSubscriptions' => [ + new PurgeSubscription( + class: DummyEntity::class, + property: null, + routeParams: [], + routeName: 'foo', + route: $route, + actions: null, + if: static function (DummyEntity $entity): bool { + return $entity->getData() > 0; + }, + ), + ], + ]; + } + + #[RequiresMethod(ReflectionClosure::class, '__construct')] + #[DataProvider('provideInvalidClosures')] + public function testInvalidClosures(\Closure $if, string $expectedMessage): void + { + $routeMetadataProvider = $this->createMock(RouteMetadataProviderInterface::class); + $routeMetadataProvider->method('provide') + ->willReturnCallback(function () use ($if): iterable { + yield new RouteMetadata( + routeName: 'foo', + route: new Route('/{foo}'), + purgeOn: new PurgeOn( + class: DummyEntity::class, + if: $if, + ), + reflectionMethod: null, + ); + }); + + $purgeSubscriptionProvider = new PurgeSubscriptionProvider( + subscriptionResolvers: [], + routeMetadataProviders: [$routeMetadataProvider], + managerRegistry: $this->createMock(ManagerRegistry::class), + targetResolverLocator: $this->createMock(ContainerInterface::class), + expressionLanguage: new ExpressionLanguage( + providers: [ + new class implements ExpressionFunctionProviderInterface { + public function getFunctions(): array + { + return [ + new ExpressionFunction('valid_function', function () {}, function () {}), + ]; + } + }, + ], + ), + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($expectedMessage); + + [...$purgeSubscriptionProvider->provide()]; + } + + public static function provideInvalidClosures(): iterable + { + yield 'invalid return type (union)' => [ + 'if' => static function (DummyEntity $entity): int|string { + return $entity->getData(); + }, + 'expectedMessage' => 'Return type of PurgeOn::if closure must be bool', + ]; + + yield 'nullable return type' => [ + 'if' => static function (DummyEntity $entity): ?bool { + return null; + }, + 'expectedMessage' => 'Return type of PurgeOn::if closure must be bool', + ]; + + yield 'invalid return type' => [ + 'if' => static function (DummyEntity $entity): int { + return $entity->getData(); + }, + 'expectedMessage' => 'Return type of PurgeOn::if closure must be bool', + ]; + + yield 'too many parameters' => [ + 'if' => static function (DummyEntity $entity, array $options): bool { + return $entity->getData() > 0; + }, + 'expectedMessage' => 'PurgeOn::if closure must have exactly 1 parameter', + ]; + + yield 'invalid parameter type (union)' => [ + 'if' => static function (DummyEntity|int $entity): bool { + return $entity->getData() > 0; + }, + 'expectedMessage' => 'Parameter in PurgeOn::if closure must be of type ' . DummyEntity::class, + ]; + + yield 'nullable parameter type' => [ + 'if' => static function (?DummyEntity $entity): bool { + return $entity?->getData() > 0; + }, + 'expectedMessage' => 'Parameter in PurgeOn::if closure must be of type ' . DummyEntity::class, + ]; + + yield 'invalid parameter type' => [ + 'if' => static function (\stdClass $entity): bool { + return true; + }, + 'expectedMessage' => 'Parameter in PurgeOn::if closure must be of type ' . DummyEntity::class, + ]; + } } diff --git a/tests/Functional/Php85TestApplication/Controller/PlantController.php b/tests/Functional/Php85TestApplication/Controller/PlantController.php new file mode 100644 index 00000000..0d2fd32d --- /dev/null +++ b/tests/Functional/Php85TestApplication/Controller/PlantController.php @@ -0,0 +1,28 @@ +getWaterLevel() === 0; + }, + actions: Action::Create + )] + public function dryPlantsAction() + { + } +} diff --git a/tests/Functional/Php85TestApplication/Entity/Plant.php b/tests/Functional/Php85TestApplication/Entity/Plant.php new file mode 100644 index 00000000..871983f1 --- /dev/null +++ b/tests/Functional/Php85TestApplication/Entity/Plant.php @@ -0,0 +1,41 @@ +waterLevel = $waterLevel; + } + + public function getId(): int + { + return $this->id; + } + + public function getWaterLevel(): int + { + return $this->waterLevel; + } + + public function setWaterLevel(int $waterLevel): void + { + $this->waterLevel = $waterLevel; + } + + +} diff --git a/tests/Functional/Php85TestApplication/config/app_config.yaml b/tests/Functional/Php85TestApplication/config/app_config.yaml new file mode 100644 index 00000000..8c12ff00 --- /dev/null +++ b/tests/Functional/Php85TestApplication/config/app_config.yaml @@ -0,0 +1,7 @@ +services: + _defaults: + autoconfigure: true + autowire: true + + Sofascore\PurgatoryBundle\Tests\Functional\Php85TestApplication\: + resource: '../' diff --git a/tests/RouteProvider/UpdatedEntityRouteProviderTest.php b/tests/RouteProvider/UpdatedEntityRouteProviderTest.php index e706c299..382314ff 100644 --- a/tests/RouteProvider/UpdatedEntityRouteProviderTest.php +++ b/tests/RouteProvider/UpdatedEntityRouteProviderTest.php @@ -5,7 +5,9 @@ namespace Sofascore\PurgatoryBundle\Tests\RouteProvider; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\RequiresFunction; use PHPUnit\Framework\Attributes\RequiresMethod; +use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; @@ -402,6 +404,69 @@ public function testExceptionIsThrownOnInvalidIfReturnType(mixed $ifResult, stri [...$routeProvider->provideRoutesFor(Action::Update, new \stdClass(), [])]; } + #[RequiresFunction('\Opis\Closure\serialize')] + public function testProvideRoutesToPurgeWithClosureIf(): void + { + $validIf = static function (\stdClass $entity): bool { + return true; + }; + $invalidIf = static function (\stdClass $entity): bool { + return false; + }; + + $routeProvider = $this->createRouteProvider([ + 'stdClass' => [ + [ + 'routeName' => 'foo_route', + 'if' => \Opis\Closure\serialize($validIf), + 'closureIf' => true, + ], + ], + 'stdClass::foo' => [ + [ + 'routeName' => 'bar_route', + 'if' => \Opis\Closure\serialize($validIf), + 'closureIf' => true, + ], + [ + 'routeName' => 'baz_route', + 'routeParams' => [ + 'param1' => [ + 'type' => PropertyValues::type(), + 'values' => ['foo', 'bar'], + ], + 'param2' => [ + 'type' => PropertyValues::type(), + 'values' => ['baz'], + ], + ], + 'if' => \Opis\Closure\serialize($invalidIf), + 'closureIf' => true, + ], + ], + ], false); + + $entity = new \stdClass(); + + self::assertTrue($routeProvider->supports(Action::Update, $entity)); + self::assertFalse($routeProvider->supports(Action::Delete, $entity)); + self::assertFalse($routeProvider->supports(Action::Create, $entity)); + + $routes = [...$routeProvider->provideRoutesFor( + action: Action::Update, + entity: $entity, + entityChangeSet: [ + 'foo' => ['old', 'new'], + ], + )]; + + self::assertCount(2, $routes); + self::assertContainsOnlyInstancesOf(PurgeRoute::class, $routes); + + self::assertSame(['name' => 'foo_route', 'params' => []], (array) $routes[0]); + self::assertSame(['name' => 'bar_route', 'params' => []], (array) $routes[1]); + } + private function createRouteProvider(array $configuration, bool $withExpressionLang): UpdatedEntityRouteProvider { $configurationLoader = $this->createMock(ConfigurationLoaderInterface::class); From d889400a53329e7176aa69b5729941fe9b4f22d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sun, 26 Oct 2025 17:58:56 +0100 Subject: [PATCH 4/8] lint --- src/Cache/Configuration/ConfigurationLoader.php | 3 ++- src/Cache/Subscription/PurgeSubscriptionProvider.php | 12 ++++++------ src/RouteProvider/AbstractEntityRouteProvider.php | 3 ++- tests/Application/Php85ConfigurationTest.php | 12 ++++++------ .../Cache/Configuration/ConfigurationLoaderTest.php | 6 +++--- .../Subscription/PurgeSubscriptionProviderTest.php | 10 +++++----- .../Functional/Php85TestApplication/Entity/Plant.php | 2 -- .../RouteProvider/UpdatedEntityRouteProviderTest.php | 1 - 8 files changed, 24 insertions(+), 25 deletions(-) diff --git a/src/Cache/Configuration/ConfigurationLoader.php b/src/Cache/Configuration/ConfigurationLoader.php index a63780d2..e5bdeb46 100644 --- a/src/Cache/Configuration/ConfigurationLoader.php +++ b/src/Cache/Configuration/ConfigurationLoader.php @@ -7,6 +7,7 @@ use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\ValuesInterface; use Sofascore\PurgatoryBundle\Cache\Subscription\PurgeSubscriptionProviderInterface; use Symfony\Component\Routing\Route; + use function Opis\Closure\serialize; final class ConfigurationLoader implements ConfigurationLoaderInterface @@ -39,7 +40,7 @@ public function load(): Configuration } if (null !== $subscription->if) { - if($subscription->if instanceof \Closure) { + if ($subscription->if instanceof \Closure) { $config['if'] = serialize($subscription->if); $config['closureIf'] = true; } else { diff --git a/src/Cache/Subscription/PurgeSubscriptionProvider.php b/src/Cache/Subscription/PurgeSubscriptionProvider.php index 121b5be0..be80c74b 100644 --- a/src/Cache/Subscription/PurgeSubscriptionProvider.php +++ b/src/Cache/Subscription/PurgeSubscriptionProvider.php @@ -22,7 +22,6 @@ use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\ExpressionLanguage\SyntaxError; -use function Opis\Closure\{serialize, unserialize}; /** * @internal Used during cache warmup @@ -145,8 +144,9 @@ private function validateRouteParams(array $routeParams, RouteMetadata $routeMet private function validateIf(\Closure|Expression $expression, string $routeName, string $entity): void { - if($expression instanceof \Closure) { + if ($expression instanceof \Closure) { $this->validateIfClosure($expression, $routeName, $entity); + return; } @@ -159,20 +159,20 @@ private function validateIfClosure(\Closure $expression, string $routeName, stri $returnType = $reflection->getReturnType(); - if(!$returnType instanceof \ReflectionNamedType + if (!$returnType instanceof \ReflectionNamedType || $returnType->allowsNull() - || !in_array($returnType->getName(), ['bool', 'true', 'false']) + || !\in_array($returnType->getName(), ['bool', 'true', 'false']) ) { throw new RuntimeException('Return type of PurgeOn::if closure must be bool'); } - if(1 !== $reflection->getNumberOfParameters()) { + if (1 !== $reflection->getNumberOfParameters()) { throw new RuntimeException('PurgeOn::if closure must have exactly 1 parameter'); } $parameterType = $reflection->getParameters()[0]->getType(); - if(!$parameterType instanceof \ReflectionNamedType + if (!$parameterType instanceof \ReflectionNamedType || $parameterType->allowsNull() || !is_a($entity, $parameterType->getName(), true) ) { diff --git a/src/RouteProvider/AbstractEntityRouteProvider.php b/src/RouteProvider/AbstractEntityRouteProvider.php index 00181e03..48883478 100644 --- a/src/RouteProvider/AbstractEntityRouteProvider.php +++ b/src/RouteProvider/AbstractEntityRouteProvider.php @@ -14,6 +14,7 @@ use Sofascore\PurgatoryBundle\Listener\Enum\Action; use Sofascore\PurgatoryBundle\RouteParamValueResolver\ValuesResolverInterface; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; + use function Opis\Closure\unserialize; /** @@ -75,7 +76,7 @@ private function processValidSubscriptions(Subscriptions $subscriptions, array $ } if (isset($subscription['if'])) { - if(isset($subscription['closureIf'])) { + if (isset($subscription['closureIf'])) { /** @var \Closure $closure */ $closure = unserialize($subscription['if'], options: ['allowed_classes' => [Box::class]]); $result = $closure($entity); diff --git a/tests/Application/Php85ConfigurationTest.php b/tests/Application/Php85ConfigurationTest.php index fc5f3c1e..8cd70ec3 100644 --- a/tests/Application/Php85ConfigurationTest.php +++ b/tests/Application/Php85ConfigurationTest.php @@ -49,11 +49,11 @@ public function testConfiguration(string $entity, array $subscription): void public static function configurationProvider(): iterable { $expectedIf = <<<'EOF' -O:16:"Opis\Closure\Box":2:{i:0;i:1;i:1;a:1:{s:4:"info";a:4:{s:3:"key";s:32:"2521276d9b695876a33347478e0d2b3d";s:6:"header";s:167:"namespace Sofascore\PurgatoryBundle\Tests\Functional\Php85TestApplication\Controller; -use Sofascore\PurgatoryBundle\Tests\Functional\Php85TestApplication\Entity\Plant;";s:4:"body";s:98:"static function (Plant $plant): bool { - return $plant->getWaterLevel() === 0; - }";s:5:"flags";i:2;}}} -EOF; + O:16:"Opis\Closure\Box":2:{i:0;i:1;i:1;a:1:{s:4:"info";a:4:{s:3:"key";s:32:"2521276d9b695876a33347478e0d2b3d";s:6:"header";s:167:"namespace Sofascore\PurgatoryBundle\Tests\Functional\Php85TestApplication\Controller; + use Sofascore\PurgatoryBundle\Tests\Functional\Php85TestApplication\Entity\Plant;";s:4:"body";s:98:"static function (Plant $plant): bool { + return $plant->getWaterLevel() === 0; + }";s:5:"flags";i:2;}}} + EOF; /* @see PlantController::dryPlantsAction */ yield [ @@ -62,7 +62,7 @@ public static function configurationProvider(): iterable 'routeName' => 'dry_plants_list', 'if' => $expectedIf, 'closureIf' => true, - 'actions' => [Action::Create] + 'actions' => [Action::Create], ], ]; } diff --git a/tests/Cache/Configuration/ConfigurationLoaderTest.php b/tests/Cache/Configuration/ConfigurationLoaderTest.php index e480b42d..abd1b430 100644 --- a/tests/Cache/Configuration/ConfigurationLoaderTest.php +++ b/tests/Cache/Configuration/ConfigurationLoaderTest.php @@ -19,7 +19,6 @@ use Sofascore\PurgatoryBundle\Cache\Subscription\PurgeSubscriptionProviderInterface; use Sofascore\PurgatoryBundle\Listener\Enum\Action; use Sofascore\PurgatoryBundle\Tests\Fixtures\DummyStringEnum; -use stdClass; use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\Routing\Route; @@ -245,18 +244,19 @@ public function testSubscriptionsWithPhp85Features(array $purgeSubscriptions, ar self::assertInstanceOf(Configuration::class, $configuration = $loader->load()); self::assertSame($expectedConfiguration, $configuration->toArray()); } + public static function purgeSubscriptionProviderPhp85(): iterable { yield 'purge subscription without property' => [ 'purgeSubscriptions' => [ new PurgeSubscription( - class: stdClass::class, + class: \stdClass::class, property: null, routeParams: [], routeName: 'app_route_foo', route: new Route('/foo'), actions: Action::cases(), - if: static function (\stdClass $entity): bool {return true;}, + if: static function (\stdClass $entity): bool {return true; }, ), ], 'expectedConfiguration' => [ diff --git a/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php b/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php index b8b4377a..1f033fac 100644 --- a/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php +++ b/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php @@ -558,7 +558,7 @@ public function testWithClosures(RouteMetadata $routeMetadata, array $expectedSu }); $targetResolverLocator = $this->createMock(ContainerInterface::class); - $targetResolverLocator->expects($this->never())->method('get'); + $targetResolverLocator->expects(self::never())->method('get'); $purgeSubscriptionProvider = new PurgeSubscriptionProvider( subscriptionResolvers: [], @@ -586,7 +586,7 @@ public static function providerRouteMetadataWithPhp85Features(): iterable class: DummyEntity::class, if: static function (DummyEntity $entity): bool { return $entity->getData() > 0; - } + }, ), reflectionMethod: new \ReflectionMethod(DummyController::class, 'barAction'), ), @@ -683,21 +683,21 @@ public static function provideInvalidClosures(): iterable 'if' => static function (DummyEntity|int $entity): bool { return $entity->getData() > 0; }, - 'expectedMessage' => 'Parameter in PurgeOn::if closure must be of type ' . DummyEntity::class, + 'expectedMessage' => 'Parameter in PurgeOn::if closure must be of type '.DummyEntity::class, ]; yield 'nullable parameter type' => [ 'if' => static function (?DummyEntity $entity): bool { return $entity?->getData() > 0; }, - 'expectedMessage' => 'Parameter in PurgeOn::if closure must be of type ' . DummyEntity::class, + 'expectedMessage' => 'Parameter in PurgeOn::if closure must be of type '.DummyEntity::class, ]; yield 'invalid parameter type' => [ 'if' => static function (\stdClass $entity): bool { return true; }, - 'expectedMessage' => 'Parameter in PurgeOn::if closure must be of type ' . DummyEntity::class, + 'expectedMessage' => 'Parameter in PurgeOn::if closure must be of type '.DummyEntity::class, ]; } } diff --git a/tests/Functional/Php85TestApplication/Entity/Plant.php b/tests/Functional/Php85TestApplication/Entity/Plant.php index 871983f1..17914b26 100644 --- a/tests/Functional/Php85TestApplication/Entity/Plant.php +++ b/tests/Functional/Php85TestApplication/Entity/Plant.php @@ -36,6 +36,4 @@ public function setWaterLevel(int $waterLevel): void { $this->waterLevel = $waterLevel; } - - } diff --git a/tests/RouteProvider/UpdatedEntityRouteProviderTest.php b/tests/RouteProvider/UpdatedEntityRouteProviderTest.php index 382314ff..29a8fc29 100644 --- a/tests/RouteProvider/UpdatedEntityRouteProviderTest.php +++ b/tests/RouteProvider/UpdatedEntityRouteProviderTest.php @@ -7,7 +7,6 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\RequiresFunction; use PHPUnit\Framework\Attributes\RequiresMethod; -use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; From 9cbf89569bc120d9302e9076fc50d4841a7270ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sun, 26 Oct 2025 18:02:32 +0100 Subject: [PATCH 5/8] update phpdoc --- src/Cache/Configuration/Configuration.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Configuration/Configuration.php b/src/Cache/Configuration/Configuration.php index 88ae2832..c0e466b2 100644 --- a/src/Cache/Configuration/Configuration.php +++ b/src/Cache/Configuration/Configuration.php @@ -58,6 +58,7 @@ public function count(): int * routeName: string, * routeParams?: array, optional?: true}>, * if?: string, + * closureIf?: true, * actions?: non-empty-list, * }>> */ From 507b536166c0b99c08db56f8cdfb3863ee5f96c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sun, 26 Oct 2025 18:06:07 +0100 Subject: [PATCH 6/8] throw exception if using closures when target is association --- src/Cache/PropertyResolver/AssociationResolver.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Cache/PropertyResolver/AssociationResolver.php b/src/Cache/PropertyResolver/AssociationResolver.php index 6d65e33c..3d2ec7ce 100644 --- a/src/Cache/PropertyResolver/AssociationResolver.php +++ b/src/Cache/PropertyResolver/AssociationResolver.php @@ -72,6 +72,10 @@ public function resolveSubscription( } if (null !== $if = $routeMetadata->purgeOn->if) { + if ($if instanceof \Closure) { + // TODO support closures + throw new \RuntimeException('Cannot create inverse subscription with closures'); + } $expression = (string) $if; $getter = $this->createGetter($associationClass, $associationTarget); $inverseIf = str_replace('obj', 'obj.'.$getter, $expression); From 7f51189f11ada06fec61525561b5c0670ea1968b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sun, 26 Oct 2025 18:11:58 +0100 Subject: [PATCH 7/8] fix test --- tests/Cache/Configuration/ConfigurationLoaderTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Cache/Configuration/ConfigurationLoaderTest.php b/tests/Cache/Configuration/ConfigurationLoaderTest.php index abd1b430..07a372e1 100644 --- a/tests/Cache/Configuration/ConfigurationLoaderTest.php +++ b/tests/Cache/Configuration/ConfigurationLoaderTest.php @@ -263,7 +263,7 @@ class: \stdClass::class, 'stdClass' => [ [ 'routeName' => 'app_route_foo', - 'if' => 'O:16:"Opis\Closure\Box":2:{i:0;i:1;i:1;a:1:{s:4:"info";a:4:{s:3:"key";s:32:"2a27e41247bbdcbf2bae2d27231c100b";s:6:"header";s:62:"namespace Sofascore\PurgatoryBundle\Tests\Cache\Configuration;";s:4:"body";s:56:"static function (\stdClass $entity): bool {return true;}";s:5:"flags";i:2;}}}', + 'if' => 'O:16:"Opis\Closure\Box":2:{i:0;i:1;i:1;a:1:{s:4:"info";a:4:{s:3:"key";s:32:"b2037a8181118b374eef46daefe3a977";s:6:"header";s:62:"namespace Sofascore\PurgatoryBundle\Tests\Cache\Configuration;";s:4:"body";s:57:"static function (\stdClass $entity): bool {return true; }";s:5:"flags";i:2;}}}', 'closureIf' => true, 'actions' => Action::cases(), ], From 7902ebf81e7dbf6f0729f180468323c7a64300bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Wed, 29 Oct 2025 23:23:44 +0100 Subject: [PATCH 8/8] run tests with php 8.5 --- .github/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 768ee087..d821dcbf 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - php: [ '8.1', '8.2', '8.3', '8.4' ] + php: [ '8.1', '8.2', '8.3', '8.4', '8.5' ] symfony: [ '5.4', '6.4', '7.0', '7.1', '7.2', '7.3' ] dependencies: [ 'highest', 'lowest' ] exclude: