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