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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +8 -0
  3. data/Guardfile +44 -0
  4. data/Makefile +138 -0
  5. data/Rakefile +26 -0
  6. data/docker-compose.yml +19 -0
  7. data/lib/boltless/configuration.rb +69 -0
  8. data/lib/boltless/errors/invalid_json_error.rb +9 -0
  9. data/lib/boltless/errors/request_error.rb +24 -0
  10. data/lib/boltless/errors/response_error.rb +30 -0
  11. data/lib/boltless/errors/transaction_begin_error.rb +9 -0
  12. data/lib/boltless/errors/transaction_in_bad_state_error.rb +11 -0
  13. data/lib/boltless/errors/transaction_not_found_error.rb +11 -0
  14. data/lib/boltless/errors/transaction_rollback_error.rb +26 -0
  15. data/lib/boltless/extensions/configuration_handling.rb +37 -0
  16. data/lib/boltless/extensions/connection_pool.rb +127 -0
  17. data/lib/boltless/extensions/operations.rb +175 -0
  18. data/lib/boltless/extensions/transactions.rb +301 -0
  19. data/lib/boltless/extensions/utilities.rb +187 -0
  20. data/lib/boltless/request.rb +386 -0
  21. data/lib/boltless/result.rb +98 -0
  22. data/lib/boltless/result_row.rb +90 -0
  23. data/lib/boltless/statement_collector.rb +40 -0
  24. data/lib/boltless/transaction.rb +234 -0
  25. data/lib/boltless/version.rb +23 -0
  26. data/lib/boltless.rb +36 -0
  27. data/spec/benchmark/transfer.rb +57 -0
  28. data/spec/boltless/extensions/configuration_handling_spec.rb +39 -0
  29. data/spec/boltless/extensions/connection_pool_spec.rb +131 -0
  30. data/spec/boltless/extensions/operations_spec.rb +189 -0
  31. data/spec/boltless/extensions/transactions_spec.rb +418 -0
  32. data/spec/boltless/extensions/utilities_spec.rb +546 -0
  33. data/spec/boltless/request_spec.rb +946 -0
  34. data/spec/boltless/result_row_spec.rb +161 -0
  35. data/spec/boltless/result_spec.rb +127 -0
  36. data/spec/boltless/statement_collector_spec.rb +45 -0
  37. data/spec/boltless/transaction_spec.rb +601 -0
  38. data/spec/boltless_spec.rb +11 -0
  39. data/spec/fixtures/files/raw_result.yml +21 -0
  40. data/spec/fixtures/files/raw_result_with_graph_result.yml +48 -0
  41. data/spec/fixtures/files/raw_result_with_meta.yml +11 -0
  42. data/spec/fixtures/files/raw_result_with_stats.yml +26 -0
  43. data/spec/spec_helper.rb +89 -0
  44. 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