activecypher 0.0.0 → 0.2.0
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/active_cypher/associations/collection_proxy.rb +144 -0
- data/lib/active_cypher/associations.rb +537 -0
- data/lib/active_cypher/base.rb +47 -0
- data/lib/active_cypher/bolt/connection.rb +525 -0
- data/lib/active_cypher/bolt/driver.rb +144 -0
- data/lib/active_cypher/bolt/handlers.rb +10 -0
- data/lib/active_cypher/bolt/message_reader.rb +100 -0
- data/lib/active_cypher/bolt/message_writer.rb +53 -0
- data/lib/active_cypher/bolt/messaging.rb +307 -0
- data/lib/active_cypher/bolt/packstream.rb +319 -0
- data/lib/active_cypher/bolt/result.rb +82 -0
- data/lib/active_cypher/bolt/session.rb +201 -0
- data/lib/active_cypher/bolt/transaction.rb +211 -0
- data/lib/active_cypher/bolt/version_encoding.rb +41 -0
- data/lib/active_cypher/bolt.rb +7 -0
- data/lib/active_cypher/connection_adapters/abstract_adapter.rb +75 -0
- data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +178 -0
- data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
- data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +58 -0
- data/lib/active_cypher/connection_factory.rb +130 -0
- data/lib/active_cypher/connection_handler.rb +9 -0
- data/lib/active_cypher/connection_pool.rb +123 -0
- data/lib/active_cypher/connection_url_resolver.rb +137 -0
- data/lib/active_cypher/cypher_config.rb +50 -0
- data/lib/active_cypher/generators/install_generator.rb +23 -0
- data/lib/active_cypher/generators/node_generator.rb +32 -0
- data/lib/active_cypher/generators/relationship_generator.rb +33 -0
- data/lib/active_cypher/generators/templates/application_graph_node.rb +5 -0
- data/lib/active_cypher/generators/templates/application_graph_relationship.rb +5 -0
- data/lib/active_cypher/generators/templates/cypher_databases.yml +28 -0
- data/lib/active_cypher/generators/templates/node.rb.erb +10 -0
- data/lib/active_cypher/generators/templates/relationship.rb.erb +11 -0
- data/lib/active_cypher/logging.rb +44 -0
- data/lib/active_cypher/model/abstract.rb +87 -0
- data/lib/active_cypher/model/attributes.rb +24 -0
- data/lib/active_cypher/model/callbacks.rb +44 -0
- data/lib/active_cypher/model/connection_handling.rb +76 -0
- data/lib/active_cypher/model/connection_owner.rb +50 -0
- data/lib/active_cypher/model/core.rb +45 -0
- data/lib/active_cypher/model/countable.rb +30 -0
- data/lib/active_cypher/model/destruction.rb +49 -0
- data/lib/active_cypher/model/inspectable.rb +28 -0
- data/lib/active_cypher/model/persistence.rb +182 -0
- data/lib/active_cypher/model/querying.rb +67 -0
- data/lib/active_cypher/railtie.rb +34 -0
- data/lib/active_cypher/relation.rb +190 -0
- data/lib/active_cypher/relationship.rb +233 -0
- data/lib/active_cypher/runtime_registry.rb +8 -0
- data/lib/active_cypher/scoping.rb +97 -0
- data/lib/active_cypher/utils/logger.rb +100 -0
- data/lib/active_cypher/version.rb +5 -0
- data/lib/activecypher.rb +108 -0
- data/lib/cyrel/call_procedure.rb +29 -0
- data/lib/cyrel/clause/call.rb +46 -0
- data/lib/cyrel/clause/call_subquery.rb +40 -0
- data/lib/cyrel/clause/create.rb +33 -0
- data/lib/cyrel/clause/delete.rb +41 -0
- data/lib/cyrel/clause/limit.rb +33 -0
- data/lib/cyrel/clause/match.rb +40 -0
- data/lib/cyrel/clause/merge.rb +34 -0
- data/lib/cyrel/clause/order_by.rb +78 -0
- data/lib/cyrel/clause/remove.rb +75 -0
- data/lib/cyrel/clause/return.rb +90 -0
- data/lib/cyrel/clause/set.rb +97 -0
- data/lib/cyrel/clause/skip.rb +34 -0
- data/lib/cyrel/clause/where.rb +42 -0
- data/lib/cyrel/clause/with.rb +94 -0
- data/lib/cyrel/clause.rb +25 -0
- data/lib/cyrel/direction.rb +18 -0
- data/lib/cyrel/expression/alias.rb +27 -0
- data/lib/cyrel/expression/base.rb +101 -0
- data/lib/cyrel/expression/case.rb +45 -0
- data/lib/cyrel/expression/comparison.rb +60 -0
- data/lib/cyrel/expression/exists.rb +42 -0
- data/lib/cyrel/expression/function_call.rb +57 -0
- data/lib/cyrel/expression/literal.rb +33 -0
- data/lib/cyrel/expression/logical.rb +38 -0
- data/lib/cyrel/expression/operator.rb +27 -0
- data/lib/cyrel/expression/pattern_comprehension.rb +44 -0
- data/lib/cyrel/expression/property_access.rb +25 -0
- data/lib/cyrel/expression.rb +56 -0
- data/lib/cyrel/functions.rb +116 -0
- data/lib/cyrel/node.rb +397 -0
- data/lib/cyrel/parameterizable.rb +20 -0
- data/lib/cyrel/pattern/node.rb +66 -0
- data/lib/cyrel/pattern/path.rb +41 -0
- data/lib/cyrel/pattern/relationship.rb +74 -0
- data/lib/cyrel/pattern.rb +8 -0
- data/lib/cyrel/query.rb +497 -0
- data/lib/cyrel/return_only.rb +26 -0
- data/lib/cyrel/types/hash_type.rb +22 -0
- data/lib/cyrel/types/symbol_type.rb +13 -0
- data/lib/cyrel.rb +72 -0
- data/lib/tasks/active_cypher_tasks.rake +6 -0
- data/sig/activecypher.rbs +4 -0
- metadata +173 -10
@@ -0,0 +1,319 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'stringio'
|
4
|
+
|
5
|
+
module ActiveCypher
|
6
|
+
module Bolt
|
7
|
+
# Handles Packstream serialization and deserialization.
|
8
|
+
# Based on Bolt Protocol Specification version 5.0
|
9
|
+
# https://7687.org/bolt/bolt-protocol-specification-5.0.html#packstream-structures
|
10
|
+
module Packstream
|
11
|
+
# Marker Bytes & Limits
|
12
|
+
TINY_STRING_MARKER_BASE = 0x80
|
13
|
+
STRING_8_MARKER = 0xD0
|
14
|
+
STRING_16_MARKER = 0xD1
|
15
|
+
# STRING_32_MARKER = 0xD2
|
16
|
+
|
17
|
+
TINY_LIST_MARKER_BASE = 0x90 # Added List marker base
|
18
|
+
LIST_8_MARKER = 0xD4 # Added List marker
|
19
|
+
LIST_16_MARKER = 0xD5 # Added List marker
|
20
|
+
# LIST_32_MARKER = 0xD6 # Added List marker
|
21
|
+
# STRING_32_MARKER = 0xD2 # Not implementing 32-bit sizes for now
|
22
|
+
|
23
|
+
TINY_MAP_MARKER_BASE = 0xA0
|
24
|
+
MAP_8_MARKER = 0xD8
|
25
|
+
MAP_16_MARKER = 0xD9
|
26
|
+
# MAP_32_MARKER = 0xDA # Not implementing 32-bit sizes for now
|
27
|
+
|
28
|
+
INT_8 = 0xC8
|
29
|
+
INT_16 = 0xC9
|
30
|
+
INT_32 = 0xCA
|
31
|
+
INT_64 = 0xCB
|
32
|
+
|
33
|
+
TINY_INT_MIN = -16
|
34
|
+
TINY_INT_MAX = 127
|
35
|
+
INT_8_MIN = -128
|
36
|
+
INT_8_MAX = 127
|
37
|
+
INT_16_MIN = -32_768
|
38
|
+
INT_16_MAX = 32_767
|
39
|
+
INT_32_MIN = -2_147_483_648
|
40
|
+
INT_32_MAX = 2_147_483_647
|
41
|
+
# INT_64 limits are typically Ruby's standard Integer limits
|
42
|
+
|
43
|
+
# Packs Ruby objects into Packstream byte format.
|
44
|
+
class Packer
|
45
|
+
# Define constants inside Packer where they are used
|
46
|
+
NULL = 0xC0
|
47
|
+
FALSEY = 0xC2
|
48
|
+
TRUETHY = 0xC3
|
49
|
+
|
50
|
+
def initialize(io)
|
51
|
+
@io = io
|
52
|
+
end
|
53
|
+
|
54
|
+
def pack(value)
|
55
|
+
case value
|
56
|
+
when String then pack_string(value)
|
57
|
+
when Hash then pack_map(value)
|
58
|
+
when Integer then pack_integer(value)
|
59
|
+
when TrueClass then write_marker([TRUETHY].pack('C'))
|
60
|
+
when FalseClass then write_marker([FALSEY].pack('C'))
|
61
|
+
when NilClass then write_marker([NULL].pack('C'))
|
62
|
+
when Array then pack_list(value)
|
63
|
+
# TODO: Add other types as needed (Float, Structure)
|
64
|
+
else
|
65
|
+
raise ProtocolError, "Cannot pack type: #{value.class}"
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def pack_string(str)
|
72
|
+
bytes = str.encode('UTF-8')
|
73
|
+
size = bytes.bytesize
|
74
|
+
|
75
|
+
if size < 16 # TinyString
|
76
|
+
write_marker_and_data([TINY_STRING_MARKER_BASE | size].pack('C'), bytes)
|
77
|
+
elsif size < 256 # STRING_8
|
78
|
+
write_marker_and_data([STRING_8_MARKER, size].pack('CC'), bytes)
|
79
|
+
elsif size < 65_536 # STRING_16
|
80
|
+
write_marker_and_data([STRING_16_MARKER, size].pack('Cn'), bytes)
|
81
|
+
else
|
82
|
+
raise ProtocolError, "String too large to pack (size: #{size})"
|
83
|
+
# write_marker_and_data([STRING_32_MARKER, size].pack('CN>'), bytes)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def pack_map(map)
|
88
|
+
size = map.size
|
89
|
+
|
90
|
+
if size < 16 # TinyMap
|
91
|
+
write_marker([TINY_MAP_MARKER_BASE | size].pack('C'))
|
92
|
+
elsif size < 256 # MAP_8
|
93
|
+
write_marker([MAP_8_MARKER, size].pack('CC'))
|
94
|
+
elsif size < 65_536 # MAP_16
|
95
|
+
write_marker([MAP_16_MARKER, size].pack('Cn'))
|
96
|
+
else
|
97
|
+
raise ProtocolError, "Map too large to pack (size: #{size})"
|
98
|
+
# write_marker([MAP_32_MARKER, size].pack('CN>'))
|
99
|
+
end
|
100
|
+
|
101
|
+
map.each do |key, value|
|
102
|
+
pack(key.to_s) # Keys must be strings
|
103
|
+
pack(value)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def pack_list(list)
|
108
|
+
size = list.size
|
109
|
+
if size < 16 # TinyList
|
110
|
+
write_marker([TINY_LIST_MARKER_BASE | size].pack('C'))
|
111
|
+
elsif size < 256 # LIST_8
|
112
|
+
write_marker([LIST_8_MARKER, size].pack('CC'))
|
113
|
+
elsif size < 65_536 # LIST_16
|
114
|
+
write_marker([LIST_16_MARKER, size].pack('Cn')) # n is already network byte order
|
115
|
+
else
|
116
|
+
raise ProtocolError, "List too large to pack (size: #{size})"
|
117
|
+
# write_marker([LIST_32_MARKER, size].pack('CN>')) # Use N> for network byte order
|
118
|
+
end
|
119
|
+
|
120
|
+
list.each { |item| pack(item) }
|
121
|
+
end
|
122
|
+
|
123
|
+
def pack_integer(int)
|
124
|
+
if int.between?(TINY_INT_MIN, TINY_INT_MAX) # Tiny Integer
|
125
|
+
write_marker([int].pack('c')) # Signed char for range -128 to 127
|
126
|
+
elsif int.between?(INT_8_MIN, INT_8_MAX) # INT_8
|
127
|
+
write_marker_and_data([INT_8].pack('C'), [int].pack('c'))
|
128
|
+
elsif int.between?(INT_16_MIN, INT_16_MAX) # INT_16
|
129
|
+
write_marker_and_data([INT_16].pack('C'), [int].pack('s>'))
|
130
|
+
elsif int.between?(INT_32_MIN, INT_32_MAX) # INT_32
|
131
|
+
write_marker_and_data([INT_32].pack('C'), [int].pack('l>'))
|
132
|
+
else # INT_64
|
133
|
+
write_marker_and_data([INT_64].pack('C'), [int].pack('q')) # q is already network byte order
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def write_marker(marker_bytes)
|
138
|
+
@io.write(marker_bytes)
|
139
|
+
end
|
140
|
+
|
141
|
+
def write_marker_and_data(marker_bytes, data_bytes)
|
142
|
+
@io.write(marker_bytes)
|
143
|
+
@io.write(data_bytes) if data_bytes && !data_bytes.empty?
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# Unpacks Packstream byte format into Ruby objects.
|
148
|
+
class Unpacker
|
149
|
+
# Marker Bytes
|
150
|
+
NULL = 0xC0
|
151
|
+
FALSEY = 0xC2
|
152
|
+
TRUETHY = 0xC3
|
153
|
+
INT_8 = 0xC8
|
154
|
+
INT_16 = 0xC9
|
155
|
+
INT_32 = 0xCA
|
156
|
+
INT_64 = 0xCB
|
157
|
+
FLOAT_64 = 0xC1 # Added Float marker
|
158
|
+
|
159
|
+
TINY_STRING_MARKER_BASE = 0x80
|
160
|
+
STRING_8_MARKER = 0xD0
|
161
|
+
STRING_16_MARKER = 0xD1
|
162
|
+
STRING_32_MARKER = 0xD2
|
163
|
+
|
164
|
+
TINY_LIST_MARKER_BASE = 0x90
|
165
|
+
LIST_8_MARKER = 0xD4
|
166
|
+
LIST_16_MARKER = 0xD5
|
167
|
+
LIST_32_MARKER = 0xD6
|
168
|
+
|
169
|
+
TINY_MAP_MARKER_BASE = 0xA0
|
170
|
+
MAP_8_MARKER = 0xD8
|
171
|
+
MAP_16_MARKER = 0xD9
|
172
|
+
MAP_32_MARKER = 0xDA
|
173
|
+
|
174
|
+
TINY_STRUCT_MARKER_BASE = 0xB0
|
175
|
+
STRUCT_8_MARKER = 0xDC
|
176
|
+
STRUCT_16_MARKER = 0xDD
|
177
|
+
STRUCT_32_MARKER = 0xDE
|
178
|
+
|
179
|
+
MARKER_HIGH_NIBBLE_MASK = 0xF0
|
180
|
+
MARKER_LOW_NIBBLE_MASK = 0x0F
|
181
|
+
|
182
|
+
def initialize(io)
|
183
|
+
@io = io
|
184
|
+
end
|
185
|
+
|
186
|
+
# Unpacks the next value from the stream.
|
187
|
+
def unpack
|
188
|
+
marker = read_byte
|
189
|
+
unpack_value(marker)
|
190
|
+
end
|
191
|
+
|
192
|
+
private
|
193
|
+
|
194
|
+
def unpack_value(marker)
|
195
|
+
# Add logging here
|
196
|
+
# Tiny types
|
197
|
+
return marker if marker >= 0 && marker < TINY_STRING_MARKER_BASE # Tiny Positive Int
|
198
|
+
return marker - 256 if marker >= 0xF0 # Tiny Negative Int (-1 to -16)
|
199
|
+
|
200
|
+
high_nibble = marker & MARKER_HIGH_NIBBLE_MASK
|
201
|
+
low_nibble = marker & MARKER_LOW_NIBBLE_MASK
|
202
|
+
|
203
|
+
case high_nibble
|
204
|
+
when TINY_STRING_MARKER_BASE then return unpack_string(low_nibble)
|
205
|
+
when TINY_LIST_MARKER_BASE then return unpack_list(low_nibble)
|
206
|
+
when TINY_MAP_MARKER_BASE then return unpack_map(low_nibble)
|
207
|
+
when TINY_STRUCT_MARKER_BASE then return unpack_structure(low_nibble)
|
208
|
+
end
|
209
|
+
|
210
|
+
# Other markers
|
211
|
+
case marker
|
212
|
+
when NULL then nil
|
213
|
+
when FALSEY then false
|
214
|
+
when TRUETHY then true
|
215
|
+
when INT_8 then read_int(1, 'c')
|
216
|
+
when INT_16 then read_int(2, 's>')
|
217
|
+
when INT_32 then read_int(4, 'l>')
|
218
|
+
when INT_64 then read_int(8, 'q>')
|
219
|
+
when STRING_8_MARKER then unpack_string(read_size(1))
|
220
|
+
when STRING_16_MARKER then unpack_string(read_size(2))
|
221
|
+
when STRING_32_MARKER then unpack_string(read_size(4))
|
222
|
+
when LIST_8_MARKER then unpack_list(read_size(1))
|
223
|
+
when LIST_16_MARKER then unpack_list(read_size(2))
|
224
|
+
when LIST_32_MARKER then unpack_list(read_size(4))
|
225
|
+
when MAP_8_MARKER then unpack_map(read_size(1))
|
226
|
+
when MAP_16_MARKER then unpack_map(read_size(2))
|
227
|
+
when MAP_32_MARKER then unpack_map(read_size(4))
|
228
|
+
when STRUCT_8_MARKER then unpack_structure(read_size(1))
|
229
|
+
when STRUCT_16_MARKER then unpack_structure(read_size(2))
|
230
|
+
when STRUCT_32_MARKER then unpack_structure(read_size(4))
|
231
|
+
when FLOAT_64 then unpack_float64
|
232
|
+
# TODO: Add Bytes
|
233
|
+
else
|
234
|
+
raise ProtocolError, "Unknown Packstream marker: 0x#{marker.to_s(16)}"
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
def unpack_string(size)
|
239
|
+
read_bytes(size).force_encoding('UTF-8')
|
240
|
+
end
|
241
|
+
|
242
|
+
def unpack_list(size)
|
243
|
+
Array.new(size) { unpack }
|
244
|
+
end
|
245
|
+
|
246
|
+
def unpack_map(size)
|
247
|
+
Array.new(size) { [unpack, unpack] }.to_h # Assumes keys are strings after unpack
|
248
|
+
end
|
249
|
+
|
250
|
+
# Unpacks a structure into a [signature, [fields]] array
|
251
|
+
def unpack_structure(size)
|
252
|
+
signature = read_byte
|
253
|
+
fields = Array.new(size) { unpack }
|
254
|
+
[signature, fields]
|
255
|
+
end
|
256
|
+
|
257
|
+
def unpack_float64
|
258
|
+
# Reads 8 bytes and unpacks as a double-precision float, big-endian
|
259
|
+
read_bytes(8).unpack1('G')
|
260
|
+
end
|
261
|
+
|
262
|
+
# Helper to read a single byte as an integer
|
263
|
+
def read_byte
|
264
|
+
byte = @io.read(1)
|
265
|
+
raise EOFError, 'Unexpected end of stream while reading byte' if byte.nil?
|
266
|
+
|
267
|
+
byte.unpack1('C')
|
268
|
+
end
|
269
|
+
|
270
|
+
# Helper to read size markers (unsigned integers)
|
271
|
+
# Helper to read size markers (unsigned integers)
|
272
|
+
def read_size(num_bytes)
|
273
|
+
bytes = read_bytes(num_bytes) # Read first
|
274
|
+
case num_bytes
|
275
|
+
when 1 then bytes.unpack1('C')
|
276
|
+
when 2 then bytes.unpack1('n') # Use 'n' (network byte order = big-endian)
|
277
|
+
when 4 then bytes.unpack1('N') # Use 'N' (network byte order = big-endian)
|
278
|
+
else raise ArgumentError, "Invalid size length: #{num_bytes}"
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
# Helper to read signed integers
|
283
|
+
def read_int(num_bytes, format)
|
284
|
+
bytes = read_bytes(num_bytes) # Read first
|
285
|
+
# Add logging immediately before unpack
|
286
|
+
#
|
287
|
+
# Put '>' back as it's needed for big-endian
|
288
|
+
bytes.unpack1(format)
|
289
|
+
end
|
290
|
+
|
291
|
+
# Helper to read exactly n bytes
|
292
|
+
def read_bytes(n)
|
293
|
+
return ''.b if n.zero? # Handle zero-length reads
|
294
|
+
|
295
|
+
data = @io.read(n)
|
296
|
+
# More robust check
|
297
|
+
raise EOFError, "Unexpected end of stream while reading #{n} bytes (got #{data&.bytesize || 'nil'})" unless data && data.bytesize == n
|
298
|
+
|
299
|
+
data
|
300
|
+
end
|
301
|
+
end
|
302
|
+
|
303
|
+
# Helper function to pack a value into a string
|
304
|
+
def self.pack(value)
|
305
|
+
io = StringIO.new(+'', 'wb') # Binary mode important
|
306
|
+
packer = Packer.new(io)
|
307
|
+
packer.pack(value)
|
308
|
+
io.string
|
309
|
+
end
|
310
|
+
|
311
|
+
# Helper function to unpack a value from a string
|
312
|
+
def self.unpack(bytes)
|
313
|
+
io = StringIO.new(bytes, 'rb') # Binary mode important
|
314
|
+
unpacker = Unpacker.new(io)
|
315
|
+
unpacker.unpack
|
316
|
+
end
|
317
|
+
end
|
318
|
+
end
|
319
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveCypher
|
4
|
+
module Bolt
|
5
|
+
# Handles query results, streams records, and provides summary information.
|
6
|
+
# Represents the result of a Cypher query execution.
|
7
|
+
class Result
|
8
|
+
include Enumerable # Allows iteration over records
|
9
|
+
|
10
|
+
attr_reader :fields, :summary_metadata, :qid
|
11
|
+
|
12
|
+
# @param fields [Array<String>] List of field names in the result.
|
13
|
+
# @param records [Array<Array>] List of records, where each record is a list of values.
|
14
|
+
# @param summary_metadata [Hash] Metadata received in the final SUCCESS message.
|
15
|
+
# @param qid [Integer, nil] The query ID associated with this result (-1 if none).
|
16
|
+
def initialize(fields, records, summary_metadata, qid = -1)
|
17
|
+
@fields = fields || [] # Ensure fields is an array
|
18
|
+
@records = records
|
19
|
+
@summary_metadata = summary_metadata || {}
|
20
|
+
@qid = qid
|
21
|
+
@consumed = false
|
22
|
+
@record_index = 0
|
23
|
+
end
|
24
|
+
|
25
|
+
# Allows iterating over the records using `each`.
|
26
|
+
# Yields each record as a Hash with field names as keys (symbols).
|
27
|
+
def each
|
28
|
+
raise 'Result already consumed or closed' if @consumed
|
29
|
+
return enum_for(:each) unless block_given? # Return enumerator if no block
|
30
|
+
|
31
|
+
@records.each do |record_values|
|
32
|
+
yield @fields.map(&:to_sym).zip(record_values).to_h
|
33
|
+
end
|
34
|
+
consume # Mark as consumed after successful iteration
|
35
|
+
end
|
36
|
+
|
37
|
+
# Retrieves a single record. Raises error if no records or more than one.
|
38
|
+
# @return [Hash] The single record as a symbol-keyed hash.
|
39
|
+
# @raise [RuntimeError] If the number of records is not exactly one.
|
40
|
+
def single
|
41
|
+
raise 'Result already consumed or closed' if @consumed
|
42
|
+
raise "Expected exactly one record, but found #{@records.size}" unless @records.size == 1
|
43
|
+
|
44
|
+
record = @fields.map(&:to_sym).zip(@records.first).to_h
|
45
|
+
consume
|
46
|
+
record
|
47
|
+
end
|
48
|
+
|
49
|
+
# Retrieves all records as an array of hashes.
|
50
|
+
# @return [Array<Hash>]
|
51
|
+
def to_a
|
52
|
+
raise 'Result already consumed or closed' if @consumed
|
53
|
+
|
54
|
+
result_array = @records.map do |record_values|
|
55
|
+
@fields.map(&:to_sym).zip(record_values).to_h
|
56
|
+
end
|
57
|
+
consume
|
58
|
+
result_array
|
59
|
+
end
|
60
|
+
|
61
|
+
# Checks if the result stream is still open (i.e., not fully consumed).
|
62
|
+
# @return [Boolean]
|
63
|
+
def open?
|
64
|
+
!@consumed
|
65
|
+
end
|
66
|
+
|
67
|
+
# Marks the result as fully consumed.
|
68
|
+
def consume
|
69
|
+
@consumed = true
|
70
|
+
# Potentially release resources if streaming was implemented differently
|
71
|
+
end
|
72
|
+
|
73
|
+
# Provides summary information about the query execution.
|
74
|
+
# @return [Hash] Summary metadata (e.g., counters, query type).
|
75
|
+
def summary
|
76
|
+
# TODO: Parse summary_metadata into a more structured Summary object?
|
77
|
+
consume unless @consumed # Ensure result is consumed before accessing summary
|
78
|
+
@summary_metadata
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,201 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'async'
|
4
|
+
|
5
|
+
module ActiveCypher
|
6
|
+
module Bolt
|
7
|
+
# A Session is the primary unit of work in the Bolt Protocol.
|
8
|
+
# It maintains a connection to the database server and allows running queries.
|
9
|
+
class Session
|
10
|
+
attr_reader :connection, :database
|
11
|
+
|
12
|
+
def initialize(connection, database: nil)
|
13
|
+
@connection = connection
|
14
|
+
|
15
|
+
# For Memgraph, never set a database (they don't support multiple DBs)
|
16
|
+
@database = if connection.adapter.is_a?(ConnectionAdapters::MemgraphAdapter)
|
17
|
+
nil
|
18
|
+
else
|
19
|
+
database
|
20
|
+
end
|
21
|
+
|
22
|
+
@current_transaction = nil
|
23
|
+
@bookmarks = []
|
24
|
+
end
|
25
|
+
|
26
|
+
# Executes a Cypher query and returns the result.
|
27
|
+
#
|
28
|
+
# @param query [String] The Cypher query to execute.
|
29
|
+
# @param parameters [Hash] Parameters for the query.
|
30
|
+
# @param mode [Symbol] The access mode (:read or :write).
|
31
|
+
# @param db [String] The database name to run the query against.
|
32
|
+
# @return [Result] The result of the query execution.
|
33
|
+
def run(query, parameters = {}, mode: :write, db: nil)
|
34
|
+
# For Memgraph, explicitly set db to nil
|
35
|
+
db = nil if @connection.adapter.is_a?(ConnectionAdapters::MemgraphAdapter)
|
36
|
+
|
37
|
+
if @current_transaction&.active?
|
38
|
+
# If we have an active transaction, run the query within it
|
39
|
+
@current_transaction.run(query, parameters)
|
40
|
+
else
|
41
|
+
# Auto-transaction mode: each query gets its own transaction
|
42
|
+
run_transaction(mode, db: db) do |tx|
|
43
|
+
tx.run(query, parameters)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Begin a new transaction.
|
49
|
+
#
|
50
|
+
# @param db [String] The database name to begin the transaction against.
|
51
|
+
# @param access_mode [Symbol] The access mode (:read or :write).
|
52
|
+
# @param tx_timeout [Integer] Transaction timeout in milliseconds.
|
53
|
+
# @param tx_metadata [Hash] Transaction metadata to send to the server.
|
54
|
+
# @return [Transaction] The new transaction.
|
55
|
+
def begin_transaction(db: nil, access_mode: :write, tx_timeout: nil, tx_metadata: nil)
|
56
|
+
raise ConnectionError, 'Already in a transaction' if @current_transaction&.active?
|
57
|
+
|
58
|
+
# Send BEGIN message with appropriate metadata
|
59
|
+
begin_meta = {}
|
60
|
+
# For Memgraph, NEVER set a database name - it doesn't support them
|
61
|
+
if @connection.adapter.is_a?(ConnectionAdapters::MemgraphAdapter)
|
62
|
+
# Explicitly don't set db for Memgraph
|
63
|
+
begin_meta['adapter'] = 'memgraph'
|
64
|
+
# Force db to nil for Memgraph
|
65
|
+
nil
|
66
|
+
elsif db || @database
|
67
|
+
begin_meta['db'] = db || @database
|
68
|
+
end
|
69
|
+
begin_meta['mode'] = access_mode == :read ? 'r' : 'w'
|
70
|
+
begin_meta['tx_timeout'] = tx_timeout if tx_timeout
|
71
|
+
begin_meta['tx_metadata'] = tx_metadata if tx_metadata
|
72
|
+
begin_meta['bookmarks'] = @bookmarks if @bookmarks&.any?
|
73
|
+
|
74
|
+
begin_msg = Messaging::Begin.new(begin_meta)
|
75
|
+
@connection.write_message(begin_msg)
|
76
|
+
|
77
|
+
# Read response to BEGIN
|
78
|
+
response = @connection.read_message
|
79
|
+
|
80
|
+
case response
|
81
|
+
when Messaging::Success
|
82
|
+
# BEGIN succeeded, create a new transaction
|
83
|
+
@current_transaction = Transaction.new(self, @bookmarks, response.metadata)
|
84
|
+
when Messaging::Failure
|
85
|
+
# BEGIN failed
|
86
|
+
code = response.metadata['code']
|
87
|
+
message = response.metadata['message']
|
88
|
+
@connection.reset!
|
89
|
+
raise QueryError, "Failed to begin transaction: #{code} - #{message}"
|
90
|
+
else
|
91
|
+
raise ProtocolError, "Unexpected response to BEGIN: #{response.class}"
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Marks a transaction as completed and removes it from the session.
|
96
|
+
#
|
97
|
+
# @param transaction [Transaction] The transaction to complete.
|
98
|
+
# @param new_bookmarks [Array<String>] New bookmarks to update.
|
99
|
+
def complete_transaction(transaction, new_bookmarks = nil)
|
100
|
+
return unless transaction == @current_transaction
|
101
|
+
|
102
|
+
@current_transaction = nil
|
103
|
+
@bookmarks = new_bookmarks if new_bookmarks
|
104
|
+
end
|
105
|
+
|
106
|
+
# Execute a block of code within a transaction.
|
107
|
+
#
|
108
|
+
# @param mode [Symbol] The access mode (:read or :write).
|
109
|
+
# @param db [String] The database name to run the transaction against.
|
110
|
+
# @param timeout [Integer] Transaction timeout in milliseconds.
|
111
|
+
# @param metadata [Hash] Transaction metadata to send to the server.
|
112
|
+
# @yield [tx] The transaction to use for queries.
|
113
|
+
# @return The result of the block.
|
114
|
+
def run_transaction(mode = :write, db: nil, timeout: nil, metadata: nil, &)
|
115
|
+
# Ensure we're running in an Async context
|
116
|
+
if Async::Task.current?
|
117
|
+
# Already in an Async task, proceed normally
|
118
|
+
begin
|
119
|
+
execute_transaction(mode, db: db, timeout: timeout, metadata: metadata, &)
|
120
|
+
rescue StandardError => e
|
121
|
+
# Ensure errors are properly propagated
|
122
|
+
raise e
|
123
|
+
end
|
124
|
+
else
|
125
|
+
# Wrap in an Async task
|
126
|
+
result = nil
|
127
|
+
error = nil
|
128
|
+
|
129
|
+
Async do
|
130
|
+
result = execute_transaction(mode, db: db, timeout: timeout, metadata: metadata, &)
|
131
|
+
rescue StandardError => e
|
132
|
+
error = e
|
133
|
+
end.wait
|
134
|
+
|
135
|
+
# Re-raise any error outside the async block
|
136
|
+
raise error if error
|
137
|
+
|
138
|
+
result
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
# Helper method to execute the transaction
|
143
|
+
def execute_transaction(mode, db:, timeout:, metadata:)
|
144
|
+
tx = begin_transaction(db: db,
|
145
|
+
access_mode: mode,
|
146
|
+
tx_timeout: timeout,
|
147
|
+
tx_metadata: metadata)
|
148
|
+
|
149
|
+
result = yield tx # your block runs here
|
150
|
+
tx.commit # happy path
|
151
|
+
result
|
152
|
+
rescue StandardError => e # any error → rollback → wrap
|
153
|
+
begin
|
154
|
+
tx.rollback
|
155
|
+
rescue StandardError => rollback_error
|
156
|
+
# Log rollback error but continue with the original error
|
157
|
+
puts "Error during rollback: #{rollback_error.message}" if ENV['DEBUG']
|
158
|
+
end
|
159
|
+
|
160
|
+
# Preserve the original error
|
161
|
+
raise ActiveCypher::TransactionError, e.message
|
162
|
+
end
|
163
|
+
|
164
|
+
def write_transaction(db: nil, timeout: nil, metadata: nil, &)
|
165
|
+
run_transaction(:write, db: db, timeout: timeout, metadata: metadata, &)
|
166
|
+
end
|
167
|
+
|
168
|
+
def read_transaction(db: nil, timeout: nil, metadata: nil, &)
|
169
|
+
run_transaction(:read, db: db, timeout: timeout, metadata: metadata, &)
|
170
|
+
end
|
171
|
+
|
172
|
+
# Access the current bookmarks for this session.
|
173
|
+
def bookmarks
|
174
|
+
@bookmarks || []
|
175
|
+
end
|
176
|
+
|
177
|
+
# Update session bookmarks for causal consistency.
|
178
|
+
attr_writer :bookmarks
|
179
|
+
|
180
|
+
# Reset any session and transaction state (e.g., after errors).
|
181
|
+
def reset
|
182
|
+
return if @current_transaction.nil?
|
183
|
+
|
184
|
+
# Mark the current transaction as no longer active
|
185
|
+
complete_transaction(@current_transaction)
|
186
|
+
|
187
|
+
# Reset the connection
|
188
|
+
@connection.reset!
|
189
|
+
end
|
190
|
+
|
191
|
+
# Close the session and any active transaction.
|
192
|
+
def close
|
193
|
+
# If there's an active transaction, try to roll it back
|
194
|
+
@current_transaction&.rollback if @current_transaction&.active?
|
195
|
+
|
196
|
+
# Mark current transaction as complete
|
197
|
+
complete_transaction(@current_transaction) if @current_transaction
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|