Skip to content
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
},
"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",
Expand Down
4 changes: 2 additions & 2 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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\<non\-empty\-string, list\<array\{routeName\: string, routeParams\?\: array\<string, array\{type\: string, values\: list\<mixed\>, optional\?\: true\}\>, if\?\: string, actions\?\: non\-empty\-list\<Sofascore\\PurgatoryBundle\\Listener\\Enum\\Action\>\}\>\>, mixed given\.$#'
message: '#^Parameter \#1 \$configuration of class Sofascore\\PurgatoryBundle\\Cache\\Configuration\\Configuration constructor expects array\<non\-empty\-string, list\<array\{routeName\: string, routeParams\?\: array\<string, array\{type\: string, values\: list\<mixed\>, optional\?\: true\}\>, if\?\: string, closureIf\?\: true, actions\?\: non\-empty\-list\<Sofascore\\PurgatoryBundle\\Listener\\Enum\\Action\>\}\>\>, mixed given\.$#'
identifier: argument.type
count: 1
path: src/Cache/Configuration/CachedConfigurationLoader.php

-
message: '#^Method Sofascore\\PurgatoryBundle\\Cache\\Configuration\\Subscriptions\:\:getIterator\(\) should return Traversable\<int, array\{routeName\: string, routeParams\?\: array\<string, array\{type\: string, values\: list\<mixed\>, optional\?\: true\}\>, if\?\: string, actions\?\: non\-empty\-list\<Sofascore\\PurgatoryBundle\\Listener\\Enum\\Action\>\}\> but returns ArrayIterator\<int, array\{routeName\: string, routeParams\?\: array\<string, array\{type\: string, values\: list\<mixed\>, optional\?\: bool\}\>, if\?\: string, actions\?\: non\-empty\-list\<Sofascore\\PurgatoryBundle\\Listener\\Enum\\Action\>\}\>\.$#'
message: '#^Method Sofascore\\PurgatoryBundle\\Cache\\Configuration\\Subscriptions\:\:getIterator\(\) should return Traversable\<int, array\{routeName\: string, routeParams\?\: array\<string, array\{type\: string, values\: list\<mixed\>, optional\?\: true\}\>, if\?\: string, closureIf\?\: true, actions\?\: non\-empty\-list\<Sofascore\\PurgatoryBundle\\Listener\\Enum\\Action\>\}\> but returns ArrayIterator\<int, array\{routeName\: string, routeParams\?\: array\<string, array\{type\: string, values\: list\<mixed\>, optional\?\: bool\}\>, if\?\: string, closureIf\?\: bool, actions\?\: non\-empty\-list\<Sofascore\\PurgatoryBundle\\Listener\\Enum\\Action\>\}\>\.$#'
identifier: return.type
count: 1
path: src/Cache/Configuration/Subscriptions.php
Expand Down
4 changes: 2 additions & 2 deletions src/Attribute/PurgeOn.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ final class PurgeOn
public readonly ?TargetInterface $target;
/** @var ?non-empty-array<string, ValuesInterface> */
public readonly ?array $routeParams;
public readonly ?Expression $if;
public readonly \Closure|Expression|null $if;
/** @var ?non-empty-list<string> */
public readonly ?array $route;
/** @var ?non-empty-list<Action> */
Expand All @@ -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,
) {
Expand Down
2 changes: 2 additions & 0 deletions src/Cache/Configuration/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ final class Configuration implements \Countable
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* closureIf?: true,
* actions?: non-empty-list<Action>,
* }>> $configuration
*/
Expand Down Expand Up @@ -57,6 +58,7 @@ public function count(): int
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* closureIf?: true,
* actions?: non-empty-list<Action>,
* }>>
*/
Expand Down
9 changes: 8 additions & 1 deletion src/Cache/Configuration/ConfigurationLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions src/Cache/Configuration/Subscriptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* closureIf?: true,
* actions?: non-empty-list<Action>,
* }>
*/
Expand All @@ -22,6 +23,7 @@ final class Subscriptions implements \IteratorAggregate, \Countable
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* closureIf?: true,
* actions?: non-empty-list<Action>,
* }> $subscriptions
*/
Expand Down Expand Up @@ -54,6 +56,7 @@ public function key(): string
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* closureIf?: true,
* actions?: non-empty-list<Action>,
* }>
*/
Expand Down
5 changes: 5 additions & 0 deletions src/Cache/PropertyResolver/AssociationResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ 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');
}

$if = $this->expressionTransformer->transform($if, $associationClass, $associationTarget, 'false');
}

Expand Down
2 changes: 1 addition & 1 deletion src/Cache/Subscription/PurgeSubscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}
}
42 changes: 41 additions & 1 deletion src/Cache/Subscription/PurgeSubscriptionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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\ExpressionValues;
use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\PropertyValues;
Expand All @@ -15,6 +16,7 @@
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\TargetSubscriptionNotResolvableException;
Expand Down Expand Up @@ -59,7 +61,7 @@ private function provideFromMetadata(RouteMetadataProviderInterface $routeMetada
$purgeOn = $routeMetadata->purgeOn;

if (null !== $purgeOn->if) {
$this->validateExpression($purgeOn->if, $routeMetadata->routeName);
$this->validateIf($purgeOn->if, $routeMetadata->routeName, $purgeOn->class);
}

// if route parameters are not specified, they are same as path variables
Expand Down Expand Up @@ -146,6 +148,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->validateExpression($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 InvalidIfClosureException($routeName, 'Return type must be bool');
}

if (1 !== $reflection->getNumberOfParameters()) {
throw new InvalidIfClosureException($routeName, '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 InvalidIfClosureException($routeName, "Parameter in closure must be of type $entity");
}
}

private function validateExpression(Expression $expression, string $routeName): void
{
try {
Expand Down
16 changes: 15 additions & 1 deletion src/Command/DebugCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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',
Expand Down Expand Up @@ -249,6 +253,7 @@
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* closureIf?: true,
* actions?: non-empty-list<Action>,
* }>> $configuration
*/
Expand All @@ -260,14 +265,23 @@
$entity = explode('::', $key);

foreach ($subscriptions as $subscription) {
if (isset($subscription['closureIf'])) {
$r = new ReflectionClosure(unserialize($subscription['if'], options: ['allowed_classes' => [Box::class]]));

Check failure on line 269 in src/Command/DebugCommand.php

View workflow job for this annotation

GitHub Actions / Static Analysis

Parameter #1 $closure of class Opis\Closure\ReflectionClosure constructor expects Closure, mixed given.

Check failure on line 269 in src/Command/DebugCommand.php

View workflow job for this annotation

GitHub Actions / Static Analysis

Offset 'if' might not exist on array{routeName: string, routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>, if?: string, closureIf: true, actions?: non-empty-list<Sofascore\PurgatoryBundle\Listener\Enum\Action>}.
$closureBody = $r->info()->getIncludePHP(false);

$if = rtrim(substr($closureBody, strpos($closureBody, 'return ') + \strlen('return ')), ';');
} else {
$if = $subscription['if'] ?? 'NONE';
}

$io->table(
['Option', 'Value'],
[
['Entity', $entity[0]],
['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'],
],
);
Expand Down
15 changes: 15 additions & 0 deletions src/Exception/InvalidIfClosureException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Sofascore\PurgatoryBundle\Exception;

final class InvalidIfClosureException extends InvalidArgumentException
{
public function __construct(
public readonly string $routeName,
string $message,
) {
parent::__construct("Invalid 'if' closure for route '$routeName': $message");
}
}
12 changes: 11 additions & 1 deletion src/RouteProvider/AbstractEntityRouteProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,6 +15,8 @@
use Sofascore\PurgatoryBundle\RouteParamValueResolver\ValuesResolverInterface;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;

use function Opis\Closure\unserialize;

/**
* @internal
*
Expand Down Expand Up @@ -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);
}
Expand Down
55 changes: 55 additions & 0 deletions tests/Application/Php85ApplicationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

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;
use Sofascore\PurgatoryBundle\Tests\Functional\Php85TestApplication\Controller\PlantController;
use Sofascore\PurgatoryBundle\Tests\Functional\Php85TestApplication\Entity\Plant;

#[RequiresPhp('>= 8.5.0')]
#[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');
}
}
Loading
Loading