From d56e8e2dbaf7c4db7797eeeaf9366ca9b31b2aa6 Mon Sep 17 00:00:00 2001 From: HypeMC Date: Sun, 25 Jan 2026 04:25:05 +0100 Subject: [PATCH] Support short-circuiting in null-safe operator chains --- doc/templates.rst | 6 ++- src/Node/Expression/GetAttrExpression.php | 48 +++++++++++++++++++---- tests/ExpressionParserTest.php | 44 +++++++++++++++++++++ tests/Node/Expression/GetAttrTest.php | 13 ++++++ 4 files changed, 102 insertions(+), 9 deletions(-) diff --git a/doc/templates.rst b/doc/templates.rst index 9743f8a37..9aa66f80c 100644 --- a/doc/templates.rst +++ b/doc/templates.rst @@ -894,7 +894,8 @@ The following operators don't fit into any of the other categories: {{ user.name }} The null-safe operator (``?.``) works like the dot operator but returns - ``null`` instead of throwing an exception when the left operand is ``null``: + ``null`` instead of throwing an exception when the left operand is ``null``. + If the operand is part of a chain, the rest of the chain is skipped: .. code-block:: twig @@ -904,6 +905,9 @@ The following operators don't fit into any of the other categories: {{ user?.address?.city }} {# can be chained for safe navigation through potentially null values #} + {{ user?.address.city }} + {# returns null if user is null, the rest of the chain is skipped (address.city is not evaluated) #} + .. versionadded:: 3.23 The null-safe operator was added in Twig 3.23. diff --git a/src/Node/Expression/GetAttrExpression.php b/src/Node/Expression/GetAttrExpression.php index f207b092c..cf6239517 100644 --- a/src/Node/Expression/GetAttrExpression.php +++ b/src/Node/Expression/GetAttrExpression.php @@ -36,7 +36,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' => !$nullSafe, 'null_safe' => $nullSafe], $lineno); + parent::__construct($nodes, ['type' => $type, 'ignore_strict_check' => false, 'optimizable' => !$nullSafe, 'null_safe' => $nullSafe, 'is_short_circuited' => false, 'var_name' => null], $lineno); } public function enableDefinedTest(): void @@ -50,7 +50,6 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest $env = $compiler->getEnvironment(); $arrayAccessSandbox = false; $nullSafe = $this->getAttribute('null_safe'); - $objectVar = null; // optimize array calls if ( @@ -99,18 +98,32 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest $this->getNode('node')->setAttribute('ignore_strict_check', true); } - if ($nullSafe) { - $objectVar = '$'.$compiler->getVarName(); + if (null === $nullSafeNode = $nullSafe ? $this : null) { + $node = $this->getNode('node'); + while ($node instanceof self) { + if ($node->getAttribute('null_safe')) { + $nullSafeNode = $node; + break; + } + $node = $node->getNode('node'); + } + } + + $isShortCircuited = false; + if (null !== $nullSafeNode && !$nullSafeNode->isShortCircuited()) { $compiler - ->raw('((null === ('.$objectVar.' = ') - ->subcompile($this->getNode('node')) + ->raw('((null === ('.$nullSafeNode->getVarName($compiler).' = ') + ->subcompile($nullSafeNode->getNode('node')) ->raw(')) ? null : '); + + $nullSafeNode->markAsShortCircuited(); + $isShortCircuited = true; } $compiler->raw('CoreExtension::getAttribute($this->env, $this->source, '); if ($nullSafe) { - $compiler->raw($objectVar); + $compiler->raw($this->getVarName($compiler)); } else { $compiler->subcompile($this->getNode('node')); } @@ -139,7 +152,7 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest $compiler->raw(')'); } - if ($nullSafe) { + if ($isShortCircuited) { $compiler->raw(')'); } } @@ -153,4 +166,23 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest $this->changeIgnoreStrictCheck($node->getNode('node')); } } + + private function markAsShortCircuited(): void + { + $this->setAttribute('is_short_circuited', true); + } + + private function isShortCircuited(): bool + { + return $this->getAttribute('is_short_circuited'); + } + + private function getVarName(Compiler $compiler): string + { + if (null === $this->getAttribute('var_name')) { + $this->setAttribute('var_name', $compiler->getVarName()); + } + + return '$'.$this->getAttribute('var_name'); + } } diff --git a/tests/ExpressionParserTest.php b/tests/ExpressionParserTest.php index 9cf9cbebb..372e4bd76 100644 --- a/tests/ExpressionParserTest.php +++ b/tests/ExpressionParserTest.php @@ -25,6 +25,7 @@ use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Compiler; use Twig\Environment; +use Twig\Error\RuntimeError; use Twig\Error\SyntaxError; use Twig\ExpressionParser\Prefix\UnaryOperatorExpressionParser; use Twig\Extension\AbstractExtension; @@ -350,6 +351,49 @@ class ExpressionParserTest extends TestCase ['foo' => (object) ['bar' => false]], '', ], + // short-circuiting + [ + '{{ foo?.bar.baz }}', + ['foo' => null], + '', + ], + [ + '{{ foo?.bar.baz?.qux.corge }}', + ['foo' => null], + '', + ], + [ + '{{ foo?.bar.baz?.qux.corge }}', + ['foo' => (object) ['bar' => (object) ['baz' => null]]], + '', + ], + ]; + } + + /** + * @dataProvider getTestForInvalidNullSafeOperatorShortCircuiting + */ + public function testInvalidNullSafeOperatorShortCircuiting(string $template, array $data, string $expectedMessage) + { + $env = new Environment(new ArrayLoader(['template' => $template]), ['strict_variables' => true]); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessage($expectedMessage); + + $env->render('template', $data); + } + + public static function getTestForInvalidNullSafeOperatorShortCircuiting() + { + yield [ + '{{ foo?.bar.baz }}', + ['foo' => (object) ['bar' => null]], + 'Impossible to access an attribute ("baz") on a null variable in "template" at line 1.', + ]; + yield [ + '{{ foo?.bar.baz?.qux.corge }}', + ['foo' => (object) ['bar' => (object) ['baz' => (object) ['qux' => null]]]], + 'Impossible to access an attribute ("corge") on a null variable in "template" at line 1.', ]; } diff --git a/tests/Node/Expression/GetAttrTest.php b/tests/Node/Expression/GetAttrTest.php index 6b2d92f61..a4454f9fd 100644 --- a/tests/Node/Expression/GetAttrTest.php +++ b/tests/Node/Expression/GetAttrTest.php @@ -51,6 +51,9 @@ class GetAttrTest extends NodeTestCase $expr = new ContextVariable('foo', 1); $attr = new ConstantExpression('bar', 1); + $attr2 = new ConstantExpression('baz', 1); + $attr3 = new ConstantExpression('qux', 1); + $attr4 = new ConstantExpression('corge', 1); $args = new ArrayExpression([], 1); $node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1); @@ -59,6 +62,16 @@ class GetAttrTest extends NodeTestCase $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::ANY_CALL, 1, true); + $node = new GetAttrExpression($node, $attr2, $args, Template::METHOD_CALL, 1); + $tests[] = [$node, '((null === ($_v%s = // line 1'."\n".'($context["foo"] ?? null))) ? null : '.self::createAttributeGetter().self::createAttributeGetter().'$_v%s, "bar", [], "any", false, false, false, 1), "baz", [], "method", false, false, false, 1))', null, true]; + + $node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1, true); + $node = new GetAttrExpression($node, $attr2, $args, Template::ANY_CALL, 1); + $node = new GetAttrExpression($node, $attr3, $args, Template::METHOD_CALL, 1, true); + $node = new GetAttrExpression($node, $attr4, $args, Template::ANY_CALL, 1); + $tests[] = [$node, '((null === ($_v0 = ((null === ($_v1 = // line 1'."\n".'($context["foo"] ?? null))) ? null : '.self::createAttributeGetter().self::createAttributeGetter().'$_v1, "bar", [], "any", false, false, false, 1), "baz", [], "any", false, false, false, 1)))) ? null : '.self::createAttributeGetter().self::createAttributeGetter().'$_v0, "qux", [], "method", false, false, false, 1), "corge", [], "any", false, false, false, 1))', null]; + $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, ];