boltless 1.0.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 +7 -0
- data/Gemfile +8 -0
- data/Guardfile +44 -0
- data/Makefile +138 -0
- data/Rakefile +26 -0
- data/docker-compose.yml +19 -0
- data/lib/boltless/configuration.rb +69 -0
- data/lib/boltless/errors/invalid_json_error.rb +9 -0
- data/lib/boltless/errors/request_error.rb +24 -0
- data/lib/boltless/errors/response_error.rb +30 -0
- data/lib/boltless/errors/transaction_begin_error.rb +9 -0
- data/lib/boltless/errors/transaction_in_bad_state_error.rb +11 -0
- data/lib/boltless/errors/transaction_not_found_error.rb +11 -0
- data/lib/boltless/errors/transaction_rollback_error.rb +26 -0
- data/lib/boltless/extensions/configuration_handling.rb +37 -0
- data/lib/boltless/extensions/connection_pool.rb +127 -0
- data/lib/boltless/extensions/operations.rb +175 -0
- data/lib/boltless/extensions/transactions.rb +301 -0
- data/lib/boltless/extensions/utilities.rb +187 -0
- data/lib/boltless/request.rb +386 -0
- data/lib/boltless/result.rb +98 -0
- data/lib/boltless/result_row.rb +90 -0
- data/lib/boltless/statement_collector.rb +40 -0
- data/lib/boltless/transaction.rb +234 -0
- data/lib/boltless/version.rb +23 -0
- data/lib/boltless.rb +36 -0
- data/spec/benchmark/transfer.rb +57 -0
- data/spec/boltless/extensions/configuration_handling_spec.rb +39 -0
- data/spec/boltless/extensions/connection_pool_spec.rb +131 -0
- data/spec/boltless/extensions/operations_spec.rb +189 -0
- data/spec/boltless/extensions/transactions_spec.rb +418 -0
- data/spec/boltless/extensions/utilities_spec.rb +546 -0
- data/spec/boltless/request_spec.rb +946 -0
- data/spec/boltless/result_row_spec.rb +161 -0
- data/spec/boltless/result_spec.rb +127 -0
- data/spec/boltless/statement_collector_spec.rb +45 -0
- data/spec/boltless/transaction_spec.rb +601 -0
- data/spec/boltless_spec.rb +11 -0
- data/spec/fixtures/files/raw_result.yml +21 -0
- data/spec/fixtures/files/raw_result_with_graph_result.yml +48 -0
- data/spec/fixtures/files/raw_result_with_meta.yml +11 -0
- data/spec/fixtures/files/raw_result_with_stats.yml +26 -0
- data/spec/spec_helper.rb +89 -0
- metadata +384 -0
@@ -0,0 +1,386 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Boltless
|
4
|
+
# A neo4j HTTP API request abstraction class, which consumes a single HTTP
|
5
|
+
# persistent connection for its whole runtime. This connection is strictly
|
6
|
+
# owned by a single request object. It is not safe to share it.
|
7
|
+
#
|
8
|
+
# rubocop:disable Metrics/ClassLength because of the isolated
|
9
|
+
# request abstraction
|
10
|
+
class Request
|
11
|
+
class << self
|
12
|
+
# Convert a multiple Cypher queries and +Hash+ arguments into multiple
|
13
|
+
# HTTP API/Cypher transaction API compatible hashes.
|
14
|
+
#
|
15
|
+
# @param statements [Array<Array<String, Hash{Symbol => Mixed}>>] the
|
16
|
+
# Cypher statements to convert
|
17
|
+
# @return [Array<Hash{Symbol => Mixed}>] the compatible statement objects
|
18
|
+
def statement_payloads(*statements)
|
19
|
+
statements.map do |(cypher, args)|
|
20
|
+
statement_payload(cypher, **(args || {}))
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Convert a single Cypher query string and +Hash+ arguments into a HTTP
|
25
|
+
# API/Cypher transaction API compatible form.
|
26
|
+
#
|
27
|
+
# @param cypher [String] the Cypher statement to run
|
28
|
+
# @param args [Hash{Symbol => Mixed}] the additional Cypher parameters
|
29
|
+
# @return [Hash{Symbol => Mixed}] the compatible statement object
|
30
|
+
def statement_payload(cypher, **args)
|
31
|
+
{ statement: cypher }.tap do |payload|
|
32
|
+
# Enable the statement statistics if requested
|
33
|
+
payload[:includeStats] = true if args.delete(:with_stats) == true
|
34
|
+
|
35
|
+
# Enable the graphing output if request
|
36
|
+
payload[:resultDataContents] = %w[row graph] \
|
37
|
+
if args.delete(:result_as_graph) == true
|
38
|
+
|
39
|
+
payload[:parameters] = args
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Setup a new neo4j request instance with the given connection to use.
|
45
|
+
#
|
46
|
+
# @param connection [HTTP::Client] a ready to use persistent
|
47
|
+
# connection object
|
48
|
+
# @param access_mode [String, Symbol] the neo4j
|
49
|
+
# transaction mode (+:read+, or +:write+)
|
50
|
+
# @param database [String, Symbol] the neo4j database to use
|
51
|
+
# @param raw_results [Boolean] whenever to return the plain HTTP API JSON
|
52
|
+
# results (as plain +Hash{Symbol => Mixed}/Array+ data), or not (then we
|
53
|
+
# return +Array<Boltless::Result>+ structs
|
54
|
+
def initialize(connection, access_mode: :write,
|
55
|
+
database: Boltless.configuration.default_db,
|
56
|
+
raw_results: false)
|
57
|
+
# Check the given access mode
|
58
|
+
@access_mode = mode = access_mode.to_s.upcase
|
59
|
+
unless %(READ WRITE).include? mode
|
60
|
+
raise ArgumentError, "Unknown access mode '#{access_mode}'. " \
|
61
|
+
"Use ':read' or ':write'."
|
62
|
+
end
|
63
|
+
|
64
|
+
@connection = connection
|
65
|
+
@path_prefix = "/db/#{database}"
|
66
|
+
@raw_results = raw_results
|
67
|
+
@requests_done = 0
|
68
|
+
|
69
|
+
# Make sure the upstream server is ready to rumble
|
70
|
+
Boltless.wait_for_server!(connection)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Run one/multiple Cypher statements inside a one-shot transaction.
|
74
|
+
# A new transaction is opened, the statements are run and the transaction
|
75
|
+
# is commited in a single HTTP request for efficiency.
|
76
|
+
#
|
77
|
+
# @param statements [Array<Hash>] the Cypher statements to run
|
78
|
+
# @return [Array<Hash{Symbol => Mixed}>] the raw neo4j results
|
79
|
+
#
|
80
|
+
# @raise [Errors::TransactionNotFoundError] when no open transaction
|
81
|
+
# was found by the given identifier
|
82
|
+
# @raise [Errors::TransactionRollbackError] when there was an error while
|
83
|
+
# committing the transaction, we assume that any error causes a
|
84
|
+
# transaction rollback at the neo4j side
|
85
|
+
def one_shot_transaction(*statements)
|
86
|
+
# We do not allow to send a run-request without Cypher statements
|
87
|
+
raise ArgumentError, 'No statements given' if statements.empty?
|
88
|
+
|
89
|
+
log_query(nil, *statements) do
|
90
|
+
handle_transaction(tx_id: 'commit') do |path|
|
91
|
+
@connection.headers('Access-Mode' => @access_mode)
|
92
|
+
.post(path, body: serialize_body(statements: statements))
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# Start a new transaction within our dedicated HTTP connection object at
|
98
|
+
# the neo4j server. When everything is fine, we return the transaction
|
99
|
+
# identifier from neo4j for further usage.
|
100
|
+
#
|
101
|
+
# @return [Integer] the neo4j transaction identifier
|
102
|
+
# @raise [Errors::TransactionBeginError] when we fail to start a
|
103
|
+
# new transaction
|
104
|
+
#
|
105
|
+
# rubocop:disable Metrics/MethodLength because of the error
|
106
|
+
# handlings and transaction identifier parsing
|
107
|
+
# rubocop:disable Metrics/AbcSize dito
|
108
|
+
def begin_transaction
|
109
|
+
log_query(:begin, Request.statement_payload('BEGIN')) do
|
110
|
+
handle_transport_errors do
|
111
|
+
path = "#{@path_prefix}/tx"
|
112
|
+
res = @connection.headers('Access-Mode' => @access_mode).post(path)
|
113
|
+
|
114
|
+
# When neo4j sends a response code other than 2xx,
|
115
|
+
# we stop further processing
|
116
|
+
raise Errors::TransactionBeginError, res.to_s \
|
117
|
+
unless res.status.success?
|
118
|
+
|
119
|
+
# Try to extract the transaction identifier
|
120
|
+
location = res.headers['Location'] || ''
|
121
|
+
location.split("#{path}/").last.to_i.tap do |tx_id|
|
122
|
+
# Make sure we flush this request from the persistent connection,
|
123
|
+
# in order to allow further requests
|
124
|
+
res.flush
|
125
|
+
|
126
|
+
# When we failed to parse the transaction identifier,
|
127
|
+
# we stop further processing
|
128
|
+
raise Errors::TransactionBeginError, res.to_s \
|
129
|
+
if tx_id.zero?
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
# rubocop:enable Metrics/MethodLength
|
135
|
+
# rubocop:enable Metrics/AbcSize
|
136
|
+
|
137
|
+
# Run one/multiple Cypher statements inside an open transaction.
|
138
|
+
#
|
139
|
+
# @param tx_id [Integer] the neo4j transaction identifier
|
140
|
+
# @param statements [Array<Hash>] the Cypher statements to run
|
141
|
+
# @return [Array<Hash{Symbol => Mixed}>] the raw neo4j results
|
142
|
+
#
|
143
|
+
# @raise [Errors::TransactionNotFoundError] when no open transaction
|
144
|
+
# was found by the given identifier
|
145
|
+
# @raise [Errors::TransactionRollbackError] when there was an error while
|
146
|
+
# committing the transaction, we assume that any error causes a
|
147
|
+
# transaction rollback at the neo4j side
|
148
|
+
def run_query(tx_id, *statements)
|
149
|
+
# We do not allow to send a run-request without Cypher statements
|
150
|
+
raise ArgumentError, 'No statements given' if statements.empty?
|
151
|
+
|
152
|
+
log_query(tx_id, *statements) do
|
153
|
+
handle_transaction(tx_id: tx_id) do |path|
|
154
|
+
@connection.post(path, body: serialize_body(statements: statements))
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
# Commit an open transaction, by the given neo4j transaction identifier.
|
160
|
+
#
|
161
|
+
# @param tx_id [Integer] the neo4j transaction identifier
|
162
|
+
# @param statements [Array<Hash>] the Cypher statements to run,
|
163
|
+
# as transaction finalization
|
164
|
+
# @return [Array<Hash{Symbol => Mixed}>] the raw neo4j results
|
165
|
+
#
|
166
|
+
# @raise [Errors::TransactionNotFoundError] when no open transaction
|
167
|
+
# was found by the given identifier
|
168
|
+
# @raise [Errors::TransactionRollbackError] when there was an error while
|
169
|
+
# committing the transaction, we assume that any error causes a
|
170
|
+
# transaction rollback at the neo4j side
|
171
|
+
def commit_transaction(tx_id, *statements)
|
172
|
+
log_query(tx_id, Request.statement_payload('COMMIT')) do
|
173
|
+
handle_transaction(tx_id: tx_id) do |path|
|
174
|
+
args = {}
|
175
|
+
args[:body] = serialize_body(statements: statements) \
|
176
|
+
if statements.any?
|
177
|
+
|
178
|
+
@connection.post("#{path}/commit", **args)
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
# Rollback an open transaction, by the given neo4j transaction identifier.
|
184
|
+
#
|
185
|
+
# @param tx_id [Integer] the neo4j transaction identifier
|
186
|
+
# @return [Array<Hash{Symbol => Mixed}>] the raw neo4j results
|
187
|
+
#
|
188
|
+
# @raise [Errors::TransactionNotFoundError] when no open transaction
|
189
|
+
# was found by the given identifier
|
190
|
+
# @raise [Errors::TransactionRollbackError] when there was an error while
|
191
|
+
# rolling the transaction back
|
192
|
+
def rollback_transaction(tx_id)
|
193
|
+
log_query(tx_id, Request.statement_payload('ROLLBACK')) do
|
194
|
+
handle_transaction(tx_id: tx_id) do |path|
|
195
|
+
@connection.delete(path)
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
# Handle a generic transaction interaction.
|
201
|
+
#
|
202
|
+
# @param tx_id [Integer] the neo4j transaction identifier
|
203
|
+
# @return [Array<Hash{Symbol => Mixed}>] the raw neo4j results
|
204
|
+
#
|
205
|
+
# @raise [Errors::TransactionNotFoundError] when no open transaction
|
206
|
+
# was found by the given identifier
|
207
|
+
# @raise [Errors::TransactionRollbackError] when there was an error while
|
208
|
+
# rolling the transaction back
|
209
|
+
def handle_transaction(tx_id: nil)
|
210
|
+
handle_transport_errors do
|
211
|
+
# Run the user given block, and pass the transaction path to it
|
212
|
+
res = yield("#{@path_prefix}/tx/#{tx_id}")
|
213
|
+
|
214
|
+
# When the transaction was not found, we tell so
|
215
|
+
raise Errors::TransactionNotFoundError.new(res.to_s, response: res) \
|
216
|
+
if res.code == 404
|
217
|
+
|
218
|
+
# When the response was simply not successful, we tell so, too
|
219
|
+
raise Errors::TransactionRollbackError.new(res.to_s, response: res) \
|
220
|
+
unless res.status.success?
|
221
|
+
|
222
|
+
# Handle the response body in a generic way
|
223
|
+
handle_response_body(res, tx_id: tx_id)
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
# Handle a neo4j HTTP API response body in a generic way.
|
228
|
+
#
|
229
|
+
# @param res [HTTP::Response] the raw HTTP response to handle
|
230
|
+
# @param tx_id [Integer] the neo4j transaction identifier
|
231
|
+
# @return [Array<Hash{Symbol => Mixed}>] the raw neo4j results
|
232
|
+
#
|
233
|
+
# @raise [Errors::TransactionRollbackError] when there were at least one
|
234
|
+
# error in the response, so we assume the transaction was rolled back
|
235
|
+
# by neo4j
|
236
|
+
#
|
237
|
+
# rubocop:disable Metrics/MethodLength because of the result
|
238
|
+
# handling (error, raw result, restructured result)
|
239
|
+
# rubocop:disable Metrics/AbcSize dito
|
240
|
+
def handle_response_body(res, tx_id: nil)
|
241
|
+
# Parse the response body as a whole
|
242
|
+
body = FastJsonparser.parse(res.to_s)
|
243
|
+
|
244
|
+
# When we hit some response errors, we handle them and
|
245
|
+
# re-raise in a wrapped exception
|
246
|
+
if (errors = body.fetch(:errors, [])).any?
|
247
|
+
list = errors.map do |error|
|
248
|
+
Errors::ResponseError.new(error[:message],
|
249
|
+
code: error[:code],
|
250
|
+
response: res)
|
251
|
+
end
|
252
|
+
raise Errors::TransactionRollbackError.new(
|
253
|
+
"Transaction (#{tx_id}) rolled back due to errors (#{list.count})",
|
254
|
+
errors: list,
|
255
|
+
response: res
|
256
|
+
)
|
257
|
+
end
|
258
|
+
|
259
|
+
# Otherwise return the results, either wrapped in a
|
260
|
+
# lightweight struct or raw
|
261
|
+
return body[:results] if @raw_results
|
262
|
+
|
263
|
+
body.fetch(:results, []).map do |result|
|
264
|
+
Boltless::Result.from(result)
|
265
|
+
end
|
266
|
+
rescue FastJsonparser::ParseError => e
|
267
|
+
# When we got something we could not parse, we tell so
|
268
|
+
raise Errors::InvalidJsonError.new(e.message, response: res)
|
269
|
+
end
|
270
|
+
# rubocop:enable Metrics/MethodLength
|
271
|
+
# rubocop:enable Metrics/AbcSize
|
272
|
+
|
273
|
+
# Serialize the given object to a JSON string.
|
274
|
+
#
|
275
|
+
# @param obj [Mixed] the object to serialize
|
276
|
+
# @return [String] the JSON string representation
|
277
|
+
def serialize_body(obj)
|
278
|
+
obj = obj.deep_stringify_keys if obj.is_a? Hash
|
279
|
+
Oj.dump(obj)
|
280
|
+
end
|
281
|
+
|
282
|
+
# Handle all the low-level http.rb gem errors transparently.
|
283
|
+
#
|
284
|
+
# @yield the given block
|
285
|
+
# @return [Mixed] the result of the given block
|
286
|
+
#
|
287
|
+
# @raise [Errors::RequestError] when a low-level error occured
|
288
|
+
def handle_transport_errors
|
289
|
+
yield
|
290
|
+
rescue HTTP::Error => e
|
291
|
+
raise Errors::RequestError, e.message
|
292
|
+
end
|
293
|
+
|
294
|
+
# Log the query details for the given statements, while benchmarking the
|
295
|
+
# given user block (which should contain the full request preparation,
|
296
|
+
# request performing and response parsing).
|
297
|
+
#
|
298
|
+
# When the +query_log_enabled+ configuration flag is set to +false+, we
|
299
|
+
# effectively do a no-op here, to keep things fast.
|
300
|
+
#
|
301
|
+
# @param tx_id [Integer] the neo4j transaction identifier
|
302
|
+
# @param statements [Array<Hash>] the Cypher statements to run
|
303
|
+
# @yield the given user block
|
304
|
+
# @return [Mixed] the result of the user given block
|
305
|
+
#
|
306
|
+
# rubocop:disable Metrics/MethodLength because of the
|
307
|
+
# configuration handling
|
308
|
+
def log_query(tx_id, *statements)
|
309
|
+
# When no query logging is enabled, we won't do it
|
310
|
+
enabled = Boltless.configuration.query_log_enabled
|
311
|
+
return yield unless enabled
|
312
|
+
|
313
|
+
# Add a new request to the counter
|
314
|
+
@requests_done += 1
|
315
|
+
|
316
|
+
# When the +query_debug_log_enabled+ config flag is set, we prodce a
|
317
|
+
# logging output before the actual request is sent, in order to help
|
318
|
+
# while debugging slow/never-ending Cypher statements
|
319
|
+
if enabled == :debug
|
320
|
+
Boltless.logger.debug do
|
321
|
+
generate_log_str(tx_id == :begin ? 'tbd' : tx_id,
|
322
|
+
nil,
|
323
|
+
*statements)
|
324
|
+
end
|
325
|
+
end
|
326
|
+
|
327
|
+
# Otherwise measure the runtime of the user given block,
|
328
|
+
# and log the related statements
|
329
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC,
|
330
|
+
:float_millisecond)
|
331
|
+
res = yield
|
332
|
+
stop = Process.clock_gettime(Process::CLOCK_MONOTONIC,
|
333
|
+
:float_millisecond)
|
334
|
+
|
335
|
+
# As a fallback to the +query_log_enabled+ config flag, we just log to
|
336
|
+
# the debug level with a block, so it won't be executed when the logger
|
337
|
+
# is not configured to print debug level
|
338
|
+
Boltless.logger.debug do
|
339
|
+
generate_log_str(tx_id == :begin ? res : tx_id,
|
340
|
+
(stop - start).truncate(1),
|
341
|
+
*statements)
|
342
|
+
end
|
343
|
+
|
344
|
+
# Return the result of the user given block
|
345
|
+
res
|
346
|
+
end
|
347
|
+
# rubocop:enable Metrics/MethodLength
|
348
|
+
|
349
|
+
# Generate a logging string for the given details,
|
350
|
+
# without actually printing it.
|
351
|
+
#
|
352
|
+
# @param tx_id [Integer, String, nil] the neo4j transaction identifier
|
353
|
+
# @param duration [Numeric, nil] the duration (ms) of the query
|
354
|
+
# @param statements [Array<Hash>] the Cypher statements to run
|
355
|
+
# @return [String] the assembled logging string
|
356
|
+
#
|
357
|
+
# rubocop:disable Metrics/MethodLength because of the complex
|
358
|
+
# logging string assembling/formatting
|
359
|
+
# rubocop:disable Metrics/AbcSize dito
|
360
|
+
def generate_log_str(tx_id, duration, *statements)
|
361
|
+
dur = "(#{duration}ms)".colorize(color: :magenta, mode: :bold) \
|
362
|
+
if duration
|
363
|
+
|
364
|
+
tag = [
|
365
|
+
'[',
|
366
|
+
"tx:#{@access_mode.downcase}:#{tx_id || 'one-shot'}",
|
367
|
+
tx_id ? " rq:#{@requests_done}" : '',
|
368
|
+
']'
|
369
|
+
].join.colorize(:white)
|
370
|
+
|
371
|
+
prefix = ['Boltless'.colorize(:magenta), tag, dur].compact.join(' ')
|
372
|
+
|
373
|
+
statements.map do |stmt|
|
374
|
+
cypher = Boltless.resolve_cypher(
|
375
|
+
stmt[:statement], **(stmt[:parameters] || {})
|
376
|
+
).lines.map(&:strip).join(' ')
|
377
|
+
cypher = cypher.colorize(color: Boltless.cypher_logging_color(cypher),
|
378
|
+
mode: :bold)
|
379
|
+
"#{prefix} #{cypher}"
|
380
|
+
end.join("\n")
|
381
|
+
end
|
382
|
+
# rubocop:enable Metrics/MethodLength
|
383
|
+
# rubocop:enable Metrics/AbcSize
|
384
|
+
end
|
385
|
+
# rubocop:enable Metrics/ClassLength
|
386
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Boltless
|
4
|
+
# A lightweight struct representation of a single result
|
5
|
+
# object from the neo4j HTTP API.
|
6
|
+
Result = Struct.new(:columns, :rows, :stats) do
|
7
|
+
# Build a mapped result structure from the given raw result hash.
|
8
|
+
#
|
9
|
+
# @param hash [Hash{Symbol => Mixed}] the raw neo4j result hash
|
10
|
+
# for a single statement
|
11
|
+
# @return [Boltless::Result] the lightweight mapped result structure
|
12
|
+
def self.from(hash)
|
13
|
+
# We setup an empty result struct first
|
14
|
+
cols = hash[:columns].map(&:to_sym)
|
15
|
+
Result.new(cols, [], hash[:stats]).tap do |res|
|
16
|
+
# Then we re-map each row from the given hash
|
17
|
+
res.rows = hash[:data].map do |datum|
|
18
|
+
ResultRow.new(res, datum[:row], datum[:meta], datum[:graph])
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# We allow direct enumration access to our rows,
|
24
|
+
# this allows direct counting, plucking etc
|
25
|
+
include Enumerable
|
26
|
+
|
27
|
+
# Yields each row of the result. This is the foundation to the +Enumerable+
|
28
|
+
# interface, so all its methods (eg. +#count+, etc) are available with
|
29
|
+
# this. If no block is given, an Enumerator is returned.
|
30
|
+
#
|
31
|
+
# @param block [Proc] the block which is called for each result row
|
32
|
+
# @yieldparam [Boltless::ResultRow] a single result row
|
33
|
+
# @return [Array<Boltless::ResultRow>] the result rows array itself
|
34
|
+
def each(&block)
|
35
|
+
rows.each(&block)
|
36
|
+
end
|
37
|
+
|
38
|
+
# A shortcut to access the result rows.
|
39
|
+
delegate :to_a, to: :rows
|
40
|
+
|
41
|
+
# A convenience shortcut for the first row of the result, and its first
|
42
|
+
# value. This comes in very handy for single-value/single-row Cypher
|
43
|
+
# statements like +RETURN date() AS date+. Or probing Cypher statements
|
44
|
+
# like +MATCH (n:User { name: $name }) RETURN 1 LIMIT 1+.
|
45
|
+
#
|
46
|
+
# @return [Mixed] the first value of the first result row
|
47
|
+
def value
|
48
|
+
rows.first.values.first
|
49
|
+
end
|
50
|
+
|
51
|
+
# A convenience method to access all mapped result rows as hashes.
|
52
|
+
#
|
53
|
+
# *Heads up!* This method is quite costly (time and heap memory) on large
|
54
|
+
# result sets, as it merges the column data with the row data in order to
|
55
|
+
# return an assembled hash. Use with caution. (Pro Tip: Iterate over the
|
56
|
+
# rows and +pluck/[]+ the result keys you are interested in, instead of the
|
57
|
+
# "grab everything" style)
|
58
|
+
#
|
59
|
+
# @return [Array<Hash{Symbol => Mixed}>] the mapped result rows
|
60
|
+
def values
|
61
|
+
rows.map(&:to_h)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Pretty print the result structure in a meaningful way.
|
65
|
+
#
|
66
|
+
# @param pp [PP] a pretty printer instance to use
|
67
|
+
#
|
68
|
+
# rubocop:disable Metrics/MethodLength because of the pretty
|
69
|
+
# printing logic
|
70
|
+
# rubocop:disable Metrics/AbcSize dito
|
71
|
+
def pretty_print(pp)
|
72
|
+
pp.object_group(self) do
|
73
|
+
pp.breakable
|
74
|
+
pp.text('columns=')
|
75
|
+
pp.pp(columns)
|
76
|
+
pp.comma_breakable
|
77
|
+
|
78
|
+
pp.text('rows=')
|
79
|
+
if rows.count > 1
|
80
|
+
pp.group(1, '[', ']') do
|
81
|
+
pp.pp(first)
|
82
|
+
pp.comma_breakable
|
83
|
+
pp.text("[+#{rows.count - 1} ..]")
|
84
|
+
end
|
85
|
+
else
|
86
|
+
pp.pp(rows)
|
87
|
+
end
|
88
|
+
pp.comma_breakable
|
89
|
+
|
90
|
+
pp.text('stats=')
|
91
|
+
pp.pp(stats)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
# rubocop:enable Metrics/MethodLength
|
95
|
+
# rubocop:enable Metrics/AbcSize
|
96
|
+
alias_method :inspect, :pretty_inspect
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Boltless
|
4
|
+
# A lightweight result row, used for convenient result data access.
|
5
|
+
#
|
6
|
+
# rubocop:disable Lint/StructNewOverride because we have an
|
7
|
+
# own implementation for +#values+
|
8
|
+
ResultRow = Struct.new(:result, :values, :meta, :graph) do
|
9
|
+
# A simple shortcut to easily access the row columns
|
10
|
+
delegate :columns, to: :result
|
11
|
+
|
12
|
+
# Return the value of the requested key. When the given key is unknown, we
|
13
|
+
# just return +nil+. The given key is normalized to its Symbol form, in
|
14
|
+
# order to allow performant indifferent hash access.
|
15
|
+
#
|
16
|
+
# @param key [Symbol, String] the key to fetch the value for
|
17
|
+
# @return [Mixed] the value for the given key
|
18
|
+
def [](key)
|
19
|
+
# When the requested key was not found, we return +nil+, no need to
|
20
|
+
# perfom the actual lookup
|
21
|
+
return unless (idx = columns.index(key.to_sym))
|
22
|
+
|
23
|
+
# Otherwise return the value from the slot
|
24
|
+
values[idx]
|
25
|
+
end
|
26
|
+
|
27
|
+
# A convenience shortcut for the first value of the row. This comes in very
|
28
|
+
# handy for single-value/single-row Cypher statements like +RETURN date()
|
29
|
+
# AS date+.
|
30
|
+
#
|
31
|
+
# @return [Mixed] the first value of the row
|
32
|
+
def value
|
33
|
+
values.first
|
34
|
+
end
|
35
|
+
|
36
|
+
# Return the assembled row as Ruby hash.
|
37
|
+
#
|
38
|
+
# *Heads up!* This method is quite costly (time and heap memory) on large
|
39
|
+
# result sets, as it merges the column data with the row data in order to
|
40
|
+
# return an assembled hash. Use with caution.
|
41
|
+
#
|
42
|
+
# @return [Hash{Symbol => Mixed}] the mapped result row
|
43
|
+
def to_h
|
44
|
+
columns.zip(values).to_h
|
45
|
+
end
|
46
|
+
|
47
|
+
# Returns a JSON hash representation the result row. This works like
|
48
|
+
# +#to_h+ but the resulting hash uses string keys instead.
|
49
|
+
#
|
50
|
+
# @return [Hash{String => Mixed}] the JSON hash representation
|
51
|
+
def as_json(*)
|
52
|
+
columns.map(&:to_s).zip(values).to_h
|
53
|
+
end
|
54
|
+
|
55
|
+
# We allow direct enumration access to our row data,
|
56
|
+
# this allows direct counting, plucking etc
|
57
|
+
include Enumerable
|
58
|
+
|
59
|
+
# Calls the user given block once for each key/columns of the row, passing
|
60
|
+
# the key-value pair as parameters. If no block is given, an enumerator is
|
61
|
+
# returned instead.
|
62
|
+
#
|
63
|
+
# @param block [Proc] the block which is called for each result row
|
64
|
+
# @yieldparam [Symbol] a row column/key
|
65
|
+
# @yieldparam [Mixed] a row value for the column/key
|
66
|
+
# @return [Array<Boltless::ResultRow>] the result rows array itself
|
67
|
+
def each
|
68
|
+
columns.each_with_index do |column, idx|
|
69
|
+
yield(column, values[idx])
|
70
|
+
end
|
71
|
+
end
|
72
|
+
alias_method :each_pair, :each
|
73
|
+
|
74
|
+
# Pretty print the result row structure in a meaningful way.
|
75
|
+
#
|
76
|
+
# @param pp [PP] a pretty printer instance to use
|
77
|
+
def pretty_print(pp)
|
78
|
+
pp.object_group(self) do
|
79
|
+
pp.breakable
|
80
|
+
%i[columns values meta graph].each_with_index do |key, idx|
|
81
|
+
pp.text("#{key}=")
|
82
|
+
pp.pp(send(key))
|
83
|
+
pp.comma_breakable if idx < 3
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
alias_method :inspect, :pretty_inspect
|
88
|
+
end
|
89
|
+
# rubocop:enable Lint/StructNewOverride
|
90
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Boltless
|
4
|
+
# A shallow interface object to collect multiple Cypher statements. We have
|
5
|
+
# an explicit different interface (+#add+ instead ot +#run+) from a regular
|
6
|
+
# transaction to clarify that we just collect statements without running them
|
7
|
+
# directly. As a result no subsequent statement can access the results of a
|
8
|
+
# previous statement within this collection.
|
9
|
+
#
|
10
|
+
# Effectively, we wrap just multiple statements for a single HTTP API/Cypher
|
11
|
+
# transaction API request.
|
12
|
+
#
|
13
|
+
# @see https://bit.ly/3zRGAEo
|
14
|
+
class StatementCollector
|
15
|
+
# We allow to read our collected details
|
16
|
+
attr_reader :statements
|
17
|
+
|
18
|
+
# We allow to access helpful utilities straigth from here
|
19
|
+
delegate :build_cypher, :prepare_label, :prepare_type, :prepare_string,
|
20
|
+
:to_options, :resolve_cypher,
|
21
|
+
to: Boltless
|
22
|
+
|
23
|
+
# Setup a new statement collector instance.
|
24
|
+
#
|
25
|
+
# @return [Boltless::StatementCollector] the new instance
|
26
|
+
def initialize
|
27
|
+
@statements = []
|
28
|
+
end
|
29
|
+
|
30
|
+
# Add a new statement to the collector.
|
31
|
+
#
|
32
|
+
# @param cypher [String] the Cypher statement to run
|
33
|
+
# @param args [Hash{Symbol => Mixed}] the additional Cypher parameters
|
34
|
+
# @return [StatementCollector] we return ourself, for method chaining
|
35
|
+
def add(cypher, **args)
|
36
|
+
@statements << Request.statement_payload(cypher, **args)
|
37
|
+
self
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|