├── .rspec ├── .rubocop_todo.yml ├── lib ├── switch_point │ ├── version.rb │ ├── error.rb │ ├── query_cache.rb │ ├── proxy_repository.rb │ ├── config.rb │ ├── connection.rb │ ├── model.rb │ └── proxy.rb └── switch_point.rb ├── Gemfile ├── gemfiles ├── rails_5.1.gemfile ├── rails_5.2.gemfile ├── rails_6.0.gemfile ├── rails_3.2.gemfile ├── rails_4.0.gemfile ├── rails_4.1.gemfile ├── rails_4.2.gemfile └── rails_5.0.gemfile ├── .gitignore ├── Rakefile ├── .travis.yml ├── LICENSE.txt ├── Appraisals ├── .rubocop.yml ├── switch_point.gemspec ├── benchmark └── proxy.rb ├── spec ├── switch_point │ ├── query_cache_spec.rb │ └── model_spec.rb ├── spec_helper.rb ├── models.rb └── switch_point_spec.rb ├── CHANGELOG.md ├── README.md └── assets └── switch_point.svg /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | Style/Documentation: 2 | Enabled: false 3 | -------------------------------------------------------------------------------- /lib/switch_point/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SwitchPoint 4 | VERSION = '0.9.0' 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in switch_point.gemspec 6 | gemspec 7 | 8 | platforms :ruby do 9 | gem 'sqlite3' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/rails_5.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 5.1.0" 6 | 7 | platforms :ruby do 8 | gem "sqlite3" 9 | end 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /gemfiles/rails_5.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 5.2.0" 6 | 7 | platforms :ruby do 8 | gem "sqlite3" 9 | end 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /gemfiles/rails_6.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.0.0" 6 | 7 | platforms :ruby do 8 | gem "sqlite3" 9 | end 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /gemfiles/rails_3.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 3.2.0" 6 | 7 | platforms :ruby do 8 | gem "sqlite3", "~> 1.3.5" 9 | end 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /gemfiles/rails_4.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 4.0.0" 6 | 7 | platforms :ruby do 8 | gem "sqlite3", "~> 1.3.6" 9 | end 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /gemfiles/rails_4.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 4.1.0" 6 | 7 | platforms :ruby do 8 | gem "sqlite3", "~> 1.3.6" 9 | end 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /gemfiles/rails_4.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 4.2.0" 6 | 7 | platforms :ruby do 8 | gem "sqlite3", "~> 1.3.6" 9 | end 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /gemfiles/rails_5.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 5.0.0" 6 | 7 | platforms :ruby do 8 | gem "sqlite3", "~> 1.3.6" 9 | end 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /lib/switch_point/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SwitchPoint 4 | class Error < StandardError 5 | end 6 | 7 | class ReadonlyError < Error 8 | end 9 | 10 | class UnconfiguredError < Error 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | *.bundle 19 | *.so 20 | *.o 21 | *.a 22 | mkmf.log 23 | /gemfiles/*.lock 24 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | 5 | task :default => %i[spec rubocop] 6 | 7 | require 'rspec/core/rake_task' 8 | RSpec::Core::RakeTask.new(:spec) 9 | 10 | require 'rubocop/rake_task' 11 | RuboCop::RakeTask.new(:rubocop) 12 | 13 | desc 'Run benchmark' 14 | task :benchmark do 15 | sh 'ruby', 'benchmark/proxy.rb' 16 | end 17 | -------------------------------------------------------------------------------- /lib/switch_point/query_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SwitchPoint 4 | class QueryCache 5 | def initialize(app, names = nil) 6 | @app = app 7 | @names = names 8 | end 9 | 10 | def call(env) 11 | names.reverse.inject(lambda { @app.call(env) }) do |func, name| 12 | lambda { ProxyRepository.checkout(name).cache(&func) } 13 | end.call 14 | end 15 | 16 | private 17 | 18 | def names 19 | @names ||= SwitchPoint.config.keys 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/switch_point/proxy_repository.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'singleton' 4 | require 'switch_point/proxy' 5 | 6 | module SwitchPoint 7 | class ProxyRepository 8 | include Singleton 9 | 10 | def self.checkout(name) 11 | instance.checkout(name) 12 | end 13 | 14 | def self.find(name) 15 | instance.find(name) 16 | end 17 | 18 | def checkout(name) 19 | proxies[name] ||= Proxy.new(name) 20 | end 21 | 22 | def find(name) 23 | proxies.fetch(name) 24 | end 25 | 26 | def proxies 27 | @proxies ||= {} 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.5.8 4 | - 2.6.6 5 | - 2.7.2 6 | - ruby-head 7 | gemfile: 8 | - gemfiles/rails_3.2.gemfile 9 | - gemfiles/rails_4.0.gemfile 10 | - gemfiles/rails_4.1.gemfile 11 | - gemfiles/rails_4.2.gemfile 12 | - gemfiles/rails_5.0.gemfile 13 | - gemfiles/rails_5.1.gemfile 14 | - gemfiles/rails_5.2.gemfile 15 | - gemfiles/rails_6.0.gemfile 16 | after_script: 17 | - bundle exec rake benchmark 18 | jobs: 19 | allow_failures: 20 | - rvm: ruby-head 21 | exclude: 22 | # Rails 3.2 - 4.2 doesn't support Ruby 2.7 23 | - rvm: 2.7.2 24 | gemfile: gemfiles/rails_3.2.gemfile 25 | - rvm: 2.7.2 26 | gemfile: gemfiles/rails_4.0.gemfile 27 | - rvm: 2.7.2 28 | gemfile: gemfiles/rails_4.1.gemfile 29 | - rvm: 2.7.2 30 | gemfile: gemfiles/rails_4.2.gemfile 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Kohei Suzuki 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise 'rails-3.2' do 4 | gem 'activerecord', '~> 3.2.0' 5 | 6 | platforms :ruby do 7 | gem 'sqlite3', '~> 1.3.5' 8 | end 9 | end 10 | 11 | appraise 'rails-4.0' do 12 | gem 'activerecord', '~> 4.0.0' 13 | 14 | platforms :ruby do 15 | gem 'sqlite3', '~> 1.3.6' 16 | end 17 | end 18 | 19 | appraise 'rails-4.1' do 20 | gem 'activerecord', '~> 4.1.0' 21 | 22 | platforms :ruby do 23 | gem 'sqlite3', '~> 1.3.6' 24 | end 25 | end 26 | 27 | appraise 'rails-4.2' do 28 | gem 'activerecord', '~> 4.2.0' 29 | 30 | platforms :ruby do 31 | gem 'sqlite3', '~> 1.3.6' 32 | end 33 | end 34 | 35 | appraise 'rails-5.0' do 36 | gem 'activerecord', '~> 5.0.0' 37 | 38 | platforms :ruby do 39 | gem 'sqlite3', '~> 1.3.6' 40 | end 41 | end 42 | 43 | appraise 'rails-5.1' do 44 | gem 'activerecord', '~> 5.1.0' 45 | 46 | platforms :ruby do 47 | gem 'sqlite3' 48 | end 49 | end 50 | 51 | appraise 'rails-5.2' do 52 | gem 'activerecord', '~> 5.2.0' 53 | 54 | platforms :ruby do 55 | gem 'sqlite3' 56 | end 57 | end 58 | 59 | appraise 'rails-6.0' do 60 | gem 'activerecord', '~> 6.0.0' 61 | 62 | platforms :ruby do 63 | gem 'sqlite3' 64 | end 65 | end 66 | 67 | # vim: set ft=ruby: 68 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | TargetRubyVersion: 2.5 5 | DisplayCopNames: true 6 | NewCops: enable 7 | Exclude: 8 | - gemfiles/*.gemfile 9 | 10 | Layout/ArgumentAlignment: 11 | Enabled: false 12 | Layout/LineLength: 13 | Enabled: false 14 | Layout/ParameterAlignment: 15 | Enabled: false 16 | 17 | Naming/FileName: 18 | Exclude: 19 | - Appraisals 20 | 21 | Style/AccessModifierDeclarations: 22 | Enabled: false 23 | Style/Alias: 24 | EnforcedStyle: prefer_alias_method 25 | Style/BlockDelimiters: 26 | Enabled: false 27 | Style/GuardClause: 28 | Enabled: false 29 | Style/HashSyntax: 30 | Exclude: 31 | - Rakefile 32 | Style/IfUnlessModifier: 33 | Enabled: false 34 | Style/Lambda: 35 | Enabled: false 36 | Style/Next: 37 | Enabled: false 38 | Style/PercentLiteralDelimiters: 39 | PreferredDelimiters: 40 | '%w': '[]' 41 | Style/RaiseArgs: 42 | EnforcedStyle: compact 43 | Style/SafeNavigation: 44 | Enabled: false 45 | Style/SignalException: 46 | Enabled: false 47 | Style/SoleNestedConditional: 48 | Enabled: false 49 | Style/TrailingCommaInArguments: 50 | Enabled: false 51 | Style/TrailingCommaInArrayLiteral: 52 | Enabled: false 53 | Style/TrailingCommaInHashLiteral: 54 | Enabled: false 55 | Metrics: 56 | Enabled: false 57 | -------------------------------------------------------------------------------- /lib/switch_point/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SwitchPoint 4 | class Config 5 | attr_accessor :auto_writable 6 | alias_method :auto_writable?, :auto_writable 7 | 8 | def initialize 9 | self.auto_writable = false 10 | end 11 | 12 | def define_switch_point(name, config) 13 | assert_valid_config!(config) 14 | switch_points[name] = config 15 | end 16 | 17 | def switch_points 18 | @switch_points ||= {} 19 | end 20 | 21 | def database_name(name, mode) 22 | fetch(name)[mode] 23 | end 24 | 25 | def model_name(name, mode) 26 | if fetch(name)[mode] 27 | "#{name}_#{mode}".camelize 28 | end 29 | end 30 | 31 | def fetch(name) 32 | switch_points.fetch(name) 33 | end 34 | 35 | def keys 36 | switch_points.keys 37 | end 38 | 39 | def each_key(&block) 40 | switch_points.each_key(&block) 41 | end 42 | 43 | private 44 | 45 | def assert_valid_config!(config) 46 | unless config.key?(:readonly) || config.key?(:writable) 47 | raise ArgumentError.new(':readonly or :writable must be specified') 48 | end 49 | 50 | if config.key?(:readonly) 51 | unless config[:readonly].is_a?(Symbol) 52 | raise TypeError.new(":readonly's value must be Symbol") 53 | end 54 | end 55 | if config.key?(:writable) 56 | unless config[:writable].is_a?(Symbol) 57 | raise TypeError.new(":writable's value must be Symbol") 58 | end 59 | end 60 | nil 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /switch_point.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'switch_point/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'switch_point' 9 | spec.version = SwitchPoint::VERSION 10 | spec.authors = ['Kohei Suzuki'] 11 | spec.email = ['eagletmt@gmail.com'] 12 | spec.summary = 'Switching database connection between readonly one and writable one.' 13 | spec.description = 'Switching database connection between readonly one and writable one.' 14 | spec.homepage = 'https://github.com/eagletmt/switch_point' 15 | spec.license = 'MIT' 16 | 17 | spec.files = `git ls-files -z`.split("\x0") 18 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 19 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 20 | spec.require_paths = ['lib'] 21 | spec.required_ruby_version = '>= 2.5.0' 22 | 23 | spec.add_development_dependency 'appraisal' 24 | spec.add_development_dependency 'benchmark-ips' 25 | spec.add_development_dependency 'bundler' 26 | spec.add_development_dependency 'coveralls', '>= 0.8.22' 27 | spec.add_development_dependency 'rack' 28 | spec.add_development_dependency 'rake' 29 | spec.add_development_dependency 'rspec', '>= 3.0' 30 | spec.add_development_dependency 'rubocop', '>= 0.50.0' 31 | spec.add_development_dependency 'simplecov', '~> 0.16.1' # XXX: The latest coveralls still depends on old version 32 | spec.add_dependency 'activerecord', '>= 3.2.0', '< 6.1.0' 33 | end 34 | -------------------------------------------------------------------------------- /benchmark/proxy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'benchmark/ips' 4 | require 'switch_point' 5 | require 'active_record' 6 | 7 | SwitchPoint.configure do |config| 8 | config.define_switch_point :proxy, 9 | readonly: :proxy_readonly, 10 | writable: :proxy_writable 11 | end 12 | 13 | class Plain < ActiveRecord::Base 14 | end 15 | 16 | class Proxy1 < ActiveRecord::Base 17 | use_switch_point :proxy 18 | end 19 | 20 | class ProxyBase < ActiveRecord::Base 21 | self.abstract_class = true 22 | use_switch_point :proxy 23 | end 24 | 25 | class Proxy2 < ProxyBase 26 | end 27 | 28 | database_config = { adapter: 'sqlite3', database: ':memory:' } 29 | ActiveRecord::Base.configurations = { 30 | 'default' => database_config.dup, 31 | 'proxy_readonly' => database_config.dup, 32 | 'proxy_writable' => database_config.dup, 33 | } 34 | ActiveRecord::Base.establish_connection(:default) 35 | 36 | Plain.connection.execute('CREATE TABLE plains (id integer primary key autoincrement)') 37 | %i[readonly writable].each do |mode| 38 | ProxyBase.public_send("with_#{mode}") do 39 | %w[proxy1s proxy2s].each do |table| 40 | ProxyBase.connection.execute("CREATE TABLE #{table} (id integer primary key autoincrement)") 41 | end 42 | end 43 | end 44 | 45 | Benchmark.ips do |x| 46 | x.report('plain') do 47 | Plain.create 48 | Plain.first 49 | end 50 | 51 | x.report('proxy1') do 52 | Proxy1.with_writable { Proxy1.create } 53 | Proxy1.first 54 | end 55 | 56 | x.report('proxy2') do 57 | Proxy2.with_writable { Proxy2.create } 58 | Proxy2.first 59 | end 60 | 61 | x.compare! 62 | end 63 | -------------------------------------------------------------------------------- /lib/switch_point.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/lazy_load_hooks' 4 | require 'switch_point/config' 5 | require 'switch_point/version' 6 | 7 | module SwitchPoint 8 | module ClassMethods 9 | def configure(&block) 10 | block.call(config) 11 | end 12 | 13 | def config 14 | @config ||= Config.new 15 | end 16 | 17 | def readonly_all! 18 | config.each_key do |name| 19 | readonly!(name) 20 | end 21 | end 22 | 23 | def readonly!(name) 24 | ProxyRepository.checkout(name).readonly! 25 | end 26 | 27 | def writable_all! 28 | config.each_key do |name| 29 | writable!(name) 30 | end 31 | end 32 | 33 | def writable!(name) 34 | ProxyRepository.checkout(name).writable! 35 | end 36 | 37 | def with_readonly(*names, &block) 38 | with_mode(:readonly, *names, &block) 39 | end 40 | 41 | def with_readonly_all(&block) 42 | with_readonly(*config.keys, &block) 43 | end 44 | 45 | def with_writable(*names, &block) 46 | with_mode(:writable, *names, &block) 47 | end 48 | 49 | def with_writable_all(&block) 50 | with_writable(*config.keys, &block) 51 | end 52 | 53 | def with_mode(mode, *names, &block) 54 | names.reverse.inject(block) do |func, name| 55 | lambda do 56 | ProxyRepository.checkout(name).with_mode(mode, &func) 57 | end 58 | end.call 59 | end 60 | end 61 | extend ClassMethods 62 | end 63 | 64 | ActiveSupport.on_load(:active_record) do 65 | require 'switch_point/connection' 66 | require 'switch_point/model' 67 | require 'switch_point/query_cache' 68 | 69 | ActiveRecord::Base.include SwitchPoint::Model 70 | ActiveRecord::ConnectionAdapters::AbstractAdapter.class_eval do 71 | prepend SwitchPoint::Connection 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/switch_point/query_cache_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'rack' 5 | 6 | class TestApp 7 | def call(env) 8 | state = {} 9 | [Nanika1, Nanika2].each do |model| 10 | r = model.with_readonly { model.connection.query_cache_enabled } 11 | w = model.with_writable { model.connection.query_cache_enabled } 12 | state[model.name] = { readonly: r, writable: w } 13 | end 14 | env[:state] = state 15 | :result 16 | end 17 | end 18 | 19 | RSpec.describe SwitchPoint::QueryCache do 20 | let(:app) do 21 | Rack::Builder.new do 22 | use SwitchPoint::QueryCache 23 | run TestApp.new 24 | end 25 | end 26 | 27 | describe '#call' do 28 | before do 29 | # Ensure the connection is established. 30 | # The query cache is enabled only when connected. 31 | # https://github.com/rails/rails/commit/25fc1f584def4c1bc36be805833194d8aee55b3a 32 | [Nanika1, Nanika2].each do |model| 33 | model.with_readonly { model.connection } 34 | model.with_writable { model.connection } 35 | end 36 | end 37 | 38 | it 'enables query cache of all models' do 39 | env = {} 40 | expect(app.call(env)).to eq(:result) 41 | expect(env[:state]).to eq( 42 | 'Nanika1' => { readonly: true, writable: true }, 43 | 'Nanika2' => { readonly: true, writable: true }, 44 | ) 45 | end 46 | 47 | context 'when names are specified' do 48 | let(:app) do 49 | Rack::Builder.new do 50 | use SwitchPoint::QueryCache, %i[main nanika1] 51 | run TestApp.new 52 | end 53 | end 54 | 55 | it 'enables query caches of specified models' do 56 | env = {} 57 | expect(app.call(env)).to eq(:result) 58 | expect(env[:state]).to eq( 59 | 'Nanika1' => { readonly: true, writable: true }, 60 | 'Nanika2' => { readonly: false, writable: true }, 61 | ) 62 | end 63 | end 64 | 65 | context 'when unknown name is specified' do 66 | let(:app) do 67 | Rack::Builder.new do 68 | use SwitchPoint::QueryCache, [:unknown] 69 | run TestApp.new 70 | end 71 | end 72 | 73 | it 'raises error' do 74 | expect { app.call({}) }.to raise_error(KeyError) 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/switch_point/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'switch_point/error' 4 | require 'switch_point/proxy_repository' 5 | 6 | module SwitchPoint 7 | module Connection 8 | # See ActiveRecord::ConnectionAdapters::QueryCache 9 | DESTRUCTIVE_METHODS = %i[insert update delete].freeze 10 | 11 | DESTRUCTIVE_METHODS.each do |method_name| 12 | define_method(method_name) do |*args, &block| 13 | if pool.equal?(ActiveRecord::Base.connection_pool) 14 | Connection.handle_base_connection(self) 15 | super(*args, &block) 16 | else 17 | parent_method = method(method_name).super_method 18 | Connection.handle_generated_connection(self, parent_method, method_name, *args, &block) 19 | end 20 | end 21 | end 22 | 23 | def self.handle_base_connection(conn) 24 | switch_points = conn.pool.spec.config[:switch_points] 25 | if switch_points 26 | switch_points.each do |switch_point| 27 | proxy = ProxyRepository.find(switch_point[:name]) 28 | if switch_point[:mode] != :writable 29 | raise Error.new("ActiveRecord::Base's switch_points must be writable, but #{switch_point[:name]} is #{switch_point[:mode]}") 30 | end 31 | 32 | purge_readonly_query_cache(proxy) 33 | end 34 | end 35 | end 36 | 37 | def self.handle_generated_connection(conn, parent_method, method_name, *args, &block) 38 | switch_point = conn.pool.spec.config[:switch_point] 39 | if switch_point 40 | proxy = ProxyRepository.find(switch_point[:name]) 41 | case switch_point[:mode] 42 | when :readonly 43 | if SwitchPoint.config.auto_writable? 44 | proxy_to_writable(proxy, method_name, *args, &block) 45 | else 46 | raise ReadonlyError.new("#{switch_point[:name]} is readonly, but destructive method #{method_name} is called") 47 | end 48 | when :writable 49 | purge_readonly_query_cache(proxy) 50 | parent_method.call(*args, &block) 51 | else 52 | raise Error.new("Unknown mode #{switch_point[:mode]} is given with #{name}") 53 | end 54 | else 55 | parent_method.call(*args, &block) 56 | end 57 | end 58 | 59 | def self.proxy_to_writable(proxy, method_name, *args, &block) 60 | proxy.with_writable do 61 | proxy.connection.send(method_name, *args, &block) 62 | end 63 | end 64 | 65 | def self.purge_readonly_query_cache(proxy) 66 | proxy.with_readonly do 67 | proxy.connection.clear_query_cache 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['RAILS_ENV'] ||= 'test' 4 | 5 | require 'coveralls' 6 | require 'simplecov' 7 | 8 | SimpleCov.formatters = [ 9 | SimpleCov::Formatter::HTMLFormatter, 10 | Coveralls::SimpleCov::Formatter, 11 | ] 12 | SimpleCov.start do 13 | add_filter Bundler.bundle_path.to_s 14 | add_filter File.dirname(__FILE__) 15 | end 16 | 17 | require 'switch_point' 18 | require 'models' 19 | 20 | RSpec.configure do |config| 21 | config.filter_run :focus 22 | config.run_all_when_everything_filtered = true 23 | 24 | if config.files_to_run.one? 25 | config.full_backtrace = true 26 | config.default_formatter = 'doc' 27 | end 28 | 29 | config.order = :random 30 | Kernel.srand config.seed 31 | 32 | config.expect_with :rspec do |expectations| 33 | expectations.syntax = :expect 34 | end 35 | 36 | config.mock_with :rspec do |mocks| 37 | mocks.syntax = :expect 38 | mocks.verify_partial_doubles = true 39 | end 40 | 41 | config.before(:suite) do 42 | Book.with_writable do 43 | Book.connection.execute('CREATE TABLE books (id integer primary key autoincrement)') 44 | end 45 | 46 | Book2.with_writable do 47 | Book2.connection.execute('CREATE TABLE book2s (id integer primary key autoincrement)') 48 | end 49 | 50 | FileUtils.cp('main_writable.sqlite3', 'main_readonly.sqlite3') 51 | 52 | Book3.with_writable do 53 | Book3.connection.execute('CREATE TABLE book3s (id integer primary key autoincrement)') 54 | end 55 | 56 | FileUtils.cp('main2_writable.sqlite3', 'main2_readonly.sqlite3') 57 | 58 | Note.connection.execute('CREATE TABLE notes (id integer primary key autoincrement)') 59 | 60 | Nanika3.connection.execute('CREATE TABLE nanika3s (id integer primary key)') 61 | end 62 | 63 | config.after(:suite) do 64 | if ActiveRecord::Base.configurations.respond_to?(:configs_for) 65 | ActiveRecord::Base.configurations.configs_for.each do |c| 66 | FileUtils.rm_f(c.config['database']) 67 | end 68 | else 69 | ActiveRecord::Base.configurations.each_value do |c| 70 | FileUtils.rm_f(c[:database]) 71 | end 72 | end 73 | end 74 | 75 | config.after(:each) do 76 | Book.with_writable do 77 | Book.delete_all 78 | end 79 | FileUtils.cp('main_writable.sqlite3', 'main_readonly.sqlite3') 80 | 81 | Nanika3.delete_all 82 | end 83 | end 84 | 85 | RSpec::Matchers.define :connect_to do |expected| 86 | database_name = lambda do |model| 87 | model.connection.pool.spec.config[:database] 88 | end 89 | 90 | match do |actual| 91 | database_name.call(actual) == expected 92 | end 93 | 94 | failure_message do |actual| 95 | "expected #{actual.name} to connect to #{expected} but connected to #{database_name.call(actual)}" 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.9.0 (2020-11-22) 2 | - Drop support for Ruby 2.2, 2.3 and 2.4 3 | - End support for upcoming ActiveRecord >= v6.1 4 | 5 | ## 0.8.0 (2016-06-06) 6 | - Drop Ruby 2.0.0 and 2.1 support 7 | - Add `AR::Base#with_readonly` and `AR::Base#with_writable` 8 | - short-hand for `AR::Base.with_readonly` and `AR::Base.with_writable` 9 | - Add `AR::Base#transaction_with` 10 | - short-hand for `AR::Base.transaction_with` 11 | - Fix warnings for Rails 5.0 12 | 13 | ## 0.7.0 (2015-10-16) 14 | - `Model.with_readonly` and `Model.with_writable` now raises error when the Model doesn't use switch_point 15 | 16 | ## 0.6.0 (2015-04-14) 17 | - Add `SwitchPoint::QueryCache` middleware 18 | - `Model.cache` and `Model.uncached` is now hooked by switch_point 19 | - `Model.cache` enables query cache for both readonly and writable. 20 | - `Model.uncached` disables query cache for both readonly and writable. 21 | - Add `SwitchPoint.with_readonly_all` and `SwitchPoint.with_writable_all` as shorthand 22 | 23 | ## 0.5.0 (2014-11-05) 24 | - Rename `SwitchPoint.with_connection` to `SwitchPoint.with_mode` 25 | - To avoid confusion with `ActiveRecord::ConnectionPool#with_connection` 26 | - Inherit superclass' switch_point configuration 27 | 28 | ## 0.4.4 (2014-07-14) 29 | - Memorize switch_point config to ConnectionSpecification#config instead of ConnectionPool 30 | - To support multi-threaded environment since Rails 4.0. 31 | 32 | ## 0.4.3 (2014-06-24) 33 | - Add Model.transaction_with method (#2, @ryopeko) 34 | 35 | ## 0.4.2 (2014-06-19) 36 | - Establish connection lazily 37 | - Just like ActiveRecord::Base, real connection isn't created until `.connection` is called 38 | 39 | ## 0.4.1 (2014-06-19) 40 | - Support :writable only configuration 41 | 42 | ## 0.4.0 (2014-06-17) 43 | - auto_writable is disabled by default 44 | - To restore the previous behavior, set `config.auto_writable = true`. 45 | - Add shorthand methods `SwitchPoint.with_readonly`, `SwitchPoint.with_writable` 46 | 47 | ## 0.3.1 (2014-06-04) 48 | - Support defaulting to writable ActiveRecord::Base connection 49 | - When `:writable` key is omitted, ActiveRecord::Base is used for the writable connection. 50 | 51 | ## 0.3.0 (2014-06-04) 52 | - Improve thread safety 53 | - Raise appropriate error if unknown mode is given to with_connection 54 | 55 | ## 0.2.3 (2014-06-02) 56 | - Support specifying the same database name within different switch_point 57 | - Add Proxy#readonly? and Proxy#writable? predicate 58 | 59 | ## 0.2.2 (2014-05-30) 60 | - Fix nil error on with_{readonly,writable} from non-switch_point model 61 | 62 | ## 0.2.1 (2014-05-29) 63 | - Add Proxy#switch_name to switch proxy configuration 64 | - Fix weird nil error when Config#define_switch_point isn't called yet 65 | 66 | ## 0.2.0 (2014-05-29) 67 | - Always send destructive operations to writable connection 68 | - Fix bug on pooled connections 69 | 70 | ## 0.1.0 (2014-05-28) 71 | - Initial release 72 | -------------------------------------------------------------------------------- /lib/switch_point/model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'switch_point/error' 4 | require 'switch_point/proxy_repository' 5 | 6 | module SwitchPoint 7 | module Model 8 | def self.included(model) 9 | super 10 | model.singleton_class.class_eval do 11 | include ClassMethods 12 | prepend MonkeyPatch 13 | end 14 | end 15 | 16 | def with_readonly(&block) 17 | self.class.with_readonly(&block) 18 | end 19 | 20 | def with_writable(&block) 21 | self.class.with_writable(&block) 22 | end 23 | 24 | def transaction_with(*models, &block) 25 | self.class.transaction_with(*models, &block) 26 | end 27 | 28 | module ClassMethods 29 | def with_readonly(&block) 30 | if switch_point_proxy 31 | switch_point_proxy.with_readonly(&block) 32 | else 33 | raise UnconfiguredError.new("#{name} isn't configured to use switch_point") 34 | end 35 | end 36 | 37 | def with_writable(&block) 38 | if switch_point_proxy 39 | switch_point_proxy.with_writable(&block) 40 | else 41 | raise UnconfiguredError.new("#{name} isn't configured to use switch_point") 42 | end 43 | end 44 | 45 | def use_switch_point(name) 46 | assert_existing_switch_point!(name) 47 | @switch_point_name = name 48 | end 49 | 50 | def switch_point_proxy 51 | if defined?(@switch_point_name) 52 | ProxyRepository.checkout(@switch_point_name) 53 | elsif self == ActiveRecord::Base 54 | nil 55 | else 56 | superclass.switch_point_proxy 57 | end 58 | end 59 | 60 | def transaction_with(*models, &block) 61 | unless can_transaction_with?(*models) 62 | raise Error.new("switch_point's model names must be consistent") 63 | end 64 | 65 | with_writable do 66 | transaction(&block) 67 | end 68 | end 69 | 70 | private 71 | 72 | def assert_existing_switch_point!(name) 73 | SwitchPoint.config.fetch(name) 74 | end 75 | 76 | def can_transaction_with?(*models) 77 | writable_switch_points = [self, *models].map do |model| 78 | if model.instance_variable_defined?(:@switch_point_name) 79 | SwitchPoint.config.model_name( 80 | model.instance_variable_get(:@switch_point_name), 81 | :writable 82 | ) 83 | end 84 | end 85 | 86 | writable_switch_points.uniq.size == 1 87 | end 88 | end 89 | 90 | module MonkeyPatch 91 | def connection 92 | if switch_point_proxy 93 | switch_point_proxy.connection 94 | else 95 | super 96 | end 97 | end 98 | 99 | def cache(&block) 100 | if switch_point_proxy 101 | switch_point_proxy.cache(&block) 102 | else 103 | super 104 | end 105 | end 106 | 107 | def uncached(&block) 108 | if switch_point_proxy 109 | switch_point_proxy.uncached(&block) 110 | else 111 | super 112 | end 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/models.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SwitchPoint.configure do |config| 4 | config.define_switch_point :main, 5 | readonly: :main_readonly, 6 | writable: :main_writable 7 | config.define_switch_point :main2, 8 | readonly: :main2_readonly, 9 | writable: :main2_writable 10 | config.define_switch_point :user, 11 | readonly: :user, 12 | writable: :user 13 | config.define_switch_point :comment, 14 | readonly: :comment_readonly, 15 | writable: :comment_writable 16 | config.define_switch_point :special, 17 | readonly: :main_readonly_special, 18 | writable: :main_writable 19 | config.define_switch_point :nanika1, 20 | readonly: :main_readonly 21 | config.define_switch_point :nanika2, 22 | readonly: :main_readonly 23 | config.define_switch_point :nanika3, 24 | writable: :comment_writable 25 | end 26 | 27 | require 'active_record' 28 | 29 | class Book < ActiveRecord::Base 30 | use_switch_point :main 31 | after_save :do_after_save 32 | 33 | private 34 | 35 | def do_after_save; end 36 | end 37 | 38 | class Book2 < ActiveRecord::Base 39 | use_switch_point :main 40 | end 41 | 42 | class Book3 < ActiveRecord::Base 43 | use_switch_point :main2 44 | end 45 | 46 | class Publisher < ActiveRecord::Base 47 | use_switch_point :main 48 | end 49 | 50 | class Comment < ActiveRecord::Base 51 | use_switch_point :comment 52 | end 53 | 54 | class User < ActiveRecord::Base 55 | use_switch_point :user 56 | end 57 | 58 | class BigData < ActiveRecord::Base 59 | use_switch_point :special 60 | end 61 | 62 | class Note < ActiveRecord::Base 63 | end 64 | 65 | class Nanika1 < ActiveRecord::Base 66 | use_switch_point :nanika1 67 | end 68 | 69 | class Nanika2 < ActiveRecord::Base 70 | use_switch_point :nanika2 71 | end 72 | 73 | class Nanika3 < ActiveRecord::Base 74 | use_switch_point :nanika3 75 | end 76 | 77 | class AbstractNanika < ActiveRecord::Base 78 | use_switch_point :main 79 | self.abstract_class = true 80 | end 81 | 82 | class DerivedNanika1 < AbstractNanika 83 | end 84 | 85 | class DerivedNanika2 < AbstractNanika 86 | use_switch_point :main2 87 | end 88 | 89 | base = 90 | if RUBY_PLATFORM == 'java' 91 | { adapter: 'jdbcsqlite3' } 92 | else 93 | { adapter: 'sqlite3' } 94 | end 95 | databases = { 96 | 'main_readonly' => base.merge(database: 'main_readonly.sqlite3'), 97 | 'main_writable' => base.merge(database: 'main_writable.sqlite3'), 98 | 'main2_readonly' => base.merge(database: 'main2_readonly.sqlite3'), 99 | 'main2_writable' => base.merge(database: 'main2_writable.sqlite3'), 100 | 'main_readonly_special' => base.merge(database: 'main_readonly_special.sqlite3'), 101 | 'user' => base.merge(database: 'user.sqlite3'), 102 | 'comment_readonly' => base.merge(database: 'comment_readonly.sqlite3'), 103 | 'comment_writable' => base.merge(database: 'comment_writable.sqlite3'), 104 | 'default' => base.merge(database: 'default.sqlite3'), 105 | } 106 | ActiveRecord::Base.configurations = 107 | # ActiveRecord.gem_version was introduced in ActiveRecord 4.0 108 | if ActiveRecord.respond_to?(:gem_version) && ActiveRecord.gem_version >= Gem::Version.new('5.1.0') 109 | { 'test' => databases } 110 | else 111 | databases 112 | end 113 | ActiveRecord::Base.establish_connection(:default) 114 | 115 | # XXX: Check connection laziness 116 | [Book, User, Note, Nanika1, ActiveRecord::Base].each do |model| 117 | if model.connected? 118 | raise "ActiveRecord::Base didn't establish connection lazily!" 119 | end 120 | end 121 | ActiveRecord::Base.connection # Create connection 122 | 123 | [Book, User, Nanika3].each do |model| 124 | model.with_writable do 125 | if model.switch_point_proxy.connected? 126 | raise "#{model.name} didn't establish connection lazily!" 127 | end 128 | end 129 | model.with_readonly do 130 | if model.switch_point_proxy.connected? 131 | raise "#{model.name} didn't establish connection lazily!" 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /spec/switch_point_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe SwitchPoint do 4 | describe '.writable_all!' do 5 | after do 6 | SwitchPoint.readonly_all! 7 | end 8 | 9 | it 'changes connection globally' do 10 | expect(Book).to connect_to('main_readonly.sqlite3') 11 | expect(Book3).to connect_to('main2_readonly.sqlite3') 12 | expect(Comment).to connect_to('comment_readonly.sqlite3') 13 | expect(User).to connect_to('user.sqlite3') 14 | expect(BigData).to connect_to('main_readonly_special.sqlite3') 15 | SwitchPoint.writable_all! 16 | expect(Book).to connect_to('main_writable.sqlite3') 17 | expect(Book3).to connect_to('main2_writable.sqlite3') 18 | expect(Comment).to connect_to('comment_writable.sqlite3') 19 | expect(User).to connect_to('user.sqlite3') 20 | expect(BigData).to connect_to('main_writable.sqlite3') 21 | end 22 | 23 | it 'affects thread-globally' do 24 | SwitchPoint.writable_all! 25 | Thread.start do 26 | expect(Book).to connect_to('main_writable.sqlite3') 27 | expect(Book3).to connect_to('main2_writable.sqlite3') 28 | expect(Comment).to connect_to('comment_writable.sqlite3') 29 | expect(User).to connect_to('user.sqlite3') 30 | expect(BigData).to connect_to('main_writable.sqlite3') 31 | end.join 32 | end 33 | 34 | context 'within with block' do 35 | it 'changes the current mode' do 36 | SwitchPoint.writable_all! 37 | Book.with_readonly do 38 | expect(Book).to connect_to('main_readonly.sqlite3') 39 | end 40 | expect(Book).to connect_to('main_writable.sqlite3') 41 | Book.with_writable do 42 | expect(Book).to connect_to('main_writable.sqlite3') 43 | end 44 | end 45 | end 46 | end 47 | 48 | describe '.writable!' do 49 | after do 50 | SwitchPoint.readonly!(:main) 51 | end 52 | 53 | it 'changes connection globally' do 54 | expect(Book).to connect_to('main_readonly.sqlite3') 55 | expect(Publisher).to connect_to('main_readonly.sqlite3') 56 | SwitchPoint.writable!(:main) 57 | expect(Book).to connect_to('main_writable.sqlite3') 58 | expect(Publisher).to connect_to('main_writable.sqlite3') 59 | end 60 | 61 | it 'affects thread-globally' do 62 | SwitchPoint.writable!(:main) 63 | Thread.start do 64 | expect(Book).to connect_to('main_writable.sqlite3') 65 | end.join 66 | end 67 | 68 | context 'within with block' do 69 | it 'changes the current mode' do 70 | Book.with_writable do 71 | SwitchPoint.readonly!(:main) 72 | expect(Book).to connect_to('main_readonly.sqlite3') 73 | end 74 | expect(Book).to connect_to('main_readonly.sqlite3') 75 | Book.with_writable do 76 | expect(Book).to connect_to('main_writable.sqlite3') 77 | end 78 | end 79 | end 80 | 81 | context 'with unknown name' do 82 | it 'raises error' do 83 | expect { SwitchPoint.writable!(:unknown) }.to raise_error(KeyError) 84 | end 85 | end 86 | end 87 | 88 | describe '.with_writable' do 89 | it 'changes connection' do 90 | SwitchPoint.with_writable(:main, :nanika1) do 91 | expect(Book).to connect_to('main_writable.sqlite3') 92 | expect(Publisher).to connect_to('main_writable.sqlite3') 93 | expect(Nanika1).to connect_to('default.sqlite3') 94 | end 95 | expect(Book).to connect_to('main_readonly.sqlite3') 96 | expect(Publisher).to connect_to('main_readonly.sqlite3') 97 | expect(Nanika1).to connect_to('main_readonly.sqlite3') 98 | end 99 | 100 | context 'with unknown name' do 101 | it 'raises error' do 102 | expect { SwitchPoint.with_writable(:unknown) { raise RuntimeError } }.to raise_error(KeyError) 103 | end 104 | end 105 | end 106 | 107 | describe '.with_writable_all' do 108 | it 'changes all connections' do 109 | expect(Book).to connect_to('main_readonly.sqlite3') 110 | expect(Comment).to connect_to('comment_readonly.sqlite3') 111 | SwitchPoint.with_writable_all do 112 | expect(Book).to connect_to('main_writable.sqlite3') 113 | expect(Comment).to connect_to('comment_writable.sqlite3') 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/switch_point/proxy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'switch_point/error' 4 | 5 | module SwitchPoint 6 | class Proxy 7 | attr_reader :initial_name 8 | 9 | AVAILABLE_MODES = %i[writable readonly].freeze 10 | DEFAULT_MODE = :readonly 11 | 12 | def initialize(name) 13 | @initial_name = name 14 | @current_name = name 15 | AVAILABLE_MODES.each do |mode| 16 | model = define_model(name, mode) 17 | memorize_switch_point(name, mode, model.connection_pool) 18 | end 19 | @global_mode = DEFAULT_MODE 20 | end 21 | 22 | def define_model(name, mode) 23 | model_name = SwitchPoint.config.model_name(name, mode) 24 | if model_name 25 | model = Class.new(ActiveRecord::Base) 26 | Proxy.const_set(model_name, model) 27 | model.establish_connection(SwitchPoint.config.database_name(name, mode)) 28 | model 29 | elsif mode == :readonly 30 | # Re-use writable connection 31 | Proxy.const_get(SwitchPoint.config.model_name(name, :writable)) 32 | else 33 | ActiveRecord::Base 34 | end 35 | end 36 | 37 | def memorize_switch_point(name, mode, pool) 38 | switch_point = { name: name, mode: mode } 39 | if pool.equal?(ActiveRecord::Base.connection_pool) 40 | if mode != :writable 41 | raise Error.new("ActiveRecord::Base's switch_points must be writable, but #{name} is #{mode}") 42 | end 43 | 44 | switch_points = pool.spec.config[:switch_points] || [] 45 | switch_points << switch_point 46 | pool.spec.config[:switch_points] = switch_points 47 | elsif pool.spec.config.key?(:switch_point) 48 | # Only :writable is specified 49 | else 50 | pool.spec.config[:switch_point] = switch_point 51 | end 52 | end 53 | 54 | def thread_local_mode 55 | Thread.current[:"switch_point_#{@current_name}_mode"] 56 | end 57 | 58 | def thread_local_mode=(mode) 59 | Thread.current[:"switch_point_#{@current_name}_mode"] = mode 60 | end 61 | private :thread_local_mode= 62 | 63 | def mode 64 | thread_local_mode || @global_mode 65 | end 66 | 67 | def readonly! 68 | if thread_local_mode 69 | self.thread_local_mode = :readonly 70 | else 71 | @global_mode = :readonly 72 | end 73 | end 74 | 75 | def readonly? 76 | mode == :readonly 77 | end 78 | 79 | def writable! 80 | if thread_local_mode 81 | self.thread_local_mode = :writable 82 | else 83 | @global_mode = :writable 84 | end 85 | end 86 | 87 | def writable? 88 | mode == :writable 89 | end 90 | 91 | def with_readonly(&block) 92 | with_mode(:readonly, &block) 93 | end 94 | 95 | def with_writable(&block) 96 | with_mode(:writable, &block) 97 | end 98 | 99 | def with_mode(new_mode, &block) 100 | unless AVAILABLE_MODES.include?(new_mode) 101 | raise ArgumentError.new("Unknown mode: #{new_mode}") 102 | end 103 | 104 | saved_mode = thread_local_mode 105 | self.thread_local_mode = new_mode 106 | block.call 107 | ensure 108 | self.thread_local_mode = saved_mode 109 | end 110 | 111 | def switch_name(new_name, &block) 112 | if block 113 | begin 114 | old_name = @current_name 115 | @current_name = new_name 116 | block.call 117 | ensure 118 | @current_name = old_name 119 | end 120 | else 121 | @current_name = new_name 122 | end 123 | end 124 | 125 | def reset_name! 126 | @current_name = @initial_name 127 | end 128 | 129 | def model_for_connection 130 | ProxyRepository.checkout(@current_name) # Ensure the target proxy is created 131 | model_name = SwitchPoint.config.model_name(@current_name, mode) 132 | if model_name 133 | Proxy.const_get(model_name) 134 | elsif mode == :readonly 135 | # When only writable is specified, re-use writable connection. 136 | with_writable do 137 | model_for_connection 138 | end 139 | else 140 | ActiveRecord::Base 141 | end 142 | end 143 | 144 | def connection 145 | model_for_connection.connection 146 | end 147 | 148 | def connected? 149 | model_for_connection.connected? 150 | end 151 | 152 | def cache(&block) 153 | r = with_readonly { model_for_connection } 154 | w = with_writable { model_for_connection } 155 | r.cache { w.cache(&block) } 156 | end 157 | 158 | def uncached(&block) 159 | r = with_readonly { model_for_connection } 160 | w = with_writable { model_for_connection } 161 | r.uncached { w.uncached(&block) } 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwitchPoint 2 | [![Gem Version](https://badge.fury.io/rb/switch_point.svg)](http://badge.fury.io/rb/switch_point) 3 | [![Build Status](https://travis-ci.org/eagletmt/switch_point.svg?branch=master)](https://travis-ci.org/eagletmt/switch_point) 4 | [![Coverage Status](https://img.shields.io/coveralls/eagletmt/switch_point.svg?branch=master)](https://coveralls.io/r/eagletmt/switch_point?branch=master) 5 | [![Code Climate](https://codeclimate.com/github/eagletmt/switch_point/badges/gpa.svg)](https://codeclimate.com/github/eagletmt/switch_point) 6 | 7 | Switching database connection between readonly one and writable one. 8 | 9 | ## Maintenance notice 10 | switch_point won't support upcoming ActiveRecord v6.1 or later. 11 | Developers should use the builtin multiple database feature introduced in ActiveRecord v6.0. 12 | https://guides.rubyonrails.org/active_record_multiple_databases.html 13 | Thus the supported ActiveRecord version is v3.2, v4.0, v4.1, v4.2, v5.0, v5.1, and v5.2. 14 | 15 | switch_point won't accept any new features. Bug fixes might be accepted. 16 | If you'd like to add a new feature (and/or support ActiveRecord >= v6.1), feel free to fork switch_point gem. 17 | 18 | ### Migration from switch_point to ActiveRecord multiple database feature 19 | 1. Upgrade your activerecord gem to v6.0 20 | - ActiveRecord v6.0 is the only series which supports both builtin multiple database feature and switch_point. 21 | 2. Change your application to use ActiveRecord multiple database feature 22 | - If you'd like to keep the number of connections during this step, it would require some tricks. 23 | 3. Remove switch_point gem from your Gemfile 24 | 4. Upgrade your activerecord gem to v6.1 or later 25 | 26 | ## Installation 27 | 28 | Add this line to your application's Gemfile: 29 | 30 | gem 'switch_point' 31 | 32 | And then execute: 33 | 34 | $ bundle 35 | 36 | Or install it yourself as: 37 | 38 | $ gem install switch_point 39 | 40 | ## Usage 41 | Suppose you have 4 databases: db-blog-master, db-blog-slave, db-comment-master and db-comment-slave. 42 | Article model and Category model are stored in db-blog-{master,slave} and Comment model is stored in db-comment-{master,slave}. 43 | 44 | ### Configuration 45 | In database.yml: 46 | 47 | ```yaml 48 | production_blog_master: 49 | adapter: mysql2 50 | username: blog_writable 51 | host: db-blog-master 52 | production_blog_slave: 53 | adapter: mysql2 54 | username: blog_readonly 55 | host: db-blog-slave 56 | production_comment_master: 57 | ... 58 | ``` 59 | 60 | In initializer: 61 | 62 | ```ruby 63 | SwitchPoint.configure do |config| 64 | config.define_switch_point :blog, 65 | readonly: :"#{Rails.env}_blog_slave", 66 | writable: :"#{Rails.env}_blog_master" 67 | config.define_switch_point :comment, 68 | readonly: :"#{Rails.env}_comment_slave", 69 | writable: :"#{Rails.env}_comment_master" 70 | end 71 | ``` 72 | 73 | In models: 74 | 75 | ```ruby 76 | class Article < ActiveRecord::Base 77 | use_switch_point :blog 78 | end 79 | 80 | class Category < ActiveRecord::Base 81 | use_switch_point :blog 82 | end 83 | 84 | class Comment < ActiveRecord::Base 85 | use_switch_point :comment 86 | end 87 | ``` 88 | 89 | ### Switching connections 90 | 91 | ```ruby 92 | Article.with_readonly { Article.first } # Read from db-blog-slave 93 | Category.with_readonly { Category.first } # Also read from db-blog-slave 94 | Comment.with_readonly { Comment.first } # Read from db-comment-slave 95 | 96 | Article.with_readonly do 97 | article = Article.first # Read from db-blog-slave 98 | article.title = 'new title' 99 | Article.with_writable do 100 | article.save! # Write to db-blog-master 101 | article.reload # Read from db-blog-master 102 | Category.first # Read from db-blog-master 103 | end 104 | end 105 | ``` 106 | 107 | Note that Article and Category shares their connections. 108 | 109 | ### Query cache 110 | `Model.cache` and `Model.uncached` enables/disables query cache for both 111 | readonly connection and writable connection. 112 | 113 | switch_point also provide a rack middleware `SwitchPoint::QueryCache` similar 114 | to `ActiveRecord::QueryCache`. It enables query cache for all models using 115 | switch_point. 116 | 117 | ```ruby 118 | # Replace ActiveRecord::QueryCache with SwitchPoint::QueryCache 119 | config.middleware.swap ActiveRecord::QueryCache, SwitchPoint::QueryCache 120 | 121 | # Enable query cache for :nanika1 only. 122 | config.middleware.swap ActiveRecord::QueryCache, SwitchPoint::QueryCache, [:nanika1] 123 | ``` 124 | 125 | ## Notes 126 | 127 | ### auto_writable 128 | `auto_writable` is disabled by default. 129 | 130 | When `auto_writable` is enabled, destructive queries is sent to writable connection even in readonly mode. 131 | But it does NOT work well on transactions. 132 | 133 | Suppose `after_save` callback is set to User model. When `User.create` is called, it proceeds as follows. 134 | 135 | 1. BEGIN TRANSACTION is sent to READONLY connection. 136 | 2. switch_point switches the connection to WRITABLE. 137 | 3. INSERT statement is sent to WRITABLE connection. 138 | 4. switch_point reset the connection to READONLY. 139 | 5. after_save callback is called. 140 | - At this point, the connection is READONLY and in a transaction. 141 | 6. COMMIT TRANSACTION is sent to READONLY connection. 142 | 143 | ### connection-related methods of model 144 | Model has several connection-related methods: `connection_handler`, `connection_pool`, `connected?` and so on. 145 | Since only `connection` method is monkey-patched, other connection-related methods doesn't work properly. 146 | If you'd like to use those methods, send it to `Model.switch_point_proxy.model_for_connection`. 147 | 148 | ## Internals 149 | There's a proxy which holds two connections: readonly one and writable one. 150 | A proxy has a thread-local state indicating the current mode: readonly or writable. 151 | 152 | Each ActiveRecord model refers to a proxy. 153 | `ActiveRecord::Base.connection` is hooked and delegated to the referred proxy. 154 | 155 | When the writable connection is requested to execute destructive query, the readonly connection clears its query cache. 156 | 157 | ![switch_point](https://gyazo.wanko.cc/switch_point.svg) 158 | 159 | ### Special case: ActiveRecord::Base.connection 160 | Basically, each connection managed by a proxy isn't shared between proxies. 161 | But there's one exception: ActiveRecord::Base. 162 | 163 | If `:writable` key is omitted (e.g., Nanika1 model in spec/models), it uses `ActiveRecord::Base.connection` as writable one. 164 | When `ActiveRecord::Base.connection` is requested to execute destructive query, all readonly connections managed by a proxy which uses `ActiveRecord::Base.connection` as a writable connection clear query cache. 165 | 166 | ## Contributing 167 | 168 | 1. Fork it ( https://github.com/eagletmt/switch_point/fork ) 169 | 2. Create your feature branch (`git checkout -b my-new-feature`) 170 | 3. Commit your changes (`git commit -am 'Add some feature'`) 171 | 4. Push to the branch (`git push origin my-new-feature`) 172 | 5. Create a new Pull Request 173 | -------------------------------------------------------------------------------- /spec/switch_point/model_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe SwitchPoint::Model do 4 | describe '.use_switch_point' do 5 | after do 6 | Book.use_switch_point :main 7 | end 8 | 9 | it 'changes connection' do 10 | expect(Book).to connect_to('main_readonly.sqlite3') 11 | Book.use_switch_point :comment 12 | expect(Book).to connect_to('comment_readonly.sqlite3') 13 | end 14 | 15 | context 'with non-existing switch point name' do 16 | it 'raises error' do 17 | expect { 18 | Class.new(ActiveRecord::Base) do 19 | use_switch_point :not_found 20 | end 21 | }.to raise_error(KeyError) 22 | end 23 | end 24 | end 25 | 26 | describe '.connection' do 27 | it 'returns readonly connection by default' do 28 | expect(Book).to connect_to('main_readonly.sqlite3') 29 | expect(Publisher).to connect_to('main_readonly.sqlite3') 30 | expect(User).to connect_to('user.sqlite3') 31 | expect(Comment).to connect_to('comment_readonly.sqlite3') 32 | expect(Note).to connect_to('default.sqlite3') 33 | expect(Book.switch_point_proxy).to be_readonly 34 | end 35 | 36 | context 'when auto_writable is disabled' do 37 | it 'raises error when destructive query is requested in readonly mode' do 38 | expect { Book.create }.to raise_error(SwitchPoint::ReadonlyError) 39 | expect { Book.with_readonly { Book.create } }.to raise_error(SwitchPoint::ReadonlyError) 40 | expect { Book.with_writable { Book.create } }.to_not raise_error 41 | end 42 | end 43 | 44 | context 'when auto_writable is enabled' do 45 | around do |example| 46 | SwitchPoint.configure do |config| 47 | config.auto_writable = true 48 | end 49 | example.run 50 | SwitchPoint.configure do |config| 51 | config.auto_writable = false 52 | end 53 | end 54 | 55 | it 'sends destructive queries to writable' do 56 | expect { Book.create }.to_not raise_error 57 | expect { Book.with_readonly { Book.create } }.to_not raise_error 58 | Book.with_readonly { expect(Book.count).to eq(0) } 59 | Book.with_writable { expect(Book.count).to eq(2) } 60 | end 61 | 62 | it 'executes after_save callback in readonly mode!' do 63 | book = Book.new 64 | expect(book).to receive(:do_after_save) { 65 | expect(Book.switch_point_proxy).to be_readonly 66 | expect(Book.connection.open_transactions).to eq(1) 67 | } 68 | book.save! 69 | end 70 | end 71 | 72 | it 'works with newly checked-out connection' do 73 | Thread.start do 74 | Book.with_writable do 75 | Book.create 76 | end 77 | Book.with_readonly { expect(Book.count).to eq(0) } 78 | Book.with_writable { expect(Book.count).to eq(1) } 79 | end.join 80 | end 81 | 82 | context 'without switch_point configuration' do 83 | it 'returns default connection' do 84 | expect(Note.connection).to equal(ActiveRecord::Base.connection) 85 | end 86 | end 87 | 88 | context 'with the same switch point name' do 89 | it 'shares connection' do 90 | expect(Book.connection).to equal(Publisher.connection) 91 | end 92 | end 93 | 94 | context 'with the same database name' do 95 | it 'does NOT shares a connection' do 96 | expect(Book.connection).to_not equal(BigData.connection) 97 | Book.with_writable do 98 | BigData.with_writable do 99 | expect(Book.connection).to_not equal(BigData.connection) 100 | end 101 | end 102 | end 103 | end 104 | 105 | context 'when superclass uses use_switch_point' do 106 | context 'without use_switch_point in derived class' do 107 | it 'inherits switch_point configuration' do 108 | expect(DerivedNanika1).to connect_to('main_readonly.sqlite3') 109 | end 110 | 111 | it 'shares connection with superclass' do 112 | expect(DerivedNanika1.connection).to equal(AbstractNanika.connection) 113 | end 114 | end 115 | 116 | context 'with use_switch_point in derived class' do 117 | it 'overrides superclass' do 118 | expect(DerivedNanika2).to connect_to('main2_readonly.sqlite3') 119 | end 120 | end 121 | 122 | context 'when superclass changes switch_point' do 123 | after do 124 | AbstractNanika.use_switch_point :main 125 | end 126 | 127 | it 'follows' do 128 | AbstractNanika.use_switch_point :main2 129 | expect(DerivedNanika1).to connect_to('main2_readonly.sqlite3') 130 | end 131 | end 132 | end 133 | 134 | context 'without :writable' do 135 | it 'sends destructive queries to ActiveRecord::Base' do 136 | expect(Nanika1).to connect_to('main_readonly.sqlite3') 137 | Nanika1.with_writable do 138 | expect(Nanika1).to connect_to('default.sqlite3') 139 | expect(Nanika1.connection).to equal(ActiveRecord::Base.connection) 140 | end 141 | end 142 | 143 | it 'clears all query caches' do 144 | expect(Nanika1.connection).to_not equal(Nanika2.connection) 145 | expect(Nanika1.connection).to receive(:clear_query_cache).once 146 | expect(Nanika2.connection).to receive(:clear_query_cache).once 147 | Note.create 148 | end 149 | end 150 | 151 | context 'without :readonly' do 152 | it 'sends all queries to :writable' do 153 | expect(Nanika3).to connect_to('comment_writable.sqlite3') 154 | Nanika3.with_writable do 155 | expect(Nanika3).to connect_to('comment_writable.sqlite3') 156 | Nanika3.create 157 | end 158 | expect(Nanika3.count).to eq(1) 159 | expect(Nanika3.with_readonly { Nanika3.connection }).to equal(Nanika3.with_writable { Nanika3.connection }) 160 | end 161 | end 162 | end 163 | 164 | describe '.with_writable' do 165 | it 'changes connection locally' do 166 | Book.with_writable do 167 | expect(Book).to connect_to('main_writable.sqlite3') 168 | expect(Book.switch_point_proxy).to be_writable 169 | end 170 | expect(Book).to connect_to('main_readonly.sqlite3') 171 | expect(Book.switch_point_proxy).to be_readonly 172 | end 173 | 174 | it 'affects to other models with the same switch point' do 175 | Book.with_writable do 176 | expect(Publisher).to connect_to('main_writable.sqlite3') 177 | end 178 | expect(Publisher).to connect_to('main_readonly.sqlite3') 179 | end 180 | 181 | it 'does not affect to other models with different switch point' do 182 | Book.with_writable do 183 | expect(Comment).to connect_to('comment_readonly.sqlite3') 184 | end 185 | end 186 | 187 | context 'with the same switch point' do 188 | it 'shares connection' do 189 | Book.with_writable do 190 | expect(Book.connection).to equal(Publisher.connection) 191 | end 192 | end 193 | end 194 | 195 | context 'with query cache' do 196 | context 'when writable connection does only non-destructive operation' do 197 | it 'keeps readable query cache' do 198 | # Ensure ActiveRecord::Base.connected? to make Book.cache work 199 | # See ActiveRecord::QueryCache::ClassMethods#cache 200 | ActiveRecord::Base.connection 201 | Book.cache do 202 | expect(Book.count).to eq(0) 203 | expect(Book.connection.query_cache.size).to eq(1) 204 | Book.with_writable do 205 | Book.count 206 | end 207 | expect(Book.connection.query_cache.size).to eq(1) 208 | end 209 | end 210 | end 211 | 212 | context 'when writable connection does destructive operation' do 213 | it 'clears readable query cache' do 214 | # Ensure ActiveRecord::Base.connected? to make Book.cache work 215 | # See ActiveRecord::QueryCache::ClassMethods#cache 216 | ActiveRecord::Base.connection 217 | Book.cache do 218 | expect(Book.count).to eq(0) 219 | expect(Book.connection.query_cache.size).to eq(1) 220 | Book.with_writable do 221 | Book.create 222 | FileUtils.cp('main_writable.sqlite3', 'main_readonly.sqlite3') # XXX: emulate replication 223 | end 224 | expect(Book.connection.query_cache.size).to eq(0) 225 | expect(Book.count).to eq(1) 226 | end 227 | end 228 | end 229 | end 230 | 231 | context 'without use_switch_point' do 232 | it 'raises error' do 233 | expect { Note.with_writable { :bypass } }.to raise_error(SwitchPoint::UnconfiguredError) 234 | end 235 | end 236 | 237 | it 'affects thread-locally' do 238 | Book.with_writable do 239 | expect(Book).to connect_to('main_writable.sqlite3') 240 | Thread.start do 241 | expect(Book).to connect_to('main_readonly.sqlite3') 242 | end.join 243 | end 244 | end 245 | end 246 | 247 | describe '#with_writable' do 248 | it 'behaves like .with_writable' do 249 | book = Book.with_writable { Book.create! } 250 | book.with_writable do 251 | expect(Book).to connect_to('main_writable.sqlite3') 252 | end 253 | expect(Book).to connect_to('main_readonly.sqlite3') 254 | end 255 | end 256 | 257 | describe '.with_readonly' do 258 | context 'when writable! is called globally' do 259 | before do 260 | SwitchPoint.writable!(:main) 261 | end 262 | 263 | after do 264 | SwitchPoint.readonly!(:main) 265 | end 266 | 267 | it 'locally overwrites global mode' do 268 | Book.with_readonly do 269 | expect(Book).to connect_to('main_readonly.sqlite3') 270 | end 271 | expect(Book).to connect_to('main_writable.sqlite3') 272 | end 273 | end 274 | end 275 | 276 | describe '#with_readonly' do 277 | before do 278 | SwitchPoint.writable!(:main) 279 | end 280 | 281 | after do 282 | SwitchPoint.readonly!(:main) 283 | end 284 | 285 | it 'behaves like .with_readonly' do 286 | book = Book.create! 287 | book.with_readonly do 288 | expect(Book).to connect_to('main_readonly.sqlite3') 289 | end 290 | expect(Book).to connect_to('main_writable.sqlite3') 291 | end 292 | end 293 | 294 | describe '#with_mode' do 295 | it 'raises error if unknown mode is given' do 296 | expect { SwitchPoint::ProxyRepository.checkout(:main).with_mode(:typo) }.to raise_error(ArgumentError) 297 | end 298 | end 299 | 300 | describe '.switch_name' do 301 | after do 302 | Book.switch_point_proxy.reset_name! 303 | end 304 | 305 | it 'switches proxy configuration' do 306 | Book.switch_point_proxy.switch_name(:comment) 307 | expect(Book).to connect_to('comment_readonly.sqlite3') 308 | expect(Publisher).to connect_to('comment_readonly.sqlite3') 309 | end 310 | 311 | context 'with block' do 312 | it 'switches proxy configuration locally' do 313 | Book.switch_point_proxy.switch_name(:comment) do 314 | expect(Book).to connect_to('comment_readonly.sqlite3') 315 | expect(Publisher).to connect_to('comment_readonly.sqlite3') 316 | end 317 | expect(Book).to connect_to('main_readonly.sqlite3') 318 | expect(Publisher).to connect_to('main_readonly.sqlite3') 319 | end 320 | end 321 | end 322 | 323 | describe '.transaction_with' do 324 | context 'when each model has a same writable' do 325 | before do 326 | @before_book_count = Book.count 327 | @before_book2_count = Book2.count 328 | 329 | Book.transaction_with(Book2) do 330 | Book.create 331 | Book2.create 332 | end 333 | 334 | @after_book_count = Book.with_writable do 335 | Book.count 336 | end 337 | @after_book2_count = Book2.with_writable do 338 | Book2.count 339 | end 340 | end 341 | 342 | it 'should create a new record' do 343 | expect( 344 | Book.with_writable do 345 | Book.count 346 | end 347 | ).to be > @before_book_count 348 | 349 | expect( 350 | Book2.with_writable do 351 | Book2.count 352 | end 353 | ).to be > @before_book2_count 354 | end 355 | end 356 | 357 | context 'when each model has a other writable' do 358 | it { 359 | expect { 360 | Book.transaction_with(Book3) do 361 | Book.create 362 | Book3.create 363 | end 364 | }.to raise_error(SwitchPoint::Error) 365 | } 366 | end 367 | 368 | context 'when raise exception in transaction that include some model, and models each have other writable' do 369 | before do 370 | @before_book_count = Book.count 371 | @before_book3_count = Book3.count 372 | 373 | Book.transaction_with(Book2) do 374 | Book.create 375 | Book3.with_writable do 376 | Book3.create 377 | end 378 | raise ActiveRecord::Rollback 379 | end 380 | end 381 | 382 | it 'Book should not create a new record (rollbacked)' do 383 | expect( 384 | Book.with_writable do 385 | Book.count 386 | end 387 | ).to eq @before_book_count 388 | end 389 | 390 | it 'Book3 should create a new record (not rollbacked)' do 391 | expect( 392 | Book3.with_writable do 393 | Book3.count 394 | end 395 | ).to be > @before_book3_count 396 | end 397 | end 398 | 399 | context 'when nested transaction_with then parent transaction rollbacked' do 400 | before do 401 | @before_book_count = Book.count 402 | @before_book3_count = Book3.count 403 | 404 | Book.transaction_with do 405 | Book.create 406 | 407 | Book3.transaction_with do 408 | Book3.create 409 | end 410 | 411 | raise ActiveRecord::Rollback 412 | end 413 | 414 | it { 415 | expect( 416 | Book.with_writable do 417 | Book.count 418 | end 419 | ).to be = @before_book_count 420 | 421 | expect( 422 | Book3.with_writable do 423 | Book3.count 424 | end 425 | ).to be > @before_book3_count 426 | } 427 | end 428 | end 429 | end 430 | 431 | describe '#transaction_with' do 432 | it 'behaves like .transaction_with' do 433 | book = Book.with_writable { Book.create! } 434 | expect(Book.with_writable { Book.count }).to eq(1) 435 | book.transaction_with(Book2) do 436 | Book.create! 437 | raise ActiveRecord::Rollback 438 | end 439 | expect(Book.with_writable { Book.count }).to eq(1) 440 | 441 | expect { book.transaction_with(Book3) {} }.to raise_error(SwitchPoint::Error) # rubocop:disable Lint/EmptyBlock 442 | end 443 | end 444 | 445 | describe '.cache' do 446 | it 'enables query cache for both readonly and writable' do 447 | Book.connection 448 | Book.with_writable { Book.connection } 449 | 450 | Book.cache do 451 | expect { Book.count }.to change { Book.connection.query_cache.size }.from(0).to(1) 452 | Book.with_writable do 453 | expect { Book.count }.to change { Book.connection.query_cache.size }.from(0).to(1) 454 | end 455 | end 456 | end 457 | end 458 | 459 | describe '.uncached' do 460 | it 'disables query cache for both readonly and writable' do 461 | Book.connection 462 | Book.with_writable { Book.connection } 463 | 464 | Book.cache do 465 | Book.uncached do 466 | expect { Book.count }.to_not change { Book.connection.query_cache.size }.from(0) 467 | Book.with_writable do 468 | expect { Book.count }.to_not change { Book.connection.query_cache.size }.from(0) 469 | end 470 | end 471 | end 472 | end 473 | end 474 | end 475 | -------------------------------------------------------------------------------- /assets/switch_point.svg: -------------------------------------------------------------------------------- 1 | 2 | mainmastercommentmastermaincommentwritablereadonlywritablereadonlyPublisherBookCommentModel < ActiveRecord::BaseProxyBigDataspecialmainspecialslavemainslavecommentslaveDatabasereadonlywritable 3 | --------------------------------------------------------------------------------