Skip to content

[ExpressionLanguage] Add support for null coalescing syntax #46142

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
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
1 change: 1 addition & 0 deletions src/Symfony/Component/ExpressionLanguage/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ CHANGELOG
6.1
---

* Add support for null-coalescing syntax
* Add support for null-safe syntax when parsing object's methods and properties
* Add new operators: `contains`, `starts with` and `ends with`
* Support lexing numbers with the numeric literal separator `_`
Expand Down
4 changes: 4 additions & 0 deletions src/Symfony/Component/ExpressionLanguage/Lexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ public function tokenize(string $expression): TokenStream
// null-safe
$tokens[] = new Token(Token::PUNCTUATION_TYPE, '?.', ++$cursor);
++$cursor;
} elseif ('?' === $expression[$cursor] && '?' === ($expression[$cursor + 1] ?? '')) {
// null-coalescing
$tokens[] = new Token(Token::PUNCTUATION_TYPE, '??', ++$cursor);
++$cursor;
} elseif (str_contains('.,?:', $expression[$cursor])) {
// punctuation
$tokens[] = new Token(Token::PUNCTUATION_TYPE, $expression[$cursor], $cursor + 1);
Expand Down
14 changes: 11 additions & 3 deletions src/Symfony/Component/ExpressionLanguage/Node/GetAttrNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class GetAttrNode extends Node
public const ARRAY_CALL = 3;

private bool $isShortCircuited = false;
public bool $isNullCoalesce = false;

public function __construct(Node $node, Node $attribute, ArrayNode $arguments, int $type)
{
Expand Down Expand Up @@ -72,8 +73,7 @@ public function evaluate(array $functions, array $values)
switch ($this->attributes['type']) {
case self::PROPERTY_CALL:
$obj = $this->nodes['node']->evaluate($functions, $values);

if (null === $obj && $this->nodes['attribute']->isNullSafe) {
if (null === $obj && ($this->nodes['attribute']->isNullSafe || $this->isNullCoalesce)) {
$this->isShortCircuited = true;

return null;
Expand All @@ -88,6 +88,10 @@ public function evaluate(array $functions, array $values)

$property = $this->nodes['attribute']->attributes['value'];

if ($this->isNullCoalesce) {
return $obj->$property ?? null;
}

return $obj->$property;

case self::METHOD_CALL:
Expand Down Expand Up @@ -118,10 +122,14 @@ public function evaluate(array $functions, array $values)
return null;
}

if (!\is_array($array) && !$array instanceof \ArrayAccess) {
if (!\is_array($array) && !$array instanceof \ArrayAccess && !(null === $array && $this->isNullCoalesce)) {
throw new \RuntimeException(sprintf('Unable to get an item of non-array "%s".', $this->nodes['node']->dump()));
}

if ($this->isNullCoalesce) {
return $array[$this->nodes['attribute']->evaluate($functions, $values)] ?? null;
}

return $array[$this->nodes['attribute']->evaluate($functions, $values)];
}
}
Expand Down
52 changes: 52 additions & 0 deletions src/Symfony/Component/ExpressionLanguage/Node/NullCoalesceNode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\ExpressionLanguage\Node;

use Symfony\Component\ExpressionLanguage\Compiler;

/**
* @author Fabien Potencier <fabien@symfony.com>
*
* @internal
*/
class NullCoalesceNode extends Node
{
public function __construct(Node $expr1, Node $expr2)
{
parent::__construct(['expr1' => $expr1, 'expr2' => $expr2]);
}

public function compile(Compiler $compiler)
{
$compiler
->raw('((')
->compile($this->nodes['expr1'])
->raw(') ?? (')
->compile($this->nodes['expr2'])
->raw('))')
;
}

public function evaluate(array $functions, array $values)
{
if ($this->nodes['expr1'] instanceof GetAttrNode) {
$this->nodes['expr1']->isNullCoalesce = true;
}

return $this->nodes['expr1']->evaluate($functions, $values) ?? $this->nodes['expr2']->evaluate($functions, $values);
}

public function toArray()
{
return ['(', $this->nodes['expr1'], ') ?? (', $this->nodes['expr2'], ')'];
}
}
7 changes: 7 additions & 0 deletions src/Symfony/Component/ExpressionLanguage/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ protected function getPrimary()

protected function parseConditionalExpression(Node\Node $expr)
{
while ($this->stream->current->test(Token::PUNCTUATION_TYPE, '??')) {
$this->stream->next();
$expr2 = $this->parseExpression();

$expr = new Node\NullCoalesceNode($expr, $expr2);
}

while ($this->stream->current->test(Token::PUNCTUATION_TYPE, '?')) {
$this->stream->next();
if (!$this->stream->current->test(Token::PUNCTUATION_TYPE, ':')) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,44 @@ public function provideInvalidNullSafe()
yield ['foo?.bar["baz"].qux.quux', (object) ['bar' => ['baz' => null]], 'Unable to get property "qux" of non-object "foo.bar["baz"]".'];
}

/**
* @dataProvider provideNullCoalescing
*/
public function testNullCoalescingEvaluate($expression, $foo)
{
$expressionLanguage = new ExpressionLanguage();
$this->assertSame($expressionLanguage->evaluate($expression, ['foo' => $foo]), 'default');
}

/**
* @dataProvider provideNullCoalescing
*/
public function testNullCoalescingCompile($expression, $foo)
{
$expressionLanguage = new ExpressionLanguage();
$this->assertSame(eval(sprintf('return %s;', $expressionLanguage->compile($expression, ['foo' => 'foo']))), 'default');
}

public function provideNullCoalescing()
{
$foo = new class() extends \stdClass {
public function bar()
{
return null;
}
};

yield ['foo.bar ?? "default"', null];

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yield foo ?? 'default' <- will be red

yield ['foo.bar.baz ?? "default"', (object) ['bar' => null]];
yield ['foo.bar ?? foo.baz ?? "default"', null];
yield ['foo[0] ?? "default"', []];
yield ['foo["bar"] ?? "default"', ['bar' => null]];
yield ['foo["baz"] ?? "default"', ['bar' => null]];
yield ['foo["bar"]["baz"] ?? "default"', ['bar' => null]];
yield ['foo["bar"].baz ?? "default"', ['bar' => null]];
yield ['foo.bar().baz ?? "default"', $foo];
}

/**
* @dataProvider getRegisterCallbacks
*/
Expand Down
10 changes: 10 additions & 0 deletions src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,16 @@ public function getParseData()
'foo?.not()',
['foo'],
],
[
new Node\NullCoalesceNode(new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('bar', true), new Node\ArgumentsNode(), Node\GetAttrNode::PROPERTY_CALL), new Node\ConstantNode('default')),
'foo.bar ?? "default"',
['foo'],
],
[
new Node\NullCoalesceNode(new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('bar'), new Node\ArgumentsNode(), Node\GetAttrNode::ARRAY_CALL), new Node\ConstantNode('default')),
'foo["bar"] ?? "default"',
['foo'],
],

// chained calls
[
Expand Down