ben hoskings

we can do better than meta

I fixed a really tricky bug this afternoon. I took a roundabout path towards the bug’s cause, and used a couple of tools from my ruby toolbox.

So ruby is dynamically typed, for better or worse. I used to think that was a great idea - who cares what something is, as long as you know what it does, right? Ducks and all that?

Not always the case. I’m starting to think ruby is too permissive. Not only can you not be sure what type an object is until runtime, you can’t be sure what the types themselves consist of - and they can change from moment to moment.

So here’s the problem:

Scenario: Uploading after logging in
  Given I am logged in as ""
    undefined method `context=' for #<Ambition::Adapters::ActiveRecord::Select:0x3523494>

The login step triggers a GET via webrat, and the call to ambition is far below that. About 25 method calls below:


But the request works fine when hit from the browser—so maybe something’s undefining #context in the test environment, or similar. In ambition / base.rb:

unless instance.respond_to? :context
  klass.class_eval do
    attr_accessor :context, :negated
    def owner;    @context.owner   end
    def clauses;  @context.clauses end
    def stash;    @context.stash   end
    def negated?; @negated         end

instance.context = context # line 122

Turns out, it’s the reverse. If we already have #context, attr_accessor (which defines it, along with #context=) is never called. But where the hell is #context coming from? This test involves rails, hammock, ambition, rspec, cucumber, webrat, machinist, and faker. Oh dear.

So first, OK. Let’s just have a look at what that object can do. Just above that respond_to? check:

# What methods does this object have, that aren't common to all objects?
p instance.methods.sort - Object.methods

Which gives us

["both", "call", "chained_call", "dbadapter_name", "downcase", "either", "not_equal", "not_regexp", "quote", "quote_column_name", "quote_string", "quote_table_name", "quoted_date", "quoted_false", "quoted_string_prefix", "quoted_true", "sanitize", "statement", "upcase"]

Which doesn’t include #context. But respond_to? :context must have returned true, otherwise all those methods would have been class_evaled. We’ll need a more subtle trick.

instance.method(:method_missing).owner #=> Kernel

Damn, they must have known we’d check that first. If it’s owned by Kernel, then no parent or mixin’s #method_missing can be responding to the #context call. How about..

instance.method(:context).owner #=> Spec::DSL::Main

Aha! instance responds to Spec::DSL::Main#context. It must have been mixed in by rspec. Let’s have a look in its source:

def describe(*args, &block)
  [ ... ]
alias :context :describe

Yep, every class responds to #context (and #describe) when rspec is in the mix. But why doesn’t it appear in the instance.methods list?

I had to think about this for a bit. It’s because every class responds to #context when rspec is in the mix, including Object itself. When we subtracted globally shared methods from the list (- Object.methods), #context was one of them. Sure enough,

instance.methods.include? 'context' #=> true

The fix is easy - the naming collision can be easily avoided in ambition by checking for #context= instead:

unless instance.respond_to? :context=
  [ ... ]

But there’s a good lesson here. Two completely unrelated bits of metaprogramming, each innocuous on their own, colliding and causing mayhem. It might seem like an elegant way to write code, and in some situations it is—but there’s got to be a better way. I’m hoping it’s this.