From 263c04fd1f08650f3efc6433793146b704cffba2 Mon Sep 17 00:00:00 2001 From: Felds Liscia Date: Mon, 1 Dec 2025 15:15:39 -0300 Subject: [PATCH] Add null-safe operator --- .../Tests/Fixtures/script_names.test | 8 +-- .../Infix/DotExpressionParser.php | 10 +++- src/Node/Expression/GetAttrExpression.php | 30 ++++++++-- tests/ExpressionParserTest.php | 56 +++++++++++++++++++ .../expressions/dot_as_concatenation.test | 2 +- tests/Node/Expression/GetAttrTest.php | 5 ++ 6 files changed, 98 insertions(+), 13 deletions(-) diff --git a/extra/intl-extra/Tests/Fixtures/script_names.test b/extra/intl-extra/Tests/Fixtures/script_names.test index 798881681..da6c022a1 100644 --- a/extra/intl-extra/Tests/Fixtures/script_names.test +++ b/extra/intl-extra/Tests/Fixtures/script_names.test @@ -2,15 +2,15 @@ "script_names" function --TEMPLATE-- {{ script_names('UNKNOWN')|length }} -{{ script_names() is iterable }} -{{ script_names('fr') is iterable }} +{{ script_names()|length > 200 ? 'more than 200' : 'less than 200' }} +{{ script_names('fr')|length > 200 ? 'more than 200' : 'less than 200' }} {{ script_names()['Marc'] }} {{ script_names('fr')['Marc'] }} --DATA-- return []; --EXPECT-- 0 -1 -1 +more than 200 +more than 200 Marchen Marchen diff --git a/src/ExpressionParser/Infix/DotExpressionParser.php b/src/ExpressionParser/Infix/DotExpressionParser.php index 7d1cf5058..31418b585 100644 --- a/src/ExpressionParser/Infix/DotExpressionParser.php +++ b/src/ExpressionParser/Infix/DotExpressionParser.php @@ -37,6 +37,7 @@ final class DotExpressionParser extends AbstractExpressionParser implements Infi public function parse(Parser $parser, AbstractExpression $expr, Token $token): AbstractExpression { + $nullSafe = '?.' === $token->getValue(); $stream = $parser->getStream(); $token = $stream->getCurrent(); $lineno = $token->getLine(); @@ -55,7 +56,7 @@ final class DotExpressionParser extends AbstractExpressionParser implements Infi ) { $attribute = new ConstantExpression($token->getValue(), $token->getLine()); } else { - throw new SyntaxError(\sprintf('Expected name or number, got value "%s" of type %s.', $token->getValue(), $token->toEnglish()), $token->getLine(), $stream->getSourceContext()); + throw new SyntaxError(\sprintf('Expected name or number, got value "%s" of type "%s".', $token->getValue(), $token->toEnglish()), $token->getLine(), $stream->getSourceContext()); } } @@ -74,7 +75,7 @@ final class DotExpressionParser extends AbstractExpressionParser implements Infi return new MacroReferenceExpression(new TemplateVariable($expr->getAttribute('name'), $expr->getTemplateLine()), 'macro_'.$attribute->getAttribute('value'), $arguments, $expr->getTemplateLine()); } - return new GetAttrExpression($expr, $attribute, $arguments, $type, $lineno); + return new GetAttrExpression($expr, $attribute, $arguments, $type, $lineno, $nullSafe); } public function getName(): string @@ -82,6 +83,11 @@ final class DotExpressionParser extends AbstractExpressionParser implements Infi return '.'; } + public function getAliases(): array + { + return ['?.']; + } + public function getDescription(): string { return 'Get an attribute on a variable'; diff --git a/src/Node/Expression/GetAttrExpression.php b/src/Node/Expression/GetAttrExpression.php index 1222d7759..ac8ef1105 100644 --- a/src/Node/Expression/GetAttrExpression.php +++ b/src/Node/Expression/GetAttrExpression.php @@ -4,7 +4,6 @@ * This file is part of Twig. * * (c) Fabien Potencier - * (c) Armin Ronacher * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. @@ -25,7 +24,7 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest /** * @param ArrayExpression|NameExpression|null $arguments */ - public function __construct(AbstractExpression $node, AbstractExpression $attribute, ?AbstractExpression $arguments, string $type, int $lineno) + public function __construct(AbstractExpression $node, AbstractExpression $attribute, ?AbstractExpression $arguments, string $type, int $lineno, bool $nullSafe = false) { $nodes = ['node' => $node, 'attribute' => $attribute]; if (null !== $arguments) { @@ -36,7 +35,7 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest trigger_deprecation('twig/twig', '3.15', \sprintf('Not passing a "%s" instance as the "arguments" argument of the "%s" constructor is deprecated ("%s" given).', ArrayExpression::class, static::class, $arguments::class)); } - parent::__construct($nodes, ['type' => $type, 'ignore_strict_check' => false, 'optimizable' => true], $lineno); + parent::__construct($nodes, ['type' => $type, 'ignore_strict_check' => false, 'optimizable' => !$nullSafe, 'null_safe' => $nullSafe], $lineno); } public function enableDefinedTest(): void @@ -49,6 +48,8 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest { $env = $compiler->getEnvironment(); $arrayAccessSandbox = false; + $nullSafe = $this->getAttribute('null_safe'); + $objectVar = null; // optimize array calls if ( @@ -93,14 +94,27 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest ; } - $compiler->raw('CoreExtension::getAttribute($this->env, $this->source, '); - if ($this->getAttribute('ignore_strict_check')) { $this->getNode('node')->setAttribute('ignore_strict_check', true); } + if ($nullSafe) { + $objectVar = '$'.$compiler->getVarName(); + $compiler + ->raw('((null === ('.$objectVar.' = ') + ->subcompile($this->getNode('node')) + ->raw(')) ? null : '); + } + + $compiler->raw('CoreExtension::getAttribute($this->env, $this->source, '); + + if ($nullSafe) { + $compiler->raw($objectVar); + } else { + $compiler->subcompile($this->getNode('node')); + } + $compiler - ->subcompile($this->getNode('node')) ->raw(', ') ->subcompile($this->getNode('attribute')) ; @@ -123,6 +137,10 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest if ($arrayAccessSandbox) { $compiler->raw(')'); } + + if ($nullSafe) { + $compiler->raw(')'); + } } private function changeIgnoreStrictCheck(self $node): void diff --git a/tests/ExpressionParserTest.php b/tests/ExpressionParserTest.php index 4f1685820..5da06b68c 100644 --- a/tests/ExpressionParserTest.php +++ b/tests/ExpressionParserTest.php @@ -288,6 +288,62 @@ class ExpressionParserTest extends TestCase ]; } + /** + * @dataProvider getTestsForNullSafeOperator + */ + public function testNullSafeOperator($template, $data, $expected) + { + $env = new Environment(new ArrayLoader(['template' => $template])); + + $this->assertSame($expected, $env->render('template', $data)); + } + + public static function getTestsForNullSafeOperator() + { + return [ + [ + '{{ foo?.bar }}', + ['foo' => (object) ['bar' => 'baz']], + 'baz', + ], + [ + '{{ foo?.bar }}', + ['foo' => null], + '', + ], + [ + '{{ foo?.bar?.baz }}', + ['foo' => (object) ['bar' => (object) ['baz' => 'qux']]], + 'qux', + ], + [ + '{{ foo?.bar?.baz }}', + ['foo' => (object) ['bar' => null]], + '', + ], + [ + '{{ foo?.bar?.baz }}', + ['foo' => null], + '', + ], + [ + '{{ foo?.bar?.baz ?? "qux" }}', + ['foo' => null], + 'qux', + ], + [ + '{{ foo?.bar ?? "qux" }}', + ['foo' => (object) ['bar' => 0]], + '0', + ], + [ + '{{ foo?.bar ?? "qux" }}', + ['foo' => (object) ['bar' => false]], + '', + ], + ]; + } + public function testMacroDefinitionDoesNotSupportNonNameVariableName() { $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); diff --git a/tests/Fixtures/expressions/dot_as_concatenation.test b/tests/Fixtures/expressions/dot_as_concatenation.test index 7aa1403b6..4a24a544f 100644 --- a/tests/Fixtures/expressions/dot_as_concatenation.test +++ b/tests/Fixtures/expressions/dot_as_concatenation.test @@ -5,4 +5,4 @@ Twig does not support using . for concatenation --DATA-- return [] --EXCEPTION-- -Twig\Error\SyntaxError: Expected name or number, got value "b" of type string in "index.twig" at line 2. +Twig\Error\SyntaxError: Expected name or number, got value "b" of type "string" in "index.twig" at line 2. diff --git a/tests/Node/Expression/GetAttrTest.php b/tests/Node/Expression/GetAttrTest.php index 69f3d0a59..6b2d92f61 100644 --- a/tests/Node/Expression/GetAttrTest.php +++ b/tests/Node/Expression/GetAttrTest.php @@ -42,6 +42,7 @@ class GetAttrTest extends NodeTestCase $this->assertEquals($attr, $node->getNode('attribute')); $this->assertEquals($args, $node->getNode('arguments')); $this->assertEquals(Template::ARRAY_CALL, $node->getAttribute('type')); + $this->assertFalse($node->getAttribute('null_safe')); } public static function provideTests(): iterable @@ -51,9 +52,13 @@ class GetAttrTest extends NodeTestCase $expr = new ContextVariable('foo', 1); $attr = new ConstantExpression('bar', 1); $args = new ArrayExpression([], 1); + $node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1); $tests[] = [$node, \sprintf('%s%s, "bar", [], "any", false, false, false, 1)', self::createAttributeGetter(), self::createVariableGetter('foo', 1))]; + $node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1, true); + $tests[] = [$node, '((null === ($_v%s = // line 1'."\n".'($context["foo"] ?? null))) ? null : '.self::createAttributeGetter().'$_v%s, "bar", [], "any", false, false, false, 1))', null, true]; + $node = new GetAttrExpression($expr, $attr, $args, Template::ARRAY_CALL, 1); $tests[] = [$node, '(($_v%s = // line 1'."\n". '($context["foo"] ?? null)) && is_array($_v%s) || $_v%s instanceof ArrayAccess ? ($_v%s["bar"] ?? null) : null)', null, true, ];