Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do I use Ruby metaprogramming to add callbacks to a Rails model?

I wrote a simple Cacheable module that makes it simple to cache aggregate fields in a parent model. The module requires that the parent object implement the cacheable method and a calc_ method for each field that requires caching at the parent level.

module Cacheable
  def cache!(fields, *objects)
    objects.each do |object|
      if object.cacheable?
        calc(fields, objects)
        save!(objects)
      end
    end
  end

  def calc(fields, objects)
    fields.each { |field| objects.each(&:"calc_#{field}") }
  end

  def save!(objects)
    objects.each(&:save!)
  end
end

I would like to add callbacks to the ActiveRecord model into which this module is included. This method would require that the model implement a hash of parent models and field names that require caching.

def cachebacks(klass, parents)
  [:after_save, :after_destroy].each do |callback|
    self.send(callback, proc { cache!(CACHEABLE[klass], self.send(parents)) })
  end
end

This approach works great if I manually add both callbacks using such as:

after_save proc { cache!(CACHEABLE[Quote], *quotes.all) }
after_destroy proc { cache!(CACHEABLE[Quote], *quotes.all) }

But, I'm receiving the following error when I try to use the cachebacks method to add these to callbacks.

cachebacks(Quote, "*quotes.all")

NoMethodError: undefined method `cachebacks' for #<Class:0x007fe7be3f2ae8>

How do I add these callbacks to the class dynamically?

like image 850
barelyknown Avatar asked Aug 23 '12 03:08

barelyknown


2 Answers

This looks like a good case for ActiveSupport::Concern. You can tweak your cachebacks method slightly to add it as a class method on the including class:

module Cacheable
  extend ActiveSupport::Concern

  module ClassMethods
    def cachebacks(&block)
      klass = self
      [:after_save, :after_destroy].each do |callback|
        self.send(callback, proc { cache!(CACHEABLE[klass], *klass.instance_eval(&block)) })
      end
    end
  end

  def cache!(fields, *objects)
    # ...
  end

  # ...
end

To use it:

class Example < ActiveRecord::Base
  include Cacheable
  cachebacks { all }
end

The block you pass to cachebacks will be executed in the context of the class that's calling it. In this example, { all } is equivalent to calling Example.all and passing the results into your cache! method.


To answer your question in the comments, Concern encapsulates a common pattern and establishes a convention in Rails. The syntax is slightly more elegant:

included do
  # behaviors
end

# instead of

def self.included(base)
  base.class_eval do
    # behaviors
  end
end

It also takes advantage of another convention to automatically and correctly include class and instance methods. If you namespace those methods in modules named ClassMethods and InstanceMethods (although as you've seen, InstanceMethods is optional), then you're done.

Last of all, it handles module dependencies. The documentation gives a good example of this, but in essence, it prevents the including class from having to explicitly include dependent modules in addition to the module it's actually interested in.

like image 139
Brandan Avatar answered Nov 10 '22 16:11

Brandan


Thanks to Brandon for the answer that helped me write the solution.

Add the following to your model. You can cacheback multiple parent relationships per model. You can also specify different attribute names for the parent and child tables by passing in a hash instead of a string for a particular field.

include Cacheable
cacheback(parent: :quotes, fields: %w(weight pallet_spots value equipment_type_id))

This module extends ActiveSupport::Concern and adds the callbacks and performs the cacheing. Your parent classes will need to implement calc_field methods to do the caching work.

module Cacheable
  extend ActiveSupport::Concern

  module ClassMethods
    def cacheback(options)
      fields = Cacheable.normalize_fields(options[:fields])
      [:after_save, :after_destroy].each do |callback|
        self.send(callback, proc { cache!(fields, self.send(options[:parent])) })
      end
    end
  end

  def cache!(fields, objects)
    objects = objects.respond_to?(:to_a) ? objects.to_a : [objects]
    objects.each do |object|
      if object.cacheable?
        calc(fields, objects)
        save!(objects)
      end
    end
  end

  def calc(fields, objects)
    fields.each do |parent_field, child_field|
      objects.each(&:"calc_#{parent_field}") if self.send("#{child_field}_changed?".to_sym)
    end
  end

  def save!(objects)
    objects.each { |object| object.save! if object.changed? }
  end

  def self.normalize_fields(fields)
    Hash[fields.collect { |f| f.is_a?(Hash) ? f.to_a : [f, f] }]
  end

end
like image 41
barelyknown Avatar answered Nov 10 '22 17:11

barelyknown