Only allow module to define method if including class/module does not

302 Views Asked by At

I'm having lots of fun with ActiveModel's serialization, specifically the tangled web of as_json and serializable_hash.

My app has a large collection of models that share behavior by including a module, we'll call it SharedBehavior.

My team has decided we have a default format we want all these classes to follow when being cast to JSON (for rendering in a Rails app), but some of them should behave a little differently. Due to odd behavior in these two methods from the ActiveModel library, adding whitelisted or blacklisted attributes in the models themselves gets overridden by the method definition in this module, and then passed on to the super declarations in ActiveModel.

For this reason, I'd like this module to only apply its definition of these methods to models if they are not explicitly overridden in those models (in essence, take the module out of the ancestor chain for a few method calls), but I still need the shared behavior from this module.

I tried solving this by conditionally, dynamically applying the method on module inclusion in IRB:

class A
  def foo
    puts 'in A'
  end
end

module D
  def self.included(base)
    unless base.instance_methods(false).include?(:foo)
      define_method(:foo) do
        puts 'in D'
        super()
      end
    end
  end
end

class B < A
  include D
end

class C < A
  include D
  def foo
    puts 'in C'
    super
  end
end

With this declaration, I expected the output of C.new.foo to be

in C
in A

but it was instead

in C
in D
in A

My only other thought is to move this logic out into another module and include that module in every class (there are about 54 of them) that does not explicitly override this method, but there are a couple downsides to that:

  1. It introduces a bit of implicit coupling in the project that a new model include this module iff it does not want to override this method implementation
  2. The current implementation of these serialization methods in the module have to do with behavior and attributes established by that module, so I feel like it would be unintuitive to have a second module that knows about and depends on those implementation details of SharedBehavior, though the second module would have almost nothing to do with the first.

Can anyone else think of another solution, or maybe spot an oversight of mine in the code example above that would allow me to make a call in the included hook? (I also tried switching the order in which the C class defined the foo method and included the D module, but saw exactly the same behavior).

1

There are 1 best solutions below

3
On BEST ANSWER

There are two tricky bugs here.

  1. Ruby evaluates classes, so the order of expressions matters. You include D before defining foo in C, so when the included hook is called, foo won't be defined in base. You need to include D at the end of the class.
  2. You're defining foo in D. So after including D in B, D#foo is defined, meaning it's still included in C even if you fix the previous bug. You need base to be the receiver of define_method.

But there's an interesting twist: fixing the second bug makes the first bug irrelevant. By defining foo in base directly, it will be overwritten by any later definitions. It would be like doing

class C < A
  def foo
    puts 'in D'
    super()
  end

  # overwrites previous definition!
  def foo
    puts 'in C'
    super
  end
end

So to summarize, you just need

# in D.included
base.class_eval do
  define_method(:foo) do
    puts 'in D'
    super()
  end
end