├── .github └── workflows │ └── prs.yml ├── .gitignore ├── .rubocop.yml ├── .simplecov ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.markdown ├── Rakefile ├── ar-multidb.gemspec ├── gemfiles ├── activerecord-5.1.gemfile ├── activerecord-5.2.gemfile ├── activerecord-6.0.gemfile ├── activerecord-6.1.gemfile ├── activerecord-7.0.gemfile └── activerecord-7.1.gemfile ├── lib ├── ar-multidb.rb ├── multidb.rb └── multidb │ ├── balancer.rb │ ├── candidate.rb │ ├── configuration.rb │ ├── log_subscriber.rb │ ├── model_extensions.rb │ └── version.rb └── spec ├── lib ├── multidb │ ├── balancer_spec.rb │ ├── candidate_spec.rb │ ├── configuration_spec.rb │ ├── log_subscriber_extension_spec.rb │ └── model_extensions_spec.rb └── multidb_spec.rb ├── spec_helper.rb └── support ├── have_database_matcher.rb └── helpers.rb /.github/workflows/prs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI PR Builds 3 | 'on': 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | concurrency: 9 | group: ci-${{ github.ref }} 10 | cancel-in-progress: true 11 | jobs: 12 | test: 13 | name: CI Build 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | ruby: 19 | - '2.5' 20 | - '2.7' 21 | - '3.0' 22 | activerecord: 23 | - '5.1' 24 | - '5.2' 25 | - '6.0' 26 | - '6.1' 27 | - '7.0' 28 | - '7.1' 29 | exclude: 30 | - ruby: '2.5' 31 | activerecord: '7.0' 32 | - ruby: '2.5' 33 | activerecord: '7.1' 34 | - ruby: '3.0' 35 | activerecord: '5.1' 36 | - ruby: '3.0' 37 | activerecord: '5.2' 38 | env: 39 | BUNDLE_GEMFILE: "${{ github.workspace }}/gemfiles/activerecord-${{ matrix.activerecord }}.gemfile" 40 | steps: 41 | - uses: actions/checkout@v4 42 | - name: Set up Ruby 43 | uses: ruby/setup-ruby@v1 44 | with: 45 | ruby-version: "${{ matrix.ruby }}" 46 | bundler-cache: true 47 | - name: Run bundle update 48 | run: bundle update 49 | - name: Run tests 50 | run: bundle exec rspec 51 | - name: Run rubocop 52 | run: bundle exec rubocop 53 | - name: Coveralls Parallel 54 | if: "${{ !env.ACT }}" 55 | uses: coverallsapp/github-action@v2 56 | with: 57 | github-token: "${{ secrets.GITHUB_TOKEN }}" 58 | flag-name: run-${{ matrix.ruby }}-${{ matrix.activerecord }} 59 | parallel: true 60 | finish: 61 | name: All CI Tests Passed 62 | needs: 63 | - test 64 | runs-on: ubuntu-latest 65 | steps: 66 | - name: Coveralls Finished 67 | if: "${{ !env.ACT }}" 68 | uses: coverallsapp/github-action@v2 69 | with: 70 | github-token: "${{ secrets.GITHUB_TOKEN }}" 71 | parallel-finished: true 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /pkg 2 | *~ 3 | \.DS_Store 4 | *.sqlite 5 | Gemfile.lock 6 | Gemfile.local 7 | gemfiles/*.lock 8 | .ruby-version 9 | .ruby-gemset 10 | .rspec 11 | /coverage 12 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rspec 3 | 4 | AllCops: 5 | TargetRubyVersion: 2.5 6 | SuggestExtensions: false 7 | NewCops: disable 8 | Exclude: 9 | - '**/vendor/**/*' 10 | 11 | Gemspec/DateAssignment: # new in 1.10 12 | Enabled: true 13 | Gemspec/RequireMFA: # new in 1.23 14 | Enabled: true 15 | 16 | Layout/EmptyLinesAroundAttributeAccessor: 17 | Enabled: true 18 | Layout/LineEndStringConcatenationIndentation: # new in 1.18 19 | Enabled: true 20 | Layout/LineLength: 21 | Exclude: 22 | - ar-multidb.gemspec 23 | Layout/SpaceAroundMethodCallOperator: 24 | Enabled: true 25 | Layout/SpaceBeforeBrackets: # new in 1.7 26 | Enabled: true 27 | 28 | Lint/AmbiguousAssignment: # new in 1.7 29 | Enabled: true 30 | Lint/AmbiguousBlockAssociation: 31 | IgnoredMethods: 32 | - change 33 | Lint/AmbiguousOperatorPrecedence: # new in 1.21 34 | Enabled: true 35 | Lint/AmbiguousRange: # new in 1.19 36 | Enabled: true 37 | Lint/DeprecatedConstants: # new in 1.8 38 | Enabled: true 39 | Lint/DeprecatedOpenSSLConstant: 40 | Enabled: true 41 | Lint/DuplicateBranch: # new in 1.3 42 | Enabled: true 43 | Lint/DuplicateElsifCondition: 44 | Enabled: true 45 | Lint/DuplicateRegexpCharacterClassElement: # new in 1.1 46 | Enabled: true 47 | Lint/EmptyBlock: # new in 1.1 48 | Enabled: true 49 | Lint/EmptyClass: # new in 1.3 50 | Enabled: true 51 | Lint/EmptyInPattern: # new in 1.16 52 | Enabled: true 53 | Lint/IncompatibleIoSelectWithFiberScheduler: # new in 1.21 54 | Enabled: true 55 | Lint/LambdaWithoutLiteralBlock: # new in 1.8 56 | Enabled: true 57 | Lint/MixedRegexpCaptureTypes: 58 | Enabled: true 59 | Lint/NoReturnInBeginEndBlocks: # new in 1.2 60 | Enabled: true 61 | Lint/NumberedParameterAssignment: # new in 1.9 62 | Enabled: true 63 | Lint/OrAssignmentToConstant: # new in 1.9 64 | Enabled: true 65 | Lint/RaiseException: 66 | Enabled: true 67 | Lint/RedundantDirGlobSort: # new in 1.8 68 | Enabled: true 69 | Lint/RefinementImportMethods: # new in 1.27 70 | Enabled: true 71 | Lint/RequireRelativeSelfPath: # new in 1.22 72 | Enabled: true 73 | Lint/StructNewOverride: 74 | Enabled: true 75 | Lint/SymbolConversion: # new in 1.9 76 | Enabled: true 77 | Lint/ToEnumArguments: # new in 1.1 78 | Enabled: true 79 | Lint/TripleQuotes: # new in 1.9 80 | Enabled: true 81 | Lint/UnexpectedBlockArity: # new in 1.5 82 | Enabled: true 83 | Lint/UnmodifiedReduceAccumulator: # new in 1.1 84 | Enabled: true 85 | Lint/UselessRuby2Keywords: # new in 1.23 86 | Enabled: true 87 | 88 | Naming/FileName: 89 | Exclude: 90 | - lib/ar-multidb.rb 91 | - 'gemfiles/*' 92 | Naming/BlockForwarding: # new in 1.24 93 | Enabled: true 94 | 95 | Metrics: 96 | Enabled: false 97 | 98 | RSpec/ExampleLength: 99 | CountAsOne: [array, heredoc] 100 | Max: 9 101 | RSpec/ExpectChange: 102 | EnforcedStyle: block 103 | RSpec/ImplicitSubject: 104 | Enabled: false 105 | RSpec/NamedSubject: 106 | Enabled: false 107 | RSpec/MultipleMemoizedHelpers: 108 | Enabled: false 109 | RSpec/MultipleExpectations: 110 | Enabled: false 111 | RSpec/NestedGroups: 112 | Max: 4 113 | 114 | Security/CompoundHash: # new in 1.28 115 | Enabled: true 116 | Security/IoMethods: # new in 1.22 117 | Enabled: true 118 | 119 | Style/AccessorGrouping: 120 | Enabled: true 121 | Style/ArgumentsForwarding: # new in 1.1 122 | Enabled: true 123 | Style/ArrayCoercion: 124 | Enabled: true 125 | Style/BisectedAttrAccessor: 126 | Enabled: true 127 | Style/BlockDelimiters: 128 | Enabled: false 129 | Style/CaseLikeIf: 130 | Enabled: true 131 | Style/CollectionCompact: # new in 1.2 132 | Enabled: true 133 | Style/Documentation: 134 | Enabled: false 135 | Style/DocumentDynamicEvalDefinition: # new in 1.1 136 | Enabled: true 137 | Style/EndlessMethod: # new in 1.8 138 | Enabled: true 139 | Style/ExponentialNotation: 140 | Enabled: true 141 | Style/FetchEnvVar: # new in 1.28 142 | Enabled: true 143 | Style/FileRead: # new in 1.24 144 | Enabled: true 145 | Style/FileWrite: # new in 1.24 146 | Enabled: true 147 | Style/HashAsLastArrayItem: 148 | Enabled: true 149 | Style/HashConversion: # new in 1.10 150 | Enabled: true 151 | Style/HashEachMethods: 152 | Enabled: true 153 | Style/HashExcept: # new in 1.7 154 | Enabled: true 155 | Style/HashLikeCase: 156 | Enabled: true 157 | Style/HashTransformKeys: 158 | Enabled: true 159 | Style/HashTransformValues: 160 | Enabled: true 161 | Style/IfWithBooleanLiteralBranches: # new in 1.9 162 | Enabled: true 163 | Style/InPatternThen: # new in 1.16 164 | Enabled: true 165 | Style/MapToHash: # new in 1.24 166 | Enabled: true 167 | Style/MultilineInPatternThen: # new in 1.16 168 | Enabled: true 169 | Style/NegatedIfElseCondition: # new in 1.2 170 | Enabled: true 171 | Style/NestedFileDirname: # new in 1.26 172 | Enabled: true 173 | Style/NilLambda: # new in 1.3 174 | Enabled: true 175 | Style/NumberedParameters: # new in 1.22 176 | Enabled: true 177 | Style/NumberedParametersLimit: # new in 1.22 178 | Enabled: true 179 | Style/ObjectThen: # new in 1.28 180 | Enabled: true 181 | Style/OpenStructUse: # new in 1.23 182 | Enabled: true 183 | Style/QuotedSymbols: # new in 1.16 184 | Enabled: true 185 | Style/RedundantArgument: # new in 1.4 186 | Enabled: true 187 | Style/RedundantAssignment: 188 | Enabled: true 189 | Style/RedundantFetchBlock: 190 | Enabled: true 191 | Style/RedundantFileExtensionInRequire: 192 | Enabled: true 193 | Style/RedundantInitialize: # new in 1.27 194 | Enabled: true 195 | Style/RedundantRegexpCharacterClass: 196 | Enabled: true 197 | Style/RedundantRegexpEscape: 198 | Enabled: true 199 | Style/RedundantSelfAssignmentBranch: # new in 1.19 200 | Enabled: true 201 | Style/SelectByRegexp: # new in 1.22 202 | Enabled: true 203 | Style/SlicingWithRange: 204 | Enabled: true 205 | Style/StringChars: # new in 1.12 206 | Enabled: true 207 | Style/SwapValues: # new in 1.1 208 | Enabled: true 209 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SimpleCov.configure do 4 | enable_coverage :branch 5 | add_filter '/spec/' 6 | add_filter 'lib/multidb/version.rb' 7 | add_filter 'lib/ar-multidb.rb' 8 | 9 | add_group 'Binaries', '/bin/' 10 | add_group 'Libraries', '/lib/' 11 | 12 | if ENV['CI'] 13 | require 'simplecov-lcov' 14 | 15 | SimpleCov::Formatter::LcovFormatter.config do |c| 16 | c.report_with_single_file = true 17 | c.single_report_path = 'coverage/lcov.info' 18 | end 19 | 20 | formatter SimpleCov::Formatter::LcovFormatter 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html) 6 | 7 | ## [0.7.0] 8 | ### Changed 9 | - add AR 7.1 support 10 | 11 | ## [0.6.0] 12 | ### Changed 13 | - dropped ruby 2.4 support 14 | - add AR 6.1 and 7.0 support 15 | 16 | ## [0.5.1] 17 | ### Fixed 18 | - corrected licenses 19 | ### Changed 20 | - updated to newer rubocop 21 | 22 | ## [0.5.0] 23 | ### Changed 24 | - added rails 6 support 25 | 26 | ## [0.4.2] 27 | ### Changed 28 | - adjust rails restriction to exclude untested rails 6 29 | - adjust minimum ruby version to 2.4 30 | - add rubocop 31 | 32 | ## [0.4.1] 33 | ### Added 34 | - Added support for database aliases ( PR #26 ) 35 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'http://rubygems.org' 4 | 5 | # Specify your gem's dependencies in ar-multidb.gemspec 6 | gemspec 7 | 8 | local_gemfile = File.expand_path('Gemfile.local', __dir__) 9 | eval(File.read(local_gemfile), binding, local_gemfile) if File.exist?(local_gemfile) # rubocop:disable Security/Eval 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Alexander Staubo 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/OutOfOrder/multidb/actions/workflows/prs.yml/badge.svg)](https://github.com/OutOfOrder/multidb/actions) 2 | [![Coverage Status](https://coveralls.io/repos/github/OutOfOrder/multidb/badge.svg?branch=master)](https://coveralls.io/github/OutOfOrder/multidb?branch=master) 3 | 4 | # Multidb 5 | 6 | A simple, no-nonsense ActiveRecord extension which allows the application to switch between multiple database connections, such as in a primary/replica environment. For example: 7 | 8 | Multidb.use(:replica) do 9 | @posts = Post.all 10 | end 11 | 12 | The extension was developed in order to support PostgreSQL 9.0's new hot standby support in a production environment. 13 | 14 | Randomized balancing of multiple connections within a group is supported. In the future, some kind of automatic balancing of read/write queries could be implemented. 15 | 16 | ## Requirements 17 | 18 | * Ruby 2.5 or later. 19 | * ActiveRecord 5.1 or later. 20 | 21 | ## Older releases 22 | For Ruby 2.4 use version 0.5.1 23 | For ActiveRecord 4. through 5.0 use version 0.3 24 | For ActiveRecord older than 4.0 use the gem version 0.1.13 25 | For ActiveRecord older than 3.0 use 0.1.10 26 | 27 | ## Comparison to other ActiveRecord extensions 28 | 29 | Compared to other, more full-featured extensions such as Octopus and Seamless Database Pool: 30 | 31 | **Minimal amount of monkeypatching magic**. The only part of ActiveRecord that is overridden is `ActiveRecord::Base#connection`. 32 | 33 | **Non-invasive**. Very small amounts of configuration and changes to the client application are required. 34 | 35 | **Orthogonal**. Unlike Octopus, for example, connections follow context: 36 | 37 | Multidb.use(:primary) do 38 | @post = Post.find(1) 39 | Multidb.use(:replica) do 40 | @post.authors # This will use the replica 41 | end 42 | end 43 | 44 | **Low-overhead**. Since `connection` is called on every single database operation, it needs to be fast. Which it is: Multidb's implementation of 45 | `connection` incurs only a single hash lookup in `Thread.current`. 46 | 47 | However, Multidb also has fewer features. At the moment it will _not_ automatically split reads and writes between database backends. 48 | 49 | ## Getting started 50 | 51 | Add to your `Gemfile`: 52 | 53 | gem 'ar-multidb', :require => 'multidb' 54 | 55 | All that is needed is to set up your `database.yml` file: 56 | 57 | production: 58 | adapter: postgresql 59 | database: myapp_production 60 | username: ohoh 61 | password: mymy 62 | host: db1 63 | multidb: 64 | databases: 65 | replica: 66 | host: db-replica 67 | 68 | Each database entry may be a hash or an array. So this also works: 69 | 70 | production: 71 | adapter: postgresql 72 | database: myapp_production 73 | username: ohoh 74 | password: mymy 75 | host: db1 76 | multidb: 77 | databases: 78 | replica: 79 | - host: db-replica1 80 | - host: db-replica2 81 | 82 | If multiple elements are specified, Multidb will use the list to pick a random candidate connection. 83 | 84 | The database hashes follow the same format as the top-level adapter configuration. In other words, each database connection may override the adapter, database name, username and so on. 85 | 86 | You may also add an "alias" record to the configuration to support more than one name for a given database configuration. 87 | 88 | production: 89 | adapter: postgresql 90 | database: myapp_production 91 | username: ohoh 92 | password: mymy 93 | host: db1 94 | multidb: 95 | databases: 96 | main_db: 97 | host: db1-a 98 | secondary_db: 99 | alias: main_db 100 | 101 | With the above, `Multidb.use(:main_db)` and `Multidb.use(:secondary_db)` will work identically. This can be useful to support naming scheme migrations transparently: once your application is updated to use `secondary_db` where necessary, you can swap out the configuration. 102 | 103 | To use the connection, modify your code by wrapping database access logic in blocks: 104 | 105 | Multidb.use(:replica) do 106 | @posts = Post.all 107 | end 108 | 109 | To wrap entire controller requests, for example: 110 | 111 | class PostsController < ApplicationController 112 | around_filter :run_using_replica, only: [:index] 113 | 114 | def index 115 | @posts = Post.all 116 | end 117 | 118 | def edit 119 | # Won't be wrapped 120 | end 121 | 122 | def run_using_replica(&block) 123 | Multidb.use(:replica, &block) 124 | end 125 | end 126 | 127 | You can also set the current connection for the remainder of the thread's execution: 128 | 129 | Multidb.use(:replica) 130 | # Do work 131 | Multidb.use(:primary) 132 | 133 | Note that the symbol `:default` will (unless you override it) refer to the default top-level ActiveRecord configuration. 134 | 135 | ## Development mode 136 | 137 | In development you will typically want `Multidb.use(:replica)` to still work, but you probably don't want to run multiple databases on your development box. To make `use` silently fall back to using the default connection, Multidb can run in fallback mode. 138 | 139 | If you are using Rails, this will be automatically enabled in `development` and `test` environments. Otherwise, simply set `fallback: true` in `database.yml`: 140 | 141 | development: 142 | adapter: postgresql 143 | database: myapp_development 144 | username: ohoh 145 | password: mymy 146 | host: db1 147 | multidb: 148 | fallback: true 149 | 150 | ## Limitations 151 | 152 | Multidb does not support per-class connections (eg., calling `establish_connection` within a class, as opposed to `ActiveRecord::Base`). 153 | 154 | ## Legal 155 | 156 | Copyright (c) 2011-2014 Alexander Staubo. Released under the MIT license. See the file `LICENSE`. 157 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new 7 | 8 | task default: :spec 9 | 10 | desc 'Bump version' 11 | task :bump do 12 | abort 'You have uncommitted changed.' if `git status -uno -s --porcelain | wc -l`.to_i.positive? 13 | 14 | text = File.read('lib/multidb/version.rb') 15 | if text =~ /VERSION = '(.*)'/ 16 | old_version = Regexp.last_match(1) 17 | version_parts = old_version.split('.') 18 | version_parts[-1] = version_parts[-1].to_i + 1 19 | new_version = version_parts.join('.') 20 | text.gsub!(/VERSION = '(.*)'/, "VERSION = '#{new_version}'") 21 | File.open('lib/multidb/version.rb', 'w') { |f| f << text } 22 | (system('git add lib/multidb/version.rb') && 23 | system("git commit -m 'Bump to #{new_version}.'")) || abort('Failed to commit.') 24 | else 25 | abort 'Could not find version number' 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /ar-multidb.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.push File.expand_path('lib', __dir__) 4 | require 'multidb/version' 5 | 6 | Gem::Specification.new do |s| 7 | s.name = 'ar-multidb' 8 | s.version = Multidb::VERSION 9 | s.authors = ['Alexander Staubo', 'Edward Rudd'] 10 | s.email = %w[alex@bengler.no urkle@outoforder.cc] 11 | s.homepage = 'https://github.com/OutOfOrder/multidb' 12 | s.summary = s.description = 'Multidb is an ActiveRecord extension for switching between multiple database connections, such as primary/replica setups.' 13 | s.license = 'MIT' 14 | s.metadata['rubygems_mfa_required'] = 'true' 15 | s.metadata['changelog_uri'] = 'https://github.com/OutOfOrder/multidb/blob/master/CHANGELOG.md' 16 | s.metadata['source_code_uri'] = 'https://github.com/OutOfOrder/multidb' 17 | 18 | s.files = `git ls-files`.split("\n") 19 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 20 | s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } 21 | s.require_paths = ['lib'] 22 | 23 | s.required_ruby_version = '>= 2.5.0' 24 | 25 | s.add_runtime_dependency 'activerecord', '>= 5.1', '< 7.2' 26 | s.add_runtime_dependency 'activesupport', '>= 5.1', '< 7.2' 27 | 28 | s.add_development_dependency 'rake', '~> 13.0' 29 | s.add_development_dependency 'rspec', '~> 3.8' 30 | s.add_development_dependency 'rubocop', '~> 1.28.0' 31 | s.add_development_dependency 'rubocop-rspec', '~> 2.10.0' 32 | s.add_development_dependency 'simplecov', '~> 0.21.2' 33 | s.add_development_dependency 'simplecov-lcov', '~> 0.8.0' 34 | s.add_development_dependency 'sqlite3', '~> 1.3' 35 | end 36 | -------------------------------------------------------------------------------- /gemfiles/activerecord-5.1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'activerecord', '~> 5.1.0' 6 | 7 | gemspec path: '..' 8 | -------------------------------------------------------------------------------- /gemfiles/activerecord-5.2.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'activerecord', '~> 5.2.0' 6 | 7 | gemspec path: '..' 8 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'activerecord', '~> 6.0.0' 6 | 7 | gemspec path: '..' 8 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'activerecord', '~> 6.1.0' 6 | 7 | gemspec path: '..' 8 | -------------------------------------------------------------------------------- /gemfiles/activerecord-7.0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'activerecord', '~> 7.0.0' 6 | 7 | gemspec path: '..' 8 | -------------------------------------------------------------------------------- /gemfiles/activerecord-7.1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'activerecord', '~> 7.1.0' 6 | 7 | gemspec path: '..' 8 | -------------------------------------------------------------------------------- /lib/ar-multidb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'multidb' 4 | -------------------------------------------------------------------------------- /lib/multidb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_record' 4 | 5 | require 'active_support/core_ext/module/delegation' 6 | require 'active_support/core_ext/module/attribute_accessors' 7 | require 'active_support/core_ext/module/aliasing' 8 | 9 | require_relative 'multidb/configuration' 10 | require_relative 'multidb/model_extensions' 11 | require_relative 'multidb/log_subscriber' 12 | require_relative 'multidb/candidate' 13 | require_relative 'multidb/balancer' 14 | require_relative 'multidb/version' 15 | 16 | module Multidb 17 | # Error raised when the configuration has not been initialized 18 | class NotInitializedError < StandardError; end 19 | 20 | class << self 21 | delegate :use, :get, :disconnect!, to: :balancer 22 | end 23 | 24 | def self.init(config) 25 | activerecord_config = config.dup.with_indifferent_access 26 | default_adapter = activerecord_config 27 | configuration_hash = activerecord_config.delete(:multidb) 28 | 29 | @balancer = Balancer.new(Configuration.new(default_adapter, configuration_hash || {})) 30 | end 31 | 32 | def self.balancer 33 | @balancer || raise(NotInitializedError, 'Balancer not initialized. You need to run Multidb.init first') 34 | end 35 | 36 | def self.reset! 37 | @balancer = nil 38 | Thread.current[:multidb] = nil 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/multidb/balancer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Multidb 4 | class Balancer 5 | attr_accessor :fallback 6 | 7 | def initialize(configuration) 8 | @candidates = {}.with_indifferent_access 9 | @default_configuration = configuration 10 | 11 | return unless @default_configuration 12 | 13 | append(@default_configuration.raw_configuration[:databases] || {}) 14 | 15 | @fallback = if @default_configuration.raw_configuration.include?(:fallback) 16 | @default_configuration.raw_configuration[:fallback] 17 | elsif defined?(Rails) 18 | %w[development test].include?(Rails.env) 19 | else 20 | false 21 | end 22 | 23 | @default_candidate = Candidate.new('default', @default_configuration.default_handler) 24 | 25 | @candidates[:default] = [@default_candidate] unless @candidates.include?(:default) 26 | end 27 | 28 | def append(databases) 29 | databases.with_indifferent_access.each_pair do |name, config| 30 | configs = config.is_a?(Array) ? config : [config] 31 | configs.each do |cfg| 32 | if cfg['alias'] 33 | @candidates[name] = @candidates[cfg['alias']] 34 | next 35 | end 36 | 37 | candidate = Candidate.new(name, @default_configuration.default_adapter.merge(cfg)) 38 | @candidates[name] ||= [] 39 | @candidates[name].push(candidate) 40 | end 41 | end 42 | end 43 | 44 | def disconnect! 45 | @candidates.values.flatten.each(&:disconnect!) 46 | end 47 | 48 | def get(name, &_block) 49 | candidates = @candidates[name] 50 | candidates ||= @fallback ? @candidates[:default] : [] 51 | 52 | raise ArgumentError, "No such database connection '#{name}'" if candidates.empty? 53 | 54 | candidate = candidates.sample 55 | 56 | block_given? ? yield(candidate) : candidate 57 | end 58 | 59 | def use(name, &_block) 60 | result = nil 61 | get(name) do |candidate| 62 | if block_given? 63 | candidate.connection do |connection| 64 | previous_configuration = Thread.current[:multidb] 65 | Thread.current[:multidb] = { 66 | connection: connection, 67 | connection_name: name 68 | } 69 | begin 70 | result = yield 71 | result = result.to_a if result.is_a?(ActiveRecord::Relation) 72 | ensure 73 | Thread.current[:multidb] = previous_configuration 74 | end 75 | result 76 | end 77 | else 78 | Thread.current[:multidb] = { 79 | connection: candidate.connection, 80 | connection_name: name 81 | } 82 | result = candidate.connection 83 | end 84 | end 85 | result 86 | end 87 | 88 | def current_connection 89 | if Thread.current[:multidb] 90 | Thread.current[:multidb][:connection] 91 | else 92 | @default_candidate.connection 93 | end 94 | end 95 | 96 | def current_connection_name 97 | if Thread.current[:multidb] 98 | Thread.current[:multidb][:connection_name] 99 | else 100 | :default 101 | end 102 | end 103 | 104 | class << self 105 | def use(name, &block) 106 | Multidb.balancer.use(name, &block) 107 | end 108 | 109 | def current_connection 110 | Multidb.balancer.current_connection 111 | end 112 | 113 | def current_connection_name 114 | Multidb.balancer.current_connection_name 115 | end 116 | 117 | def disconnect! 118 | Multidb.balancer.disconnect! 119 | end 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/multidb/candidate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Multidb 4 | class Candidate 5 | USE_RAILS_61 = Gem::Version.new(::ActiveRecord::VERSION::STRING) >= Gem::Version.new('6.1') 6 | SPEC_NAME = if USE_RAILS_61 7 | 'ActiveRecord::Base' 8 | else 9 | 'primary' 10 | end 11 | 12 | def initialize(name, target) 13 | @name = name 14 | 15 | case target 16 | when Hash 17 | target = target.merge(name: 'primary') unless USE_RAILS_61 18 | 19 | @connection_handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new 20 | @connection_handler.establish_connection(target) 21 | when ActiveRecord::ConnectionAdapters::ConnectionHandler 22 | @connection_handler = target 23 | else 24 | raise ArgumentError, 'Connection handler not passed to target' 25 | end 26 | end 27 | 28 | def connection(&block) 29 | if block_given? 30 | @connection_handler.retrieve_connection_pool(SPEC_NAME).with_connection(&block) 31 | else 32 | @connection_handler.retrieve_connection(SPEC_NAME) 33 | end 34 | end 35 | 36 | def disconnect! 37 | @connection_handler.clear_all_connections! 38 | end 39 | 40 | attr_reader :name 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/multidb/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Multidb 4 | class Configuration 5 | def initialize(default_adapter, configuration_hash) 6 | @default_handler = ActiveRecord::Base.connection_handler 7 | @default_adapter = default_adapter 8 | @raw_configuration = configuration_hash 9 | end 10 | 11 | attr_reader :default_handler, :default_adapter, :raw_configuration 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/multidb/log_subscriber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Multidb 4 | module LogSubscriberExtension 5 | def sql(event) 6 | name = Multidb.balancer.current_connection_name 7 | event.payload[:db_name] = name if name 8 | super 9 | end 10 | 11 | def debug(msg = nil) 12 | name = Multidb.balancer.current_connection_name 13 | if name 14 | db = color("[DB: #{name}]", ActiveSupport::LogSubscriber::GREEN, true) 15 | super(db + msg.to_s) 16 | else 17 | super 18 | end 19 | end 20 | end 21 | end 22 | 23 | ActiveRecord::LogSubscriber.prepend(Multidb::LogSubscriberExtension) 24 | -------------------------------------------------------------------------------- /lib/multidb/model_extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_record/base' 4 | 5 | module Multidb 6 | module Connection 7 | def establish_connection(spec = nil) 8 | super(spec) 9 | config = if connection_pool.respond_to?(:db_config) 10 | connection_pool.db_config.configuration_hash 11 | else 12 | connection_pool.spec.config 13 | end 14 | 15 | Multidb.init(config) 16 | end 17 | 18 | def connection 19 | Multidb.balancer.current_connection 20 | rescue Multidb::NotInitializedError 21 | super 22 | end 23 | end 24 | 25 | module ModelExtensions 26 | extend ActiveSupport::Concern 27 | 28 | included do 29 | class << self 30 | prepend Multidb::Connection 31 | end 32 | end 33 | end 34 | end 35 | 36 | ActiveRecord::Base.class_eval do 37 | include Multidb::ModelExtensions 38 | end 39 | -------------------------------------------------------------------------------- /lib/multidb/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Multidb 4 | VERSION = '0.7.0' 5 | end 6 | -------------------------------------------------------------------------------- /spec/lib/multidb/balancer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Multidb::Balancer do 6 | let(:config) { configuration_with_replicas } 7 | let(:configuration) { 8 | c = config.with_indifferent_access 9 | Multidb::Configuration.new(c.except(:multidb), c[:multidb] || {}) 10 | } 11 | let(:balancer) { global_config ? Multidb.balancer : described_class.new(configuration) } 12 | let(:global_config) { false } 13 | 14 | before do 15 | ActiveRecord::Base.establish_connection(config) if global_config 16 | end 17 | 18 | describe '#initialize' do 19 | subject { balancer } 20 | 21 | context 'when configuration has no multidb config' do 22 | let(:config) { configuration_with_replicas.except('multidb') } 23 | 24 | it 'sets @candidates to have only default set of candidates' do 25 | expect(subject.instance_variable_get(:@candidates).keys).to contain_exactly('default') 26 | end 27 | 28 | it 'sets @default_candidate to be the fist candidate for the default @candidates' do 29 | candidates = subject.instance_variable_get(:@candidates) 30 | 31 | expect(subject.instance_variable_get(:@default_candidate)).to eq(candidates['default'].first) 32 | end 33 | 34 | it 'sets @default_configuration to be the configuration' do 35 | expect(subject.instance_variable_get(:@default_configuration)).to eq(configuration) 36 | end 37 | 38 | it 'sets fallback to false' do 39 | expect(subject.fallback).to eq(false) 40 | end 41 | 42 | context 'when rails ENV is development' do 43 | before do 44 | stub_const('Rails', class_double('Rails', env: 'development')) 45 | end 46 | 47 | it 'sets fallback to true' do 48 | expect(subject.fallback).to eq(true) 49 | end 50 | end 51 | 52 | context 'when rails ENV is test' do 53 | before do 54 | stub_const('Rails', class_double('Rails', env: 'test')) 55 | end 56 | 57 | it 'sets fallback to true' do 58 | expect(subject.fallback).to eq(true) 59 | end 60 | end 61 | end 62 | 63 | context 'when configuration has fallback: true' do 64 | let(:config) { configuration_with_replicas.merge('multidb' => { 'fallback' => true }) } 65 | 66 | it 'sets @candidates to have only default set of candidates' do 67 | expect(subject.instance_variable_get(:@candidates).keys).to contain_exactly('default') 68 | end 69 | 70 | it 'sets @default_candidate to be the fist candidate for the default @candidates' do 71 | candidates = subject.instance_variable_get(:@candidates) 72 | 73 | expect(subject.instance_variable_get(:@default_candidate)).to eq(candidates['default'].first) 74 | end 75 | 76 | it 'sets @default_configuration to be the configuration' do 77 | expect(subject.instance_variable_get(:@default_configuration)).to eq(configuration) 78 | end 79 | 80 | it 'sets fallback to true' do 81 | expect(subject.fallback).to eq(true) 82 | end 83 | end 84 | 85 | context 'when configuration has default multidb configuration' do 86 | let(:config) { 87 | extra = { multidb: { databases: { 88 | default: { 89 | adapter: 'sqlite3', 90 | database: 'spec/test-default.sqlite' 91 | } 92 | } } } 93 | configuration_with_replicas.merge(extra) 94 | } 95 | 96 | it 'set @candidates default to that configuration and not @default_candidate' do 97 | candidates = subject.instance_variable_get(:@candidates) 98 | default_candidate = subject.instance_variable_get(:@default_candidate) 99 | 100 | expect(candidates[:default].first).not_to eq default_candidate 101 | end 102 | end 103 | 104 | context 'when configuration is nil' do 105 | let(:configuration) { nil } 106 | 107 | it 'set @candidates to an empty hash' do 108 | expect(subject.instance_variable_get(:@candidates)).to eq({}) 109 | end 110 | end 111 | end 112 | 113 | describe '#append' do 114 | subject { balancer.append(appended_config) } 115 | 116 | let(:config) { configuration_with_replicas.except('multidb') } 117 | 118 | context 'with a basic configuration' do 119 | let(:appended_config) { { replica4: { database: 'spec/test-replica4.sqlite' } } } 120 | 121 | it 'registers the new candidate set in @candidates' do 122 | expect { subject }.to change { 123 | balancer.instance_variable_get(:@candidates) 124 | }.to include('replica4') 125 | end 126 | 127 | it 'makes it available for use' do 128 | subject 129 | 130 | balancer.use(:replica4) do 131 | expect(balancer.current_connection).to have_database 'test-replica4.sqlite' 132 | end 133 | end 134 | 135 | it 'returns the connection name' do 136 | subject 137 | 138 | balancer.use(:replica4) do 139 | expect(balancer.current_connection_name).to eq :replica4 140 | end 141 | end 142 | end 143 | 144 | context 'with an alias' do 145 | let(:appended_config) { 146 | { 147 | replica2: { 148 | database: 'spec/test-replica4.sqlite' 149 | }, 150 | replica_alias: { 151 | alias: 'replica2' 152 | } 153 | } 154 | } 155 | 156 | it 'aliases replica4 as replica2' do 157 | subject 158 | 159 | candidates = balancer.instance_variable_get(:@candidates) 160 | 161 | expect(candidates['replica2']).to eq(candidates['replica_alias']) 162 | end 163 | end 164 | end 165 | 166 | describe '#disconnect!' do 167 | subject { balancer.disconnect! } 168 | 169 | it 'calls disconnect! on all the candidates' do 170 | candidate1 = instance_double(Multidb::Candidate, disconnect!: nil) 171 | candidate2 = instance_double(Multidb::Candidate, disconnect!: nil) 172 | 173 | candidates = { 'replica1' => [candidate1], 'replica2' => [candidate2] } 174 | 175 | balancer.instance_variable_set(:@candidates, candidates) 176 | 177 | subject 178 | 179 | expect(candidate1).to have_received(:disconnect!) 180 | expect(candidate2).to have_received(:disconnect!) 181 | end 182 | end 183 | 184 | describe '#get' do 185 | subject { balancer.get(name) } 186 | 187 | let(:name) { :replica1 } 188 | let(:candidates) { balancer.instance_variable_get(:@candidates) } 189 | 190 | context 'when there is only one candidate' do 191 | it 'returns the candidate' do 192 | is_expected.to eq candidates['replica1'].first 193 | end 194 | end 195 | 196 | context 'when there is more than one candidate' do 197 | it 'returns a random candidate' do 198 | returned = Set.new 199 | 100.times do 200 | returned << balancer.get(:replica3) 201 | end 202 | 203 | expect(returned).to match_array candidates['replica3'] 204 | end 205 | end 206 | 207 | context 'when the name has no configuration' do 208 | let(:name) { :other } 209 | 210 | context 'when fallback is false' do 211 | it 'raises an error' do 212 | expect { subject }.to raise_error(ArgumentError, /No such database connection/) 213 | end 214 | end 215 | 216 | context 'when fallback is true' do 217 | before do 218 | balancer.fallback = true 219 | end 220 | 221 | it 'returns the default connection' do 222 | is_expected.to eq candidates[:default].first 223 | end 224 | end 225 | 226 | context 'when given a block' do 227 | it 'yields the candidate' do 228 | expect { |y| 229 | balancer.get(:replica1, &y) 230 | }.to yield_with_args(candidates[:replica1].first) 231 | end 232 | end 233 | end 234 | end 235 | 236 | describe '#use' do 237 | context 'with an undefined connection' do 238 | it 'raises exception' do 239 | expect { 240 | balancer.use(:something) { nil } 241 | }.to raise_error(ArgumentError) 242 | end 243 | end 244 | 245 | context 'with a configured connection' do 246 | let(:global_config) { true } 247 | 248 | it 'returns default connection on :default' do 249 | balancer.use(:default) do 250 | expect(balancer.current_connection).to have_database 'test.sqlite' 251 | end 252 | end 253 | 254 | it 'returns results instead of relation' do 255 | foobar_class = Class.new(ActiveRecord::Base) do 256 | self.table_name = 'foo_bars' 257 | end 258 | 259 | res = balancer.use(:replica1) do 260 | ActiveRecord::Migration.verbose = false 261 | ActiveRecord::Schema.define(version: 1) { create_table :foo_bars } 262 | foobar_class.where(id: 42) 263 | end 264 | 265 | expect(res).to eq [] 266 | end 267 | end 268 | 269 | it 'returns replica connection' do 270 | balancer.use(:replica1) do 271 | expect(balancer.current_connection).to have_database 'test-replica1.sqlite' 272 | end 273 | end 274 | 275 | it 'returns supports nested replica connection' do 276 | balancer.use(:replica1) do 277 | balancer.use(:replica2) do 278 | expect(balancer.current_connection).to have_database 'test-replica2.sqlite' 279 | end 280 | end 281 | end 282 | 283 | it 'returns preserves state when nesting' do 284 | balancer.use(:replica1) do 285 | balancer.use(:replica2) do 286 | expect(balancer.current_connection).to have_database 'test-replica2.sqlite' 287 | end 288 | 289 | expect(balancer.current_connection).to have_database 'test-replica1.sqlite' 290 | end 291 | end 292 | 293 | it 'returns the parent connection for aliases' do 294 | expect(balancer.use(:replica1)).not_to eq balancer.use(:replica_alias) 295 | expect(balancer.use(:replica2)).to eq balancer.use(:replica_alias) 296 | end 297 | 298 | context 'when there are multiple candidates' do 299 | it 'returns random candidate' do 300 | names = [] 301 | 100.times do 302 | balancer.use(:replica3) do 303 | list = balancer.current_connection.execute('pragma database_list') 304 | names.push(File.basename(list.first&.[]('file'))) 305 | end 306 | end 307 | expect(names.uniq).to match_array %w[test-replica3-1.sqlite test-replica3-2.sqlite] 308 | end 309 | end 310 | end 311 | 312 | describe '#current_connection' do 313 | subject { balancer.current_connection } 314 | 315 | context 'when no alternate connection is active' do 316 | let(:global_config) { true } 317 | 318 | it 'returns main connection by default' do 319 | is_expected.to have_database 'test.sqlite' 320 | 321 | is_expected.to eq ActiveRecord::Base.retrieve_connection 322 | end 323 | end 324 | 325 | context 'when an alternate connection is active' do 326 | before do 327 | Thread.current[:multidb] = { connection: 'a different connection' } 328 | end 329 | 330 | it 'returns the thread local connection' do 331 | is_expected.to eq 'a different connection' 332 | end 333 | end 334 | end 335 | 336 | describe '#current_connection_name' do 337 | subject { balancer.current_connection_name } 338 | 339 | context 'when no alternate connection is active' do 340 | it 'returns default connection name for default connection' do 341 | is_expected.to eq :default 342 | end 343 | end 344 | 345 | context 'when an alternate connection is active' do 346 | before do 347 | Thread.current[:multidb] = { connection_name: :replica1 } 348 | end 349 | 350 | it 'returns the thread local connection' do 351 | is_expected.to eq :replica1 352 | end 353 | end 354 | end 355 | 356 | describe 'class delegates' do 357 | let(:balancer) { 358 | instance_double('Multidb::Balancer', 359 | use: nil, 360 | current_connection: nil, 361 | current_connection_name: nil, 362 | disconnect!: nil) 363 | } 364 | 365 | before do 366 | Multidb.instance_variable_set(:@balancer, balancer) 367 | end 368 | 369 | it 'delegates use to the balancer' do 370 | described_class.use(:name) 371 | 372 | expect(balancer).to have_received(:use).with(:name) 373 | end 374 | 375 | it 'delegates current_connection to the balancer' do 376 | described_class.current_connection 377 | 378 | expect(balancer).to have_received(:current_connection) 379 | end 380 | 381 | it 'delegates current_connection_name to the balancer' do 382 | described_class.current_connection_name 383 | 384 | expect(balancer).to have_received(:current_connection_name) 385 | end 386 | 387 | it 'delegates disconnect! to the balancer' do 388 | described_class.disconnect! 389 | 390 | expect(balancer).to have_received(:disconnect!) 391 | end 392 | end 393 | end 394 | -------------------------------------------------------------------------------- /spec/lib/multidb/candidate_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Multidb::Candidate do 6 | subject(:candidate) { described_class.new(name, target) } 7 | 8 | let(:name) { :default } 9 | let(:config) { configuration_with_replicas.with_indifferent_access.except(:multidb) } 10 | let(:target) { config } 11 | 12 | describe '#initialize' do 13 | context 'when target is a config hash' do 14 | let(:target) { config } 15 | 16 | it 'sets the connection_handler to a new AR connection handler' do 17 | handler = subject.instance_variable_get(:@connection_handler) 18 | expect(handler).to an_instance_of(ActiveRecord::ConnectionAdapters::ConnectionHandler) 19 | end 20 | 21 | it 'merges the name: primary into the hash', rails: '< 6.1' do 22 | handler = instance_double('ActiveRecord::ConnectionAdapters::ConnectionHandler') 23 | allow(ActiveRecord::ConnectionAdapters::ConnectionHandler).to receive(:new).and_return(handler) 24 | allow(handler).to receive(:establish_connection) 25 | 26 | subject 27 | 28 | expect(handler).to have_received(:establish_connection).with(hash_including(name: 'primary')) 29 | end 30 | end 31 | 32 | context 'when target is a connection handler' do 33 | let(:target) { ActiveRecord::ConnectionAdapters::ConnectionHandler.new } 34 | 35 | it 'sets the connection_handler to the passed handler' do 36 | handler = subject.instance_variable_get(:@connection_handler) 37 | expect(handler).to eq(target) 38 | end 39 | end 40 | 41 | context 'when target is anything else' do 42 | let(:target) { 'something else' } 43 | 44 | it 'raises an ArgumentError' do 45 | expect { subject }.to raise_error(ArgumentError, /Connection handler not passed/) 46 | end 47 | end 48 | 49 | it 'sets the name to the name' do 50 | expect(subject.name).to eq name 51 | end 52 | end 53 | 54 | describe '#connection' do 55 | let(:pool) { 56 | instance_double('ActiveRecord::ConnectionAdapters::ConnectionPool').tap do |o| 57 | allow(o).to receive(:with_connection).and_yield('a connection') 58 | end 59 | } 60 | let(:target) { 61 | ActiveRecord::ConnectionAdapters::ConnectionHandler.new.tap do |o| 62 | allow(o).to receive(:retrieve_connection) 63 | allow(o).to receive(:retrieve_connection_pool).and_return(pool) 64 | end 65 | } 66 | 67 | context 'when given a block' do 68 | it 'calls retrieve_connection_pool' do 69 | subject.connection { |_| nil } 70 | 71 | expect(target).to have_received(:retrieve_connection_pool).with(Multidb::Candidate::SPEC_NAME) 72 | end 73 | 74 | it 'yields a connection object' do 75 | expect { |y| 76 | subject.connection(&y) 77 | }.to yield_with_args('a connection') 78 | end 79 | end 80 | 81 | context 'when not given a block' do 82 | it 'calls retrieve_connection on the handler' do 83 | subject.connection 84 | 85 | expect(target).to have_received(:retrieve_connection).with(Multidb::Candidate::SPEC_NAME) 86 | end 87 | end 88 | end 89 | 90 | describe '#disconnect!' do 91 | subject { candidate.disconnect! } 92 | 93 | let(:target) { 94 | ActiveRecord::ConnectionAdapters::ConnectionHandler.new.tap do |o| 95 | allow(o).to receive(:clear_all_connections!) 96 | end 97 | } 98 | 99 | it 'calls clear_all_connections! on the handler' do 100 | subject 101 | 102 | expect(target).to have_received(:clear_all_connections!) 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/lib/multidb/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Multidb::Configuration do 6 | subject { described_class.new(config.except(:multidb), config[:multidb]) } 7 | 8 | let(:config) { configuration_with_replicas.with_indifferent_access } 9 | 10 | describe '#initialize' do 11 | it 'sets the default_handler to the AR connection handler' do 12 | expect(subject.default_handler).to eq(ActiveRecord::Base.connection_handler) 13 | end 14 | 15 | it 'sets the default_adapter to the main configuration' do 16 | expect(subject.default_adapter).to eq config.except(:multidb) 17 | end 18 | 19 | it 'sets the raw_configuration to the multidb configuration' do 20 | expect(subject.raw_configuration).to eq config[:multidb] 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/lib/multidb/log_subscriber_extension_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Multidb::LogSubscriberExtension do 6 | before do 7 | ActiveRecord::Base.establish_connection(configuration_with_replicas) 8 | end 9 | 10 | let(:klass) { 11 | klass = Class.new do 12 | def sql(event) 13 | event 14 | end 15 | 16 | def debug(msg) 17 | msg 18 | end 19 | 20 | def color(text, _color, _bold) 21 | text 22 | end 23 | end 24 | 25 | klass.tap do |o| 26 | o.prepend described_class 27 | end 28 | } 29 | 30 | let(:instance) { klass.new } 31 | 32 | it 'prepends the extension into the ActiveRecord::LogSubscriber' do 33 | expect(ActiveRecord::LogSubscriber.included_modules).to include(described_class) 34 | end 35 | 36 | describe '#sql' do 37 | subject { instance.sql(event) } 38 | 39 | let(:event) { instance_double('Event', payload: {}) } 40 | 41 | it 'sets the :default db_name into the event payload' do 42 | expect { subject }.to change { event.payload }.to include(db_name: :default) 43 | end 44 | 45 | context 'when a replica is active' do 46 | it 'sets the db_name into the event payload to the replica' do 47 | expect { 48 | Multidb.use(:replica1) { subject } 49 | }.to change { event.payload }.to include(db_name: :replica1) 50 | end 51 | end 52 | 53 | context 'when there is no name returned from the balancer' do 54 | before do 55 | allow(Multidb.balancer).to receive(:current_connection_name) 56 | end 57 | 58 | it 'does not change the payload' do 59 | expect { subject }.not_to change { event.payload } 60 | end 61 | end 62 | end 63 | 64 | describe '#debug' do 65 | subject { instance.debug('message') } 66 | 67 | it 'prepends the db name to the message' do 68 | is_expected.to include('[DB: default]') 69 | end 70 | 71 | context 'when a replica is active' do 72 | it 'prepends the replica dbname to the message' do 73 | Multidb.use(:replica1) { 74 | is_expected.to include('[DB: replica1') 75 | } 76 | end 77 | end 78 | 79 | context 'when there is no name returned from the balancer' do 80 | before do 81 | allow(Multidb.balancer).to receive(:current_connection_name) 82 | end 83 | 84 | it 'does not prepend to the message' do 85 | is_expected.to eq('message') 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /spec/lib/multidb/model_extensions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Multidb::ModelExtensions do 6 | it 'includes the Multidb::Connection module into the class methods of ActiveRecord::Base' do 7 | expect(ActiveRecord::Base.singleton_class.included_modules).to include Multidb::Connection 8 | end 9 | 10 | describe Multidb::Connection do 11 | describe '.establish_connection' do 12 | subject { ActiveRecord::Base.establish_connection(configuration_with_replicas) } 13 | 14 | it 'initializes multidb' do 15 | allow(Multidb).to receive(:init) 16 | 17 | subject 18 | 19 | expect(Multidb).to have_received(:init) 20 | end 21 | end 22 | 23 | describe '.connection' do 24 | subject { klass.connection } 25 | 26 | let(:klass) { 27 | Class.new do 28 | def self.connection 29 | 'AR connection' 30 | end 31 | 32 | include Multidb::ModelExtensions 33 | end 34 | } 35 | 36 | context 'when multidb is not initialized' do 37 | it 'calls AR::Base.connection' do 38 | is_expected.to eq('AR connection') 39 | end 40 | end 41 | 42 | context 'when multidb is initialized' do 43 | let(:balancer) { instance_double('Multidb::Balancer', current_connection: 'Multidb connection') } 44 | 45 | before do 46 | Multidb.instance_variable_set(:@balancer, balancer) 47 | end 48 | 49 | it 'calls current_connection on the balancer' do 50 | subject 51 | 52 | expect(balancer).to have_received(:current_connection) 53 | end 54 | 55 | it 'returns the balancer connection' do 56 | is_expected.to eq('Multidb connection') 57 | end 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/lib/multidb_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Multidb do 6 | let(:balancer) { instance_double('Multidb::Balancer', use: nil, get: nil, disconnect!: nil) } 7 | 8 | describe '.balancer' do 9 | subject { described_class.balancer } 10 | 11 | context 'with no configuration' do 12 | it 'raises exception' do 13 | expect { subject }.to raise_error(Multidb::NotInitializedError) 14 | end 15 | end 16 | 17 | context 'with configuration' do 18 | before do 19 | ActiveRecord::Base.establish_connection(configuration_with_replicas) 20 | end 21 | 22 | it 'returns balancer' do 23 | is_expected.to be_an_instance_of(Multidb::Balancer) 24 | end 25 | end 26 | end 27 | 28 | describe '.init' do 29 | subject { described_class.init(config) } 30 | 31 | let(:config) { configuration_with_replicas } 32 | 33 | it 'initializes @balancer' do 34 | expect { subject }.to change { 35 | described_class.instance_variable_get(:@balancer) 36 | }.from(nil).to an_instance_of(Multidb::Balancer) 37 | end 38 | 39 | it 'initializes the balancer with a configuration object' do 40 | allow(Multidb::Configuration).to receive(:new) 41 | 42 | subject 43 | 44 | expect(Multidb::Configuration).to have_received(:new).with(config.except('multidb'), config['multidb']) 45 | end 46 | end 47 | 48 | describe '.reset!' do 49 | subject { described_class.reset! } 50 | 51 | before do 52 | described_class.instance_variable_set(:@balancer, balancer) 53 | end 54 | 55 | it 'clears @balancer' do 56 | expect { subject }.to change { 57 | described_class.instance_variable_get(:@balancer) 58 | }.from(balancer).to(nil) 59 | end 60 | 61 | it 'clears the multidb thread local' do 62 | Thread.current[:multidb] = { some: :value } 63 | 64 | expect { subject }.to change { Thread.current[:multidb] }.to nil 65 | end 66 | end 67 | 68 | describe 'balancer delegates' do 69 | before do 70 | described_class.instance_variable_set(:@balancer, balancer) 71 | end 72 | 73 | it 'delegates use to the balancer' do 74 | described_class.use(:name) 75 | 76 | expect(balancer).to have_received(:use).with(:name) 77 | end 78 | 79 | it 'delegates get to the balancer' do 80 | described_class.get(:name) 81 | 82 | expect(balancer).to have_received(:get).with(:name) 83 | end 84 | 85 | it 'delegates disconnect! to the balancer' do 86 | described_class.disconnect! 87 | 88 | expect(balancer).to have_received(:disconnect!) 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | SimpleCov.start 5 | 6 | ENV['RACK_ENV'] ||= 'test' 7 | 8 | require 'rspec' 9 | require 'yaml' 10 | require 'active_record' 11 | require 'fileutils' 12 | 13 | $LOAD_PATH.unshift(File.expand_path('lib', __dir__)) 14 | require 'multidb' 15 | 16 | Dir[File.join(__dir__, 'support', '**', '*.rb')].sort.each { |f| require f } 17 | 18 | RSpec.configure do |config| 19 | config.disable_monkey_patching! 20 | config.order = :random 21 | Kernel.srand config.seed 22 | 23 | config.filter_run :focus 24 | config.run_all_when_everything_filtered = true 25 | 26 | config.filter_run_excluding rails: lambda { |v| 27 | rails_version = Gem::Version.new(ActiveRecord::VERSION::STRING) 28 | test = Gem::Requirement.new(v) 29 | !test.satisfied_by?(rails_version) 30 | } 31 | 32 | config.expect_with :rspec do |expectations| 33 | expectations.syntax = :expect 34 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 35 | end 36 | 37 | config.mock_with :rspec do |mocks| 38 | mocks.verify_partial_doubles = true 39 | end 40 | 41 | config.before do 42 | ActiveRecord::Base.clear_all_connections! 43 | Multidb.reset! 44 | end 45 | 46 | config.after do 47 | Multidb.reset! 48 | Dir.glob(File.expand_path('test*.sqlite', __dir__)).each do |f| 49 | FileUtils.rm(f) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/support/have_database_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define :have_database do |expected| 4 | match do |conn| 5 | list = conn.execute('pragma database_list') 6 | @result = File.basename(list.first&.[]('file')) 7 | @result == expected 8 | end 9 | description do 10 | "be connected to #{expected}" 11 | end 12 | failure_message do |actual| 13 | "expected that #{actual} would be connected to #{expected}, found #{@result}" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Helpers 4 | def configuration_with_replicas 5 | YAML.safe_load(<<~YAML) 6 | adapter: sqlite3 7 | database: spec/test.sqlite 8 | encoding: utf-8 9 | multidb: 10 | databases: 11 | replica1: 12 | database: spec/test-replica1.sqlite 13 | replica2: 14 | database: spec/test-replica2.sqlite 15 | replica3: 16 | - database: spec/test-replica3-1.sqlite 17 | - database: spec/test-replica3-2.sqlite 18 | replica_alias: 19 | alias: replica2 20 | YAML 21 | end 22 | end 23 | 24 | RSpec.configure do |config| 25 | config.include Helpers 26 | end 27 | --------------------------------------------------------------------------------