8000 feature #45795 [ExpressionLanguage] Add support for null-safe operato… · symfony/symfony@1b75cc0 · GitHub
[go: up one dir, main page]

Skip to content

Commit 1b75cc0

Browse files
feature #45795 [ExpressionLanguage] Add support for null-safe operator (mytuny)
This PR was merged into the 6.1 branch. Discussion ---------- [ExpressionLanguage] Add support for null-safe operator | Q | A | ------------- | --- | Branch? | 6.1 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | Fix #45411, #21691 | License | MIT | Doc PR | symfony/symfony-docs#16630 This is a long-time-lasting feature for the `ExpressionLanguage` component. I've been waiting for the support of `Nullsafe operator` in expressions dealing with mutable objects, until I finally decided to work on it once for all 👍 The lack of [nullsafety feature](https://wiki.php.net/rfc/nullsafe_operator) has been repeatedly reported as a BUG several time (e.g [#45411](#45411) & [#21691](#21691)) when it is actually a missing feature. Currently, expressions like `foo.bar` assumes that the property `bar` "always" exists on the object `foo` and if doesn't the parser throws a `RuntimeException`. Although, sometimes, that's exactly the behavior we need, some other times we may work with mutable objects with uncontrolled structure, thus, such assumption is error-prone and will force adding extra checks making the expression uglier and less readable. The proposed work, introduces the support for the `?.` syntax alongside with the usual `.` syntax to help working with objects with dynamic structure. The two notations works identically in all normal cases. The difference occurs when trying to access non-existant properties and/or methods where the `.` notation will throw a `RuntimeException` as usual and the `?.` notation will return `null` instead and no errors nor exceptions will be thrown. Hence the name "Null-Safe". PS: This work account ONLY for accessing **object's** properties and methods. It does not account for non-existant **array** items which is a seperate problem that can be addressed by introducing the [null coalescing](https://wiki.php.net/rfc/isset_ternary) operator. Another feature that I'm currently working on as well 💯 Commits ------- 946c59f [ExpressionLanguage] Add support for null-safe operator
2 parents 3c07197 + 946c59f commit 1b75cc0

File tree

7 files changed

+74
-11
lines changed

7 files changed

+74
-11
lines changed

src/Symfony/Component/ExpressionLanguage/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ CHANGELOG
22
=========
33

44
6.1
5-
-----
5+
---
66

7+
* Add support for null-safe syntax when parsing object's methods and properties
78
* Support lexing numbers with the numeric literal separator `_`
89
* Support lexing decimals with no leading zero
910

src/Symfony/Component/ExpressionLanguage/Lexer.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ public function tokenize(string $expression): TokenStream
7373
// operators
7474
$tokens[] = new Token(Token::OPERATOR_TYPE, $match[0], $cursor + 1);
7575
$cursor += \strlen($match[0]);
76+
} elseif ('?' === $expression[$cursor] && '.' === ($expression[$cursor + 1] ?? '')) {
77+
// null-safe
78+
$tokens[] = new Token(Token::PUNCTUATION_TYPE, '?.', ++$cursor);
79+
++$cursor;
7680
} elseif (str_contains('.,?:', $expression[$cursor])) {
7781
// punctuation
7882
$tokens[] = new Token(Token::PUNCTUATION_TYPE, $expression[$cursor], $cursor + 1);

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@
2020
*/
2121
class ConstantNode extends Node
2222
{
23+
public readonly bool $isNullSafe;
2324
private bool $isIdentifier;
2425

25-
public function __construct(mixed $value, bool $isIdentifier = false)
26+
public function __construct(mixed $value, bool $isIdentifier = false, bool $isNullSafe = false)
2627
{
2728
$this->isIdentifier = $isIdentifier;
29+
$this->isNullSafe = $isNullSafe;
2830
parent::__construct(
2931
[],
3032
['value' => $value]

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,20 @@ public function __construct(Node $node, Node $attribute, ArrayNode $arguments, i
3434

3535
public function compile(Compiler $compiler)
3636
{
37+
$nullSafe = $this->nodes['attribute'] instanceof ConstantNode && $this->nodes['attribute']->isNullSafe;
3738
switch ($this->attributes['type']) {
3839
case self::PROPERTY_CALL:
3940
$compiler
4041
->compile($this->nodes['node'])
41-
->raw('->')
42+
->raw($nullSafe ? '?->' : '->')
4243
->raw($this->nodes['attribute']->attributes['value'])
4344
;
4445
break;
4546

4647
case self::METHOD_CALL:
4748
$compiler
4849
->compile($this->nodes['node'])
49-
->raw('->')
50+
->raw($nullSafe ? '?->' : '->')
5051
->raw($this->nodes['attribute']->attributes['value'])
5152
->raw('(')
5253
->compile($this->nodes['arguments'])
@@ -69,6 +70,9 @@ public function evaluate(array $functions, array $values)
6970
switch ($this->attributes['type']) {
7071
case self::PROPERTY_CALL:
7172
$obj = $this->nodes['node']->evaluate($functions, $values);
73+
if (null === $obj && $this->nodes['attribute']->isNullSafe) {
74+
return null;
75+
}
7276
if (!\is_object($obj)) {
7377
throw new \RuntimeException(sprintf('Unable to get property "%s" of non-object "%s".', $this->nodes['attribute']->dump(), $this->nodes['node']->dump()));
7478
}
@@ -79,6 +83,9 @@ public function evaluate(array $functions, array $values)
7983

8084
case self::METHOD_CALL:
8185
$obj = $this->nodes['node']->evaluate($functions, $values);
86+
if (null === $obj && $this->nodes['attribute']->isNullSafe) {
87+
return null;
88+
}
8289
if (!\is_object($obj)) {
8390
throw new \RuntimeException(sprintf('Unable to call method "%s" of non-object "%s".', $this->nodes['attribute']->dump(), $this->nodes['node']->dump()));
8491
}

src/Symfony/Component/ExpressionLanguage/Parser.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,8 @@ public function parsePostfixExpression(Node\Node $node)
335335
{
336336
$token = $this->stream->current;
337337
while (Token::PUNCTUATION_TYPE == $token->type) {
338-
if ('.' === $token->value) {
338+
if ('.' === $token->value || '?.' === $token->value) {
339+
$isNullSafe = '?.' === $token->value;
339340
$this->stream->next();
340341
$token = $this->stream->current;
341342
$this->stream->next();
@@ -359,7 +360,7 @@ public function parsePostfixExpression(Node\Node $node)
359360
throw new SyntaxError('Expected name.', $token->cursor, $this->stream->getExpression());
360361
}
361362

362-
$arg = new Node\ConstantNode($token->value, true);
363+
$arg = new Node\ConstantNode($token->value, true, $isNullSafe);
363364

364365
$arguments = new Node\ArgumentsNode();
365366
if ($this->stream->current->test(Token::PUNCTUATION_TYPE, '(')) {

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

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -237,12 +237,41 @@ public function testRegisterAfterEval($registerCallback)
237237
$registerCallback($el);
238238
}
239239

240-
public function testCallBadCallable()
240+
/**
241+
* @dataProvider provideNullSafe
242+
*/
243+
public function testNullSafeEvaluate($expression, $foo)
241244
{
242-
$this->expectException(\RuntimeException::class);
243-
$this->expectExceptionMessageMatches('/Unable to call method "\w+" of object "\w+"./');
244-
$el = new ExpressionLanguage();
245-
$el->evaluate('foo.myfunction()', ['foo' => new \stdClass()]);
245+
$expressionLanguage = new ExpressionLanguage();
246+
$this->assertNull($expressionLanguage->evaluate($expression, ['foo' => $foo]));
247+
}
248+
249+
/**
250+
* @dataProvider provideNullSafe
251+
*/
252+
public function testNullsafeCompile($expression, $foo)
253+
{
254+
$expressionLanguage = new ExpressionLanguage();
255+
$this->assertNull(eval(sprintf('return %s;', $expressionLanguage->compile($expression, ['foo' => 'foo']))));
256+
}
257+
258+
public function provideNullsafe()
259+
{
260+
$foo = new class() extends \stdClass {
261+
public function bar()
262+
{
263+
return null;
264+
}
265+
};
266+
267+
yield ['foo?.bar', null];
268+
yield ['foo?.bar()', null];
269+
yield ['foo.bar?.baz', (object) ['bar' => null]];
270+
yield ['foo.bar?.baz()', (object) ['bar' => null]];
271+
yield ['foo["bar"]?.baz', ['bar' => null]];
272+
yield ['foo["bar"]?.baz()', ['bar' => null]];
273+
yield ['foo.bar()?.baz', $foo];
274+
yield ['foo.bar()?.baz()', $foo];
246275
}
247276

248277
/**

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,21 @@ public function getParseData()
148148
new Node\BinaryNode('contains', new Node\ConstantNode('foo'), new Node\ConstantNode('f')),
149149
'"foo" contains "f"',
150150
],
151+
[
152+
new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('bar', true, true), new Node\ArgumentsNode(), Node\GetAttrNode::PROPERTY_CALL),
153+
'foo?.bar',
154+
['foo'],
155+
],
156+
[
157+
new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('bar', true, true), new Node\ArgumentsNode(), Node\GetAttrNode::METHOD_CALL),
158+
'foo?.bar()',
159+
['foo'],
160+
],
161+
[
162+
new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('not', true, true), new Node\ArgumentsNode(), Node\GetAttrNode::METHOD_CALL),
163+
'foo?.not()',
164+
['foo'],
165+
],
151166

152167
// chained calls
153168
[
@@ -281,6 +296,10 @@ public function getLintData(): array
281296
'expression' => 'foo["some_key"].callFunction(a ? b)',
282297
'names' => ['foo', 'a', 'b'],
283298
],
299+
'valid expression with null safety' => [
300+
'expression' => 'foo["some_key"]?.callFunction(a ? b)',
301+
'names' => ['foo', 'a', 'b'],
302+
],
284303
'allow expression without names' => [
285304
'expression' => 'foo.bar',
286305
'names' => null,

0 commit comments

Comments
 (0)
0