Skip to content

Commit 998d802

Browse files
mytunynicolas-grekas
authored andcommitted
[ExpressionLanguage] Add support for nullsafe operator
1 parent 3c07197 commit 998d802

File tree

6 files changed

+123
-50
lines changed

6 files changed

+123
-50
lines changed

src/Symfony/Component/ExpressionLanguage/CHANGELOG.md

+2-1
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 nullsafe 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/Node/ConstantNode.php

+3-1
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

+9-2
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

+56-41
Original file line numberDiff line numberDiff line change
@@ -335,49 +335,18 @@ 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) {
339339
$this->stream->next();
340340
$token = $this->stream->current;
341-
$this->stream->next();
342-
343-
if (
344-
Token::NAME_TYPE !== $token->type
345-
&&
346-
// Operators like "not" and "matches" are valid method or property names,
347-
//
348-
// In other words, besides NAME_TYPE, OPERATOR_TYPE could also be parsed as a property or method.
349-
// This is because operators are processed by the lexer prior to names. So "not" in "foo.not()" or "matches" in "foo.matches" will be recognized as an operator first.
350-
// But in fact, "not" and "matches" in such expressions shall be parsed as method or property names.
351-
//
352-
// And this ONLY works if the operator consists of valid characters for a property or method name.
353-
//
354-
// Other types, such as STRING_TYPE and NUMBER_TYPE, can't be parsed as property nor method names.
355-
//
356-
// As a result, if $token is NOT an operator OR $token->value is NOT a valid property or method name, an exception shall be thrown.
357-
(Token::OPERATOR_TYPE !== $token->type || !preg_match('/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/A', $token->value))
358-
) {
359-
throw new SyntaxError('Expected name.', $token->cursor, $this->stream->getExpression());
360-
}
361-
362-
$arg = new Node\ConstantNode($token->value, true);
363-
364-
$arguments = new Node\ArgumentsNode();
365-
if ($this->stream->current->test(Token::PUNCTUATION_TYPE, '(')) {
366-
$type = Node\GetAttrNode::METHOD_CALL;
367-
foreach ($this->parseArguments()->nodes as $n) {
368-
$arguments->addElement($n);
369-
}
341+
if ('.' === $token->value) {
342+
$node = $this->parseObjectAccessExpression($node, true);
370343
} else {
371-
$type = Node\GetAttrNode::PROPERTY_CALL;
344+
$node = $this->parseExpression();
372345
}
373-
374-
$node = new Node\GetAttrNode($node, $arg, $arguments, $type);
346+
} elseif ('.' === $token->value) {
347+
$node = $this->parseObjectAccessExpression($node, false);
375348
} elseif ('[' === $token->value) {
376-
$this->stream->next();
377-
$arg = $this->parseExpression();
378-
$this->stream->expect(Token::PUNCTUATION_TYPE, ']');
379-
380-
$node = new Node\GetAttrNode($node, $arg, new Node\ArgumentsNode(), Node\GetAttrNode::ARRAY_CALL);
349+
$node = $this->parseArrayAccessExpression($node);
381350
} else {
382351
break;
383352
}
@@ -388,9 +357,6 @@ public function parsePostfixExpression(Node\Node $node)
388357
return $node;
389358
}
390359

391-
/**
392-
* Parses arguments.
393-
*/
394360
public function parseArguments()
395361
{
396362
$args = [];
@@ -406,4 +372,53 @@ public function parseArguments()
406372

407373
return new Node\Node($args);
408374
}
375+
376+
private function parseObjectAccessExpression(Node\Node $node, bool $isNullSafe): Node\GetAttrNode
377+
{
378+
$this->stream->next();
379+
$token = $this->stream->current;
380+
$this->stream->next();
381+
382+
if (
383+
Token::NAME_TYPE !== $token->type
384+
&&
385+
// Operators like "not" and "matches" are valid method or property names,
386+
//
387+
// In other words, besides NAME_TYPE, OPERATOR_TYPE could also be parsed as a property or method.
388+
// This is because operators are processed by the lexer prior to names. So "not" in "foo.not()" or "matches" in "foo.matches" will be recognized as an operator first.
389+
// But in fact, "not" and "matches" in such expressions shall be parsed as method or property names.
390+
//
391+
// And this ONLY works if the operator consists of valid characters for a property or method name.
392+
//
393+
// Other types, such as STRING_TYPE and NUMBER_TYPE, can't be parsed as property nor method names.
394+
//
395+
// As a result, if $token is NOT an operator OR $token->value is NOT a valid property or method name, an exception shall be thrown.
396+
(Token::OPERATOR_TYPE !== $token->type || !preg_match('/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/A', $token->value))
397+
) {
398+
throw new SyntaxError('Expected name.', $token->cursor, $this->stream->getExpression());
399+
}
400+
401+
$arg = new Node\ConstantNode($token->value, true, $isNullSafe);
402+
403+
$arguments = new Node\ArgumentsNode();
404+
if ($this->stream->current->test(Token::PUNCTUATION_TYPE, '(')) {
405+
$type = Node\GetAttrNode::METHOD_CALL;
406+
foreach ($this->parseArguments()->nodes as $n) {
407+
$arguments->addElement($n);
408+
}
409+
} else {
410+
$type = Node\GetAttrNode::PROPERTY_CALL;
411+
}
412+
413+
return new Node\GetAttrNode($node, $arg, $arguments, $type);
414+
}
415+
416+
private function parseArrayAccessExpression(Node\Node $node): Node\GetAttrNode
417+
{
418+
$this->stream->next();
419+
$arg = $this->parseExpression();
420+
$this->stream->expect(Token::PUNCTUATION_TYPE, ']');
421+
422+
return new Node\GetAttrNode($node, $arg, new Node\ArgumentsNode(), Node\GetAttrNode::ARRAY_CALL);
423+
}
409424
}

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

+34-5
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

+19
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)