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!