Debajit
Debajit

Reputation: 47101

Ruby Metaprogramming Q: Calling an external class method on after_save

I have the following classes:

class AwardBase
class AwardOne < AwardBase
class Post < ActiveRecord::Base

The Post is an ActiveRecord, and the Award has a can_award? class method which takes a post object and checks to see if it meets some criteria. If yes, it updates post.owner.awards.

I know I can do this using an Observer pattern (I tested it and the code works fine). However, that requires me to add additional code to the model. I'd like not to touch the model at all if possible. What I'd like to do is run the Award checks like this (the trigger will be invoked at class load time):

class AwardOne < AwardBase
  trigger :post, :after_save

  def self.can_award?(post)
    ...
  end
end

The intention with the above code is that it should automatically add AwardOne.can_award? to Post's after_save method

So essentially what I'm trying to do is to get the trigger call be equivalent to:

class Post < ActiveRecord::Base
  after_save AwardOne.can_award?(self)
  ...
end

which is basically:

class Post < ActiveRecord::Base
  after_save :check_award

  def check_award
    AwardOne.can_award?(self)
  end
end

How can I do this without modifying the Post class?


Here's what I've done (which does not appear to work):

class AwardBase

  def self.trigger (klass, active_record_event)
    model_class = klass.to_class

    this = self
    model_class.instance_eval do
      def award_callback
        this.can_award?(self)
      end
    end

    model_class.class_eval do
      self.send(active_record_event, :award_callback)
    end
  end

  def self.can_award? (model)
    raise NotImplementedError
  end
end

The above code fails with the error:

NameError (undefined local variable or method `award_callback' for #<Post:0x002b57c04d52e0>):

Upvotes: 3

Views: 284

Answers (2)

mrbrdo
mrbrdo

Reputation: 8258

You should think about why you want to do it this way. I would argue it is even worse than using the observer pattern. You are violating the principle of least surprise (also called principle of least astonishment).

Imagine that this is a larger project and I come as a new developer to this project. I am debugging an issue where a Post does not save correctly. Naturally, I will first go through the code of the model. I might even go through the code of the posts controller. Doing that there will be no indication that there is a second class involved in saving the Post. It would be much harder for me to figure out what the issue is since I would have no idea that the code from AwardOne is even involved. In this case it would actually be most preferable to do this in the controller. It is the place that is easiest to debug and understand (since models have enough responsibilities already and are generally larger).

This is a common issue with metaprogramming. Most of the time it is better to avoid it precisely because of principle of least surprise. You will be glad you didn't use it a year from now when you get back to this code because of some issue you need to debug. You will forget what "clever" thing you have done. If you don't have a hell-of-a-good reason then just stick to the established conventions, they are there for a reason.

If nothing else then at least figure out a way to do this elegantly by declaring something in the Post model. For example by registering an awardable class method on ActiveRecord::Base. But the best approach would probably be doing it in the controller or via a service object. It is not the responsibility of AwardOne to handle how Post should be saved!

Upvotes: 1

Siva
Siva

Reputation: 8058

Because you are adding award_callback as class method. I bet it will be registered if you grep class methods.

So change your code like below. It should work fine.

model_class.class_eval do ## Changed to class_eval
  def award_callback
    this.can_award?(self)
  end
end

Let me give a detailed example if it sounds confusing.

class Test
end

Test.instance_eval do
  def class_fun
    p "from class method "
  end
end

Test.class_eval do
  def instance_fun
    p "from instance method "
  end
end


Test.methods.grep /class_fun/
# => [:class_fun]

Test.instance_methods.grep /instance_fun/
# => [:instance_fun]

Test.class_fun
# => "from class method "

Test.new.instance_fun
# => "from instance method "

Upvotes: 0

Related Questions