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.
Files changed (97) 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 +50 -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 +28 -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/lib/tasks/active_cypher_tasks.rake +6 -0
  96. data/sig/activecypher.rbs +4 -0
  97. metadata +173 -10
@@ -0,0 +1,525 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async'
4
+ require 'io/endpoint'
5
+ require 'io/endpoint/host_endpoint'
6
+ require 'io/endpoint/ssl_endpoint'
7
+
8
+ require 'io/stream'
9
+ require 'socket'
10
+ require 'stringio'
11
+
12
+ module ActiveCypher
13
+ module Bolt
14
+ class Connection
15
+ include VersionEncoding
16
+
17
+ attr_reader :host, :port, :timeout_seconds, :socket,
18
+ :protocol_version, :server_agent, :connection_id, :adapter
19
+
20
+ SUPPORTED_VERSIONS = [5.8, 5.2].freeze
21
+
22
+ # Initializes a new Bolt connection.
23
+ #
24
+ # @param host [String] the database host
25
+ # @param port [Integer] the database port
26
+ # @param adapter [Object] the adapter using this connection
27
+ # @param auth_token [Hash] authentication token
28
+ # @param timeout_seconds [Integer] connection timeout in seconds
29
+ # @param secure [Boolean] whether to use SSL
30
+ # @param verify_cert [Boolean] whether to verify SSL certificates
31
+ #
32
+ # @note The ceremony required to instantiate a connection. Because nothing says “enterprise” like 8 arguments.
33
+ def initialize(host, port, adapter,
34
+ auth_token:, timeout_seconds: 15,
35
+ secure: false, verify_cert: true)
36
+ @host = host
37
+ @port = port
38
+ @auth_token = auth_token
39
+ @timeout_seconds = timeout_seconds
40
+ @secure = secure
41
+ @verify_cert = verify_cert
42
+ @adapter = adapter
43
+
44
+ @socket = nil
45
+ @connected = false
46
+ @protocol_version = nil
47
+ @server_agent = nil
48
+ @connection_id = nil
49
+ @reconnect_attempts = 0
50
+ @max_reconnect_attempts = 3
51
+ end
52
+
53
+ # ───────────────────────── connection lifecycle ────────────── #
54
+
55
+ # Establishes the connection to the database.
56
+ #
57
+ # @raise [ConnectionError] if the connection fails
58
+ #
59
+ # @note Attempts to connect, or at least to feel something.
60
+ def connect
61
+ return if connected?
62
+
63
+ # Using a variable to track errors instead of re-raising inside the Async block
64
+ error = nil
65
+
66
+ begin
67
+ Async do |task|
68
+ task.with_timeout(@timeout_seconds) do
69
+ @socket = open_socket
70
+ perform_handshake
71
+ @connected = true
72
+ @reconnect_attempts = 0
73
+ end
74
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
75
+ SocketError, OpenSSL::SSL::SSLError => e
76
+ # Catch connection errors inside the task
77
+ close
78
+ # Store the error instead of raising
79
+ error = ConnectionError.new("Failed to connect to #{host}:#{port} - #{e.message}")
80
+ rescue StandardError => e
81
+ # Catch any other errors inside the task
82
+ close
83
+ # Store the error instead of raising
84
+ error = ConnectionError.new("Error during connection: #{e.message}")
85
+ end.wait
86
+ rescue Async::TimeoutError => e
87
+ error = ConnectionError.new("Connection timed out to #{host}:#{port} - #{e.message}")
88
+ rescue StandardError => e
89
+ close
90
+ error = ConnectionError.new("Connection error: #{e.message}")
91
+ end
92
+
93
+ # After the Async block is complete, raise the error if one occurred
94
+ raise error if error
95
+ end
96
+
97
+ # Writes raw bytes directly to the socket.
98
+ #
99
+ # @param bytes [String] the bytes to write
100
+ # @raise [ConnectionError] if the socket is not open
101
+ #
102
+ # @note Because sometimes you just want to feel close to the metal.
103
+ def write_raw(bytes)
104
+ raise ConnectionError, 'Socket not open for writing' unless socket_open?
105
+
106
+ @socket.write(bytes) # Async::IO::Socket yields if blocked.
107
+ rescue IOError, Errno::EPIPE => e
108
+ close
109
+ raise ConnectionError, "Connection lost during raw write: #{e.message}"
110
+ end
111
+
112
+ # Closes the TCP connection if it's open.
113
+ #
114
+ # @note The digital equivalent of ghosting.
115
+ def close
116
+ @socket&.close if connected?
117
+ rescue IOError
118
+ ensure
119
+ @socket = nil
120
+ @connected = false
121
+ end
122
+
123
+ # Checks if the connection is open and the socket is alive.
124
+ #
125
+ # @return [Boolean]
126
+ # @note Checks if we're still pretending to be connected.
127
+ def connected? = @connected && socket_open?
128
+
129
+ # Attempts to reconnect if the connection is lost.
130
+ #
131
+ # @return [Boolean] True if reconnection was successful, false otherwise
132
+ # @note Attempts to reconnect, because hope springs eternal.
133
+ def reconnect
134
+ return true if connected?
135
+
136
+ @reconnect_attempts += 1
137
+ if @reconnect_attempts <= @max_reconnect_attempts
138
+ close
139
+ begin
140
+ connect
141
+ # Reset reconnect counter on successful connection
142
+ @reconnect_attempts = 0
143
+ true
144
+ rescue ConnectionError => e
145
+ # Log the error but don't raise
146
+ puts "Reconnection attempt #{@reconnect_attempts}/#{@max_reconnect_attempts} failed: #{e.message}" if ENV['DEBUG']
147
+ # Sleep to avoid hammering the server
148
+ sleep(0.1 * @reconnect_attempts)
149
+ false
150
+ end
151
+ else
152
+ # Reset the counter after max attempts to allow future reconnects
153
+ @reconnect_attempts = 0
154
+ false
155
+ end
156
+ end
157
+
158
+ # Writes data to the socket.
159
+ #
160
+ # @param data [String] the data to write
161
+ # @raise [ConnectionError] if not connected or write fails
162
+ #
163
+ # @note Because nothing says "robust" like a method that can explode at any time.
164
+ def write(data)
165
+ raise ConnectionError, 'Not connected' unless connected?
166
+
167
+ @socket.write(data)
168
+ rescue Errno::EPIPE, IOError => e
169
+ close
170
+ raise ConnectionError, "Connection lost during write: #{e.message}"
171
+ end
172
+
173
+ # Reads data from the socket.
174
+ #
175
+ # @param length [Integer] number of bytes to read
176
+ # @raise [ConnectionError] if not connected or read fails
177
+ #
178
+ # @note Reading from the void, hoping something meaningful comes back.
179
+ def read(length)
180
+ raise ConnectionError, 'Not connected' unless connected?
181
+
182
+ @socket.read_exactly(length)
183
+ rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError => e
184
+ close
185
+ raise ConnectionError, "Connection lost during read: #{e.message}"
186
+ end
187
+
188
+ # Debug output for those who enjoy hexadecimal existentialism.
189
+ #
190
+ # @param label [String]
191
+ # @param bytes [String]
192
+ def dump(label, bytes)
193
+ puts "[DEBUG] #{label.ljust(18)}: #{bytes.bytes.map { |b| b.to_s(16).rjust(2, '0') }.join(' ')}" if ENV['DEBUG']
194
+ end
195
+
196
+ # A single Bolt socket is strictly single‑plex:
197
+ #
198
+ # @return [Integer] always 1, because concurrency is for people with more optimistic protocols.
199
+ def concurrency = 1
200
+
201
+ # Re‑use only if still alive:
202
+ #
203
+ # @return [Boolean]
204
+ def reusable? = connected?
205
+
206
+ # This method is required by Async::Pool to check if the connection is viable for reuse
207
+ #
208
+ # @return [Boolean]
209
+ # @note The database equivalent of "are you still there?"
210
+ def viable?
211
+ return false unless connected?
212
+
213
+ # Perform a lightweight check to verify the connection is still functional
214
+ begin
215
+ # Try to send a simple NOOP query to check connection health
216
+ write_message(Messaging::Run.new('RETURN 1', {}, {}), 'VIABILITY_CHECK')
217
+ read_message
218
+
219
+ # Reset the connection state
220
+ reset!
221
+
222
+ # If we got a successful response, the connection is viable
223
+ true
224
+ rescue ConnectionError, ProtocolError
225
+ # If the connection is broken, close it and return false
226
+ close
227
+ false
228
+ rescue StandardError
229
+ # For any other errors, also consider the connection non-viable
230
+ close
231
+ false
232
+ end
233
+ end
234
+
235
+ # Performs the Bolt handshake sequence.
236
+ #
237
+ # @raise [ProtocolError, ConnectionError] on failure
238
+ #
239
+ # @note The digital equivalent of a secret handshake, but with more bytes and less trust.
240
+ def perform_handshake
241
+ # Bolt Magic Preamble (0x6060B017)
242
+ magic = "\x60\x60\xB0\x17"
243
+ dump('Magic', magic)
244
+ write_raw(magic)
245
+
246
+ # Proposed Bolt Versions (ordered by preference)
247
+ # Encoded as 4‑byte big‑endian integers
248
+ proposed_versions = (SUPPORTED_VERSIONS + [0, 0])[0, 4]
249
+ versions = proposed_versions.map { |v| encode_version(v) }.join
250
+ dump('Sending versions', versions)
251
+ write_raw(versions)
252
+
253
+ # Read agreed version (4 bytes)
254
+ agreed_version_bytes = read_raw(4)
255
+ dump('Agreed version', agreed_version_bytes)
256
+ @protocol_version = decode_version(agreed_version_bytes)
257
+
258
+ # Validate agreed version
259
+ unless SUPPORTED_VERSIONS.include?(@protocol_version)
260
+ close
261
+ raise ProtocolError,
262
+ "Server only supports unsupported Bolt protocol (#{@protocol_version}). This client requires one of: #{SUPPORTED_VERSIONS.join(', ')}"
263
+ end
264
+
265
+ # Send HELLO message
266
+ send_hello
267
+
268
+ # Read response (should be SUCCESS or FAILURE)
269
+ response = begin
270
+ msg = read_message
271
+ msg
272
+ rescue EOFError => e
273
+ raise ConnectionError, "Server closed connection: #{e.message}"
274
+ end
275
+
276
+ case response
277
+ when Messaging::Success
278
+ handle_hello_success(response.metadata)
279
+
280
+ # if auth credentials were provided, send LOGON
281
+ send_logon if @auth_token && @auth_token[:scheme] == 'basic'
282
+
283
+ # Let adapter create protocol handler instead of directly instantiating
284
+ @protocol_handler = @adapter.create_protocol_handler(self)
285
+ when Messaging::Failure
286
+ handle_hello_failure(response.metadata)
287
+ else
288
+ close
289
+ raise ProtocolError, "Unexpected response during handshake: #{response.class}"
290
+ end
291
+ rescue ConnectionError, ProtocolError => e
292
+ close
293
+ raise e
294
+ rescue StandardError => e
295
+ close
296
+ raise ConnectionError, "Handshake error: #{e.message}"
297
+ end
298
+
299
+ # Sends the HELLO message.
300
+ #
301
+ # @note Because every protocol needs a little small talk before the pain begins.
302
+ def send_hello
303
+ user_agent = "ActiveCypher::Bolt/#{ActiveCypher::VERSION} (Ruby/#{RUBY_VERSION})"
304
+ platform = RUBY_DESCRIPTION.split[1..].join(' ') # Gets everything after "ruby" in RUBY_DESCRIPTION
305
+ metadata = {
306
+ 'user_agent' => user_agent,
307
+ 'notifications_minimum_severity' => 'WARNING',
308
+ 'bolt_agent' => {
309
+ 'product' => user_agent,
310
+ 'platform' => platform,
311
+ 'language' => "#{RUBY_PLATFORM}/#{RUBY_VERSION}",
312
+ 'language_details' => "#{RUBY_ENGINE} #{RUBY_ENGINE_VERSION}"
313
+ }
314
+ }
315
+ hello_message = Messaging::Hello.new(metadata)
316
+ write_message(hello_message, 'HELLO')
317
+ end
318
+
319
+ # Sends the LOGON message.
320
+ #
321
+ # @note Because authentication is just another opportunity for disappointment.
322
+ def send_logon
323
+ # Get credentials from the connection's auth token
324
+ metadata = {
325
+ 'scheme' => @auth_token[:scheme],
326
+ 'principal' => @auth_token[:principal],
327
+ 'credentials' => @auth_token[:credentials]
328
+ }
329
+
330
+ # Create and send LOGON message
331
+ begin
332
+ logon_msg = Messaging::Logon.new(metadata)
333
+ write_message(logon_msg, 'LOGON')
334
+
335
+ # Read and process response
336
+ logon_response = read_message
337
+
338
+ case logon_response
339
+ when Messaging::Success
340
+ true
341
+ when Messaging::Failure
342
+ code = logon_response.metadata['code']
343
+ message = logon_response.metadata['message']
344
+ close
345
+ raise ConnectionError, "Authentication failed during LOGON: #{code} - #{message}"
346
+ else
347
+ close
348
+ raise ProtocolError, "Unexpected response to LOGON: #{logon_response.class}"
349
+ end
350
+ rescue StandardError => e
351
+ close
352
+ raise ConnectionError, "Authentication error: #{e.message}"
353
+ end
354
+ end
355
+
356
+ # Handles a SUCCESS response to HELLO.
357
+ #
358
+ # @note The rarest of all outcomes.
359
+ def handle_hello_success(metadata)
360
+ @connection_id = metadata['connection_id']
361
+ @server_agent = metadata['server']
362
+ end
363
+
364
+ # Handles a FAILURE response to HELLO.
365
+ #
366
+ # @note The more common outcome.
367
+ def handle_hello_failure(metadata)
368
+ code = metadata['code']
369
+ message = metadata['message']
370
+ close
371
+ raise ConnectionError, "Authentication failed: #{code} - #{message}"
372
+ end
373
+
374
+ # Writes a Bolt message using the MessageWriter, adding Bolt chunking.
375
+ #
376
+ # @param message [Object] the Bolt message to write
377
+ # @param debug_label [String, nil] optional debug label
378
+ # @raise [ProtocolError] if writing fails
379
+ #
380
+ # @note Because nothing says "enterprise" like chunked binary messages.
381
+ def write_message(message, debug_label = nil)
382
+ raise ConnectionError, 'Socket not open for writing' unless socket_open?
383
+
384
+ if message.is_a?(ActiveCypher::Bolt::Messaging::Run)
385
+ dump '→ RUN', " #{message.fields[0]} #{message.fields[2].inspect}" # query & metadata
386
+ end
387
+ # 1. Pack the message into a temporary buffer
388
+ message_io = StringIO.new(+'', 'wb')
389
+ writer = MessageWriter.new(message_io)
390
+ writer.write(message)
391
+ message_bytes = message_io.string
392
+ message_size = message_bytes.bytesize
393
+
394
+ # Debug output if a label was provided
395
+ dump(debug_label, message_bytes) if debug_label
396
+
397
+ # 2. Write the chunk header and data
398
+ chunk_header = [message_size].pack('n')
399
+ write_raw(chunk_header)
400
+ write_raw(message_bytes)
401
+ write_raw("\x00\x00") # Chunk terminator
402
+
403
+ # Ensure everything is sent
404
+ @socket.flush
405
+ rescue StandardError => e
406
+ close
407
+ raise ProtocolError, "Failed to write message: #{e.message}"
408
+ end
409
+
410
+ # Reads a Bolt message using the MessageReader.
411
+ #
412
+ # @return [Object] the Bolt message
413
+ # @raise [ConnectionError, ProtocolError] if reading fails
414
+ #
415
+ # @note Reads from the abyss and hopes for a message, not a void.
416
+ def read_message
417
+ raise ConnectionError, 'Socket not open for reading' unless socket_open?
418
+
419
+ reader = MessageReader.new(@socket)
420
+ reader.read_message
421
+ rescue ConnectionError, ProtocolError => e
422
+ close
423
+ raise e
424
+ rescue EOFError => e
425
+ close
426
+ raise ConnectionError, "Connection closed unexpectedly: #{e.message}"
427
+ end
428
+
429
+ # Access to the protocol handler
430
+ attr_reader :protocol_handler
431
+
432
+ # Resets the connection state.
433
+ #
434
+ # @return [Boolean] true if reset succeeded, false otherwise
435
+ # @note For when you want to pretend nothing ever happened.
436
+ def reset!
437
+ return false unless connected?
438
+
439
+ begin
440
+ write_message(ActiveCypher::Bolt::Messaging::Reset.new)
441
+ msg = read_message # should be Messaging::Success
442
+ msg.is_a?(ActiveCypher::Bolt::Messaging::Success)
443
+ rescue ConnectionError, ProtocolError
444
+ # If reset fails, close the connection
445
+ close
446
+ false
447
+ rescue StandardError
448
+ # For any other errors, also close the connection
449
+ close
450
+ false
451
+ end
452
+ end
453
+
454
+ # Returns a fresh Session object that re‑uses this TCP/Bolt socket.
455
+ #
456
+ # @param **kwargs passed to the Session initializer
457
+ # @return [Bolt::Session]
458
+ # @note Because every connection deserves a second chance.
459
+ def session(**)
460
+ Bolt::Session.new(self, **)
461
+ end
462
+
463
+ # ────────────────────────────────────────────────────────────────────
464
+ # PRIVATE HELPER METHODS
465
+ # ────────────────────────────────────────────────────────────────────
466
+ private
467
+
468
+ # Opens a non‑blocking TCP socket wrapped by Async.
469
+ #
470
+ # @return [IO::Stream] the opened socket
471
+ # @raise [ConnectionError] if connection fails
472
+ # @note Because blocking is for people who like waiting.
473
+ def open_socket
474
+ endpoint =
475
+ if @secure
476
+ ctx = OpenSSL::SSL::SSLContext.new
477
+ ctx.verify_mode = @verify_cert ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
478
+ IO::Endpoint.ssl(@host, @port,
479
+ ssl_context: ctx,
480
+ hostname: @host)
481
+ else
482
+ IO::Endpoint.tcp(@host, @port)
483
+ end
484
+
485
+ # Ensure all exceptions are caught and wrapped appropriately
486
+ begin
487
+ endpoint.connect.then { |io| IO::Stream(io) }
488
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
489
+ close
490
+ # Let the error propagate up to the connect method
491
+ raise ConnectionError, "Failed to connect to #{host}:#{port} - #{e.message}"
492
+ rescue StandardError => e
493
+ close
494
+ # Let the error propagate up to the connect method
495
+ raise ConnectionError, "Unexpected error connecting to #{host}:#{port} - #{e.message}"
496
+ end
497
+ end
498
+
499
+ # Reads exactly n raw bytes from the socket.
500
+ #
501
+ # @param n [Integer] number of bytes to read
502
+ # @return [String] the bytes read
503
+ # @raise [ConnectionError] if the socket is not open or read fails
504
+ # @note Because sometimes you want exactly n bytes, not a byte more, not a byte less.
505
+ def read_raw(n)
506
+ raise ConnectionError, 'Socket not open for reading' unless socket_open?
507
+
508
+ data = @socket.read_exactly(n) # Will yield until n bytes ready.
509
+ raise EOFError, "Connection closed while reading #{n} bytes" if data.nil?
510
+ raise ProtocolError, "Expected #{n} bytes, got #{data.bytesize}" if data.bytesize != n
511
+
512
+ data
513
+ rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError => e
514
+ close
515
+ raise ConnectionError, "Connection lost during raw read: #{e.message}"
516
+ end
517
+
518
+ # Internal check if the socket exists and is not closed.
519
+ #
520
+ # @return [Boolean]
521
+ # @note The Schrödinger's cat of sockets.
522
+ def socket_open? = @socket && !@socket.closed?
523
+ end
524
+ end
525
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'async'
5
+ require 'async/pool'
6
+
7
+ module ActiveCypher
8
+ module Bolt
9
+ # @!parse
10
+ # # The Bolt Driver manages connection pooling and session creation for Bolt protocol.
11
+ # # Because apparently every ORM needs to reinvent connection pooling, but with more existential dread.
12
+ class Driver
13
+ DEFAULT_POOL_SIZE = ENV.fetch('BOLT_POOL_SIZE', 10).to_i
14
+
15
+ # Map URI schemes ➞ security/verification flags
16
+ # Because nothing says "enterprise" like six ways to spell 'bolt'.
17
+ SCHEMES = {
18
+ 'bolt' => { secure: false, verify: true },
19
+ 'bolt+s' => { secure: true, verify: true },
20
+ 'bolt+ssc' => { secure: true, verify: false },
21
+ 'neo4j' => { secure: false, verify: true },
22
+ 'neo4j+s' => { secure: true, verify: true },
23
+ 'neo4j+ssc' => { secure: true, verify: false }
24
+ }.freeze
25
+
26
+ # Initializes the driver, because you can't spell "abstraction" without "action".
27
+ #
28
+ # @param uri [String] The Bolt URI
29
+ # @param adapter [Object] The adapter instance
30
+ # @param auth_token [Hash] Authentication token
31
+ # @param pool_size [Integer] Connection pool size
32
+ def initialize(uri:, adapter:, auth_token:, pool_size: DEFAULT_POOL_SIZE)
33
+ @uri = URI(uri)
34
+ scheme = SCHEMES.fetch(@uri.scheme) { raise ArgumentError, "Unsupported Bolt scheme: #{@uri.scheme}" }
35
+
36
+ @adapter = adapter
37
+ @auth = auth_token
38
+ @secure = scheme[:secure]
39
+ @verify_cert = scheme[:verify]
40
+
41
+ # Create a connection pool with the specified size
42
+ # Because one connection is never enough for true disappointment.
43
+ @pool = Async::Pool::Controller.wrap(
44
+ limit: pool_size
45
+ ) { build_connection }
46
+ end
47
+
48
+ # Yields a Session. Works inside or outside an Async reactor.
49
+ # Because sometimes you want async, and sometimes you just want to feel something.
50
+ #
51
+ # @yieldparam session [Bolt::Session] The session to use
52
+ # @return [Object] The result of the block
53
+ def with_session(**kw)
54
+ if Async::Task.current?
55
+ # We're already in an Async context, use the pool directly
56
+ @pool.acquire do |conn|
57
+ # Check if connection is viable before using it
58
+ unless conn.viable?
59
+ # Create a fresh connection, because hope springs eternal
60
+ begin
61
+ conn.close
62
+ rescue StandardError
63
+ nil
64
+ end
65
+ conn = build_connection
66
+ end
67
+
68
+ yield Bolt::Session.new(conn, **kw)
69
+ end
70
+ else
71
+ # We're not in an Async context, create one and wait
72
+ Async do
73
+ @pool.acquire do |conn|
74
+ # Check if connection is viable before using it
75
+ unless conn.viable?
76
+ # Create a fresh connection, because why not
77
+ begin
78
+ conn.close
79
+ rescue StandardError
80
+ nil
81
+ end
82
+ conn = build_connection
83
+ end
84
+
85
+ yield Bolt::Session.new(conn, **kw)
86
+ end
87
+ end.wait
88
+ end
89
+ rescue Async::TimeoutError => e
90
+ raise ActiveCypher::ConnectionError, "Connection pool timeout: #{e.message}"
91
+ rescue StandardError => e
92
+ raise ActiveCypher::ConnectionError, "Connection error: #{e.message}"
93
+ end
94
+
95
+ # Checks if the database is alive, or just faking it for your benefit.
96
+ #
97
+ # @return [Boolean]
98
+ def verify_connectivity
99
+ with_session { |s| s.run('RETURN 1') }
100
+ true
101
+ rescue StandardError
102
+ false
103
+ end
104
+
105
+ # Closes the connection pool. Because sometimes you just need to let go.
106
+ def close
107
+ @pool.close
108
+ rescue StandardError => e
109
+ # Log but don't raise to ensure we don't prevent cleanup
110
+ puts "Warning: Error while closing connection pool: #{e.message}" if ENV['DEBUG']
111
+ end
112
+
113
+ private
114
+
115
+ # Builds a new connection, because the old one just wasn't good enough.
116
+ #
117
+ # @return [Connection]
118
+ def build_connection
119
+ connection = Connection.new(
120
+ @uri.host,
121
+ @uri.port || 7687,
122
+ @adapter,
123
+ auth_token: @auth,
124
+ timeout_seconds: 15,
125
+ secure: @secure,
126
+ verify_cert: @verify_cert
127
+ )
128
+
129
+ begin
130
+ connection.connect
131
+ rescue StandardError => e
132
+ begin
133
+ connection.close
134
+ rescue StandardError
135
+ nil
136
+ end
137
+ raise e
138
+ end
139
+
140
+ connection
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCypher
4
+ module Bolt
5
+ # Contains handlers for processing sequences of Bolt responses.
6
+ module Handlers
7
+ # TODO: Implement response sequence handlers (e.g., for RUN/PULL)
8
+ end
9
+ end
10
+ end