Skip to content

Commit 393cd62

Browse files
Support RediSearch FT.PROFILE command (#1727)
1 parent 3de2e6b commit 393cd62

File tree

4 files changed

+156
-7
lines changed

4 files changed

+156
-7
lines changed

redis/commands/helpers.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,12 @@ def delist(x):
3535

3636

3737
def parse_to_list(response):
38-
"""Optimistally parse the response to a list.
39-
"""
38+
"""Optimistically parse the response to a list."""
4039
res = []
40+
41+
if response is None:
42+
return res
43+
4144
for item in response:
4245
try:
4346
res.append(int(item))
@@ -51,6 +54,40 @@ def parse_to_list(response):
5154
return res
5255

5356

57+
def parse_list_to_dict(response):
58+
res = {}
59+
for i in range(0, len(response), 2):
60+
if isinstance(response[i], list):
61+
res['Child iterators'].append(parse_list_to_dict(response[i]))
62+
elif isinstance(response[i+1], list):
63+
res['Child iterators'] = [parse_list_to_dict(response[i+1])]
64+
else:
65+
try:
66+
res[response[i]] = float(response[i+1])
67+
except (TypeError, ValueError):
68+
res[response[i]] = response[i+1]
69+
return res
70+
71+
72+
def parse_to_dict(response):
73+
if response is None:
74+
return {}
75+
76+
res = {}
77+
for det in response:
78+
if isinstance(det[1], list):
79+
res[det[0]] = parse_list_to_dict(det[1])
80+
else:
81+
try: # try to set the attribute. may be provided without value
82+
try: # try to convert the value to float
83+
res[det[0]] = float(det[1])
84+
except (TypeError, ValueError):
85+
res[det[0]] = det[1]
86+
except IndexError:
87+
pass
88+
return res
89+
90+
5491
def random_string(length=10):
5592
"""
5693
Returns a random N character long string.

redis/commands/search/commands.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from ._util import to_string
88
from .aggregation import AggregateRequest, AggregateResult, Cursor
99
from .suggestion import SuggestionParser
10+
from ..helpers import parse_to_dict
1011

1112
NUMERIC = "NUMERIC"
1213

@@ -20,6 +21,7 @@
2021
EXPLAINCLI_CMD = "FT.EXPLAINCLI"
2122
DEL_CMD = "FT.DEL"
2223
AGGREGATE_CMD = "FT.AGGREGATE"
24+
PROFILE_CMD = "FT.PROFILE"
2325
CURSOR_CMD = "FT.CURSOR"
2426
SPELLCHECK_CMD = "FT.SPELLCHECK"
2527
DICT_ADD_CMD = "FT.DICTADD"
@@ -382,11 +384,11 @@ def explain_cli(self, query): # noqa
382384

383385
def aggregate(self, query):
384386
"""
385-
Issue an aggregation query
387+
Issue an aggregation query.
386388
387389
### Parameters
388390
389-
**query**: This can be either an `AggeregateRequest`, or a `Cursor`
391+
**query**: This can be either an `AggregateRequest`, or a `Cursor`
390392
391393
An `AggregateResult` object is returned. You can access the rows from
392394
its `rows` property, which will always yield the rows of the result.
@@ -401,6 +403,9 @@ def aggregate(self, query):
401403
raise ValueError("Bad query", query)
402404

403405
raw = self.execute_command(*cmd)
406+
return self._get_AggregateResult(raw, query, has_cursor)
407+
408+
def _get_AggregateResult(self, raw, query, has_cursor):
404409
if has_cursor:
405410
if isinstance(query, Cursor):
406411
query.cid = raw[1]
@@ -418,8 +423,48 @@ def aggregate(self, query):
418423
schema = None
419424
rows = raw[1:]
420425

421-
res = AggregateResult(rows, cursor, schema)
422-
return res
426+
return AggregateResult(rows, cursor, schema)
427+
428+
def profile(self, query, limited=False):
429+
"""
430+
Performs a search or aggregate command and collects performance
431+
information.
432+
433+
### Parameters
434+
435+
**query**: This can be either an `AggregateRequest`, `Query` or
436+
string.
437+
**limited**: If set to True, removes details of reader iterator.
438+
439+
"""
440+
st = time.time()
441+
cmd = [PROFILE_CMD, self.index_name, ""]
442+
if limited:
443+
cmd.append("LIMITED")
444+
cmd.append('QUERY')
445+
446+
if isinstance(query, AggregateRequest):
447+
cmd[2] = "AGGREGATE"
448+
cmd += query.build_args()
449+
elif isinstance(query, Query):
450+
cmd[2] = "SEARCH"
451+
cmd += query.get_args()
452+
else:
453+
raise ValueError("Must provide AggregateRequest object or "
454+
"Query object.")
455+
456+
res = self.execute_command(*cmd)
457+
458+
if isinstance(query, AggregateRequest):
459+
result = self._get_AggregateResult(res[0], query, query._cursor)
460+
else:
461+
result = Result(res[0],
462+
not query._no_content,
463+
duration=(time.time() - st) * 1000.0,
464+
has_payload=query._with_payloads,
465+
with_scores=query._with_scores,)
466+
467+
return result, parse_to_dict(res[1])
423468

424469
def spellcheck(self, query, distance=None, include=None, exclude=None):
425470
"""

tests/test_helpers.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
nativestr,
66
parse_to_list,
77
quote_string,
8-
random_string
8+
random_string,
9+
parse_to_dict
910
)
1011

1112

@@ -19,11 +20,34 @@ def test_list_or_args():
1920

2021

2122
def test_parse_to_list():
23+
assert parse_to_list(None) == []
2224
r = ["hello", b"my name", "45", "555.55", "is simon!", None]
2325
assert parse_to_list(r) == \
2426
["hello", "my name", 45, 555.55, "is simon!", None]
2527

2628

29+
def test_parse_to_dict():
30+
assert parse_to_dict(None) == {}
31+
r = [['Some number', '1.0345'],
32+
['Some string', 'hello'],
33+
['Child iterators',
34+
['Time', '0.2089', 'Counter', 3, 'Child iterators',
35+
['Type', 'bar', 'Time', '0.0729', 'Counter', 3],
36+
['Type', 'barbar', 'Time', '0.058', 'Counter', 3]]]]
37+
assert parse_to_dict(r) == {
38+
'Child iterators': {
39+
'Child iterators': [
40+
{'Counter': 3.0, 'Time': 0.0729, 'Type': 'bar'},
41+
{'Counter': 3.0, 'Time': 0.058, 'Type': 'barbar'}
42+
],
43+
'Counter': 3.0,
44+
'Time': 0.2089
45+
},
46+
'Some number': 1.0345,
47+
'Some string': 'hello'
48+
}
49+
50+
2751
def test_nativestr():
2852
assert nativestr('teststr') == 'teststr'
2953
assert nativestr(b'teststr') == 'teststr'

tests/test_search.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1519,3 +1519,46 @@ def test_json_with_jsonpath(client):
15191519
assert res.docs[0].id == "doc:1"
15201520
with pytest.raises(Exception):
15211521
res.docs[0].name_unsupported
1522+
1523+
1524+
@pytest.mark.redismod
1525+
def test_profile(client):
1526+
client.ft().create_index((TextField('t'),))
1527+
client.ft().client.hset('1', 't', 'hello')
1528+
client.ft().client.hset('2', 't', 'world')
1529+
1530+
# check using Query
1531+
q = Query('hello|world').no_content()
1532+
res, det = client.ft().profile(q)
1533+
assert det['Iterators profile']['Counter'] == 2.0
1534+
assert len(det['Iterators profile']['Child iterators']) == 2
1535+
assert det['Iterators profile']['Type'] == 'UNION'
1536+
assert det['Parsing time'] < 0.3
1537+
assert len(res.docs) == 2 # check also the search result
1538+
1539+
# check using AggregateRequest
1540+
req = aggregations.AggregateRequest("*").load("t")\
1541+
.apply(prefix="startswith(@t, 'hel')")
1542+
res, det = client.ft().profile(req)
1543+
assert det['Iterators profile']['Counter'] == 2.0
1544+
assert det['Iterators profile']['Type'] == 'WILDCARD'
1545+
assert det['Parsing time'] < 0.3
1546+
assert len(res.rows) == 2 # check also the search result
1547+
1548+
1549+
@pytest.mark.redismod
1550+
def test_profile_limited(client):
1551+
client.ft().create_index((TextField('t'),))
1552+
client.ft().client.hset('1', 't', 'hello')
1553+
client.ft().client.hset('2', 't', 'hell')
1554+
client.ft().client.hset('3', 't', 'help')
1555+
client.ft().client.hset('4', 't', 'helowa')
1556+
1557+
q = Query('%hell% hel*')
1558+
res, det = client.ft().profile(q, limited=True)
1559+
assert det['Iterators profile']['Child iterators'][0]['Child iterators'] \
1560+
== 'The number of iterators in the union is 3'
1561+
assert det['Iterators profile']['Child iterators'][1]['Child iterators'] \
1562+
== 'The number of iterators in the union is 4'
1563+
assert det['Iterators profile']['Type'] == 'INTERSECT'
1564+
assert len(res.docs) == 3 # check also the search result

0 commit comments

Comments
 (0)