diff --git a/src/Symfony/Component/ExpressionLanguage/CHANGELOG.md b/src/Symfony/Component/ExpressionLanguage/CHANGELOG.md index 9c4f52b899f89..ac4b6b2b99216 100644 --- a/src/Symfony/Component/ExpressionLanguage/CHANGELOG.md +++ b/src/Symfony/Component/ExpressionLanguage/CHANGELOG.md @@ -2,8 +2,9 @@ CHANGELOG ========= 6.1 ------ +--- + * Add support for null-safe syntax when parsing object's methods and properties * Support lexing numbers with the numeric literal separator `_` * Support lexing decimals with no leading zero diff --git a/src/Symfony/Component/ExpressionLanguage/Lexer.php b/src/Symfony/Component/ExpressionLanguage/Lexer.php index 26153dfe201c3..d0fe6dbc5b5a1 100644 --- a/src/Symfony/Component/ExpressionLanguage/Lexer.php +++ b/src/Symfony/Component/ExpressionLanguage/Lexer.php @@ -73,6 +73,10 @@ public function tokenize(string $expression): TokenStream // operators $tokens[] = new Token(Token::OPERATOR_TYPE, $match[0], $cursor + 1); $cursor += \strlen($match[0]); + } elseif ('?' === $expression[$cursor] && '.' === ($expression[$cursor + 1] ?? '')) { + // null-safe + $tokens[] = new Token(Token::PUNCTUATION_TYPE, '?.', ++$cursor); + ++$cursor; } elseif (str_contains('.,?:', $expression[$cursor])) { // punctuation $tokens[] = new Token(Token::PUNCTUATION_TYPE, $expression[$cursor], $cursor + 1); diff --git a/src/Symfony/Component/ExpressionLanguage/Node/ConstantNode.php b/src/Symfony/Component/ExpressionLanguage/Node/ConstantNode.php index 74ed464fe88f7..869e350dc9d4c 100644 --- a/src/Symfony/Component/ExpressionLanguage/Node/ConstantNode.php +++ b/src/Symfony/Component/ExpressionLanguage/Node/ConstantNode.php @@ -20,11 +20,13 @@ */ class ConstantNode extends Node { + public readonly bool $isNullSafe; private bool $isIdentifier; - public function __construct(mixed $value, bool $isIdentifier = false) + public function __construct(mixed $value, bool $isIdentifier = false, bool $isNullSafe = false) { $this->isIdentifier = $isIdentifier; + $this->isNullSafe = $isNullSafe; parent::__construct( [], ['value' => $value] diff --git a/src/Symfony/Component/ExpressionLanguage/Node/GetAttrNode.php b/src/Symfony/Component/ExpressionLanguage/Node/GetAttrNode.php index 04ac669954176..edcec01374749 100644 --- a/src/Symfony/Component/ExpressionLanguage/Node/GetAttrNode.php +++ b/src/Symfony/Component/ExpressionLanguage/Node/GetAttrNode.php @@ -34,11 +34,12 @@ public function __construct(Node $node, Node $attribute, ArrayNode $arguments, i public function compile(Compiler $compiler) { + $nullSafe = $this->nodes['attribute'] instanceof ConstantNode && $this->nodes['attribute']->isNullSafe; switch ($this->attributes['type']) { case self::PROPERTY_CALL: $compiler ->compile($this->nodes['node']) - ->raw('->') + ->raw($nullSafe ? '?->' : '->') ->raw($this->nodes['attribute']->attributes['value']) ; break; @@ -46,7 +47,7 @@ public function compile(Compiler $compiler) case self::METHOD_CALL: $compiler ->compile($this->nodes['node']) - ->raw('->') + ->raw($nullSafe ? '?->' : '->') ->raw($this->nodes['attribute']->attributes['value']) ->raw('(') ->compile($this->nodes['arguments']) @@ -69,6 +70,9 @@ 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) { + return null; + } if (!\is_object($obj)) { throw new \RuntimeException(sprintf('Unable to get property "%s" of non-object "%s".', $this->nodes['attribute']->dump(), $this->nodes['node']->dump())); } @@ -79,6 +83,9 @@ public function evaluate(array $functions, array $values) case self::METHOD_CALL: $obj = $this->nodes['node']->evaluate($functions, $values); + if (null === $obj && $this->nodes['attribute']->isNullSafe) { + return null; + } if (!\is_object($obj)) { throw new \RuntimeException(sprintf('Unable to call method "%s" of non-object "%s".', $this->nodes['attribute']->dump(), $this->nodes['node']->dump())); } diff --git a/src/Symfony/Component/ExpressionLanguage/Parser.php b/src/Symfony/Component/ExpressionLanguage/Parser.php index 8a5376464010d..716639a339a3c 100644 --- a/src/Symfony/Component/ExpressionLanguage/Parser.php +++ b/src/Symfony/Component/ExpressionLanguage/Parser.php @@ -335,7 +335,8 @@ public function parsePostfixExpression(Node\Node $node) { $token = $this->stream->current; while (Token::PUNCTUATION_TYPE == $token->type) { - if ('.' === $token->value) { + if ('.' === $token->value || '?.' === $token->value) { + $isNullSafe = '?.' === $token->value; $this->stream->next(); $token = $this->stream->current; $this->stream->next(); @@ -359,7 +360,7 @@ public function parsePostfixExpression(Node\Node $node) throw new SyntaxError('Expected name.', $token->cursor, $this->stream->getExpression()); } - $arg = new Node\ConstantNode($token->value, true); + $arg = new Node\ConstantNode($token->value, true, $isNullSafe); $arguments = new Node\ArgumentsNode(); if ($this->stream->current->test(Token::PUNCTUATION_TYPE, '(')) { diff --git a/src/Symfony/Component/ExpressionLanguage/Tests/ExpressionLanguageTest.php b/src/Symfony/Component/ExpressionLanguage/Tests/ExpressionLanguageTest.php index c0bd560d31ba1..1f9770972f5e6 100644 --- a/src/Symfony/Component/ExpressionLanguage/Tests/ExpressionLanguageTest.php +++ b/src/Symfony/Component/ExpressionLanguage/Tests/ExpressionLanguageTest.php @@ -237,12 +237,41 @@ public function testRegisterAfterEval($registerCallback) $registerCallback($el); } - public function testCallBadCallable() + /** + * @dataProvider provideNullSafe + */ + public function testNullSafeEvaluate($expression, $foo) { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessageMatches('/Unable to call method "\w+" of object "\w+"./'); - $el = new ExpressionLanguage(); - $el->evaluate('foo.myfunction()', ['foo' => new \stdClass()]); + $expressionLanguage = new ExpressionLanguage(); + $this->assertNull($expressionLanguage->evaluate($expression, ['foo' => $foo])); + } + + /** + * @dataProvider provideNullSafe + */ + public function testNullsafeCompile($expression, $foo) + { + $expressionLanguage = new ExpressionLanguage(); + $this->assertNull(eval(sprintf('return %s;', $expressionLanguage->compile($expression, ['foo' => 'foo'])))); + } + + public function provideNullsafe() + { + $foo = new class() extends \stdClass { + public function bar() + { + return null; + } + }; + + yield ['foo?.bar', null]; + yield ['foo?.bar()', null]; + yield ['foo.bar?.baz', (object) ['bar' => null]]; + yield ['foo.bar?.baz()', (object) ['bar' => null]]; + yield ['foo["bar"]?.baz', ['bar' => null]]; + yield ['foo["bar"]?.baz()', ['bar' => null]]; + yield ['foo.bar()?.baz', $foo]; + yield ['foo.bar()?.baz()', $foo]; } /** diff --git a/src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php b/src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php index 1dfd71fb5b13d..af2857cd18a92 100644 --- a/src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php +++ b/src/Symfony/Component/ExpressionLanguage/Tests/ParserTest.php @@ -148,6 +148,21 @@ public function getParseData() new Node\BinaryNode('contains', new Node\ConstantNode('foo'), new Node\ConstantNode('f')), '"foo" contains "f"', ], + [ + new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('bar', true, true), new Node\ArgumentsNode(), Node\GetAttrNode::PROPERTY_CALL), + 'foo?.bar', + ['foo'], + ], + [ + new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('bar', true, true), new Node\ArgumentsNode(), Node\GetAttrNode::METHOD_CALL), + 'foo?.bar()', + ['foo'], + ], + [ + new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('not', true, true), new Node\ArgumentsNode(), Node\GetAttrNode::METHOD_CALL), + 'foo?.not()', + ['foo'], + ], // chained calls [ @@ -281,6 +296,10 @@ public function getLintData(): array 'expression' => 'foo["some_key"].callFunction(a ? b)', 'names' => ['foo', 'a', 'b'], ], + 'valid expression with null safety' => [ + 'expression' => 'foo["some_key"]?.callFunction(a ? b)', + 'names' => ['foo', 'a', 'b'], + ], 'allow expression without names' => [ 'expression' => 'foo.bar', 'names' => null,