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,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
|