How do I use Ruby metaprogramming to add callbacks to a Rails model?
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.
Ruby on Rails - Callbacks
While the possible duplicate is correct, in order to alter a model in rails without editing that models code to explicitly include the listened, you to use the following:
Host::Managed.send(:include, ::HostExtensions::ManagedHost)
Which is telling the Host::Managed class that it should include the HostExtensions::ManagedHost module at runtime.
And for completeness sake, if you're doing this in a Rails App you may need to add something like this to your Engine class (engine.rb):
config.to_prepare do
require File.expand_path('../../../app/models/concerns/host_extensions/managedhost', __FILE__)
Host::Managed.send(:include, ::HostExtensions::ManagedHost)
end
Ruby / Rails metaprogramming: generating helper methods on the fly
The problem you're trying to solve (keeping your views from hitting model methods) isn't solved by delegating the same logic to a view helper. You should be doing this in your controllers if you want to stick to the MVC convention of keeping your views from triggering SQL queries.
def index
models = Foo, Bar, Bat
@counts = models.inject({}) do |result, model|
result[model.name.downcase.to_sym] = model.count
result
end
end
You then have a nice hash of the counts of each of the models passed:
@counts #=> { :foo => 3, :bar => 59, :bat => 42 }
How to dynamically add class methods to Rails Models using module
ActiveSupport provides a pretty idiomatic and cool way of doing that, ActiveSupport::Concern
:
module Whatever
extend ActiveSupport::Concern
module ClassMethods
def say_hello_to(to)
puts "Hello #{to}"
end
end
end
class YourModel
include Whatever
say_hello_to "someone"
end
See the API doc. Although it is not directly related to your question, the included
method is incredibly useful for models or controllers (scopes, helper methods, filters, etc), plus ActiveSupport::Concern
handles dependencies between modules for free (both as in freedom and as in beer).
Add callback method to observer dynamically
In short, this behavior is due to the fact that Rails hooks up UserObserver callbacks to User events at the initialization time. If the after_create
callback is not defined for UserObserver at that time, it will not be called, even if later added.
If you are interested in more details on how that observer initialization and hook-up to the wobserved class works, at the end I posted a brief walk-through through the Observer implementation. But before we get to that, here is a way to make your tests work. Now, I'm not sure if you want to use that, and not sure why you decided to test the observer behavior in the first place in your application, but for the sake of completeness...
After you do define_method(:after_create)
for observer in your matcher insert the explicit call to define_callbacks
(a protected method; see walkthrough through the Observer implementatiin below on what it does) on observer instance. Here is the code:
observer.class_eval do
define_method(:after_create) { |user| }
end
observer.instance.instance_eval do # this is the added code
define_callbacks(obj.class) # - || -
end # - || -
A brief walk-through through the Observer implementation.
Note: I'm using the "rails-observers" gem sources (in Rails 4 observers were moved to an optional gem, which by default is not installed). In your case, if you are on Rails 3.x, the details of implementation may be different, but I believe the idea will be the same.
First, this is where the observers' instantiation is launched: https://github.com/rails/rails-observers/blob/master/lib/rails/observers/railtie.rb#L24. Basically, call ActiveRecord::Base.instantiate_observers
in ActiveSupport.on_load(:active_record)
, i.e. when the ActiveRecord library is loaded.
In the same file you can see how it takes the config.active_record.observers
parameter normally provided in the config/application.rb
and passes it to the observers=
defined here: https://github.com/rails/rails-observers/blob/master/lib/rails/observers/active_model/observing.rb#L38
But back to ActiveRecord::Base.instantiate_observers
. It just cycles through all defined observers and calls instantiate_observer
for each of them. Here is where the instantiate_observer
is implemented: https://github.com/rails/rails-observers/blob/master/lib/rails/observers/active_model/observing.rb#L180. Basically, it makes a call to Observer.instance
(as a Singleton, an observer has a single instance), which will initialize that instance if that was not done yet.
This is how Observer initialization looks like: https://github.com/rails/rails-observers/blob/master/lib/rails/observers/active_model/observing.rb#L340. I.e. a call to add_observer!
.
You can see add_observer!
, together with and define_callbacks
that it calls, here: https://github.com/rails/rails-observers/blob/master/lib/rails/observers/activerecord/observer.rb#L95.
This define_callbacks
method goes through all the callbacks defined in your observer class (UserObserver) at that time and creates "_notify_#{observer_name}_for_#{callback}"
methods for the observed class (User), and register them to be called on that event in the observed class (User, again).
In your case, it should have been _notify_user_observer_for_after_create
method added as after_create
callback to User. Inside, that _notify_user_observer_for_after_create
would call update
on the UserObserver class, which in turn would call after_create
on UserObserver, and all would work from there.
But, in your case after_create
doesn't exist in UserObserver
during Rails initialization, so no method is created and registered for User.after_create
callback. Thus, no luck after that with catching it in your tests. That little mystery is solved.
Custom Hook/Callback/Macro Methods
Here's a solution that uses prepend
. When you call before_operations
for the first time it creates a new (empty) module and prepends it to your class. This means that when you call method foo
on your class, it will look first for that method in the module.
The before_operations
method then defines simple methods in this module that first invoke your 'before' method, and then use super
to invoke the real implementation in your class.
class ActiveClass
def self.before_operations(before_method,*methods)
prepend( @active_wrapper=Module.new ) unless @active_wrapper
methods.each do |method_name|
@active_wrapper.send(:define_method,method_name) do |*args,&block|
send before_method
super(*args,&block)
end
end
end
end
class SubClass < ActiveClass
before_operations :first_validate_something, :do_this_method, :do_that_method
def do_this_method(*args,&block)
p doing:'this', with:args, and:block
end
def do_that_method; end
private
def first_validate_something
p :validating
end
end
SubClass.new.do_this_method(3,4){ |x| p x }
#=> :validating
#=> {:doing=>"this", :with=>[3, 4], :and=>#<Proc:0x007fdb1301fa18@/tmp.rb:31>}
If you want to make the idea by @SteveTurczyn work you must:
- receive the args params in the block of
define_method
, not as arguments to it. - call
before_operations
AFTER your methods have been defined if you want to be able to alias them.
class ActiveClass
def self.before_operations(before_method, *methods)
methods.each do |meth|
raise "No method `#{meth}` defined in #{self}" unless method_defined?(meth)
orig_method = "_original_#{meth}"
alias_method orig_method, meth
define_method(meth) do |*args,&block|
send before_method
send orig_method, *args, &block
end
end
end
end
class SubClass < ActiveClass
def do_this_method(*args,&block)
p doing:'this', with:args, and:block
end
def do_that_method; end
before_operations :first_validate_something, :do_this_method, :do_that_method
private
def first_validate_something
p :validating
end
end
SubClass.new.do_this_method(3,4){ |x| p x }
#=> :validating
#=> {:doing=>"this", :with=>[3, 4], :and=>#<Proc:0x007fdb1301fa18@/tmp.rb:31>}
before_create outside of model
Rails allows to extend the class you're including a module in. Basic techniques are described in http://api.rubyonrails.org/classes/ActiveSupport/Concern.html#method-i-included
This allows to "set up" a module, like
module Foo
extend ActiveSupport::Concern
included do
# Within this area "self" now refers to the class in which the module is included
# Every method you now call is called agains the class
# As such you can now write things like
validates_inclusion_of ...
define_method ...
end
end
Related Topics
Rails 3: Call Functions Inside Controllers
Do Ruby 1.8 and 1.9 Have the Same Hash Code for a String
Ruby: Write Escaped String to Yaml
Star Rating in Ajax with Ruby on Rails
How to Convert String to Bytes in Ruby
Rails Console Not Working on Server
Ruby Undefined Method 'Bytesize' for #<Hash:0X2954Fe8>
Generating a Short Uuid String Using Uuidtools in Rails
Certificate Verify Failed in "Gem Install Foundation"
Can a Ruby Script Tell What Directory It's In
How to Add Confirm Message with Link_To Ruby on Rails
Why Do I Get an "Undefined Method for 'Has_Attached_File' When Installing Paperclip
I'm Getting "Found Character That Cannot Start Any Token While Scanning for the Next Token"
Ruby - Activerecord::Connectionnotestablished
How to Update a Single Attribute Without Touching the Updated_At Attribute