Skip to content

Commit 175a05f

Browse files
Adding RedisGraph support (#1673)
Co-authored-by: Chayim I. Kirshen <[email protected]>
1 parent b94e230 commit 175a05f

File tree

16 files changed

+1720
-1
lines changed

16 files changed

+1720
-1
lines changed

redis/commands/graph/__init__.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
from ..helpers import quote_string, random_string, stringify_param_value
2+
from .commands import GraphCommands
3+
from .edge import Edge # noqa
4+
from .node import Node # noqa
5+
from .path import Path # noqa
6+
7+
8+
class Graph(GraphCommands):
9+
"""
10+
Graph, collection of nodes and edges.
11+
"""
12+
13+
def __init__(self, client, name=random_string()):
14+
"""
15+
Create a new graph.
16+
"""
17+
self.NAME = name # Graph key
18+
self.client = client
19+
self.execute_command = client.execute_command
20+
21+
self.nodes = {}
22+
self.edges = []
23+
self._labels = [] # List of node labels.
24+
self._properties = [] # List of properties.
25+
self._relationshipTypes = [] # List of relation types.
26+
self.version = 0 # Graph version
27+
28+
@property
29+
def name(self):
30+
return self.NAME
31+
32+
def _clear_schema(self):
33+
self._labels = []
34+
self._properties = []
35+
self._relationshipTypes = []
36+
37+
def _refresh_schema(self):
38+
self._clear_schema()
39+
self._refresh_labels()
40+
self._refresh_relations()
41+
self._refresh_attributes()
42+
43+
def _refresh_labels(self):
44+
lbls = self.labels()
45+
46+
# Unpack data.
47+
self._labels = [None] * len(lbls)
48+
for i, l in enumerate(lbls):
49+
self._labels[i] = l[0]
50+
51+
def _refresh_relations(self):
52+
rels = self.relationshipTypes()
53+
54+
# Unpack data.
55+
self._relationshipTypes = [None] * len(rels)
56+
for i, r in enumerate(rels):
57+
self._relationshipTypes[i] = r[0]
58+
59+
def _refresh_attributes(self):
60+
props = self.propertyKeys()
61+
62+
# Unpack data.
63+
self._properties = [None] * len(props)
64+
for i, p in enumerate(props):
65+
self._properties[i] = p[0]
66+
67+
def get_label(self, idx):
68+
"""
69+
Returns a label by it's index
70+
71+
Args:
72+
73+
idx:
74+
The index of the label
75+
"""
76+
try:
77+
label = self._labels[idx]
78+
except IndexError:
79+
# Refresh labels.
80+
self._refresh_labels()
81+
label = self._labels[idx]
82+
return label
83+
84+
def get_relation(self, idx):
85+
"""
86+
Returns a relationship type by it's index
87+
88+
Args:
89+
90+
idx:
91+
The index of the relation
92+
"""
93+
try:
94+
relationship_type = self._relationshipTypes[idx]
95+
except IndexError:
96+
# Refresh relationship types.
97+
self._refresh_relations()
98+
relationship_type = self._relationshipTypes[idx]
99+
return relationship_type
100+
101+
def get_property(self, idx):
102+
"""
103+
Returns a property by it's index
104+
105+
Args:
106+
107+
idx:
108+
The index of the property
109+
"""
110+
try:
111+
propertie = self._properties[idx]
112+
except IndexError:
113+
# Refresh properties.
114+
self._refresh_attributes()
115+
propertie = self._properties[idx]
116+
return propertie
117+
118+
def add_node(self, node):
119+
"""
120+
Adds a node to the graph.
121+
"""
122+
if node.alias is None:
123+
node.alias = random_string()
124+
self.nodes[node.alias] = node
125+
126+
def add_edge(self, edge):
127+
"""
128+
Adds an edge to the graph.
129+
"""
130+
if not (self.nodes[edge.src_node.alias] and self.nodes[edge.dest_node.alias]):
131+
raise AssertionError("Both edge's end must be in the graph")
132+
133+
self.edges.append(edge)
134+
135+
def _build_params_header(self, params):
136+
if not isinstance(params, dict):
137+
raise TypeError("'params' must be a dict")
138+
# Header starts with "CYPHER"
139+
params_header = "CYPHER "
140+
for key, value in params.items():
141+
params_header += str(key) + "=" + stringify_param_value(value) + " "
142+
return params_header
143+
144+
# Procedures.
145+
def call_procedure(self, procedure, *args, read_only=False, **kwagrs):
146+
args = [quote_string(arg) for arg in args]
147+
q = f"CALL {procedure}({','.join(args)})"
148+
149+
y = kwagrs.get("y", None)
150+
if y:
151+
q += f" YIELD {','.join(y)}"
152+
153+
return self.query(q, read_only=read_only)
154+
155+
def labels(self):
156+
return self.call_procedure("db.labels", read_only=True).result_set
157+
158+
def relationshipTypes(self):
159+
return self.call_procedure("db.relationshipTypes", read_only=True).result_set
160+
161+
def propertyKeys(self):
162+
return self.call_procedure("db.propertyKeys", read_only=True).result_set

redis/commands/graph/commands.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
from redis import DataError
2+
from redis.exceptions import ResponseError
3+
4+
from .exceptions import VersionMismatchException
5+
from .query_result import QueryResult
6+
7+
8+
class GraphCommands:
9+
def commit(self):
10+
"""
11+
Create entire graph.
12+
For more information see `CREATE <https://oss.redis.com/redisgraph/master/commands/#create>`_. # noqa
13+
"""
14+
if len(self.nodes) == 0 and len(self.edges) == 0:
15+
return None
16+
17+
query = "CREATE "
18+
for _, node in self.nodes.items():
19+
query += str(node) + ","
20+
21+
query += ",".join([str(edge) for edge in self.edges])
22+
23+
# Discard leading comma.
24+
if query[-1] == ",":
25+
query = query[:-1]
26+
27+
return self.query(query)
28+
29+
def query(self, q, params=None, timeout=None, read_only=False, profile=False):
30+
"""
31+
Executes a query against the graph.
32+
For more information see `GRAPH.QUERY <https://oss.redis.com/redisgraph/master/commands/#graphquery>`_. # noqa
33+
34+
Args:
35+
36+
-------
37+
q :
38+
The query.
39+
params : dict
40+
Query parameters.
41+
timeout : int
42+
Maximum runtime for read queries in milliseconds.
43+
read_only : bool
44+
Executes a readonly query if set to True.
45+
profile : bool
46+
Return details on results produced by and time
47+
spent in each operation.
48+
"""
49+
50+
# maintain original 'q'
51+
query = q
52+
53+
# handle query parameters
54+
if params is not None:
55+
query = self._build_params_header(params) + query
56+
57+
# construct query command
58+
# ask for compact result-set format
59+
# specify known graph version
60+
if profile:
61+
cmd = "GRAPH.PROFILE"
62+
else:
63+
cmd = "GRAPH.RO_QUERY" if read_only else "GRAPH.QUERY"
64+
command = [cmd, self.name, query, "--compact"]
65+
66+
# include timeout is specified
67+
if timeout:
68+
if not isinstance(timeout, int):
69+
raise Exception("Timeout argument must be a positive integer")
70+
command += ["timeout", timeout]
71+
72+
# issue query
73+
try:
74+
response = self.execute_command(*command)
75+
return QueryResult(self, response, profile)
76+
except ResponseError as e:
77+
if "wrong number of arguments" in str(e):
78+
print(
79+
"Note: RedisGraph Python requires server version 2.2.8 or above"
80+
) # noqa
81+
if "unknown command" in str(e) and read_only:
82+
# `GRAPH.RO_QUERY` is unavailable in older versions.
83+
return self.query(q, params, timeout, read_only=False)
84+
raise e
85+
except VersionMismatchException as e:
86+
# client view over the graph schema is out of sync
87+
# set client version and refresh local schema
88+
self.version = e.version
89+
self._refresh_schema()
90+
# re-issue query
91+
return self.query(q, params, timeout, read_only)
92+
93+
def merge(self, pattern):
94+
"""
95+
Merge pattern.
96+
For more information see `MERGE <https://oss.redis.com/redisgraph/master/commands/#merge>`_. # noqa
97+
"""
98+
query = "MERGE "
99+
query += str(pattern)
100+
101+
return self.query(query)
102+
103+
def delete(self):
104+
"""
105+
Deletes graph.
106+
For more information see `DELETE <https://oss.redis.com/redisgraph/master/commands/#delete>`_. # noqa
107+
"""
108+
self._clear_schema()
109+
return self.execute_command("GRAPH.DELETE", self.name)
110+
111+
# declared here, to override the built in redis.db.flush()
112+
def flush(self):
113+
"""
114+
Commit the graph and reset the edges and the nodes to zero length.
115+
"""
116+
self.commit()
117+
self.nodes = {}
118+
self.edges = []
119+
120+
def explain(self, query, params=None):
121+
"""
122+
Get the execution plan for given query,
123+
Returns an array of operations.
124+
For more information see `GRAPH.EXPLAIN <https://oss.redis.com/redisgraph/master/commands/#graphexplain>`_. # noqa
125+
126+
Args:
127+
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+
return "\n".join(plan)
139+
140+
def bulk(self, **kwargs):
141+
"""Internal only. Not supported."""
142+
raise NotImplementedError(
143+
"GRAPH.BULK is internal only. "
144+
"Use https://github.com/redisgraph/redisgraph-bulk-loader."
145+
)
146+
147+
def profile(self, query):
148+
"""
149+
Execute a query and produce an execution plan augmented with metrics
150+
for each operation's execution. Return a string representation of a
151+
query execution plan, with details on results produced by and time
152+
spent in each operation.
153+
For more information see `GRAPH.PROFILE <https://oss.redis.com/redisgraph/master/commands/#graphprofile>`_. # noqa
154+
"""
155+
return self.query(query, profile=True)
156+
157+
def slowlog(self):
158+
"""
159+
Get a list containing up to 10 of the slowest queries issued
160+
against the given graph ID.
161+
For more information see `GRAPH.SLOWLOG <https://oss.redis.com/redisgraph/master/commands/#graphslowlog>`_. # noqa
162+
163+
Each item in the list has the following structure:
164+
1. A unix timestamp at which the log entry was processed.
165+
2. The issued command.
166+
3. The issued query.
167+
4. The amount of time needed for its execution, in milliseconds.
168+
"""
169+
return self.execute_command("GRAPH.SLOWLOG", self.name)
170+
171+
def config(self, name, value=None, set=False):
172+
"""
173+
Retrieve or update a RedisGraph configuration.
174+
For more information see `GRAPH.CONFIG <https://oss.redis.com/redisgraph/master/commands/#graphconfig>`_. # noqa
175+
176+
Args:
177+
178+
name : str
179+
The name of the configuration
180+
value :
181+
The value we want to ser (can be used only when `set` is on)
182+
set : bool
183+
Turn on to set a configuration. Default behavior is get.
184+
"""
185+
params = ["SET" if set else "GET", name]
186+
if value is not None:
187+
if set:
188+
params.append(value)
189+
else:
190+
raise DataError(
191+
"``value`` can be provided only when ``set`` is True"
192+
) # noqa
193+
return self.execute_command("GRAPH.CONFIG", *params)
194+
195+
def list_keys(self):
196+
"""
197+
Lists all graph keys in the keyspace.
198+
For more information see `GRAPH.LIST <https://oss.redis.com/redisgraph/master/commands/#graphlist>`_. # noqa
199+
"""
200+
return self.execute_command("GRAPH.LIST")

0 commit comments

Comments
 (0)