Skip to content

Commit 0ed0660

Browse files
authored
4.1.4 release cherry-picks (#1994)
1 parent 6c00e09 commit 0ed0660

File tree

7 files changed

+443
-45
lines changed

7 files changed

+443
-45
lines changed

Diff for: .github/workflows/integration.yaml

+2
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ on:
88
- '**/*.md'
99
branches:
1010
- master
11+
- '[0-9].[0-9]'
1112
pull_request:
1213
branches:
1314
- master
15+
- '[0-9].[0-9]'
1416

1517
jobs:
1618

Diff for: redis/commands/graph/commands.py

+31-21
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from redis.exceptions import ResponseError
33

44
from .exceptions import VersionMismatchException
5+
from .execution_plan import ExecutionPlan
56
from .query_result import QueryResult
67

78

@@ -118,27 +119,6 @@ def flush(self):
118119
self.nodes = {}
119120
self.edges = []
120121

121-
def explain(self, query, params=None):
122-
"""
123-
Get the execution plan for given query,
124-
Returns an array of operations.
125-
For more information see `GRAPH.EXPLAIN <https://oss.redis.com/redisgraph/master/commands/#graphexplain>`_. # noqa
126-
127-
Args:
128-
129-
query:
130-
The query that will be executed.
131-
params: dict
132-
Query parameters.
133-
"""
134-
if params is not None:
135-
query = self._build_params_header(params) + query
136-
137-
plan = self.execute_command("GRAPH.EXPLAIN", self.name, query)
138-
if isinstance(plan[0], bytes):
139-
plan = [b.decode() for b in plan]
140-
return "\n".join(plan)
141-
142122
def bulk(self, **kwargs):
143123
"""Internal only. Not supported."""
144124
raise NotImplementedError(
@@ -200,3 +180,33 @@ def list_keys(self):
200180
For more information see `GRAPH.LIST <https://oss.redis.com/redisgraph/master/commands/#graphlist>`_. # noqa
201181
"""
202182
return self.execute_command("GRAPH.LIST")
183+
184+
def execution_plan(self, query, params=None):
185+
"""
186+
Get the execution plan for given query,
187+
GRAPH.EXPLAIN returns an array of operations.
188+
189+
Args:
190+
query: the query that will be executed
191+
params: query parameters
192+
"""
193+
if params is not None:
194+
query = self._build_params_header(params) + query
195+
196+
plan = self.execute_command("GRAPH.EXPLAIN", self.name, query)
197+
return "\n".join(plan)
198+
199+
def explain(self, query, params=None):
200+
"""
201+
Get the execution plan for given query,
202+
GRAPH.EXPLAIN returns ExecutionPlan object.
203+
204+
Args:
205+
query: the query that will be executed
206+
params: query parameters
207+
"""
208+
if params is not None:
209+
query = self._build_params_header(params) + query
210+
211+
plan = self.execute_command("GRAPH.EXPLAIN", self.name, query)
212+
return ExecutionPlan(plan)

Diff for: redis/commands/graph/execution_plan.py

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

Diff for: redis/commands/search/commands.py

+35-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import itertools
22
import time
3+
from typing import Dict, Union
34

45
from ..helpers import parse_to_dict
56
from ._util import to_string
@@ -377,7 +378,17 @@ def info(self):
377378
it = map(to_string, res)
378379
return dict(zip(it, it))
379380

380-
def _mk_query_args(self, query):
381+
def get_params_args(self, query_params: Dict[str, Union[str, int, float]]):
382+
args = []
383+
if len(query_params) > 0:
384+
args.append("params")
385+
args.append(len(query_params) * 2)
386+
for key, value in query_params.items():
387+
args.append(key)
388+
args.append(value)
389+
return args
390+
391+
def _mk_query_args(self, query, query_params: Dict[str, Union[str, int, float]]):
381392
args = [self.index_name]
382393

383394
if isinstance(query, str):
@@ -387,9 +398,16 @@ def _mk_query_args(self, query):
387398
raise ValueError(f"Bad query type {type(query)}")
388399

389400
args += query.get_args()
401+
if query_params is not None:
402+
args += self.get_params_args(query_params)
403+
390404
return args, query
391405

392-
def search(self, query):
406+
def search(
407+
self,
408+
query: Union[str, Query],
409+
query_params: Dict[str, Union[str, int, float]] = None,
410+
):
393411
"""
394412
Search the index for a given query, and return a result of documents
395413
@@ -401,7 +419,7 @@ def search(self, query):
401419
402420
For more information: https://oss.redis.com/redisearch/Commands/#ftsearch
403421
""" # noqa
404-
args, query = self._mk_query_args(query)
422+
args, query = self._mk_query_args(query, query_params=query_params)
405423
st = time.time()
406424
res = self.execute_command(SEARCH_CMD, *args)
407425

@@ -413,18 +431,26 @@ def search(self, query):
413431
with_scores=query._with_scores,
414432
)
415433

416-
def explain(self, query):
434+
def explain(
435+
self,
436+
query=Union[str, Query],
437+
query_params: Dict[str, Union[str, int, float]] = None,
438+
):
417439
"""Returns the execution plan for a complex query.
418440
419441
For more information: https://oss.redis.com/redisearch/Commands/#ftexplain
420442
""" # noqa
421-
args, query_text = self._mk_query_args(query)
443+
args, query_text = self._mk_query_args(query, query_params=query_params)
422444
return self.execute_command(EXPLAIN_CMD, *args)
423445

424-
def explain_cli(self, query): # noqa
446+
def explain_cli(self, query: Union[str, Query]): # noqa
425447
raise NotImplementedError("EXPLAINCLI will not be implemented.")
426448

427-
def aggregate(self, query):
449+
def aggregate(
450+
self,
451+
query: Union[str, Query],
452+
query_params: Dict[str, Union[str, int, float]] = None,
453+
):
428454
"""
429455
Issue an aggregation query.
430456
@@ -445,6 +471,8 @@ def aggregate(self, query):
445471
cmd = [CURSOR_CMD, "READ", self.index_name] + query.build_args()
446472
else:
447473
raise ValueError("Bad query", query)
474+
if query_params is not None:
475+
cmd += self.get_params_args(query_params)
448476

449477
raw = self.execute_command(*cmd)
450478
return self._get_AggregateResult(raw, query, has_cursor)

Diff for: setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
long_description_content_type="text/markdown",
99
keywords=["Redis", "key-value store", "database"],
1010
license="MIT",
11-
version="4.1.3",
11+
version="4.1.4",
1212
packages=find_packages(
1313
include=[
1414
"redis",

0 commit comments

Comments
 (0)