Exploring the Mechanics of Rails Nested Attributes
Unravel the mysteries of Rails nested attributes by diving into the depths of the Rails codebase. In this exploration, we'll dissect the inner workings of accepts_nested_attributes_for and uncover the magic that enables you to effortlessly manage associated records. By the end of this journey, you'll have a clearer understanding of how nested attributes work behind the scenes in Rails.
As I've progressed in my career as a software developer, I've found it extremely helpful to read not only documentation but also to read code. Early on in my career, this was a little bit intimidating, but it's a practice I highly recommend. Rails is a great codebase to read, especially if you're familiar with using Rails because so much of what we might view as magic is Ruby we can understand and read behind the scenes.In a previous post we took a deep dive into how to use accepts_nested_attributes
and has_many
through to create complex nested forms. While writing that post, I took a dive into the Rails codebase to understand how accepts_nested_attributes
actually works and found it interesting and wanted to share if this was something you may be curious about well.When I'm looking for a method in a codebase, I search the method name in the search bar on the repo. Typing in accepts_nested_attributes_for takes me here .The method accepts_nested_attributes_for
looks like this:
ruby1def accepts_nested_attributes_for(*attr_names) 2 options = { allow_destroy: false, update_only: false } 3 options.update(attr_names.extract_options!) 4 options.assert_valid_keys(:allow_destroy, :reject_if, :limit, :update_only) 5 options[:reject_if] = REJECT_ALL_BLANK_PROC if options[:reject_if] == :all_blank 6 7 attr_names.each do |association_name| 8 if reflection = _reflect_on_association(association_name) 9 reflection.autosave = true 10 define_autosave_validation_callbacks(reflection) 11 12 nested_attributes_options = self.nested_attributes_options.dup 13 nested_attributes_options[association_name.to_sym] = options 14 self.nested_attributes_options = nested_attributes_options 15 16 type = (reflection.collection? ? :collection : :one_to_one) 17 generate_association_writer(association_name, type) 18 else 19 raise ArgumentError, "No association found for name `#{association_name}'. Has it been defined yet?" 20 end 21 end 22end
The interesting that we will be looking at is this line.
ruby1generate_association_writer(association_name, type)
This method is the crucial thing that feels like "magic" that rails is doing when you use accepts_nested_attributes_for.
It defines an attributes writer for the specified attributes.
ruby1def generate_association_writer(association_name, type) 2 generated_association_methods.module_eval <<-eoruby, __FILE__, __LINE__ + 1 3 silence_redefinition_of_method :#{association_name}_attributes= 4 def #{association_name}_attributes=(attributes) 5 assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes) 6 end 7 eoruby 8end
Let's go through this line by line.
ruby1generated_association_methods.module_eval <<-eoruby, __FILE__, __LINE__ + 1
generated_association_methods
is defined here .
ruby1def generated_association_methods 2 @generated_association_methods ||= begin 3 mod = const_set(:GeneratedAssociationMethods, Module.new) 4 private_constant :GeneratedAssociationMethods 5 include mod 6 7 mod 8 end 9end
const_set
is a Ruby method that sets the named constant to the given object and returns it. This method is inside the ClassMethods
module, creating a module called GeneratedAssociationMethods
in the model's namespace that the accepts_nested_attributes_for
is defined. The pantry application in the previous blog post linked above creates this on-demand where we called accepts_nested_attributes_for.
(On-demand meaning, when we first invoke those methods, on the /recipes
and recipes/:id
pages.)
ruby1Recipe::GeneratedAssociationMethods 2Ingredient::GeneratedAssociationMethods 3RecipeIngredient::GeneratedAssociationMethods
Side note : Something I like to do to get a handle on what's going on is to clone the codebase to my local machine and then point my gem in the Gemfile
to the path where it lives. So this is in my Pantry Gemfile
.
ruby1gem 'rails', path: '../rails'
Now in the local version of rails, I can add puts statements to inspect what's going on.
ruby1def generated_association_methods # :nodoc: 2 @generated_association_methods ||= begin 3 mod = const_set(:GeneratedAssociationMethods, Module.new) 4 private_constant :GeneratedAssociationMethods 5 include mod 6 puts mod.inspect 7 mod 8 end 9end
That allowed me to see what mod
is.Now, back to the method at hand.
ruby1def generate_association_writer(association_name, type) 2 generated_association_methods.module_eval <<-eoruby, __FILE__, __LINE__ + 1 3 silence_redefinition_of_method :#{association_name}_attributes= 4 def #{association_name}_attributes=(attributes) 5 assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes) 6 end 7 eoruby 8end
So we're creating a module at Recipe::GeneratedAssociationMethods,
we're silencing the redefinition of the method ( see what that means here ). Next, we are creating a method inside of the newly created module and setting it dynamically to #{association_name}_attributes=(attributes).
The association name for our Recipe
example is recipe_ingredients.
Now we have a method called def recipe_ingredients_attributes=(attributes)
inside of our newly created module.Now that recipe_ingredients_attributes
might look familiar! Good catch if you thought that. We use this of our strong parameters.
ruby1def recipe_params 2 params.require(:recipe).permit(:title, recipe_ingredients_attributes: [:amount, :description, :_destroy, :id, ingredient_attributes: [:name, :id]]) 3end
Now let's inspect what the type
is. For our recipe, it's collection.
This means that we need to find the method called assign_nested_attributes_for_collection_association,
that lives here . The documentation for that method is well defined, we won't go into how this method works precisely. It assigns the attributes that get passed into it (in our example above, the hash with {amount:, description:, _destroy:, id:, ingredient_attributes: {name:, id:}}
) to the collection association. It either updates the record if an id is given, creates a new one without an id, or destroys the record if _destroy
is a truthy value.
It should be apparent why in our nested params, we need to have our nested attributes named a certain way because they are referring to method names that get dynamically created.
Hey hey! 👋
I'm Meagan, lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et