From ce55df39ccbfe885fd551d0f1bd7307679bc654e Mon Sep 17 00:00:00 2001 From: Reid Morrison Date: Thu, 17 Dec 2020 17:22:52 -0500 Subject: [PATCH 1/7] Working towards Rails 6 --- .rubocop.yml | 63 +++++ .travis.yml | 58 ++--- Appraisals | 23 +- Gemfile | 22 +- README.md | 225 ++++++++++-------- Rakefile | 16 +- active_record_replica.gemspec | 26 +- lib/active_record_replica.rb | 20 +- .../active_record_replica.rb | 54 +++-- lib/active_record_replica/extensions.rb | 52 ++-- lib/active_record_replica/replica.rb | 2 + test/database_rails6.yml | 23 ++ test/test_helper.rb | 34 +-- 13 files changed, 385 insertions(+), 233 deletions(-) create mode 100644 .rubocop.yml create mode 100644 test/database_rails6.yml diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..f816aea --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,63 @@ +AllCops: + TargetRubyVersion: 2.5 + Exclude: + - ".git/**/*" + - "gemfiles/*" + NewCops: enable + +# +# RuboCop built-in settings. +# For documentation on all settings see: https://docs.rubocop.org/en/stable +# + +# Turn on auto-correction of equals alignment. +Layout/EndAlignment: + AutoCorrect: true + +# Prevent accidental windows line endings +Layout/EndOfLine: + EnforcedStyle: lf + +# Use a table layout for hashes +Layout/HashAlignment: + EnforcedHashRocketStyle: table + EnforcedColonStyle: table + +# Match existing layout +Layout/SpaceInsideHashLiteralBraces: + EnforcedStyle: no_space + +# Support long block lengths for tests +Metrics/BlockLength: + Exclude: + - "test/**/*" + +# Initialization Vector abbreviation +Naming/MethodParameterName: + AllowedNames: ['iv', '_', 'io', 'ap'] + +# Does not allow Symbols to load +Security/YAMLLoad: + AutoCorrect: false + +# Needed for testing DateTime +Style/DateTime: + Exclude: ["test/**/*"] + +Style/Documentation: + Enabled: false + +# One line methods +Style/EmptyMethod: + EnforcedStyle: expanded + +Style/NumericPredicate: + AutoCorrect: true + +# Since English may not be loaded, cannot force its use. +Style/SpecialGlobalVars: + Enabled: false + +# Match Rails +Style/StringLiterals: + EnforcedStyle: double_quotes diff --git a/.travis.yml b/.travis.yml index 1027726..596cbce 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,51 +3,27 @@ bundler_args: --without development matrix: include: - - name: "Rails 4.2 on Ruby 2.3.8" - rvm: 2.3.8 - gemfile: gemfiles/rails_4.2.gemfile - - - name: "Rails 5.0 on Ruby 2.4.7" - rvm: 2.4.7 - gemfile: gemfiles/rails_5.0.gemfile - - name: "Rails 5.1 on Ruby 2.4.7" - rvm: 2.4.7 - gemfile: gemfiles/rails_5.1.gemfile - - name: "Rails 5.2 on Ruby 2.4.7" - rvm: 2.4.7 - gemfile: gemfiles/rails_5.2.gemfile - - - name: "Rails 5.0 on Ruby 2.5.6" - rvm: 2.5.6 - gemfile: gemfiles/rails_5.0.gemfile - - name: "Rails 5.1 on Ruby 2.5.6" - rvm: 2.5.6 - gemfile: gemfiles/rails_5.1.gemfile - - name: "Rails 5.2 on Ruby 2.5.6" - rvm: 2.5.6 - gemfile: gemfiles/rails_5.2.gemfile - - - name: "Rails 5.2 on Ruby 2.6.3" - rvm: 2.6.3 - gemfile: gemfiles/rails_5.2.gemfile - - - name: "Rails 5.2 on JRuby 9.2.8.0" - rvm: jruby-9.2.8.0 - gemfile: gemfiles/rails_5.2.gemfile + - name: "Rails 6.0 on Ruby 2.5.8" + rvm: 2.5.8 + gemfile: gemfiles/rails_6.0.gemfile + - name: "Rails 6.0 on JRuby 9.2.13.0" + rvm: jruby-9.2.13.0 + gemfile: gemfiles/rails_6.0.gemfile + + - name: "Rails 6.1 on Ruby 2.6.6" + rvm: 2.6.6 + gemfile: gemfiles/rails_6.1.gemfile + - name: "Rails 6.1 on Ruby 2.7.2" + rvm: 2.7.2 + gemfile: gemfiles/rails_6.1.gemfile + - name: "Rails 6.1 on JRuby 9.2.13.0" + rvm: jruby-9.2.13.0 + gemfile: gemfiles/rails_6.1.gemfile allow_failures: - - rvm: jruby-9.2.8.0 + - rvm: jruby-9.2.13.0 jdk: - openjdk10 -# Gitter integration -notifications: - webhooks: - urls: - - https://webhooks.gitter.im/e/4d6749e48eb60321640e - on_success: change # options: [always|never|change] default: always - on_failure: always # options: [always|never|change] default: always - on_start: never # options: [always|never|change] default: always - sudo: false diff --git a/Appraisals b/Appraisals index 490a7d2..37d695c 100644 --- a/Appraisals +++ b/Appraisals @@ -1,16 +1,21 @@ -appraise 'rails_4.2' do - gem 'activerecord', '~> 4.2.0' - gem 'activerecord-jdbcsqlite3-adapter', '~> 1.0', platform: :jruby +# frozen_string_literal: true + +appraise "rails_5.0" do + gem "activerecord", "~> 5.0" +end + +appraise "rails_5.1" do + gem "activerecord", "~> 5.1.0" end -appraise 'rails_5.0' do - gem 'activerecord', '~> 5.0' +appraise "rails_5.2" do + gem "activerecord", "~> 5.2.3" end -appraise 'rails_5.1' do - gem 'activerecord', '~> 5.1.0' +appraise "rails_6.0" do + gem "activerecord", "~> 6.0.3" end -appraise 'rails_5.2' do - gem 'activerecord', '~> 5.2.3' +appraise "rails_6.1" do + gem "activerecord", "~> 6.1.0" end diff --git a/Gemfile b/Gemfile index 8f4ffdd..8751a8c 100644 --- a/Gemfile +++ b/Gemfile @@ -1,15 +1,17 @@ -source 'https://rubygems.org' +# frozen_string_literal: true -gemspec +source "https://rubygems.org" -gem 'rake' -gem 'minitest' -gem 'awesome_print' +gemspec -gem 'sqlite3', '~> 1.3.0', platform: :ruby -gem 'jdbc-sqlite3', platform: :jruby -gem 'activerecord-jdbcsqlite3-adapter', platform: :jruby -gem 'appraisal' +gem "amazing_print" +gem "minitest" +gem "rake" -gem 'activerecord', '~> 5.2.0' +gem "activerecord-jdbcsqlite3-adapter", platform: :jruby +gem "appraisal" +gem "jdbc-sqlite3", platform: :jruby +gem "sqlite3", platform: :ruby +# gem "activerecord", "~> 5.2.0" +gem "activerecord", "~> 6.0.3" diff --git a/README.md b/README.md index de1c1e2..7ff132d 100644 --- a/README.md +++ b/README.md @@ -59,13 +59,13 @@ Log file output: ### Example showing how reads within a transaction go to the primary -```ruby +~~~ruby Role.transaction do r = Role.where(name: 'manager').first r.description = 'Manager' r.save! end -``` +~~~ Log file output: @@ -76,11 +76,11 @@ Log file output: Sometimes it is necessary to read from the primary: -```ruby +~~~ruby ActiveRecordReplica.read_from_primary do r = Role.where(name: 'manager').first end -``` +~~~ ## Usage Notes @@ -88,21 +88,21 @@ end Delete all executes against the primary database since it is only a delete: -``` +~~~ D, [2012-11-06T19:47:29.125932 #89772] DEBUG -- : SQL (1.0ms) DELETE FROM "users" -``` +~~~ ### destroy_all First performs a read against the replica database and then deletes the corresponding data from the primary -``` +~~~ D, [2012-11-06T19:43:26.890674 #89002] DEBUG -- : Replica: User Load (0.1ms) SELECT "users".* FROM "users" D, [2012-11-06T19:43:26.890972 #89002] DEBUG -- : (0.0ms) begin transaction D, [2012-11-06T19:43:26.891667 #89002] DEBUG -- : SQL (0.4ms) DELETE FROM "users" WHERE "users"."id" = ? [["id", 3]] D, [2012-11-06T19:43:26.892697 #89002] DEBUG -- : (0.9ms) commit transaction -``` +~~~ ## Transactions @@ -114,16 +114,16 @@ inside a transaction: In file config/application.rb: -```ruby +~~~ruby # Read from replica even when in an active transaction config.active_record_replica.ignore_transactions = true -``` +~~~ It is important to identify any code in the application that depends on being able to read any changes already part of the transaction, but not yet committed and wrap those reads with `ActiveRecordReplica.read_from_primary` -```ruby +~~~ruby Inquiry.transaction do # Create a new inquiry Inquiry.create @@ -133,9 +133,8 @@ Inquiry.transaction do ActiveRecordReplica.read_from_primary do count = Inquiry.count end - end -``` +~~~ ## Note @@ -168,17 +167,17 @@ When performing in-memory only model assignments Active Record will create a tra the transaction may never be used. Even though the transaction is unused it sends the following messages to the primary database: -~~ +~~~sql SET autocommit=0 commit SET autocommit=1 -~~ +~~~ This will impact the primary database if sufficient calls are made, such as in batch workers. For Example: -~~ruby +~~~ruby class Parent < ActiveRecord::Base has_one :child, dependent: :destroy end @@ -190,7 +189,7 @@ end # The following code will create an unused transaction against the primary, even when reads are going to replicas: parent = Parent.new parent.child = Child.new -~~ +~~~ If the `dependent: :destroy` is removed it no longer creates a transaction, but it also means dependents are not destroyed when a parent is destroyed. @@ -198,135 +197,175 @@ destroyed when a parent is destroyed. For this scenario when we are 100% confident no writes are being performed the following can be performed to ignore any attempt Active Record makes at creating the transaction: -~~ruby +~~~ruby ActiveRecordReplica.skip_transactions do parent = Parent.new parent.child = Child.new end -~~ +~~~ To help identify any code within a block that is creating transactions, wrap the code with `ActiveRecordReplica.block_transactions` to make it raise an exception anytime a transaction is attempted: -~~ruby +~~~ruby ActiveRecordReplica.block_transactions do parent = Parent.new parent.child = Child.new end -~~ +~~~ + +## Rails 6 and above -## Install +Rails 6 natively supports multiple databases. It unfortunately only supports connection switching, so it cannot +transparently redirect reads to a replica database the way ActiveRecordReplica does. + +### Installation Add to `Gemfile` -```ruby -gem 'active_record_replica' -``` +~~~ruby +gem "active_record_replica", "~>3.0" +~~~ -Run bundler to install: +### Configuration -``` -bundle -``` +Move the existing database config into a section under `primary` and add another section called `primary_reader` +with `replica: true` to `database.yml`, for example: + +~~~yaml +config: &config + database: production + username: username + password: password + encoding: utf8 + adapter: mysql + pool: 50 + +production: + primary: + <<: *config + host: primary1 + primary_reader: + <<: *config + host: replica1 + replica: true +~~~ + +In order to tell Active Record about these entries, add the required entry to `ApplicationRecord`. +For example: + +~~~ruby +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true + + connects_to database: {writing: :primary, reading: :primary_reader} +end +~~~ -Or, without Bundler: +Rails recommends that all user models should inherit from ApplicationRecord, but if your models still inherit +directly from `ActiveRecord::Base` then the following code could be used: -``` -gem install active_record_replica -``` +~~~ruby +# Not recommended +class ActiveRecord::Base + connects_to database: {writing: :primary, reading: :primary_reader} +end +~~~ -## Configuration +## Rails 4 & 5 + +### Installation + +Add to `Gemfile` + +~~~ruby +gem "active_record_replica", "~>2.0" +~~~ + +### Configuration To enable replica reads for any environment just add a _replica:_ entry to database.yml along with all the usual ActiveRecord database configuration options. For Example: -```yaml -production: +~~~yaml +config: &config database: production username: username password: password encoding: utf8 adapter: mysql - host: primary1 pool: 50 + +production: + <<: *config + host: primary1 replica: - database: production - username: username - password: password - encoding: utf8 - adapter: mysql - host: replica1 - pool: 50 -``` + <<: *config + host: replica1 +~~~ Sometimes it is useful to turn on replica reads per host, for example to activate replica reads only on the linux host 'batch': -```yaml -production: +~~~yaml +config: &config database: production username: username password: password encoding: utf8 adapter: mysql - host: primary1 pool: 50 + +production: + <<: *config + host: primary1 <% if `hostname`.strip == 'batch' %> replica: - database: production - username: username - password: password - encoding: utf8 - adapter: mysql + <<: *config host: replica1 - pool: 50 <% end %> -``` +~~~ If there are multiple replicas, it is possible to randomly select a replica on startup to balance the load across the replicas: -```yaml -production: +~~~yaml +config: &config database: production username: username password: password encoding: utf8 adapter: mysql - host: primary1 pool: 50 + +production: + <<: *config + host: primary1 replica: - database: production - username: username - password: password - encoding: utf8 - adapter: mysql - host: <%= %w(replica1 replica2 replica3).sample %> - pool: 50 -``` + <<: *config + host: <%= %w(replica1 replica2 replica3).sample %> +~~~ Replicas can also be assigned to specific hosts by using the hostname: -```yaml -production: +~~~yaml +config: &config database: production username: username password: password encoding: utf8 adapter: mysql - host: primary1 pool: 50 + +production: + <<: *config + host: primary1 replica: - database: production - username: username - password: password - encoding: utf8 - adapter: mysql - host: <%= `hostname`.strip == 'app1' ? 'replica1' : 'replica2' %> - pool: 50 -``` + <<: *config + host: <%= `hostname`.strip == 'app1' ? 'replica1' : 'replica2' %> +~~~ ## Set primary as default for Read @@ -334,17 +373,17 @@ The default behavior can also set to read/write operations against primary datab Create an initializer file config/initializer/active_record_replica.rb to force read from primary: -```yaml - ActiveRecordReplica.read_from_primary! -``` +~~~ruby +ActiveRecordReplica.read_from_primary! +~~~ Then use this method and supply block to read from the replica database: -```yaml +~~~ruby ActiveRecordReplica.read_from_replica do - User.count + User.count end -``` +~~~ ## Dependencies @@ -360,16 +399,16 @@ This project uses [Semantic Versioning](http://semver.org/). 2. Checkout your forked repository: - ```bash - git clone https://github.com/your_github_username/active_record_replica.git - cd active_record_replica - ``` +~~~shell +git clone https://github.com/your_github_username/active_record_replica.git +cd active_record_replica +~~~ 3. Create branch for your contribution: - ```bash - git co -b your_new_branch_name - ``` +~~~shell +git co -b your_new_branch_name +~~~ 4. Make code changes. @@ -377,9 +416,9 @@ This project uses [Semantic Versioning](http://semver.org/). 6. Push to your fork origin. - ```bash - git push origin - ``` +~~~shell +git push origin +~~~ 7. Submit PR from the branch on your fork in Github. diff --git a/Rakefile b/Rakefile index 71637e0..79c9c98 100644 --- a/Rakefile +++ b/Rakefile @@ -1,15 +1,17 @@ +# frozen_string_literal: true + # Setup bundler to avoid having to run bundle exec all the time. -require 'rubygems' -require 'bundler/setup' +require "rubygems" +require "bundler/setup" -require 'rake/testtask' -require_relative 'lib/active_record_replica/version' +require "rake/testtask" +require_relative "lib/active_record_replica/version" task :gem do system "gem build active_record_replica.gemspec" end -task :publish => :gem do +task publish: :gem do system "git tag -a v#{ActiveRecordReplica::VERSION} -m 'Tagging #{ActiveRecordReplica::VERSION}'" system "git push --tags" system "gem push active_record_replica-#{ActiveRecordReplica::VERSION}.gem" @@ -17,14 +19,14 @@ task :publish => :gem do end Rake::TestTask.new(:test) do |t| - t.pattern = 'test/**/*_test.rb' + t.pattern = "test/**/*_test.rb" t.verbose = true t.warning = false end # By default run tests against all appraisals if !ENV["APPRAISAL_INITIALIZED"] && !ENV["TRAVIS"] - require 'appraisal' + require "appraisal" task default: :appraisal else task default: :test diff --git a/active_record_replica.gemspec b/active_record_replica.gemspec index 6b231b9..58997c6 100644 --- a/active_record_replica.gemspec +++ b/active_record_replica.gemspec @@ -1,20 +1,22 @@ -$:.push File.expand_path('../lib', __FILE__) +# frozen_string_literal: true + +$LOAD_PATH.push File.expand_path("lib", __dir__) # Maintain your gem's version: -require 'active_record_replica/version' +require "active_record_replica/version" # Describe your gem and declare its dependencies: Gem::Specification.new do |spec| - spec.name = 'active_record_replica' + spec.name = "active_record_replica" spec.version = ActiveRecordReplica::VERSION spec.platform = Gem::Platform::RUBY - spec.authors = ['Reid Morrison'] - spec.email = ['reidmo@gmail.com'] - spec.homepage = 'https://github.com/rocketjob/active_record_replica' - spec.summary = 'Redirect ActiveRecord (Rails) reads to replica databases while ensuring all writes go to the primary database.' - spec.files = Dir['lib/**/*', 'LICENSE.txt', 'Rakefile', 'README.md'] - spec.test_files = Dir['test/**/*'] - spec.license = 'Apache-2.0' - spec.required_ruby_version = '>= 2.0' - spec.add_dependency 'activerecord', '>= 3.0', '< 6.0.0' + spec.authors = ["Reid Morrison"] + spec.email = ["reidmo@gmail.com"] + spec.homepage = "https://github.com/rocketjob/active_record_replica" + spec.summary = "Redirect ActiveRecord (Rails) reads to replica databases while ensuring all writes go to the primary database." + spec.files = Dir["lib/**/*", "LICENSE.txt", "Rakefile", "README.md"] + spec.test_files = Dir["test/**/*"] + spec.license = "Apache-2.0" + spec.required_ruby_version = ">= 2.5" + spec.add_dependency "activerecord", ">= 4.2" end diff --git a/lib/active_record_replica.rb b/lib/active_record_replica.rb index 33df46d..6d14b51 100644 --- a/lib/active_record_replica.rb +++ b/lib/active_record_replica.rb @@ -1,11 +1,11 @@ -require 'active_record' -require 'active_record/base' -require 'active_record_replica/version' -require 'active_record_replica/errors' -require 'active_record_replica/replica' -require 'active_record_replica/active_record_replica' -require 'active_record_replica/extensions' +# frozen_string_literal: true -if defined?(Rails) - require 'active_record_replica/railtie' -end +require "active_record" +require "active_record/base" +require "active_record_replica/errors" +require "active_record_replica/replica" unless ActiveRecord::VERSION::MAJOR >= 6 +require "active_record_replica/version" +require "active_record_replica/active_record_replica" +require "active_record_replica/extensions" + +require "active_record_replica/railtie" if defined?(Rails) diff --git a/lib/active_record_replica/active_record_replica.rb b/lib/active_record_replica/active_record_replica.rb index 00a6f91..9282a5a 100644 --- a/lib/active_record_replica/active_record_replica.rb +++ b/lib/active_record_replica/active_record_replica.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # # ActiveRecord read from a replica # @@ -11,41 +13,53 @@ module ActiveRecordReplica # environment: # In a non-Rails environment, supply the environment such as # 'development', 'production' - def self.install!(adapter_class = nil, environment = nil) - replica_config = ActiveRecord::Base.configurations[environment || Rails.env]["replica"] + def self.install!(base: ActiveRecord::Base, adapter_class: nil, environment: nil) + replica_config = base.configurations[environment || Rails.env]["replica"] unless replica_config - ActiveRecord::Base.logger.info("ActiveRecordReplica not installed since no replica database defined") - return + base.logger.info("ActiveRecordReplica not installed since no replica database defined") + return false end # When the DBMS is not available, an exception (e.g. PG::ConnectionBad) is raised - active_db_connection = ActiveRecord::Base.connection.active? rescue false + active_db_connection = begin + ActiveRecord::Base.connection.active? + rescue StandardError + false + end + unless active_db_connection - ActiveRecord::Base.logger.info("ActiveRecord not connected so not installing ActiveRecordReplica") + base.logger.info("ActiveRecord not connected so not installing ActiveRecordReplica") return end version = ActiveRecordReplica::VERSION - ActiveRecord::Base.logger.info("ActiveRecordReplica.install! v#{version} Establishing connection to replica database") - Replica.establish_connection(replica_config) + if ActiveRecord::VERSION::MAJOR >= 6 + base.logger.info("ActiveRecordReplica.install! v#{version} redirecting reads to role: :reading") + else + base.logger.info("ActiveRecordReplica.install! v#{version} Establishing connection to replica database") + Replica.establish_connection(replica_config) + end # Inject a new #select method into the ActiveRecord Database adapter - base = adapter_class || ActiveRecord::Base.connection.class - base.include(Extensions) + adapter_class ||= base.connection.class + adapter_class.include(Extensions) + true end - # Force reads for the supplied block to read from the primary database - # Only applies to calls made within the current thread + # Force reads for the supplied block to read from the primary database. + # Only applies to calls made within the current thread. + # + # Note: + # * This block overrides any value set with read_from_replica! def self.read_from_primary(&block) thread_variable_yield(:active_record_replica, :primary, &block) end + # Force reads for the supplied block to read from the replica database. + # Only applies to calls made within the current thread. # - # The default behavior can also set to read/write operations against primary - # Create an initializer file config/initializer/active_record_replica.rb - # and set ActiveRecordReplica.read_from_primary! to force read from primary. - # Then use this method and supply block to read from the replica database - # Only applies to calls made within the current thread + # Note: + # * This block overrides any value set with read_from_primary! def self.read_from_replica(&block) thread_variable_yield(:active_record_replica, :replica, &block) end @@ -86,11 +100,15 @@ def self.read_from_replica? # The default behavior can be set to read/write operations against primary. # Create an initializer file config/initializer/active_record_replica.rb # and set ActiveRecordReplica.read_from_primary! to force read from primary. + # + # Blocks of code can override this setting by calling .read_from_replica. def self.read_from_primary! @read_from_replica = false end # Force all subsequent reads in this process to read from the replica database. + # + # Blocks of code can override this setting by calling .read_from_primary. def self.read_from_replica! @read_from_replica = true end @@ -115,8 +133,6 @@ def self.ignore_transactions=(ignore_transactions) @ignore_transactions = ignore_transactions end - private - def self.thread_variable_equals(key, value) Thread.current.thread_variable_get(key) == value end diff --git a/lib/active_record_replica/extensions.rb b/lib/active_record_replica/extensions.rb index 963dd21..98605e9 100644 --- a/lib/active_record_replica/extensions.rb +++ b/lib/active_record_replica/extensions.rb @@ -1,65 +1,83 @@ -require 'active_support/concern' +# frozen_string_literal: true + +require "active_support/concern" module ActiveRecordReplica module Extensions extend ActiveSupport::Concern - [:select, :select_all, :select_one, :select_rows, :select_value, :select_values].each do |select_method| - class_eval <<-RUBY, __FILE__, __LINE__ + 1 + %i[select select_all select_one select_rows select_value select_values].each do |select_method| + class_eval <<~RUBY, __FILE__, __LINE__ + 1 def #{select_method}(sql, name = nil, *args) return super if active_record_replica_read_from_primary? - - ActiveRecordReplica.read_from_primary do - reader_connection.#{select_method}(sql, "Replica: \#{name || 'SQL'}", *args) - end + + active_record_replica_select(:#{select_method}, sql, name, *args) end RUBY end - def reader_connection - Replica.connection + if ActiveRecord::VERSION::MAJOR >= 6 + def active_record_replica_select(select_method, sql, name = nil, *args) + ActiveRecordReplica.read_from_primary do + # if current_role == ActiveRecord::Base.reading_role + # connection.public_send(select_method, sql, "Replica: \#{name || 'SQL'}", *args) + # else + ActiveRecord::Base.connected_to(role: ActiveRecord::Base.reading_role) do + connection.public_send(select_method, sql, "Replica: \#{name || 'SQL'}", *args) + end + # end + end + end + else + def active_record_replica_select(select_method, sql, name = nil, *args) + ActiveRecordReplica.read_from_primary do + reader_connection.public_send(select_method, sql, "Replica: \#{name || 'SQL'}", *args) + end + end + + def reader_connection + Replica.connection + end end def begin_db_transaction return if ActiveRecordReplica.skip_transactions? return super unless ActiveRecordReplica.block_transactions? - raise(TransactionAttempted, 'Attempting to begin a transaction during a read-only database connection.') + raise(TransactionAttempted, "Attempting to begin a transaction during a read-only database connection.") end def commit_db_transaction return if ActiveRecordReplica.skip_transactions? return super unless ActiveRecordReplica.block_transactions? - raise(TransactionAttempted, 'Attempting to commit a transaction during a read-only database connection.') + raise(TransactionAttempted, "Attempting to commit a transaction during a read-only database connection.") end def create_savepoint(name = current_savepoint_name(true)) return if ActiveRecordReplica.skip_transactions? return super unless ActiveRecordReplica.block_transactions? - raise(TransactionAttempted, 'Attempting to create a savepoint during a read-only database connection.') + raise(TransactionAttempted, "Attempting to create a savepoint during a read-only database connection.") end def rollback_to_savepoint(name = current_savepoint_name(true)) return if ActiveRecordReplica.skip_transactions? return super unless ActiveRecordReplica.block_transactions? - raise(TransactionAttempted, 'Attempting to rollback a savepoint during a read-only database connection.') + raise(TransactionAttempted, "Attempting to rollback a savepoint during a read-only database connection.") end def release_savepoint(name = current_savepoint_name(true)) return if ActiveRecordReplica.skip_transactions? return super unless ActiveRecordReplica.block_transactions? - raise(TransactionAttempted, 'Attempting to release a savepoint during a read-only database connection.') + raise(TransactionAttempted, "Attempting to release a savepoint during a read-only database connection.") end # Returns whether to read from the primary database def active_record_replica_read_from_primary? - # Read from primary when forced by thread variable, or - # in a transaction and not ignoring transactions ActiveRecordReplica.read_from_primary? || - (open_transactions > 0) && !ActiveRecordReplica.ignore_transactions? + open_transactions.positive? && !ActiveRecordReplica.ignore_transactions? end end end diff --git a/lib/active_record_replica/replica.rb b/lib/active_record_replica/replica.rb index 1c0266f..3f4ec19 100644 --- a/lib/active_record_replica/replica.rb +++ b/lib/active_record_replica/replica.rb @@ -1,5 +1,7 @@ module ActiveRecordReplica # Class to hold replica connection pool + # + # Note: Not used with Rails 6 and above class Replica < ActiveRecord::Base # Prevent Rails from trying to create an instance of this model self.abstract_class = true diff --git a/test/database_rails6.yml b/test/database_rails6.yml new file mode 100644 index 0000000..bbe8455 --- /dev/null +++ b/test/database_rails6.yml @@ -0,0 +1,23 @@ +config: &config + adapter: sqlite3 + pool: 5 + timeout: 5000 + +# Make the replica a separate database that is not replicated to ensure reads +# and writes go to the appropriate databases +test: + primary: + <<: *config + database: test/db1_primary.sqlite3 + primary_reader: + <<: *config + database: test/db1_replica.sqlite3 + replica: true + + consumers: + <<: *config + database: test/db2_primary.sqlite3 + consumers_reader: + <<: *config + database: test/db2_replica.sqlite3 + replica: true diff --git a/test/test_helper.rb b/test/test_helper.rb index 54c7502..7cac744 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,24 +1,28 @@ -ENV['RAILS_ENV'] = 'test' +# frozen_string_literal: true -require 'active_record' -require 'minitest/autorun' -require 'active_record_replica' -require 'awesome_print' -require 'logger' -require 'erb' +ENV["RAILS_ENV"] = "test" -l = Logger.new('test.log') +require "active_record" +require "minitest/autorun" +require "active_record_replica" +require "amazing_print" +require "logger" +require "erb" + +config_file_name = ActiveRecord::VERSION::MAJOR >= 6 ? "test/database_rails6.yml" : "test/database.yml" + +l = Logger.new("test.log") l.level = ::Logger::DEBUG ActiveRecord::Base.logger = l -ActiveRecord::Base.configurations = YAML::load(ERB.new(IO.read('test/database.yml')).result) +ActiveRecord::Base.configurations = YAML.load(ERB.new(IO.read(config_file_name)).result) # Define Schema in second database (replica) # Note: This is not be required when the primary database is being replicated to the replica db -ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations['test']['replica']) +ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations["test"]["replica"]) # Create table users in database active_record_replica_test -ActiveRecord::Schema.define :version => 0 do - create_table :users, :force => true do |t| +ActiveRecord::Schema.define version: 0 do + create_table :users, force: true do |t| t.string :name t.string :address end @@ -28,8 +32,8 @@ ActiveRecord::Base.establish_connection(:test) # Create table users in database active_record_replica_test -ActiveRecord::Schema.define :version => 0 do - create_table :users, :force => true do |t| +ActiveRecord::Schema.define version: 0 do + create_table :users, force: true do |t| t.string :name t.string :address end @@ -41,4 +45,4 @@ class User < ActiveRecord::Base # Install ActiveRecord replica. Done automatically by railtie in a Rails environment # Also tell it to use the test environment since Rails.env is not available -ActiveRecordReplica.install!(nil, 'test') +ActiveRecordReplica.install!(environment: "test") From 8af418ee98e74a9099a3c86a8abb598ced8b4e1d Mon Sep 17 00:00:00 2001 From: Reid Morrison Date: Mon, 4 Jan 2021 16:30:06 -0500 Subject: [PATCH 2/7] Single database support --- .travis.yml | 38 +++++++++++++-- Appraisals | 5 ++ README.md | 8 ++-- active_record_replica.gemspec | 5 +- gemfiles/rails_4.2.gemfile | 8 ++-- gemfiles/rails_5.0.gemfile | 8 ++-- gemfiles/rails_5.1.gemfile | 8 ++-- gemfiles/rails_5.2.gemfile | 8 ++-- gemfiles/rails_6.0.gemfile | 14 ++++++ gemfiles/rails_6.1.gemfile | 14 ++++++ .../active_record_replica.rb | 10 ++-- lib/active_record_replica/extensions.rb | 10 ++-- test/database_rails6.yml | 4 +- test/test_helper.rb | 46 ++++++++++++------- 14 files changed, 132 insertions(+), 54 deletions(-) create mode 100644 gemfiles/rails_6.0.gemfile create mode 100644 gemfiles/rails_6.1.gemfile diff --git a/.travis.yml b/.travis.yml index 596cbce..f2bb3b4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,11 +3,39 @@ bundler_args: --without development matrix: include: + - name: "Rails 4.2 on Ruby 2.3.8" + rvm: 2.3.8 + gemfile: gemfiles/rails_4.2.gemfile + + - name: "Rails 5.0 on Ruby 2.4.7" + rvm: 2.4.7 + gemfile: gemfiles/rails_5.0.gemfile + - name: "Rails 5.1 on Ruby 2.4.7" + rvm: 2.4.7 + gemfile: gemfiles/rails_5.1.gemfile + - name: "Rails 5.2 on Ruby 2.4.7" + rvm: 2.4.7 + gemfile: gemfiles/rails_5.2.gemfile + + - name: "Rails 5.0 on Ruby 2.5.8" + rvm: 2.5.8 + gemfile: gemfiles/rails_5.0.gemfile + - name: "Rails 5.1 on Ruby 2.5.8" + rvm: 2.5.8 + gemfile: gemfiles/rails_5.1.gemfile + - name: "Rails 5.2 on Ruby 2.5.8" + rvm: 2.5.8 + gemfile: gemfiles/rails_5.2.gemfile + + - name: "Rails 5.2 on Ruby 2.6.6" + rvm: 2.6.6 + gemfile: gemfiles/rails_5.2.gemfile + - name: "Rails 6.0 on Ruby 2.5.8" rvm: 2.5.8 gemfile: gemfiles/rails_6.0.gemfile - - name: "Rails 6.0 on JRuby 9.2.13.0" - rvm: jruby-9.2.13.0 + - name: "Rails 6.0 on JRuby 9.2.14.0" + rvm: jruby-9.2.14.0 gemfile: gemfiles/rails_6.0.gemfile - name: "Rails 6.1 on Ruby 2.6.6" @@ -16,12 +44,12 @@ matrix: - name: "Rails 6.1 on Ruby 2.7.2" rvm: 2.7.2 gemfile: gemfiles/rails_6.1.gemfile - - name: "Rails 6.1 on JRuby 9.2.13.0" - rvm: jruby-9.2.13.0 + - name: "Rails 6.1 on JRuby 9.2.14.0" + rvm: jruby-9.2.14.0 gemfile: gemfiles/rails_6.1.gemfile allow_failures: - - rvm: jruby-9.2.13.0 + - rvm: jruby-9.2.14.0 jdk: - openjdk10 diff --git a/Appraisals b/Appraisals index 37d695c..8a7bca0 100644 --- a/Appraisals +++ b/Appraisals @@ -1,5 +1,10 @@ # frozen_string_literal: true +appraise "rails_4.2" do + gem "activerecord", "~> 4.2.0" + gem "activerecord-jdbcsqlite3-adapter", "~> 1.0", platform: :jruby +end + appraise "rails_5.0" do gem "activerecord", "~> 5.0" end diff --git a/README.md b/README.md index 7ff132d..db25b61 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ D, [2012-11-06T19:43:26.892697 #89002] DEBUG -- : (0.9ms) commit transaction ## Transactions -By default ActiveRecordReplica detects when a call is inside a transaction and will +By default Active Record Replica detects when a call is inside a transaction and will send all reads to the _primary_ when a transaction is active. It is now possible to send reads to database replicas and ignore whether currently @@ -217,14 +217,14 @@ end ## Rails 6 and above Rails 6 natively supports multiple databases. It unfortunately only supports connection switching, so it cannot -transparently redirect reads to a replica database the way ActiveRecordReplica does. +transparently redirect reads to a replica database the way Active Record Replica does. ### Installation Add to `Gemfile` ~~~ruby -gem "active_record_replica", "~>3.0" +gem "active_record_replica" ~~~ ### Configuration @@ -279,7 +279,7 @@ end Add to `Gemfile` ~~~ruby -gem "active_record_replica", "~>2.0" +gem "active_record_replica" ~~~ ### Configuration diff --git a/active_record_replica.gemspec b/active_record_replica.gemspec index 58997c6..4ed0f1b 100644 --- a/active_record_replica.gemspec +++ b/active_record_replica.gemspec @@ -10,9 +10,8 @@ Gem::Specification.new do |spec| spec.name = "active_record_replica" spec.version = ActiveRecordReplica::VERSION spec.platform = Gem::Platform::RUBY - spec.authors = ["Reid Morrison"] - spec.email = ["reidmo@gmail.com"] - spec.homepage = "https://github.com/rocketjob/active_record_replica" + spec.authors = ["Reid Morrison", "James Brady"] + spec.homepage = "https://github.com/teespring/active_record_replica" spec.summary = "Redirect ActiveRecord (Rails) reads to replica databases while ensuring all writes go to the primary database." spec.files = Dir["lib/**/*", "LICENSE.txt", "Rakefile", "README.md"] spec.test_files = Dir["test/**/*"] diff --git a/gemfiles/rails_4.2.gemfile b/gemfiles/rails_4.2.gemfile index a05c02d..830ac95 100644 --- a/gemfiles/rails_4.2.gemfile +++ b/gemfiles/rails_4.2.gemfile @@ -2,13 +2,13 @@ source "https://rubygems.org" -gem "rake" +gem "amazing_print" gem "minitest" -gem "awesome_print" -gem "sqlite3", "~> 1.3.0", platform: :ruby -gem "jdbc-sqlite3", platform: :jruby +gem "rake" gem "activerecord-jdbcsqlite3-adapter", "~> 1.0", platform: :jruby gem "appraisal" +gem "jdbc-sqlite3", platform: :jruby +gem "sqlite3", platform: :ruby gem "activerecord", "~> 4.2.0" gemspec path: "../" diff --git a/gemfiles/rails_5.0.gemfile b/gemfiles/rails_5.0.gemfile index 7a02cf7..59ea279 100644 --- a/gemfiles/rails_5.0.gemfile +++ b/gemfiles/rails_5.0.gemfile @@ -2,13 +2,13 @@ source "https://rubygems.org" -gem "rake" +gem "amazing_print" gem "minitest" -gem "awesome_print" -gem "sqlite3", "~> 1.3.0", platform: :ruby -gem "jdbc-sqlite3", platform: :jruby +gem "rake" gem "activerecord-jdbcsqlite3-adapter", platform: :jruby gem "appraisal" +gem "jdbc-sqlite3", platform: :jruby +gem "sqlite3", platform: :ruby gem "activerecord", "~> 5.0" gemspec path: "../" diff --git a/gemfiles/rails_5.1.gemfile b/gemfiles/rails_5.1.gemfile index d3ae36a..3170b86 100644 --- a/gemfiles/rails_5.1.gemfile +++ b/gemfiles/rails_5.1.gemfile @@ -2,13 +2,13 @@ source "https://rubygems.org" -gem "rake" +gem "amazing_print" gem "minitest" -gem "awesome_print" -gem "sqlite3", "~> 1.3.0", platform: :ruby -gem "jdbc-sqlite3", platform: :jruby +gem "rake" gem "activerecord-jdbcsqlite3-adapter", platform: :jruby gem "appraisal" +gem "jdbc-sqlite3", platform: :jruby +gem "sqlite3", platform: :ruby gem "activerecord", "~> 5.1.0" gemspec path: "../" diff --git a/gemfiles/rails_5.2.gemfile b/gemfiles/rails_5.2.gemfile index c84416b..a0c07ac 100644 --- a/gemfiles/rails_5.2.gemfile +++ b/gemfiles/rails_5.2.gemfile @@ -2,13 +2,13 @@ source "https://rubygems.org" -gem "rake" +gem "amazing_print" gem "minitest" -gem "awesome_print" -gem "sqlite3", "~> 1.3.0", platform: :ruby -gem "jdbc-sqlite3", platform: :jruby +gem "rake" gem "activerecord-jdbcsqlite3-adapter", platform: :jruby gem "appraisal" +gem "jdbc-sqlite3", platform: :jruby +gem "sqlite3", platform: :ruby gem "activerecord", "~> 5.2.3" gemspec path: "../" diff --git a/gemfiles/rails_6.0.gemfile b/gemfiles/rails_6.0.gemfile new file mode 100644 index 0000000..44cd273 --- /dev/null +++ b/gemfiles/rails_6.0.gemfile @@ -0,0 +1,14 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "amazing_print" +gem "minitest" +gem "rake" +gem "activerecord-jdbcsqlite3-adapter", platform: :jruby +gem "appraisal" +gem "jdbc-sqlite3", platform: :jruby +gem "sqlite3", platform: :ruby +gem "activerecord", "~> 6.0.3" + +gemspec path: "../" diff --git a/gemfiles/rails_6.1.gemfile b/gemfiles/rails_6.1.gemfile new file mode 100644 index 0000000..a404288 --- /dev/null +++ b/gemfiles/rails_6.1.gemfile @@ -0,0 +1,14 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "amazing_print" +gem "minitest" +gem "rake" +gem "activerecord-jdbcsqlite3-adapter", platform: :jruby +gem "appraisal" +gem "jdbc-sqlite3", platform: :jruby +gem "sqlite3", platform: :ruby +gem "activerecord", "~> 6.1.0" + +gemspec path: "../" diff --git a/lib/active_record_replica/active_record_replica.rb b/lib/active_record_replica/active_record_replica.rb index 9282a5a..f64a069 100644 --- a/lib/active_record_replica/active_record_replica.rb +++ b/lib/active_record_replica/active_record_replica.rb @@ -14,10 +14,12 @@ module ActiveRecordReplica # In a non-Rails environment, supply the environment such as # 'development', 'production' def self.install!(base: ActiveRecord::Base, adapter_class: nil, environment: nil) - replica_config = base.configurations[environment || Rails.env]["replica"] - unless replica_config - base.logger.info("ActiveRecordReplica not installed since no replica database defined") - return false + if ActiveRecord::VERSION::MAJOR < 6 + replica_config = base.configurations[environment || Rails.env]["replica"] + unless replica_config + base.logger.info("ActiveRecordReplica not installed since no replica database defined") + return false + end end # When the DBMS is not available, an exception (e.g. PG::ConnectionBad) is raised diff --git a/lib/active_record_replica/extensions.rb b/lib/active_record_replica/extensions.rb index 98605e9..472b657 100644 --- a/lib/active_record_replica/extensions.rb +++ b/lib/active_record_replica/extensions.rb @@ -18,13 +18,13 @@ def #{select_method}(sql, name = nil, *args) if ActiveRecord::VERSION::MAJOR >= 6 def active_record_replica_select(select_method, sql, name = nil, *args) ActiveRecordReplica.read_from_primary do - # if current_role == ActiveRecord::Base.reading_role - # connection.public_send(select_method, sql, "Replica: \#{name || 'SQL'}", *args) - # else + if ActiveRecord::Base.current_role == ActiveRecord::Base.reading_role + public_send(select_method, sql, "Replica: #{name || 'SQL'}", *args) + else ActiveRecord::Base.connected_to(role: ActiveRecord::Base.reading_role) do - connection.public_send(select_method, sql, "Replica: \#{name || 'SQL'}", *args) + ActiveRecord::Base.connection.public_send(select_method, sql, "Replica: #{name || 'SQL'}", *args) end - # end + end end end else diff --git a/test/database_rails6.yml b/test/database_rails6.yml index bbe8455..95469a0 100644 --- a/test/database_rails6.yml +++ b/test/database_rails6.yml @@ -12,7 +12,9 @@ test: primary_reader: <<: *config database: test/db1_replica.sqlite3 - replica: true + # In tests it writes the schema to the "replicated" database. + # Should set to true in production. + # replica: true consumers: <<: *config diff --git a/test/test_helper.rb b/test/test_helper.rb index 7cac744..6eb33dd 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -16,31 +16,45 @@ ActiveRecord::Base.logger = l ActiveRecord::Base.configurations = YAML.load(ERB.new(IO.read(config_file_name)).result) -# Define Schema in second database (replica) -# Note: This is not be required when the primary database is being replicated to the replica db -ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations["test"]["replica"]) +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true -# Create table users in database active_record_replica_test -ActiveRecord::Schema.define version: 0 do - create_table :users, force: true do |t| - t.string :name - t.string :address + if ActiveRecord::VERSION::MAJOR >= 6 + connects_to database: { writing: :primary, reading: :primary_reader } end end -# Define Schema in primary database -ActiveRecord::Base.establish_connection(:test) +# Active Record based model +class User < ApplicationRecord +end # Create table users in database active_record_replica_test -ActiveRecord::Schema.define version: 0 do - create_table :users, force: true do |t| - t.string :name - t.string :address +def create_schema + ActiveRecord::Schema.define version: 0 do + create_table :users, force: true do |t| + t.string :name + t.string :address + end end end -# AR Model -class User < ActiveRecord::Base +# Define Schema in both databases. +# Note: This is for testing purposes only and not needed by a Rails app. +if ActiveRecord::VERSION::MAJOR >= 6 + ApplicationRecord.connected_to(database: :primary_reader) do + create_schema + end + ApplicationRecord.connected_to(database: :primary) do + create_schema + end + ApplicationRecord.establish_connection(:test) +else + ApplicationRecord.establish_connection(ActiveRecord::Base.configurations["test"]["replica"]) + create_schema + + # Define Schema in primary database + ApplicationRecord.establish_connection(:test) + create_schema end # Install ActiveRecord replica. Done automatically by railtie in a Rails environment From 6652639c4c6c6040df830cdd94570cbe92359a71 Mon Sep 17 00:00:00 2001 From: Reid Morrison Date: Fri, 8 Jan 2021 14:57:28 -0500 Subject: [PATCH 3/7] Fix tests --- active_record_replica.gemspec | 2 +- test/test_helper.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/active_record_replica.gemspec b/active_record_replica.gemspec index 4ed0f1b..6e1d8f8 100644 --- a/active_record_replica.gemspec +++ b/active_record_replica.gemspec @@ -16,6 +16,6 @@ Gem::Specification.new do |spec| spec.files = Dir["lib/**/*", "LICENSE.txt", "Rakefile", "README.md"] spec.test_files = Dir["test/**/*"] spec.license = "Apache-2.0" - spec.required_ruby_version = ">= 2.5" + spec.required_ruby_version = ">= 2.3" spec.add_dependency "activerecord", ">= 4.2" end diff --git a/test/test_helper.rb b/test/test_helper.rb index 6eb33dd..2296cb8 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -49,11 +49,11 @@ def create_schema end ApplicationRecord.establish_connection(:test) else - ApplicationRecord.establish_connection(ActiveRecord::Base.configurations["test"]["replica"]) + ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations["test"]["replica"]) create_schema # Define Schema in primary database - ApplicationRecord.establish_connection(:test) + ActiveRecord::Base.establish_connection(:test) create_schema end From 5736332370ef1396e6f9139ba79f17123024e3bc Mon Sep 17 00:00:00 2001 From: Reid Morrison Date: Fri, 8 Jan 2021 15:32:19 -0500 Subject: [PATCH 4/7] Test fixes --- .travis.yml | 3 +++ Appraisals | 1 + Gemfile | 2 +- gemfiles/rails_4.2.gemfile | 2 +- test/test_helper.rb | 6 +++--- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index f2bb3b4..33db90f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -44,6 +44,9 @@ matrix: - name: "Rails 6.1 on Ruby 2.7.2" rvm: 2.7.2 gemfile: gemfiles/rails_6.1.gemfile + - name: "Rails 6.1 on Ruby 3.0.0" + rvm: 3.0.0 + gemfile: gemfiles/rails_6.1.gemfile - name: "Rails 6.1 on JRuby 9.2.14.0" rvm: jruby-9.2.14.0 gemfile: gemfiles/rails_6.1.gemfile diff --git a/Appraisals b/Appraisals index 8a7bca0..dfdd250 100644 --- a/Appraisals +++ b/Appraisals @@ -3,6 +3,7 @@ appraise "rails_4.2" do gem "activerecord", "~> 4.2.0" gem "activerecord-jdbcsqlite3-adapter", "~> 1.0", platform: :jruby + gem "sqlite3", "~> 1.3.6", platform: :ruby end appraise "rails_5.0" do diff --git a/Gemfile b/Gemfile index 8751a8c..716a371 100644 --- a/Gemfile +++ b/Gemfile @@ -14,4 +14,4 @@ gem "jdbc-sqlite3", platform: :jruby gem "sqlite3", platform: :ruby # gem "activerecord", "~> 5.2.0" -gem "activerecord", "~> 6.0.3" +gem "activerecord", "~> 6.1" diff --git a/gemfiles/rails_4.2.gemfile b/gemfiles/rails_4.2.gemfile index 830ac95..9f7b879 100644 --- a/gemfiles/rails_4.2.gemfile +++ b/gemfiles/rails_4.2.gemfile @@ -8,7 +8,7 @@ gem "rake" gem "activerecord-jdbcsqlite3-adapter", "~> 1.0", platform: :jruby gem "appraisal" gem "jdbc-sqlite3", platform: :jruby -gem "sqlite3", platform: :ruby +gem "sqlite3", "~> 1.3.6", platform: :ruby gem "activerecord", "~> 4.2.0" gemspec path: "../" diff --git a/test/test_helper.rb b/test/test_helper.rb index 2296cb8..6cf3ba5 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -41,13 +41,13 @@ def create_schema # Define Schema in both databases. # Note: This is for testing purposes only and not needed by a Rails app. if ActiveRecord::VERSION::MAJOR >= 6 - ApplicationRecord.connected_to(database: :primary_reader) do + ActiveRecord::Base.connected_to(database: :primary_reader) do create_schema end - ApplicationRecord.connected_to(database: :primary) do + ActiveRecord::Base.connected_to(database: :primary) do create_schema end - ApplicationRecord.establish_connection(:test) + ActiveRecord::Base.establish_connection(:test) else ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations["test"]["replica"]) create_schema From 25df55e523f9c739b0a5dfb0864458d412d062dd Mon Sep 17 00:00:00 2001 From: Reid Morrison Date: Tue, 12 Jan 2021 11:48:16 -0500 Subject: [PATCH 5/7] Make reader_connection replaceable --- lib/active_record_replica/extensions.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/active_record_replica/extensions.rb b/lib/active_record_replica/extensions.rb index 472b657..79d204d 100644 --- a/lib/active_record_replica/extensions.rb +++ b/lib/active_record_replica/extensions.rb @@ -22,11 +22,15 @@ def active_record_replica_select(select_method, sql, name = nil, *args) public_send(select_method, sql, "Replica: #{name || 'SQL'}", *args) else ActiveRecord::Base.connected_to(role: ActiveRecord::Base.reading_role) do - ActiveRecord::Base.connection.public_send(select_method, sql, "Replica: #{name || 'SQL'}", *args) + reader_connection.public_send(select_method, sql, "Replica: #{name || 'SQL'}", *args) end end end end + + def reader_connection + ActiveRecord::Base.connection + end else def active_record_replica_select(select_method, sql, name = nil, *args) ActiveRecordReplica.read_from_primary do From f8b2f2b73a287acb52af563cd5f6cf6ebd2fb590 Mon Sep 17 00:00:00 2001 From: Reid Morrison Date: Mon, 1 Feb 2021 14:14:23 -0500 Subject: [PATCH 6/7] Support Ruby 3.0 --- lib/active_record_replica/extensions.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/active_record_replica/extensions.rb b/lib/active_record_replica/extensions.rb index 79d204d..58d2fa9 100644 --- a/lib/active_record_replica/extensions.rb +++ b/lib/active_record_replica/extensions.rb @@ -7,22 +7,22 @@ module Extensions %i[select select_all select_one select_rows select_value select_values].each do |select_method| class_eval <<~RUBY, __FILE__, __LINE__ + 1 - def #{select_method}(sql, name = nil, *args) + def #{select_method}(*args, **kargs) return super if active_record_replica_read_from_primary? - active_record_replica_select(:#{select_method}, sql, name, *args) + active_record_replica_select(:#{select_method}, *args, **kargs) end RUBY end if ActiveRecord::VERSION::MAJOR >= 6 - def active_record_replica_select(select_method, sql, name = nil, *args) + def active_record_replica_select(select_method, sql, name = nil, *args, **kargs) ActiveRecordReplica.read_from_primary do if ActiveRecord::Base.current_role == ActiveRecord::Base.reading_role - public_send(select_method, sql, "Replica: #{name || 'SQL'}", *args) + public_send(select_method, sql, "Replica: #{name || 'SQL'}", *args, **kargs) else ActiveRecord::Base.connected_to(role: ActiveRecord::Base.reading_role) do - reader_connection.public_send(select_method, sql, "Replica: #{name || 'SQL'}", *args) + reader_connection.public_send(select_method, sql, "Replica: #{name || 'SQL'}", *args, **kargs) end end end From 8417fefe9a501381b2d5c95d7c901060defeea97 Mon Sep 17 00:00:00 2001 From: Reid Morrison Date: Thu, 4 Feb 2021 14:58:34 -0500 Subject: [PATCH 7/7] So many different combinations of Rails and Ruby ... --- Appraisals | 4 ++- gemfiles/rails_5.0.gemfile | 6 ++-- lib/active_record_replica/extensions.rb | 43 +++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/Appraisals b/Appraisals index dfdd250..84b9c75 100644 --- a/Appraisals +++ b/Appraisals @@ -7,7 +7,9 @@ appraise "rails_4.2" do end appraise "rails_5.0" do - gem "activerecord", "~> 5.0" + gem "activerecord", "~> 5.0.0" + gem "activerecord-jdbcsqlite3-adapter", "~> 1.0", platform: :jruby + gem "sqlite3", "~> 1.3.6", platform: :ruby end appraise "rails_5.1" do diff --git a/gemfiles/rails_5.0.gemfile b/gemfiles/rails_5.0.gemfile index 59ea279..1095e8e 100644 --- a/gemfiles/rails_5.0.gemfile +++ b/gemfiles/rails_5.0.gemfile @@ -5,10 +5,10 @@ source "https://rubygems.org" gem "amazing_print" gem "minitest" gem "rake" -gem "activerecord-jdbcsqlite3-adapter", platform: :jruby +gem "activerecord-jdbcsqlite3-adapter", "~> 1.0", platform: :jruby gem "appraisal" gem "jdbc-sqlite3", platform: :jruby -gem "sqlite3", platform: :ruby -gem "activerecord", "~> 5.0" +gem "sqlite3", "~> 1.3.6", platform: :ruby +gem "activerecord", "~> 5.0.0" gemspec path: "../" diff --git a/lib/active_record_replica/extensions.rb b/lib/active_record_replica/extensions.rb index 58d2fa9..9a2cb0c 100644 --- a/lib/active_record_replica/extensions.rb +++ b/lib/active_record_replica/extensions.rb @@ -5,18 +5,49 @@ module ActiveRecordReplica module Extensions extend ActiveSupport::Concern - %i[select select_all select_one select_rows select_value select_values].each do |select_method| + no_keyword_args = %i[select select_one select_rows select_value select_values] + keyword_args = [] + + if ActiveRecord::VERSION::MAJOR >= 5 + keyword_args << :select_all + else + no_keyword_args << :select_all + end + + no_keyword_args.each do |select_method| + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + def #{select_method}(*args) + return super if active_record_replica_read_from_primary? + + active_record_replica_select(:#{select_method}, *args) + end + RUBY + end + + keyword_args.each do |select_method| class_eval <<~RUBY, __FILE__, __LINE__ + 1 def #{select_method}(*args, **kargs) return super if active_record_replica_read_from_primary? - active_record_replica_select(:#{select_method}, *args, **kargs) + active_record_replica_select_kargs(:#{select_method}, *args, **kargs) end RUBY end if ActiveRecord::VERSION::MAJOR >= 6 - def active_record_replica_select(select_method, sql, name = nil, *args, **kargs) + def active_record_replica_select(select_method, sql, name = nil, *args) + ActiveRecordReplica.read_from_primary do + if ActiveRecord::Base.current_role == ActiveRecord::Base.reading_role + public_send(select_method, sql, "Replica: #{name || 'SQL'}", *args) + else + ActiveRecord::Base.connected_to(role: ActiveRecord::Base.reading_role) do + reader_connection.public_send(select_method, sql, "Replica: #{name || 'SQL'}", *args) + end + end + end + end + + def active_record_replica_select_kargs(select_method, sql, name = nil, *args, **kargs) ActiveRecordReplica.read_from_primary do if ActiveRecord::Base.current_role == ActiveRecord::Base.reading_role public_send(select_method, sql, "Replica: #{name || 'SQL'}", *args, **kargs) @@ -38,6 +69,12 @@ def active_record_replica_select(select_method, sql, name = nil, *args) end end + def active_record_replica_select_kargs(select_method, sql, name = nil, *args, **kargs) + ActiveRecordReplica.read_from_primary do + reader_connection.public_send(select_method, sql, "Replica: \#{name || 'SQL'}", *args, **kargs) + end + end + def reader_connection Replica.connection end