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 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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: From e70797bac0d5c3315be4f278f569dae2fad4478b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sat, 14 Mar 2026 10:21:34 +0100 Subject: [PATCH 09/15] add expectations for mock --- tests/Cache/Configuration/ConfigurationLoaderTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Cache/Configuration/ConfigurationLoaderTest.php b/tests/Cache/Configuration/ConfigurationLoaderTest.php index bb76af68..a8c5025a 100644 --- a/tests/Cache/Configuration/ConfigurationLoaderTest.php +++ b/tests/Cache/Configuration/ConfigurationLoaderTest.php @@ -236,7 +236,8 @@ class: 'Foo', public function testSubscriptionsWithPhp85Features(array $purgeSubscriptions, array $expectedConfiguration): void { $purgeSubscriptionProvider = $this->createMock(PurgeSubscriptionProviderInterface::class); - $purgeSubscriptionProvider->method('provide') + $purgeSubscriptionProvider->expects(self::once()) + ->method('provide') ->willReturn($purgeSubscriptions); $loader = new ConfigurationLoader($purgeSubscriptionProvider); From e2381af2b8d87c3dd42dadb3948d312a61a5197d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sat, 14 Mar 2026 10:23:31 +0100 Subject: [PATCH 10/15] lint --- src/Cache/Subscription/PurgeSubscriptionProvider.php | 1 - tests/Cache/Subscription/PurgeSubscriptionProviderTest.php | 6 +++--- .../Php85TestApplication/Controller/PlantController.php | 4 ++-- tests/RouteProvider/UpdatedEntityRouteProviderTest.php | 1 - 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Cache/Subscription/PurgeSubscriptionProvider.php b/src/Cache/Subscription/PurgeSubscriptionProvider.php index d45aac59..b2acee4a 100644 --- a/src/Cache/Subscription/PurgeSubscriptionProvider.php +++ b/src/Cache/Subscription/PurgeSubscriptionProvider.php @@ -186,7 +186,6 @@ private function validateIfClosure(\Closure $expression, string $routeName, stri } } - private function validateExpression(Expression $expression, string $routeName): void { try { diff --git a/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php b/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php index 28aa8134..16404d20 100644 --- a/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php +++ b/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php @@ -601,7 +601,7 @@ public function testWithClosures(RouteMetadata $routeMetadata, array $expectedSu { $routeMetadataProvider = $this->createMock(RouteMetadataProviderInterface::class); $routeMetadataProvider->method('provide') - ->willReturnCallback(function () use ($routeMetadata) { + ->willReturnCallback(static function () use ($routeMetadata) { yield $routeMetadata; }); @@ -660,7 +660,7 @@ public function testInvalidClosures(\Closure $if, string $expectedMessage): void { $routeMetadataProvider = $this->createMock(RouteMetadataProviderInterface::class); $routeMetadataProvider->method('provide') - ->willReturnCallback(function () use ($if): iterable { + ->willReturnCallback(static function () use ($if): iterable { yield new RouteMetadata( routeName: 'foo', route: new Route('/{foo}'), @@ -683,7 +683,7 @@ class: DummyEntity::class, public function getFunctions(): array { return [ - new ExpressionFunction('valid_function', function () {}, function () {}), + new ExpressionFunction('valid_function', static function () {}, static function () {}), ]; } }, diff --git a/tests/Functional/Php85TestApplication/Controller/PlantController.php b/tests/Functional/Php85TestApplication/Controller/PlantController.php index 0d2fd32d..b6585938 100644 --- a/tests/Functional/Php85TestApplication/Controller/PlantController.php +++ b/tests/Functional/Php85TestApplication/Controller/PlantController.php @@ -18,9 +18,9 @@ class PlantController #[AnnotationRoute('/plants/dry', name: 'dry_plants_list')] #[PurgeOn(Plant::class, if: static function (Plant $plant): bool { - return $plant->getWaterLevel() === 0; + return 0 === $plant->getWaterLevel(); }, - actions: Action::Create + actions: Action::Create, )] public function dryPlantsAction() { diff --git a/tests/RouteProvider/UpdatedEntityRouteProviderTest.php b/tests/RouteProvider/UpdatedEntityRouteProviderTest.php index 60169975..dfd126d7 100644 --- a/tests/RouteProvider/UpdatedEntityRouteProviderTest.php +++ b/tests/RouteProvider/UpdatedEntityRouteProviderTest.php @@ -6,7 +6,6 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\RequiresFunction; -use PHPUnit\Framework\Attributes\RequiresMethod; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; From ce4f53e90dc1137e954500bf1197eaf020c3d063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sat, 14 Mar 2026 10:38:00 +0100 Subject: [PATCH 11/15] stan --- phpstan-baseline.neon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6d361a01..72112176 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -13,13 +13,13 @@ parameters: path: src/Attribute/RouteParamValue/EnumValues.php - - message: '#^Parameter \#1 \$configuration of class Sofascore\\PurgatoryBundle\\Cache\\Configuration\\Configuration constructor expects array\, optional\?\: true\}\>, if\?\: string, actions\?\: non\-empty\-list\\}\>\>, mixed given\.$#' + message: '#^Parameter \#1 \$configuration of class Sofascore\\PurgatoryBundle\\Cache\\Configuration\\Configuration constructor expects array\, optional\?\: true\}\>, if\?\: string, closureIf\?\: true, actions\?\: non\-empty\-list\\}\>\>, mixed given\.$#' identifier: argument.type count: 1 path: src/Cache/Configuration/CachedConfigurationLoader.php - - message: '#^Method Sofascore\\PurgatoryBundle\\Cache\\Configuration\\Subscriptions\:\:getIterator\(\) should return Traversable\, optional\?\: true\}\>, if\?\: string, actions\?\: non\-empty\-list\\}\> but returns ArrayIterator\, optional\?\: bool\}\>, if\?\: string, actions\?\: non\-empty\-list\\}\>\.$#' + message: '#^Method Sofascore\\PurgatoryBundle\\Cache\\Configuration\\Subscriptions\:\:getIterator\(\) should return Traversable\, optional\?\: true\}\>, if\?\: string, closureIf\?\: true, actions\?\: non\-empty\-list\\}\> but returns ArrayIterator\, optional\?\: bool\}\>, if\?\: string, closureIf\?\: bool, actions\?\: non\-empty\-list\\}\>\.$#' identifier: return.type count: 1 path: src/Cache/Configuration/Subscriptions.php From be3b0d5f75bd27e92fbedcf377e10c61d3b95339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sat, 14 Mar 2026 10:48:23 +0100 Subject: [PATCH 12/15] fix test --- tests/Application/Php85ConfigurationTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Application/Php85ConfigurationTest.php b/tests/Application/Php85ConfigurationTest.php index 8cd70ec3..bf33a128 100644 --- a/tests/Application/Php85ConfigurationTest.php +++ b/tests/Application/Php85ConfigurationTest.php @@ -49,9 +49,9 @@ 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; + O:16:"Opis\Closure\Box":2:{i:0;i:1;i:1;a:1:{s:4:"info";a:4:{s:3:"key";s:32:"7de5a138e0501360b836ac5fe50fc543";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; + return 0 === $plant->getWaterLevel(); }";s:5:"flags";i:2;}}} EOF; From aacb9e2655e1af7f1e4fd19573451e3a1dc31437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sun, 22 Mar 2026 21:16:19 +0100 Subject: [PATCH 13/15] cleanup --- .../PurgeSubscriptionProvider.php | 8 +-- src/Exception/InvalidIfClosureException.php | 15 +++++ .../PurgeSubscriptionProviderTest.php | 55 ++++++++----------- 3 files changed, 43 insertions(+), 35 deletions(-) create mode 100644 src/Exception/InvalidIfClosureException.php diff --git a/src/Cache/Subscription/PurgeSubscriptionProvider.php b/src/Cache/Subscription/PurgeSubscriptionProvider.php index b2acee4a..cad12ce9 100644 --- a/src/Cache/Subscription/PurgeSubscriptionProvider.php +++ b/src/Cache/Subscription/PurgeSubscriptionProvider.php @@ -16,9 +16,9 @@ use Sofascore\PurgatoryBundle\Cache\RouteMetadata\RouteMetadataProviderInterface; use Sofascore\PurgatoryBundle\Cache\TargetResolver\TargetResolverInterface; use Sofascore\PurgatoryBundle\Exception\EntityMetadataNotFoundException; +use Sofascore\PurgatoryBundle\Exception\InvalidIfClosureException; 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; @@ -169,11 +169,11 @@ private function validateIfClosure(\Closure $expression, string $routeName, stri || $returnType->allowsNull() || !\in_array($returnType->getName(), ['bool', 'true', 'false']) ) { - throw new RuntimeException('Return type of PurgeOn::if closure must be bool'); + throw new InvalidIfClosureException($routeName, 'Return type must be bool'); } if (1 !== $reflection->getNumberOfParameters()) { - throw new RuntimeException('PurgeOn::if closure must have exactly 1 parameter'); + throw new InvalidIfClosureException($routeName, 'Closure must have exactly 1 parameter'); } $parameterType = $reflection->getParameters()[0]->getType(); @@ -182,7 +182,7 @@ private function validateIfClosure(\Closure $expression, string $routeName, stri || $parameterType->allowsNull() || !is_a($entity, $parameterType->getName(), true) ) { - throw new RuntimeException("Parameter in PurgeOn::if closure must be of type $entity"); + throw new InvalidIfClosureException($routeName, "Parameter in closure must be of type $entity"); } } diff --git a/src/Exception/InvalidIfClosureException.php b/src/Exception/InvalidIfClosureException.php new file mode 100644 index 00000000..646f1f0d --- /dev/null +++ b/src/Exception/InvalidIfClosureException.php @@ -0,0 +1,15 @@ +createMock(RouteMetadataProviderInterface::class); - $routeMetadataProvider->method('provide') + $routeMetadataProvider->expects(self::once()) + ->method('provide') ->willReturnCallback(static function () use ($routeMetadata) { yield $routeMetadata; }); @@ -611,7 +614,7 @@ public function testWithClosures(RouteMetadata $routeMetadata, array $expectedSu $purgeSubscriptionProvider = new PurgeSubscriptionProvider( subscriptionResolvers: [], routeMetadataProviders: [$routeMetadataProvider], - managerRegistry: $this->createMock(ManagerRegistry::class), + managerRegistry: self::createStub(ManagerRegistry::class), targetResolverLocator: $targetResolverLocator, expressionLanguage: null, ); @@ -626,15 +629,17 @@ public function testWithClosures(RouteMetadata $routeMetadata, array $expectedSu public static function providerRouteMetadataWithPhp85Features(): iterable { $route = new Route('/foo'); + $if = static function (DummyEntity $entity): bool { + return $entity->getData() > 0; + }; + 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; - }, + if: $if, ), reflectionMethod: new \ReflectionMethod(DummyController::class, 'barAction'), ), @@ -646,9 +651,7 @@ class: DummyEntity::class, routeName: 'foo', route: $route, actions: null, - if: static function (DummyEntity $entity): bool { - return $entity->getData() > 0; - }, + if: $if, ), ], ]; @@ -659,7 +662,8 @@ class: DummyEntity::class, public function testInvalidClosures(\Closure $if, string $expectedMessage): void { $routeMetadataProvider = $this->createMock(RouteMetadataProviderInterface::class); - $routeMetadataProvider->method('provide') + $routeMetadataProvider->expects(self::once()) + ->method('provide') ->willReturnCallback(static function () use ($if): iterable { yield new RouteMetadata( routeName: 'foo', @@ -675,23 +679,12 @@ class: DummyEntity::class, $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', static function () {}, static function () {}), - ]; - } - }, - ], - ), + managerRegistry: self::createStub(ManagerRegistry::class), + targetResolverLocator: self::createStub(ContainerInterface::class), + expressionLanguage: self::createStub(ExpressionLanguage::class), ); - $this->expectException(\RuntimeException::class); + $this->expectException(InvalidIfClosureException::class); $this->expectExceptionMessage($expectedMessage); [...$purgeSubscriptionProvider->provide()]; @@ -703,49 +696,49 @@ public static function provideInvalidClosures(): iterable 'if' => static function (DummyEntity $entity): int|string { return $entity->getData(); }, - 'expectedMessage' => 'Return type of PurgeOn::if closure must be bool', + 'expectedMessage' => 'Return type 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', + 'expectedMessage' => 'Return type 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', + 'expectedMessage' => 'Return type 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', + 'expectedMessage' => '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, + 'expectedMessage' => 'Parameter in 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 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 closure must be of type '.DummyEntity::class, ]; } } From 60314d0bd9c8e2de3ab9d3dd64f6607c7bf2675f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sat, 25 Apr 2026 20:59:59 +0200 Subject: [PATCH 14/15] debug command --- src/Command/DebugCommand.php | 16 +++++++++++++++- tests/Command/DebugCommandTest.php | 13 +++++++++++++ .../DebugCommand/Controller/PostController.php | 5 +++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/Command/DebugCommand.php b/src/Command/DebugCommand.php index 696d1c1b..c0906805 100644 --- a/src/Command/DebugCommand.php +++ b/src/Command/DebugCommand.php @@ -6,6 +6,8 @@ use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\Mapping\ClassMetadata; +use Opis\Closure\Box; +use Opis\Closure\ReflectionClosure; use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\CompoundValues; use Sofascore\PurgatoryBundle\Cache\Configuration\Configuration; use Sofascore\PurgatoryBundle\Cache\Configuration\ConfigurationLoaderInterface; @@ -18,6 +20,8 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use function Opis\Closure\unserialize; + #[AsCommand( name: 'purgatory:debug', description: 'Display purge subscription information for an entity or multiple entities', @@ -249,6 +253,7 @@ private function findSubscriptionsForRoute(string $routeName): array * routeName: string, * routeParams?: array, optional?: true}>, * if?: string, + * closureIf?: true, * actions?: non-empty-list, * }>> $configuration */ @@ -260,6 +265,15 @@ private function display(SymfonyStyle $io, array $configuration): void $entity = explode('::', $key); foreach ($subscriptions as $subscription) { + if (isset($subscription['closureIf'])) { + $r = new ReflectionClosure(unserialize($subscription['if'], options: ['allowed_classes' => [Box::class]])); + $closureBody = $r->info()->getIncludePHP(false); + + $if = rtrim(substr($closureBody, strpos($closureBody, 'return ') + \strlen('return ')), ';'); + } else { + $if = $subscription['if'] ?? 'NONE'; + } + $io->table( ['Option', 'Value'], [ @@ -267,7 +281,7 @@ private function display(SymfonyStyle $io, array $configuration): void ['Property', $entity[1] ?? 'ANY'], ['Route Name', $subscription['routeName']], ['Route Params', isset($subscription['routeParams']) ? $this->formatRouteParams($subscription['routeParams']) : 'NONE'], - ['Condition', $subscription['if'] ?? 'NONE'], + ['Condition', $if], ['Actions', isset($subscription['actions']) ? $this->formatActions($subscription['actions']) : 'ANY'], ], ); diff --git a/tests/Command/DebugCommandTest.php b/tests/Command/DebugCommandTest.php index db028b4d..a7ef793e 100644 --- a/tests/Command/DebugCommandTest.php +++ b/tests/Command/DebugCommandTest.php @@ -101,6 +101,19 @@ public function testOptionAll(): void needle: 'full_name: Expression("obj.firstName~\"-\"~obj.lastName")', haystack: $display, ); + + $expectedClosure = <<<'PHP' + Condition static function (Author $author): bool { + return !$author->getPosts()->isEmpty() + && null !== $author->getFirstName() + && null !== $author->getLastName(); + } + PHP; + + self::assertStringContainsString( + needle: $expectedClosure, + haystack: preg_replace('/ +$/m', '', $display), + ); } public function testOptionRoute(): void diff --git a/tests/Functional/DebugCommand/Controller/PostController.php b/tests/Functional/DebugCommand/Controller/PostController.php index 1293d94c..205bec16 100644 --- a/tests/Functional/DebugCommand/Controller/PostController.php +++ b/tests/Functional/DebugCommand/Controller/PostController.php @@ -81,6 +81,11 @@ public function filterByAuthorAndTag(Author $author) routeParams: [ 'full_name' => new ExpressionValues('obj.firstName~"-"~obj.lastName'), ], + if: static function (Author $author): bool { + return !$author->getPosts()->isEmpty() + && null !== $author->getFirstName() + && null !== $author->getLastName(); + }, )] public function filterByAuthorFullName() { From 8da47f02ddf3ba05fe9120f5925ce724d5936e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sat, 25 Apr 2026 21:19:40 +0200 Subject: [PATCH 15/15] fix tests --- tests/Application/Php85ApplicationTest.php | 2 +- tests/Application/Php85ConfigurationTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Application/Php85ApplicationTest.php b/tests/Application/Php85ApplicationTest.php index 92485814..cf1d4d07 100644 --- a/tests/Application/Php85ApplicationTest.php +++ b/tests/Application/Php85ApplicationTest.php @@ -12,7 +12,7 @@ use Sofascore\PurgatoryBundle\Tests\Functional\Php85TestApplication\Controller\PlantController; use Sofascore\PurgatoryBundle\Tests\Functional\Php85TestApplication\Entity\Plant; -#[RequiresPhp('>= 8.5')] +#[RequiresPhp('>= 8.5.0')] #[RequiresFunction('\Opis\Closure\serialize')] final class Php85ApplicationTest extends AbstractKernelTestCase { diff --git a/tests/Application/Php85ConfigurationTest.php b/tests/Application/Php85ConfigurationTest.php index bf33a128..98cadaaf 100644 --- a/tests/Application/Php85ConfigurationTest.php +++ b/tests/Application/Php85ConfigurationTest.php @@ -13,7 +13,7 @@ use Sofascore\PurgatoryBundle\Tests\Functional\Php85TestApplication\Controller\PlantController; use Sofascore\PurgatoryBundle\Tests\Functional\Php85TestApplication\Entity\Plant; -#[RequiresPhp('>= 8.5')] +#[RequiresPhp('>= 8.5.0')] #[RequiresFunction('\Opis\Closure\serialize')] class Php85ConfigurationTest extends AbstractKernelTestCase {