activecypher 0.0.0 → 0.3.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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_cypher/associations/collection_proxy.rb +144 -0
  3. data/lib/active_cypher/associations.rb +537 -0
  4. data/lib/active_cypher/base.rb +47 -0
  5. data/lib/active_cypher/bolt/connection.rb +525 -0
  6. data/lib/active_cypher/bolt/driver.rb +144 -0
  7. data/lib/active_cypher/bolt/handlers.rb +10 -0
  8. data/lib/active_cypher/bolt/message_reader.rb +100 -0
  9. data/lib/active_cypher/bolt/message_writer.rb +53 -0
  10. data/lib/active_cypher/bolt/messaging.rb +307 -0
  11. data/lib/active_cypher/bolt/packstream.rb +319 -0
  12. data/lib/active_cypher/bolt/result.rb +82 -0
  13. data/lib/active_cypher/bolt/session.rb +201 -0
  14. data/lib/active_cypher/bolt/transaction.rb +211 -0
  15. data/lib/active_cypher/bolt/version_encoding.rb +41 -0
  16. data/lib/active_cypher/bolt.rb +7 -0
  17. data/lib/active_cypher/connection_adapters/abstract_adapter.rb +75 -0
  18. data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +178 -0
  19. data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
  20. data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +58 -0
  21. data/lib/active_cypher/connection_factory.rb +130 -0
  22. data/lib/active_cypher/connection_handler.rb +9 -0
  23. data/lib/active_cypher/connection_pool.rb +123 -0
  24. data/lib/active_cypher/connection_url_resolver.rb +137 -0
  25. data/lib/active_cypher/cypher_config.rb +61 -0
  26. data/lib/active_cypher/generators/install_generator.rb +23 -0
  27. data/lib/active_cypher/generators/node_generator.rb +32 -0
  28. data/lib/active_cypher/generators/relationship_generator.rb +33 -0
  29. data/lib/active_cypher/generators/templates/application_graph_node.rb +5 -0
  30. data/lib/active_cypher/generators/templates/application_graph_relationship.rb +5 -0
  31. data/lib/active_cypher/generators/templates/cypher_databases.yml +16 -0
  32. data/lib/active_cypher/generators/templates/node.rb.erb +10 -0
  33. data/lib/active_cypher/generators/templates/relationship.rb.erb +11 -0
  34. data/lib/active_cypher/logging.rb +44 -0
  35. data/lib/active_cypher/model/abstract.rb +87 -0
  36. data/lib/active_cypher/model/attributes.rb +24 -0
  37. data/lib/active_cypher/model/callbacks.rb +44 -0
  38. data/lib/active_cypher/model/connection_handling.rb +76 -0
  39. data/lib/active_cypher/model/connection_owner.rb +50 -0
  40. data/lib/active_cypher/model/core.rb +45 -0
  41. data/lib/active_cypher/model/countable.rb +30 -0
  42. data/lib/active_cypher/model/destruction.rb +49 -0
  43. data/lib/active_cypher/model/inspectable.rb +28 -0
  44. data/lib/active_cypher/model/persistence.rb +182 -0
  45. data/lib/active_cypher/model/querying.rb +67 -0
  46. data/lib/active_cypher/railtie.rb +34 -0
  47. data/lib/active_cypher/relation.rb +190 -0
  48. data/lib/active_cypher/relationship.rb +233 -0
  49. data/lib/active_cypher/runtime_registry.rb +8 -0
  50. data/lib/active_cypher/scoping.rb +97 -0
  51. data/lib/active_cypher/utils/logger.rb +100 -0
  52. data/lib/active_cypher/version.rb +5 -0
  53. data/lib/activecypher.rb +108 -0
  54. data/lib/cyrel/call_procedure.rb +29 -0
  55. data/lib/cyrel/clause/call.rb +46 -0
  56. data/lib/cyrel/clause/call_subquery.rb +40 -0
  57. data/lib/cyrel/clause/create.rb +33 -0
  58. data/lib/cyrel/clause/delete.rb +41 -0
  59. data/lib/cyrel/clause/limit.rb +33 -0
  60. data/lib/cyrel/clause/match.rb +40 -0
  61. data/lib/cyrel/clause/merge.rb +34 -0
  62. data/lib/cyrel/clause/order_by.rb +78 -0
  63. data/lib/cyrel/clause/remove.rb +75 -0
  64. data/lib/cyrel/clause/return.rb +90 -0
  65. data/lib/cyrel/clause/set.rb +97 -0
  66. data/lib/cyrel/clause/skip.rb +34 -0
  67. data/lib/cyrel/clause/where.rb +42 -0
  68. data/lib/cyrel/clause/with.rb +94 -0
  69. data/lib/cyrel/clause.rb +25 -0
  70. data/lib/cyrel/direction.rb +18 -0
  71. data/lib/cyrel/expression/alias.rb +27 -0
  72. data/lib/cyrel/expression/base.rb +101 -0
  73. data/lib/cyrel/expression/case.rb +45 -0
  74. data/lib/cyrel/expression/comparison.rb +60 -0
  75. data/lib/cyrel/expression/exists.rb +42 -0
  76. data/lib/cyrel/expression/function_call.rb +57 -0
  77. data/lib/cyrel/expression/literal.rb +33 -0
  78. data/lib/cyrel/expression/logical.rb +38 -0
  79. data/lib/cyrel/expression/operator.rb +27 -0
  80. data/lib/cyrel/expression/pattern_comprehension.rb +44 -0
  81. data/lib/cyrel/expression/property_access.rb +25 -0
  82. data/lib/cyrel/expression.rb +56 -0
  83. data/lib/cyrel/functions.rb +116 -0
  84. data/lib/cyrel/node.rb +397 -0
  85. data/lib/cyrel/parameterizable.rb +20 -0
  86. data/lib/cyrel/pattern/node.rb +66 -0
  87. data/lib/cyrel/pattern/path.rb +41 -0
  88. data/lib/cyrel/pattern/relationship.rb +74 -0
  89. data/lib/cyrel/pattern.rb +8 -0
  90. data/lib/cyrel/query.rb +497 -0
  91. data/lib/cyrel/return_only.rb +26 -0
  92. data/lib/cyrel/types/hash_type.rb +22 -0
  93. data/lib/cyrel/types/symbol_type.rb +13 -0
  94. data/lib/cyrel.rb +72 -0
  95. data/sig/activecypher.rbs +4 -0
  96. metadata +172 -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