Skip to content

Commit 979f637

Browse files
paegunjeffreylovitz
authored andcommitted
RedisGraph protocol v2 --compact (#4)
* RedisGraph protocol v2 --compact * Maintain support for RedisGraph protocol v1. * Add support for RedisGraph protocol v2. * locally cache labels, relationshipTypes, propertyKeys. * use --compact form for all graph queries. * RedisGraph protocol v2 --compact * Add additional specs from quickstart at the following location: https://oss.redislabs.com/redisgraph/ * Modify parsing to pass added specs. * aggregate * resultset w/o propertyKeys de-reference * RedisGraph protocol v2 --compact * Fix aggregate function support. * on comparison to RedisGraph protocol v1, found aggregate function resultset was incorrectly not returning an array of values for the row. * RedisGraph protocol v2 --compact * Remove backwards compatibility for v1. * Provide README guidance wrt previous version in a subsection under compatibility. * Specify the docker image which contains v2. * Update the gem version to 2.0.0 . * Refactor RedisGraph::Metadata to remove circular dependency between RedisGraph and QueryResult. * Remove eager load of Metadata. * Only invalidate Metadata on observing labels, propertyKeys, and relationshipTypes while reading. * Do not parse query to determine query type; this is performed by the module. * Refactor and reorder parsing of the resultset columns. * Header contains type information. * Rows contain compact references to Metadata, so use the first row to combine both vectors of information into an array of columns. * Make the specs more isolated using `described_class` w/i the graph name. * Make the specs more readily run individually as well as isolated (rspec can/will run in random order) by moving graph creation into the before. * RedisGraph protocol v2 --compact * Remove remnant compact/call_compact? from when supporting v1. * Add raise on incompatible version on connect. * Modify QueryResult columns and result_set to match expected. * columns is as in the query. * result_set cell by type: * scalar - value * node - array of properties { key => value } * edge - array of properties { key => value } * Revert begin/end wrap of rescue w/i rspec before(:all). * RedisGraph protocol v2 --compact * Add mapping of scalar values to supported scalar types. * RedisGraph protocol v2 --compact * Add mapping of scalar value of NULL type. * Add TODO'd mapping of scalar value of Array type. * Module support for this was added 3d ago, but is not yet in the distro package or docker image.
1 parent 2e45ec0 commit 979f637

8 files changed

+290
-84
lines changed

README.md

+9-2
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@
99
`redisgraph-rb` is a Ruby gem client for the [RedisGraph](https://github.com/RedisLabsModules/RedisGraph) module. It relies on `redis-rb` for Redis connection management and provides support for graph QUERY, EXPLAIN, and DELETE commands.
1010

1111
## RedisGraph compatibility
12-
`redisgraph-rb` is currently compatible with RedisGraph versions <= 1.2.2.
12+
`redisgraph-rb` is currently compatible with RedisGraph versions >= 1.99 (module version: 19900)
1313

1414
The result set structure introduced by RedisGraph 2.0 requires some modifications to this client. If you are interested in using this client with the latest RedisGraph, please inform us by commenting on [the corresponding issue](https://github.com/RedisGraph/redisgraph-rb/issues/1)!
1515

16+
### Previous Version
17+
For RedisGraph versions >= 1.0 and < 2.0 (ie module version: 10202), instead use and refer to
18+
the redisgraph gem version ~> 1.0.0
19+
20+
which corresponds to the following docker image
21+
`docker run -p 6379:6379 -it --rm redislabs/redisgraph:1.2.2`
22+
1623
## Installation
1724
To install, run:
1825

@@ -62,7 +69,7 @@ To ensure prerequisites are installed, run the following:
6269
These tests expect a Redis server with the Graph module loaded to be available at localhost:6379
6370

6471
The currently compatible version of the RedisGraph module may be run as follows:
65-
`docker run -p 6379:6379 -it --rm redislabs/redisgraph:1.2.2`
72+
`docker run -p 6379:6379 -it --rm redislabs/redisgraph:2.0-edge`
6673

6774
A simple test suite is provided, and can be run with:
6875
`rspec`

lib/redisgraph.rb

+50-14
Original file line numberDiff line numberDiff line change
@@ -8,37 +8,73 @@
88
class RedisGraph
99
attr_accessor :connection
1010
attr_accessor :graphname
11+
attr_accessor :metadata
12+
13+
class Metadata
14+
def initialize(opts = {})
15+
@graphname = opts[:graphname]
16+
@connection = opts[:connection]
17+
18+
# cache semantics around these labels, propertyKeys, and relationshipTypes
19+
# defers first read and is invalidated when changed.
20+
@labels_proc = -> { call_procedure('db.labels') }
21+
@property_keys_proc = -> { call_procedure('db.propertyKeys') }
22+
@relationship_types_proc = -> { call_procedure('db.relationshipTypes') }
23+
end
24+
25+
def invalidate
26+
@labels = @property_key = @relationship_types
27+
end
28+
29+
def labels
30+
@labels ||= @labels_proc.call
31+
end
32+
33+
def property_keys
34+
@property_keys ||= @property_keys_proc.call
35+
end
36+
37+
def relationship_types
38+
@relationship_types ||= @relationship_types_proc.call
39+
end
40+
41+
def call_procedure(procedure)
42+
res = @connection.call("GRAPH.QUERY", @graphname, "CALL #{procedure}()")
43+
res[1].flatten
44+
rescue Redis::CommandError => e
45+
raise CallError, e
46+
end
47+
end
1148

1249
# The RedisGraph constructor instantiates a Redis connection
1350
# and validates that the graph module is loaded
1451
def initialize(graph, redis_options = {})
1552
@graphname = graph
1653
connect_to_server(redis_options)
54+
@metadata = Metadata.new(graphname: @graphname,
55+
connection: @connection)
1756
end
1857

1958
# Execute a command and return its parsed result
2059
def query(command)
21-
begin
22-
resp = @connection.call("GRAPH.QUERY", @graphname, command)
23-
rescue Redis::CommandError => e
24-
raise QueryError, e
25-
end
26-
27-
QueryResult.new(resp)
60+
resp = @connection.call("GRAPH.QUERY", @graphname, command, '--compact')
61+
QueryResult.new(resp,
62+
metadata: @metadata)
63+
rescue Redis::CommandError => e
64+
raise QueryError, e
2865
end
2966

3067
# Return the execution plan for a given command
3168
def explain(command)
32-
begin
33-
resp = @connection.call("GRAPH.EXPLAIN", @graphname, command)
34-
rescue Redis::CommandError => e
35-
raise QueryError, e
36-
end
69+
@connection.call("GRAPH.EXPLAIN", @graphname, command)
70+
rescue Redis::CommandError => e
71+
raise ExplainError, e
3772
end
3873

3974
# Delete the graph and all associated keys
4075
def delete
41-
resp = @connection.call("GRAPH.DELETE", @graphname)
76+
@connection.call("GRAPH.DELETE", @graphname)
77+
rescue Redis::CommandError => e
78+
raise DeleteError, e
4279
end
4380
end
44-

lib/redisgraph/connection.rb

+5-3
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
class RedisGraph
22
def connect_to_server(options)
33
@connection = Redis.new(options)
4-
self.verify_module()
4+
@module_version = module_version()
5+
raise ServerError, "RedisGraph module not loaded." if @module_version.nil?
6+
raise ServerError, "RedisGraph module incompatible, expecting >= 1.99." if @module_version < 19900
57
end
68

79
# Ensure that the connected Redis server supports modules
810
# and has loaded the RedisGraph module
9-
def verify_module()
11+
def module_version()
1012
redis_version = @connection.info["redis_version"]
1113
major_version = redis_version.split('.').first.to_i
1214
raise ServerError, "Redis 4.0 or greater required for RedisGraph support." unless major_version >= 4
1315
modules = @connection.call("MODULE", "LIST")
1416
module_graph = modules.detect { |_name_key, name, _ver_key, _ver| name == 'graph' }
15-
raise ServerError, "RedisGraph module not loaded." if module_graph.nil?
17+
module_graph[3] if module_graph
1618
end
1719
end

lib/redisgraph/errors.rb

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
class RedisGraph
2-
class RedisGraphError < RuntimeError
3-
end
2+
class RedisGraphError < RuntimeError; end
43

5-
class ServerError < RedisGraphError
6-
end
4+
class ServerError < RedisGraphError; end
75

8-
class QueryError < RedisGraphError
9-
end
6+
class CallError < RedisGraphError; end
7+
class QueryError < RedisGraphError; end
8+
class ExplainError < RedisGraphError; end
9+
class DeleteError < RedisGraphError; end
1010
end

lib/redisgraph/query_result.rb

+102-23
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,23 @@ class QueryResult
33
attr_accessor :resultset
44
attr_accessor :stats
55

6+
def initialize(response, opts = {})
7+
# The response for any query is expected to be a nested array.
8+
# If compact (RedisGraph protocol v2)
9+
# The resultset is an array w/ three elements:
10+
# 0] Node/Edge key names w/ the ordinal position used in [1]
11+
# en lieu of the name to compact the result set.
12+
# 1] Node/Edge key/value pairs as an array w/ two elements:
13+
# 0] node/edge name id from [0]
14+
# 1..matches] node/edge values
15+
# 2] Statistics as an array of strings
16+
17+
@metadata = opts[:metadata]
18+
19+
@resultset = parse_resultset(response)
20+
@stats = parse_stats(response)
21+
end
22+
623
def print_resultset
724
pretty = Terminal::Table.new headings: columns do |t|
825
resultset.each { |record| t << record }
@@ -11,49 +28,111 @@ def print_resultset
1128
end
1229

1330
def parse_resultset(response)
31+
# In the v2 protocol, CREATE does not contain an empty row preceding statistics
32+
return unless response.length > 1
33+
1434
# Any non-empty result set will have multiple rows (arrays)
15-
return nil unless response[0].length > 1
16-
# First row is return elements / properties
17-
@columns = response[0].shift
18-
# Subsequent rows are records
19-
@resultset = response[0]
35+
36+
37+
# First row is header describing the returned records, corresponding
38+
# precisely in order and naming to the RETURN clause of the query.
39+
header = response[0]
40+
@columns = header.map { |(_type, name)| name }
41+
42+
# Second row is the actual data returned by the query
43+
# note handling for encountering an id for propertyKey that is out of
44+
# the cached set.
45+
data = response[1].map do |row|
46+
i = -1
47+
header.reduce([]) do |agg, (type, _it)|
48+
i += 1
49+
el = row[i]
50+
51+
case type
52+
when 1 # scalar
53+
agg << map_scalar(el[0], el[1])
54+
when 2 # node
55+
props = el[2]
56+
agg << props.sort_by { |prop| prop[0] }.map { |prop| map_prop(prop) }
57+
when 3 # relation
58+
props = el[4]
59+
agg << props.sort_by { |prop| prop[0] }.map { |prop| map_prop(prop) }
60+
end
61+
62+
agg
63+
end
64+
end
65+
66+
data
67+
end
68+
69+
def map_scalar(type, val)
70+
map_func = case type
71+
when 1 # null
72+
return nil
73+
when 2 # string
74+
:to_s
75+
when 3 # integer
76+
:to_i
77+
when 4 # boolean
78+
# no :to_b
79+
return val == "true"
80+
when 5 # double
81+
:to_f
82+
# TODO: when in the distro packages and docker images,
83+
# the following _should_ work
84+
# when 6 # array
85+
# val.map { |it| map_scalar(it[0], it[1]) }
86+
end
87+
val.send(map_func)
88+
end
89+
90+
def map_prop(prop)
91+
# maximally a single @metadata.invalidate should occur
92+
93+
property_keys = @metadata.property_keys
94+
prop_index = prop[0]
95+
if prop_index > property_keys.length
96+
@metadata.invalidate
97+
property_keys = @metadata.property_keys
98+
end
99+
{ property_keys[prop_index] => map_scalar(prop[1], prop[2]) }
20100
end
21101

22102
# Read metrics about internal query handling
23103
def parse_stats(response)
24-
return nil unless response[1]
104+
# In the v2 protocol, CREATE does not contain an empty row preceding statistics
105+
stats_offset = response.length == 1 ? 0 : 2
25106

107+
return nil unless response[stats_offset]
108+
109+
parse_stats_row(response[stats_offset])
110+
end
111+
112+
def parse_stats_row(response_row)
26113
stats = {}
27114

28-
response[1].each do |stat|
115+
response_row.each do |stat|
29116
line = stat.split(': ')
30-
val = line[1].split(' ')[0]
117+
val = line[1].split(' ')[0].to_i
31118

32119
case line[0]
33120
when /^Labels added/
34-
stats[:labels_added] = val.to_i
121+
stats[:labels_added] = val
35122
when /^Nodes created/
36-
stats[:nodes_created] = val.to_i
123+
stats[:nodes_created] = val
37124
when /^Nodes deleted/
38-
stats[:nodes_deleted] = val.to_i
125+
stats[:nodes_deleted] = val
39126
when /^Relationships deleted/
40-
stats[:relationships_deleted] = val.to_i
127+
stats[:relationships_deleted] = val
41128
when /^Properties set/
42-
stats[:properties_set] = val.to_i
129+
stats[:properties_set] = val
43130
when /^Relationships created/
44-
stats[:relationships_created] = val.to_i
131+
stats[:relationships_created] = val
45132
when /^Query internal execution time/
46-
stats[:internal_execution_time] = val.to_f
133+
stats[:internal_execution_time] = val
47134
end
48135
end
49136
stats
50137
end
51-
52-
def initialize(response)
53-
# The response for any query is expected to be a nested array.
54-
# The only top-level values will be the result set and the statistics.
55-
@resultset = parse_resultset(response)
56-
@stats = parse_stats(response)
57-
end
58138
end
59-

lib/redisgraph/version.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
class RedisGraph
2-
VERSION = '1.0.0'
2+
VERSION = '2.0.0'
33
end

spec/redisgraph_quickstart_spec.rb

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
require 'helper.rb'
2+
3+
require_relative '../lib/redisgraph.rb'
4+
5+
# based on queries extracted from
6+
describe RedisGraph do
7+
before(:all) do
8+
begin
9+
@r = RedisGraph.new("#{described_class}_test")
10+
create_graph
11+
rescue Redis::BaseError => e
12+
$stderr.puts(e)
13+
exit 1
14+
end
15+
end
16+
17+
after(:all) do
18+
@r.delete if @r
19+
end
20+
21+
def create_graph()
22+
q = "CREATE (:Rider {name:'Valentino Rossi'})-[:rides]->(:Team {name:'Yamaha'})," \
23+
"(:Rider {name:'Dani Pedrosa'})-[:rides]->(:Team {name:'Honda'})," \
24+
"(:Rider {name:'Andrea Dovizioso'})-[:rides]->(:Team {name:'Ducati'})"
25+
26+
res = @r.query(q)
27+
28+
expect(res.resultset).to be_nil
29+
stats = res.stats
30+
expect(stats).to include(:internal_execution_time)
31+
stats.delete(:internal_execution_time)
32+
expect(stats).to eq({
33+
labels_added: 2,
34+
nodes_created: 6,
35+
properties_set: 6,
36+
relationships_created: 3
37+
})
38+
end
39+
40+
context 'quickstart' do
41+
it 'should query relations, with a predicate' do
42+
q = "MATCH (r:Rider)-[:rides]->(t:Team) WHERE t.name = 'Yamaha' RETURN r.name, t.name"
43+
44+
res = @r.query(q)
45+
46+
expect(res.columns).to eq(["r.name", "t.name"])
47+
expect(res.resultset).to eq([["Valentino Rossi", "Yamaha"]])
48+
end
49+
50+
# not in the quickstart, but demonstrates multiple rows
51+
it 'should query relations, without a predicate' do
52+
q = "MATCH (r:Rider)-[:rides]->(t:Team) RETURN r.name, t.name ORDER BY r.name"
53+
54+
res = @r.query(q)
55+
56+
expect(res.columns).to eq(["r.name", "t.name"])
57+
expect(res.resultset).to eq([
58+
["Andrea Dovizioso", "Ducati"],
59+
["Dani Pedrosa", "Honda"],
60+
["Valentino Rossi", "Yamaha"]
61+
])
62+
end
63+
64+
it 'should query relations, with an aggregate function' do
65+
q = "MATCH (r:Rider)-[:rides]->(t:Team {name:'Ducati'}) RETURN count(r)"
66+
67+
res = @r.query(q)
68+
69+
expect(res.columns).to eq(["count(r)"])
70+
expect(res.resultset).to eq([[1]])
71+
end
72+
end
73+
end

0 commit comments

Comments
 (0)