From 665f4509a73feb448906e73fc008b613bef0bca9 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Mon, 29 Apr 2024 17:48:17 +0100 Subject: [PATCH] Experimental Trilogy support (#13) * Experimental `triology` support * Add `Janus::QueryDirector` * Update README.md * Update test config * Clear context before test run * Rubocop tidy * Trying to get things to run via Github Actions * Increase `Metrics/AbcSize * Setup test users for trilogy support * Whoops! * Update connection strings * Return to required, debugging on CI isn't fun! * MySQL 8 * Add notes to readme --- .github/workflows/ci.yml | 13 +- .rubocop.yml | 2 +- Gemfile.lock | 2 + README.md | 18 ++- janus-ar.gemspec | 1 + .../janus_mysql2_adapter.rb | 82 +++++----- .../janus_trilogy_adapter.rb | 144 ++++++++++++++++++ lib/janus.rb | 2 + lib/janus/client.rb | 6 + lib/janus/query_director.rb | 54 +++++++ .../janus_mysql_adapter_spec.rb | 85 +---------- .../janus_trilogy_adapter_spec.rb | 80 ++++++++++ spec/lib/janus/client_spec.rb | 7 + spec/lib/janus/query_director_spec.rb | 58 +++++++ spec/shared_examples/a_mysql_like_server.rb | 85 +++++++++++ spec/spec_helper.rb | 3 + 16 files changed, 514 insertions(+), 128 deletions(-) create mode 100644 lib/active_record/connection_adapters/janus_trilogy_adapter.rb create mode 100644 lib/janus/client.rb create mode 100644 lib/janus/query_director.rb create mode 100644 spec/lib/active_record/connection_adapters/janus_trilogy_adapter_spec.rb create mode 100644 spec/lib/janus/client_spec.rb create mode 100644 spec/lib/janus/query_director_spec.rb create mode 100644 spec/shared_examples/a_mysql_like_server.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 917fe9a..d0568f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,12 +17,12 @@ jobs: name: Ruby ${{ matrix.ruby }} services: mysql: - image: mysql:5.7 + image: mysql:8 env: MYSQL_DATABASE: test MYSQL_ROOT_PASSWORD: password - MYSQL_USER: primary - MYSQL_PASSWORD: primary_password + MYSQL_USER: test + MYSQL_PASSWORD: test_password ports: - 3306:3306 options: >- @@ -37,7 +37,12 @@ jobs: ruby-version: ${{ matrix.ruby }} bundler-cache: true - run: | - mysql -u root -p${{ env.MYSQL_PASSWORD || 'password' }} -h 127.0.0.1 -e "CREATE USER 'replica'@'%' IDENTIFIED BY 'replica_password';GRANT SELECT ON test.* TO 'replica'@'%';FLUSH PRIVILEGES;" + mysql -e "CREATE USER 'replica'@'%' IDENTIFIED WITH mysql_native_password BY 'replica_password';" -u root -p${{ env.MYSQL_PASSWORD || 'password' }} -h 127.0.0.1 + mysql -e "GRANT SELECT ON test.* TO 'replica'@'%'" -u root -p${{ env.MYSQL_PASSWORD || 'password' }} -h 127.0.0.1 + + mysql -e "CREATE USER 'primary'@'%' IDENTIFIED WITH mysql_native_password BY 'primary_password';" -u root -p${{ env.MYSQL_PASSWORD || 'password' }} -h 127.0.0.1 + mysql -e "GRANT ALL PRIVILEGES ON *.* TO 'primary'@'%';" -u root -p${{ env.MYSQL_PASSWORD || 'password' }} -h 127.0.0.1 + mysql -e "FLUSH PRIVILEGES;" -u root -p${{ env.MYSQL_PASSWORD || 'password' }} -h 127.0.0.1 - run: | bundle exec rspec env: diff --git a/.rubocop.yml b/.rubocop.yml index 15bf289..63411bd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -19,4 +19,4 @@ Style/GlobalVars: - 'spec/**/*' Metrics/AbcSize: - Max: 22 + Max: 25 diff --git a/Gemfile.lock b/Gemfile.lock index 3ca0864..28ff842 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -100,6 +100,7 @@ GEM rubocop (>= 0.90.0) ruby-progressbar (1.13.0) timeout (0.4.1) + trilogy (2.8.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) @@ -121,6 +122,7 @@ DEPENDENCIES rubocop-rails (~> 2.24.0) rubocop-rspec rubocop-thread_safety + trilogy BUNDLED WITH 2.4.22 diff --git a/README.md b/README.md index aa21e75..ea35991 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,16 @@ [![CI](https://github.com/OLIOEX/janus-ar/actions/workflows/ci.yml/badge.svg)](https://github.com/OLIOEX/janus-ar/actions/workflows/ci.yml) [![Gem Version](https://badge.fury.io/rb/janus-ar.svg)](https://badge.fury.io/rb/janus-ar) -Janus ActiveRecord is generic primary/replica proxy for ActiveRecord 7.1+ and MySQL. It handles the switching of connections between primary and replica database servers. It comes with an ActiveRecord database adapter implementation. +Janus ActiveRecord is generic primary/replica proxy for ActiveRecord 7.1+ and MySQL (via `mysql2` and `trilogy`). It handles the switching of connections between primary and replica database servers. It comes with an ActiveRecord database adapter implementation. + +Note: Trilogy support is experimental at this stage. Janus is heavily inspired by [Makara](https://github.com/instacart/makara) from TaskRabbit and then Instacart. Unfortunately this project is unmaintained and broke for us with Rails 7.1. This is an attempt to start afresh on the project. It is definitely not as fully featured as Makara at this stage. Learn more about its origins: [https://tech.olioex.com/ruby/2024/04/16/introducing-janus.html](https://tech.olioex.com/ruby/2024/04/16/introducing-janus.html). +Notes: GEM is currently tested with MySQL 8, Ruby 3.2, ActiveRecord 7.1+ + ## Installation Use the current version of the gem from [rubygems](https://rubygems.org/gems/janus-ar) in your `Gemfile`. @@ -48,6 +52,18 @@ development: password: ithappenstobedifferent host: replica-host.local ``` +Note: For `trilogy` please use adapter "janus_trilogy". You'll probably need to add the following to your configuration to have it connect: + +```yml + ssl: true + ssl_mode: 'REQUIRED' + tls_min_version: 3 +``` + +`tls_min_version` here refers to TLS1.2. + +Otherwise you will get an error like the following (see https://github.com/trilogy-libraries/trilogy/issues/26): +> trilogy_auth_recv: caching_sha2_password requires either TCP with TLS or a unix socket: TRILOGY_UNSUPPORTED" ### Forcing connections diff --git a/janus-ar.gemspec b/janus-ar.gemspec index 21f4754..e40032c 100644 --- a/janus-ar.gemspec +++ b/janus-ar.gemspec @@ -24,6 +24,7 @@ Gem::Specification.new do |gem| gem.add_development_dependency 'activerecord', '>= 7.1.0' gem.add_development_dependency 'activesupport', '>= 7.1.0' gem.add_development_dependency 'mysql2' + gem.add_development_dependency 'trilogy' gem.add_development_dependency 'pry' gem.add_development_dependency 'rake' gem.add_development_dependency 'rspec', '~> 3' diff --git a/lib/active_record/connection_adapters/janus_mysql2_adapter.rb b/lib/active_record/connection_adapters/janus_mysql2_adapter.rb index 294b913..fcc04b7 100644 --- a/lib/active_record/connection_adapters/janus_mysql2_adapter.rb +++ b/lib/active_record/connection_adapters/janus_mysql2_adapter.rb @@ -24,15 +24,6 @@ module ActiveRecord module ConnectionAdapters class JanusMysql2Adapter < ActiveRecord::ConnectionAdapters::Mysql2Adapter FOUND_ROWS = 'FOUND_ROWS' - SQL_PRIMARY_MATCHERS = [ - /\A\s*select.+for update\Z/i, /select.+lock in share mode\Z/i, - /\A\s*select.+(nextval|currval|lastval|get_lock|release_lock|pg_advisory_lock|pg_advisory_unlock)\(/i, - /\A\s*show/i - ].freeze - SQL_REPLICA_MATCHERS = [/\A\s*(select|with.+\)\s*select)\s/i].freeze - SQL_ALL_MATCHERS = [/\A\s*set\s/i].freeze - SQL_SKIP_ALL_MATCHERS = [/\A\s*set\s+local\s/i].freeze - WRITE_PREFIXES = %w(INSERT UPDATE DELETE LOCK CREATE GRANT DROP ALTER TRUNCATE FLUSH).freeze attr_reader :config @@ -56,30 +47,46 @@ def initialize(*args) update_config end + def raw_execute(sql, name, async: false, allow_retry: false, materialize_transactions: true) + case where_to_send?(sql) + when :all + send_to_replica(sql, connection: :all, method: :raw_execute) + super + when :replica + send_to_replica(sql, connection: :replica, method: :raw_execute) + else + Janus::Context.stick_to_primary if write_query?(sql) + Janus::Context.used_connection(:primary) + super + end + end + def execute(sql) - if should_send_to_all?(sql) + case where_to_send?(sql) + when :all send_to_replica(sql, connection: :all, method: :execute) - return super(sql) + super(sql) + when :replica + send_to_replica(sql, connection: :replica, method: :execute) + else + Janus::Context.stick_to_primary if write_query?(sql) + Janus::Context.used_connection(:primary) + super(sql) end - return send_to_replica(sql, connection: :replica, method: :execute) if can_go_to_replica?(sql) - - Janus::Context.stick_to_primary if write_query?(sql) - Janus::Context.used_connection(:primary) - - super(sql) end def execute_and_free(sql, name = nil, async: false) - if should_send_to_all?(sql) - send_to_replica(sql, name, connection: :all) - return super(sql, name, async:) + case where_to_send?(sql) + when :all + send_to_replica(sql, connection: :all, method: :execute) + super(sql, name, async:) + when :replica + send_to_replica(sql, connection: :replica, method: :execute) + else + Janus::Context.stick_to_primary if write_query?(sql) + Janus::Context.used_connection(:primary) + super(sql, name, async:) end - return send_to_replica(sql, connection: :replica) if can_go_to_replica?(sql) - - Janus::Context.stick_to_primary if write_query?(sql) - Janus::Context.used_connection(:primary) - - super(sql, name, async:) end def connect!(...) @@ -108,41 +115,28 @@ def replica_connection private - def should_send_to_all?(sql) - SQL_ALL_MATCHERS.any? { |matcher| sql =~ matcher } && SQL_SKIP_ALL_MATCHERS.none? { |matcher| sql =~ matcher } - end - - def can_go_to_replica?(sql) - !should_go_to_primary?(sql) - end - - def should_go_to_primary?(sql) - Janus::Context.use_primary? || - write_query?(sql) || - open_transactions.positive? || - SQL_PRIMARY_MATCHERS.any? { |matcher| sql =~ matcher } + def where_to_send?(sql) + Janus::QueryDirector.new(sql, open_transactions).where_to_send? end def send_to_replica(sql, connection: nil, method: :exec_query) Janus::Context.used_connection(connection) if connection if method == :execute replica_connection.execute(sql) + elsif method == :raw_execute + replica_connection.execute(sql) else replica_connection.exec_query(sql) end end - def write_query?(sql) - WRITE_PREFIXES.include?(sql.upcase.split(' ').first) - end - def update_config @config[:flags] ||= 0 if @config[:flags].is_a? Array @config[:flags].push FOUND_ROWS else - @config[:flags] |= ::Mysql2::Client::FOUND_ROWS + @config[:flags] |= ::Janus::Client::FOUND_ROWS end end end diff --git a/lib/active_record/connection_adapters/janus_trilogy_adapter.rb b/lib/active_record/connection_adapters/janus_trilogy_adapter.rb new file mode 100644 index 0000000..39b7490 --- /dev/null +++ b/lib/active_record/connection_adapters/janus_trilogy_adapter.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require 'active_record/connection_adapters/abstract_adapter' +require 'active_record/connection_adapters/trilogy_adapter' +require_relative '../../janus' + +module ActiveRecord + module ConnectionHandling + def janus_trilogy_connection(config) + ActiveRecord::ConnectionAdapters::JanusTrilogyAdapter.new(config) + end + end +end + +module ActiveRecord + class Base + def self.janus_trilogy_adapter_class + ActiveRecord::ConnectionAdapters::JanusTrilogyAdapter + end + end +end + +module ActiveRecord + module ConnectionAdapters + class JanusTrilogyAdapter < ActiveRecord::ConnectionAdapters::TrilogyAdapter + FOUND_ROWS = 'FOUND_ROWS' + + attr_reader :config + + class << self + def dbconsole(config, options = {}) + connection_config = Janus::DbConsoleConfig.new(config) + + super(connection_config, options) + end + end + + def initialize(*args) + args[0][:janus]['replica']['database'] = args[0][:database] + args[0][:janus]['primary']['database'] = args[0][:database] + + @replica_config = args[0][:janus]['replica'].symbolize_keys + args[0] = args[0][:janus]['primary'].symbolize_keys + + super(*args) + @connection_parameters ||= args[0] + update_config + end + + def raw_execute(sql, name, async: false, allow_retry: false, materialize_transactions: true) + case where_to_send?(sql) + when :all + send_to_replica(sql, connection: :all, method: :execute) + super + when :replica + send_to_replica(sql, connection: :replica, method: :execute) + else + Janus::Context.stick_to_primary if write_query?(sql) + Janus::Context.used_connection(:primary) + super + end + end + + def execute(sql) + case where_to_send?(sql) + when :all + send_to_replica(sql, connection: :all, method: :execute) + super(sql) + when :replica + send_to_replica(sql, connection: :replica, method: :execute) + else + Janus::Context.stick_to_primary if write_query?(sql) + Janus::Context.used_connection(:primary) + super(sql) + end + end + + def execute_and_free(sql, name = nil, async: false) + case where_to_send?(sql) + when :all + send_to_replica(sql, connection: :all, method: :execute) + super(sql, name, async:) + when :replica + send_to_replica(sql, connection: :replica, method: :execute) + else + Janus::Context.stick_to_primary if write_query?(sql) + Janus::Context.used_connection(:primary) + super(sql, name, async:) + end + end + + def connect!(...) + replica_connection.connect!(...) + super + end + + def reconnect!(...) + replica_connection.reconnect!(...) + super + end + + def disconnect!(...) + replica_connection.disconnect!(...) + super + end + + def clear_cache!(...) + replica_connection.clear_cache!(...) + super + end + + def replica_connection + @replica_connection ||= ActiveRecord::ConnectionAdapters::TrilogyAdapter.new(@replica_config) + end + + private + + def where_to_send?(sql) + Janus::QueryDirector.new(sql, open_transactions).where_to_send? + end + + def send_to_replica(sql, connection: nil, method: :exec_query) + Janus::Context.used_connection(connection) if connection + if method == :execute + replica_connection.execute(sql) + elsif method == :raw_execute + replica_connection.execute(sql) + else + replica_connection.exec_query(sql) + end + end + + def update_config + @config[:flags] ||= 0 + + if @config[:flags].is_a? Array + @config[:flags].push FOUND_ROWS + else + @config[:flags] |= ::Janus::Client::FOUND_ROWS + end + end + end + end +end diff --git a/lib/janus.rb b/lib/janus.rb index a977836..7e48287 100644 --- a/lib/janus.rb +++ b/lib/janus.rb @@ -4,6 +4,8 @@ module Janus autoload :Context, 'janus/context' + autoload :Client, 'janus/client' + autoload :QueryDirector, 'janus/query_director' autoload :VERSION, 'janus/version' autoload :DbConsoleConfig, 'janus/db_console_config' diff --git a/lib/janus/client.rb b/lib/janus/client.rb new file mode 100644 index 0000000..cd36418 --- /dev/null +++ b/lib/janus/client.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +module Janus + class Client + FOUND_ROWS = 2 + end +end diff --git a/lib/janus/query_director.rb b/lib/janus/query_director.rb new file mode 100644 index 0000000..6d1b020 --- /dev/null +++ b/lib/janus/query_director.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true +module Janus + class QueryDirector + ALL = :all + REPLICA = :replica + PRIMARY = :primary + + SQL_PRIMARY_MATCHERS = [ + /\A\s*select.+for update\Z/i, /select.+lock in share mode\Z/i, + /\A\s*select.+(nextval|currval|lastval|get_lock|release_lock|pg_advisory_lock|pg_advisory_unlock)\(/i, + /\A\s*show/i + ].freeze + SQL_REPLICA_MATCHERS = [/\A\s*(select|with.+\)\s*select)\s/i].freeze + SQL_ALL_MATCHERS = [/\A\s*set\s/i].freeze + SQL_SKIP_ALL_MATCHERS = [/\A\s*set\s+local\s/i].freeze + WRITE_PREFIXES = %w(INSERT UPDATE DELETE LOCK CREATE GRANT DROP ALTER TRUNCATE FLUSH).freeze + + def initialize(sql, open_transactions) + @_sql = sql + @_open_transactions = open_transactions + end + + def where_to_send? + if should_send_to_all? + ALL + elsif can_go_to_replica? + REPLICA + else + PRIMARY + end + end + + private + + def should_send_to_all? + SQL_ALL_MATCHERS.any? { |matcher| @_sql =~ matcher } && SQL_SKIP_ALL_MATCHERS.none? { |matcher| @_sql =~ matcher } + end + + def can_go_to_replica? + !should_go_to_primary? + end + + def should_go_to_primary? + Janus::Context.use_primary? || + write_query? || + @_open_transactions.positive? || + SQL_PRIMARY_MATCHERS.any? { |matcher| @_sql =~ matcher } + end + + def write_query? + WRITE_PREFIXES.include?(@_sql.upcase.split(' ').first) + end + end +end diff --git a/spec/lib/active_record/connection_adapters/janus_mysql_adapter_spec.rb b/spec/lib/active_record/connection_adapters/janus_mysql_adapter_spec.rb index 1d3cb19..b38e038 100644 --- a/spec/lib/active_record/connection_adapters/janus_mysql_adapter_spec.rb +++ b/spec/lib/active_record/connection_adapters/janus_mysql_adapter_spec.rb @@ -4,21 +4,6 @@ subject { described_class.new(config) } it { expect(described_class::FOUND_ROWS).to eq 'FOUND_ROWS' } - it { expect(described_class::SQL_SKIP_ALL_MATCHERS).to eq [/\A\s*set\s+local\s/i] } - it { - expect(described_class::SQL_PRIMARY_MATCHERS).to eq( - [ - /\A\s*select.+for update\Z/i, /select.+lock in share mode\Z/i, - /\A\s*select.+(nextval|currval|lastval|get_lock|release_lock|pg_advisory_lock|pg_advisory_unlock)\(/i, - /\A\s*show/i - ] - ) - } - it { expect(described_class::SQL_REPLICA_MATCHERS).to eq([/\A\s*(select|with.+\)\s*select)\s/i]) } - it { expect(described_class::SQL_ALL_MATCHERS).to eq([/\A\s*set\s/i]) } - it { - expect(described_class::WRITE_PREFIXES).to eq %w(INSERT UPDATE DELETE LOCK CREATE GRANT DROP ALTER TRUNCATE FLUSH) - } let(:database) { 'test' } let(:primary_config) do @@ -51,14 +36,14 @@ it 'creates primary connection as expected' do config = primary_config.dup.freeze expect(subject.config).to eq config.merge('database' => database, - 'flags' => ::Mysql2::Client::FOUND_ROWS).symbolize_keys + 'flags' => ::Janus::Client::FOUND_ROWS).symbolize_keys end it 'creates replica connection as expected' do config = replica_config.dup.freeze expect( subject.replica_connection.instance_variable_get(:@config) - ).to eq config.merge('database' => database, 'flags' => ::Mysql2::Client::FOUND_ROWS).symbolize_keys + ).to eq config.merge('database' => database, 'flags' => ::Janus::Client::FOUND_ROWS).symbolize_keys end context 'Rails sets empty database for server connection' do @@ -68,7 +53,7 @@ config = primary_config.dup.freeze expect(subject.config).to eq config.merge( 'database' => nil, - 'flags' => ::Mysql2::Client::FOUND_ROWS + 'flags' => ::Janus::Client::FOUND_ROWS ).symbolize_keys end @@ -76,72 +61,16 @@ config = replica_config.dup.freeze expect( subject.replica_connection.instance_variable_get(:@config) - ).to eq config.merge('database' => nil, 'flags' => ::Mysql2::Client::FOUND_ROWS).symbolize_keys + ).to eq config.merge('database' => nil, 'flags' => ::Janus::Client::FOUND_ROWS).symbolize_keys end end end describe 'Integration tests' do - let(:create_test_table) { ActiveRecord::Base.connection.execute('CREATE TABLE test_table (id INT);') } + describe 'Integration tests' do + let(:table_name) { 'table_name_mysql2' } - before(:each) do - $query_logger.flush_all - ActiveRecord::Base.establish_connection(config) - end - - after(:each) do - ActiveRecord::Base.connection.execute(<<-SQL - SELECT CONCAT('DROP TABLE IF EXISTS `', table_name, '`;') - FROM information_schema.tables - WHERE table_schema = '#{database}'; - SQL - ).to_a.map { |row| ActiveRecord::Base.connection.execute(row[0]) } - end - - it 'can list tables' do - expect(ActiveRecord::Base.connection.execute('SHOW TABLES;').to_a).to eq [] - end - - it 'can create table' do - create_test_table - expect(ActiveRecord::Base.connection.execute('SHOW TABLES;').to_a).to eq [%w(test_table)] - end - - describe 'SELECT' do - it 'reads from `replica` by default' do - create_test_table - Janus::Context.release_all - $query_logger.flush_all - ActiveRecord::Base.connection.execute('SELECT * FROM test_table;') - expect($query_logger.queries.first).to include '[replica]' - end - - it 'will read from primary after a write operation' do - create_test_table - $query_logger.flush_all - ActiveRecord::Base.connection.execute('SELECT * FROM test_table;') - expect($query_logger.queries.first).to include '[primary]' - end - end - - describe 'INSERT' do - let(:insert_query) { 'INSERT INTO test_table SET `id` = 5;' } - - before(:each) do - create_test_table - $query_logger.flush_all - Janus::Context.release_all - end - - it 'sends INSERT query to primary' do - ActiveRecord::Base.connection.execute(insert_query) - expect($query_logger.queries.first).to include '[primary]' - end - - it 'ignores case when directing queries' do - ActiveRecord::Base.connection.execute(insert_query.downcase) - expect($query_logger.queries.first).to include '[primary]' - end + it_behaves_like 'a mysql like server' end end end diff --git a/spec/lib/active_record/connection_adapters/janus_trilogy_adapter_spec.rb b/spec/lib/active_record/connection_adapters/janus_trilogy_adapter_spec.rb new file mode 100644 index 0000000..5d9e0e3 --- /dev/null +++ b/spec/lib/active_record/connection_adapters/janus_trilogy_adapter_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +RSpec.describe ActiveRecord::ConnectionAdapters::JanusTrilogyAdapter do + subject { described_class.new(config) } + + it { expect(described_class::FOUND_ROWS).to eq 'FOUND_ROWS' } + + let(:database) { 'test' } + let(:primary_config) do + { + 'username' => 'primary', + 'password' => 'primary_password', + 'host' => '127.0.0.1', + 'ssl' => true, + 'ssl_mode' => 'REQUIRED', + 'tls_min_version' => Trilogy::TLS_VERSION_12, + } + end + let(:replica_config) do + { + 'username' => 'replica', + 'password' => 'replica_password', + 'host' => '127.0.0.1', + 'pool' => 500, + 'ssl' => true, + 'ssl_mode' => 'REQUIRED', + 'tls_min_version' => Trilogy::TLS_VERSION_12, + } + end + let(:config) do + { + database:, + adapter: 'janus_trilogy', + janus: { + 'primary' => primary_config, + 'replica' => replica_config, + }, + } + end + + describe 'Configuration' do + it 'creates primary connection as expected' do + config = primary_config.dup.freeze + expect(subject.config).to eq config.merge('database' => database, + 'flags' => ::Janus::Client::FOUND_ROWS).symbolize_keys + end + + it 'creates replica connection as expected' do + config = replica_config.dup.freeze + expect( + subject.replica_connection.instance_variable_get(:@config) + ).to eq config.merge('database' => database).symbolize_keys + end + + context 'Rails sets empty database for server connection' do + let(:database) { nil } + + it 'creates primary connection as expected' do + config = primary_config.dup.freeze + expect(subject.config).to eq config.merge( + 'database' => nil, + 'flags' => ::Janus::Client::FOUND_ROWS + ).symbolize_keys + end + + it 'creates replica connection as expected' do + config = replica_config.dup.freeze + expect( + subject.replica_connection.instance_variable_get(:@config) + ).to eq config.merge('database' => nil).symbolize_keys + end + end + end + + describe 'Integration tests' do + let(:table_name) { 'table_name_trilogy' } + + it_behaves_like 'a mysql like server' + end +end diff --git a/spec/lib/janus/client_spec.rb b/spec/lib/janus/client_spec.rb new file mode 100644 index 0000000..11ca750 --- /dev/null +++ b/spec/lib/janus/client_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'janus/client' + +RSpec.describe Janus::Client do + it { expect(described_class::FOUND_ROWS).to eq 2 } +end diff --git a/spec/lib/janus/query_director_spec.rb b/spec/lib/janus/query_director_spec.rb new file mode 100644 index 0000000..c12f4e1 --- /dev/null +++ b/spec/lib/janus/query_director_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +RSpec.describe Janus::QueryDirector do + describe 'Constants' do + it { expect(described_class::SQL_SKIP_ALL_MATCHERS).to eq [/\A\s*set\s+local\s/i] } + it { + expect(described_class::SQL_PRIMARY_MATCHERS).to eq( + [ + /\A\s*select.+for update\Z/i, /select.+lock in share mode\Z/i, + /\A\s*select.+(nextval|currval|lastval|get_lock|release_lock|pg_advisory_lock|pg_advisory_unlock)\(/i, + /\A\s*show/i + ] + ) + } + it { expect(described_class::SQL_REPLICA_MATCHERS).to eq([/\A\s*(select|with.+\)\s*select)\s/i]) } + it { expect(described_class::SQL_ALL_MATCHERS).to eq([/\A\s*set\s/i]) } + it { + expect(described_class::WRITE_PREFIXES).to eq %w(INSERT UPDATE DELETE LOCK CREATE GRANT DROP ALTER TRUNCATE FLUSH) + } + + it { expect(described_class::ALL).to eq :all } + it { expect(described_class::REPLICA).to eq :replica } + it { expect(described_class::PRIMARY).to eq :primary } + end + + describe '#where_to_send?' do + before(:each) do + Janus::Context.release_all + end + + context 'when should send to all' do + it 'returns :all' do + sql = 'SET foo = bar' + open_transactions = 0 + query_director = described_class.new(sql, open_transactions) + expect(query_director.where_to_send?).to eq(:all) + end + end + + context 'when can go to replica' do + it 'returns :replica' do + sql = 'SELECT * FROM users' + open_transactions = 0 + query_director = described_class.new(sql, open_transactions) + expect(query_director.where_to_send?).to eq(:replica) + end + end + + context 'when should go to primary' do + it 'returns :primary' do + sql = 'INSERT INTO users (name) VALUES ("John")' + open_transactions = 0 + query_director = described_class.new(sql, open_transactions) + expect(query_director.where_to_send?).to eq(:primary) + end + end + end +end diff --git a/spec/shared_examples/a_mysql_like_server.rb b/spec/shared_examples/a_mysql_like_server.rb new file mode 100644 index 0000000..6b153b2 --- /dev/null +++ b/spec/shared_examples/a_mysql_like_server.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true +RSpec.shared_examples 'a mysql like server' do + let(:create_test_table) { ActiveRecord::Base.connection.execute("CREATE TABLE `#{table_name}` (id INT);") } + + before(:each) do + $query_logger.flush_all + ActiveRecord::Base.establish_connection(config) + end + + after(:each) do + ActiveRecord::Base.connection.execute(<<-SQL + SELECT CONCAT('DROP TABLE IF EXISTS `', table_name, '`;') + FROM information_schema.tables + WHERE table_schema = '#{database}'; + SQL + ).to_a.map { |row| ActiveRecord::Base.connection.execute(row[0]) } + end + + it 'can list tables' do + expect(ActiveRecord::Base.connection.execute('SHOW TABLES;').to_a).to eq [] + end + + it 'can create table' do + create_test_table + expect(ActiveRecord::Base.connection.execute('SHOW TABLES;').to_a).to eq [[table_name]] + end + + describe 'SELECT' do + it 'reads from `replica` by default' do + create_test_table + Janus::Context.release_all + $query_logger.flush_all + ActiveRecord::Base.connection.execute("SELECT * FROM `#{table_name}`;") + expect($query_logger.queries.first).to include '[replica]' + end + + it 'will read from primary after a write operation' do + create_test_table + $query_logger.flush_all + ActiveRecord::Base.connection.execute("SELECT * FROM `#{table_name}`;") + expect($query_logger.queries.first).to include '[primary]' + end + end + + describe 'INSERT' do + let(:insert_query) { "INSERT INTO `#{table_name}` SET `id` = 5;" } + + before(:each) do + create_test_table + $query_logger.flush_all + Janus::Context.release_all + end + + it 'sends INSERT query to primary' do + ActiveRecord::Base.connection.execute(insert_query) + expect($query_logger.queries.first).to include '[primary]' + end + + it 'ignores case when directing queries' do + ActiveRecord::Base.connection.execute(insert_query.downcase) + expect($query_logger.queries.first).to include '[primary]' + end + end + + describe 'UPDATE' do + before(:each) do + create_test_table + 5.times { |i| ActiveRecord::Base.connection.execute("INSERT INTO `#{table_name}` SET `id` = #{i};") } + $query_logger.flush_all + Janus::Context.release_all + end + + it 'continues to direct after bulk update' do + ActiveRecord::Base.connection.execute("UPDATE `#{table_name}` SET `id` = `id` + 2;") + expect($query_logger.queries.first).to include '[primary]' + expect(Janus::Context.last_used_connection).to eq :primary + ActiveRecord::Base.connection.execute("SELECT * FROM `#{table_name}`;") + expect($query_logger.queries.last).to include '[primary]' + Janus::Context.release_all + ActiveRecord::Base.connection.execute("SELECT * FROM `#{table_name}`;") + expect($query_logger.queries.last).to include '[replica]' + expect(Janus::Context.last_used_connection).to eq :replica + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 45341f1..8ab5d35 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,6 +6,9 @@ require './lib/janus' require './lib/active_record/connection_adapters/janus_mysql2_adapter' +require './lib/active_record/connection_adapters/janus_trilogy_adapter' + +require './spec/shared_examples/a_mysql_like_server.rb' class QueryLogger def initialize