tina4ruby 3.13.110 → 3.13.112
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/tina4/dev_admin.rb +10 -0
- data/lib/tina4/drivers/arango_graph_driver.rb +248 -0
- data/lib/tina4/drivers/bolt_graph_driver.rb +566 -0
- data/lib/tina4/drivers/ultipa_graph_driver.rb +186 -0
- data/lib/tina4/graph.rb +330 -0
- data/lib/tina4/rack_app.rb +186 -169
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +1 -0
- metadata +5 -1
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Bolt graph adapter — Neo4j AND Memgraph (both speak Bolt + Cypher).
|
|
4
|
+
#
|
|
5
|
+
# The portable node/edge/traverse surface is expressed in Cypher on top of a
|
|
6
|
+
# compact, self-contained Bolt 4.4 / PackStream v2 client (stdlib +socket+ only —
|
|
7
|
+
# NO third-party gem). Neo4j and Memgraph share this ONE adapter; the URL scheme
|
|
8
|
+
# only picks the engine label and default port.
|
|
9
|
+
#
|
|
10
|
+
# Why an embedded client rather than a community gem (proven on the lab, no mocks):
|
|
11
|
+
# * +neo4j-ruby-driver+ (4.4.6) connects to Neo4j but NOT to Memgraph — its
|
|
12
|
+
# handshake offers Bolt 4.4 only inside a 4.2–4.4 *range* entry, which this
|
|
13
|
+
# Memgraph build does not accept, so negotiation falls back to Bolt v3 where
|
|
14
|
+
# the driver's strict server-version parser rejects Memgraph's
|
|
15
|
+
# "Neo4j/v5.11.0 compatible … Memgraph" agent string with an ArgumentError.
|
|
16
|
+
# It also pulls in ActiveSupport and needs a connection_pool 2.x pin.
|
|
17
|
+
# * The pure-Ruby +neo4j_bolt+ gem hardcodes +scheme => 'none'+ (no auth) and a
|
|
18
|
+
# single process-global connection, so it cannot authenticate to Neo4j.
|
|
19
|
+
# Both lab engines DO negotiate a *plain* Bolt 4.4 handshake, so a small real
|
|
20
|
+
# client that offers plain 4.4 works against BOTH. This is a REAL driver hitting
|
|
21
|
+
# REAL engines, not a mock.
|
|
22
|
+
#
|
|
23
|
+
# Cypher note (verified live against Neo4j + Memgraph): +id(n)+ is the portable
|
|
24
|
+
# node/edge id (an INTEGER on both — Neo4j's +elementId+ is Neo4j-only, Memgraph
|
|
25
|
+
# has no +elementId+); variable-length traversal is Cypher's +[*1..N]+ (the
|
|
26
|
+
# OPPOSITE of Ultipa's GQL +{1,N}+); +SET n += $props+ merges.
|
|
27
|
+
|
|
28
|
+
require "socket"
|
|
29
|
+
|
|
30
|
+
module Tina4
|
|
31
|
+
module Drivers
|
|
32
|
+
# A minimal Bolt 4.4 / PackStream v2 client — just enough to run Cypher with
|
|
33
|
+
# bound parameters and read records back. Not a general Bolt library; it
|
|
34
|
+
# implements exactly the message set the graph layer needs (HELLO, RUN, PULL,
|
|
35
|
+
# RESET, GOODBYE) and the PackStream types Cypher returns.
|
|
36
|
+
class BoltConnection
|
|
37
|
+
# PackStream markers reused across encode/decode.
|
|
38
|
+
NULL = 0xC0
|
|
39
|
+
FALSE = 0xC2
|
|
40
|
+
TRUE = 0xC3
|
|
41
|
+
FLOAT64 = 0xC1
|
|
42
|
+
INT8 = 0xC8
|
|
43
|
+
INT16 = 0xC9
|
|
44
|
+
INT32 = 0xCA
|
|
45
|
+
INT64 = 0xCB
|
|
46
|
+
# Bolt request message struct tags.
|
|
47
|
+
MSG_HELLO = 0x01
|
|
48
|
+
MSG_GOODBYE = 0x02
|
|
49
|
+
MSG_RESET = 0x0F
|
|
50
|
+
MSG_RUN = 0x10
|
|
51
|
+
MSG_PULL = 0x3F
|
|
52
|
+
# Bolt response message struct tags.
|
|
53
|
+
MSG_SUCCESS = 0x70
|
|
54
|
+
MSG_RECORD = 0x71
|
|
55
|
+
MSG_IGNORED = 0x7E
|
|
56
|
+
MSG_FAILURE = 0x7F
|
|
57
|
+
# Structure tags for graph values (only meaningful for raw queries that
|
|
58
|
+
# return whole nodes/relationships; the portable core returns decomposed
|
|
59
|
+
# id/labels/props scalars and never hits these).
|
|
60
|
+
NODE = 0x4E
|
|
61
|
+
RELATIONSHIP = 0x52
|
|
62
|
+
UNBOUND_RELATIONSHIP = 0x72
|
|
63
|
+
PATH = 0x50
|
|
64
|
+
|
|
65
|
+
# A Bolt-layer failure carrying the server's error code + message.
|
|
66
|
+
class BoltError < StandardError; end
|
|
67
|
+
# A connect/socket failure (unreachable host, handshake, timeout).
|
|
68
|
+
class BoltConnectError < StandardError; end
|
|
69
|
+
|
|
70
|
+
def initialize(host:, port:, user: nil, password: nil, connect_timeout: nil, use_tls: false)
|
|
71
|
+
@host = host
|
|
72
|
+
@port = port
|
|
73
|
+
@user = user
|
|
74
|
+
@password = password
|
|
75
|
+
@connect_timeout = connect_timeout
|
|
76
|
+
@use_tls = use_tls
|
|
77
|
+
connect
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Run one Cypher statement, PULL all records, return an array of row hashes
|
|
81
|
+
# keyed by the RETURN column names (string keys — parity with the Ultipa
|
|
82
|
+
# driver and the Python master's +record.data()+).
|
|
83
|
+
def run(cypher, params = nil)
|
|
84
|
+
fields = send_run(cypher, params || {})
|
|
85
|
+
records = []
|
|
86
|
+
pull_all do |values|
|
|
87
|
+
records << fields.zip(values).to_h
|
|
88
|
+
end
|
|
89
|
+
records
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def close
|
|
93
|
+
write_message(MSG_GOODBYE)
|
|
94
|
+
@socket&.close
|
|
95
|
+
rescue StandardError
|
|
96
|
+
# closing is best-effort — a dead socket needs no goodbye.
|
|
97
|
+
ensure
|
|
98
|
+
@socket = nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# -- connection + handshake ------------------------------------------
|
|
104
|
+
|
|
105
|
+
def connect
|
|
106
|
+
@socket = open_socket
|
|
107
|
+
# Bolt handshake: magic preamble + four offered versions, MSB first.
|
|
108
|
+
# We offer plain 4.4 only (both Neo4j and Memgraph accept it); the three
|
|
109
|
+
# zero slots are "no further offer".
|
|
110
|
+
@socket.write(["6060B017".to_i(16), 0x00000404, 0, 0, 0].pack("N5"))
|
|
111
|
+
negotiated = @socket.read(4)&.unpack1("N")
|
|
112
|
+
if negotiated.nil? || negotiated.zero?
|
|
113
|
+
raise BoltConnectError, "server offered no compatible Bolt version"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
hello
|
|
117
|
+
rescue BoltError => e
|
|
118
|
+
# An auth/HELLO rejection is a real Bolt failure, not a connect timeout.
|
|
119
|
+
raise BoltConnectError, e.message
|
|
120
|
+
rescue IOError, SystemCallError, SocketError => e
|
|
121
|
+
raise BoltConnectError, e.message
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def open_socket
|
|
125
|
+
if @connect_timeout
|
|
126
|
+
Socket.tcp(@host, @port, connect_timeout: @connect_timeout)
|
|
127
|
+
else
|
|
128
|
+
Socket.tcp(@host, @port)
|
|
129
|
+
end
|
|
130
|
+
rescue Errno::ETIMEDOUT, Errno::EHOSTUNREACH, Errno::ECONNREFUSED,
|
|
131
|
+
Errno::ENETUNREACH, SocketError, Timeout::Error => e
|
|
132
|
+
raise BoltConnectError, e.message
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def hello
|
|
136
|
+
extra = { "user_agent" => "tina4-ruby-bolt/1.0" }
|
|
137
|
+
if @user && !@user.empty?
|
|
138
|
+
extra["scheme"] = "basic"
|
|
139
|
+
extra["principal"] = @user
|
|
140
|
+
extra["credentials"] = @password.to_s
|
|
141
|
+
else
|
|
142
|
+
extra["scheme"] = "none"
|
|
143
|
+
end
|
|
144
|
+
write_message(MSG_HELLO, extra)
|
|
145
|
+
read_success # raises BoltError on FAILURE (bad credentials, etc.)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# -- Bolt request/response cycle -------------------------------------
|
|
149
|
+
|
|
150
|
+
# Send RUN, return the ordered list of result column names.
|
|
151
|
+
def send_run(cypher, params)
|
|
152
|
+
write_message(MSG_RUN, cypher, stringify_keys(params), {})
|
|
153
|
+
meta = read_success
|
|
154
|
+
Array(meta["fields"])
|
|
155
|
+
rescue BoltError
|
|
156
|
+
# A failed RUN leaves the connection FAILED; RESET clears it so the same
|
|
157
|
+
# connection can serve the next statement.
|
|
158
|
+
reset
|
|
159
|
+
raise
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Send PULL(n:-1) and yield each RECORD's value list until SUCCESS.
|
|
163
|
+
def pull_all
|
|
164
|
+
write_message(MSG_PULL, { "n" => -1 })
|
|
165
|
+
loop do
|
|
166
|
+
tag, value = read_message
|
|
167
|
+
case tag
|
|
168
|
+
when MSG_RECORD
|
|
169
|
+
yield value
|
|
170
|
+
when MSG_SUCCESS
|
|
171
|
+
break
|
|
172
|
+
when MSG_FAILURE
|
|
173
|
+
reset
|
|
174
|
+
raise BoltError, bolt_failure_message(value)
|
|
175
|
+
when MSG_IGNORED
|
|
176
|
+
reset
|
|
177
|
+
raise BoltError, "server ignored PULL"
|
|
178
|
+
else
|
|
179
|
+
raise BoltError, "unexpected Bolt response 0x#{tag.to_s(16)}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def reset
|
|
185
|
+
write_message(MSG_RESET)
|
|
186
|
+
# Drain until the RESET SUCCESS/FAILURE so the stream is clean.
|
|
187
|
+
loop do
|
|
188
|
+
tag, = read_message
|
|
189
|
+
break if [MSG_SUCCESS, MSG_FAILURE].include?(tag)
|
|
190
|
+
end
|
|
191
|
+
rescue StandardError
|
|
192
|
+
# If RESET itself cannot complete the connection is unusable; the next
|
|
193
|
+
# operation will surface it.
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def read_success
|
|
198
|
+
tag, value = read_message
|
|
199
|
+
case tag
|
|
200
|
+
when MSG_SUCCESS then value || {}
|
|
201
|
+
when MSG_FAILURE then raise BoltError, bolt_failure_message(value)
|
|
202
|
+
when MSG_IGNORED then raise BoltError, "server ignored request"
|
|
203
|
+
else raise BoltError, "unexpected Bolt response 0x#{tag.to_s(16)}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def bolt_failure_message(value)
|
|
208
|
+
return "Bolt failure" unless value.is_a?(Hash)
|
|
209
|
+
|
|
210
|
+
[value["code"], value["message"]].compact.join(": ")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# -- chunked message framing -----------------------------------------
|
|
214
|
+
|
|
215
|
+
# Serialize (tag + fields) as a struct, split into 64KB chunks, terminate
|
|
216
|
+
# with a zero-length chunk.
|
|
217
|
+
def write_message(tag, *fields)
|
|
218
|
+
body = +"".b
|
|
219
|
+
body << [0xB0 | fields.length].pack("C") << [tag].pack("C")
|
|
220
|
+
fields.each { |f| pack(f, body) }
|
|
221
|
+
offset = 0
|
|
222
|
+
while offset < body.bytesize
|
|
223
|
+
slice = body.byteslice(offset, 65_535)
|
|
224
|
+
@socket.write([slice.bytesize].pack("n"))
|
|
225
|
+
@socket.write(slice)
|
|
226
|
+
offset += slice.bytesize
|
|
227
|
+
end
|
|
228
|
+
@socket.write([0].pack("n")) # message boundary
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Read one chunked message, return [struct_tag, single_field_value].
|
|
232
|
+
# Bolt response messages are one-field structs (SUCCESS/RECORD/FAILURE).
|
|
233
|
+
def read_message
|
|
234
|
+
buffer = +"".b
|
|
235
|
+
loop do
|
|
236
|
+
size = read_exact(2).unpack1("n")
|
|
237
|
+
break if size.zero?
|
|
238
|
+
|
|
239
|
+
buffer << read_exact(size)
|
|
240
|
+
end
|
|
241
|
+
parse_struct(StringScanner.new(buffer))
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def read_exact(n)
|
|
245
|
+
data = +"".b
|
|
246
|
+
while data.bytesize < n
|
|
247
|
+
chunk = @socket.read(n - data.bytesize)
|
|
248
|
+
raise BoltConnectError, "connection closed mid-message" if chunk.nil?
|
|
249
|
+
|
|
250
|
+
data << chunk
|
|
251
|
+
end
|
|
252
|
+
data
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# A struct at the top of a response message: returns [tag, first_field].
|
|
256
|
+
def parse_struct(scanner)
|
|
257
|
+
marker = scanner.byte
|
|
258
|
+
raise BoltError, "expected struct, got 0x#{marker.to_s(16)}" unless (marker & 0xF0) == 0xB0
|
|
259
|
+
|
|
260
|
+
size = marker & 0x0F
|
|
261
|
+
tag = scanner.byte
|
|
262
|
+
fields = Array.new(size) { unpack(scanner) }
|
|
263
|
+
[tag, fields[0]]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# -- PackStream v2 encode --------------------------------------------
|
|
267
|
+
|
|
268
|
+
def pack(value, out)
|
|
269
|
+
case value
|
|
270
|
+
when nil then out << [NULL].pack("C")
|
|
271
|
+
when true then out << [TRUE].pack("C")
|
|
272
|
+
when false then out << [FALSE].pack("C")
|
|
273
|
+
when Integer then pack_integer(value, out)
|
|
274
|
+
when Float then out << [FLOAT64].pack("C") << [value].pack("G")
|
|
275
|
+
when String, Symbol then pack_string(value.to_s, out)
|
|
276
|
+
when Array then pack_list(value, out)
|
|
277
|
+
when Hash then pack_map(value, out)
|
|
278
|
+
else pack_string(value.to_s, out)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def pack_integer(value, out)
|
|
283
|
+
if value >= -16 && value <= 127
|
|
284
|
+
out << [value].pack("c")
|
|
285
|
+
elsif value >= -128 && value <= 127
|
|
286
|
+
out << [INT8, value].pack("Cc")
|
|
287
|
+
elsif value >= -32_768 && value <= 32_767
|
|
288
|
+
out << [INT16].pack("C") << [value].pack("s>")
|
|
289
|
+
elsif value >= -2_147_483_648 && value <= 2_147_483_647
|
|
290
|
+
out << [INT32].pack("C") << [value].pack("l>")
|
|
291
|
+
else
|
|
292
|
+
out << [INT64].pack("C") << [value].pack("q>")
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def pack_string(str, out)
|
|
297
|
+
bytes = str.b
|
|
298
|
+
size = bytes.bytesize
|
|
299
|
+
if size < 16
|
|
300
|
+
out << [0x80 | size].pack("C")
|
|
301
|
+
elsif size < 256
|
|
302
|
+
out << [0xD0, size].pack("CC")
|
|
303
|
+
elsif size < 65_536
|
|
304
|
+
out << [0xD1].pack("C") << [size].pack("n")
|
|
305
|
+
else
|
|
306
|
+
out << [0xD2].pack("C") << [size].pack("N")
|
|
307
|
+
end
|
|
308
|
+
out << bytes
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def pack_list(list, out)
|
|
312
|
+
size = list.length
|
|
313
|
+
if size < 16
|
|
314
|
+
out << [0x90 | size].pack("C")
|
|
315
|
+
elsif size < 256
|
|
316
|
+
out << [0xD4, size].pack("CC")
|
|
317
|
+
elsif size < 65_536
|
|
318
|
+
out << [0xD5].pack("C") << [size].pack("n")
|
|
319
|
+
else
|
|
320
|
+
out << [0xD6].pack("C") << [size].pack("N")
|
|
321
|
+
end
|
|
322
|
+
list.each { |item| pack(item, out) }
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def pack_map(map, out)
|
|
326
|
+
size = map.length
|
|
327
|
+
if size < 16
|
|
328
|
+
out << [0xA0 | size].pack("C")
|
|
329
|
+
elsif size < 256
|
|
330
|
+
out << [0xD8, size].pack("CC")
|
|
331
|
+
elsif size < 65_536
|
|
332
|
+
out << [0xD9].pack("C") << [size].pack("n")
|
|
333
|
+
else
|
|
334
|
+
out << [0xDA].pack("C") << [size].pack("N")
|
|
335
|
+
end
|
|
336
|
+
map.each do |key, val|
|
|
337
|
+
pack_string(key.to_s, out)
|
|
338
|
+
pack(val, out)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# -- PackStream v2 decode --------------------------------------------
|
|
343
|
+
|
|
344
|
+
def unpack(scanner)
|
|
345
|
+
marker = scanner.byte
|
|
346
|
+
# Tiny int (positive 0..127, negative -16..-1).
|
|
347
|
+
return marker if marker < 0x80
|
|
348
|
+
return marker - 0x100 if marker >= 0xF0
|
|
349
|
+
|
|
350
|
+
case marker
|
|
351
|
+
when 0x80..0x8F then scanner.str(marker & 0x0F).force_encoding("UTF-8")
|
|
352
|
+
when 0x90..0x9F then Array.new(marker & 0x0F) { unpack(scanner) }
|
|
353
|
+
when 0xA0..0xAF then unpack_map(scanner, marker & 0x0F)
|
|
354
|
+
when 0xB0..0xBF then unpack_structure(scanner, marker & 0x0F)
|
|
355
|
+
when NULL then nil
|
|
356
|
+
when TRUE then true
|
|
357
|
+
when FALSE then false
|
|
358
|
+
when FLOAT64 then scanner.str(8).unpack1("G")
|
|
359
|
+
when INT8 then scanner.str(1).unpack1("c")
|
|
360
|
+
when INT16 then scanner.str(2).unpack1("s>")
|
|
361
|
+
when INT32 then scanner.str(4).unpack1("l>")
|
|
362
|
+
when INT64 then scanner.str(8).unpack1("q>")
|
|
363
|
+
when 0xD0 then scanner.str(scanner.byte).force_encoding("UTF-8")
|
|
364
|
+
when 0xD1 then scanner.str(scanner.str(2).unpack1("n")).force_encoding("UTF-8")
|
|
365
|
+
when 0xD2 then scanner.str(scanner.str(4).unpack1("N")).force_encoding("UTF-8")
|
|
366
|
+
when 0xD4 then Array.new(scanner.byte) { unpack(scanner) }
|
|
367
|
+
when 0xD5 then Array.new(scanner.str(2).unpack1("n")) { unpack(scanner) }
|
|
368
|
+
when 0xD6 then Array.new(scanner.str(4).unpack1("N")) { unpack(scanner) }
|
|
369
|
+
when 0xD8 then unpack_map(scanner, scanner.byte)
|
|
370
|
+
when 0xD9 then unpack_map(scanner, scanner.str(2).unpack1("n"))
|
|
371
|
+
when 0xDA then unpack_map(scanner, scanner.str(4).unpack1("N"))
|
|
372
|
+
else raise BoltError, "unknown PackStream marker 0x#{marker.to_s(16)}"
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def unpack_map(scanner, size)
|
|
377
|
+
result = {}
|
|
378
|
+
size.times do
|
|
379
|
+
key = unpack(scanner)
|
|
380
|
+
result[key] = unpack(scanner)
|
|
381
|
+
end
|
|
382
|
+
result
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# A nested structure inside a RECORD — decode the known graph structs to
|
|
386
|
+
# plain hashes so a raw query that returns whole nodes still yields data.
|
|
387
|
+
def unpack_structure(scanner, size)
|
|
388
|
+
tag = scanner.byte
|
|
389
|
+
fields = Array.new(size) { unpack(scanner) }
|
|
390
|
+
case tag
|
|
391
|
+
when NODE
|
|
392
|
+
{ "id" => fields[0], "labels" => fields[1], "properties" => fields[2] }
|
|
393
|
+
when RELATIONSHIP
|
|
394
|
+
{ "id" => fields[0], "start" => fields[1], "end" => fields[2],
|
|
395
|
+
"type" => fields[3], "properties" => fields[4] }
|
|
396
|
+
when UNBOUND_RELATIONSHIP
|
|
397
|
+
{ "id" => fields[0], "type" => fields[1], "properties" => fields[2] }
|
|
398
|
+
else
|
|
399
|
+
fields
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def stringify_keys(hash)
|
|
404
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# A tiny forward-only byte cursor over a binary string (avoids pulling in
|
|
408
|
+
# StringScanner, which is text-oriented and not binary-safe for our needs).
|
|
409
|
+
class StringScanner
|
|
410
|
+
def initialize(bytes)
|
|
411
|
+
@bytes = bytes
|
|
412
|
+
@pos = 0
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def byte
|
|
416
|
+
b = @bytes.getbyte(@pos)
|
|
417
|
+
@pos += 1
|
|
418
|
+
b
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def str(n)
|
|
422
|
+
s = @bytes.byteslice(@pos, n)
|
|
423
|
+
@pos += n
|
|
424
|
+
s
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# The Bolt graph adapter — Neo4j + Memgraph behind the portable surface.
|
|
430
|
+
class BoltGraphDriver < Tina4::GraphAdapter
|
|
431
|
+
def initialize(graph_url, username: "", password: "")
|
|
432
|
+
@url = graph_url
|
|
433
|
+
@last_error = nil
|
|
434
|
+
timeout = Tina4.resolve_graph_connect_timeout
|
|
435
|
+
user = graph_url.username
|
|
436
|
+
user = username unless user && !user.empty?
|
|
437
|
+
pwd = graph_url.password
|
|
438
|
+
pwd = password unless pwd && !pwd.empty?
|
|
439
|
+
user = "neo4j" if (user.nil? || user.empty?) && graph_url.engine == "bolt" && !password.to_s.empty?
|
|
440
|
+
@conn = BoltConnection.new(
|
|
441
|
+
host: graph_url.host, port: graph_url.port,
|
|
442
|
+
user: user, password: pwd,
|
|
443
|
+
connect_timeout: timeout, use_tls: graph_url.use_tls
|
|
444
|
+
)
|
|
445
|
+
rescue BoltConnection::BoltConnectError => e
|
|
446
|
+
@last_error = e.message
|
|
447
|
+
raise Tina4::GraphConnectTimeout,
|
|
448
|
+
"Graph connect to #{graph_url.host}:#{graph_url.port} timed out or was refused " \
|
|
449
|
+
"(TINA4_GRAPH_CONNECT_TIMEOUT). Raise TINA4_GRAPH_CONNECT_TIMEOUT if the server is " \
|
|
450
|
+
"simply slow, or set it to 0 to wait indefinitely. Cause: #{e.message}"
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# -- raw pass-through (native Cypher) --------------------------------
|
|
454
|
+
|
|
455
|
+
def query(text, params = nil)
|
|
456
|
+
rows = run(text, params)
|
|
457
|
+
columns = rows.empty? ? [] : rows[0].keys
|
|
458
|
+
Tina4::GraphResult.new(records: rows, columns: columns)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def execute(text, params = nil)
|
|
462
|
+
query(text, params)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# -- portable node/edge/traverse core (Cypher) -----------------------
|
|
466
|
+
|
|
467
|
+
def add_node(label, properties = nil)
|
|
468
|
+
cypher = "CREATE (n:`#{label}` $props) " \
|
|
469
|
+
"RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props"
|
|
470
|
+
rows = run(cypher, { "props" => properties || {} })
|
|
471
|
+
node_from_row(rows[0])
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def add_edge(from_id, to_id, type, properties = nil)
|
|
475
|
+
cypher = "MATCH (a), (b) WHERE id(a) = $from_id AND id(b) = $to_id " \
|
|
476
|
+
"CREATE (a)-[e:`#{type}` $props]->(b) " \
|
|
477
|
+
"RETURN id(e) AS id, type(e) AS type, id(a) AS f, id(b) AS t, " \
|
|
478
|
+
"properties(e) AS props"
|
|
479
|
+
rows = run(cypher, { "from_id" => from_id, "to_id" => to_id, "props" => properties || {} })
|
|
480
|
+
return nil if rows.empty?
|
|
481
|
+
|
|
482
|
+
row = rows[0]
|
|
483
|
+
Tina4::GraphEdge.new(
|
|
484
|
+
id: row["id"], type: row["type"], from: row["f"], to: row["t"],
|
|
485
|
+
properties: row["props"] || {}
|
|
486
|
+
)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def get_node(node_id)
|
|
490
|
+
cypher = "MATCH (n) WHERE id(n) = $id " \
|
|
491
|
+
"RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props"
|
|
492
|
+
rows = run(cypher, { "id" => node_id })
|
|
493
|
+
node_from_row(rows[0])
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def update_node(node_id, properties)
|
|
497
|
+
cypher = "MATCH (n) WHERE id(n) = $id SET n += $props " \
|
|
498
|
+
"RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props"
|
|
499
|
+
rows = run(cypher, { "id" => node_id, "props" => properties || {} })
|
|
500
|
+
node_from_row(rows[0])
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def delete_node(node_id)
|
|
504
|
+
run("MATCH (n) WHERE id(n) = $id DETACH DELETE n", { "id" => node_id })
|
|
505
|
+
true
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def neighbors(node_id, direction: "both", edge_type: nil, limit: 100)
|
|
509
|
+
edge = edge_type ? ":`#{edge_type}`" : ""
|
|
510
|
+
pattern = case direction
|
|
511
|
+
when "out" then "(n)-[#{edge}]->(m)"
|
|
512
|
+
when "in" then "(n)<-[#{edge}]-(m)"
|
|
513
|
+
else "(n)-[#{edge}]-(m)"
|
|
514
|
+
end
|
|
515
|
+
cypher = "MATCH #{pattern} WHERE id(n) = $id " \
|
|
516
|
+
"RETURN DISTINCT id(m) AS id, labels(m) AS labels, properties(m) AS props " \
|
|
517
|
+
"LIMIT #{limit.to_i}"
|
|
518
|
+
run(cypher, { "id" => node_id }).map { |row| node_from_row(row) }
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def traverse(start_id, depth: 1, direction: "both", edge_type: nil, limit: 1000)
|
|
522
|
+
# Cypher variable-length path `[*1..N]` — Neo4j AND Memgraph (the OPPOSITE
|
|
523
|
+
# of Ultipa's GQL quantifier `{1,N}`).
|
|
524
|
+
edge = edge_type ? ":`#{edge_type}`" : ""
|
|
525
|
+
n = depth.to_i
|
|
526
|
+
arrow = case direction
|
|
527
|
+
when "out" then "-[#{edge}*1..#{n}]->"
|
|
528
|
+
when "in" then "<-[#{edge}*1..#{n}]-"
|
|
529
|
+
else "-[#{edge}*1..#{n}]-"
|
|
530
|
+
end
|
|
531
|
+
cypher = "MATCH (n)#{arrow}(m) WHERE id(n) = $start " \
|
|
532
|
+
"RETURN DISTINCT id(m) AS id, labels(m) AS labels, properties(m) AS props " \
|
|
533
|
+
"LIMIT #{limit.to_i}"
|
|
534
|
+
run(cypher, { "start" => start_id }).map { |row| node_from_row(row) }
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def close
|
|
538
|
+
@conn&.close
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
private
|
|
542
|
+
|
|
543
|
+
# Run a Cypher statement, wrapping the client's Bolt errors as the
|
|
544
|
+
# framework's GraphConnectTimeout / GraphError.
|
|
545
|
+
def run(cypher, params)
|
|
546
|
+
@conn.run(cypher, params)
|
|
547
|
+
rescue BoltConnection::BoltConnectError => e
|
|
548
|
+
@last_error = e.message
|
|
549
|
+
raise Tina4::GraphConnectTimeout,
|
|
550
|
+
"Graph connect to #{@url.host}:#{@url.port} failed " \
|
|
551
|
+
"(TINA4_GRAPH_CONNECT_TIMEOUT). Cause: #{e.message}"
|
|
552
|
+
rescue BoltConnection::BoltError => e
|
|
553
|
+
@last_error = e.message
|
|
554
|
+
raise Tina4::GraphError, e.message
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def node_from_row(row)
|
|
558
|
+
return nil if row.nil?
|
|
559
|
+
|
|
560
|
+
Tina4::GraphNode.new(
|
|
561
|
+
id: row["id"], labels: row["labels"], properties: row["props"] || {}
|
|
562
|
+
)
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
end
|