boltless 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +8 -0
  3. data/Guardfile +44 -0
  4. data/Makefile +138 -0
  5. data/Rakefile +26 -0
  6. data/docker-compose.yml +19 -0
  7. data/lib/boltless/configuration.rb +69 -0
  8. data/lib/boltless/errors/invalid_json_error.rb +9 -0
  9. data/lib/boltless/errors/request_error.rb +24 -0
  10. data/lib/boltless/errors/response_error.rb +30 -0
  11. data/lib/boltless/errors/transaction_begin_error.rb +9 -0
  12. data/lib/boltless/errors/transaction_in_bad_state_error.rb +11 -0
  13. data/lib/boltless/errors/transaction_not_found_error.rb +11 -0
  14. data/lib/boltless/errors/transaction_rollback_error.rb +26 -0
  15. data/lib/boltless/extensions/configuration_handling.rb +37 -0
  16. data/lib/boltless/extensions/connection_pool.rb +127 -0
  17. data/lib/boltless/extensions/operations.rb +175 -0
  18. data/lib/boltless/extensions/transactions.rb +301 -0
  19. data/lib/boltless/extensions/utilities.rb +187 -0
  20. data/lib/boltless/request.rb +386 -0
  21. data/lib/boltless/result.rb +98 -0
  22. data/lib/boltless/result_row.rb +90 -0
  23. data/lib/boltless/statement_collector.rb +40 -0
  24. data/lib/boltless/transaction.rb +234 -0
  25. data/lib/boltless/version.rb +23 -0
  26. data/lib/boltless.rb +36 -0
  27. data/spec/benchmark/transfer.rb +57 -0
  28. data/spec/boltless/extensions/configuration_handling_spec.rb +39 -0
  29. data/spec/boltless/extensions/connection_pool_spec.rb +131 -0
  30. data/spec/boltless/extensions/operations_spec.rb +189 -0
  31. data/spec/boltless/extensions/transactions_spec.rb +418 -0
  32. data/spec/boltless/extensions/utilities_spec.rb +546 -0
  33. data/spec/boltless/request_spec.rb +946 -0
  34. data/spec/boltless/result_row_spec.rb +161 -0
  35. data/spec/boltless/result_spec.rb +127 -0
  36. data/spec/boltless/statement_collector_spec.rb +45 -0
  37. data/spec/boltless/transaction_spec.rb +601 -0
  38. data/spec/boltless_spec.rb +11 -0
  39. data/spec/fixtures/files/raw_result.yml +21 -0
  40. data/spec/fixtures/files/raw_result_with_graph_result.yml +48 -0
  41. data/spec/fixtures/files/raw_result_with_meta.yml +11 -0
  42. data/spec/fixtures/files/raw_result_with_stats.yml +26 -0
  43. data/spec/spec_helper.rb +89 -0
  44. metadata +384 -0
@@ -0,0 +1,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