Skip to content

Commit

Permalink
Add expression language support (#14)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
mpdude authored Jul 1, 2024
1 parent 63c4e68 commit 2e91b32
Show file tree
Hide file tree
Showing 18 changed files with 159 additions and 25 deletions.
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ in turn determines the name of the route that will finally be used.

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

You can also use a configuration setting named `paramExpressions` for expression language support; see the section below.

`extraParams` can be given to the object router, and those will be passed-on to the underlying router as-is.

```php
Expand Down Expand Up @@ -103,7 +105,25 @@ class Workshop {

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.

# License
## Expression Language support

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.

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.

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

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=....`.

This is not possible with Property Access paths alone, but can be done with Expression language support:

```
#[ObjectRoute(..., paramExpressions: ['?year' => 'this.isArchived ? this.year : null'])]
```

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.

## License

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

Expand Down
6 changes: 3 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,17 @@
"require": {
"php": ">= 8.1",
"jms/metadata": "^2.6.1",
"symfony/expression-language": "^3.4|^4.0|^5.0|^6.0|^7.0",
"symfony/property-access": "^3.4|^4.0|^5.0|^6.0|^7.0"
},

"require-dev": {
"doctrine/common": "^2.2",
"phpunit/phpunit": "^9.6",
"symfony/phpunit-bridge": ">5.0",
"symfony/routing": "^2.2|^3.0|^4.0",
"symfony/yaml": "^3.0|^4.0|^5.0",
"twig/twig": "^2.0|^3.0",
"symfony/phpunit-bridge": ">5.0"
"twig/twig": "^2.0|^3.0"
},

"conflict": {
Expand All @@ -37,5 +38,4 @@
"JMS\\Tests": "tests/"
}
}

}
6 changes: 5 additions & 1 deletion src/JMS/ObjectRouting/Attribute/ObjectRoute.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@ final class ObjectRoute
/** @var array */
public $params = [];

public function __construct(string $type, string $name, array $params = [])
/** @var array */
public $paramExpressions = [];

public function __construct(string $type, string $name, array $params = [], array $paramExpressions = [])
{
$this->type = $type;
$this->name = $name;
$this->params = $params;
$this->paramExpressions = $paramExpressions;
}
}
3 changes: 2 additions & 1 deletion src/JMS/ObjectRouting/Metadata/ClassMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ class ClassMetadata extends MergeableClassMetadata
{
public $routes = [];

public function addRoute($type, $name, array $params = [])
public function addRoute($type, $name, array $params = [], array $paramExpressions = [])
{
$this->routes[$type] = [
'name' => $name,
'params' => $params,
'paramExpressions' => $paramExpressions,
];
}

Expand Down
2 changes: 1 addition & 1 deletion src/JMS/ObjectRouting/Metadata/Driver/AttributeDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public function loadMetadataForClass(\ReflectionClass $class): ?ClassMetadata
$hasMetadata = false;
foreach ($this->fetchAttributes($class) as $attribute) {
$hasMetadata = true;
$metadata->addRoute($attribute->type, $attribute->name, $attribute->params);
$metadata->addRoute($attribute->type, $attribute->name, $attribute->params, $attribute->paramExpressions);
}

return $hasMetadata ? $metadata : null;
Expand Down
7 changes: 6 additions & 1 deletion src/JMS/ObjectRouting/Metadata/Driver/XmlDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@ protected function loadMetadataFromFile(\ReflectionClass $class, string $file):
$params[(string) $p->attributes()] = (string) $p;
}

$metadata->addRoute($type, $name, $params);
$paramExpressions = [];
foreach ($r->xpath('./paramExpression') as $p) {
$paramExpressions[(string) $p->attributes()] = (string) $p;
}

$metadata->addRoute($type, $name, $params, $paramExpressions);
}

return $metadata;
Expand Down
7 changes: 6 additions & 1 deletion src/JMS/ObjectRouting/Metadata/Driver/YamlDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ protected function loadMetadataFromFile(\ReflectionClass $class, string $file):
if (!\array_key_exists('name', $value)) {
throw new RuntimeException('Could not find key "type" inside yaml element.');
}
$metadata->addRoute($type, $value['name'], \array_key_exists('params', $value) ? $value['params'] : []);
$metadata->addRoute(
$type,
$value['name'],
\array_key_exists('params', $value) ? $value['params'] : [],
\array_key_exists('paramExpressions', $value) ? $value['paramExpressions'] : []
);
}

return $metadata;
Expand Down
27 changes: 24 additions & 3 deletions src/JMS/ObjectRouting/ObjectRouter.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,29 +23,34 @@
use Metadata\Driver\DriverChain;
use Metadata\MetadataFactory;
use Metadata\MetadataFactoryInterface;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\ExpressionLanguage\ParsedExpression;
use Symfony\Component\PropertyAccess\PropertyAccessor;

class ObjectRouter
{
private $router;
private $metadataFactory;
private $accessor;
private $expressionLanguage;

public static function create(RouterInterface $router)
public static function create(RouterInterface $router, ?ExpressionLanguage $expressionLanguage = null)
{
return new self(
$router,
new MetadataFactory(new DriverChain([
new AttributeDriver(),
]))
])),
$expressionLanguage
);
}

public function __construct(RouterInterface $router, MetadataFactoryInterface $metadataFactory)
public function __construct(RouterInterface $router, MetadataFactoryInterface $metadataFactory, ?ExpressionLanguage $expressionLanguage = null)
{
$this->router = $router;
$this->metadataFactory = $metadataFactory;
$this->accessor = new PropertyAccessor();
$this->expressionLanguage = $expressionLanguage ?? new ExpressionLanguage();
}

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

foreach ($route['paramExpressions'] as $k => $expression) {
if (!$expression instanceof ParsedExpression) {
$expression = $this->expressionLanguage->parse($expression, ['this', 'params']);
$metadata->routes[$type]['paramExpressions'][$k] = $expression;
}
$evaluated = $this->expressionLanguage->evaluate($expression, ['this' => $object, 'params' => $params]);
if ('?' === $k[0]) {
if (null === $evaluated) {
continue;
}
$params[substr($k, 1)] = $evaluated;
} else {
$params[$k] = $evaluated;
}
}

return $this->router->generate($route['name'], $params, $absolute);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ public function testMerge()
$base->merge($merged);

$this->assertEquals(self::class, $base->name);
$this->assertEquals(['test' => ['name' => 'merged-route', 'params' => []]], $base->routes);
$this->assertEquals(['test' => ['name' => 'merged-route', 'params' => [], 'paramExpressions' => []]], $base->routes);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ public function testLoad()
$this->assertCount(2, $metadata->routes);

$routes = [
'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug']],
'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug']],
'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug'], 'paramExpressions' => ['?year' => 'this.isArchived ? this.year : null']],
'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug'], 'paramExpressions' => []],
];
$this->assertEquals($routes, $metadata->routes);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,33 @@

use JMS\ObjectRouting\Attribute\ObjectRoute;

#[ObjectRoute(type: 'view', name: 'blog_post_view', params: ['slug' => 'slug'])]
#[ObjectRoute(type: 'view', name: 'blog_post_view', params: ['slug' => 'slug'], paramExpressions: ['?year' => 'this.isArchived ? this.year : null'])]
#[ObjectRoute(type: 'edit', name: 'blog_post_edit', params: ['slug' => 'slug'])]
class BlogPostWithAttributes
{
private $slug;
private $archived;
private $year;

public function __construct($slug)
public function __construct($slug, $archived, $year)
{
$this->slug = $slug;
$this->archived = $archived;
$this->year = $year;
}

public function getSlug()
{
return $this->slug;
}

public function isArchived()
{
return $this->archived;
}

public function getYear()
{
return $this->year;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ public function testLoad()
$this->assertCount(2, $metadata->routes);

$routes = [
'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug']],
'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug']],
'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug'], 'paramExpressions' => ['?year' => 'this.isArchived ? this.year : null']],
'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug'], 'paramExpressions' => []],
];
$this->assertEquals($routes, $metadata->routes);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ public function testLoad()
$this->assertCount(2, $metadata->routes);

$routes = [
'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug']],
'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug']],
'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug'], 'paramExpressions' => ['?year' => 'this.isArchived ? this.year : null']],
'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug'], 'paramExpressions' => []],
];
$this->assertEquals($routes, $metadata->routes);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ public function testLoad()
$this->assertCount(2, $metadata->routes);

$routes = [
'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug']],
'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug']],
'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug'], 'paramExpressions' => ['?year' => 'this.isArchived ? this.year : null']],
'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug'], 'paramExpressions' => []],
];
$this->assertEquals($routes, $metadata->routes);
}
Expand Down
61 changes: 61 additions & 0 deletions tests/JMS/Tests/ObjectRouting/ObjectRouterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,67 @@ public function testGenerateWithParams()
$this->assertEquals('/foobar', $this->router->generate('view', $object));
}

public function testGenerateWithParamExpression()
{
$metadata = new ClassMetadata('stdClass');
$metadata->addRoute('view', 'view_name', [], ['foo' => 'this.bar']);

$object = new \stdClass();
$object->bar = 'baz';

$this->factory->expects($this->once())
->method('getMetadataForClass')
->willReturn($metadata);

$this->adapter->expects($this->once())
->method('generate')
->with('view_name', ['foo' => 'baz'], false)
->willReturn('/foobar');

$this->assertEquals('/foobar', $this->router->generate('view', $object));
}

public function testGenerateWithParamExpressionThatRefersToParam()
{
$metadata = new ClassMetadata('stdClass');
$metadata->addRoute('view', 'view_name', ['foo' => 'bar'], ['concat' => 'params["foo"] ~ this.bar']);

$object = new \stdClass();
$object->bar = 'baz';

$this->factory->expects($this->once())
->method('getMetadataForClass')
->willReturn($metadata);

$this->adapter->expects($this->once())
->method('generate')
->with('view_name', ['foo' => 'baz', 'concat' => 'bazbaz'], false)
->willReturn('/foobar');

$this->assertEquals('/foobar', $this->router->generate('view', $object));
}

public function testGenerateWithNullableParamExpression()
{
$metadata = new ClassMetadata('stdClass');
$metadata->addRoute('view', 'view_name', [], ['?foo' => 'this.bar', '?quux' => 'this.barbaz']);

$object = new \stdClass();
$object->bar = 'baz';
$object->barbaz = null;

$this->factory->expects($this->once())
->method('getMetadataForClass')
->willReturn($metadata);

$this->adapter->expects($this->once())
->method('generate')
->with('view_name', ['foo' => 'baz'], false)
->willReturn('/foobar');

$this->assertEquals('/foobar', $this->router->generate('view', $object));
}

public function testGenerateNonExistentType()
{
$this->expectException(\RuntimeException::class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

$metadata = new JMS\ObjectRouting\Metadata\ClassMetadata('JMS\Tests\ObjectRouting\Metadata\Driver\Fixture\BlogPost');

$metadata->addRoute('view', 'blog_post_view', ['slug' => 'slug']);
$metadata->addRoute('view', 'blog_post_view', ['slug' => 'slug'], ['?year' => 'this.isArchived ? this.year : null']);
$metadata->addRoute('edit', 'blog_post_edit', ['slug' => 'slug']);

return $metadata;
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
<class name="JMS\Tests\ObjectRouting\Metadata\Driver\Fixture\BlogPost">
<route type="view" name="blog_post_view">
<param name="slug">slug</param>
<paramExpression name="?year">this.isArchived ? this.year : null</paramExpression>
</route>
<route type="edit" name="blog_post_edit">
<param name="slug">slug</param>
</route>
</class>
</object-routing>
</object-routing>
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ JMS\Tests\ObjectRouting\Metadata\Driver\Fixture\BlogPost:
name: "blog_post_view"
params:
slug: "slug"
paramExpressions:
?year: "this.isArchived ? this.year : null"
edit:
name: "blog_post_edit"
params:
Expand Down

0 comments on commit 2e91b32

Please sign in to comment.