The magic of metaprogramming : Examining the has_many association using Active Record's code base - Part 2
Grant Cloyd
Posted on July 18, 2021
This post picks up exactly where we left our intrepid has_many macro for our Teacher class and their books:
class Teacher < ApplicationRecord
has_many :books
end
class Book < ApplicationRecord
belongs_to :teacher
end
Please be sure to refer to part one of this post before diving in any further!
So, when last we left off, we had just gotten back our reflection, and now we are ready to move on to that next line and see our accessors and continue to see the code beneath the Rails magic. We'll take a look at what mixins are in Ruby over the course of this post and how they relate to the accessors in particular.
Line 2 - That's a #{reflection.name} of a different #{mixin.value}!
On to the next line to see what needs to happen.
define_accessors model, reflection
Okay - I can read what will happen next - even though I don't necessarily know exactly how it will happen. Thanks Ruby and ActiveRecord team for making where we're going fairly clear! We're going to take that reflection we just got back, and we're going to pass it and our model (Teacher) to a #define_accessors
method. It seems pretty likely we're going to get to see how our #books
and our #books=
are finally created here.
If we move further down the page we can find the #define_accessors class method and start to unravel it and the subsequent methods that will get us back what we need.
def self.define_accessors(model, reflection)
mixin = model.generated_association_methods
name = reflection.name
define_readers(mixin, name)
define_writers(mixin, name)
end
Great! We're at the place where they're about to be defined. But before we get there - we've got a mixin variable. So, what's that about? For a quick answer, we can look to this short Geeks for Geeks post which explains:
"When a class can inherit features from more than one parent class, the class is supposed to have multiple inheritance. But Ruby does not support multiple inheritance directly and instead uses a facility called mixin. Mixins in Ruby allow modules to access instance methods of another one using the include method. Mixins provide a controlled way of adding functionality to classes."
This will hopefully make a little more sense as we move into the method itself. The file points to a more abstract method than most of what we've come across so far.
def generated_association_methods # :nodoc:
@generated_association_methods ||= begin
mod = const_set(:GeneratedAssociationMethods, Module.new)
private_constant :GeneratedAssociationMethods
include mod
mod
end
end
We can see that there is an instance method that either already exists or which will point to a place in memory where a block is passed that will create the instance variable mod (the new Module class that is created). From the ruby docs, const_set, "Sets the named constant to the given object, returning that object," and then, "[c]reates a new constant if no constant with the given name previously existed." This seems in line with the usage of the ||=
portion which points to either something that exists or something that needs to be set to point to the following line of code. I've spent sometime looking at this method and I still find it a little strange. My assumption with this overall method is that it is a method that is used in multiple places in the code base, and in order to make it more flexible and dynamic it can either create a new version of generated methods or leave it alone if it already exists. Ensuring that is the intended logic would likely take a much deeper dive into the code base. However, as we spent a little time broadly exploring the HasManyAssocation class when it would have been passed in earlier, it seems likely that our necessary methods are already set rather than needing to be created again and simply need to be attached to our class.
If we look back at the first line to what the generated_assocation_methods
is being called on (our Teacher model), we can see that whether we needed to generate more methods or if our methods already existed, the new module is being added to the model class as an instance variable called @generated_assocation_methods
. This seems to line up quite nicely with our definition of a mixin in which multiple class inheritances are able to be combined to create what will ultimately allow our Teacher class to make use of our specific instance of those private HasManyAssocation instance methods. It seems like this is starting to come together
Once the mod is done, it is returned and we can move on to the last few lines in our #define_accessors method.
name = reflection.name
define_readers(mixin, name)
define_writers(mixin, name)
Our reflection name is accessed from the the reflection variable (our books) and then we have two more methods define_readers
and define_writers
that we will need to - once again - scroll down the file to locate.
def self.define_readers(mixin, name)
mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
def #{name}
association(:#{name}).reader
end
CODE
end
def self.define_writers(mixin, name)
mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
def #{name}=(value)
association(:#{name}).writer(value)
end
CODE
end
The class_eval
method is part of the Ruby language itself, Ruby Docs, and is a method that, "Evaluates the string or block in the context of mod, except that when a block is given, constant/class variable lookup is not affected. This can be used to add methods to a class." Which is exactly what it is doing in our case, the heredoc will ultimately resolve as a string and opens with <<-CODE
and closes with CODE
. From googling, the __FILE__
portion seems to be a way in Ruby to reference the current file that the code is running in, and unsurprisingly, the __LINE__
refers similarly to the current line. My assumption here in terms of the problem it might be solving is that when the methods are generated, they are being created in a way that if the program does not move to the next available line before generating the code, each time a method is created (after the first method) it would cause the program to run into an error as an end
would proceed a def
on the same line. These two methods are where our getter and setter methods are actually defined. However, the way they're written is so dynamic that (assuming we've missed all those possible errors along the way) we can pass so many different names and associations in to Rails and always get our specific named variation back. The magic is advanced and clever usage of interpolation to create whatever is needed! These particular methods should help give us the #books
and `#books=, but this style of generating method writers appears elsewhere in the ActiveRecord code base.
As an example - take a look at these methods from our CollectionAssocation class, the parent of our HasManyAssocation, which would have been generated previously during the creation of our reflection:
`
mixin.class_eval <<-CODE, FILE, LINE + 1
def #{name.to_s.singularize}_ids
association(:#{name}).ids_reader
end
CODE
end
def self.define_writers(mixin, name)
super
mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
def #{name.to_s.singularize}_ids=(ids)
association(:#{name}).ids_writer(ids)
end
CODE
end
`
Looks pretty similar right? Here - it's making sure though that instead of passing 'books' as the variable to a string it will pass the id of a 'book'. This follows a somewhat similar pattern that we saw before when we came across the #camelize
method elsewhere. These are the kinds of methods and writing that generate the convention that makes using Rails relatively easy.
Ultimately, because we're making a has_many
association, we'll get the following commands back from similar reader/writer pairs throughout the process of generating this specific macro:
Teacher#books.empty?, Teacher#books.size, Teacher#books, Project#books<<(book), Teacher#books.delete(book), Teacher#books.destroy(book), Teacher#books.find(book_id), Teacher#books.build, Teacher#books.create
So we can check to see if if this teacher has any books (which would be stored in an array and the method would return a boolean), how many books (if any) are stored in that array, return an array that contains all of the books objects, shovel in another book into our books array, delete a specific book by passing in the specific book instance we want to get rid of, run the same operation except destroy it (which would allow callbacks to run, if they have been set up, that could, for example, destroy other associations that might reference the deleted object). Nice so we must be done now right?
No! Not even close! There are still two more methods to go before our reflection is passed back to us which will further tether our reflection (our individualized BooksHasManyAssocation) to our model (Teacher).
define_callbacks model, reflection
define_validations model, reflection
reflection
end
In our case though, we did not define any callbacks, such as this very readable method from the Active Record docs
`
before_validation :ensure_login_has_a_value
private
def ensure_login_has_a_value
if login.nil?
self.login = email unless email.blank?
end
end
`
This callback method will check if a login variable (essentially a username) which is possibly being passed through an HTTP request exists, and if that variable does not exist upon arrival, the object that has been passed will have the login set to the email argument that would have been passed - unless that too was passed as a nil using the #blank? method to check. That callback is then set-up using the before_validation macro to instruct the program exactly when during the creation process, or lifecycle, of this method to utilize the provided method.
We also did not define any validations on our class - such as:
validates :books, presence: true
Which ... in fairness would be a rather strange thing to define given our current example. But those methods will still need to be traversed before the Assocation can make it's way back to us. However, we've come to the end of our two lines and will leave those methods for another day and a part three if these posts receive enough attention.
On a personal note - it's humbling and wonderful to be able to learn from programmers who are far more experienced than I am just by just being able to look at their code in GitHub - especially if it is also well documented like Rails and the Active Record team have done in this case. The ability to move back and forth between those pieces of information are truly invaluable to increasing my understanding of both the language, the framework, and coding in general and I'd recommend others reading this post to not be afraid to do the same.
And again, as mentioned in my previous post, if there are more experienced programmers who can see errors in my explanation and who have somehow made it this far without clawing your eyes out and banging your head against a wall - please chime in and explain any and all mistakes!
While I can't pretend to have fully pulled back the curtain of ActiveRecord or even all of has_many
with these posts - and assuredly some explanations are inadequate - I hope that the process of working with Rails continues to look less like magic and more like:
Posted on July 18, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.