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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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 { From 0279411897e9792b4e842ae42a83f734aea989ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Thu, 4 Jun 2026 17:48:16 +0200 Subject: [PATCH 16/34] tests --- tests/Command/DebugCommandTest.php | 13 ---- tests/Command/Php85DebugCommandTest.php | 66 +++++++++++++++++++ .../Controller/PostController.php | 5 -- 3 files changed, 66 insertions(+), 18 deletions(-) create mode 100644 tests/Command/Php85DebugCommandTest.php diff --git a/tests/Command/DebugCommandTest.php b/tests/Command/DebugCommandTest.php index a7ef793e..db028b4d 100644 --- a/tests/Command/DebugCommandTest.php +++ b/tests/Command/DebugCommandTest.php @@ -101,19 +101,6 @@ 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/Command/Php85DebugCommandTest.php b/tests/Command/Php85DebugCommandTest.php new file mode 100644 index 00000000..93b54c3d --- /dev/null +++ b/tests/Command/Php85DebugCommandTest.php @@ -0,0 +1,66 @@ += 8.5.0')] +#[RequiresFunction('\Opis\Closure\serialize')] +final class Php85DebugCommandTest extends AbstractKernelTestCase +{ + private string|false $colSize; + private CommandTester $command; + + protected function setUp(): void + { + $this->colSize = getenv('COLUMNS'); + putenv('COLUMNS=300'); + + self::initializeApplication(['test_case' => 'Php85TestApplication', 'config' => 'app_config.yaml']); + + $this->command = new CommandTester( + command: (new Application(self::$kernel))->find('purgatory:debug'), + ); + } + + protected function tearDown(): void + { + putenv($this->colSize ? 'COLUMNS='.$this->colSize : 'COLUMNS'); + + unset( + $this->colSize, + $this->command, + ); + + parent::tearDown(); + } + + public function testClosureIfIsRendered(): void + { + $this->command->execute([ + '--all' => true, + ]); + + $this->command->assertCommandIsSuccessful(); + + $expectedClosure = <<<'PHP' + Condition static function (Plant $plant): bool { + return 0 === $plant->getWaterLevel(); + } + PHP; + + self::assertStringContainsString( + needle: $expectedClosure, + haystack: preg_replace('/ +$/m', '', $this->command->getDisplay()), + ); + } +} diff --git a/tests/Functional/DebugCommand/Controller/PostController.php b/tests/Functional/DebugCommand/Controller/PostController.php index 278f6738..9d15c309 100644 --- a/tests/Functional/DebugCommand/Controller/PostController.php +++ b/tests/Functional/DebugCommand/Controller/PostController.php @@ -81,11 +81,6 @@ public function filterByAuthorAndTag(Author $author): void 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(): void { From a5c47ae3f958888d46e9972f2742750a64a37000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Thu, 4 Jun 2026 18:05:12 +0200 Subject: [PATCH 17/34] cs/sa --- src/Command/DebugCommand.php | 6 ++++-- .../Php85TestApplication/Controller/PlantController.php | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Command/DebugCommand.php b/src/Command/DebugCommand.php index c0906805..adc994ef 100644 --- a/src/Command/DebugCommand.php +++ b/src/Command/DebugCommand.php @@ -265,8 +265,10 @@ 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]])); + if (isset($subscription['closureIf'], $subscription['if'])) { + /** @var \Closure $closure */ + $closure = unserialize($subscription['if'], options: ['allowed_classes' => [Box::class]]); + $r = new ReflectionClosure($closure); $closureBody = $r->info()->getIncludePHP(false); $if = rtrim(substr($closureBody, strpos($closureBody, 'return ') + \strlen('return ')), ';'); diff --git a/tests/Functional/Php85TestApplication/Controller/PlantController.php b/tests/Functional/Php85TestApplication/Controller/PlantController.php index b6585938..8e270226 100644 --- a/tests/Functional/Php85TestApplication/Controller/PlantController.php +++ b/tests/Functional/Php85TestApplication/Controller/PlantController.php @@ -22,7 +22,7 @@ class PlantController }, actions: Action::Create, )] - public function dryPlantsAction() + public function dryPlantsAction(): void { } } From b7a9ed6dc353c2902935efe6aab23d1ea34520e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Thu, 11 Jun 2026 20:44:39 +0200 Subject: [PATCH 18/34] check if opis/closure is installed --- src/Attribute/PurgeOn.php | 4 ++++ tests/Attribute/PurgeOnTest.php | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/Attribute/PurgeOn.php b/src/Attribute/PurgeOn.php index 04841014..8f25db61 100644 --- a/src/Attribute/PurgeOn.php +++ b/src/Attribute/PurgeOn.php @@ -39,6 +39,10 @@ public function __construct( string|array|null $route = null, string|array|Action|null $actions = null, ) { + if ($if instanceof \Closure && !\function_exists('Opis\Closure\serialize')) { + throw new LogicException('You cannot use a closure for the "if" attribute because the "opis/closure" package is not installed. Try running "composer require opis/closure".'); + } + $this->target = \is_array($target) || \is_string($target) ? new ForProperties($target) : $target; $this->routeParams = null !== $routeParams ? self::normalizeRouteParams($routeParams) : null; $this->if = \is_string($if) ? self::normalizeExpression($if) : $if; diff --git a/tests/Attribute/PurgeOnTest.php b/tests/Attribute/PurgeOnTest.php index 17e2ca08..13177aac 100644 --- a/tests/Attribute/PurgeOnTest.php +++ b/tests/Attribute/PurgeOnTest.php @@ -5,6 +5,7 @@ namespace Sofascore\PurgatoryBundle\Tests\Attribute; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\RequiresFunction; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Sofascore\PurgatoryBundle\Attribute\PurgeOn; @@ -45,4 +46,14 @@ public function testValueNormalization(string $property, mixed $value, mixed $ex self::assertEquals($expectedValue, $purgeOn->$property); } + + #[RequiresFunction('\Opis\Closure\serialize')] + public function testIfWithClosure(): void + { + $if = static fn (\stdClass $obj): bool => true; + + $purgeOn = new PurgeOn(\stdClass::class, if: $if); + + self::assertSame($if, $purgeOn->if); + } } From 1d844cde80d9b9e94a79932723061e0124590795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Thu, 11 Jun 2026 20:48:24 +0200 Subject: [PATCH 19/34] use native ReflectionFunction when validating if closure --- src/Cache/Subscription/PurgeSubscriptionProvider.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Cache/Subscription/PurgeSubscriptionProvider.php b/src/Cache/Subscription/PurgeSubscriptionProvider.php index cad12ce9..4aa29d09 100644 --- a/src/Cache/Subscription/PurgeSubscriptionProvider.php +++ b/src/Cache/Subscription/PurgeSubscriptionProvider.php @@ -5,7 +5,6 @@ namespace Sofascore\PurgatoryBundle\Cache\Subscription; use Doctrine\Persistence\ManagerRegistry; -use Opis\Closure\ReflectionClosure; use Psr\Container\ContainerInterface; use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\ExpressionValues; use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\PropertyValues; @@ -161,7 +160,7 @@ private function validateIf(\Closure|Expression $expression, string $routeName, private function validateIfClosure(\Closure $expression, string $routeName, string $entity): void { - $reflection = new ReflectionClosure($expression); + $reflection = new \ReflectionFunction($expression); $returnType = $reflection->getReturnType(); From 01657e0fd0ccd371b3828b7ef1dd173fcf381fca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Thu, 11 Jun 2026 21:02:05 +0200 Subject: [PATCH 20/34] assert that closure is static and does not use outside variables --- .../PurgeSubscriptionProvider.php | 8 +++++++ .../PurgeSubscriptionProviderTest.php | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/Cache/Subscription/PurgeSubscriptionProvider.php b/src/Cache/Subscription/PurgeSubscriptionProvider.php index 4aa29d09..390c10f7 100644 --- a/src/Cache/Subscription/PurgeSubscriptionProvider.php +++ b/src/Cache/Subscription/PurgeSubscriptionProvider.php @@ -162,6 +162,14 @@ private function validateIfClosure(\Closure $expression, string $routeName, stri { $reflection = new \ReflectionFunction($expression); + if (null !== $reflection->getClosureThis()) { + throw new InvalidIfClosureException($routeName, 'Closure must be static'); + } + + if ([] !== $reflection->getClosureUsedVariables()) { + throw new InvalidIfClosureException($routeName, 'Closure must not capture variables'); + } + $returnType = $reflection->getReturnType(); if (!$returnType instanceof \ReflectionNamedType diff --git a/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php b/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php index 205f87d9..64a9563a 100644 --- a/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php +++ b/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php @@ -740,5 +740,28 @@ public static function provideInvalidClosures(): iterable }, 'expectedMessage' => 'Parameter in closure must be of type '.DummyEntity::class, ]; + + yield 'closure bound to an instance' => [ + 'if' => \Closure::bind(function (DummyEntity $entity): bool { + return true; + }, new DummyEntity(), DummyEntity::class), + 'expectedMessage' => 'Closure must be static', + ]; + + $number = 1; + yield 'captured scalar variable' => [ + 'if' => static function (DummyEntity $entity) use ($number): bool { + return $entity->getData() > $number; + }, + 'expectedMessage' => 'Closure must not capture variables', + ]; + + $object = new DummyEntity(); + yield 'captured object variable' => [ + 'if' => static function (DummyEntity $entity) use ($object): bool { + return $entity->getData() > $object->getData(); + }, + 'expectedMessage' => 'Closure must not capture variables', + ]; } } From b609509aeaaba808173f1358e393cc45747b2436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Thu, 11 Jun 2026 21:03:18 +0200 Subject: [PATCH 21/34] validate if result only for expressions --- src/RouteProvider/AbstractEntityRouteProvider.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/RouteProvider/AbstractEntityRouteProvider.php b/src/RouteProvider/AbstractEntityRouteProvider.php index e4305098..5af5bd09 100644 --- a/src/RouteProvider/AbstractEntityRouteProvider.php +++ b/src/RouteProvider/AbstractEntityRouteProvider.php @@ -80,13 +80,15 @@ private function processValidSubscriptions(Subscriptions $subscriptions, array $ if (isset($subscription['closureIf'])) { /** @var \Closure $closure */ $closure = unserialize($subscription['if'], options: ['allowed_classes' => [Box::class]]); + + /** @var bool $result */ $result = $closure($entity); } else { $result = $this->getExpressionLanguage()->evaluate($subscription['if'], ['obj' => $entity]); - } - if (!\is_bool($result)) { - throw new InvalidIfExpressionResultException($subscription['routeName'], $subscription['if'], $result); + if (!\is_bool($result)) { + throw new InvalidIfExpressionResultException($subscription['routeName'], $subscription['if'], $result); + } } if (!$result) { From 6662944d67b170757b94138f744f96cb37de0476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Thu, 11 Jun 2026 21:10:17 +0200 Subject: [PATCH 22/34] memoize unserialized closures --- src/RouteProvider/AbstractEntityRouteProvider.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/RouteProvider/AbstractEntityRouteProvider.php b/src/RouteProvider/AbstractEntityRouteProvider.php index 5af5bd09..33b659bd 100644 --- a/src/RouteProvider/AbstractEntityRouteProvider.php +++ b/src/RouteProvider/AbstractEntityRouteProvider.php @@ -34,6 +34,9 @@ abstract protected function getChangedProperties(object $entity, array $entityCh private ?Configuration $configuration = null; + /** @var array */ + private array $unserializedClosures = []; + public function __construct( private readonly ConfigurationLoaderInterface $configurationLoader, private readonly ?ExpressionLanguage $expressionLanguage, @@ -78,8 +81,7 @@ private function processValidSubscriptions(Subscriptions $subscriptions, array $ if (isset($subscription['if'])) { if (isset($subscription['closureIf'])) { - /** @var \Closure $closure */ - $closure = unserialize($subscription['if'], options: ['allowed_classes' => [Box::class]]); + $closure = $this->unserializedClosures[$subscription['if']] ??= $this->unserializeClosure($subscription['if']); /** @var bool $result */ $result = $closure($entity); @@ -182,4 +184,10 @@ private function getExpressionLanguage(): ExpressionLanguage return $this->expressionLanguage ?? throw new LogicException('You cannot use expressions because the Symfony ExpressionLanguage component is not installed. Try running "composer require symfony/expression-language".'); } + + private function unserializeClosure(string $serializedClosure): \Closure + { + /** @var \Closure */ + return unserialize($serializedClosure, options: ['allowed_classes' => [Box::class]]); + } } From fb52de7282c3ee3c6744bd95fb87e1e924dbf6e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Thu, 11 Jun 2026 21:27:52 +0200 Subject: [PATCH 23/34] update docs --- docs/README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/README.md b/docs/README.md index c5f653bb..46459fda 100644 --- a/docs/README.md +++ b/docs/README.md @@ -378,6 +378,33 @@ In this example, the purge will only occur if the post has more than 3,000 upvot You can also add [custom Expression Language functions](custom-expression-language-functions.md). +### Adding Conditional Logic with Closures + +Starting with [PHP 8.5](https://www.php.net/releases/8.5/en.php), closures can be used in attributes, so the same +condition can be written in plain PHP instead of an expression. The closure receives the entity as its only argument: + +```php +#[Route('/post/{id<\d+>}', name: 'post_details', methods: 'GET')] +#[PurgeOn(Post::class, if: static function (Post $post): bool { + return $post->upvotes > 3000; +})] +public function detailsAction(Post $post) +{ +} +``` + +This feature requires the [`opis/closure`](https://github.com/opis/closure) package: + +```sh +composer require opis/closure +``` + +The closure must: + +- have exactly one parameter, typed with the subscribed entity class or one of its parents, +- declare a non-nullable `bool` return type. + + ### Using Purge on Actions with Multiple Routes By default, the attribute generates URLs for all routes associated with the action. You can limit this to one or more From 1286019cbc1cc3c4e58cd2f64e1f3d81b0fd1d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Thu, 11 Jun 2026 21:31:32 +0200 Subject: [PATCH 24/34] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec17c604..54f146d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 in https://github.com/sofascore/purgatory-bundle/pull/112 - Ability to pass a static method callable as a `DynamicValues` provider by @HypeMC in https://github.com/sofascore/purgatory-bundle/pull/137 +- Ability to use a closure as the `PurgeOn` `if` condition on PHP 8.5+ by @Brajk19 + in https://github.com/sofascore/purgatory-bundle/pull/116 ### Changed From 1a52072afa016500c44a236b0b19464694302466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Thu, 11 Jun 2026 22:12:13 +0200 Subject: [PATCH 25/34] cs --- src/RouteProvider/AbstractEntityRouteProvider.php | 2 +- tests/Cache/Subscription/PurgeSubscriptionProviderTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/RouteProvider/AbstractEntityRouteProvider.php b/src/RouteProvider/AbstractEntityRouteProvider.php index 33b659bd..45be2041 100644 --- a/src/RouteProvider/AbstractEntityRouteProvider.php +++ b/src/RouteProvider/AbstractEntityRouteProvider.php @@ -187,7 +187,7 @@ private function getExpressionLanguage(): ExpressionLanguage private function unserializeClosure(string $serializedClosure): \Closure { - /** @var \Closure */ + /* @var \Closure */ return unserialize($serializedClosure, options: ['allowed_classes' => [Box::class]]); } } diff --git a/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php b/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php index 64a9563a..1c427edf 100644 --- a/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php +++ b/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php @@ -742,7 +742,7 @@ public static function provideInvalidClosures(): iterable ]; yield 'closure bound to an instance' => [ - 'if' => \Closure::bind(function (DummyEntity $entity): bool { + 'if' => \Closure::bind(static function (DummyEntity $entity): bool { return true; }, new DummyEntity(), DummyEntity::class), 'expectedMessage' => 'Closure must be static', From 1b65a295d332755f8bc58fe2ee1bd2cc1fee8f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Thu, 11 Jun 2026 22:15:00 +0200 Subject: [PATCH 26/34] phpstan --- src/RouteProvider/AbstractEntityRouteProvider.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/RouteProvider/AbstractEntityRouteProvider.php b/src/RouteProvider/AbstractEntityRouteProvider.php index 45be2041..924ee6c6 100644 --- a/src/RouteProvider/AbstractEntityRouteProvider.php +++ b/src/RouteProvider/AbstractEntityRouteProvider.php @@ -187,7 +187,9 @@ private function getExpressionLanguage(): ExpressionLanguage private function unserializeClosure(string $serializedClosure): \Closure { - /* @var \Closure */ - return unserialize($serializedClosure, options: ['allowed_classes' => [Box::class]]); + /** @var \Closure $closure */ + $closure = unserialize($serializedClosure, options: ['allowed_classes' => [Box::class]]); + + return $closure; } } From 1687dee805912a6302be46c61a3467c496f703b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Thu, 11 Jun 2026 22:21:47 +0200 Subject: [PATCH 27/34] fix test --- .../Subscription/PurgeSubscriptionProviderTest.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php b/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php index 1c427edf..5d6267af 100644 --- a/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php +++ b/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php @@ -742,9 +742,14 @@ public static function provideInvalidClosures(): iterable ]; yield 'closure bound to an instance' => [ - 'if' => \Closure::bind(static function (DummyEntity $entity): bool { - return true; - }, new DummyEntity(), DummyEntity::class), + 'if' => (new class { + public function getIf(): \Closure + { + return function (DummyEntity $entity): bool { + return $this instanceof self; + }; + } + })->getIf(), 'expectedMessage' => 'Closure must be static', ]; From 251bd33de298411092b929335e7bf90c957d30ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sat, 27 Jun 2026 15:11:38 +0200 Subject: [PATCH 28/34] use symfony/deepclone instead of opis/closure --- composer.json | 6 +- docs/README.md | 6 +- src/Attribute/PurgeOn.php | 4 +- src/Cache/Configuration/Configuration.php | 6 +- .../Configuration/ConfigurationLoader.php | 5 +- src/Cache/Configuration/Subscriptions.php | 9 +-- src/Command/DebugCommand.php | 56 +++++++++++++------ .../AbstractEntityRouteProvider.php | 19 +------ tests/Application/Php85ApplicationTest.php | 2 - tests/Application/Php85ConfigurationTest.php | 24 ++++---- tests/Attribute/PurgeOnTest.php | 2 +- .../Configuration/ConfigurationLoaderTest.php | 13 +++-- .../PurgeSubscriptionProviderTest.php | 6 +- tests/Command/Php85DebugCommandTest.php | 13 ++--- .../UpdatedEntityRouteProviderTest.php | 26 ++++----- 15 files changed, 101 insertions(+), 96 deletions(-) diff --git a/composer.json b/composer.json index 2497bdd7..5b2ccc55 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,6 @@ }, "require-dev": { "doctrine/common": "^3.2", - "opis/closure": "^4.3", "phpunit/phpunit": "^12.5", "symfony/cache": "^6.4 || ^7.4 || ^8.0", "symfony/doctrine-messenger": "^6.4 || ^7.4 || ^8.0", @@ -55,6 +54,7 @@ "symfony/filesystem": "^6.4 || ^7.4 || ^8.0", "symfony/http-client": "^6.4 || ^7.4 || ^8.0", "symfony/messenger": "^6.4 || ^7.4 || ^8.0", + "symfony/polyfill-deepclone": "^1.39", "symfony/process": "^6.4 || ^7.4 || ^8.0", "symfony/serializer": "^6.4 || ^7.4 || ^8.0", "symfony/yaml": "^6.4 || ^7.4 || ^8.0" @@ -67,6 +67,10 @@ "symfony/serializer": "<6.4", "symfony/yaml": "<6.4" }, + "suggest": { + "symfony/polyfill-deepclone": "To use a closure as the \"if\" condition of a #[PurgeOn] attribute (PHP 8.5+)", + "symfony/deepclone": "Faster, native PHP extension alternative to symfony/polyfill-deepclone for using a closure as the \"if\" condition of a #[PurgeOn] attribute (PHP 8.5+)" + }, "scripts": { "run-checks": [ "php-cs-fixer fix -vvv", diff --git a/docs/README.md b/docs/README.md index 46459fda..f8eec8eb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -393,12 +393,14 @@ public function detailsAction(Post $post) } ``` -This feature requires the [`opis/closure`](https://github.com/opis/closure) package: +This feature requires the [`symfony/polyfill-deepclone`](https://github.com/symfony/polyfill-deepclone) package: ```sh -composer require opis/closure +composer require symfony/polyfill-deepclone ``` +For better performance you can instead install the [`symfony/deepclone`](https://github.com/symfony/php-ext-deepclone) PHP extension. + The closure must: - have exactly one parameter, typed with the subscribed entity class or one of its parents, diff --git a/src/Attribute/PurgeOn.php b/src/Attribute/PurgeOn.php index 8f25db61..9b023253 100644 --- a/src/Attribute/PurgeOn.php +++ b/src/Attribute/PurgeOn.php @@ -39,8 +39,8 @@ public function __construct( string|array|null $route = null, string|array|Action|null $actions = null, ) { - if ($if instanceof \Closure && !\function_exists('Opis\Closure\serialize')) { - throw new LogicException('You cannot use a closure for the "if" attribute because the "opis/closure" package is not installed. Try running "composer require opis/closure".'); + if ($if instanceof \Closure && !\function_exists('deepclone_to_array')) { + throw new LogicException('You cannot use a closure for the "if" attribute because deepclone support is not available. Install the "symfony/polyfill-deepclone" package (run "composer require symfony/polyfill-deepclone") or the "symfony/deepclone" PHP extension (https://github.com/symfony/php-ext-deepclone).'); } $this->target = \is_array($target) || \is_string($target) ? new ForProperties($target) : $target; diff --git a/src/Cache/Configuration/Configuration.php b/src/Cache/Configuration/Configuration.php index c0e466b2..34379d34 100644 --- a/src/Cache/Configuration/Configuration.php +++ b/src/Cache/Configuration/Configuration.php @@ -12,8 +12,7 @@ final class Configuration implements \Countable * @param array, optional?: true}>, - * if?: string, - * closureIf?: true, + * if?: string|array, * actions?: non-empty-list, * }>> $configuration */ @@ -57,8 +56,7 @@ public function count(): int * @return array, optional?: true}>, - * if?: string, - * closureIf?: true, + * if?: string|array, * actions?: non-empty-list, * }>> */ diff --git a/src/Cache/Configuration/ConfigurationLoader.php b/src/Cache/Configuration/ConfigurationLoader.php index e5bdeb46..f7f30a21 100644 --- a/src/Cache/Configuration/ConfigurationLoader.php +++ b/src/Cache/Configuration/ConfigurationLoader.php @@ -8,8 +8,6 @@ use Sofascore\PurgatoryBundle\Cache\Subscription\PurgeSubscriptionProviderInterface; use Symfony\Component\Routing\Route; -use function Opis\Closure\serialize; - final class ConfigurationLoader implements ConfigurationLoaderInterface { public function __construct( @@ -41,8 +39,7 @@ public function load(): Configuration if (null !== $subscription->if) { if ($subscription->if instanceof \Closure) { - $config['if'] = serialize($subscription->if); - $config['closureIf'] = true; + $config['if'] = deepclone_to_array($subscription->if); } else { $config['if'] = (string) $subscription->if; } diff --git a/src/Cache/Configuration/Subscriptions.php b/src/Cache/Configuration/Subscriptions.php index 124c4fde..fd47b54e 100644 --- a/src/Cache/Configuration/Subscriptions.php +++ b/src/Cache/Configuration/Subscriptions.php @@ -10,8 +10,7 @@ * @implements \IteratorAggregate, optional?: true}>, - * if?: string, - * closureIf?: true, + * if?: string|array, * actions?: non-empty-list, * }> */ @@ -22,8 +21,7 @@ final class Subscriptions implements \IteratorAggregate, \Countable * @param list, optional?: true}>, - * if?: string, - * closureIf?: true, + * if?: string|array, * actions?: non-empty-list, * }> $subscriptions */ @@ -55,8 +53,7 @@ public function key(): string * @return list, optional?: true}>, - * if?: string, - * closureIf?: true, + * if?: string|array, * actions?: non-empty-list, * }> */ diff --git a/src/Command/DebugCommand.php b/src/Command/DebugCommand.php index adc994ef..24fe8180 100644 --- a/src/Command/DebugCommand.php +++ b/src/Command/DebugCommand.php @@ -6,8 +6,6 @@ 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; @@ -20,8 +18,6 @@ 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', @@ -208,7 +204,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int * @return array, optional?: true}>, - * if?: string, + * if?: string|array, * actions?: non-empty-list, * }>> */ @@ -231,7 +227,7 @@ private function findSubscriptions(string $subscription, bool $withProperties): * @return array, optional?: true}>, - * if?: string, + * if?: string|array, * actions?: non-empty-list, * }>> */ @@ -252,8 +248,7 @@ private function findSubscriptionsForRoute(string $routeName): array * @param array, optional?: true}>, - * if?: string, - * closureIf?: true, + * if?: string|array, * actions?: non-empty-list, * }>> $configuration */ @@ -265,15 +260,10 @@ private function display(SymfonyStyle $io, array $configuration): void $entity = explode('::', $key); foreach ($subscriptions as $subscription) { - if (isset($subscription['closureIf'], $subscription['if'])) { - /** @var \Closure $closure */ - $closure = unserialize($subscription['if'], options: ['allowed_classes' => [Box::class]]); - $r = new ReflectionClosure($closure); - $closureBody = $r->info()->getIncludePHP(false); - - $if = rtrim(substr($closureBody, strpos($closureBody, 'return ') + \strlen('return ')), ';'); + if (isset($subscription['if']) && \is_array($subscription['if'])) { + $if = $this->formatClosureCondition($subscription['if']); } else { - $if = $subscription['if'] ?? 'NONE'; + $if = \is_string($subscription['if'] ?? null) ? $subscription['if'] : 'NONE'; } $io->table( @@ -291,6 +281,40 @@ private function display(SymfonyStyle $io, array $configuration): void } } + private function formatClosureCondition(array $serializedClosure): string + { + /** @var \Closure $closure */ + $closure = deepclone_from_array($serializedClosure); + $reflection = new \ReflectionFunction($closure); + + $file = $reflection->getFileName(); + $startLine = $reflection->getStartLine(); + $endLine = $reflection->getEndLine(); + + if (false === $file || false === $startLine || false === $endLine || false === $lines = @file($file)) { + return 'CLOSURE'; + } + + $sourceLines = array_map( + static fn (string $line): string => rtrim($line, "\r\n"), + \array_slice($lines, $startLine - 1, $endLine - $startLine + 1), + ); + + $indent = ''; + if (preg_match('/^(\s*).*?(?=(?:static\s+)?(?:function|fn)\b)/', $sourceLines[0], $matches)) { + $indent = $matches[1]; + $sourceLines[0] = substr($sourceLines[0], \strlen($matches[0])); + } + + foreach ($sourceLines as $i => $line) { + if ($i > 0 && '' !== $indent && str_starts_with($line, $indent)) { + $sourceLines[$i] = substr($line, \strlen($indent)); + } + } + + return rtrim(rtrim(implode("\n", $sourceLines)), ','); + } + /** * @param array, optional?: true}> $routeParams */ diff --git a/src/RouteProvider/AbstractEntityRouteProvider.php b/src/RouteProvider/AbstractEntityRouteProvider.php index 924ee6c6..f4eec2fa 100644 --- a/src/RouteProvider/AbstractEntityRouteProvider.php +++ b/src/RouteProvider/AbstractEntityRouteProvider.php @@ -5,7 +5,6 @@ namespace Sofascore\PurgatoryBundle\RouteProvider; use Doctrine\ORM\PersistentCollection; -use Opis\Closure\Box; use Psr\Container\ContainerInterface; use Sofascore\PurgatoryBundle\Cache\Configuration\Configuration; use Sofascore\PurgatoryBundle\Cache\Configuration\ConfigurationLoaderInterface; @@ -16,8 +15,6 @@ use Sofascore\PurgatoryBundle\RouteParamValueResolver\ValuesResolverInterface; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; -use function Opis\Closure\unserialize; - /** * @internal * @@ -34,9 +31,6 @@ abstract protected function getChangedProperties(object $entity, array $entityCh private ?Configuration $configuration = null; - /** @var array */ - private array $unserializedClosures = []; - public function __construct( private readonly ConfigurationLoaderInterface $configurationLoader, private readonly ?ExpressionLanguage $expressionLanguage, @@ -80,8 +74,9 @@ private function processValidSubscriptions(Subscriptions $subscriptions, array $ } if (isset($subscription['if'])) { - if (isset($subscription['closureIf'])) { - $closure = $this->unserializedClosures[$subscription['if']] ??= $this->unserializeClosure($subscription['if']); + if (\is_array($subscription['if'])) { + /** @var \Closure $closure */ + $closure = deepclone_from_array($subscription['if']); /** @var bool $result */ $result = $closure($entity); @@ -184,12 +179,4 @@ private function getExpressionLanguage(): ExpressionLanguage return $this->expressionLanguage ?? throw new LogicException('You cannot use expressions because the Symfony ExpressionLanguage component is not installed. Try running "composer require symfony/expression-language".'); } - - private function unserializeClosure(string $serializedClosure): \Closure - { - /** @var \Closure $closure */ - $closure = unserialize($serializedClosure, options: ['allowed_classes' => [Box::class]]); - - return $closure; - } } diff --git a/tests/Application/Php85ApplicationTest.php b/tests/Application/Php85ApplicationTest.php index cf1d4d07..9e4ccfed 100644 --- a/tests/Application/Php85ApplicationTest.php +++ b/tests/Application/Php85ApplicationTest.php @@ -5,7 +5,6 @@ namespace Sofascore\PurgatoryBundle\Tests\Application; use Doctrine\ORM\EntityManagerInterface; -use PHPUnit\Framework\Attributes\RequiresFunction; use PHPUnit\Framework\Attributes\RequiresPhp; use Sofascore\PurgatoryBundle\Test\InteractsWithPurgatory; use Sofascore\PurgatoryBundle\Tests\Functional\AbstractKernelTestCase; @@ -13,7 +12,6 @@ use Sofascore\PurgatoryBundle\Tests\Functional\Php85TestApplication\Entity\Plant; #[RequiresPhp('>= 8.5.0')] -#[RequiresFunction('\Opis\Closure\serialize')] final class Php85ApplicationTest extends AbstractKernelTestCase { use InteractsWithPurgatory; diff --git a/tests/Application/Php85ConfigurationTest.php b/tests/Application/Php85ConfigurationTest.php index 98cadaaf..b664299a 100644 --- a/tests/Application/Php85ConfigurationTest.php +++ b/tests/Application/Php85ConfigurationTest.php @@ -5,8 +5,8 @@ namespace Sofascore\PurgatoryBundle\Tests\Application; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\RequiresFunction; use PHPUnit\Framework\Attributes\RequiresPhp; +use Sofascore\PurgatoryBundle\Attribute\PurgeOn; use Sofascore\PurgatoryBundle\Cache\Configuration\Configuration; use Sofascore\PurgatoryBundle\Listener\Enum\Action; use Sofascore\PurgatoryBundle\Tests\Functional\AbstractKernelTestCase; @@ -14,7 +14,6 @@ use Sofascore\PurgatoryBundle\Tests\Functional\Php85TestApplication\Entity\Plant; #[RequiresPhp('>= 8.5.0')] -#[RequiresFunction('\Opis\Closure\serialize')] class Php85ConfigurationTest extends AbstractKernelTestCase { private static ?Configuration $configuration; @@ -48,20 +47,23 @@ 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:"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 0 === $plant->getWaterLevel(); - }";s:5:"flags";i:2;}}} - EOF; - /* @see PlantController::dryPlantsAction */ yield [ 'entity' => Plant::class, 'subscription' => [ 'routeName' => 'dry_plants_list', - 'if' => $expectedIf, - 'closureIf' => true, + 'if' => [ + 'classes' => '', + 'objectMeta' => 0, + 'prepared' => [ + 'Sofascore\PurgatoryBundle\Tests\Functional\Php85TestApplication\Controller\PlantController', + 'dryPlantsAction()', + 2, + 0, + 20 + ], + 'mask' => 1 + ], 'actions' => [Action::Create], ], ]; diff --git a/tests/Attribute/PurgeOnTest.php b/tests/Attribute/PurgeOnTest.php index 13177aac..d2fe2f2d 100644 --- a/tests/Attribute/PurgeOnTest.php +++ b/tests/Attribute/PurgeOnTest.php @@ -47,7 +47,7 @@ public function testValueNormalization(string $property, mixed $value, mixed $ex self::assertEquals($expectedValue, $purgeOn->$property); } - #[RequiresFunction('\Opis\Closure\serialize')] + #[RequiresFunction('deepclone_to_array')] public function testIfWithClosure(): void { $if = static fn (\stdClass $obj): bool => true; diff --git a/tests/Cache/Configuration/ConfigurationLoaderTest.php b/tests/Cache/Configuration/ConfigurationLoaderTest.php index a8c5025a..318638ea 100644 --- a/tests/Cache/Configuration/ConfigurationLoaderTest.php +++ b/tests/Cache/Configuration/ConfigurationLoaderTest.php @@ -4,10 +4,9 @@ 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\Attributes\RequiresPhp; use PHPUnit\Framework\TestCase; use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\CompoundValues; use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\EnumValues; @@ -231,7 +230,7 @@ class: 'Foo', ]; } - #[RequiresMethod(ReflectionClosure::class, '__construct')] + #[RequiresPhp('>= 8.5.0')] #[DataProvider('purgeSubscriptionProviderPhp85')] public function testSubscriptionsWithPhp85Features(array $purgeSubscriptions, array $expectedConfiguration): void { @@ -246,6 +245,9 @@ public function testSubscriptionsWithPhp85Features(array $purgeSubscriptions, ar self::assertSame($expectedConfiguration, $configuration->toArray()); } + + private const \Closure SAMPLE_IF = static function (\stdClass $entity): bool {return true; }; + public static function purgeSubscriptionProviderPhp85(): iterable { yield 'purge subscription without property' => [ @@ -257,15 +259,14 @@ class: \stdClass::class, routeName: 'app_route_foo', route: new Route('/foo'), actions: Action::cases(), - if: static function (\stdClass $entity): bool {return true; }, + if: self::SAMPLE_IF, ), ], '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:"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, + 'if' => deepclone_to_array(self::SAMPLE_IF), 'actions' => Action::cases(), ], ], diff --git a/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php b/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php index 5d6267af..df54007a 100644 --- a/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php +++ b/tests/Cache/Subscription/PurgeSubscriptionProviderTest.php @@ -7,10 +7,9 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\Persistence\ManagerRegistry; -use Opis\Closure\ReflectionClosure; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\RequiresMethod; +use PHPUnit\Framework\Attributes\RequiresFunction; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; use Sofascore\PurgatoryBundle\Attribute\PurgeOn; @@ -597,7 +596,6 @@ public static function provideInvalidExpressions(): iterable ]; } - #[RequiresMethod(ReflectionClosure::class, '__construct')] #[DataProvider('providerRouteMetadataWithPhp85Features')] public function testWithClosures(RouteMetadata $routeMetadata, array $expectedSubscriptions): void { @@ -657,7 +655,7 @@ class: DummyEntity::class, ]; } - #[RequiresMethod(ReflectionClosure::class, '__construct')] + #[RequiresFunction('deepclone_to_array')] #[DataProvider('provideInvalidClosures')] public function testInvalidClosures(\Closure $if, string $expectedMessage): void { diff --git a/tests/Command/Php85DebugCommandTest.php b/tests/Command/Php85DebugCommandTest.php index 93b54c3d..7aab9dab 100644 --- a/tests/Command/Php85DebugCommandTest.php +++ b/tests/Command/Php85DebugCommandTest.php @@ -5,7 +5,6 @@ namespace Sofascore\PurgatoryBundle\Tests\Command; use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\RequiresFunction; use PHPUnit\Framework\Attributes\RequiresPhp; use Sofascore\PurgatoryBundle\Command\DebugCommand; use Sofascore\PurgatoryBundle\Tests\Functional\AbstractKernelTestCase; @@ -14,7 +13,6 @@ #[CoversClass(DebugCommand::class)] #[RequiresPhp('>= 8.5.0')] -#[RequiresFunction('\Opis\Closure\serialize')] final class Php85DebugCommandTest extends AbstractKernelTestCase { private string|false $colSize; @@ -52,11 +50,12 @@ public function testClosureIfIsRendered(): void $this->command->assertCommandIsSuccessful(); - $expectedClosure = <<<'PHP' - Condition static function (Plant $plant): bool { - return 0 === $plant->getWaterLevel(); - } - PHP; + // The closure source is dedented relative to its declaration: the body is + // indented one level under "static function" and the closing brace lines up + // with it. In the table, every line is padded to the value column (offset 17). + $expectedClosure = 'Condition static function (Plant $plant): bool {'."\n" + .str_repeat(' ', 21).'return 0 === $plant->getWaterLevel();'."\n" + .str_repeat(' ', 17).'}'; self::assertStringContainsString( needle: $expectedClosure, diff --git a/tests/RouteProvider/UpdatedEntityRouteProviderTest.php b/tests/RouteProvider/UpdatedEntityRouteProviderTest.php index a0eacb65..d3765a68 100644 --- a/tests/RouteProvider/UpdatedEntityRouteProviderTest.php +++ b/tests/RouteProvider/UpdatedEntityRouteProviderTest.php @@ -449,29 +449,28 @@ public function testExceptionIsThrownOnInvalidIfReturnType(mixed $ifResult, stri [...$routeProvider->provideRoutesFor(Action::Update, new \stdClass(), [])]; } - #[RequiresFunction('\Opis\Closure\serialize')] + private const \Closure VALID_IF = static function (\stdClass $entity): bool { + return true; + }; + + private const \Closure INVALID_IF = static function (\stdClass $entity): bool { + return false; + }; + + #[RequiresFunction('deepclone_to_array')] 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, + 'if' => deepclone_to_array(self::VALID_IF), ], ], 'stdClass::foo' => [ [ 'routeName' => 'bar_route', - 'if' => \Opis\Closure\serialize($validIf), - 'closureIf' => true, + 'if' => deepclone_to_array(self::VALID_IF), ], [ 'routeName' => 'baz_route', @@ -485,8 +484,7 @@ public function testProvideRoutesToPurgeWithClosureIf(): void 'values' => ['baz'], ], ], - 'if' => \Opis\Closure\serialize($invalidIf), - 'closureIf' => true, + 'if' => deepclone_to_array(self::INVALID_IF), ], ], ], false); From 0371718ab39c7f72369b0bbfdbe5be673c145053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sat, 27 Jun 2026 15:14:21 +0200 Subject: [PATCH 29/34] lint --- tests/Application/Php85ConfigurationTest.php | 7 +++---- tests/Cache/Configuration/ConfigurationLoaderTest.php | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/Application/Php85ConfigurationTest.php b/tests/Application/Php85ConfigurationTest.php index b664299a..3d1a38dc 100644 --- a/tests/Application/Php85ConfigurationTest.php +++ b/tests/Application/Php85ConfigurationTest.php @@ -6,7 +6,6 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhp; -use Sofascore\PurgatoryBundle\Attribute\PurgeOn; use Sofascore\PurgatoryBundle\Cache\Configuration\Configuration; use Sofascore\PurgatoryBundle\Listener\Enum\Action; use Sofascore\PurgatoryBundle\Tests\Functional\AbstractKernelTestCase; @@ -60,9 +59,9 @@ public static function configurationProvider(): iterable 'dryPlantsAction()', 2, 0, - 20 - ], - 'mask' => 1 + 20, + ], + 'mask' => 1, ], 'actions' => [Action::Create], ], diff --git a/tests/Cache/Configuration/ConfigurationLoaderTest.php b/tests/Cache/Configuration/ConfigurationLoaderTest.php index 318638ea..b775b95a 100644 --- a/tests/Cache/Configuration/ConfigurationLoaderTest.php +++ b/tests/Cache/Configuration/ConfigurationLoaderTest.php @@ -245,7 +245,6 @@ public function testSubscriptionsWithPhp85Features(array $purgeSubscriptions, ar self::assertSame($expectedConfiguration, $configuration->toArray()); } - private const \Closure SAMPLE_IF = static function (\stdClass $entity): bool {return true; }; public static function purgeSubscriptionProviderPhp85(): iterable From 4e47e075ab8f20cb5d7f6a9c2088ab6bb22caf59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sat, 27 Jun 2026 15:18:39 +0200 Subject: [PATCH 30/34] phpstan --- phpstan-baseline.neon | 4 ++-- src/Command/DebugCommand.php | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ef35156a..4e169a47 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, closureIf\?\: true, actions\?\: non\-empty\-list\\}\>\>, mixed given\.$#' + message: '#^Parameter \#1 \$configuration of class Sofascore\\PurgatoryBundle\\Cache\\Configuration\\Configuration constructor expects array\, optional\?\: true\}\>, if\?\: array\\|string, 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, closureIf\?\: true, actions\?\: non\-empty\-list\\}\> but returns ArrayIterator\, optional\?\: bool\}\>, if\?\: string, closureIf\?\: bool, actions\?\: non\-empty\-list\\}\>\.$#' + message: '#^Method Sofascore\\PurgatoryBundle\\Cache\\Configuration\\Subscriptions\:\:getIterator\(\) should return Traversable\, optional\?\: true\}\>, if\?\: array\\|string, actions\?\: non\-empty\-list\\}\> but returns ArrayIterator\, optional\?\: bool\}\>, if\?\: array\\|string, actions\?\: non\-empty\-list\\}\>\.$#' identifier: return.type count: 1 path: src/Cache/Configuration/Subscriptions.php diff --git a/src/Command/DebugCommand.php b/src/Command/DebugCommand.php index 24fe8180..bf4ee2f3 100644 --- a/src/Command/DebugCommand.php +++ b/src/Command/DebugCommand.php @@ -281,6 +281,9 @@ private function display(SymfonyStyle $io, array $configuration): void } } + /** + * @param array $serializedClosure + */ private function formatClosureCondition(array $serializedClosure): string { /** @var \Closure $closure */ From 215f034e11f3e9848aa544f906095adf03208998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sat, 27 Jun 2026 15:21:30 +0200 Subject: [PATCH 31/34] fix composer.json --- composer.json | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 08f36132..7b70daf7 100644 --- a/composer.json +++ b/composer.json @@ -64,7 +64,9 @@ "symfony/http-client": "To use the Varnish purger", "symfony/messenger": "To purge URLs asynchronously", "symfony/serializer": "To resolve purge targets from serialization groups with `ForGroups`", - "symfony/yaml": "To configure purge subscriptions using YAML files" + "symfony/yaml": "To configure purge subscriptions using YAML files", + "symfony/polyfill-deepclone": "To use a closure as the \"if\" condition of a #[PurgeOn] attribute (PHP 8.5+)", + "symfony/deepclone": "Faster, native PHP extension alternative to symfony/polyfill-deepclone for using a closure as the \"if\" condition of a #[PurgeOn] attribute (PHP 8.5+)" }, "conflict": { "symfony/cache": "<6.4", @@ -74,10 +76,6 @@ "symfony/serializer": "<6.4", "symfony/yaml": "<6.4" }, - "suggest": { - "symfony/polyfill-deepclone": "To use a closure as the \"if\" condition of a #[PurgeOn] attribute (PHP 8.5+)", - "symfony/deepclone": "Faster, native PHP extension alternative to symfony/polyfill-deepclone for using a closure as the \"if\" condition of a #[PurgeOn] attribute (PHP 8.5+)" - }, "scripts": { "run-checks": [ "php-cs-fixer fix -vvv", From b0cc2643ac3b4354658fe5b4244b528fec461272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sat, 27 Jun 2026 17:30:01 +0200 Subject: [PATCH 32/34] fix tests --- .../Configuration/ConfigurationLoaderTest.php | 9 ++++--- tests/Fixtures/ClosureIfHolder.php | 24 +++++++++++++++++++ .../UpdatedEntityRouteProviderTest.php | 15 ++++-------- 3 files changed, 32 insertions(+), 16 deletions(-) create mode 100644 tests/Fixtures/ClosureIfHolder.php diff --git a/tests/Cache/Configuration/ConfigurationLoaderTest.php b/tests/Cache/Configuration/ConfigurationLoaderTest.php index b775b95a..c608acc0 100644 --- a/tests/Cache/Configuration/ConfigurationLoaderTest.php +++ b/tests/Cache/Configuration/ConfigurationLoaderTest.php @@ -17,6 +17,7 @@ use Sofascore\PurgatoryBundle\Cache\Subscription\PurgeSubscription; use Sofascore\PurgatoryBundle\Cache\Subscription\PurgeSubscriptionProviderInterface; use Sofascore\PurgatoryBundle\Listener\Enum\Action; +use Sofascore\PurgatoryBundle\Tests\Fixtures\ClosureIfHolder; use Sofascore\PurgatoryBundle\Tests\Fixtures\DummyStringEnum; use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\Routing\Route; @@ -245,11 +246,9 @@ public function testSubscriptionsWithPhp85Features(array $purgeSubscriptions, ar self::assertSame($expectedConfiguration, $configuration->toArray()); } - private const \Closure SAMPLE_IF = static function (\stdClass $entity): bool {return true; }; - public static function purgeSubscriptionProviderPhp85(): iterable { - yield 'purge subscription without property' => [ + yield 'purge subscription with closure if' => [ 'purgeSubscriptions' => [ new PurgeSubscription( class: \stdClass::class, @@ -258,14 +257,14 @@ class: \stdClass::class, routeName: 'app_route_foo', route: new Route('/foo'), actions: Action::cases(), - if: self::SAMPLE_IF, + if: ClosureIfHolder::RETURNS_TRUE, ), ], 'expectedConfiguration' => [ 'stdClass' => [ [ 'routeName' => 'app_route_foo', - 'if' => deepclone_to_array(self::SAMPLE_IF), + 'if' => deepclone_to_array(ClosureIfHolder::RETURNS_TRUE), 'actions' => Action::cases(), ], ], diff --git a/tests/Fixtures/ClosureIfHolder.php b/tests/Fixtures/ClosureIfHolder.php new file mode 100644 index 00000000..b18419a8 --- /dev/null +++ b/tests/Fixtures/ClosureIfHolder.php @@ -0,0 +1,24 @@ +provideRoutesFor(Action::Update, new \stdClass(), [])]; } - private const \Closure VALID_IF = static function (\stdClass $entity): bool { - return true; - }; - - private const \Closure INVALID_IF = static function (\stdClass $entity): bool { - return false; - }; - #[RequiresFunction('deepclone_to_array')] public function testProvideRoutesToPurgeWithClosureIf(): void { @@ -464,13 +457,13 @@ public function testProvideRoutesToPurgeWithClosureIf(): void 'stdClass' => [ [ 'routeName' => 'foo_route', - 'if' => deepclone_to_array(self::VALID_IF), + 'if' => deepclone_to_array(ClosureIfHolder::RETURNS_TRUE), ], ], 'stdClass::foo' => [ [ 'routeName' => 'bar_route', - 'if' => deepclone_to_array(self::VALID_IF), + 'if' => deepclone_to_array(ClosureIfHolder::RETURNS_TRUE), ], [ 'routeName' => 'baz_route', @@ -484,7 +477,7 @@ public function testProvideRoutesToPurgeWithClosureIf(): void 'values' => ['baz'], ], ], - 'if' => deepclone_to_array(self::INVALID_IF), + 'if' => deepclone_to_array(ClosureIfHolder::RETURNS_FALSE), ], ], ], false); From 85f9d76936476697082577199049126540acfd16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sat, 27 Jun 2026 17:41:26 +0200 Subject: [PATCH 33/34] fix tests --- .../Configuration/ConfigurationLoaderTest.php | 37 +++++++------------ .../UpdatedEntityRouteProviderTest.php | 4 +- 2 files changed, 16 insertions(+), 25 deletions(-) diff --git a/tests/Cache/Configuration/ConfigurationLoaderTest.php b/tests/Cache/Configuration/ConfigurationLoaderTest.php index c608acc0..133bbd1f 100644 --- a/tests/Cache/Configuration/ConfigurationLoaderTest.php +++ b/tests/Cache/Configuration/ConfigurationLoaderTest.php @@ -232,24 +232,12 @@ class: 'Foo', } #[RequiresPhp('>= 8.5.0')] - #[DataProvider('purgeSubscriptionProviderPhp85')] - public function testSubscriptionsWithPhp85Features(array $purgeSubscriptions, array $expectedConfiguration): void + public function testSubscriptionsWithClosureIf(): void { $purgeSubscriptionProvider = $this->createMock(PurgeSubscriptionProviderInterface::class); $purgeSubscriptionProvider->expects(self::once()) ->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 with closure if' => [ - 'purgeSubscriptions' => [ + ->willReturn([ new PurgeSubscription( class: \stdClass::class, property: null, @@ -259,16 +247,19 @@ class: \stdClass::class, actions: Action::cases(), if: ClosureIfHolder::RETURNS_TRUE, ), - ], - 'expectedConfiguration' => [ - 'stdClass' => [ - [ - 'routeName' => 'app_route_foo', - 'if' => deepclone_to_array(ClosureIfHolder::RETURNS_TRUE), - 'actions' => Action::cases(), - ], + ]); + + $loader = new ConfigurationLoader($purgeSubscriptionProvider); + + self::assertInstanceOf(Configuration::class, $configuration = $loader->load()); + self::assertSame([ + 'stdClass' => [ + [ + 'routeName' => 'app_route_foo', + 'if' => deepclone_to_array(ClosureIfHolder::RETURNS_TRUE), + 'actions' => Action::cases(), ], ], - ]; + ], $configuration->toArray()); } } diff --git a/tests/RouteProvider/UpdatedEntityRouteProviderTest.php b/tests/RouteProvider/UpdatedEntityRouteProviderTest.php index 6216b84f..07f37fd2 100644 --- a/tests/RouteProvider/UpdatedEntityRouteProviderTest.php +++ b/tests/RouteProvider/UpdatedEntityRouteProviderTest.php @@ -9,7 +9,7 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\PersistentCollection; use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\RequiresFunction; +use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; @@ -450,7 +450,7 @@ public function testExceptionIsThrownOnInvalidIfReturnType(mixed $ifResult, stri [...$routeProvider->provideRoutesFor(Action::Update, new \stdClass(), [])]; } - #[RequiresFunction('deepclone_to_array')] + #[RequiresPhp('>= 8.5.0')] public function testProvideRoutesToPurgeWithClosureIf(): void { $routeProvider = $this->createRouteProvider([ From ea2c49fa39cff3f7be1aa6b6a7f48c49c89cd719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Brajkovi=C4=87?= Date: Sat, 27 Jun 2026 17:56:24 +0200 Subject: [PATCH 34/34] cleanup --- tests/Command/Php85DebugCommandTest.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/Command/Php85DebugCommandTest.php b/tests/Command/Php85DebugCommandTest.php index 7aab9dab..6f52da9c 100644 --- a/tests/Command/Php85DebugCommandTest.php +++ b/tests/Command/Php85DebugCommandTest.php @@ -50,12 +50,11 @@ public function testClosureIfIsRendered(): void $this->command->assertCommandIsSuccessful(); - // The closure source is dedented relative to its declaration: the body is - // indented one level under "static function" and the closing brace lines up - // with it. In the table, every line is padded to the value column (offset 17). - $expectedClosure = 'Condition static function (Plant $plant): bool {'."\n" - .str_repeat(' ', 21).'return 0 === $plant->getWaterLevel();'."\n" - .str_repeat(' ', 17).'}'; + $expectedClosure = <<<'PHP' + Condition static function (Plant $plant): bool { + return 0 === $plant->getWaterLevel(); + } + PHP; self::assertStringContainsString( needle: $expectedClosure,