Repository: redis/redis-py (python)

Issue:
Add support for missing command ACL DRYRUN

Pull request:
Add support for ACL DRYRUN
Resolves #1963

Patch:
diff --git a/redis/commands/core.py b/redis/commands/core.py
index 5074e806..1e221c94 100644
--- a/redis/commands/core.py
+++ b/redis/commands/core.py
@@ -28,6 +28,14 @@ class ACLCommands:
         pieces = [category] if category else []
         return self.execute_command("ACL CAT", *pieces, **kwargs)
 
+    def acl_dryrun(self, username, *args, **kwargs):
+        """
+        Simulate the execution of a given command by a given ``username``.
+
+        For more information check https://redis.io/commands/acl-dryrun
+        """
+        return self.execute_command("ACL DRYRUN", username, *args, **kwargs)
+
     def acl_deluser(self, *username, **kwargs):
         """
         Delete the ACL for the specified ``username``s
diff --git a/redis/commands/graph/commands.py b/redis/commands/graph/commands.py
index 157f6283..baed1fc2 100644
--- a/redis/commands/graph/commands.py
+++ b/redis/commands/graph/commands.py
@@ -2,6 +2,7 @@ from redis import DataError
 from redis.exceptions import ResponseError
 
 from .exceptions import VersionMismatchException
+from .execution_plan import ExecutionPlan
 from .query_result import QueryResult
 
 
@@ -118,27 +119,6 @@ class GraphCommands:
         self.nodes = {}
         self.edges = []
 
-    def explain(self, query, params=None):
-        """
-        Get the execution plan for given query,
-        Returns an array of operations.
-        For more information see `GRAPH.EXPLAIN <https://oss.redis.com/redisgraph/master/commands/#graphexplain>`_. # noqa
-
-        Args:
-
-        query:
-            The query that will be executed.
-        params: dict
-            Query parameters.
-        """
-        if params is not None:
-            query = self._build_params_header(params) + query
-
-        plan = self.execute_command("GRAPH.EXPLAIN", self.name, query)
-        if isinstance(plan[0], bytes):
-            plan = [b.decode() for b in plan]
-        return "\n".join(plan)
-
     def bulk(self, **kwargs):
         """Internal only. Not supported."""
         raise NotImplementedError(
@@ -200,3 +180,33 @@ class GraphCommands:
         For more information see `GRAPH.LIST <https://oss.redis.com/redisgraph/master/commands/#graphlist>`_. # noqa
         """
         return self.execute_command("GRAPH.LIST")
+
+    def execution_plan(self, query, params=None):
+        """
+        Get the execution plan for given query,
+        GRAPH.EXPLAIN returns an array of operations.
+
+        Args:
+            query: the query that will be executed
+            params: query parameters
+        """
+        if params is not None:
+            query = self._build_params_header(params) + query
+
+        plan = self.execute_command("GRAPH.EXPLAIN", self.name, query)
+        return "\n".join(plan)
+
+    def explain(self, query, params=None):
+        """
+        Get the execution plan for given query,
+        GRAPH.EXPLAIN returns ExecutionPlan object.
+
+        Args:
+            query: the query that will be executed
+            params: query parameters
+        """
+        if params is not None:
+            query = self._build_params_header(params) + query
+
+        plan = self.execute_command("GRAPH.EXPLAIN", self.name, query)
+        return ExecutionPlan(plan)
diff --git a/redis/commands/graph/execution_plan.py b/redis/commands/graph/execution_plan.py
new file mode 100644
index 00000000..a9b4ad07
--- /dev/null
+++ b/redis/commands/graph/execution_plan.py
@@ -0,0 +1,208 @@
+import re
+
+
+class ProfileStats:
+    """
+    ProfileStats, runtime execution statistics of operation.
+    """
+
+    def __init__(self, records_produced, execution_time):
+        self.records_produced = records_produced
+        self.execution_time = execution_time
+
+
+class Operation:
+    """
+    Operation, single operation within execution plan.
+    """
+
+    def __init__(self, name, args=None, profile_stats=None):
+        """
+        Create a new operation.
+
+        Args:
+            name: string that represents the name of the operation
+            args: operation arguments
+            profile_stats: profile statistics
+        """
+        self.name = name
+        self.args = args
+        self.profile_stats = profile_stats
+        self.children = []
+
+    def append_child(self, child):
+        if not isinstance(child, Operation) or self is child:
+            raise Exception("child must be Operation")
+
+        self.children.append(child)
+        return self
+
+    def child_count(self):
+        return len(self.children)
+
+    def __eq__(self, o: object) -> bool:
+        if not isinstance(o, Operation):
+            return False
+
+        return self.name == o.name and self.args == o.args
+
+    def __str__(self) -> str:
+        args_str = "" if self.args is None else " | " + self.args
+        return f"{self.name}{args_str}"
+
+
+class ExecutionPlan:
+    """
+    ExecutionPlan, collection of operations.
+    """
+
+    def __init__(self, plan):
+        """
+        Create a new execution plan.
+
+        Args:
+            plan: array of strings that represents the collection operations
+                  the output from GRAPH.EXPLAIN
+        """
+        if not isinstance(plan, list):
+            raise Exception("plan must be an array")
+
+        self.plan = plan
+        self.structured_plan = self._operation_tree()
+
+    def _compare_operations(self, root_a, root_b):
+        """
+        Compare execution plan operation tree
+
+        Return: True if operation trees are equal, False otherwise
+        """
+
+        # compare current root
+        if root_a != root_b:
+            return False
+
+        # make sure root have the same number of children
+        if root_a.child_count() != root_b.child_count():
+            return False
+
+        # recursively compare children
+        for i in range(root_a.child_count()):
+            if not self._compare_operations(root_a.children[i], root_b.children[i]):
+                return False
+
+        return True
+
+    def __str__(self) -> str:
+        def aggraget_str(str_children):
+            return "\n".join(
+                [
+                    "    " + line
+                    for str_child in str_children
+                    for line in str_child.splitlines()
+                ]
+            )
+
+        def combine_str(x, y):
+            return f"{x}\n{y}"
+
+        return self._operation_traverse(
+            self.structured_plan, str, aggraget_str, combine_str
+        )
+
+    def __eq__(self, o: object) -> bool:
+        """Compares two execution plans
+
+        Return: True if the two plans are equal False otherwise
+        """
+        # make sure 'o' is an execution-plan
+        if not isinstance(o, ExecutionPlan):
+            return False
+
+        # get root for both plans
+        root_a = self.structured_plan
+        root_b = o.structured_plan
+
+        # compare execution trees
+        return self._compare_operations(root_a, root_b)
+
+    def _operation_traverse(self, op, op_f, aggregate_f, combine_f):
+        """
+        Traverse operation tree recursively applying functions
+
+        Args:
+            op: operation to traverse
+            op_f: function applied for each operation
+            aggregate_f: aggregation function applied for all children of a single operation
+            combine_f: combine function applied for the operation result and the children result
+        """  # noqa
+        # apply op_f for each operation
+        op_res = op_f(op)
+        if len(op.children) == 0:
+            return op_res  # no children return
+        else:
+            # apply _operation_traverse recursively
+            children = [
+                self._operation_traverse(child, op_f, aggregate_f, combine_f)
+                for child in op.children
+            ]
+            # combine the operation result with the children aggregated result
+            return combine_f(op_res, aggregate_f(children))
+
+    def _operation_tree(self):
+        """Build the operation tree from the string representation"""
+
+        # initial state
+        i = 0
+        level = 0
+        stack = []
+        current = None
+
+        def _create_operation(args):
+            profile_stats = None
+            name = args[0].strip()
+            args.pop(0)
+            if len(args) > 0 and "Records produced" in args[-1]:
+                records_produced = int(
+                    re.search("Records produced: (\\d+)", args[-1]).group(1)
+                )
+                execution_time = float(
+                    re.search("Execution time: (\\d+.\\d+) ms", args[-1]).group(1)
+                )
+                profile_stats = ProfileStats(records_produced, execution_time)
+                args.pop(-1)
+            return Operation(
+                name, None if len(args) == 0 else args[0].strip(), profile_stats
+            )
+
+        # iterate plan operations
+        while i < len(self.plan):
+            current_op = self.plan[i]
+            op_level = current_op.count("    ")
+            if op_level == level:
+                # if the operation level equal to the current level
+                # set the current operation and move next
+                child = _create_operation(current_op.split("|"))
+                if current:
+                    current = stack.pop()
+                    current.append_child(child)
+                current = child
+                i += 1
+            elif op_level == level + 1:
+                # if the operation is child of the current operation
+                # add it as child and set as current operation
+                child = _create_operation(current_op.split("|"))
+                current.append_child(child)
+                stack.append(current)
+                current = child
+                level += 1
+                i += 1
+            elif op_level < level:
+                # if the operation is not child of current operation
+                # go back to it's parent operation
+                levels_back = level - op_level + 1
+                for _ in range(levels_back):
+                    current = stack.pop()
+                level -= levels_back
+            else:
+                raise Exception("corrupted plan")
+        return stack[0]


Test patch:
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 0e27836b..ad9ee2c5 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -87,6 +87,19 @@ class TestRedisCommands:
         assert isinstance(commands, list)
         assert "get" in commands
 
+    @skip_if_server_version_lt("7.0.0")
+    def test_acl_dryrun(self, r):
+        username = "redis-py-user"
+        r.acl_setuser(
+            username,
+            keys=["*"],
+            commands=["+set"],
+        )
+        assert r.acl_dryrun(username, "set", "key", "value") == b"OK"
+        assert r.acl_dryrun(username, "get", "key").startswith(
+            b"This user has no permissions to run the"
+        )
+
     @skip_if_server_version_lt("6.0.0")
     @skip_if_redis_enterprise()
     def test_acl_deluser(self, r, request):
diff --git a/tests/test_graph.py b/tests/test_graph.py
index 2125d4a2..8de63465 100644
--- a/tests/test_graph.py
+++ b/tests/test_graph.py
@@ -1,6 +1,7 @@
 import pytest
 
 from redis.commands.graph import Edge, Node, Path
+from redis.commands.graph.execution_plan import Operation
 from redis.exceptions import ResponseError
 
 
@@ -259,21 +260,6 @@ def test_cached_execution(client):
     assert cached_result.cached_execution
 
 
-@pytest.mark.redismod
-def test_explain(client):
-    create_query = """CREATE (:Rider {name:'Valentino Rossi'})-[:rides]->(:Team {name:'Yamaha'}),
-    (:Rider {name:'Dani Pedrosa'})-[:rides]->(:Team {name:'Honda'}),
-    (:Rider {name:'Andrea Dovizioso'})-[:rides]->(:Team {name:'Ducati'})"""
-    client.graph().query(create_query)
-
-    result = client.graph().explain(
-        "MATCH (r:Rider)-[:rides]->(t:Team) WHERE t.name = $name RETURN r.name, t.name, $params",  # noqa
-        {"name": "Yehuda"},
-    )
-    expected = "Results\n    Project\n        Conditional Traverse | (t:Team)->(r:Rider)\n            Filter\n                Node By Label Scan | (t:Team)"  # noqa
-    assert result == expected
-
-
 @pytest.mark.redismod
 def test_slowlog(client):
     create_query = """CREATE (:Rider {name:'Valentino Rossi'})-[:rides]->(:Team {name:'Yamaha'}),
@@ -475,3 +461,114 @@ def test_cache_sync(client):
     assert A._properties[1] == "x"
     assert A._relationship_types[0] == "S"
     assert A._relationship_types[1] == "R"
+
+
+@pytest.mark.redismod
+def test_execution_plan(client):
+    redis_graph = client.graph("execution_plan")
+    create_query = """CREATE (:Rider {name:'Valentino Rossi'})-[:rides]->(:Team {name:'Yamaha'}),
+    (:Rider {name:'Dani Pedrosa'})-[:rides]->(:Team {name:'Honda'}),
+    (:Rider {name:'Andrea Dovizioso'})-[:rides]->(:Team {name:'Ducati'})"""
+    redis_graph.query(create_query)
+
+    result = redis_graph.execution_plan(
+        "MATCH (r:Rider)-[:rides]->(t:Team) WHERE t.name = $name RETURN r.name, t.name, $params",  # noqa
+        {"name": "Yehuda"},
+    )
+    expected = "Results\n    Project\n        Conditional Traverse | (t:Team)->(r:Rider)\n            Filter\n                Node By Label Scan | (t:Team)"  # noqa
+    assert result == expected
+
+    redis_graph.delete()
+
+
+@pytest.mark.redismod
+def test_explain(client):
+    redis_graph = client.graph("execution_plan")
+    # graph creation / population
+    create_query = """CREATE
+(:Rider {name:'Valentino Rossi'})-[:rides]->(:Team {name:'Yamaha'}),
+(:Rider {name:'Dani Pedrosa'})-[:rides]->(:Team {name:'Honda'}),
+(:Rider {name:'Andrea Dovizioso'})-[:rides]->(:Team {name:'Ducati'})"""
+    redis_graph.query(create_query)
+
+    result = redis_graph.explain(
+        """MATCH (r:Rider)-[:rides]->(t:Team)
+WHERE t.name = $name
+RETURN r.name, t.name
+UNION
+MATCH (r:Rider)-[:rides]->(t:Team)
+WHERE t.name = $name
+RETURN r.name, t.name""",
+        {"name": "Yamaha"},
+    )
+    expected = """\
+Results
+Distinct
+    Join
+        Project
+            Conditional Traverse | (t:Team)->(r:Rider)
+                Filter
+                    Node By Label Scan | (t:Team)
+        Project
+            Conditional Traverse | (t:Team)->(r:Rider)
+                Filter
+                    Node By Label Scan | (t:Team)"""
+    assert str(result).replace(" ", "").replace("\n", "") == expected.replace(
+        " ", ""
+    ).replace("\n", "")
+
+    expected = Operation("Results").append_child(
+        Operation("Distinct").append_child(
+            Operation("Join")
+            .append_child(
+                Operation("Project").append_child(
+                    Operation(
+                        "Conditional Traverse", "(t:Team)->(r:Rider)"
+                    ).append_child(
+                        Operation("Filter").append_child(
+                            Operation("Node By Label Scan", "(t:Team)")
+                        )
+                    )
+                )
+            )
+            .append_child(
+                Operation("Project").append_child(
+                    Operation(
+                        "Conditional Traverse", "(t:Team)->(r:Rider)"
+                    ).append_child(
+                        Operation("Filter").append_child(
+                            Operation("Node By Label Scan", "(t:Team)")
+                        )
+                    )
+                )
+            )
+        )
+    )
+
+    assert result.structured_plan == expected
+
+    result = redis_graph.explain(
+        """MATCH (r:Rider), (t:Team)
+                                    RETURN r.name, t.name"""
+    )
+    expected = """\
+Results
+Project
+    Cartesian Product
+        Node By Label Scan | (r:Rider)
+        Node By Label Scan | (t:Team)"""
+    assert str(result).replace(" ", "").replace("\n", "") == expected.replace(
+        " ", ""
+    ).replace("\n", "")
+
+    expected = Operation("Results").append_child(
+        Operation("Project").append_child(
+            Operation("Cartesian Product")
+            .append_child(Operation("Node By Label Scan"))
+            .append_child(Operation("Node By Label Scan"))
+        )
+    )
+
+    assert result.structured_plan == expected
+
+    redis_graph.delete()