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.
@@ -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