Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -17,6 +18,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;
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 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 validateExpression(Expression $expression, string $routeName): void
{
try {
Expand Down
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')]
#[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');
}
}
83 changes: 83 additions & 0 deletions tests/Application/Php85ConfigurationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace Sofascore\PurgatoryBundle\Tests\Application;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\RequiresFunction;
use PHPUnit\Framework\Attributes\RequiresPhp;
use Sofascore\PurgatoryBundle\Cache\Configuration\Configuration;
use Sofascore\PurgatoryBundle\Listener\Enum\Action;
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')]
#[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:"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,
'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),
);
}
}
Loading
Loading