boltless 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +8 -0
- data/Guardfile +44 -0
- data/Makefile +138 -0
- data/Rakefile +26 -0
- data/docker-compose.yml +19 -0
- data/lib/boltless/configuration.rb +69 -0
- data/lib/boltless/errors/invalid_json_error.rb +9 -0
- data/lib/boltless/errors/request_error.rb +24 -0
- data/lib/boltless/errors/response_error.rb +30 -0
- data/lib/boltless/errors/transaction_begin_error.rb +9 -0
- data/lib/boltless/errors/transaction_in_bad_state_error.rb +11 -0
- data/lib/boltless/errors/transaction_not_found_error.rb +11 -0
- data/lib/boltless/errors/transaction_rollback_error.rb +26 -0
- data/lib/boltless/extensions/configuration_handling.rb +37 -0
- data/lib/boltless/extensions/connection_pool.rb +127 -0
- data/lib/boltless/extensions/operations.rb +175 -0
- data/lib/boltless/extensions/transactions.rb +301 -0
- data/lib/boltless/extensions/utilities.rb +187 -0
- data/lib/boltless/request.rb +386 -0
- data/lib/boltless/result.rb +98 -0
- data/lib/boltless/result_row.rb +90 -0
- data/lib/boltless/statement_collector.rb +40 -0
- data/lib/boltless/transaction.rb +234 -0
- data/lib/boltless/version.rb +23 -0
- data/lib/boltless.rb +36 -0
- data/spec/benchmark/transfer.rb +57 -0
- data/spec/boltless/extensions/configuration_handling_spec.rb +39 -0
- data/spec/boltless/extensions/connection_pool_spec.rb +131 -0
- data/spec/boltless/extensions/operations_spec.rb +189 -0
- data/spec/boltless/extensions/transactions_spec.rb +418 -0
- data/spec/boltless/extensions/utilities_spec.rb +546 -0
- data/spec/boltless/request_spec.rb +946 -0
- data/spec/boltless/result_row_spec.rb +161 -0
- data/spec/boltless/result_spec.rb +127 -0
- data/spec/boltless/statement_collector_spec.rb +45 -0
- data/spec/boltless/transaction_spec.rb +601 -0
- data/spec/boltless_spec.rb +11 -0
- data/spec/fixtures/files/raw_result.yml +21 -0
- data/spec/fixtures/files/raw_result_with_graph_result.yml +48 -0
- data/spec/fixtures/files/raw_result_with_meta.yml +11 -0
- data/spec/fixtures/files/raw_result_with_stats.yml +26 -0
- data/spec/spec_helper.rb +89 -0
- metadata +384 -0
@@ -0,0 +1,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
|