boltless 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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