diff --git a/CHANGELOG b/CHANGELOG index 1ee3882b7..c0e5a831f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,7 +1,7 @@ # 3.23.0 (2026-XX-XX) * Add `=` assignment operator (allows to set variables in expression or to replace the short-form of the set tag) - * Add sequence destructuring + * Add sequence, mapping, and object destructuring * Add `?.` null-safe operator * Add `===` and `!==` operators (equivalent to the `same as` and `not same as` tests) diff --git a/doc/templates.rst b/doc/templates.rst index 14a3f7904..9743f8a37 100644 --- a/doc/templates.rst +++ b/doc/templates.rst @@ -1147,6 +1147,25 @@ You can skip values by leaving a slot empty: {# only assign the second value #} {% do [, last] = ['Fabien', 'Potencier'] %} +Object Destructuring +~~~~~~~~~~~~~~~~~~~~ + +Use curly braces on the left side of an assignment to destructure an object +or mapping by extracting values based on property/key names: + +.. code-block:: twig + + {% do {name, email} = user %} + + {{ name }} {# user.name #} + {{ email }} {# user.email #} + +.. note:: + + Object destructuring uses the :ref:`dot operator ` to access + values, so ``{name} = user`` is equivalent to ``name = user.name`` or + ``name = user["name"]`` depending on the type of the variable. + .. _templates-whitespace-control: Whitespace Control diff --git a/src/ExpressionParser/Infix/AssignmentExpressionParser.php b/src/ExpressionParser/Infix/AssignmentExpressionParser.php index 2f761bf1d..fa8c4f2b0 100644 --- a/src/ExpressionParser/Infix/AssignmentExpressionParser.php +++ b/src/ExpressionParser/Infix/AssignmentExpressionParser.php @@ -16,6 +16,7 @@ use Twig\ExpressionParser\InfixAssociativity; use Twig\Node\Expression\AbstractExpression; use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\Binary\AbstractBinary; +use Twig\Node\Expression\Binary\ObjectDestructuringSetBinary; use Twig\Node\Expression\Binary\SequenceDestructuringSetBinary; use Twig\Node\Expression\Binary\SetBinary; use Twig\Node\Expression\Variable\ContextVariable; @@ -48,7 +49,11 @@ class AssignmentExpressionParser extends BinaryOperatorExpressionParser }; if ($left instanceof ArrayExpression) { - return new SequenceDestructuringSetBinary($left, $right, $token->getLine()); + if ($left->isSequence()) { + return new SequenceDestructuringSetBinary($left, $right, $token->getLine()); + } else { + return new ObjectDestructuringSetBinary($left, $right, $token->getLine()); + } } else { return new SetBinary($left, $right, $token->getLine()); } diff --git a/src/Node/Expression/ArrayExpression.php b/src/Node/Expression/ArrayExpression.php index e27b72d73..f9f719dd9 100644 --- a/src/Node/Expression/ArrayExpression.php +++ b/src/Node/Expression/ArrayExpression.php @@ -61,6 +61,31 @@ class ArrayExpression extends AbstractExpression implements SupportDefinedTestIn return false; } + /** + * Checks if the array is a sequence (keys are sequential integers starting from 0). + * + * @internal + */ + public function isSequence(): bool + { + foreach ($this->getKeyValuePairs() as $i => $pair) { + $key = $pair['key']; + if ($key instanceof TempNameExpression) { + $keyValue = $key->getAttribute('name'); + } elseif ($key instanceof ConstantExpression) { + $keyValue = $key->getAttribute('value'); + } else { + return false; + } + + if ($keyValue !== $i) { + return false; + } + } + + return true; + } + public function addElement(AbstractExpression $value, ?AbstractExpression $key = null): void { if (null === $key) { diff --git a/src/Node/Expression/Binary/ObjectDestructuringSetBinary.php b/src/Node/Expression/Binary/ObjectDestructuringSetBinary.php new file mode 100644 index 000000000..2c0853f65 --- /dev/null +++ b/src/Node/Expression/Binary/ObjectDestructuringSetBinary.php @@ -0,0 +1,71 @@ +getKeyValuePairs() as $pair) { + if (!$pair['value'] instanceof ContextVariable) { + throw new SyntaxError(\sprintf('Cannot assign to "%s", only variables can be assigned in object/mapping destructuring.', $pair['value']::class), $lineno); + } + $this->properties[] = $pair['value']->getAttribute('name'); + } + + parent::__construct($left, $right, $lineno); + } + + public function compile(Compiler $compiler): void + { + $compiler->addDebugInfo($this); + $compiler->raw('['); + foreach ($this->properties as $i => $property) { + if ($i) { + $compiler->raw(', '); + } + $compiler->raw('$context[')->repr($property)->raw(']'); + } + $compiler->raw('] = ['); + foreach ($this->properties as $i => $property) { + if ($i) { + $compiler->raw(', '); + } + $compiler->raw('CoreExtension::getAttribute($this->env, $this->source, ')->subcompile($this->getNode('right'))->raw(', ')->repr($property)->raw(', [], \\Twig\\Template::ANY_CALL, false, false, false, ')->repr($this->getNode('right')->getTemplateLine())->raw(')'); + } + $compiler->raw(']'); + } + + public function operator(Compiler $compiler): Compiler + { + return $compiler->raw('='); + } +} diff --git a/tests/Fixtures/expressions/set.test b/tests/Fixtures/expressions/set.test index c202d0f66..1a82c7229 100644 --- a/tests/Fixtures/expressions/set.test +++ b/tests/Fixtures/expressions/set.test @@ -16,8 +16,20 @@ Twig supports the "=" operator (assignment) {% do [a, b] = [b, a] %}{{ a }}{{ b }} {% do [, second] = ['first', 'second'] %}{{ second }} {% do [x, y, z] = ['one', 'two'] %}{{ x }} {{ y }} {{ z is same as(null) ? 'null' : z }} + +# Object destructuring +{% do {name, email} = user %}{{ name }} {{ email }} +{% do {first_name, last_name} = user_map %}{{ first_name }} {{ last_name }} +{% do {name} = user_obj %}{{ name }} --DATA-- -return [] +return [ + 'user' => (object)['name' => 'Fabien', 'email' => 'fabien@example.com'], + 'user_map' => ['first_name' => 'Fabien', 'last_name' => 'Potencier'], + 'user_obj' => new class { + public function getName() { return 'Fabien'; } + }, + 'null_obj' => null +] --EXPECT-- 4 44 @@ -30,3 +42,8 @@ Fabien Potencier 49 second one two null + +# Object destructuring +Fabien fabien@example.com +Fabien Potencier +Fabien