Can I write an activerecord scope which wraps the result in a block at query time?

Viewed 282

Our rails app has a primary and a replica DB. We already have a method defined which takes a block, and while inside the block, queries will be made against the replica, like so:

on_replica do
  @my_things = MyModel.where(my_attr: "my value").to_a
end

This works well in most cases, but if you don't actually perform the query in the block, it doesn't work:

on_replica do
  @my_things = MyModel.where(my_attr: "my value")
end

# this will run its query against the primary DB
@my_things.to_a 

I'd really like to be able to define a scope (or scope-like thing) so that any query happening past that point in the method chain happens against the replica:

@my_things = MyModel.where(my_attr: "my value").eventually_on_replica

# this runs its query against the replica DB:
@my_things.to_a

Is it possible to define an eventually_on_replica method that would work this way, using the existing on_replica method? Bonus points if MyModel.eventually_on_replica.where(my_attr: "my value") works too.

2 Answers

my idea is to wrap your on_replica into a relation then we can append it at the end of query chains.

i don't know whether your on_replica will work on this proposal or not, i just demo with ActiveRecord::Base.connected_to

# lib/active_record/base.rb
module ActiveRecord
  module Querying
    delegate :eventually_on_replica, to: :all
  end

  class Relation
    def eventually_on_replica(database: nil, role: nil, shard: nil, prevent_writes: false)
      ActiveRecord::Base.connected_to(
        database: database,
        role: role,
        shard: shard,
        prevent_writes: prevent_writes
      ) do
        self.load
      end
    end
  end
end

Demo

# config/application.rb
config.active_record.reading_role = :dev
config.active_record.reading_role = :test

# models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  connects_to database: { dev: :development, test: :test }
end

# database development
result = Requirement.where(id: [1,2,3]).eventually_on_replica(role: :dev)
result.to_a # have data

# database test
result = Requirement.where(id: [1,2,3]).eventually_on_replica(role: :test)
result.to_a # [] no data

So you can try replace ActiveRecord::Base.connected_to by your on_relica.

In case of Model.eventually_on_replica.where, i need to set a flag to mark that is running on replica then try to cache all query chains in order to execute later (on connected_to block) and it turns out a very complex case and maybe effect other queries in normal case so i don't go further.

For the eventually_on_replica method at the end of the chain, I have a simple method.

In your application_record.rb file (if you have it, otherwise you may need to patch ActiveRecord::Base

# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base

  def self.eventually_on_replica
    on_replica do
      self.all
    end
  end

end

However, as soon as you call eventually_on_replica, it will run the DB query and load the results. It doesn't support lazy loading but after all, you only need to call eventually_on_replica when you really need the results so maybe this will work for your use case.

Related