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,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boltless
4
+ # A single neo4j transaction representation.
5
+ #
6
+ # When passing Cypher statements you can tweak some HTTP API result options
7
+ # while passing the following keys to the Cypher parameters (they wont be
8
+ # sent to neo4j):
9
+ #
10
+ # * +with_stats: true|false+: whenever to include statement
11
+ # statistics, or not (see: https://bit.ly/3SKXfC8)
12
+ # * +result_as_graph: true|false+: whenever to return the result as a graph
13
+ # structure that can be visualized (see: https://bit.ly/3doJw3Z)
14
+ #
15
+ # Error handling details (see: https://bit.ly/3pdqTCy):
16
+ #
17
+ # > If there is an error in a request, the server will roll back the
18
+ # > transaction. You can tell if the transaction is still open by inspecting
19
+ # > the response for the presence/absence of the transaction key.
20
+ class Transaction
21
+ # We allow to read some internal configurations
22
+ attr_reader :access_mode, :id, :raw_state
23
+
24
+ # We allow to access helpful utilities straigth from here
25
+ delegate :build_cypher, :prepare_label, :prepare_type, :prepare_string,
26
+ :to_options, :resolve_cypher,
27
+ to: Boltless
28
+
29
+ # Setup a new neo4j transaction management instance.
30
+ #
31
+ # @param connection [HTTP::Client] a ready to use persistent
32
+ # connection object
33
+ # @param database [String, Symbol] the neo4j database to use
34
+ # @param access_mode [String, Symbol] the neo4j
35
+ # transaction mode (+:read+, or +:write+)
36
+ # @param raw_results [Boolean] whenever to return the plain HTTP API JSON
37
+ # results (as plain +Hash{Symbol => Mixed}/Array+ data), or not (then we
38
+ # return +Array<Boltless::Result>+ structs
39
+ def initialize(connection, database: Boltless.configuration.default_db,
40
+ access_mode: :write, raw_results: false)
41
+ @request = Request.new(connection, access_mode: access_mode,
42
+ database: database,
43
+ raw_results: raw_results)
44
+ @access_mode = access_mode
45
+ @raw_state = :not_yet_started
46
+ end
47
+
48
+ # Return the transaction state as +ActiveSupport::StringInquirer+
49
+ # for convenience.
50
+ #
51
+ # @return [ActiveSupport::StringInquirer] the transaction state
52
+ def state
53
+ ActiveSupport::StringInquirer.new(@raw_state.to_s)
54
+ end
55
+
56
+ # Begin a new transaction. No exceptions will be rescued.
57
+ #
58
+ # @return [TrueClass] when the transaction was successfully started
59
+ #
60
+ # @raise [Errors::RequestError] when an error occurs, see request object
61
+ # for fine-grained details
62
+ def begin!
63
+ # We do not allow messing around in wrong states
64
+ unless @raw_state == :not_yet_started
65
+ raise Errors::TransactionInBadStateError,
66
+ "Transaction already #{@raw_state}"
67
+ end
68
+
69
+ @id = @request.begin_transaction
70
+ @raw_state = :open
71
+ true
72
+ end
73
+
74
+ # Begin a new transaction. We rescue all errors transparently.
75
+ #
76
+ # @return [Boolean] whenever the transaction was successfully started,
77
+ # or not
78
+ def begin
79
+ handle_errors(false) { begin! }
80
+ end
81
+
82
+ # Run a single Cypher statement inside the transaction. This results
83
+ # in a single HTTP API request for the statement.
84
+ #
85
+ # @param cypher [String] the Cypher statement to run
86
+ # @param args [Hash{Symbol => Mixed}] the additional Cypher parameters
87
+ # @return [Hash{Symbol => Mixed}] the raw neo4j results
88
+ #
89
+ # @raise [Errors::RequestError] when an error occurs, see request object
90
+ # for fine-grained details
91
+ def run!(cypher, **args)
92
+ # We do not allow messing around in wrong states
93
+ raise Errors::TransactionInBadStateError, 'Transaction not open' \
94
+ unless @raw_state == :open
95
+
96
+ @request.run_query(@id, Request.statement_payload(cypher, **args)).first
97
+ end
98
+
99
+ # Run a single Cypher statement inside the transaction. This results in a
100
+ # single HTTP API request for the statement. We rescue all errors
101
+ # transparently.
102
+ #
103
+ # @param cypher [String] the Cypher statement to run
104
+ # @param args [Hash{Symbol => Mixed}] the additional Cypher parameters
105
+ # @return [Array<Hash{Symbol => Mixed}>, nil] the raw neo4j results,
106
+ # or +nil+ in case of errors
107
+ def run(cypher, **args)
108
+ handle_errors { run!(cypher, **args) }
109
+ end
110
+
111
+ # Run a multiple Cypher statement inside the transaction. This results
112
+ # in a single HTTP API request for all the statements.
113
+ #
114
+ # @param statements [Array<Hash>] the Cypher statements to run
115
+ # @return [Array<Hash{Symbol => Mixed}>] the raw neo4j results
116
+ #
117
+ # @raise [Errors::RequestError] when an error occurs, see request object
118
+ # for fine-grained details
119
+ def run_in_batch!(*statements)
120
+ # We do not allow messing around in wrong states
121
+ raise Errors::TransactionInBadStateError, 'Transaction not open' \
122
+ unless @raw_state == :open
123
+
124
+ @request.run_query(@id, *Request.statement_payloads(*statements))
125
+ end
126
+
127
+ # Run a multiple Cypher statement inside the transaction. This results
128
+ # in a single HTTP API request for all the statements. We rescue all errors
129
+ # transparently.
130
+ #
131
+ # @param statements [Array<Hash>] the Cypher statements to run
132
+ # @return [Array<Hash{Symbol => Mixed}>, nil] the raw neo4j results,
133
+ # or +nil+ in case of errors
134
+ #
135
+ # @raise [Errors::RequestError] when an error occurs, see request object
136
+ # for fine-grained details
137
+ def run_in_batch(*statements)
138
+ handle_errors { run_in_batch!(*statements) }
139
+ end
140
+
141
+ # Commit the transaction, while also sending finalizing Cypher
142
+ # statement(s). This results in a single HTTP API request for all the
143
+ # statement(s). You can also omit the statement(s) in order to just commit
144
+ # the transaction.
145
+ #
146
+ # @param statements [Array<Hash>] the Cypher statements to run
147
+ # @return [Array<Hash{Symbol => Mixed}>] the raw neo4j results
148
+ #
149
+ # @raise [Errors::RequestError] when an error occurs, see request object
150
+ # for fine-grained details
151
+ def commit!(*statements)
152
+ # We do not allow messing around in wrong states
153
+ raise Errors::TransactionInBadStateError, 'Transaction not open' \
154
+ unless @raw_state == :open
155
+
156
+ @request.commit_transaction(
157
+ @id,
158
+ *Request.statement_payloads(*statements)
159
+ ).tap { @raw_state = :closed }
160
+ end
161
+
162
+ # Commit the transaction, while also sending finalizing Cypher
163
+ # statement(s). This results in a single HTTP API request for all the
164
+ # statement(s). You can also omit the statement(s) in order to just commit
165
+ # the transaction. We rescue all errors transparently.
166
+ #
167
+ # @param statements [Array<Hash>] the Cypher statements to run
168
+ # @return [Array<Hash{Symbol => Mixed}>, nil] the raw neo4j results,
169
+ # or +nil+ in case of errors
170
+ #
171
+ # @raise [Errors::RequestError] when an error occurs, see request object
172
+ # for fine-grained details
173
+ def commit(*statements)
174
+ handle_errors { commit!(*statements) }
175
+ end
176
+
177
+ # Rollback this transaction. No exceptions will be rescued.
178
+ #
179
+ # @return [TrueClass] when the transaction was successfully rolled back
180
+ #
181
+ # @raise [Errors::RequestError] when an error occurs, see request object
182
+ # for fine-grained details
183
+ def rollback!
184
+ # We do not allow messing around in wrong states
185
+ raise Errors::TransactionInBadStateError, 'Transaction not open' \
186
+ unless @raw_state == :open
187
+
188
+ @request.rollback_transaction(@id)
189
+ @raw_state = :closed
190
+ true
191
+ end
192
+
193
+ # Rollback this transaction. We rescue all errors transparently.
194
+ #
195
+ # @return [Boolean] whenever the transaction was successfully rolled back,
196
+ # or not
197
+ def rollback
198
+ handle_errors(false) { rollback! }
199
+ end
200
+
201
+ # Handle all request/response errors of the low-level connection for
202
+ # our non-bang methods in a generic way.
203
+ #
204
+ # @param error_result [Proc, Mixed] the object to return on errors, when a
205
+ # proc is given, we call it with the actual exception object as parameter
206
+ # and use the result of the proc as return value
207
+ # @yield the given block
208
+ # @return [Mixed] the result of the block, or on exceptions the
209
+ # given +error_result+
210
+ def handle_errors(error_result = nil)
211
+ yield
212
+ rescue Errors::RequestError, Errors::ResponseError,
213
+ Errors::TransactionInBadStateError => e
214
+ # When an error occured, the transaction is automatically rolled back by
215
+ # neo4j, so we cannot handle any further interaction
216
+ cleanup
217
+ @raw_state = :closed
218
+
219
+ # When we got a proc/lambda as error result, call it
220
+ return error_result.call(e) if error_result.is_a? Proc
221
+
222
+ # Otherwise use the error result as it is
223
+ error_result
224
+ end
225
+
226
+ # Clean the transaction, in order to make it unusable for further
227
+ # interaction. This prevents users from leaking the transaction context and
228
+ # mess around with the connection pool.
229
+ def cleanup
230
+ @request = nil
231
+ @raw_state = :cleaned
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The gem version details.
4
+ module Boltless
5
+ # The version of the +boltless+ gem
6
+ VERSION = '1.0.0'
7
+
8
+ class << self
9
+ # Returns the version of gem as a string.
10
+ #
11
+ # @return [String] the gem version as string
12
+ def version
13
+ VERSION
14
+ end
15
+
16
+ # Returns the version of the gem as a +Gem::Version+.
17
+ #
18
+ # @return [Gem::Version] the gem version as object
19
+ def gem_version
20
+ Gem::Version.new VERSION
21
+ end
22
+ end
23
+ end
data/lib/boltless.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zeitwerk'
4
+ require 'http'
5
+ require 'connection_pool'
6
+ require 'oj'
7
+ require 'fast_jsonparser'
8
+ require 'colorize'
9
+ require 'active_support'
10
+ require 'active_support/concern'
11
+ require 'active_support/core_ext/module/delegation'
12
+ require 'active_support/core_ext/numeric/time'
13
+ require 'active_support/core_ext/enumerable'
14
+ require 'pp'
15
+
16
+ # The gem root namespace. Everything is bundled here.
17
+ module Boltless
18
+ # Setup a Zeitwerk autoloader instance and configure it
19
+ loader = Zeitwerk::Loader.for_gem
20
+
21
+ # Finish the auto loader configuration
22
+ loader.setup
23
+
24
+ # Load standalone code
25
+ require 'boltless/version'
26
+
27
+ # Include top-level features
28
+ include Extensions::ConfigurationHandling
29
+ include Extensions::ConnectionPool
30
+ include Extensions::Transactions
31
+ include Extensions::Operations
32
+ include Extensions::Utilities
33
+
34
+ # Make sure to eager load all SDK constants
35
+ loader.eager_load
36
+ end
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'boltless'
6
+ require 'benchmark'
7
+
8
+ Boltless.configure do |conf|
9
+ conf.base_url = 'http://172.17.0.4:7474'
10
+ end
11
+
12
+ Benchmark.bm do |x|
13
+ cycles = 150
14
+
15
+ cypher = <<~CYPHER
16
+ MATCH (n:User { id: $subject }) -[:ROLE*]-> () -[r:READ]-> (o)
17
+ WITH
18
+ o.id AS id,
19
+ null AS all,
20
+ r.through AS through,
21
+ r.condition_keys AS keys,
22
+ r.condition_values AS values
23
+ RETURN DISTINCT id, all, through, keys, values
24
+ CYPHER
25
+
26
+ x.report('.transaction (raw results)') do
27
+ cycles.times do
28
+ Boltless.transaction(raw_results: true) do |tx|
29
+ tx.run(cypher, subject: '2d07b107-2a11-436e-a66d-6e0e0272d78e')
30
+ end.first[:data].count
31
+ end
32
+ end
33
+
34
+ x.report('.transaction') do
35
+ cycles.times do
36
+ Boltless.transaction(raw_results: false) do |tx|
37
+ tx.run(cypher, subject: '2d07b107-2a11-436e-a66d-6e0e0272d78e')
38
+ end.first.count
39
+ end
40
+ end
41
+ end
42
+
43
+ # == Old hard-mapping
44
+ # user system total real
45
+ # .transaction (raw results) 66.999341 22.444895 89.444236 (227.354975)
46
+ # .transaction 133.277385 23.208360 156.485745 (291.491150)
47
+ #
48
+ # .transaction (raw results) avg(real/150) 1.51569s
49
+ # .transaction avg(real/150) 1.94327s (1.3x slower)
50
+
51
+ # == New lazy mapping
52
+ # user system total real
53
+ # .transaction (raw results) 67.460811 22.520042 89.980853 (229.456833)
54
+ # .transaction 73.468645 22.909531 96.378176 (237.652516)
55
+ #
56
+ # .transaction (raw results) avg(real/150) 1.52971222s
57
+ # .transaction avg(real/150) 1.58435011s (±1.0x same-ish)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Boltless::Extensions::ConfigurationHandling do
6
+ let(:described_class) { Boltless }
7
+
8
+ before { described_class.reset_configuration! }
9
+
10
+ it 'allows the access of the configuration' do
11
+ expect(described_class.configuration).not_to be_nil
12
+ end
13
+
14
+ describe '.configure' do
15
+ it 'yields the configuration' do
16
+ expect do |block|
17
+ described_class.configure(&block)
18
+ end.to yield_with_args(described_class.configuration)
19
+ end
20
+ end
21
+
22
+ describe '.reset_configuration!' do
23
+ it 'resets the configuration to its defaults' do
24
+ described_class.configuration.request_timeout = 100
25
+ expect { described_class.reset_configuration! }.to \
26
+ change { described_class.configuration.request_timeout }
27
+ end
28
+ end
29
+
30
+ describe '.logger' do
31
+ it 'returns a Logger instance' do
32
+ expect(described_class.logger).to be_a(Logger)
33
+ end
34
+
35
+ it 'returns a logger with the default info level' do
36
+ expect(described_class.logger.level).to be_eql(Logger::INFO)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Boltless::Extensions::ConnectionPool do
6
+ let(:described_class) { Boltless }
7
+ let(:reload) do
8
+ proc do
9
+ described_class.connection_pool.shutdown(&:close)
10
+ described_class.instance_variable_set(:@connection_pool, nil)
11
+ end
12
+ end
13
+
14
+ after do
15
+ Boltless.reset_configuration!
16
+ reload.call
17
+ end
18
+
19
+ describe '.connection_pool' do
20
+ let(:action) { described_class.connection_pool }
21
+ let(:options) { action.checkout.default_options }
22
+ let(:auth) do
23
+ Base64.decode64(
24
+ options.headers['Authorization'].split(' ').last
25
+ ).split(':')
26
+ end
27
+
28
+ it 'returns a memoized connection pool instance' do
29
+ expect(described_class.connection_pool).to \
30
+ be(described_class.connection_pool)
31
+ end
32
+
33
+ it 'returns a HTTP client instance when requested' do
34
+ expect(action.checkout).to be_a(HTTP::Client)
35
+ end
36
+
37
+ it 'returns a configured HTTP client (pool size)' do
38
+ Boltless.configuration.connection_pool_size = 1
39
+ reload.call
40
+ expect(action.size).to be_eql(1)
41
+ end
42
+
43
+ it 'returns a configured HTTP client (connection aquire timeout)' do
44
+ Boltless.configuration.connection_pool_timeout = 1
45
+ reload.call
46
+ expect(action.instance_variable_get(:@timeout)).to be_eql(1)
47
+ end
48
+
49
+ it 'returns a configured HTTP client (persistent base URL)' do
50
+ Boltless.configuration.base_url = 'http://test:1234'
51
+ reload.call
52
+ expect(options.persistent).to be_eql('http://test:1234')
53
+ end
54
+
55
+ it 'returns a configured HTTP client (username)' do
56
+ Boltless.configuration.username = 'test'
57
+ reload.call
58
+ expect(auth.first).to be_eql('test')
59
+ end
60
+
61
+ it 'returns a configured HTTP client (password)' do
62
+ Boltless.configuration.password = 'test'
63
+ reload.call
64
+ expect(auth.last).to be_eql('test')
65
+ end
66
+
67
+ it 'returns a configured HTTP client (request timeout)' do
68
+ Boltless.configuration.request_timeout = 7
69
+ reload.call
70
+ expect(options.timeout_options[:global_timeout]).to be_eql(7)
71
+ end
72
+
73
+ it 'allows send requests' do
74
+ expect(action.checkout.get('/').to_s).to include('neo4j')
75
+ end
76
+ end
77
+
78
+ describe '.wait_for_server!' do
79
+ let(:connection) { described_class.connection_pool.checkout }
80
+ let(:action) { described_class.wait_for_server!(connection) }
81
+ let(:request_timeout) { 2.seconds }
82
+ let(:wait_for_upstream_server) { 2.seconds }
83
+ let(:logger) { Logger.new(log) }
84
+ let(:log) { StringIO.new }
85
+
86
+ before do
87
+ Boltless.configure do |conf|
88
+ conf.request_timeout = request_timeout
89
+ conf.wait_for_upstream_server = wait_for_upstream_server
90
+ conf.logger = logger
91
+ end
92
+ Boltless.instance_variable_set(:@upstream_is_ready, nil)
93
+ Boltless.instance_variable_set(:@upstream_retry_count, nil)
94
+ reload.call
95
+ end
96
+
97
+ context 'when the server is up and running' do
98
+ it 'returns the given connection' do
99
+ expect(action).to be(connection)
100
+ end
101
+
102
+ it 'memoizes the check' do
103
+ action
104
+ expect(connection).not_to receive(:get)
105
+ action
106
+ end
107
+ end
108
+
109
+ context 'when the server is not available' do
110
+ before do
111
+ Boltless.configuration.base_url = 'http://localhost:8751'
112
+ reload.call
113
+ end
114
+
115
+ it 'raises a HTTP::ConnectionError' do
116
+ expect { action }.to raise_error(HTTP::ConnectionError)
117
+ end
118
+
119
+ describe 'logging' do
120
+ let(:wait_for_upstream_server) { 2.1.seconds }
121
+
122
+ it 'logs a retry' do
123
+ suppress(HTTP::ConnectionError) { action }
124
+ expect(log.string).to \
125
+ include('neo4j is unavailable, retry in 2 seconds ' \
126
+ '(1/2, http://localhost:8751)')
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end