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
2 changes: 1 addition & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
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": "^11.5",
"symfony/cache": "^5.4 || ^6.4 || ^7.0",
"symfony/doctrine-messenger": "^5.4 || ^6.4 || ^7.0",
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
@@ -1,3 +1,3 @@
<?php

declare(strict_types=1);
Expand All @@ -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 @@
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* closureIf?: true,
* actions?: non-empty-list<Action>,
* }> $subscriptions
*/
Expand All @@ -33,7 +35,7 @@

public function getIterator(): \Traversable
{
return new \ArrayIterator($this->subscriptions);

Check failure on line 38 in src/Cache/Configuration/Subscriptions.php

View workflow job for this annotation

GitHub Actions / Static Analysis (PHPStan)

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>}>.
}

public function count(): int
Expand All @@ -54,6 +56,7 @@
* routeName: string,
* routeParams?: array<string, array{type: string, values: list<mixed>, optional?: true}>,
* if?: string,
* closureIf?: true,
* actions?: non-empty-list<Action>,
* }>
*/
Expand Down
4 changes: 4 additions & 0 deletions src/Cache/PropertyResolver/AssociationResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
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\PropertyValues;
use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\ValuesInterface;
Expand All @@ -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;
Expand Down Expand Up @@ -58,7 +60,7 @@
$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
Expand Down Expand Up @@ -140,6 +142,44 @@
}
}

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)

Check failure on line 177 in src/Cache/Subscription/PurgeSubscriptionProvider.php

View workflow job for this annotation

GitHub Actions / Static Analysis (Psalm)

ArgumentTypeCoercion

src/Cache/Subscription/PurgeSubscriptionProvider.php:177:31: ArgumentTypeCoercion: Argument 2 of is_a expects class-string, but parent type non-empty-string provided (see https://psalm.dev/193)
) {
throw new RuntimeException("Parameter in PurgeOn::if closure must be of type $entity");
}
}

private function validateIfExpression(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 @@
}

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]);

Check failure on line 84 in src/RouteProvider/AbstractEntityRouteProvider.php

View workflow job for this annotation

GitHub Actions / Static Analysis (Psalm)

MixedAssignment

src/RouteProvider/AbstractEntityRouteProvider.php:84:21: MixedAssignment: Unable to determine the type that $result is being assigned to (see https://psalm.dev/032)
}

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:"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),
);
}
}
Loading
Loading