From 7e49e6491db846d8b91d1e486cd34580d6d40451 Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Thu, 27 Mar 2025 16:34:19 +0200 Subject: [PATCH] formula: nested key access --- .../Functions/VariableGetValueByKeyType.php | 24 ++++- .../Functions/VariableSetKeyValueType.php | 64 ++++++++++- application/Espo/Core/Formula/Parser.php | 101 +++++++++++++++--- .../unit/Espo/Core/Formula/EvaluatorTest.php | 29 +++++ 4 files changed, 194 insertions(+), 24 deletions(-) diff --git a/application/Espo/Core/Formula/Functions/VariableGetValueByKeyType.php b/application/Espo/Core/Formula/Functions/VariableGetValueByKeyType.php index 64c74aa7fd..fb22904949 100644 --- a/application/Espo/Core/Formula/Functions/VariableGetValueByKeyType.php +++ b/application/Espo/Core/Formula/Functions/VariableGetValueByKeyType.php @@ -45,7 +45,7 @@ class VariableGetValueByKeyType extends BaseFunction } $name = $this->evaluate($args[0]); - $key = $this->evaluate($args[1]); + $keys = $this->evaluate($args[1]); if (!is_string($name)) { $this->throwBadArgumentValue(1, 'string'); @@ -57,13 +57,21 @@ class VariableGetValueByKeyType extends BaseFunction $reference =& $this->getVariables()->$name; - return $this->getByKey($reference, $key); + $value = null; + + foreach ($keys as $key) { + $value =& $this->getByKey($reference, $key); + + $reference =& $value; + } + + return $value; } /** * @throws Error */ - private function getByKey(mixed &$reference, mixed $key): mixed + private function &getByKey(mixed &$reference, mixed $key): mixed { if (!is_array($reference) && !$reference instanceof stdClass) { throw new Error("Cannot access by key of variable that is non-array and non-object."); @@ -86,7 +94,10 @@ class VariableGetValueByKeyType extends BaseFunction throw new Error("Cannot get array item value by non-existent key."); } - return $reference[$key]; + /** @noinspection PhpUnnecessaryLocalVariableInspection */ + $value =& $reference[$key]; + + return $value; } if (!is_string($key)) { @@ -101,6 +112,9 @@ class VariableGetValueByKeyType extends BaseFunction throw new Error("Cannot get object item value by non-existent key."); } - return $reference->$key; + /** @noinspection PhpUnnecessaryLocalVariableInspection */ + $value =& $reference->$key; + + return $value; } } diff --git a/application/Espo/Core/Formula/Functions/VariableSetKeyValueType.php b/application/Espo/Core/Formula/Functions/VariableSetKeyValueType.php index 50af12519a..ceaf0ab8a7 100644 --- a/application/Espo/Core/Formula/Functions/VariableSetKeyValueType.php +++ b/application/Espo/Core/Formula/Functions/VariableSetKeyValueType.php @@ -45,7 +45,7 @@ class VariableSetKeyValueType extends BaseFunction } $name = $this->evaluate($args[0]); - $key = $this->evaluate($args[1]); + $keys = $this->evaluate($args[1]); $value = $this->evaluate($args[2]); if (!is_string($name)) { @@ -58,7 +58,17 @@ class VariableSetKeyValueType extends BaseFunction $reference =& $this->getVariables()->$name; - $this->setByKey($reference, $key, $value); + foreach ($keys as $i => $key) { + if ($i === count($keys) - 1) { + $this->setByKey($reference, $key, $value); + + return; + } + + $referenceValue =& $this->getByKey($reference, $key); + + $reference =& $referenceValue; + } } /** @@ -98,4 +108,54 @@ class VariableSetKeyValueType extends BaseFunction $reference->$key = $value; } + + /** + * @throws Error + */ + private function &getByKey(mixed &$reference, mixed $key): mixed + { + if (!is_array($reference) && !$reference instanceof stdClass) { + throw new Error("Cannot access by key of variable that is non-array and non-object."); + } + + if (is_array($reference)) { + if (!is_int($key)) { + throw new Error("Cannot get array item value by non-integer key."); + } + + if ($key < 0) { + throw new Error("Cannot get array item value by key that is less than zero."); + } + + if ($key > count($reference) - 1) { + throw new Error("Cannot get array item value by key that is out of array end."); + } + + if (!array_key_exists($key, $reference)) { + throw new Error("Cannot get array item value by non-existent key."); + } + + /** @noinspection PhpUnnecessaryLocalVariableInspection */ + $value =& $reference[$key]; + + return $value; + } + + if (!is_string($key)) { + throw new Error("Cannot get object item value by non-string key."); + } + + if ($key === '') { + throw new Error("Cannot get object item value by empty string key."); + } + + if (!property_exists($reference, $key)) { + throw new Error("Cannot get object item value by non-existent key."); + } + + /** @noinspection PhpUnnecessaryLocalVariableInspection */ + $value =& $reference->$key; + + return $value; + } } diff --git a/application/Espo/Core/Formula/Parser.php b/application/Espo/Core/Formula/Parser.php index 0327e365d8..8f97ac7b0e 100644 --- a/application/Espo/Core/Formula/Parser.php +++ b/application/Espo/Core/Formula/Parser.php @@ -1361,23 +1361,21 @@ class Parser $isArrayAppend = false; $isKeyValue = false; - $keyExpression = ''; + $keyPath = []; if (str_ends_with($firstPart, '[]')) { $variable = substr($firstPart, 1, -2); $isArrayAppend = true; } else if (str_ends_with($firstPart, ']') && str_contains($firstPart, '[')) { - $bracketPosition = strpos($firstPart, '['); + $bracketPosition = strpos($firstPart, '[') ?: 0; $variable = substr($firstPart, 1, $bracketPosition - 1); - $keyExpression = trim(substr($firstPart, $bracketPosition + 1, -1)); + + $keyPart = trim(substr($firstPart, $bracketPosition)); + $keyPath = array_map(fn ($it) => $this->split($it), $this->splitKeys($keyPart)); $isKeyValue = true; - - if ($keyExpression === '') { - throw new SyntaxError("No expression inside brackets."); - } } if ($variable === '' || !preg_match($this->variableNameRegExp, $variable)) { @@ -1394,7 +1392,7 @@ class Parser if ($isKeyValue) { return new Node('variableSetKeyValue', [ new Value($variable), - $this->split($keyExpression), + new Node('list', $keyPath), $this->split($secondPart) ]); } @@ -1415,7 +1413,7 @@ class Parser $isIncrement = false; $isDecrement = false; $isKeyValue = false; - $keyExpression = ''; + $keyPath = []; if (str_ends_with($expression, '++')) { $isIncrement = true; @@ -1428,16 +1426,12 @@ class Parser $value = rtrim(substr($value, 0, -2)); } else if (str_ends_with($expression, ']') && str_contains($expression, '[')) { - $bracketPosition = strpos($expression, '['); - + $bracketPosition = strpos($expression, '[') ?: 0; $value = substr($expression, 1, $bracketPosition - 1); - $keyExpression = trim(substr($expression, $bracketPosition + 1, -1)); + $keyPart = trim(substr($expression, $bracketPosition)); + $keyPath = array_map(fn ($it) => $this->split($it), $this->splitKeys($keyPart)); $isKeyValue = true; - - if ($keyExpression === '') { - throw new SyntaxError("No expression inside brackets."); - } } if ($value === '' || !preg_match($this->variableNameRegExp, $value)) { @@ -1459,10 +1453,83 @@ class Parser if ($isKeyValue) { return new Node('variableGetValueByKey', [ new Value($value), - $this->split($keyExpression), + new Node('list', $keyPath), ]); } return new Variable($value); } + + /** + * @return string[] + * @throws SyntaxError + */ + private function splitKeys(string $expression): array + { + $modifiedExpression = ''; + + $this->processString($expression, $modifiedExpression, $statementList, true); + + $expressionLength = strlen($modifiedExpression); + + $parenthesisCounter = 0; + $bracketCounter = 0; + + $output = []; + + /** @var array{int, int}[] $indexPairs */ + $indexPairs = []; + + $startIndex = -1; + + for ($i = 0; $i < $expressionLength; $i++) { + $value = $modifiedExpression[$i]; + + if ($value === '(') { + $parenthesisCounter++; + } else if ($value === ')') { + $parenthesisCounter--; + } else if ($value === '[') { + $bracketCounter++; + } else if ($value === ']') { + $bracketCounter--; + } + + if ( + $value === '[' && + $parenthesisCounter === 0 && + $bracketCounter === 1 + ) { + $startIndex = $i; + } + + if ( + $value === ']' && + $parenthesisCounter === 0 && + $bracketCounter === 0 + ) { + $indexPairs[] = [$startIndex + 1, $i]; + + $startIndex = -1; + } + } + + foreach ($indexPairs as $i => $pair) { + if ($i > 0) { + if ($indexPairs[$i - 1][1] !== $pair[0] - 2) { + throw new SyntaxError("Nested brackets must have no gaps in between."); + } + } + + $itemExpression = trim(substr($expression, $pair[0], $pair[1] - $pair[0])); + + if ($itemExpression === '') { + throw new SyntaxError("No expression inside brackets."); + } + + $output[] = $itemExpression; + } + + return $output; + } } diff --git a/tests/unit/Espo/Core/Formula/EvaluatorTest.php b/tests/unit/Espo/Core/Formula/EvaluatorTest.php index 7674e905cc..b70dcd7d75 100644 --- a/tests/unit/Espo/Core/Formula/EvaluatorTest.php +++ b/tests/unit/Espo/Core/Formula/EvaluatorTest.php @@ -1989,4 +1989,33 @@ class EvaluatorTest extends TestCase /** @noinspection PhpUnhandledExceptionInspection */ $this->evaluator->process($expression); } + + public function testArrayGetSetNested(): void + { + $expression = " + \$a = list(list(0, 1)); + \$a[0][2] = 2; + \$a[0][2]; + "; + + /** @noinspection PhpUnhandledExceptionInspection */ + $result = $this->evaluator->process($expression); + + $this->assertEquals(2, $result); + } + + public function testObjectGetSetNested(): void + { + $expression = " + \$o = object\\create(); + \$o['a'] = object\\create(); + \$o['a']['a1'] = '1'; + \$o['a']['a1']; + "; + + /** @noinspection PhpUnhandledExceptionInspection */ + $result = $this->evaluator->process($expression); + + $this->assertEquals('1', $result); + } }