Skip to content

Commit b27bc08

Browse files
AviAvniswilly22
andauthored
structure execution plan (#148)
* structure execution plan * lint * test operation tree * fix format * lint * introduce operation class * linter issues * fix indent * fix indent * fix comparison * address review * indentation * address review * compare plans * document * remove redundant equal * review Co-authored-by: swilly22 <[email protected]>
1 parent 7d8caae commit b27bc08

File tree

3 files changed

+205
-10
lines changed

3 files changed

+205
-10
lines changed

redisgraph/execution_plan.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
class Operation:
2+
"""
3+
Operation, single operation within execution plan.
4+
"""
5+
6+
def __init__(self, name, args=None):
7+
"""
8+
Create a new operation.
9+
10+
Args:
11+
name: string that represents the name of the operation
12+
args: operation arguments
13+
"""
14+
self.name = name
15+
self.args = args
16+
self.children = []
17+
18+
def append_child(self, child):
19+
if not isinstance(child, Operation) or self is child:
20+
raise Exception("child must be Operation")
21+
22+
self.children.append(child)
23+
return self
24+
25+
def child_count(self):
26+
return len(self.children)
27+
28+
def __eq__(self, o: object) -> bool:
29+
if not isinstance(o, Operation):
30+
return False
31+
32+
return (self.name == o.name and self.args == o.args)
33+
34+
def __str__(self) -> str:
35+
args_str = "" if self.args is None else f" | {self.args}"
36+
return f"{self.name}{args_str}"
37+
38+
39+
class ExecutionPlan:
40+
"""
41+
ExecutionPlan, collection of operations.
42+
"""
43+
44+
def __init__(self, plan):
45+
"""
46+
Create a new execution plan.
47+
48+
Args:
49+
plan: array of strings that represents the collection operations
50+
the output from GRAPH.EXPLAIN
51+
"""
52+
if not isinstance(plan, list):
53+
raise Exception("plan must be an array")
54+
55+
self.plan = plan
56+
self.structured_plan = self._operation_tree()
57+
58+
def _compare_operations(self, root_a, root_b):
59+
"""
60+
Compare execution plan operation tree
61+
62+
Return: True if operation trees are equal, False otherwise
63+
"""
64+
65+
# compare current root
66+
if root_a != root_b:
67+
return False
68+
69+
# make sure root have the same number of children
70+
if root_a.child_count() != root_b.child_count():
71+
return False
72+
73+
# recursively compare children
74+
for i in range(root_a.child_count()):
75+
if not self._compare_operations(root_a.children[i], root_b.children[i]):
76+
return False
77+
78+
return True
79+
80+
def __str__(self) -> str:
81+
def aggraget_str(str_children):
82+
return "\n".join([" " + line for str_child in str_children for line in str_child.splitlines()])
83+
84+
def combine_str(x, y):
85+
return f"{x}\n{y}"
86+
87+
return self._operation_traverse(self.structured_plan, str, aggraget_str, combine_str)
88+
89+
def __eq__(self, o: object) -> bool:
90+
""" Compares two execution plans
91+
92+
Return: True if the two plans are equal False otherwise
93+
"""
94+
# make sure 'o' is an execution-plan
95+
if not isinstance(o, ExecutionPlan):
96+
return False
97+
98+
# get root for both plans
99+
root_a = self.structured_plan
100+
root_b = o.structured_plan
101+
102+
# compare execution trees
103+
return self._compare_operations(root_a, root_b)
104+
105+
def _operation_traverse(self, op, op_f, aggregate_f, combine_f):
106+
"""
107+
Traverse operation tree recursively applying functions
108+
109+
Args:
110+
op: operation to traverse
111+
op_f: function applied for each operation
112+
aggregate_f: aggregation function applied for all children of a single operation
113+
combine_f: combine function applied for the operation result and the children result
114+
"""
115+
# apply op_f for each operation
116+
op_res = op_f(op)
117+
if len(op.children) == 0:
118+
return op_res # no children return
119+
else:
120+
# apply _operation_traverse recursively
121+
children = [self._operation_traverse(child, op_f, aggregate_f, combine_f) for child in op.children]
122+
# combine the operation result with the children aggregated result
123+
return combine_f(op_res, aggregate_f(children))
124+
125+
def _operation_tree(self):
126+
""" Build the operation tree from the string representation """
127+
128+
# initial state
129+
i = 0
130+
level = 0
131+
stack = []
132+
current = None
133+
134+
# iterate plan operations
135+
while i < len(self.plan):
136+
current_op = self.plan[i]
137+
op_level = current_op.count(" ")
138+
if op_level == level:
139+
# if the operation level equal to the current level
140+
# set the current operation and move next
141+
args = current_op.split("|")
142+
current = Operation(args[0].strip(), None if len(args) == 1 else args[1].strip())
143+
i += 1
144+
elif op_level == level + 1:
145+
# if the operation is child of the current operation
146+
# add it as child and set as current operation
147+
args = current_op.split("|")
148+
child = Operation(args[0].strip(), None if len(args) == 1 else args[1].strip())
149+
current.append_child(child)
150+
stack.append(current)
151+
current = child
152+
level += 1
153+
i += 1
154+
elif op_level < level:
155+
# if the operation is not child of current operation
156+
# go back to it's parent operation
157+
levels_back = level - op_level + 1
158+
for _ in range(levels_back):
159+
current = stack.pop()
160+
level -= levels_back
161+
else:
162+
raise Exception("corrupted plan")
163+
return stack[0]

redisgraph/graph.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from redisgraph.util import random_string, quote_string, stringify_param_value
44
from redisgraph.query_result import QueryResult
55
from redisgraph.exceptions import VersionMismatchException
6+
from redisgraph.execution_plan import ExecutionPlan
67

78

89
class Graph:
@@ -215,9 +216,6 @@ def query(self, q, params=None, timeout=None, read_only=False):
215216
# re-issue query
216217
return self.query(q, params, timeout, read_only)
217218

218-
def _execution_plan_to_string(self, plan):
219-
return "\n".join(plan)
220-
221219
def execution_plan(self, query, params=None):
222220
"""
223221
Get the execution plan for given query,
@@ -231,7 +229,7 @@ def execution_plan(self, query, params=None):
231229
query = self._build_params_header(params) + query
232230

233231
plan = self.redis_con.execute_command("GRAPH.EXPLAIN", self.name, query)
234-
return self._execution_plan_to_string(plan)
232+
return ExecutionPlan(plan)
235233

236234
def delete(self):
237235
"""

tests/functional/test_all.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import unittest
2+
from redisgraph.execution_plan import Operation
23
from tests.utils import base
34

45
import redis
@@ -245,14 +246,47 @@ def test_cached_execution(self):
245246

246247
def test_execution_plan(self):
247248
redis_graph = Graph('execution_plan', self.r)
248-
create_query = """CREATE (:Rider {name:'Valentino Rossi'})-[:rides]->(:Team {name:'Yamaha'}),
249-
(:Rider {name:'Dani Pedrosa'})-[:rides]->(:Team {name:'Honda'}),
250-
(:Rider {name:'Andrea Dovizioso'})-[:rides]->(:Team {name:'Ducati'})"""
249+
create_query = """CREATE
250+
(:Rider {name:'Valentino Rossi'})-[:rides]->(:Team {name:'Yamaha'}),
251+
(:Rider {name:'Dani Pedrosa'})-[:rides]->(:Team {name:'Honda'}),
252+
(:Rider {name:'Andrea Dovizioso'})-[:rides]->(:Team {name:'Ducati'})"""
251253
redis_graph.query(create_query)
252254

253-
result = redis_graph.execution_plan("MATCH (r:Rider)-[:rides]->(t:Team) WHERE t.name = $name RETURN r.name, t.name, $params", {'name': 'Yehuda'})
254-
expected = "Results\n Project\n Conditional Traverse | (t:Team)->(r:Rider)\n Filter\n Node By Label Scan | (t:Team)"
255-
self.assertEqual(result, expected)
255+
result = redis_graph.execution_plan("""MATCH (r:Rider)-[:rides]->(t:Team)
256+
WHERE t.name = $name
257+
RETURN r.name, t.name, $params
258+
UNION
259+
MATCH (r:Rider)-[:rides]->(t:Team)
260+
WHERE t.name = $name
261+
RETURN r.name, t.name, $params""", {'name': 'Yehuda'})
262+
expected = '''\
263+
Results
264+
Distinct
265+
Join
266+
Project
267+
Conditional Traverse | (t:Team)->(r:Rider)
268+
Filter
269+
Node By Label Scan | (t:Team)
270+
Project
271+
Conditional Traverse | (t:Team)->(r:Rider)
272+
Filter
273+
Node By Label Scan | (t:Team)'''
274+
self.assertEqual(str(result), expected)
275+
276+
expected = Operation('Results') \
277+
.append_child(Operation('Distinct')
278+
.append_child(Operation('Join')
279+
.append_child(Operation('Project')
280+
.append_child(Operation('Conditional Traverse', "(t:Team)->(r:Rider)")
281+
.append_child(Operation("Filter")
282+
.append_child(Operation('Node By Label Scan', "(t:Team)")))))
283+
.append_child(Operation('Project')
284+
.append_child(Operation('Conditional Traverse', "(t:Team)->(r:Rider)")
285+
.append_child(Operation("Filter")
286+
.append_child(Operation('Node By Label Scan', "(t:Team)")))))
287+
))
288+
289+
self.assertEqual(result.structured_plan, expected)
256290

257291
redis_graph.delete()
258292

0 commit comments

Comments
 (0)