ar-multidb 0.4.2 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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