janus-ar 8.0.0 → 8.0.1

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.
@@ -1,59 +1,141 @@
1
- # frozen_string_literal: true
2
-
3
- RSpec.describe Janus::QueryDirector do
4
- describe 'Constants' do
5
- it { expect(described_class::SQL_SKIP_ALL_MATCHERS).to eq [/\A\s*set\s+local\s/i] }
6
- it {
7
- expect(described_class::SQL_PRIMARY_MATCHERS).to eq(
8
- [
9
- /\A\s*select.+for update\Z/i, /select.+lock in share mode\Z/i,
10
- /\A\s*select.+(nextval|currval|lastval|get_lock|release_lock|pg_advisory_lock|pg_advisory_unlock)\(/i,
11
- /\A\s*show/i
12
- ]
13
- )
14
- }
15
- it { expect(described_class::SQL_REPLICA_MATCHERS).to eq([/\A\s*(select|with.+\)\s*select)\s/i]) }
16
- it { expect(described_class::SQL_ALL_MATCHERS).to eq([/\A\s*set\s/i]) }
17
- it {
18
- expect(described_class::WRITE_PREFIXES).to eq %w(INSERT UPDATE DELETE LOCK CREATE GRANT DROP ALTER TRUNCATE BEGIN
19
- SAVEPOINT FLUSH)
20
- }
21
-
22
- it { expect(described_class::ALL).to eq :all }
23
- it { expect(described_class::REPLICA).to eq :replica }
24
- it { expect(described_class::PRIMARY).to eq :primary }
25
- end
26
-
27
- describe '#where_to_send?' do
28
- before(:each) do
29
- Janus::Context.release_all
30
- end
31
-
32
- context 'when should send to all' do
33
- it 'returns :all' do
34
- sql = 'SET foo = bar'
35
- open_transactions = 0
36
- query_director = described_class.new(sql, open_transactions)
37
- expect(query_director.where_to_send?).to eq(:all)
38
- end
39
- end
40
-
41
- context 'when can go to replica' do
42
- it 'returns :replica' do
43
- sql = 'SELECT * FROM users'
44
- open_transactions = 0
45
- query_director = described_class.new(sql, open_transactions)
46
- expect(query_director.where_to_send?).to eq(:replica)
47
- end
48
- end
49
-
50
- context 'when should go to primary' do
51
- it 'returns :primary' do
52
- sql = 'INSERT INTO users (name) VALUES ("John")'
53
- open_transactions = 0
54
- query_director = described_class.new(sql, open_transactions)
55
- expect(query_director.where_to_send?).to eq(:primary)
56
- end
57
- end
58
- end
59
- end
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Janus::QueryDirector do
4
+ describe 'Constants' do
5
+ it { expect(described_class::SQL_SKIP_ALL_MATCHERS).to eq [/\A\s*set\s+local\s/i] }
6
+ it {
7
+ expect(described_class::SQL_PRIMARY_MATCHERS).to eq(
8
+ [
9
+ /\A\s*select.+for update\Z/i, /select.+lock in share mode\Z/i,
10
+ /\A\s*select.+(nextval|currval|lastval|get_lock|release_lock|pg_advisory_lock|pg_advisory_unlock)\(/i,
11
+ /\A\s*show/i
12
+ ]
13
+ )
14
+ }
15
+ it { expect(described_class::SQL_REPLICA_MATCHERS).to eq([/\A\s*(select|with.+\)\s*select)\s/i]) }
16
+ it { expect(described_class::SQL_ALL_MATCHERS).to eq([/\A\s*set\s/i]) }
17
+
18
+ it { expect(described_class::ALL).to eq :all }
19
+ it { expect(described_class::REPLICA).to eq :replica }
20
+ it { expect(described_class::PRIMARY).to eq :primary }
21
+ end
22
+
23
+ describe '#where_to_send?' do
24
+ let(:open_transactions) { 0 }
25
+
26
+ before(:each) { Janus::Context.release_all }
27
+
28
+ context 'with reads' do
29
+ {
30
+ 'plain select' => 'SELECT * FROM users',
31
+ 'lower case select' => 'select * from users',
32
+ 'CTE select' => 'WITH recent AS (SELECT * FROM users) SELECT * FROM recent',
33
+ 'leading whitespace' => "\n\t SELECT 1",
34
+ }.each do |label, query|
35
+ it "routes a #{label} to the replica" do
36
+ expect(described_class.new(query, 0).where_to_send?).to eq(:replica)
37
+ end
38
+ end
39
+ end
40
+
41
+ context 'with writes' do
42
+ # Every one of these used to be sent to the replica because the router
43
+ # defaulted unknown statements there. They are genuine writes and must
44
+ # reach the primary.
45
+ %w(
46
+ INSERT UPDATE DELETE REPLACE RENAME CALL LOAD OPTIMIZE ANALYZE REPAIR
47
+ CREATE DROP TRUNCATE ALTER
48
+ ).each do |verb|
49
+ it "routes #{verb} to the primary" do
50
+ sql = "#{verb} something that is not a read"
51
+ expect(described_class.new(sql, 0).where_to_send?).to eq(:primary)
52
+ end
53
+ end
54
+
55
+ it 'routes a write annotated with a leading comment to the primary' do
56
+ sql = '/* app:web,controller:orders */ INSERT INTO orders (id) VALUES (1)'
57
+ expect(described_class.new(sql, 0).where_to_send?).to eq(:primary)
58
+ end
59
+
60
+ it 'routes a read annotated with a leading comment to the replica' do
61
+ sql = '/* app:web */ SELECT * FROM orders'
62
+ expect(described_class.new(sql, 0).where_to_send?).to eq(:replica)
63
+ end
64
+ end
65
+
66
+ context 'with comments and whitespace around the statement' do
67
+ {
68
+ 'a line (--) comment then a read' => "-- audit trail\nSELECT * FROM orders",
69
+ 'a hash (#) comment then a read' => "# cache key\nSELECT * FROM orders",
70
+ 'several stacked leading comments then a read' => "/* a */ /* b */\n\t SELECT 1",
71
+ 'tabs and newlines then a read' => "\n\t SELECT 1",
72
+ }.each do |label, query|
73
+ it "routes #{label} to the replica" do
74
+ expect(described_class.new(query, 0).where_to_send?).to eq(:replica)
75
+ end
76
+ end
77
+
78
+ {
79
+ 'a line (--) comment then a write' => "-- triggered by job 42\nUPDATE orders SET state = 1",
80
+ 'a hash (#) comment then a write' => "# backfill\nDELETE FROM orders",
81
+ 'a block comment then a write' => "/* migration */ ALTER TABLE orders ADD COLUMN x INT",
82
+ }.each do |label, query|
83
+ it "routes #{label} to the primary" do
84
+ expect(described_class.new(query, 0).where_to_send?).to eq(:primary)
85
+ end
86
+ end
87
+ end
88
+
89
+ context 'with locking reads' do
90
+ it 'routes SELECT ... FOR UPDATE to the primary' do
91
+ expect(described_class.new('SELECT * FROM users FOR UPDATE', 0).where_to_send?).to eq(:primary)
92
+ end
93
+
94
+ it 'routes SELECT ... LOCK IN SHARE MODE to the primary' do
95
+ expect(described_class.new('SELECT * FROM users LOCK IN SHARE MODE', 0).where_to_send?).to eq(:primary)
96
+ end
97
+
98
+ it 'routes advisory lock reads to the primary' do
99
+ expect(described_class.new("SELECT get_lock('x', 0)", 0).where_to_send?).to eq(:primary)
100
+ end
101
+ end
102
+
103
+ context 'with SHOW' do
104
+ it 'routes SHOW statements to the primary' do
105
+ expect(described_class.new('SHOW TABLES', 0).where_to_send?).to eq(:primary)
106
+ end
107
+ end
108
+
109
+ context 'with SET' do
110
+ it 'routes SET to all connections' do
111
+ expect(described_class.new('SET sql_mode = ?', 0).where_to_send?).to eq(:all)
112
+ end
113
+
114
+ it 'does not broadcast SET LOCAL to all connections' do
115
+ expect(described_class.new('SET LOCAL sql_mode = ?', 0).where_to_send?).to eq(:primary)
116
+ end
117
+ end
118
+
119
+ context 'when the context is stuck to the primary' do
120
+ before(:each) { Janus::Context.stick_to_primary }
121
+
122
+ it 'routes an otherwise replica-bound read to the primary' do
123
+ expect(described_class.new('SELECT * FROM users', 0).where_to_send?).to eq(:primary)
124
+ end
125
+ end
126
+
127
+ context 'when inside a transaction' do
128
+ let(:open_transactions) { 1 }
129
+
130
+ it 'routes reads to the primary so they can see uncommitted writes' do
131
+ expect(described_class.new('SELECT * FROM users', open_transactions).where_to_send?).to eq(:primary)
132
+ end
133
+ end
134
+
135
+ context 'with an unrecognised / empty statement' do
136
+ it 'defaults blank input to the primary' do
137
+ expect(described_class.new(' ', 0).where_to_send?).to eq(:primary)
138
+ end
139
+ end
140
+ end
141
+ end
@@ -82,4 +82,74 @@ RSpec.shared_examples 'a mysql like server' do
82
82
  expect(Janus::Context.last_used_connection).to eq :replica
83
83
  end
84
84
  end
85
+
86
+ describe 'ActiveRecord compatibility' do
87
+ before(:each) do
88
+ create_test_table
89
+ Janus::Context.release_all
90
+ end
91
+
92
+ it 'accepts the optional name argument on #execute' do
93
+ expect do
94
+ ActiveRecord::Base.connection.execute("SELECT * FROM `#{table_name}`;", 'CustomName')
95
+ end.not_to raise_error
96
+ end
97
+
98
+ it 'returns a usable result through the exec_query read path' do
99
+ ActiveRecord::Base.connection.execute("INSERT INTO `#{table_name}` SET `id` = 7;")
100
+ Janus::Context.release_all
101
+ $query_logger.flush_all
102
+
103
+ result = ActiveRecord::Base.connection.exec_query("SELECT `id` FROM `#{table_name}`")
104
+
105
+ expect(result.rows).to eq [[7]]
106
+ expect($query_logger.queries.first).to include '[replica]'
107
+ end
108
+ end
109
+
110
+ describe 'Transactions' do
111
+ before(:each) do
112
+ create_test_table
113
+ Janus::Context.release_all
114
+ $query_logger.flush_all
115
+ end
116
+
117
+ it 'routes reads inside a transaction to the primary' do
118
+ ActiveRecord::Base.transaction do
119
+ ActiveRecord::Base.connection.execute("SELECT * FROM `#{table_name}`;")
120
+ end
121
+
122
+ selects = $query_logger.queries.select { |q| q.downcase.include?("select * from `#{table_name}`") }
123
+ expect(selects).not_to be_empty
124
+ expect(selects).to all(include('[primary]'))
125
+ end
126
+
127
+ it 'keeps later reads on the primary until the context is released' do
128
+ ActiveRecord::Base.transaction do
129
+ ActiveRecord::Base.connection.execute("INSERT INTO `#{table_name}` SET `id` = 1;")
130
+ end
131
+ $query_logger.flush_all
132
+
133
+ ActiveRecord::Base.connection.execute("SELECT * FROM `#{table_name}`;")
134
+ expect($query_logger.queries.last).to include '[primary]'
135
+
136
+ Janus::Context.release_all
137
+ ActiveRecord::Base.connection.execute("SELECT * FROM `#{table_name}`;")
138
+ expect($query_logger.queries.last).to include '[replica]'
139
+ end
140
+ end
141
+
142
+ describe 'SET statements' do
143
+ before(:each) { Janus::Context.release_all }
144
+
145
+ it 'sends a session SET down the broadcast (:all) path without error' do
146
+ expect do
147
+ ActiveRecord::Base.connection.execute("SET SESSION time_zone = '+00:00'")
148
+ end.not_to raise_error
149
+
150
+ # `:all` means the statement ran against the replica connection too, not
151
+ # just the primary - a write would have been marked `:primary`.
152
+ expect(Janus::Context.last_used_connection).to eq :all
153
+ end
154
+ end
85
155
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: janus-ar
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.0.0
4
+ version: 8.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lloyd Watkin
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-01-06 00:00:00.000000000 Z
10
+ date: 2026-06-19 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activerecord
@@ -120,28 +119,28 @@ dependencies:
120
119
  requirements:
121
120
  - - "~>"
122
121
  - !ruby/object:Gem::Version
123
- version: 1.69.2
122
+ version: 1.88.0
124
123
  type: :development
125
124
  prerelease: false
126
125
  version_requirements: !ruby/object:Gem::Requirement
127
126
  requirements:
128
127
  - - "~>"
129
128
  - !ruby/object:Gem::Version
130
- version: 1.69.2
129
+ version: 1.88.0
131
130
  - !ruby/object:Gem::Dependency
132
131
  name: rubocop-rails
133
132
  requirement: !ruby/object:Gem::Requirement
134
133
  requirements:
135
134
  - - "~>"
136
135
  - !ruby/object:Gem::Version
137
- version: 2.28.0
136
+ version: 2.35.2
138
137
  type: :development
139
138
  prerelease: false
140
139
  version_requirements: !ruby/object:Gem::Requirement
141
140
  requirements:
142
141
  - - "~>"
143
142
  - !ruby/object:Gem::Version
144
- version: 2.28.0
143
+ version: 2.35.2
145
144
  - !ruby/object:Gem::Dependency
146
145
  name: rubocop-rspec
147
146
  requirement: !ruby/object:Gem::Requirement
@@ -208,22 +207,28 @@ files:
208
207
  - assets/.gitkeep
209
208
  - assets/janus-logo.png
210
209
  - bin/release.sh
210
+ - gemfiles/activerecord_8_0.gemfile
211
+ - gemfiles/activerecord_8_1.gemfile
211
212
  - janus-ar.gemspec
212
213
  - lib/janus-ar.rb
213
214
  - lib/janus-ar/active_record/connection_adapters/janus_mysql2_adapter.rb
214
215
  - lib/janus-ar/active_record/connection_adapters/janus_trilogy_adapter.rb
216
+ - lib/janus-ar/adapter_extensions.rb
215
217
  - lib/janus-ar/client.rb
216
218
  - lib/janus-ar/context.rb
217
219
  - lib/janus-ar/db_console_config.rb
218
220
  - lib/janus-ar/logging/logger.rb
219
221
  - lib/janus-ar/logging/subscriber.rb
220
222
  - lib/janus-ar/query_director.rb
223
+ - lib/janus-ar/railtie.rb
221
224
  - lib/janus-ar/version.rb
222
225
  - spec/lib/janus-ar/active_record/connection_adapters/janus_mysql_adapter_spec.rb
223
226
  - spec/lib/janus-ar/active_record/connection_adapters/janus_trilogy_adapter_spec.rb
224
227
  - spec/lib/janus-ar/client_spec.rb
225
228
  - spec/lib/janus-ar/context_spec.rb
229
+ - spec/lib/janus-ar/db_console_config_spec.rb
226
230
  - spec/lib/janus-ar/logging/logger_spec.rb
231
+ - spec/lib/janus-ar/logging/subscriber_spec.rb
227
232
  - spec/lib/janus-ar/query_director_spec.rb
228
233
  - spec/shared_examples/a_mysql_like_server.rb
229
234
  - spec/spec_helper.rb
@@ -232,7 +237,6 @@ licenses:
232
237
  - MIT
233
238
  metadata:
234
239
  source_code_uri: https://github.com/olioex/janus-ar
235
- post_install_message:
236
240
  rdoc_options: []
237
241
  require_paths:
238
242
  - lib
@@ -247,8 +251,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
247
251
  - !ruby/object:Gem::Version
248
252
  version: '0'
249
253
  requirements: []
250
- rubygems_version: 3.5.23
251
- signing_key:
254
+ rubygems_version: 3.6.2
252
255
  specification_version: 4
253
256
  summary: Read/Write proxy for ActiveRecord using primary/replica databases
254
257
  test_files: []