diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 768ee08..d821dcb 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: diff --git a/composer.json b/composer.json index 79170b3..bacf022 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", diff --git a/src/Attribute/PurgeOn.php b/src/Attribute/PurgeOn.php index 1a40b92..0484101 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 e9c0c20..c0e466b 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 */ @@ -57,6 +58,7 @@ public function count(): int * routeName: string, * routeParams?: array, optional?: true}>, * if?: string, + * closureIf?: true, * actions?: non-empty-list, * }>> */ diff --git a/src/Cache/Configuration/ConfigurationLoader.php b/src/Cache/Configuration/ConfigurationLoader.php index aad9e2d..e5bdeb4 100644 --- a/src/Cache/Configuration/ConfigurationLoader.php +++ b/src/Cache/Configuration/ConfigurationLoader.php @@ -8,6 +8,8 @@ use Sofascore\PurgatoryBundle\Cache\Subscription\PurgeSubscriptionProviderInterface; use Symfony\Component\Routing\Route; +use function Opis\Closure\serialize; + final class ConfigurationLoader implements ConfigurationLoaderInterface { public function __construct( @@ -38,7 +40,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 de145eb..124c4fd 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/PropertyResolver/AssociationResolver.php b/src/Cache/PropertyResolver/AssociationResolver.php index 6d65e33..3d2ec7c 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); diff --git a/src/Cache/Subscription/PurgeSubscription.php b/src/Cache/Subscription/PurgeSubscription.php index 15b14dd..db8416d 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 e615fef..be80c74 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,6 +17,7 @@ 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; @@ -58,7 +60,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 +142,44 @@ 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 19e3643..4888347 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; @@ -14,6 +15,8 @@ use Sofascore\PurgatoryBundle\RouteParamValueResolver\ValuesResolverInterface; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use function Opis\Closure\unserialize; + /** * @internal * @@ -73,7 +76,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); } diff --git a/tests/Application/Php85ApplicationTest.php b/tests/Application/Php85ApplicationTest.php new file mode 100644 index 0000000..9248581 --- /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 0000000..8cd70ec --- /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 66cb52f..07a372e 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; @@ -228,4 +230,45 @@ 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:"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(), + ], + ], + ], + ]; + } } diff --git a/tests/Cache/Subscription/Fixtures/DummyEntity.php b/tests/Cache/Subscription/Fixtures/DummyEntity.php new file mode 100644 index 0000000..9c849c9 --- /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(self::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 0000000..0d2fd32 --- /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 0000000..17914b2 --- /dev/null +++ b/tests/Functional/Php85TestApplication/Entity/Plant.php @@ -0,0 +1,39 @@ +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 0000000..8c12ff0 --- /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 e706c29..29a8fc2 100644 --- a/tests/RouteProvider/UpdatedEntityRouteProviderTest.php +++ b/tests/RouteProvider/UpdatedEntityRouteProviderTest.php @@ -5,6 +5,7 @@ namespace Sofascore\PurgatoryBundle\Tests\RouteProvider; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\RequiresFunction; use PHPUnit\Framework\Attributes\RequiresMethod; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; @@ -402,6 +403,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);