From 56524329740683db4b33510b0f578aaa737b9b97 Mon Sep 17 00:00:00 2001 From: Tizian Schmidlin Date: Tue, 26 Oct 2021 14:56:36 +0200 Subject: [PATCH 1/4] [WIP] Work on null coalescing operator --- .../NullcoalescingExpressionNode.php | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 src/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNode.php diff --git a/src/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNode.php b/src/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNode.php new file mode 100644 index 000000000..05ac7d49b --- /dev/null +++ b/src/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNode.php @@ -0,0 +1,157 @@ +\%\s\{\}\:\,]+ # Check variable side + [\s]?\?\?[\s]? + [_a-zA-Z0-9.\s\'\"\\.]+ # Fallback value side + ) + } # End of shorthand syntax + )/x'; + + /** + * Filter out variable names form expression + */ + protected static $variableDetection = '/[^\'_a-zA-Z0-9\.\\\\]{0,1}([_a-zA-Z0-9\.\\\\]*)[^\']{0,1}/'; + + /** + * @param RenderingContextInterface $renderingContext + * @param string $expression + * @param array $matches + * @return mixed + */ + public static function evaluateExpression(RenderingContextInterface $renderingContext, $expression, array $matches) + { + $parts = preg_split('/([\?\?])/s', $expression); + $parts = array_map([__CLASS__, 'trimPart'], $parts); + + list ($value, $fallback) = $parts; + + $context = static::gatherContext($renderingContext, $expression); + + // After gathering context, we have to evaluate the variable and either it is null, then we return $fallback or + // it is not and we return $value + + $parser = new BooleanParser(); + $checkResult = $parser->evaluate($value, $context); + + if ($checkResult) { + return static::getTemplateVariableOrValueItself($renderingContext->getTemplateParser()->unquoteString($then), $renderingContext); + } else { + return static::getTemplateVariableOrValueItself($renderingContext->getTemplateParser()->unquoteString($else), $renderingContext); + } + } + + /** + * @param mixed $candidate + * @param RenderingContextInterface $renderingContext + * @return mixed + */ + public static function getTemplateVariableOrValueItself($candidate, RenderingContextInterface $renderingContext) + { + $suspect = parent::getTemplateVariableOrValueItself($candidate, $renderingContext); + if ($suspect === $candidate) { + return $renderingContext->getTemplateParser()->unquoteString($suspect); + } + return $suspect; + } + + /** + * Gather all context variables used in the expression + * + * @param RenderingContextInterface $renderingContext + * @param string $expression + * @return array + */ + public static function gatherContext($renderingContext, $expression) + { + $context = []; + if (preg_match_all(static::$variableDetection, $expression, $matches) > 0) { + foreach ($matches[1] as $variable) { + if (strtolower($variable) == 'true' || strtolower($variable) == 'false' || empty($variable) === true) { + continue; + } + $context[$variable] = static::getTemplateVariableOrValueItself($variable, $renderingContext); + } + } + return $context; + } + + /** + * Compiles the ExpressionNode, returning an array with + * exactly two keys which contain strings: + * + * - "initialization" which contains variable initializations + * - "execution" which contains the execution (that uses the variables) + * + * The expression and matches can be read from the local + * instance - and the RenderingContext and other APIs + * can be accessed via the TemplateCompiler. + * + * @param TemplateCompiler $templateCompiler + * @return string + */ + public function compile(TemplateCompiler $templateCompiler) + { + $parts = preg_split('/([\?:])/s', $this->getExpression()); + $parts = array_map([__CLASS__, 'trimPart'], $parts); + list ($check, $then, $else) = $parts; + + $matchesVariable = $templateCompiler->variableName('array'); + $initializationPhpCode = '// Rendering TernaryExpression node' . chr(10); + $initializationPhpCode .= sprintf('%s = %s;', $matchesVariable, var_export($this->getMatches(), true)) . chr(10); + + $parser = new BooleanParser(); + $compiledExpression = $parser->compile($check); + $functionName = $templateCompiler->variableName('ternaryExpression'); + $initializationPhpCode .= sprintf( + '%s = function($context, $renderingContext) { + if (%s::convertToBoolean(' . $compiledExpression . ', $renderingContext) === TRUE) { + return %s::getTemplateVariableOrValueItself(%s, $renderingContext); + } else { + return %s::getTemplateVariableOrValueItself(%s, $renderingContext); + } + };' . chr(10), + $functionName, + BooleanNode::class, + static::class, + var_export($then, true), + static::class, + var_export($else, true) + ); + + return [ + 'initialization' => $initializationPhpCode, + 'execution' => sprintf( + '%s(%s::gatherContext($renderingContext, %s[1]), $renderingContext)', + $functionName, + static::class, + $matchesVariable + ) + ]; + } +} From 3ce5045321355693909f78988912e2e3c932bdc3 Mon Sep 17 00:00:00 2001 From: Tizian Schmidlin Date: Tue, 26 Oct 2021 16:46:41 +0200 Subject: [PATCH 2/4] [WIP] Fix and extend null coalescing parsing --- .../NullcoalescingExpressionNode.php | 114 ++++-------------- src/Core/Rendering/RenderingContext.php | 2 + .../NullcoalescingExpressionNodeTest.php | 59 +++++++++ 3 files changed, 82 insertions(+), 93 deletions(-) create mode 100644 tests/Unit/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNodeTest.php diff --git a/src/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNode.php b/src/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNode.php index 05ac7d49b..73eaf8ecc 100644 --- a/src/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNode.php +++ b/src/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNode.php @@ -10,6 +10,7 @@ use TYPO3Fluid\Fluid\Core\Parser\BooleanParser; use TYPO3Fluid\Fluid\Core\Parser\SyntaxTree\BooleanNode; use TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface; +use TYPO3Fluid\Fluid\Core\Variables\VariableExtractor; /** * Ternary Condition Node - allows the shorthand version @@ -49,109 +50,36 @@ public static function evaluateExpression(RenderingContextInterface $renderingCo $parts = preg_split('/([\?\?])/s', $expression); $parts = array_map([__CLASS__, 'trimPart'], $parts); - list ($value, $fallback) = $parts; - - $context = static::gatherContext($renderingContext, $expression); - - // After gathering context, we have to evaluate the variable and either it is null, then we return $fallback or - // it is not and we return $value - - $parser = new BooleanParser(); - $checkResult = $parser->evaluate($value, $context); - - if ($checkResult) { - return static::getTemplateVariableOrValueItself($renderingContext->getTemplateParser()->unquoteString($then), $renderingContext); - } else { - return static::getTemplateVariableOrValueItself($renderingContext->getTemplateParser()->unquoteString($else), $renderingContext); + foreach($parts as $part) { + $value = static::getTemplateVariableOrValueItself($part, $renderingContext); + if(!is_null($value)) { + return $value; + } } + + return null; } + /** * @param mixed $candidate * @param RenderingContextInterface $renderingContext * @return mixed */ - public static function getTemplateVariableOrValueItself($candidate, RenderingContextInterface $renderingContext) - { - $suspect = parent::getTemplateVariableOrValueItself($candidate, $renderingContext); - if ($suspect === $candidate) { - return $renderingContext->getTemplateParser()->unquoteString($suspect); - } - return $suspect; - } - - /** - * Gather all context variables used in the expression - * - * @param RenderingContextInterface $renderingContext - * @param string $expression - * @return array - */ - public static function gatherContext($renderingContext, $expression) + protected static function getTemplateVariableOrValueItself($candidate, RenderingContextInterface $renderingContext) { - $context = []; - if (preg_match_all(static::$variableDetection, $expression, $matches) > 0) { - foreach ($matches[1] as $variable) { - if (strtolower($variable) == 'true' || strtolower($variable) == 'false' || empty($variable) === true) { - continue; - } - $context[$variable] = static::getTemplateVariableOrValueItself($variable, $renderingContext); - } + $variables = $renderingContext->getVariableProvider()->getAll(); + $extractor = new VariableExtractor(); + $suspect = $extractor->getByPath($variables, $candidate); + + if (is_numeric($candidate)) { + $suspect = $candidate; + } elseif (mb_strpos($candidate, '\'') === 0) { + $suspect = trim($candidate, '\''); + } elseif (mb_strpos($candidate, '"') === 0) { + $suspect = trim($candidate, '"'); } - return $context; - } - /** - * Compiles the ExpressionNode, returning an array with - * exactly two keys which contain strings: - * - * - "initialization" which contains variable initializations - * - "execution" which contains the execution (that uses the variables) - * - * The expression and matches can be read from the local - * instance - and the RenderingContext and other APIs - * can be accessed via the TemplateCompiler. - * - * @param TemplateCompiler $templateCompiler - * @return string - */ - public function compile(TemplateCompiler $templateCompiler) - { - $parts = preg_split('/([\?:])/s', $this->getExpression()); - $parts = array_map([__CLASS__, 'trimPart'], $parts); - list ($check, $then, $else) = $parts; - - $matchesVariable = $templateCompiler->variableName('array'); - $initializationPhpCode = '// Rendering TernaryExpression node' . chr(10); - $initializationPhpCode .= sprintf('%s = %s;', $matchesVariable, var_export($this->getMatches(), true)) . chr(10); - - $parser = new BooleanParser(); - $compiledExpression = $parser->compile($check); - $functionName = $templateCompiler->variableName('ternaryExpression'); - $initializationPhpCode .= sprintf( - '%s = function($context, $renderingContext) { - if (%s::convertToBoolean(' . $compiledExpression . ', $renderingContext) === TRUE) { - return %s::getTemplateVariableOrValueItself(%s, $renderingContext); - } else { - return %s::getTemplateVariableOrValueItself(%s, $renderingContext); - } - };' . chr(10), - $functionName, - BooleanNode::class, - static::class, - var_export($then, true), - static::class, - var_export($else, true) - ); - - return [ - 'initialization' => $initializationPhpCode, - 'execution' => sprintf( - '%s(%s::gatherContext($renderingContext, %s[1]), $renderingContext)', - $functionName, - static::class, - $matchesVariable - ) - ]; + return $suspect; } } diff --git a/src/Core/Rendering/RenderingContext.php b/src/Core/Rendering/RenderingContext.php index 43fb7908a..4793a68de 100644 --- a/src/Core/Rendering/RenderingContext.php +++ b/src/Core/Rendering/RenderingContext.php @@ -14,6 +14,7 @@ use TYPO3Fluid\Fluid\Core\Parser\Interceptor\Escape; use TYPO3Fluid\Fluid\Core\Parser\SyntaxTree\Expression\CastingExpressionNode; use TYPO3Fluid\Fluid\Core\Parser\SyntaxTree\Expression\MathExpressionNode; +use TYPO3Fluid\Fluid\Core\Parser\SyntaxTree\Expression\NullcoalescingExpressionNode; use TYPO3Fluid\Fluid\Core\Parser\SyntaxTree\Expression\TernaryExpressionNode; use TYPO3Fluid\Fluid\Core\Parser\TemplateParser; use TYPO3Fluid\Fluid\Core\Parser\TemplateProcessor\EscapingModifierTemplateProcessor; @@ -106,6 +107,7 @@ class RenderingContext implements RenderingContextInterface protected $expressionNodeTypes = [ CastingExpressionNode::class, MathExpressionNode::class, + NullcoalescingExpressionNode::class, TernaryExpressionNode::class, ]; diff --git a/tests/Unit/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNodeTest.php b/tests/Unit/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNodeTest.php new file mode 100644 index 000000000..da6d4b88f --- /dev/null +++ b/tests/Unit/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNodeTest.php @@ -0,0 +1,59 @@ +setVariableProvider(new StandardVariableProvider($variables)); + $result = NullcoalescingExpressionNode::evaluateExpression($renderingContext, $expression, []); + $this->assertEquals($expected, $result); + } + + /** + * @return array + */ + public function getEvaluateExpressionTestValues() + { + return [ + ['{a ?? 1}', ['a' => 'a'], 'a'], + ['{a ?? 1}', ['a' => null], 1], + ['{a ?? b}', ['a' => 'a', 'b' => 'b'], 'a'], + ['{a ?? b}', ['a' => null, 'b' => 'b'], 'b'], + ['{a ?? b ?? c}', ['a' => '1', 'b' => '2', 'c' => '3'], '1'], + ['{a ?? b ?? c}', ['a' => '1', 'b' => null, 'c' => '3'], '1'], + ['{a ?? b ?? c}', ['a' => null, 'b' => '2', 'c' => '3'], '2'], + ['{a ?? b ?? c}', ['a' => null, 'b' => null, 'c' => '3'], '3'], + ['{a ?? b ?? c}', ['a' => 'd', 'b' => 'e', 'c' => 'f'], 'd'], + ['{a ?? b ?? c}', ['a' => 'd', 'b' => null, 'c' => 'f'], 'd'], + ['{a ?? b ?? c}', ['a' => null, 'b' => 'e', 'c' => 'f'], 'e'], + ['{a ?? b ?? c}', ['a' => null, 'b' => null, 'c' => 'f'], 'f'], + ['{a ?? b ?? c}', ['a' => null, 'b' => null, 'c' => null], null], + ['{a ?? b ?? \'test\'}', ['a' => null, 'b' => null], 'test'], + ['{a ?? b ?? "test"}', ['a' => null, 'b' => null], 'test'], + + ]; + } +} From 936e4272a345fd17795814d43748440a101e1a67 Mon Sep 17 00:00:00 2001 From: Tizian Schmidlin Date: Tue, 26 Oct 2021 17:03:49 +0200 Subject: [PATCH 3/4] [TASK] Rewrite tests to include integer comparisons --- .../Expression/NullcoalescingExpressionNodeTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Unit/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNodeTest.php b/tests/Unit/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNodeTest.php index da6d4b88f..a7aa80d02 100644 --- a/tests/Unit/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNodeTest.php +++ b/tests/Unit/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNodeTest.php @@ -42,10 +42,10 @@ public function getEvaluateExpressionTestValues() ['{a ?? 1}', ['a' => null], 1], ['{a ?? b}', ['a' => 'a', 'b' => 'b'], 'a'], ['{a ?? b}', ['a' => null, 'b' => 'b'], 'b'], - ['{a ?? b ?? c}', ['a' => '1', 'b' => '2', 'c' => '3'], '1'], - ['{a ?? b ?? c}', ['a' => '1', 'b' => null, 'c' => '3'], '1'], - ['{a ?? b ?? c}', ['a' => null, 'b' => '2', 'c' => '3'], '2'], - ['{a ?? b ?? c}', ['a' => null, 'b' => null, 'c' => '3'], '3'], + ['{a ?? b ?? c}', ['a' => 1, 'b' => 2, 'c' => 3], 1], + ['{a ?? b ?? c}', ['a' => 1, 'b' => null, 'c' => 3], 1], + ['{a ?? b ?? c}', ['a' => null, 'b' => 2, 'c' => 3], 2], + ['{a ?? b ?? c}', ['a' => null, 'b' => null, 'c' => 3], 3], ['{a ?? b ?? c}', ['a' => 'd', 'b' => 'e', 'c' => 'f'], 'd'], ['{a ?? b ?? c}', ['a' => 'd', 'b' => null, 'c' => 'f'], 'd'], ['{a ?? b ?? c}', ['a' => null, 'b' => 'e', 'c' => 'f'], 'e'], From 5b8e97a03d08dab7de02182ae34efbd7e58e512e Mon Sep 17 00:00:00 2001 From: Tizian Schmidlin Date: Wed, 14 Jun 2023 17:01:45 +0200 Subject: [PATCH 4/4] [DOCS] Update PHPDoc blocs --- .../Expression/NullcoalescingExpressionNode.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNode.php b/src/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNode.php index 73eaf8ecc..6fe253301 100644 --- a/src/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNode.php +++ b/src/Core/Parser/SyntaxTree/Expression/NullcoalescingExpressionNode.php @@ -13,15 +13,15 @@ use TYPO3Fluid\Fluid\Core\Variables\VariableExtractor; /** - * Ternary Condition Node - allows the shorthand version - * of a condition to be written as `{var ? thenvar : elsevar}` + * Null Coalescing Condition Node - allows the shorthand of a null check + * for a default value as `{nullableVar ?? valueIfNull}` */ class NullcoalescingExpressionNode extends AbstractExpressionNode { /** - * Pattern which detects ternary conditions written in shorthand - * syntax, e.g. {checkvar ? thenvar : elsevar}. + * Pattern which detects null coalescing conditions written in shorthand + * syntax, e.g. {nullableVar ?? valueIfNull}. */ public static $detectionExpression = '/ (