8000 feature #46142 [ExpressionLanguage] Add support for null coalescing s… · symfony/symfony@39191b4 · GitHub
[go: up one dir, main page]

Skip to content

Commit 39191b4

Browse files
committed
feature #46142 [ExpressionLanguage] Add support for null coalescing syntax (mytuny)
This PR was merged into the 6.2 branch. Discussion ---------- [ExpressionLanguage] Add support for null coalescing syntax | Q | A | ------------- | --- | Branch? | 6.1 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | Fix #45411, #21691 | License | MIT | Doc PR | symfony/symfony-docs#16743 This is another waited feature for the syntax of the expression-language component. The [null-coalescing](https://wiki.php.net/rfc/isset_ternary) operator ``??`` becomes a need for variant programming needs these days. Following my previous PR introducing the null-safe operator (#45795). I'm hereby introducing yet another essential operator to make the syntax even more complete. The null-coalescing operator is a syntactic sugar for a common use of ternary in conjunction with ``isset()`` (in PHP) or equivalent in other languages. This is such a common use-case to the point that almost all majors programming syntax nowadays support a sort of a short-hand for that operation namely coalescing operator. Now it's time for the syntax of Expression-Language to do so! Expressions like: * ``foo.bar ?? 'default'`` * ``foo[3] ?? 'default'`` * ``foo.bar ?? foo['bar'] ?? 'default'`` will default to the expression in the right-hand-side of the ``??`` operator whenever the expression in the left-hand-side of it does not exist or it's ``null``. Note that this coalescing behavior can be chained and the validation logic takes decreasing priority from left to right. Commits ------- 8e3c505 [ExpressionLanguage] Add support for null coalescing syntax
2 parents aee7c31 + 8e3c505 commit 39191b4

File tree

7 files changed

+123
-3
lines changed

7 files changed

+123
-3
lines changed

src/Symfony/Component/ExpressionLanguage/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
6.1
55
---
66

7+
* Add support for null-coalescing syntax
78
* Add support for null-safe syntax when parsing object's methods and properties
89
* Add new operators: `contains`, `starts with` and `ends with`
910
* Support lexing numbers with the numeric literal separator `_`

src/Symfony/Component/ExpressionLanguage/Lexer.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ public function tokenize(string $expression): TokenStream
7777
// null-safe
7878
$tokens[] = new Token(Token::PUNCTUATION_TYPE, '?.', ++$cursor);
7979
++$cursor;
80+
} elseif ('?' === $expression[$cursor] && '?' === ($expression[$cursor + 1] ?? '')) {
81+
// null-coalescing
82+
$tokens[] = new Token(Token::PUNCTUATION_TYPE, '??', ++$cursor);
83+
++$cursor;
8084
} elseif (str_contains('.,?:', $expression[$cursor])) {
8185
// punctuation
8286
$tokens[] = new Token(Token::PUNCTUATION_TYPE, $expression[$cursor], $cursor + 1);

src/Symfony/Component/ExpressionLanguage/Node/GetAttrNode.php

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class GetAttrNode extends Node
2525
public const ARRAY_CALL = 3;
2626

2727
private bool $isShortCircuited = false;
28+
public bool $isNullCoalesce = false;
2829

2930
public function __construct(Node $node, Node $attribute, ArrayNode $arguments, int $type)
3031
{
@@ -72,8 +73,7 @@ public function evaluate(array $functions, array $values)
7273
switch ($this->attributes['type']) {
7374
case self::PROPERTY_CALL:
7475
$obj = $this->nodes['node']->evaluate($functions, $values);
75-
76-
if (null === $obj && $this->nodes['attribute']->isNullSafe) {
76+
if (null === $obj && ($this->nodes['attribute']->isNullSafe || $this->isNullCoalesce)) {
7777
$this->isShortCircuited = true;
7878

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

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

91+
if ($this->isNullCoalesce) {
92+
return $obj->$property ?? null;
93+
}
94+
9195
return $obj->$property;
9296

9397
case self::METHOD_CALL:
@@ -118,10 +122,14 @@ public function evaluate(array $functions, array $values)
118122
return null;
119123
}
120124

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

129+
if ($this->isNullCoalesce) {
130+
return $array[$this->nodes['attribute']->evaluate($functions, $values)] ?? null;
131+
}
132+
125133
return $array[$this->nodes['attribute']->evaluate($functions, $values)];
126134
}
127135
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\ExpressionLanguage\Node;
13+
14+
use Symfony\Component\ExpressionLanguage\Compiler;
15+
16+
/**
17+
* @author Fabien Potencier <fabien@symfony.com>
18+
*
19+
* @internal
20+
*/
21+
class NullCoalesceNode extends Node
22+
{
23+
public function __construct(Node $expr1, Node $expr2)
24+
{
25+
parent::__construct(['expr1' => $expr1, 'expr2' => $expr2]);
26+
}
27+
28+
public function compile(Compiler $compiler)
29+
{
30+
$compiler
31+
->raw('((')
32+
->compile($this->nodes['expr1'])
33+
->raw(') ?? (')
34+
->compile($this->nodes['expr2'])
35+
->raw('))')
36+
;
37+
}
38+
39+
public function evaluate(array $functions, array $values)
40+
{
41+
if ($this->nodes['expr1'] instanceof GetAttrNode) {
42+
$this->nodes['expr1']->isNullCoalesce = true;
43+
}
44+
45+
return $this->nodes['expr1']->evaluate($functions, $values) ?? $this->nodes['expr2']->evaluate($functions, $values);
46+
}
47+
48+
public function toArray()
49+
{
50+
return ['(', $this->nodes['expr1'], ') ?? (', $this->nodes['expr2'], ')'];
51+
}
52+
}

src/Symfony/Component/ExpressionLanguage/Parser.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,13 @@ protected function getPrimary()
176176

177177
protected function parseConditionalExpression(Node\Node $expr)
178178
{
179+
while ($this->stream->current->test(Token::PUNCTUATION_TYPE, '??')) {
180+
$this->stream->next();
181+
$expr2 = $this->parseExpression();
182+
183+
$expr = new Node\NullCoalesceNode($expr, $expr2);
184+
}
185+
179186
while ($this->stream->current->test(Token::PUNCTUATION_TYPE, '?')) {
180187
$this->stream->next();
181188
if (!$this->stream->current->test(Token::PUNCTUATION_TYPE, ':')) {

src/Symfony/Component/ExpressionLanguage/Tests/ExpressionLanguageTest.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,44 @@ public function provideInvalidNullSafe()
315315
yield ['foo?.bar["baz"].qux.quux', (object) ['bar' => ['baz' => null]], 'Unable to get property "qux" of non-object "foo.bar["baz"]".'];
316316
}
317317

318+
/**
319+
* @dataProvider provideNullCoalescing
320+
*/
321+
public function testNullCoalescingEvaluate($expression, $foo)
322+
{
323+
$expressionLanguage = new ExpressionLanguage();
324+
$this->assertSame($expressionLanguage->evaluate($expression, ['foo' => $foo]), 'default');
325+
}
326+
327+
/**
328+
* @dataProvider provideNullCoalescing
329+
*/
330+
public function testNullCoalescingCompile($expression, $foo)
331+
{
332+
$expressionLanguage = new ExpressionLanguage();
333+
$this->assertSame(eval(sprintf('return %s;', $expressionLanguage->compile($expression, ['foo' => 'foo']))), 'default');
334+
}
335+
336+
public function provideNullCoalescing()
337+
{
338+
$foo = new class() extends \stdClass {
339+
public function bar()
340+
{
341+
return null;
342+
}
343+
};
344+
345+
yield ['foo.bar ?? "default"', null];
346+
yield ['foo.bar.baz ?? "default"', (object) ['bar' => null]];
347+
yield ['foo.bar ?? foo.baz ?? "default"', null];
348+
yield ['foo[0] ?? "default"', []];
349+
yield ['foo["bar"] ?? "default"', ['bar' => null]];
350+
yield ['foo["baz"] ?? "default"', ['bar' => null]];
351+
yield ['foo["bar"]["baz"] ?? "default"', ['bar' => null]];
352+
yield ['foo["bar"].baz ?? "default"', ['bar' => null]];
353+
yield ['foo.bar().baz ?? "default"', $foo];
354+
}
355+
318356
/**
319357
* @dataProvider getRegisterCallbacks
320358
*/

src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,16 @@ public function getParseData()
163163
'foo?.not()',
164164
['foo'],
165165
],
166+
[
167+
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')),
168+
'foo.bar ?? "default"',
169+
['foo'],
170+
],
171+
[
172+
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')),
173+
'foo["bar"] ?? "default"',
174+
['foo'],
175+
],
166176

167177
// chained calls
168178
[

0 commit comments

Comments
 (0)
0