diff --git a/config/services.php b/config/services.php index 1f45114..6525840 100644 --- a/config/services.php +++ b/config/services.php @@ -93,6 +93,7 @@ ->tag('purgatory.subscription_resolver') ->args([ service('property_info.reflection_extractor'), + service('sofascore.purgatory.expression_language')->nullOnInvalid(), ]) ->set('sofascore.purgatory.subscription_resolver.embeddable', EmbeddableResolver::class) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index a69be82..47cc4bf 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + getPath()]]> @@ -8,6 +8,44 @@ getPath()]]> + + + + + + + + + + + + + + + + diff --git a/src/Cache/PropertyResolver/AssociationResolver.php b/src/Cache/PropertyResolver/AssociationResolver.php index 6d65e33..b22fa2c 100644 --- a/src/Cache/PropertyResolver/AssociationResolver.php +++ b/src/Cache/PropertyResolver/AssociationResolver.php @@ -12,8 +12,15 @@ use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\ValuesInterface; use Sofascore\PurgatoryBundle\Cache\RouteMetadata\RouteMetadata; use Sofascore\PurgatoryBundle\Cache\Subscription\PurgeSubscription; +use Sofascore\PurgatoryBundle\Exception\LogicException; use Sofascore\PurgatoryBundle\Exception\PropertyNotAccessibleException; use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\ExpressionLanguage\Node\ArgumentsNode; +use Symfony\Component\ExpressionLanguage\Node\ConstantNode; +use Symfony\Component\ExpressionLanguage\Node\GetAttrNode; +use Symfony\Component\ExpressionLanguage\Node\NameNode; +use Symfony\Component\ExpressionLanguage\Node\Node; use Symfony\Component\PropertyInfo\PropertyReadInfo; use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface; @@ -21,6 +28,7 @@ final class AssociationResolver implements SubscriptionResolverInterface { public function __construct( private readonly PropertyReadInfoExtractorInterface $extractor, + private readonly ?ExpressionLanguage $expressionLanguage, ) { } @@ -72,10 +80,7 @@ public function resolveSubscription( } if (null !== $if = $routeMetadata->purgeOn->if) { - $expression = (string) $if; - $getter = $this->createGetter($associationClass, $associationTarget); - $inverseIf = str_replace('obj', 'obj.'.$getter, $expression); - $if = new Expression("obj.$getter !== null && ($inverseIf)"); + $if = $this->createInverseExpression($if, $associationClass, $associationTarget); } yield new PurgeSubscription( @@ -96,18 +101,64 @@ private function getInverseValuesFor(ValuesInterface $values, string $associatio return $values instanceof InverseValuesAwareInterface ? $values->buildInverseValuesFor($associationTarget) : $values; } - private function createGetter(string $class, string $property): string - { - if (null === $readInfo = $this->extractor->getReadInfo($class, $property)) { - throw new PropertyNotAccessibleException($class, $property); + private function createInverseExpression( + Expression $expression, + string $associationClass, + string $associationTarget, + ): Expression { + if (null === $readInfo = $this->extractor->getReadInfo($associationClass, $associationTarget)) { + throw new PropertyNotAccessibleException($associationClass, $associationTarget); } /** @var PropertyReadInfo::TYPE_* $type */ $type = $readInfo->getType(); + $name = $readInfo->getName(); - return match ($type) { - PropertyReadInfo::TYPE_METHOD => $readInfo->getName().'()', - PropertyReadInfo::TYPE_PROPERTY => $readInfo->getName(), + [$callType, $getter] = match ($type) { + PropertyReadInfo::TYPE_METHOD => [GetAttrNode::METHOD_CALL, "$name()"], + PropertyReadInfo::TYPE_PROPERTY => [GetAttrNode::PROPERTY_CALL, $name], }; + + $node = $this->getExpressionLanguage()->parse($expression, ['obj'])->getNodes(); + $inverseIf = $this->replaceObjWithInverse($node, $name, $callType)->dump(); + + return new Expression("obj.$getter !== null && ($inverseIf)"); + } + + /** + * @param GetAttrNode::PROPERTY_CALL|GetAttrNode::METHOD_CALL $type + */ + private function replaceObjWithInverse(Node $node, string $inverse, int $type): Node + { + if ($node instanceof NameNode && 'obj' === $node->attributes['name']) { + return new GetAttrNode( + node: new NameNode('obj'), + attribute: new ConstantNode( + value: $inverse, + isIdentifier: true, + ), + arguments: new ArgumentsNode(), + type: $type, + ); + } + + $newNode = clone $node; + $newNode->nodes = []; + + /** + * @var string $key + * @var Node $childNode + */ + foreach ($node->nodes as $key => $childNode) { + $newNode->nodes[$key] = $this->replaceObjWithInverse($childNode, $inverse, $type); + } + + return $newNode; + } + + 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".'); } } diff --git a/tests/Application/ConfigurationTest.php b/tests/Application/ConfigurationTest.php index b31e8db..abafe08 100644 --- a/tests/Application/ConfigurationTest.php +++ b/tests/Application/ConfigurationTest.php @@ -257,7 +257,7 @@ public static function configurationWithoutTargetProvider(): iterable ], ], ], - 'if' => "obj.owner !== null && (obj.owner.firstName === 'John')", + 'if' => 'obj.owner !== null && ((obj.owner.firstName === "John"))', ], ]; } diff --git a/tests/Cache/PropertyResolver/AssociationResolverTestCase.php b/tests/Cache/PropertyResolver/AssociationResolverTestCase.php index 4534339..9780f12 100644 --- a/tests/Cache/PropertyResolver/AssociationResolverTestCase.php +++ b/tests/Cache/PropertyResolver/AssociationResolverTestCase.php @@ -14,7 +14,9 @@ use Sofascore\PurgatoryBundle\Cache\PropertyResolver\AssociationResolver; use Sofascore\PurgatoryBundle\Cache\RouteMetadata\RouteMetadata; use Sofascore\PurgatoryBundle\Cache\Subscription\PurgeSubscription; +use Sofascore\PurgatoryBundle\Exception\PropertyNotAccessibleException; use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\PropertyInfo\PropertyReadInfo; use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface; @@ -43,7 +45,10 @@ public function testResolveAssociations( ), ); - $resolver = new AssociationResolver($extractor); + $resolver = new AssociationResolver( + extractor: $extractor, + expressionLanguage: new ExpressionLanguage(), + ); $classMetadata = $this->createMock(ClassMetadata::class); $classMetadata->method('hasAssociation') @@ -104,7 +109,7 @@ classMetadata: $classMetadata, $subscription[0]->routeParams['param1'], ); self::assertEquals(new RawValues('const'), $subscription[0]->routeParams['param2']); - self::assertSame('obj.getFoo() !== null && (obj.getFoo().isActive() === true)', (string) $subscription[0]->if); + self::assertSame('obj.getFoo() !== null && ((obj.getFoo().isActive() === true))', (string) $subscription[0]->if); } abstract public static function associationProvider(): iterable; @@ -112,7 +117,8 @@ abstract public static function associationProvider(): iterable; public function testFieldNotAssociation(): void { $resolver = new AssociationResolver( - $this->createMock(PropertyReadInfoExtractorInterface::class), + extractor: $this->createMock(PropertyReadInfoExtractorInterface::class), + expressionLanguage: new ExpressionLanguage(), ); $classMetadata = $this->createMock(ClassMetadata::class); @@ -146,7 +152,8 @@ classMetadata: $classMetadata, public function testInvalidAssociationType(array $associationMapping): void { $resolver = new AssociationResolver( - $this->createMock(PropertyReadInfoExtractorInterface::class), + extractor: $this->createMock(PropertyReadInfoExtractorInterface::class), + expressionLanguage: new ExpressionLanguage(), ); $classMetadata = $this->createMock(ClassMetadata::class); @@ -182,4 +189,72 @@ classMetadata: $classMetadata, } abstract public static function invalidAssociationProvider(): iterable; + + #[DataProvider('associationProvider')] + public function testExceptionIsThrownOnUnreadableAssocationTarget( + array $associationMapping, + bool $isGetAssociationMappedByTargetFieldCalled, + bool $isAssociationInverseSide, + ): void { + $extractor = $this->createMock(PropertyReadInfoExtractorInterface::class); + $extractor->expects(self::once()) + ->method('getReadInfo') + ->with('BarEntity', 'barProperty') + ->willReturn(null); + + $resolver = new AssociationResolver( + extractor: $extractor, + expressionLanguage: new ExpressionLanguage(), + ); + + $classMetadata = $this->createMock(ClassMetadata::class); + $classMetadata->expects(self::once()) + ->method('hasAssociation') + ->with('fooProperty') + ->willReturn(true); + $classMetadata->method('getAssociationMapping') + ->with('fooProperty') + ->willReturn($this->createAssociationMapping($associationMapping)); + + $classMetadata->method('isAssociationInverseSide') + ->with('fooProperty') + ->willReturn($isAssociationInverseSide); + + if ($isGetAssociationMappedByTargetFieldCalled) { + $classMetadata->expects(self::once()) + ->method('getAssociationMappedByTargetField') + ->with('fooProperty') + ->willReturn('barProperty'); + } else { + $classMetadata->expects(self::never()) + ->method('getAssociationMappedByTargetField'); + } + + $classMetadata->method('getAssociationTargetClass') + ->with('fooProperty') + ->willReturn('BarEntity'); + + $purgeSubscription = $resolver->resolveSubscription( + routeMetadata: new RouteMetadata( + routeName: 'route_foo', + route: new Route('/foo/{param1}/{param2}'), + purgeOn: new PurgeOn( + class: 'FooEntity', + if: new Expression('obj.isActive() === true'), + ), + reflectionMethod: null, + ), + classMetadata: $classMetadata, + routeParams: [ + 'param1' => new PropertyValues('bazProperty'), + 'param2' => new RawValues('const'), + ], + target: 'fooProperty', + ); + + $this->expectException(PropertyNotAccessibleException::class); + $this->expectExceptionMessage('Unable to create a getter for property "BarEntity::barProperty".'); + + [...$purgeSubscription]; + } }