From 034f67e5f8529100444517d27e51e70dfeb560ae Mon Sep 17 00:00:00 2001
From: Christoph Reiter <reiter.christoph@gmail.com>
Date: Wed, 22 Feb 2023 14:12:32 +0100
Subject: [PATCH] expression language: inject an object providing helper
 functions/methods

instead of injecting global functions inject an object. Mainly to
have some namespacing in case the official language gains new functions
and to make it clear which function are from us and where the documentation
for thenm needs to be looked up.
---
 .../ExpressionExtension.php                   | 142 ++++++++++++++++++
 src/ExpressionLanguage/ExpressionLanguage.php |  11 ++
 tests/ExpressionLanguageTest.php              |  41 +++++
 3 files changed, 194 insertions(+)
 create mode 100644 src/ExpressionLanguage/ExpressionExtension.php

diff --git a/src/ExpressionLanguage/ExpressionExtension.php b/src/ExpressionLanguage/ExpressionExtension.php
new file mode 100644
index 0000000..4eb5585
--- /dev/null
+++ b/src/ExpressionLanguage/ExpressionExtension.php
@@ -0,0 +1,142 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Dbp\Relay\CoreBundle\ExpressionLanguage;
+
+/**
+ * This Type gets injected into our expression language variant with the name
+ * 'relay'. This allows us to add functions/methods with some kind of namespacing,
+ * instead of polluting the global namespace.
+ */
+class ExpressionExtension
+{
+    /**
+     * @var ExpressionLanguage
+     */
+    private $lang;
+
+    public function __construct(ExpressionLanguage $lang)
+    {
+        $this->lang = $lang;
+    }
+
+    public static function str_starts_with()
+    {
+        $args = func_get_args();
+
+        return call_user_func_array('str_starts_with', $args);
+    }
+
+    public static function str_ends_with()
+    {
+        $args = func_get_args();
+
+        return call_user_func_array('str_ends_with', $args);
+    }
+
+    public static function substr()
+    {
+        $args = func_get_args();
+
+        return call_user_func_array('substr', $args);
+    }
+
+    public static function strpos()
+    {
+        $args = func_get_args();
+
+        return call_user_func_array('strpos', $args);
+    }
+
+    public static function strlen()
+    {
+        $args = func_get_args();
+
+        return call_user_func_array('strlen', $args);
+    }
+
+    public static function ceil()
+    {
+        $args = func_get_args();
+
+        return call_user_func_array('ceil', $args);
+    }
+
+    public static function floor()
+    {
+        $args = func_get_args();
+
+        return call_user_func_array('floor', $args);
+    }
+
+    public static function round()
+    {
+        $args = func_get_args();
+
+        return call_user_func_array('round', $args);
+    }
+
+    public static function max()
+    {
+        $args = func_get_args();
+
+        return call_user_func_array('max', $args);
+    }
+
+    public static function min()
+    {
+        $args = func_get_args();
+
+        return call_user_func_array('min', $args);
+    }
+
+    public static function count()
+    {
+        $args = func_get_args();
+
+        return call_user_func_array('count', $args);
+    }
+
+    public static function implode()
+    {
+        $args = func_get_args();
+
+        return call_user_func_array('implode', $args);
+    }
+
+    public static function explode()
+    {
+        $args = func_get_args();
+
+        return call_user_func_array('explode', $args);
+    }
+
+    public static function empty($value): bool
+    {
+        // empty is not a real function, so call_user_func_array doesn't work
+        return empty($value);
+    }
+
+    public function map(iterable $iterable, string $expression): array
+    {
+        $transformedResult = [];
+        foreach ($iterable as $key => $value) {
+            $transformedResult[$key] = $this->lang->evaluate($expression, ['key' => $key, 'value' => $value, 'relay' => $this]);
+        }
+
+        return $transformedResult;
+    }
+
+    public function filter(iterable $iterable, string $expression): array
+    {
+        $filteredResult = [];
+        foreach ($iterable as $key => $value) {
+            if ($this->lang->evaluate($expression, ['key' => $key, 'value' => $value])) {
+                $filteredResult[] = $value;
+            }
+        }
+
+        return $filteredResult;
+    }
+}
diff --git a/src/ExpressionLanguage/ExpressionLanguage.php b/src/ExpressionLanguage/ExpressionLanguage.php
index fe72647..bc29f31 100644
--- a/src/ExpressionLanguage/ExpressionLanguage.php
+++ b/src/ExpressionLanguage/ExpressionLanguage.php
@@ -30,4 +30,15 @@ class ExpressionLanguage extends SymfonyExpressionLanguage
 
         parent::__construct($cache, $providers);
     }
+
+    /**
+     * @return mixed
+     */
+    public function evaluate($expression, array $values = [])
+    {
+        $ext = new ExpressionExtension($this);
+        $values['relay'] = $ext;
+
+        return parent::evaluate($expression, $values);
+    }
 }
diff --git a/tests/ExpressionLanguageTest.php b/tests/ExpressionLanguageTest.php
index 4b31dfa..ef09df2 100644
--- a/tests/ExpressionLanguageTest.php
+++ b/tests/ExpressionLanguageTest.php
@@ -25,6 +25,15 @@ class ExpressionLanguageTest extends TestCase
         $this->assertSame([1, 5, 2, 4], $lang->evaluate('filter([1, 5, 2, 4], "42")'));
         $this->assertSame([5, 4], $lang->evaluate('filter([1, 5, 2, 4], "value > 2")'));
         $this->assertSame([2, 4], $lang->evaluate('filter({1: 2, 3: 4}, "true")'));
+        $this->assertSame([5, 2, 4], $lang->evaluate('filter([0.5, 5, 2, 4], "floor(value)")'));
+
+        $this->assertSame([], $lang->evaluate('relay.filter([], "true")'));
+        $this->assertSame([], $lang->evaluate('relay.filter([1, 5, 2, 4], "false")'));
+        $this->assertSame([1, 5, 2, 4], $lang->evaluate('relay.filter([1, 5, 2, 4], "true")'));
+        $this->assertSame([1, 5, 2, 4], $lang->evaluate('relay.filter([1, 5, 2, 4], "42")'));
+        $this->assertSame([5, 4], $lang->evaluate('relay.filter([1, 5, 2, 4], "value > 2")'));
+        $this->assertSame([2, 4], $lang->evaluate('relay.filter({1: 2, 3: 4}, "true")'));
+        $this->assertSame([5, 2, 4], $lang->evaluate('relay.filter([0.5, 5, 2, 4], "relay.floor(value)")'));
     }
 
     public function testMap()
@@ -35,6 +44,14 @@ class ExpressionLanguageTest extends TestCase
         $this->assertSame([2, 6, 3, 5], $lang->evaluate('map([1, 5, 2, 4], "value + 1")'));
         $this->assertSame([1 => 3, 3 => 7], $lang->evaluate('map({1: 2, 3: 4}, "key + value")'));
         $this->assertSame([1 => 42, 3 => 42], $lang->evaluate('map({1: 2, 3: 4}, "42")'));
+        $this->assertSame([1.0], $lang->evaluate('map([0.5], "ceil(value)")'));
+
+        $this->assertSame([], $lang->evaluate('relay.map([], "true")'));
+        $this->assertSame([false], $lang->evaluate('relay.map([1], "false")'));
+        $this->assertSame([2, 6, 3, 5], $lang->evaluate('relay.map([1, 5, 2, 4], "value + 1")'));
+        $this->assertSame([1 => 3, 3 => 7], $lang->evaluate('relay.map({1: 2, 3: 4}, "key + value")'));
+        $this->assertSame([1 => 42, 3 => 42], $lang->evaluate('relay.map({1: 2, 3: 4}, "42")'));
+        $this->assertSame([1.0], $lang->evaluate('relay.map([0.5], "relay.ceil(value)")'));
     }
 
     public function testEmpty()
@@ -45,6 +62,12 @@ class ExpressionLanguageTest extends TestCase
         $this->assertTrue($lang->evaluate('empty("0")'));
         $this->assertFalse($lang->evaluate('empty(42)'));
         $this->assertFalse($lang->evaluate('empty([42])'));
+
+        $this->assertTrue($lang->evaluate('relay.empty([])'));
+        $this->assertTrue($lang->evaluate('relay.empty(0)'));
+        $this->assertTrue($lang->evaluate('relay.empty("0")'));
+        $this->assertFalse($lang->evaluate('relay.empty(42)'));
+        $this->assertFalse($lang->evaluate('relay.empty([42])'));
     }
 
     public function testPhp()
@@ -53,6 +76,10 @@ class ExpressionLanguageTest extends TestCase
         $this->assertSame(2, $lang->evaluate('count([1, 2])'));
         $this->assertSame('1-2', $lang->evaluate('implode("-", ["1", "2"])'));
         $this->assertSame(['1', '2'], $lang->evaluate('explode("-", "1-2")'));
+
+        $this->assertSame(2, $lang->evaluate('relay.count([1, 2])'));
+        $this->assertSame('1-2', $lang->evaluate('relay.implode("-", ["1", "2"])'));
+        $this->assertSame(['1', '2'], $lang->evaluate('relay.explode("-", "1-2")'));
     }
 
     public function testNumeric()
@@ -63,6 +90,12 @@ class ExpressionLanguageTest extends TestCase
         $this->assertSame(1.0, $lang->evaluate('round(0.5)'));
         $this->assertSame(42, $lang->evaluate('max([2, 42])'));
         $this->assertSame(2, $lang->evaluate('min([2, 42])'));
+
+        $this->assertSame(2.0, $lang->evaluate('relay.ceil(1.2)'));
+        $this->assertSame(1.0, $lang->evaluate('relay.floor(1.9)'));
+        $this->assertSame(1.0, $lang->evaluate('relay.round(0.5)'));
+        $this->assertSame(42, $lang->evaluate('relay.max([2, 42])'));
+        $this->assertSame(2, $lang->evaluate('relay.min([2, 42])'));
     }
 
     public function testString()
@@ -75,5 +108,13 @@ class ExpressionLanguageTest extends TestCase
         $this->assertSame('foo', $lang->evaluate('substr("foobar", 0, 3)'));
         $this->assertSame(1, $lang->evaluate('strpos("foobar", "oo")'));
         $this->assertSame(6, $lang->evaluate('strlen("foobar")'));
+
+        $this->assertTrue($lang->evaluate('relay.str_starts_with("foo", "fo")'));
+        $this->assertFalse($lang->evaluate('relay.str_starts_with("foo", "xo")'));
+        $this->assertTrue($lang->evaluate('relay.str_ends_with("foo", "oo")'));
+        $this->assertFalse($lang->evaluate('relay.str_ends_with("foo", "of")'));
+        $this->assertSame('foo', $lang->evaluate('relay.substr("foobar", 0, 3)'));
+        $this->assertSame(1, $lang->evaluate('relay.strpos("foobar", "oo")'));
+        $this->assertSame(6, $lang->evaluate('relay.strlen("foobar")'));
     }
 }
-- 
GitLab