ar-multidb 0.5.1 → 0.7.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.
@@ -0,0 +1,393 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Multidb::Balancer do
6
+ let(:config) { configuration_with_replicas }
7
+ let(:configuration) {
8
+ c = config.with_indifferent_access
9
+ Multidb::Configuration.new(c.except(:multidb), c[:multidb] || {})
10
+ }
11
+ let(:balancer) { global_config ? Multidb.balancer : described_class.new(configuration) }
12
+ let(:global_config) { false }
13
+
14
+ before do
15
+ ActiveRecord::Base.establish_connection(config) if global_config
16
+ end
17
+
18
+ describe '#initialize' do
19
+ subject { balancer }
20
+
21
+ context 'when configuration has no multidb config' do
22
+ let(:config) { configuration_with_replicas.except('multidb') }
23
+
24
+ it 'sets @candidates to have only default set of candidates' do
25
+ expect(subject.instance_variable_get(:@candidates).keys).to contain_exactly('default')
26
+ end
27
+
28
+ it 'sets @default_candidate to be the fist candidate for the default @candidates' do
29
+ candidates = subject.instance_variable_get(:@candidates)
30
+
31
+ expect(subject.instance_variable_get(:@default_candidate)).to eq(candidates['default'].first)
32
+ end
33
+
34
+ it 'sets @default_configuration to be the configuration' do
35
+ expect(subject.instance_variable_get(:@default_configuration)).to eq(configuration)
36
+ end
37
+
38
+ it 'sets fallback to false' do
39
+ expect(subject.fallback).to eq(false)
40
+ end
41
+
42
+ context 'when rails ENV is development' do
43
+ before do
44
+ stub_const('Rails', class_double('Rails', env: 'development'))
45
+ end
46
+
47
+ it 'sets fallback to true' do
48
+ expect(subject.fallback).to eq(true)
49
+ end
50
+ end
51
+
52
+ context 'when rails ENV is test' do
53
+ before do
54
+ stub_const('Rails', class_double('Rails', env: 'test'))
55
+ end
56
+
57
+ it 'sets fallback to true' do
58
+ expect(subject.fallback).to eq(true)
59
+ end
60
+ end
61
+ end
62
+
63
+ context 'when configuration has fallback: true' do
64
+ let(:config) { configuration_with_replicas.merge('multidb' => { 'fallback' => true }) }
65
+
66
+ it 'sets @candidates to have only default set of candidates' do
67
+ expect(subject.instance_variable_get(:@candidates).keys).to contain_exactly('default')
68
+ end
69
+
70
+ it 'sets @default_candidate to be the fist candidate for the default @candidates' do
71
+ candidates = subject.instance_variable_get(:@candidates)
72
+
73
+ expect(subject.instance_variable_get(:@default_candidate)).to eq(candidates['default'].first)
74
+ end
75
+
76
+ it 'sets @default_configuration to be the configuration' do
77
+ expect(subject.instance_variable_get(:@default_configuration)).to eq(configuration)
78
+ end
79
+
80
+ it 'sets fallback to true' do
81
+ expect(subject.fallback).to eq(true)
82
+ end
83
+ end
84
+
85
+ context 'when configuration has default multidb configuration' do
86
+ let(:config) {
87
+ extra = { multidb: { databases: {
88
+ default: {
89
+ adapter: 'sqlite3',
90
+ database: 'spec/test-default.sqlite'
91
+ }
92
+ } } }
93
+ configuration_with_replicas.merge(extra)
94
+ }
95
+
96
+ it 'set @candidates default to that configuration and not @default_candidate' do
97
+ candidates = subject.instance_variable_get(:@candidates)
98
+ default_candidate = subject.instance_variable_get(:@default_candidate)
99
+
100
+ expect(candidates[:default].first).not_to eq default_candidate
101
+ end
102
+ end
103
+
104
+ context 'when configuration is nil' do
105
+ let(:configuration) { nil }
106
+
107
+ it 'set @candidates to an empty hash' do
108
+ expect(subject.instance_variable_get(:@candidates)).to eq({})
109
+ end
110
+ end
111
+ end
112
+
113
+ describe '#append' do
114
+ subject { balancer.append(appended_config) }
115
+
116
+ let(:config) { configuration_with_replicas.except('multidb') }
117
+
118
+ context 'with a basic configuration' do
119
+ let(:appended_config) { { replica4: { database: 'spec/test-replica4.sqlite' } } }
120
+
121
+ it 'registers the new candidate set in @candidates' do
122
+ expect { subject }.to change {
123
+ balancer.instance_variable_get(:@candidates)
124
+ }.to include('replica4')
125
+ end
126
+
127
+ it 'makes it available for use' do
128
+ subject
129
+
130
+ balancer.use(:replica4) do
131
+ expect(balancer.current_connection).to have_database 'test-replica4.sqlite'
132
+ end
133
+ end
134
+
135
+ it 'returns the connection name' do
136
+ subject
137
+
138
+ balancer.use(:replica4) do
139
+ expect(balancer.current_connection_name).to eq :replica4
140
+ end
141
+ end
142
+ end
143
+
144
+ context 'with an alias' do
145
+ let(:appended_config) {
146
+ {
147
+ replica2: {
148
+ database: 'spec/test-replica4.sqlite'
149
+ },
150
+ replica_alias: {
151
+ alias: 'replica2'
152
+ }
153
+ }
154
+ }
155
+
156
+ it 'aliases replica4 as replica2' do
157
+ subject
158
+
159
+ candidates = balancer.instance_variable_get(:@candidates)
160
+
161
+ expect(candidates['replica2']).to eq(candidates['replica_alias'])
162
+ end
163
+ end
164
+ end
165
+
166
+ describe '#disconnect!' do
167
+ subject { balancer.disconnect! }
168
+
169
+ it 'calls disconnect! on all the candidates' do
170
+ candidate1 = instance_double(Multidb::Candidate, disconnect!: nil)
171
+ candidate2 = instance_double(Multidb::Candidate, disconnect!: nil)
172
+
173
+ candidates = { 'replica1' => [candidate1], 'replica2' => [candidate2] }
174
+
175
+ balancer.instance_variable_set(:@candidates, candidates)
176
+
177
+ subject
178
+
179
+ expect(candidate1).to have_received(:disconnect!)
180
+ expect(candidate2).to have_received(:disconnect!)
181
+ end
182
+ end
183
+
184
+ describe '#get' do
185
+ subject { balancer.get(name) }
186
+
187
+ let(:name) { :replica1 }
188
+ let(:candidates) { balancer.instance_variable_get(:@candidates) }
189
+
190
+ context 'when there is only one candidate' do
191
+ it 'returns the candidate' do
192
+ is_expected.to eq candidates['replica1'].first
193
+ end
194
+ end
195
+
196
+ context 'when there is more than one candidate' do
197
+ it 'returns a random candidate' do
198
+ returned = Set.new
199
+ 100.times do
200
+ returned << balancer.get(:replica3)
201
+ end
202
+
203
+ expect(returned).to match_array candidates['replica3']
204
+ end
205
+ end
206
+
207
+ context 'when the name has no configuration' do
208
+ let(:name) { :other }
209
+
210
+ context 'when fallback is false' do
211
+ it 'raises an error' do
212
+ expect { subject }.to raise_error(ArgumentError, /No such database connection/)
213
+ end
214
+ end
215
+
216
+ context 'when fallback is true' do
217
+ before do
218
+ balancer.fallback = true
219
+ end
220
+
221
+ it 'returns the default connection' do
222
+ is_expected.to eq candidates[:default].first
223
+ end
224
+ end
225
+
226
+ context 'when given a block' do
227
+ it 'yields the candidate' do
228
+ expect { |y|
229
+ balancer.get(:replica1, &y)
230
+ }.to yield_with_args(candidates[:replica1].first)
231
+ end
232
+ end
233
+ end
234
+ end
235
+
236
+ describe '#use' do
237
+ context 'with an undefined connection' do
238
+ it 'raises exception' do
239
+ expect {
240
+ balancer.use(:something) { nil }
241
+ }.to raise_error(ArgumentError)
242
+ end
243
+ end
244
+
245
+ context 'with a configured connection' do
246
+ let(:global_config) { true }
247
+
248
+ it 'returns default connection on :default' do
249
+ balancer.use(:default) do
250
+ expect(balancer.current_connection).to have_database 'test.sqlite'
251
+ end
252
+ end
253
+
254
+ it 'returns results instead of relation' do
255
+ foobar_class = Class.new(ActiveRecord::Base) do
256
+ self.table_name = 'foo_bars'
257
+ end
258
+
259
+ res = balancer.use(:replica1) do
260
+ ActiveRecord::Migration.verbose = false
261
+ ActiveRecord::Schema.define(version: 1) { create_table :foo_bars }
262
+ foobar_class.where(id: 42)
263
+ end
264
+
265
+ expect(res).to eq []
266
+ end
267
+ end
268
+
269
+ it 'returns replica connection' do
270
+ balancer.use(:replica1) do
271
+ expect(balancer.current_connection).to have_database 'test-replica1.sqlite'
272
+ end
273
+ end
274
+
275
+ it 'returns supports nested replica connection' do
276
+ balancer.use(:replica1) do
277
+ balancer.use(:replica2) do
278
+ expect(balancer.current_connection).to have_database 'test-replica2.sqlite'
279
+ end
280
+ end
281
+ end
282
+
283
+ it 'returns preserves state when nesting' do
284
+ balancer.use(:replica1) do
285
+ balancer.use(:replica2) do
286
+ expect(balancer.current_connection).to have_database 'test-replica2.sqlite'
287
+ end
288
+
289
+ expect(balancer.current_connection).to have_database 'test-replica1.sqlite'
290
+ end
291
+ end
292
+
293
+ it 'returns the parent connection for aliases' do
294
+ expect(balancer.use(:replica1)).not_to eq balancer.use(:replica_alias)
295
+ expect(balancer.use(:replica2)).to eq balancer.use(:replica_alias)
296
+ end
297
+
298
+ context 'when there are multiple candidates' do
299
+ it 'returns random candidate' do
300
+ names = []
301
+ 100.times do
302
+ balancer.use(:replica3) do
303
+ list = balancer.current_connection.execute('pragma database_list')
304
+ names.push(File.basename(list.first&.[]('file')))
305
+ end
306
+ end
307
+ expect(names.uniq).to match_array %w[test-replica3-1.sqlite test-replica3-2.sqlite]
308
+ end
309
+ end
310
+ end
311
+
312
+ describe '#current_connection' do
313
+ subject { balancer.current_connection }
314
+
315
+ context 'when no alternate connection is active' do
316
+ let(:global_config) { true }
317
+
318
+ it 'returns main connection by default' do
319
+ is_expected.to have_database 'test.sqlite'
320
+
321
+ is_expected.to eq ActiveRecord::Base.retrieve_connection
322
+ end
323
+ end
324
+
325
+ context 'when an alternate connection is active' do
326
+ before do
327
+ Thread.current[:multidb] = { connection: 'a different connection' }
328
+ end
329
+
330
+ it 'returns the thread local connection' do
331
+ is_expected.to eq 'a different connection'
332
+ end
333
+ end
334
+ end
335
+
336
+ describe '#current_connection_name' do
337
+ subject { balancer.current_connection_name }
338
+
339
+ context 'when no alternate connection is active' do
340
+ it 'returns default connection name for default connection' do
341
+ is_expected.to eq :default
342
+ end
343
+ end
344
+
345
+ context 'when an alternate connection is active' do
346
+ before do
347
+ Thread.current[:multidb] = { connection_name: :replica1 }
348
+ end
349
+
350
+ it 'returns the thread local connection' do
351
+ is_expected.to eq :replica1
352
+ end
353
+ end
354
+ end
355
+
356
+ describe 'class delegates' do
357
+ let(:balancer) {
358
+ instance_double('Multidb::Balancer',
359
+ use: nil,
360
+ current_connection: nil,
361
+ current_connection_name: nil,
362
+ disconnect!: nil)
363
+ }
364
+
365
+ before do
366
+ Multidb.instance_variable_set(:@balancer, balancer)
367
+ end
368
+
369
+ it 'delegates use to the balancer' do
370
+ described_class.use(:name)
371
+
372
+ expect(balancer).to have_received(:use).with(:name)
373
+ end
374
+
375
+ it 'delegates current_connection to the balancer' do
376
+ described_class.current_connection
377
+
378
+ expect(balancer).to have_received(:current_connection)
379
+ end
380
+
381
+ it 'delegates current_connection_name to the balancer' do
382
+ described_class.current_connection_name
383
+
384
+ expect(balancer).to have_received(:current_connection_name)
385
+ end
386
+
387
+ it 'delegates disconnect! to the balancer' do
388
+ described_class.disconnect!
389
+
390
+ expect(balancer).to have_received(:disconnect!)
391
+ end
392
+ end
393
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Multidb::Candidate do
6
+ subject(:candidate) { described_class.new(name, target) }
7
+
8
+ let(:name) { :default }
9
+ let(:config) { configuration_with_replicas.with_indifferent_access.except(:multidb) }
10
+ let(:target) { config }
11
+
12
+ describe '#initialize' do
13
+ context 'when target is a config hash' do
14
+ let(:target) { config }
15
+
16
+ it 'sets the connection_handler to a new AR connection handler' do
17
+ handler = subject.instance_variable_get(:@connection_handler)
18
+ expect(handler).to an_instance_of(ActiveRecord::ConnectionAdapters::ConnectionHandler)
19
+ end
20
+
21
+ it 'merges the name: primary into the hash', rails: '< 6.1' do
22
+ handler = instance_double('ActiveRecord::ConnectionAdapters::ConnectionHandler')
23
+ allow(ActiveRecord::ConnectionAdapters::ConnectionHandler).to receive(:new).and_return(handler)
24
+ allow(handler).to receive(:establish_connection)
25
+
26
+ subject
27
+
28
+ expect(handler).to have_received(:establish_connection).with(hash_including(name: 'primary'))
29
+ end
30
+ end
31
+
32
+ context 'when target is a connection handler' do
33
+ let(:target) { ActiveRecord::ConnectionAdapters::ConnectionHandler.new }
34
+
35
+ it 'sets the connection_handler to the passed handler' do
36
+ handler = subject.instance_variable_get(:@connection_handler)
37
+ expect(handler).to eq(target)
38
+ end
39
+ end
40
+
41
+ context 'when target is anything else' do
42
+ let(:target) { 'something else' }
43
+
44
+ it 'raises an ArgumentError' do
45
+ expect { subject }.to raise_error(ArgumentError, /Connection handler not passed/)
46
+ end
47
+ end
48
+
49
+ it 'sets the name to the name' do
50
+ expect(subject.name).to eq name
51
+ end
52
+ end
53
+
54
+ describe '#connection' do
55
+ let(:pool) {
56
+ instance_double('ActiveRecord::ConnectionAdapters::ConnectionPool').tap do |o|
57
+ allow(o).to receive(:with_connection).and_yield('a connection')
58
+ end
59
+ }
60
+ let(:target) {
61
+ ActiveRecord::ConnectionAdapters::ConnectionHandler.new.tap do |o|
62
+ allow(o).to receive(:retrieve_connection)
63
+ allow(o).to receive(:retrieve_connection_pool).and_return(pool)
64
+ end
65
+ }
66
+
67
+ context 'when given a block' do
68
+ it 'calls retrieve_connection_pool' do
69
+ subject.connection { |_| nil }
70
+
71
+ expect(target).to have_received(:retrieve_connection_pool).with(Multidb::Candidate::SPEC_NAME)
72
+ end
73
+
74
+ it 'yields a connection object' do
75
+ expect { |y|
76
+ subject.connection(&y)
77
+ }.to yield_with_args('a connection')
78
+ end
79
+ end
80
+
81
+ context 'when not given a block' do
82
+ it 'calls retrieve_connection on the handler' do
83
+ subject.connection
84
+
85
+ expect(target).to have_received(:retrieve_connection).with(Multidb::Candidate::SPEC_NAME)
86
+ end
87
+ end
88
+ end
89
+
90
+ describe '#disconnect!' do
91
+ subject { candidate.disconnect! }
92
+
93
+ let(:target) {
94
+ ActiveRecord::ConnectionAdapters::ConnectionHandler.new.tap do |o|
95
+ allow(o).to receive(:clear_all_connections!)
96
+ end
97
+ }
98
+
99
+ it 'calls clear_all_connections! on the handler' do
100
+ subject
101
+
102
+ expect(target).to have_received(:clear_all_connections!)
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Multidb::Configuration do
6
+ subject { described_class.new(config.except(:multidb), config[:multidb]) }
7
+
8
+ let(:config) { configuration_with_replicas.with_indifferent_access }
9
+
10
+ describe '#initialize' do
11
+ it 'sets the default_handler to the AR connection handler' do
12
+ expect(subject.default_handler).to eq(ActiveRecord::Base.connection_handler)
13
+ end
14
+
15
+ it 'sets the default_adapter to the main configuration' do
16
+ expect(subject.default_adapter).to eq config.except(:multidb)
17
+ end
18
+
19
+ it 'sets the raw_configuration to the multidb configuration' do
20
+ expect(subject.raw_configuration).to eq config[:multidb]
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Multidb::LogSubscriberExtension do
6
+ before do
7
+ ActiveRecord::Base.establish_connection(configuration_with_replicas)
8
+ end
9
+
10
+ let(:klass) {
11
+ klass = Class.new do
12
+ def sql(event)
13
+ event
14
+ end
15
+
16
+ def debug(msg)
17
+ msg
18
+ end
19
+
20
+ def color(text, _color, _bold)
21
+ text
22
+ end
23
+ end
24
+
25
+ klass.tap do |o|
26
+ o.prepend described_class
27
+ end
28
+ }
29
+
30
+ let(:instance) { klass.new }
31
+
32
+ it 'prepends the extension into the ActiveRecord::LogSubscriber' do
33
+ expect(ActiveRecord::LogSubscriber.included_modules).to include(described_class)
34
+ end
35
+
36
+ describe '#sql' do
37
+ subject { instance.sql(event) }
38
+
39
+ let(:event) { instance_double('Event', payload: {}) }
40
+
41
+ it 'sets the :default db_name into the event payload' do
42
+ expect { subject }.to change { event.payload }.to include(db_name: :default)
43
+ end
44
+
45
+ context 'when a replica is active' do
46
+ it 'sets the db_name into the event payload to the replica' do
47
+ expect {
48
+ Multidb.use(:replica1) { subject }
49
+ }.to change { event.payload }.to include(db_name: :replica1)
50
+ end
51
+ end
52
+
53
+ context 'when there is no name returned from the balancer' do
54
+ before do
55
+ allow(Multidb.balancer).to receive(:current_connection_name)
56
+ end
57
+
58
+ it 'does not change the payload' do
59
+ expect { subject }.not_to change { event.payload }
60
+ end
61
+ end
62
+ end
63
+
64
+ describe '#debug' do
65
+ subject { instance.debug('message') }
66
+
67
+ it 'prepends the db name to the message' do
68
+ is_expected.to include('[DB: default]')
69
+ end
70
+
71
+ context 'when a replica is active' do
72
+ it 'prepends the replica dbname to the message' do
73
+ Multidb.use(:replica1) {
74
+ is_expected.to include('[DB: replica1')
75
+ }
76
+ end
77
+ end
78
+
79
+ context 'when there is no name returned from the balancer' do
80
+ before do
81
+ allow(Multidb.balancer).to receive(:current_connection_name)
82
+ end
83
+
84
+ it 'does not prepend to the message' do
85
+ is_expected.to eq('message')
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Multidb::ModelExtensions do
6
+ it 'includes the Multidb::Connection module into the class methods of ActiveRecord::Base' do
7
+ expect(ActiveRecord::Base.singleton_class.included_modules).to include Multidb::Connection
8
+ end
9
+
10
+ describe Multidb::Connection do
11
+ describe '.establish_connection' do
12
+ subject { ActiveRecord::Base.establish_connection(configuration_with_replicas) }
13
+
14
+ it 'initializes multidb' do
15
+ allow(Multidb).to receive(:init)
16
+
17
+ subject
18
+
19
+ expect(Multidb).to have_received(:init)
20
+ end
21
+ end
22
+
23
+ describe '.connection' do
24
+ subject { klass.connection }
25
+
26
+ let(:klass) {
27
+ Class.new do
28
+ def self.connection
29
+ 'AR connection'
30
+ end
31
+
32
+ include Multidb::ModelExtensions
33
+ end
34
+ }
35
+
36
+ context 'when multidb is not initialized' do
37
+ it 'calls AR::Base.connection' do
38
+ is_expected.to eq('AR connection')
39
+ end
40
+ end
41
+
42
+ context 'when multidb is initialized' do
43
+ let(:balancer) { instance_double('Multidb::Balancer', current_connection: 'Multidb connection') }
44
+
45
+ before do
46
+ Multidb.instance_variable_set(:@balancer, balancer)
47
+ end
48
+
49
+ it 'calls current_connection on the balancer' do
50
+ subject
51
+
52
+ expect(balancer).to have_received(:current_connection)
53
+ end
54
+
55
+ it 'returns the balancer connection' do
56
+ is_expected.to eq('Multidb connection')
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end