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,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boltless
4
+ module Extensions
5
+ # A top-level gem-module extension for neo4j operations.
6
+ #
7
+ # rubocop:disable Metrics/BlockLength because this is how
8
+ # an +ActiveSupport::Concern+ looks like
9
+ module Operations
10
+ extend ActiveSupport::Concern
11
+
12
+ class_methods do
13
+ # Clear everything from the neo4j database, including indexes,
14
+ # constraints, all nodes and all relationships. Afterwards the
15
+ # database is completely empty and "unconfigured". (dist clean)
16
+ #
17
+ # @param database [String, Symbol] the neo4j database to use
18
+ #
19
+ # rubocop:disable Metrics/MethodLength because of
20
+ # multiple transactions
21
+ # rubocop:disable Metrics/AbcSize because of the extra transaction
22
+ # handlings (we cannot do multiple structural changes in a single
23
+ # transaction)
24
+ def clear_database!(database: Boltless.configuration.default_db)
25
+ logger.warn('Clear neo4j database ..')
26
+
27
+ # Clear all constraints, this is a schema
28
+ # modification so we have to run it in a separate transaction
29
+ transaction!(database: database) do |tx|
30
+ constraint_names.each do |name|
31
+ logger.info(" > Drop neo4j constraint #{name} ..")
32
+ tx.run!("DROP CONSTRAINT #{name}")
33
+ end
34
+ end
35
+
36
+ # Clear all indexes, this is a schema
37
+ # modification so we have to run it in a separate transaction
38
+ transaction!(database: database) do |tx|
39
+ index_names.each do |name|
40
+ logger.info(" > Drop neo4j index #{name} ..")
41
+ tx.run!("DROP INDEX #{name}")
42
+ end
43
+ end
44
+
45
+ # Afterwards clear all nodes and relationships
46
+ stats = write!('MATCH (n) DETACH DELETE n',
47
+ database: database,
48
+ with_stats: true).stats
49
+
50
+ logger.info(" > Nodes deleted: #{stats[:nodes_deleted]}")
51
+ logger.info(
52
+ " > Relationships deleted: #{stats[:relationship_deleted]}"
53
+ )
54
+ end
55
+ # rubocop:enable Metrics/MethodLength
56
+ # rubocop:enable Metrics/AbcSize
57
+
58
+ # Check whenever the given name is already taken on the neo4j
59
+ # database for a component (index or constraint).
60
+ #
61
+ # @param database [String, Symbol] the neo4j database to use
62
+ # @param name [String, Symbol] the name to check
63
+ # @return [Boolean] whenever the name is taken/present or not
64
+ def component_name_present?(name,
65
+ database: Boltless.configuration.default_db)
66
+ pool = index_names(database: database) +
67
+ constraint_names(database: database)
68
+ pool.include? name.to_s
69
+ end
70
+
71
+ # List all currently available indexes by their names.
72
+ #
73
+ # @param database [String, Symbol] the neo4j database to use
74
+ # @return [Array<String>] the index names
75
+ def index_names(database: Boltless.configuration.default_db)
76
+ res = query!('SHOW INDEXES YIELD name',
77
+ database: database).pluck(:name)
78
+ res - constraint_names
79
+ end
80
+
81
+ # List all currently available constraints by their names.
82
+ #
83
+ # @param database [String, Symbol] the neo4j database to use
84
+ # @return [Array<String>] the constraint names
85
+ def constraint_names(database: Boltless.configuration.default_db)
86
+ query!('SHOW CONSTRAINTS YIELD name',
87
+ database: database).pluck(:name)
88
+ end
89
+
90
+ # Create a new index at the neo4j database.
91
+ #
92
+ # @see https://bit.ly/3GawzVP
93
+ # @param name [String, Symbol] the name of the index, we do not
94
+ # allow autogenerated names
95
+ # @param type [String, Symbol] the index type (+:btree+, +:lookup+,
96
+ # +:text+, +:range+, +:point+, +:fulltext+)
97
+ # @param options [Hash{Symbol => Mixed}] additional index options
98
+ # for neo4j
99
+ # @param for [String] the node/property query for matching
100
+ # @param on [String] the collection of nodes/properties to index
101
+ # @param database [String, Symbol] the neo4j database to use
102
+ #
103
+ # rubocop:disable Metrics/ParameterLists because of the various
104
+ # configuration options of a neo4j index
105
+ def add_index(name:, for:, on:, type: :btree, options: nil,
106
+ database: Boltless.configuration.default_db)
107
+ # CREATE TEXT INDEX [index_name] IF NOT EXISTS
108
+ # FOR ()-[r:TYPE_NAME]-()
109
+ # ON (r.propertyName)
110
+ # OPTIONS {option: value[, ...]}
111
+
112
+ # Assemble and perform the neo4j query
113
+ type = type == :btree ? '' : type.to_s.upcase
114
+ options = options.blank? ? '' : "OPTIONS #{to_options(options)}"
115
+ write <<~CYPHER, database: database
116
+ CREATE #{type} INDEX #{name} IF NOT EXISTS
117
+ FOR #{binding.local_variable_get(:for)}
118
+ ON #{on}
119
+ #{options}
120
+ CYPHER
121
+ end
122
+ # rubocop:enable Metrics/ParameterLists
123
+
124
+ # Drop an index from the neo4j database.
125
+ #
126
+ # @param name [String, Symbol] the name of the index
127
+ # @param database [String, Symbol] the neo4j database to use
128
+ def drop_index(name, database: Boltless.configuration.default_db)
129
+ write!("DROP INDEX #{name} IF EXISTS", database: database)
130
+ end
131
+
132
+ # Create a new constraint at the neo4j database.
133
+ #
134
+ # Adding constraints is an atomic operation that can take a while.
135
+ # All existing data has to be scanned before neo4j can turn the
136
+ # constraint 'on'.
137
+ #
138
+ # @see https://bit.ly/3GawzVP
139
+ #
140
+ # @param name [String, Symbol] the name of the index, we do not
141
+ # allow autogenerated names
142
+ # @param options [Hash{Symbol => Mixed}] additional index options
143
+ # for neo4j
144
+ # @param for [String] the node/property query for matching
145
+ # @param require [String] the constraint to apply
146
+ # @param database [String, Symbol] the neo4j database to use
147
+ def add_constraint(name:, for:, require:, options: nil,
148
+ database: Boltless.configuration.default_db)
149
+ # CREATE CONSTRAINT [constraint_name] IF NOT EXISTS
150
+ # FOR (n:LabelName)
151
+ # REQUIRE n.propertyName IS UNIQUE
152
+ # OPTIONS { option: value[, ...] }
153
+
154
+ # Assemble and perform the neo4j query
155
+ options = options.blank? ? '' : "OPTIONS #{to_options(options)}"
156
+ write! <<~CYPHER, database: database
157
+ CREATE CONSTRAINT #{name} IF NOT EXISTS
158
+ FOR #{binding.local_variable_get(:for)}
159
+ REQUIRE #{binding.local_variable_get(:require)}
160
+ #{options}
161
+ CYPHER
162
+ end
163
+
164
+ # Drop a constraint from the neo4j database.
165
+ #
166
+ # @param name [String, Symbol] the name of the constraint
167
+ # @param database [String, Symbol] the neo4j database to use
168
+ def drop_constraint(name, database: Boltless.configuration.default_db)
169
+ write!("DROP CONSTRAINT #{name} IF EXISTS", database: database)
170
+ end
171
+ end
172
+ end
173
+ # rubocop:enable Metrics/BlockLength
174
+ end
175
+ end
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boltless
4
+ module Extensions
5
+ # A top-level gem-module extension to add easy-to-use methods to use the
6
+ # Cypher transactional API.
7
+ #
8
+ # rubocop:disable Metrics/BlockLength because this is how
9
+ # an +ActiveSupport::Concern+ looks like
10
+ # rubocop:disable Metrics/ModuleLength dito
11
+ module Transactions
12
+ extend ActiveSupport::Concern
13
+
14
+ class_methods do
15
+ # Perform a single Cypher statement and return its results.
16
+ #
17
+ # @param cypher [String] the Cypher statement to run
18
+ # @param access_mode [String, Symbol] the neo4j transaction access
19
+ # mode, use +:read+ or +:write+
20
+ # @param database [String, Symbol] the neo4j database to use
21
+ # @param raw_results [Boolean] whenever to return the plain HTTP API
22
+ # JSON results (as plain +Hash{Symbol => Mixed}/Array+ data), or not
23
+ # (then we return +Array<Boltless::Result>+ structs
24
+ # @return [Array<Hash{Symbol => Mixed}>] the (raw) neo4j results
25
+ #
26
+ # @raise [Boltless::Errors::RequestError] in case of low-level issues
27
+ # @raise [Boltless::Errors::ResponseError] in case of issues
28
+ # found by neo4j
29
+ def execute!(cypher, access_mode: :write,
30
+ database: Boltless.configuration.default_db,
31
+ raw_results: false, **args)
32
+ one_shot!(
33
+ access_mode,
34
+ database: database,
35
+ raw_results: raw_results
36
+ ) do |tx|
37
+ tx.add(cypher, **args)
38
+ end.first
39
+ end
40
+ alias_method :write!, :execute!
41
+
42
+ # Perform a single Cypher statement and return its results.
43
+ # Any transfer error will be rescued.
44
+ #
45
+ # @param cypher [String] the Cypher statement to run
46
+ # @param access_mode [String, Symbol] the neo4j transaction access
47
+ # mode, use +:read+ or +:write+
48
+ # @param database [String, Symbol] the neo4j database to use
49
+ # @param raw_results [Boolean] whenever to return the plain HTTP API
50
+ # JSON results (as plain +Hash{Symbol => Mixed}/Array+ data), or not
51
+ # (then we return +Array<Boltless::Result>+ structs
52
+ # @return [Array<Hash{Symbol => Mixed}>, nil] the (raw) neo4j
53
+ # results, or +nil+ in case of errors
54
+ def execute(cypher, access_mode: :write,
55
+ database: Boltless.configuration.default_db,
56
+ raw_results: false, **args)
57
+ one_shot(
58
+ access_mode,
59
+ database: database,
60
+ raw_results: raw_results
61
+ ) do |tx|
62
+ tx.add(cypher, **args)
63
+ end&.first
64
+ end
65
+ alias_method :write, :execute
66
+
67
+ # A simple shortcut to perform a single Cypher in read access mode. See
68
+ # +.execute!+ for further details.
69
+ #
70
+ # @param cypher [String] the Cypher statement to run
71
+ # @param access_mode [String, Symbol] the neo4j transaction access
72
+ # mode, use +:read+ or +:write+
73
+ # @param args [Hash{Symbol}] all additional arguments of +.execute!+
74
+ # @return [Array<Hash{Symbol => Mixed}>, nil] the (raw) neo4j
75
+ # results, or +nil+ in case of errors
76
+ def query!(cypher, access_mode: :read, **args)
77
+ execute!(cypher, access_mode: access_mode, **args)
78
+ end
79
+ alias_method :read!, :query!
80
+
81
+ # A simple shortcut to perform a single Cypher in read access mode. See
82
+ # +.execute+ for further details. Any transfer error will be rescued.
83
+ #
84
+ # @param cypher [String] the Cypher statement to run
85
+ # @param access_mode [String, Symbol] the neo4j transaction access
86
+ # mode, use +:read+ or +:write+
87
+ # @param args [Hash{Symbol}] all additional arguments of +.execute+
88
+ # @return [Array<Hash{Symbol => Mixed}>, nil] the (raw) neo4j
89
+ # results, or +nil+ in case of errors
90
+ def query(cypher, access_mode: :read, **args)
91
+ execute(cypher, access_mode: access_mode, **args)
92
+ end
93
+ alias_method :read, :query
94
+
95
+ # Start an single shot transaction and run all the given Cypher
96
+ # statements inside it. When anything within the user given block
97
+ # raises, we do not send the actual HTTP API request to the neo4j
98
+ # server.
99
+ #
100
+ # @param access_mode [String, Symbol] the neo4j transaction access
101
+ # mode, use +:read+ or +:write+
102
+ # @param database [String, Symbol] the neo4j database to use
103
+ # @param raw_results [Boolean] whenever to return the plain HTTP API
104
+ # JSON results (as plain +Hash{Symbol => Mixed}/Array+ data), or not
105
+ # (then we return +Array<Boltless::Result>+ structs
106
+ # @yield [Boltless::StatementCollector] the statement collector object
107
+ # to use
108
+ # @return [Array<Hash{Symbol => Mixed}>] the (raw) neo4j results
109
+ #
110
+ # @raise [Boltless::Errors::RequestError] in case of low-level issues
111
+ # @raise [Boltless::Errors::ResponseError] in case of issues
112
+ # found by neo4j
113
+ # @raise [Mixed] when an exception occurs inside the user given block
114
+ def one_shot!(access_mode = :write,
115
+ database: Boltless.configuration.default_db,
116
+ raw_results: false)
117
+ # Fetch a connection from the pool
118
+ connection_pool.with do |connection|
119
+ # Setup a neo4j HTTP API request abstraction instance,
120
+ # and a statement collector
121
+ req = Request.new(connection, access_mode: access_mode,
122
+ database: database,
123
+ raw_results: raw_results)
124
+ collector = StatementCollector.new
125
+
126
+ # Run the user given block, and pass down the collector
127
+ yield(collector)
128
+
129
+ # Perform the actual HTTP API request
130
+ req.one_shot_transaction(*collector.statements)
131
+ end
132
+ end
133
+
134
+ # Start an single shot transaction and run all the given Cypher
135
+ # statements inside it. When anything within the user given block
136
+ # raises, we do not send the actual HTTP API request to the neo4j
137
+ # server. Any other transfer error will be rescued.
138
+ #
139
+ # @param access_mode [String, Symbol] the neo4j transaction access
140
+ # mode, use +:read+ or +:write+
141
+ # @param database [String, Symbol] the neo4j database to use
142
+ # @param raw_results [Boolean] whenever to return the plain HTTP API
143
+ # JSON results (as plain +Hash{Symbol => Mixed}/Array+ data), or not
144
+ # (then we return +Array<Boltless::Result>+ structs
145
+ # @yield [Boltless::StatementCollector] the statement collector object
146
+ # to use
147
+ # @return [Array<Hash{Symbol => Mixed}>, nil] the (raw) neo4j results,
148
+ # or +nil+ on errors
149
+ #
150
+ # @raise [Mixed] when an exception occurs inside the user given block
151
+ #
152
+ # rubocop:disable Metrics/MethodLength because of the extra
153
+ # error handling
154
+ def one_shot(access_mode = :write,
155
+ database: Boltless.configuration.default_db,
156
+ raw_results: false)
157
+ # Fetch a connection from the pool
158
+ connection_pool.with do |connection|
159
+ # Setup a neo4j HTTP API request abstraction instance,
160
+ # and a statement collector
161
+ req = Request.new(connection, access_mode: access_mode,
162
+ database: database,
163
+ raw_results: raw_results)
164
+ collector = StatementCollector.new
165
+
166
+ # Run the user given block, and pass down the collector
167
+ yield(collector)
168
+
169
+ # Perform the actual HTTP API request
170
+ begin
171
+ req.one_shot_transaction(*collector.statements)
172
+ rescue Errors::RequestError, Errors::ResponseError
173
+ # When we hit an error here, we will return +nil+ to signalize it
174
+ nil
175
+ end
176
+ end
177
+ end
178
+ # rubocop:enable Metrics/MethodLength
179
+
180
+ # Start an new transaction and run Cypher statements inside it. When
181
+ # anything within the user given block raises, we automatically
182
+ # rollback the transaction.
183
+ #
184
+ # @param access_mode [String, Symbol] the neo4j transaction access
185
+ # mode, use +:read+ or +:write+
186
+ # @param database [String, Symbol] the neo4j database to use
187
+ # @param raw_results [Boolean] whenever to return the plain HTTP API
188
+ # JSON results (as plain +Hash{Symbol => Mixed}/Array+ data), or not
189
+ # (then we return +Array<Boltless::Result>+ structs
190
+ # @yield [Boltless::Transaction] the transaction object to use
191
+ # @return [Mixed] the result of the user given block
192
+ #
193
+ # @raise [Boltless::Errors::RequestError] in case of low-level issues
194
+ # @raise [Boltless::Errors::ResponseError] in case of issues
195
+ # found by neo4j
196
+ # @raise [Mixed] when an exception occurs inside the user given
197
+ # block, we re-raise it
198
+ #
199
+ # rubocop:disable Metrics/MethodLength because this is the workflow
200
+ def transaction!(access_mode = :write,
201
+ database: Boltless.configuration.default_db,
202
+ raw_results: false)
203
+ # Fetch a connection from the pool
204
+ connection_pool.with do |connection|
205
+ # Setup and start a new transaction
206
+ tx = Boltless::Transaction.new(connection,
207
+ database: database,
208
+ access_mode: access_mode,
209
+ raw_results: raw_results)
210
+ tx.begin!
211
+
212
+ begin
213
+ # Run the user given block, and pass
214
+ # the transaction instance down
215
+ res = yield(tx)
216
+ rescue StandardError => e
217
+ # In case anything raises inside the user block,
218
+ # we try to auto-rollback the opened transaction
219
+ tx.rollback
220
+
221
+ # Re-raise the original error
222
+ raise e
223
+ end
224
+
225
+ # Try to commit after the user given block,
226
+ # when the transaction is still open
227
+ tx.commit! if tx.state.open?
228
+
229
+ # Return the result of the user given block
230
+ res
231
+ ensure
232
+ # Clean up the transaction object
233
+ tx.cleanup
234
+ end
235
+ end
236
+ # rubocop:enable Metrics/MethodLength
237
+
238
+ # Start an new transaction and run Cypher statements inside it. When
239
+ # anything within the user given block raises, we automatically
240
+ # rollback the transaction.
241
+ #
242
+ # @param access_mode [String, Symbol] the neo4j transaction access
243
+ # mode, use +:read+ or +:write+
244
+ # @param database [String, Symbol] the neo4j database to use
245
+ # @param raw_results [Boolean] whenever to return the plain HTTP API
246
+ # JSON results (as plain +Hash{Symbol => Mixed}/Array+ data), or not
247
+ # (then we return +Array<Boltless::Result>+ structs
248
+ # @yield [Boltless::Transaction] the transaction object to use
249
+ # @return [Mixed] the result of the user given block
250
+ #
251
+ # @raise [Mixed] when an exception occurs inside the user given
252
+ # block, we re-raise it
253
+ #
254
+ # rubocop:disable Metrics/MethodLength because this is the workflow
255
+ def transaction(access_mode = :write,
256
+ database: Boltless.configuration.default_db,
257
+ raw_results: false)
258
+ # Fetch a connection from the pool
259
+ connection_pool.with do |connection|
260
+ # Setup and start a new transaction, when the start up fails,
261
+ # we return +nil+ and stop further processing
262
+ tx = Boltless::Transaction.new(connection,
263
+ database: database,
264
+ access_mode: access_mode,
265
+ raw_results: raw_results)
266
+ next unless tx.begin
267
+
268
+ begin
269
+ # Run the user given block, and pass
270
+ # the transaction instance down
271
+ res = yield(tx)
272
+ rescue StandardError => e
273
+ # In case anything raises inside the user block,
274
+ # we auto-rollback the opened transaction
275
+ tx.rollback
276
+
277
+ # Re-raise the original error
278
+ raise e
279
+ end
280
+
281
+ # Try to commit after the user given block, when the transaction is
282
+ # still open, and return the results of the user given block if the
283
+ # transaction is successfully commited
284
+ tx_committed = tx.state.open? ? tx.commit : true
285
+ next res if tx_committed
286
+
287
+ # Otherwise return +nil+ again,
288
+ # to signalize the transaction failed
289
+ nil
290
+ ensure
291
+ # Clean up the transaction object
292
+ tx.cleanup
293
+ end
294
+ end
295
+ # rubocop:enable Metrics/MethodLength
296
+ end
297
+ end
298
+ # rubocop:enable Metrics/BlockLength
299
+ # rubocop:enable Metrics/ModuleLength
300
+ end
301
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boltless
4
+ module Extensions
5
+ # A top-level gem-module extension add helpers and utilites.
6
+ #
7
+ # rubocop:disable Metrics/BlockLength because this is how
8
+ # an +ActiveSupport::Concern+ looks like
9
+ module Utilities
10
+ extend ActiveSupport::Concern
11
+
12
+ class_methods do
13
+ # Build an Cypher query string with unsafe user inputs. The inputs
14
+ # will be replaced with their escaped/prepared equivalents. This is
15
+ # handy in cases where user inputs cannot be passed as Cypher
16
+ # parameters (eg. +$subject+) like node labels or relationship
17
+ # types.
18
+ #
19
+ # Replacements must be referenced like this: +%<subject_label>s+ in
20
+ # the Cypher string template.
21
+ #
22
+ # We support various replacement/preparation strategies. Name the
23
+ # replacement keys like this:
24
+ #
25
+ # * +*_label(s?)+ will cast to string, camelize it and
26
+ # escapes if needed
27
+ # * +*_type(s?)+ will cast to string, underscore+upcase it and
28
+ # escapes if needed
29
+ # * +*_str(s?)+ will cast to string and escapes with double quotes
30
+ #
31
+ # All the replacements also work with multiple values (sets/arrays).
32
+ # The correct concatenation is guaranteed.
33
+ #
34
+ # @see https://bit.ly/3atOivN neo4j Cypher expressions
35
+ # @see https://bit.ly/3RktlEa +WHERE properties(d) = $props+
36
+ # @param replacements [Hash{Symbol,String => Mixed}] the
37
+ # inline-replacements
38
+ # @yield the given block result will be used as Cypher string
39
+ # template
40
+ # @return [String] the built Cypher query
41
+ #
42
+ # rubocop:disable Metrics/MethodLength because of the various
43
+ # replacement strategies
44
+ # rubocop:disable Metrics/AbcSize dito
45
+ def build_cypher(**replacements)
46
+ # Process the given replacements in order to prevent Cypher
47
+ # injections from user given values
48
+ replacements = replacements \
49
+ .stringify_keys
50
+ .each_with_object({}) do |(key, val), memo|
51
+ val = prepare_label(val) if key.match?(/_labels?$|^labels?$/)
52
+ val = prepare_type(val) if key.match?(/_types?$|^types?$/)
53
+ val = prepare_string(val) if key.match?(/_strs?$/)
54
+ memo[key.to_sym] = val
55
+ end
56
+
57
+ # Then evaluate the given block to get the Cypher template
58
+ # which should be interpolated with the replacements
59
+ format(yield.to_s, replacements).lines.map do |line|
60
+ line.split('//').first.rstrip.yield_self do |processed|
61
+ processed.empty? ? nil : processed
62
+ end
63
+ end.compact.join("\n")
64
+ end
65
+ # rubocop:enable Metrics/MethodLength
66
+ # rubocop:enable Metrics/AbcSize
67
+
68
+ # Prepare the given input(s) as node label for injection-free Cypher.
69
+ #
70
+ # @param inputs [Array<#to_s>] the input object(s) to prepare as label
71
+ # @return [String] the prepared and concatenated string
72
+ # @raise [ArgumentError] when no inputs are given, or only +nil+
73
+ def prepare_label(*inputs)
74
+ list = inputs.map { |itm| Array(itm) }.flatten.compact
75
+ raise ArgumentError, "Bad labels: #{inputs.inspect}" if list.empty?
76
+
77
+ list.map do |input|
78
+ res = input.to_s.underscore.gsub(/-/, '_').camelcase
79
+ res.match?(/[^a-z0-9]/i) ? "`#{res}`" : res
80
+ end.sort.uniq.join(':')
81
+ end
82
+
83
+ # Prepare the given input as relationship tyep for
84
+ # injection-free Cypher.
85
+ #
86
+ # @param inputs [Array<#to_s>] the input object(s) to prepare as type
87
+ # @return [String] the prepared string
88
+ # @raise [ArgumentError] when no inputs are given, or only +nil+
89
+ def prepare_type(*inputs)
90
+ list = inputs.map { |itm| Array(itm) }.flatten.compact
91
+ raise ArgumentError, "Bad types: #{inputs.inspect}" if list.empty?
92
+
93
+ list.map do |input|
94
+ res = input.to_s.underscore.gsub(/-/, '_').upcase
95
+ res.match?(/[^a-z0-9_]/i) ? "`#{res}`" : res
96
+ end.sort.uniq.join('|')
97
+ end
98
+
99
+ # Prepare the given input as escaped string for injection-free Cypher.
100
+ #
101
+ # @param inputs [Array<#to_s>] the input object(s) to prepare
102
+ # as string
103
+ # @return [String] the prepared string
104
+ def prepare_string(*inputs)
105
+ inputs = inputs.map { |itm| Array(itm) }.flatten.compact
106
+ return %("") if inputs.empty?
107
+
108
+ inputs.map do |input|
109
+ "\"#{input.to_s.gsub(/"/, '\"')}\""
110
+ end.uniq.join(', ')
111
+ end
112
+
113
+ # Generate a neo4j specific options data format from the given object.
114
+ #
115
+ # @param obj [Mixed] the object to convert accordingly
116
+ # @return [String] the string representation for neo4j
117
+ def to_options(obj)
118
+ # We keep nil, as it is
119
+ return if obj.nil?
120
+
121
+ # We have to escape all string input values with single quotes
122
+ return %('#{obj}') if obj.is_a? String
123
+
124
+ # We have to walk through array values and assemble
125
+ # a resulting string
126
+ if obj.is_a? Array
127
+ list = obj.map { |elem| to_options(elem) }.compact
128
+ return %([ #{list.join(', ')} ])
129
+ end
130
+
131
+ # We keep non-hash values (eg. boolean, integer, etc) as they are
132
+ # and use their Ruby string representation accordingly
133
+ return obj.to_s unless obj.is_a? Hash
134
+
135
+ # Hashes require specialized key quotation with backticks
136
+ res = obj.map { |key, value| %(`#{key}`: #{to_options(value)}) }
137
+ %({ #{res.join(', ')} })
138
+ end
139
+
140
+ # Resolve the given Cypher statement with all parameters
141
+ # for debugging.
142
+ #
143
+ # @param cypher [String] the Cypher query to perform
144
+ # @param args [Hash{Symbol => Mixed}] additional Cypher variables
145
+ # @return [String] the resolved Cypher statement
146
+ def resolve_cypher(cypher, **args)
147
+ args.reduce(cypher) do |str, (var, val)|
148
+ str.gsub(/\$#{var}\b/) do
149
+ val.is_a?(String) ? %("#{val}") : val.to_s
150
+ end
151
+ end
152
+ end
153
+
154
+ # Get the logging color for the given Cypher statement in order to
155
+ # visually distinguish the queries on the log.
156
+ #
157
+ # @param cypher [String] the Cypher query to check
158
+ # @return [Symbol] the ANSI color name
159
+ #
160
+ # rubocop:disable Metrics/CyclomaticComplexity because of the
161
+ # various conditions
162
+ def cypher_logging_color(cypher)
163
+ cypher = cypher.to_s.downcase.lines.map(&:strip)
164
+
165
+ # Check for transaction starts
166
+ return :magenta if cypher.first == 'begin'
167
+
168
+ # Check for creations/transaction commits
169
+ return :green \
170
+ if cypher.first == 'commit' || cypher.grep(/\s*create /).any?
171
+
172
+ # Check for upserts/updates
173
+ return :yellow if cypher.grep(/\s*(set|merge) /).any?
174
+
175
+ # Check for deletions
176
+ return :red if cypher.first == 'rollback' \
177
+ || cypher.grep(/\s*(delete|remove) /).any?
178
+
179
+ # Everything else, like matches
180
+ :light_blue
181
+ end
182
+ # rubocop:enable Metrics/CyclomaticComplexity
183
+ end
184
+ end
185
+ # rubocop:enable Metrics/BlockLength
186
+ end
187
+ end