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