Skip to content

Commit c9b555f

Browse files
authored
Add ExpressionValues (#112)
1 parent d0644fe commit c9b555f

30 files changed

Lines changed: 653 additions & 63 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [2.0.0] - xxxx-xx-xx
99

10+
### Added
11+
12+
- `ExpressionValues` to enable resolution of route parameter values via expressions by @HypeMC
13+
in https://github.com/sofascore/purgatory-bundle/pull/112
14+
1015
### Removed
1116

1217
- Symfony v5 support by @HypeMC in https://github.com/sofascore/purgatory-bundle/pull/128
18+
- `InverseValuesAwareInterface`, use dedicated builder services instead by @HypeMC
19+
in https://github.com/sofascore/purgatory-bundle/pull/123
1320

1421
## [1.3.0] - 2025-12-15
1522

config/services.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
use Sofascore\PurgatoryBundle\Cache\Configuration\ConfigurationLoader;
99
use Sofascore\PurgatoryBundle\Cache\PropertyResolver\AssociationResolver;
1010
use Sofascore\PurgatoryBundle\Cache\PropertyResolver\EmbeddableResolver;
11+
use Sofascore\PurgatoryBundle\Cache\PropertyResolver\ExpressionLanguage\InverseRelationExpressionTransformer;
1112
use Sofascore\PurgatoryBundle\Cache\PropertyResolver\InverseValuesBuilder\CompoundInverseValuesBuilder;
1213
use Sofascore\PurgatoryBundle\Cache\PropertyResolver\InverseValuesBuilder\DynamicInverseValuesBuilder;
14+
use Sofascore\PurgatoryBundle\Cache\PropertyResolver\InverseValuesBuilder\ExpressionInverseValuesBuilder;
1315
use Sofascore\PurgatoryBundle\Cache\PropertyResolver\InverseValuesBuilder\PropertyInverseValuesBuilder;
1416
use Sofascore\PurgatoryBundle\Cache\PropertyResolver\MethodResolver;
1517
use Sofascore\PurgatoryBundle\Cache\PropertyResolver\PropertyResolver;
@@ -31,6 +33,7 @@
3133
use Sofascore\PurgatoryBundle\RouteParamValueResolver\CompoundValuesResolver;
3234
use Sofascore\PurgatoryBundle\RouteParamValueResolver\DynamicValuesResolver;
3335
use Sofascore\PurgatoryBundle\RouteParamValueResolver\EnumValuesResolver;
36+
use Sofascore\PurgatoryBundle\RouteParamValueResolver\ExpressionValuesResolver;
3437
use Sofascore\PurgatoryBundle\RouteParamValueResolver\PropertyValuesResolver;
3538
use Sofascore\PurgatoryBundle\RouteParamValueResolver\RawValuesResolver;
3639
use Sofascore\PurgatoryBundle\RouteProvider\AbstractEntityRouteProvider;
@@ -95,8 +98,8 @@
9598
->set('sofascore.purgatory.subscription_resolver.association', AssociationResolver::class)
9699
->tag('purgatory.subscription_resolver')
97100
->args([
98-
service('property_info.reflection_extractor'),
99101
tagged_locator('purgatory.inverse_values_builder', defaultIndexMethod: 'for'),
102+
service('sofascore.purgatory.inverse_relation_expression_transformer'),
100103
])
101104

102105
->set('sofascore.purgatory.subscription_resolver.embeddable', EmbeddableResolver::class)
@@ -105,6 +108,11 @@
105108
service('doctrine'),
106109
])
107110

111+
->set('sofascore.purgatory.inverse_relation_expression_transformer', InverseRelationExpressionTransformer::class)
112+
->args([
113+
service('property_info.reflection_extractor'),
114+
])
115+
108116
->set('sofascore.purgatory.inverse_values_builder.compound', CompoundInverseValuesBuilder::class)
109117
->tag('purgatory.inverse_values_builder')
110118
->args([
@@ -114,6 +122,12 @@
114122
->set('sofascore.purgatory.inverse_values_builder.dynamic', DynamicInverseValuesBuilder::class)
115123
->tag('purgatory.inverse_values_builder')
116124

125+
->set('sofascore.purgatory.inverse_values_builder.expression', ExpressionInverseValuesBuilder::class)
126+
->tag('purgatory.inverse_values_builder')
127+
->args([
128+
service('sofascore.purgatory.inverse_relation_expression_transformer'),
129+
])
130+
117131
->set('sofascore.purgatory.inverse_values_builder.property', PropertyInverseValuesBuilder::class)
118132
->tag('purgatory.inverse_values_builder')
119133

@@ -240,6 +254,12 @@
240254
service('sofascore.purgatory.property_accessor'),
241255
])
242256

257+
->set('sofascore.purgatory.route_parameter_resolver.expression', ExpressionValuesResolver::class)
258+
->tag('purgatory.route_param_value_resolver')
259+
->args([
260+
service('sofascore.purgatory.expression_language')->nullOnInvalid(),
261+
])
262+
243263
->set('sofascore.purgatory.property_accessor', PurgatoryPropertyAccessor::class)
244264
->args([
245265
service('property_accessor'),

docs/complex-route-params.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,31 @@ public function listAction(string $lang)
119119
In this example, multiple URLs are generated based on all values from the `LanguageCodes` enum and the raw value `XK`
120120
for the `lang` parameter.
121121

122+
### Using Values Provided by an Expression
123+
124+
You can also map route parameters to values provided dynamically using a **Symfony ExpressionLanguage** expression.
125+
This is useful when a route parameter cannot be mapped directly to a single property and needs to be composed or
126+
transformed.
127+
128+
In these expressions, the entity is available as the `obj` variable:
129+
130+
```php
131+
use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\ExpressionValues;
132+
133+
#[Route('/posts-by-author/{full_name}', name: 'posts_list_by_author', methods: 'GET')]
134+
#[PurgeOn(Author::class, routeParams: ['full_name' => new ExpressionValues('obj.firstName~"-"~obj.lastName')])]
135+
public function listAction(Author $author)
136+
{
137+
}
138+
```
139+
140+
You can also add [custom Expression Language functions](custom-expression-language-functions.md) to extend the available
141+
expression syntax.
142+
122143
### Using Values Provided by a Service
123144

124-
You can also map route parameters to values provided dynamically by a service. This is particularly useful when you need
125-
route parameters that depend on context or runtime information:
145+
As an alternative to expressions, route parameter values can be provided dynamically by a service. This is particularly
146+
useful when you need route parameters that depend on context or runtime information:
126147

127148
```php
128149
use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\DynamicValues;

docs/custom-expression-language-functions.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Custom Expression Language Functions
22

3-
You can add custom functions to the Expression Language for use with the `if` parameter.
3+
You can add custom Expression Language functions that can be used by expressions defined in `ExpressionValues` or the
4+
`if` parameter.
45

56
```php
67
#[Route('/post/{id<\d+>}', name: 'post_details', methods: 'GET')]

docs/purge-subscriptions-using-yaml.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ posts_list:
5858
- !enum App\Enum\LanguageCodes
5959
- !raw XK
6060

61+
# Using values provided by an expression
62+
posts_list_by_author:
63+
class: App\Entity\Author
64+
route_params:
65+
full_name: !expression 'obj.firstName~"-"~obj.lastName'
66+
6167
# Using values provided by a service
6268
posts_list:
6369
class: App\Entity\Post

phpstan-baseline.neon

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ parameters:
4242
count: 1
4343
path: src/Cache/RouteMetadata/YamlMetadataProvider.php
4444

45+
-
46+
message: '#^Parameter \#1 \$expression of class Sofascore\\PurgatoryBundle\\Attribute\\RouteParamValue\\ExpressionValues constructor expects string\|Symfony\\Component\\ExpressionLanguage\\Expression, bool\|float\|int\|list\<bool\|float\|int\|string\|Symfony\\Component\\Yaml\\Tag\\TaggedValue\>\|string given\.$#'
47+
identifier: argument.type
48+
count: 1
49+
path: src/Cache/RouteMetadata/YamlMetadataProvider.php
50+
4551
-
4652
message: '#^Parameter \#1 \$property of class Sofascore\\PurgatoryBundle\\Attribute\\RouteParamValue\\PropertyValues constructor expects string, bool\|float\|int\|string\|Symfony\\Component\\Yaml\\Tag\\TaggedValue given\.$#'
4753
identifier: argument.type

psalm-baseline.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
<code><![CDATA[$alias]]></code>
2424
</PossiblyUndefinedArrayOffset>
2525
</file>
26+
<file src="src/Cache/PropertyResolver/InverseValuesBuilder/ExpressionInverseValuesBuilder.php">
27+
<PossiblyUndefinedArrayOffset>
28+
<code><![CDATA[$expression]]></code>
29+
</PossiblyUndefinedArrayOffset>
30+
</file>
2631
<file src="src/Cache/RouteMetadata/AttributeMetadataProvider.php">
2732
<PossiblyUndefinedArrayOffset>
2833
<code><![CDATA[$method]]></code>
@@ -38,6 +43,7 @@
3843
</ArgumentTypeCoercion>
3944
<InvalidArgument>
4045
<code><![CDATA[$value]]></code>
46+
<code><![CDATA[$value]]></code>
4147
</InvalidArgument>
4248
<PossiblyInvalidArgument>
4349
<code><![CDATA[$value]]></code>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sofascore\PurgatoryBundle\Attribute\RouteParamValue;
6+
7+
use Sofascore\PurgatoryBundle\Exception\LogicException;
8+
use Symfony\Component\ExpressionLanguage\Expression;
9+
10+
final class ExpressionValues extends AbstractValues
11+
{
12+
private readonly Expression $expression;
13+
14+
public function __construct(
15+
string|Expression $expression,
16+
) {
17+
$this->expression = self::normalizeExpression($expression);
18+
}
19+
20+
/**
21+
* @return list<Expression>
22+
*/
23+
public function getValues(): array
24+
{
25+
return [$this->expression];
26+
}
27+
28+
public function toArray(): array
29+
{
30+
return [
31+
'type' => self::type(),
32+
'values' => [(string) $this->expression],
33+
];
34+
}
35+
36+
public static function type(): string
37+
{
38+
return 'expression';
39+
}
40+
41+
private static function normalizeExpression(string|Expression $expression): Expression
42+
{
43+
if ($expression instanceof Expression) {
44+
return $expression;
45+
}
46+
47+
if (!class_exists(Expression::class)) {
48+
throw new LogicException(\sprintf('You cannot use "%s" because the Symfony ExpressionLanguage component is not installed. Try running "composer require symfony/expression-language".', self::class));
49+
}
50+
51+
return new Expression($expression);
52+
}
53+
}

src/Cache/PropertyResolver/AssociationResolver.php

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,16 @@
1010
use Doctrine\Persistence\Mapping\ClassMetadata;
1111
use Psr\Container\ContainerInterface;
1212
use Sofascore\PurgatoryBundle\Attribute\RouteParamValue\ValuesInterface;
13+
use Sofascore\PurgatoryBundle\Cache\PropertyResolver\ExpressionLanguage\InverseRelationExpressionTransformer;
1314
use Sofascore\PurgatoryBundle\Cache\PropertyResolver\InverseValuesBuilder\InverseValuesBuilderInterface;
1415
use Sofascore\PurgatoryBundle\Cache\RouteMetadata\RouteMetadata;
1516
use Sofascore\PurgatoryBundle\Cache\Subscription\PurgeSubscription;
16-
use Sofascore\PurgatoryBundle\Exception\PropertyNotAccessibleException;
17-
use Symfony\Component\ExpressionLanguage\Expression;
18-
use Symfony\Component\PropertyInfo\PropertyReadInfo;
19-
use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface;
2017

2118
final class AssociationResolver implements SubscriptionResolverInterface
2219
{
2320
public function __construct(
24-
private readonly PropertyReadInfoExtractorInterface $extractor,
2521
private readonly ContainerInterface $inverseValuesBuilderLocator,
22+
private readonly InverseRelationExpressionTransformer $expressionTransformer,
2623
) {
2724
}
2825

@@ -76,10 +73,7 @@ public function resolveSubscription(
7673
}
7774

7875
if (null !== $if = $routeMetadata->purgeOn->if) {
79-
$expression = (string) $if;
80-
$getter = $this->createGetter($associationClass, $associationTarget);
81-
$inverseIf = str_replace('obj', 'obj.'.$getter, $expression);
82-
$if = new Expression("obj.$getter !== null && ($inverseIf)");
76+
$if = $this->expressionTransformer->transform($if, $associationClass, $associationTarget, 'false');
8377
}
8478

8579
yield new PurgeSubscription(
@@ -111,19 +105,4 @@ private function getInverseValuesBuilderFor(ValuesInterface $values): ?InverseVa
111105

112106
return $builder;
113107
}
114-
115-
private function createGetter(string $class, string $property): string
116-
{
117-
if (null === $readInfo = $this->extractor->getReadInfo($class, $property)) {
118-
throw new PropertyNotAccessibleException($class, $property);
119-
}
120-
121-
/** @var PropertyReadInfo::TYPE_* $type */
122-
$type = $readInfo->getType();
123-
124-
return match ($type) {
125-
PropertyReadInfo::TYPE_METHOD => $readInfo->getName().'()',
126-
PropertyReadInfo::TYPE_PROPERTY => $readInfo->getName(),
127-
};
128-
}
129108
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sofascore\PurgatoryBundle\Cache\PropertyResolver\ExpressionLanguage;
6+
7+
use Sofascore\PurgatoryBundle\Exception\PropertyNotAccessibleException;
8+
use Symfony\Component\ExpressionLanguage\Expression;
9+
use Symfony\Component\PropertyInfo\PropertyReadInfo;
10+
use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface;
11+
12+
/**
13+
* @internal
14+
*/
15+
final class InverseRelationExpressionTransformer
16+
{
17+
public function __construct(
18+
private readonly PropertyReadInfoExtractorInterface $extractor,
19+
) {
20+
}
21+
22+
public function transform(Expression $expression, string $class, string $property, string $fallback): Expression
23+
{
24+
$getter = $this->createGetter($class, $property);
25+
$inverseExpression = str_replace('obj', 'obj.'.$getter, (string) $expression);
26+
27+
return new Expression("obj.$getter !== null ? ($inverseExpression) : $fallback");
28+
}
29+
30+
private function createGetter(string $class, string $property): string
31+
{
32+
if (null === $readInfo = $this->extractor->getReadInfo($class, $property)) {
33+
throw new PropertyNotAccessibleException($class, $property);
34+
}
35+
36+
/** @var PropertyReadInfo::TYPE_* $type */
37+
$type = $readInfo->getType();
38+
39+
return match ($type) {
40+
PropertyReadInfo::TYPE_METHOD => $readInfo->getName().'()',
41+
PropertyReadInfo::TYPE_PROPERTY => $readInfo->getName(),
42+
};
43+
}
44+
}

0 commit comments

Comments
 (0)