Reading Rails: Understanding how accepts_nested_attributes_for works
Tags: ruby, rails
Published: 2021-02-27 03:09:39

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](/use-a-nested-dynamic-form-with-a-hasmany-through-association-in-rails) 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](https://github.com/rails/rails/blob/291a3d2ef29a3842d1156ada7526f4ee60dd2b59/activerecord/lib/active_record/nested_attributes.rb).

<!--more-->

The method `accepts_nested_attributes_for` looks like this:

```ruby
def accepts_nested_attributes_for(*attr_names)
  options = { allow_destroy: false, update_only: false }
  options.update(attr_names.extract_options!)
  options.assert_valid_keys(:allow_destroy, :reject_if, :limit, :update_only)
  options[:reject_if] = REJECT_ALL_BLANK_PROC if options[:reject_if] == :all_blank

  attr_names.each do |association_name|
    if reflection = _reflect_on_association(association_name)
      reflection.autosave = true
      define_autosave_validation_callbacks(reflection)

      nested_attributes_options = self.nested_attributes_options.dup
      nested_attributes_options[association_name.to_sym] = options
      self.nested_attributes_options = nested_attributes_options

      type = (reflection.collection? ? :collection : :one_to_one)
      generate_association_writer(association_name, type)
    else
      raise ArgumentError, "No association found for name `#{association_name}'. Has it been defined yet?"
    end
  end
end
```

The interesting that we will be looking at is this line.

```ruby
generate_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.

```ruby
def generate_association_writer(association_name, type)
  generated_association_methods.module_eval <<-eoruby, __FILE__, __LINE__ + 1
    silence_redefinition_of_method :#{association_name}_attributes=
    def #{association_name}_attributes=(attributes)
      assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes)
    end
  eoruby
end
```

Let's go through this line by line.

```ruby
generated_association_methods.module_eval <<-eoruby, __FILE__, __LINE__ + 1
```

`generated_association_methods` is defined [here](https://github.com/rails/rails/blob/291a3d2ef29a3842d1156ada7526f4ee60dd2b59/activerecord/lib/active_record/core.rb#L379-L387).

```ruby
def generated_association_methods
  @generated_association_methods ||= begin
    mod = const_set(:GeneratedAssociationMethods, Module.new)
    private_constant :GeneratedAssociationMethods
    include mod

    mod
  end
end
```

`const_set` is a [Ruby method](https://apidock.com/ruby/Module/const_set) 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.)

```ruby
Recipe::GeneratedAssociationMethods
Ingredient::GeneratedAssociationMethods
RecipeIngredient::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.

```ruby
gem 'rails', path: '../rails'
```

Now in the local version of rails, I can add puts statements to inspect what's going on.

```ruby
def generated_association_methods # :nodoc:
  @generated_association_methods ||= begin
    mod = const_set(:GeneratedAssociationMethods, Module.new)
    private_constant :GeneratedAssociationMethods
    include mod
    puts mod.inspect
    mod
  end
end
```

That allowed me to see what `mod` is.

Now, back to the method at hand.

```ruby
def generate_association_writer(association_name, type)
  generated_association_methods.module_eval <<-eoruby, __FILE__, __LINE__ + 1
    silence_redefinition_of_method :#{association_name}_attributes=
    def #{association_name}_attributes=(attributes)
      assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes)
    end
  eoruby
end
```

So we're creating a module at `Recipe::GeneratedAssociationMethods,` we're silencing the redefinition of the method ([see what that means here](https://github.com/rails/rails/blob/291a3d2ef29a3842d1156ada7526f4ee60dd2b59/activesupport/lib/active_support/core_ext/module/redefine_method.rb#L7-L13)). 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.

```ruby
def recipe_params
  params.require(:recipe).permit(:title, recipe_ingredients_attributes: [:amount, :description, :_destroy, :id, ingredient_attributes: [:name, :id]])
end
```

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](https://github.com/rails/rails/blob/291a3d2ef29a3842d1156ada7526f4ee60dd2b59/activerecord/lib/active_record/nested_attributes.rb#L463-L521). 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.