Last Updated: September 29, 2021
·
886
· bwittenbrook3

Dynamically proxy rails model scopes to trailblazer operations

At JMI Labs, we recently had a ActiveRecord model with over 30 scopes used to dynamically filter our model. Since we were already heavily invested in Trailblazer, we decided to find a way to dynamically proxy scopes to trailblazer operations in order to clean up our model and gain some of the niceties offered by Trailblazer operations (contracts, policies, etc.).


First we developed a scope that would act as the proxy method to call individual filter scopes (such as filter_by_organism).

# Use meta programming to build a filter_by_* scope automatically.
# If a Isolate::FilterScope operation exists that matches the filter key,
# pass in the arguments for the filter_by_ method, and return the model
# of the operation that contains the ActiveRecord::Relation class.
scope :filter_by_, proc{|filter_key, *arguments|
  begin
    operation = "Isolate::FilterScope::#{filter_key.camelize}".constantize
    next operation.(
      current_scope: all,
      arguments: arguments
    ).model
  rescue NameError => e
      if e.message == "uninitialized constant Isolate::FilterScope::#{filter_key.camelize}"
        raise "Don't have Isolate::FilterScope::#{filter_key.camelize} operation defined."
      else
        raise e
      end
  end
}

Next we had to tell our model to proxy all filter scopes to the delegator scope above. This was done with the following code.

# Leverage the method missing function to automatically call the filter_by_
# scope to build the scope methods on the fly.
def self.method_missing(method_sym, *arguments, &block)
  # the first argument is a Symbol, so you need to_s it if you want to pattern match
  if method_sym.to_s =~ /^filter_by_(.*)$/
    send(:filter_by_, $1, *arguments)
  else
    super
  end
end

Lastly we had to tell rails that our model, Isolate, could respond to the filter_by_* scopes so they would get proxied correctly.

# We have to say that Isolates can respond to the dynamic :filter_by scope
def self.respond_to?(method_name, include_private = false)
  method_name.to_s.start_with?('filter_by_') || super
end

Now we can drop in operations into the filter_scope module as needed...

# app/concepts/isolate/filter_scope/operations/age_range.rb
class Isolate < ActiveRecord::Base
  module FilterScope

    # Filter isolates based on age range
    #   => Isolate.filter_by_age_range({include: {min: 5, max: 65}})
    #   => Isolate.filter_by_age_range({exclude: {min: 5, max: 65}})
    #
    class AgeRange < Trailblazer::Operation

      ...
    end 
  end 
end

We found this architecture useful for models with many scope methods to help dry up our code. Feel free to leave your thoughts and suggestions on ways to improve this acritecutre!