简体   繁体   中英

'Intercept' ActiveRecord::Relation's ARel without killing a kitten

In the gem I'm making, I want to allow developer to add a class method I've written, let's call it interceptor , to a model, in classic Devise syntax:

class User < ActiveRecord::Base
  has_interceptor
end

This allows you to call User.interceptor , which returns an Interceptor object that does magic things with querying the database through the Squeel gem. All good.

However, I'd like to find a graceful way of allowing the developer to scope the queries the interceptor performs, first. This can be accomplished by allowing interceptor to take in an ActiveRecord::Relation and chain Squeel off of that, and otherwise fall back on the model. This implementation works as follows:

# Builds on blank ARel from User:
User.interceptor.perform_magic
#=> "SELECT `users`.* FROM `users` WHERE interceptor magic"

# Build on scoped ARel from Relation:
User.interceptor( User.where('name LIKE (?)', 'chris') ).perform_magic
#=> "SELECT `users`.* FROM `users`  WHERE `users`.`name` LIKE 'chris' AND  interceptor magic"

Which is effective, but ugly. What I really want is something like:

# Build on scoped ARel:
User.where('name LIKE (?)', 'chris').interceptor.perform_magic
#=> "SELECT `users`.* FROM `users`  WHERE `users`.`name` LIKE 'chris' AND  interceptor magic"

Essentially, I'd like to 'tap in' to the ActiveRecord::Relation chain and steal it's ARel, passing it into my Interceptor object to modify it before I evaluate it. But every way I can think of to do this involves code so horrifying, I know God would kill a kitten if I implemented it. I don't need that blood on my hands. Help me save a kitten?

ISSUES:

Adding to my complications,

class User < ActiveRecord::Base
  has_interceptor :other_interceptor_name
end

allows you to call User.other_interceptor_name , and models can have multiple interceptors. It works well, but makes using method_missing an even worse idea than normal.

I ended up hacking ActiveRecord::Relation 's method_missing after all, it didn't turn out too ugly. Here's the full process, from beginning to end.

My gem defines an Interceptor class, intended to be a DSL that developers may subclass. This object takes in some root ARel, from a Model or a Relation , and manipulates the query further before rendering.

# gem/app/interceptors/interceptor.rb
class Interceptor
  attr_accessor :name, :root, :model
  def initialize(name, root)
    self.name = name
    self.root = root
    self.model = root.respond_to?(:klass) ? root.klass : root
  end
  def render
    self.root.apply_dsl_methods.all.to_json
  end
  ...DSL methods...
end

Implemented:

# sample/app/interceptors/user_interceptor.rb
class UserInterceptor < Interceptor
  ...DSL...
end

Then I give models the has_interceptor method that defines new interceptors and builds an interceptors mapping:

# gem/lib/interceptors/model_additions.rb
module Interceptor::ModelAdditions

  def has_interceptor(name=:interceptor, klass=Interceptor)
    cattr_accessor :interceptors unless self.respond_to? :interceptors
    self.interceptors ||= {}
    if self.has_interceptor? name
      raise Interceptor::NameError,
        "#{self.name} already has a interceptor with the name '#{name}'. "\
        "Please supply a parameter to has_interceptor other than:"\
        "#{self.interceptors.join(', ')}"
    else
      self.interceptors[name] = klass
      cattr_accessor name
      # Creates new Interceptor that builds off the Model
      self.send("#{name}=", klass.new(name, self))
    end
  end

  def has_interceptor?(name=:interceptor)
    self.respond_to? :interceptors and self.interceptors.keys.include? name.to_s
  end

end

ActiveRecord::Base.extend Interceptor::ModelAdditions

Implemented:

# sample/app/models/user.rb
class User < ActiveRecord::Base
  # User.interceptor, uses default Interceptor Class
  has_interceptor
  # User.custom_interceptor, uses custom CustomInterceptor Class
  has_interceptor :custom_interceptor, CustomInterceptor

  # User.interceptors #show interceptor mappings
  #=> {
  #     interceptor: #<Class:Interceptor>,
  #     custom_interceptor: #<Class:CustomInterceptor>
  #   }
  # User.custom_interceptor #gets an instance
  #=> #<CustomInterceptor:0x005h3h234h33>
end

With that alone, you can call User.interceptor and build an Interceptor with a clean query as the root for all interceptor query manipulation. However, with a little more effort, we can extend ActiveRecord::Relation so that you can call interceptor methods as an endpoint in a chain of scopes:

# gem/lib/interceptor/relation_additions.rb
module Interceptor::RelationAdditions

  delegate :has_interceptor?, to: :klass

  def respond_to?(method, include_private = false)
    self.has_interceptor? method
  end

protected

  def method_missing(method, *args, &block)
    if self.has_interceptor? method
      # Creates new Interceptor that builds off of a Relation
      self.klass.interceptors[method.to_s].new(method.to_s, self)
    else
      super
    end
  end

end

ActiveRecord::Relation.send :include, Interceptor::RelationAdditions

Now, User.where('created_at > (?)', Time.current - 2.weeks).custom_interceptor will apply all the scoping set up in the Interceptor DSL on top of whatever query you build on the model.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM