Dino
Posted on March 13, 2023
Let's imagine we are building DSL similar to ActiveRecord associations.
class Person
associated_with :account
end
Person.new.account # => "Account associated with a Person"
In order to build this feature, we will create a new module that dynamically defines association methods.
module Associations
def associated_with(name)
define_method(name) do
puts "associated #{name}"
end
end
end
class Person
extend Associations
associated_with :account
end
Person.new.account #=> "associated account"
define_method
creates an instance method on a receiver, which is exactly what we need.
define_method
basically has done this;
class Person
...
def account
"associated account"
end
end
We can easily validate this theory by inspecting the ancestors chain and instance methods:
Person.ancestors # => Person,Object,Kernel,BasicObject
Person.instance_methods(false) # => [account]
Overwriting dynamically defined method
If we wish to overwrite a dynamically defined method we can do it without any problems since this is just a "regular" instance method (albeit defined with some metaprogramming)
class Person
extend Associations
associated_with :account
def account
"overridden"
end
end
Person.new.account #=> "overridden"
But, calling a super when overriding will fail
def account
super
"overridden"
end
Person.new.account # => `account': super: no superclass method `account' for ...
This makes sense since we are calling super
on the method we've completely overwritten.
In order for super
to work the method need to be defined in Persons
ancestors chain.
We can do this by generating a new module on the fly, including that module in the class and define dynamic methods on that module instead of the class itself.
module Associations
# Create new module on the fly.
# Include that module in the ancestor chain
def generated_association_methods
@generated_association_methods ||= begin
mod = const_set(:GeneratedAssociationMethods, Module.new)
include mod
mod
end
end
def associated_with(name)
mixin = generated_association_methods
# define methods on the newly created module
mixin.define_method(name) do
puts "associated #{name}"
end
end
end
class Person
extend Associations
associated_with :account
end
Now dynamically defined methods live inside the Person::GeneratedAssociationMethods
, which is part of ancestors chain.
Person.ancestors # => Person,**Person::GeneratedAssociationMethods**, Object,Kernel,BasicObject
Person.instance_methods(false) # => []
So calling super will work fine:
def account
super
"overridden"
end
Person.new.account
#=> "associated_account"
#=> "overridden"
I've seen this pattern used in Rails codebase in multiple places where this kind of behaviour is needed.
✌️
Posted on March 13, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.