Skip to content

Commit 2e91b32

Browse files
authored
Add expression language support (#14)
Currently, the `params` used in `ObjectRoute` definitions are property path expressions (https://symfony.com/doc/current/components/property_access.html). For certain use cases, more flexibility would be helpful. For example, assume a blog post can be archived. When you link to such a blog post, you need to include the post's year in an extra URL parameter: `?year=...`. Something like `#[ObjectRoute(..., params: ['year' => 'year'])]` does not work, since the property access component cannot evaluate conditional expressions. This PR adds a new configuration parameter named `paramExpressions` and uses the Symfony ExpressionLanguage component to evaluate it. Example: `#[ObjectRoute(..., paramExpressions: ['year' => 'this.isArchived ? this.year : null'])]` Expressions given in `paramExpressions` can access two variables: `this` is the object on which the route is being generated, and `params` gives access to all parameter values. Those are the combination of the `extraParams` passed to the object router, and all `params` evaluated through property access expressions as previously. In order to make optional parameters possible, the parameter name in `paramExpressions` can be prefixed with `?` to indicate that a parameter should not be used (filtered out) when the expression evaluates to `null`. So, `#[ObjectRoute(..., paramExpressions: ['?year' => 'this.isArchived ? this.year : null'])]` would only pass the `year` parameter to the underlying router when the blog post has been archived. Resolves #12.
1 parent 63c4e68 commit 2e91b32

18 files changed

+159
-25
lines changed

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ in turn determines the name of the route that will finally be used.
5757

5858
`params` declared in an object route will be evaluated as [Symfony PropertyAccess](https://symfony.com/doc/current/components/property_access.html) expressions on the given object, and the resulting values will be passed on to the underlying router.
5959

60+
You can also use a configuration setting named `paramExpressions` for expression language support; see the section below.
61+
6062
`extraParams` can be given to the object router, and those will be passed-on to the underlying router as-is.
6163

6264
```php
@@ -103,7 +105,25 @@ class Workshop {
103105

104106
In this example, you could use the same Twig expression `object_path('detail', schedule_item)` to generate the right route for the `schedule_item` depending on whether it is a `Talk` or a `Workshop`, and the appropriate parameters (either the `id` or the `slug`) would be passed automatically as well.
105107

106-
# License
108+
## Expression Language support
109+
110+
In an `#[ObjectRoute]` declaration, you can also use the `paramExpressions` key to use [Symfony Expression Language](https://symfony.com/doc/current/reference/formats/expression_language.html) expressions.
111+
112+
The expression gets access to two variables: `this` is the object that the route is generated on, and `params` gives access to all `extraParams` passed to the object router and the values that have been read from the object through property path expressions.
113+
114+
The keys of `extraParams` indicate the parameter name. Prefixing the key with `?` means that the value should not be set if the expression evaluates to `null`.
115+
116+
The motivating use case it that you might have a `BlogPost` object that can be archived. When you link to such a blog post, you need to include the post's year in an extra URL parameter: `?year=....`.
117+
118+
This is not possible with Property Access paths alone, but can be done with Expression language support:
119+
120+
```
121+
#[ObjectRoute(..., paramExpressions: ['?year' => 'this.isArchived ? this.year : null'])]
122+
```
123+
124+
In this case, when the `BlogPost::isArchived()` method returns `true`, the value returned from `BlogPost::getYear()` will be included in the `year` parameter for the route. When it returns `false`, the `year` parameter is omitted.
125+
126+
## License
107127

108128
The code is released under the [Apache2 license](http://www.apache.org/licenses/LICENSE-2.0.html).
109129

composer.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,17 @@
1010
"require": {
1111
"php": ">= 8.1",
1212
"jms/metadata": "^2.6.1",
13+
"symfony/expression-language": "^3.4|^4.0|^5.0|^6.0|^7.0",
1314
"symfony/property-access": "^3.4|^4.0|^5.0|^6.0|^7.0"
1415
},
1516

1617
"require-dev": {
1718
"doctrine/common": "^2.2",
1819
"phpunit/phpunit": "^9.6",
20+
"symfony/phpunit-bridge": ">5.0",
1921
"symfony/routing": "^2.2|^3.0|^4.0",
2022
"symfony/yaml": "^3.0|^4.0|^5.0",
21-
"twig/twig": "^2.0|^3.0",
22-
"symfony/phpunit-bridge": ">5.0"
23+
"twig/twig": "^2.0|^3.0"
2324
},
2425

2526
"conflict": {
@@ -37,5 +38,4 @@
3738
"JMS\\Tests": "tests/"
3839
}
3940
}
40-
4141
}

src/JMS/ObjectRouting/Attribute/ObjectRoute.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,14 @@ final class ObjectRoute
3030
/** @var array */
3131
public $params = [];
3232

33-
public function __construct(string $type, string $name, array $params = [])
33+
/** @var array */
34+
public $paramExpressions = [];
35+
36+
public function __construct(string $type, string $name, array $params = [], array $paramExpressions = [])
3437
{
3538
$this->type = $type;
3639
$this->name = $name;
3740
$this->params = $params;
41+
$this->paramExpressions = $paramExpressions;
3842
}
3943
}

src/JMS/ObjectRouting/Metadata/ClassMetadata.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ class ClassMetadata extends MergeableClassMetadata
2525
{
2626
public $routes = [];
2727

28-
public function addRoute($type, $name, array $params = [])
28+
public function addRoute($type, $name, array $params = [], array $paramExpressions = [])
2929
{
3030
$this->routes[$type] = [
3131
'name' => $name,
3232
'params' => $params,
33+
'paramExpressions' => $paramExpressions,
3334
];
3435
}
3536

src/JMS/ObjectRouting/Metadata/Driver/AttributeDriver.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public function loadMetadataForClass(\ReflectionClass $class): ?ClassMetadata
3131
$hasMetadata = false;
3232
foreach ($this->fetchAttributes($class) as $attribute) {
3333
$hasMetadata = true;
34-
$metadata->addRoute($attribute->type, $attribute->name, $attribute->params);
34+
$metadata->addRoute($attribute->type, $attribute->name, $attribute->params, $attribute->paramExpressions);
3535
}
3636

3737
return $hasMetadata ? $metadata : null;

src/JMS/ObjectRouting/Metadata/Driver/XmlDriver.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,12 @@ protected function loadMetadataFromFile(\ReflectionClass $class, string $file):
7171
$params[(string) $p->attributes()] = (string) $p;
7272
}
7373

74-
$metadata->addRoute($type, $name, $params);
74+
$paramExpressions = [];
75+
foreach ($r->xpath('./paramExpression') as $p) {
76+
$paramExpressions[(string) $p->attributes()] = (string) $p;
77+
}
78+
79+
$metadata->addRoute($type, $name, $params, $paramExpressions);
7580
}
7681

7782
return $metadata;

src/JMS/ObjectRouting/Metadata/Driver/YamlDriver.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@ protected function loadMetadataFromFile(\ReflectionClass $class, string $file):
5252
if (!\array_key_exists('name', $value)) {
5353
throw new RuntimeException('Could not find key "type" inside yaml element.');
5454
}
55-
$metadata->addRoute($type, $value['name'], \array_key_exists('params', $value) ? $value['params'] : []);
55+
$metadata->addRoute(
56+
$type,
57+
$value['name'],
58+
\array_key_exists('params', $value) ? $value['params'] : [],
59+
\array_key_exists('paramExpressions', $value) ? $value['paramExpressions'] : []
60+
);
5661
}
5762

5863
return $metadata;

src/JMS/ObjectRouting/ObjectRouter.php

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,29 +23,34 @@
2323
use Metadata\Driver\DriverChain;
2424
use Metadata\MetadataFactory;
2525
use Metadata\MetadataFactoryInterface;
26+
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
27+
use Symfony\Component\ExpressionLanguage\ParsedExpression;
2628
use Symfony\Component\PropertyAccess\PropertyAccessor;
2729

2830
class ObjectRouter
2931
{
3032
private $router;
3133
private $metadataFactory;
3234
private $accessor;
35+
private $expressionLanguage;
3336

34-
public static function create(RouterInterface $router)
37+
public static function create(RouterInterface $router, ?ExpressionLanguage $expressionLanguage = null)
3538
{
3639
return new self(
3740
$router,
3841
new MetadataFactory(new DriverChain([
3942
new AttributeDriver(),
40-
]))
43+
])),
44+
$expressionLanguage
4145
);
4246
}
4347

44-
public function __construct(RouterInterface $router, MetadataFactoryInterface $metadataFactory)
48+
public function __construct(RouterInterface $router, MetadataFactoryInterface $metadataFactory, ?ExpressionLanguage $expressionLanguage = null)
4549
{
4650
$this->router = $router;
4751
$this->metadataFactory = $metadataFactory;
4852
$this->accessor = new PropertyAccessor();
53+
$this->expressionLanguage = $expressionLanguage ?? new ExpressionLanguage();
4954
}
5055

5156
/**
@@ -80,6 +85,22 @@ public function generate($type, $object, $absolute = false, array $extraParams =
8085
$params[$k] = $this->accessor->getValue($object, $path);
8186
}
8287

88+
foreach ($route['paramExpressions'] as $k => $expression) {
89+
if (!$expression instanceof ParsedExpression) {
90+
$expression = $this->expressionLanguage->parse($expression, ['this', 'params']);
91+
$metadata->routes[$type]['paramExpressions'][$k] = $expression;
92+
}
93+
$evaluated = $this->expressionLanguage->evaluate($expression, ['this' => $object, 'params' => $params]);
94+
if ('?' === $k[0]) {
95+
if (null === $evaluated) {
96+
continue;
97+
}
98+
$params[substr($k, 1)] = $evaluated;
99+
} else {
100+
$params[$k] = $evaluated;
101+
}
102+
}
103+
83104
return $this->router->generate($route['name'], $params, $absolute);
84105
}
85106

tests/JMS/Tests/ObjectRouting/Metadata/ClassMetadataTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ public function testMerge()
1818
$base->merge($merged);
1919

2020
$this->assertEquals(self::class, $base->name);
21-
$this->assertEquals(['test' => ['name' => 'merged-route', 'params' => []]], $base->routes);
21+
$this->assertEquals(['test' => ['name' => 'merged-route', 'params' => [], 'paramExpressions' => []]], $base->routes);
2222
}
2323
}

tests/JMS/Tests/ObjectRouting/Metadata/Driver/AttributeDriverTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ public function testLoad()
1616
$this->assertCount(2, $metadata->routes);
1717

1818
$routes = [
19-
'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug']],
20-
'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug']],
19+
'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug'], 'paramExpressions' => ['?year' => 'this.isArchived ? this.year : null']],
20+
'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug'], 'paramExpressions' => []],
2121
];
2222
$this->assertEquals($routes, $metadata->routes);
2323
}

0 commit comments

Comments
 (0)