My Little Blog

A blogging framework for hackers.

Deep Nested Attributes Using Has_many Through Join Model

(rails 3.2.15)

It’s been almost six months since I graduated from the Flatiron School. It’s hard to believe that I haven’t written a blog in just as long. Every time I overcame a challenge I kept telling myself that I should write a blog, but never got around to it.

But this. This problem was a tough nut to crack. I found hints of what to do on StackOverflow and countless articles, but three sources (appended at the end) were most useful. So, definitely check those out.

SCENARIO: My company had a very long and convoluted controller. The desire was to simplify it in anticipation for a version update. My task was to wipe the controller clean, redesign the corresponding section, and aim for simple CRUD. But I couldn’t get nested_attributes to work. So I broke the hash into pieces and wrote controller methods to get my CRUD actions to work in time for the release. It was shorter and cleaner than the previous version, and handled all validations beautifully. But it just seemed wrong to end up with a fat controller that doesn’t take advantage of the magic that is Rails (wink). I’ve sanitized the code a bit to keep it generic, but it works just the same.

Problems this blog addresses:

- Two-level deep nesting
- Parent-Child validation (Bi-Directional Association)
- Assigning multiple drop-down selections to nested object (Join Model Association)
- Multiple drop-downs from same collection: the last drop-down selection overwrites the first
- Join table with has_and_belongs_to_many association not working with nested_attributes
- Nested attributes works only on create action and not on update

VERSION 1: THE INITIAL CODE THAT DOESN’T AUTOMAGICALLY WORK

I setup my form using fields_for and accepts_nested_attributes_for following the Rails documentation. Here’s a high-level breakdown:

Car > Make > Pricing
           > Feature ( Select: #{feature_type_id: 1 } ) <-- Color
           > Feature ( Select: #{feature_type_id: 2)} ) <-- Body Type (2-door, 4-door...)

Here’s how controller and associations are originally setup in the models, and what the resulting hash looks like:

class CarsController < ApplicationController
  def new
    @car = Car.new
    make = @car.makes.build
    make.pricings.build
  end

  def create
    @car = Car.new(params[:car])
    if @car.save
      redirect_to edit_car_path(@car), notice: "Car created successfully"
    else
      redirect_to new_car_path(@car), notice: "Car could not be created"
    end
  end
end

Models
class Car < ActiveRecord::Base
  attr_accessible :name, :model_xx, :makes_attributes
  has_many :makes
  has_many :pricings,      through: :makes
  has_many :features,      through: :makes
  has_many :feature_types, through: :features
  accepts_nested_attributes_for :makes, allow_destroy: :true

class Make < ActiveRecord::Base
  attr_accessible :car_id, :vin, :pricings_attributes, :features_attributes
  belongs_to :car,           touch: true
  has_many   :pricings,      dependent: :destroy
  has_many   :feature_types, through: :features
  has_and_belongs_to_many :features, join_table: :features_makes
  accepts_nested_attributes_for :makes, allow_destroy: true

class Pricing < ActiveRecord::Base
  attr_accessible :make_id, :price, :currency
  belongs_to :make, touch: true
  has_and_belongs_to_many :makes, join_table: :pricings_makes
  validates  :make_id, :currency, :price, presence: true

class FeatureType < ActiveRecord::Base
  attr_accessible :name
  has_many  :features, dependent: :destroy
  validates :name,    presence: true
end

class Feature < ActiveRecord::Base
  attr_accessible :name, :feature_type_id
  belongs_to :feature_type
  has_and_belongs_to_many :makes, join_table: :features_makes
  validates :name, presence: true
end

form.haml
= form_for @car do |f|
  = f.label :car_name
  = f.text_field :name

  = f.label :model_code
  = f.text_field :model_xx

  = f.fields_for :makes do |v|
    = v.label :vin
    = v.text_field :vin

    = v.fields_for :features do |o|
      = o.label :id, "Color"
      = o.collection_select :id, Feature.where(feature_type_id: 1).order(:name), :id, :name, { prompt: "Select Color" }

      = o.label :id, "Body Type"
      = o.collection_select :id, Feature.where(feature_type_id: 2).order(:name), :id, :name, { prompt: "Select BodyType" }

    = v.fields_for :pricings do |p|
      = p.label :price
      = p.select :currency, Pricing.all.collect{ |p| [currency_symbol(p.currency), p.currency] }, { include_blank: "Currency" }, { class: "selectpicker", data: {style: "btn-primary currency-dropdown"} }
      = p.text_field :price

params:
"car"=>{"name"=>"delano", "model_code"=>"dx",
  "makes_attributes"=>{"0"=>{"vin"=>"vin98765",
    "pricings_attributes"=>{"0"=>{"currency"=>"usd", "price"=>"100000"}},
    "features_attributes"=> {"0"=>{"id"=>"6"}, "1"=>{"id"=>"4"}}
  }}
}

In a perfect world, clicking submit triggers Car.create(params[:car]) and the car auto-magically creates not only itself, but also all of its makes and associations in one fell swoop.

In reality, the car cannot be created for a number of reasons:

- Car and Make cannot be saved simultaneously because:
  - Car is not valid because there's no make_id
  - Make is not valid because there's no car_id
  - Pricing is not valid because there's no make_id
- Make doesn't know what to do with the features hash
- Last feature selection over-writes the first selection

BI-DIRECTIONAL ASSOCIATION: Parent-Child Validation for Nested Attributes

What’s causing this error:

ActiveRecord::RecordNotFound: Couldn't find [table] with ID=1 for ... with ID=

By default, Active Record (AR) creates separate in-memory copies of object data. You need to specify to Rails that there’s an inverse relationship between models to optimize object loading. If you don’t, AR creates objects with no association to each other. By specifying an inverse relationship, you’re telling AR that this Pricing belongs to this make, which belongs to this car. In addition to telling AR how to load the objects, you also need to set the :autosave feature to tell it that you want to save the members simultaneously. By default this is set to false.

You should also set validations down the line to require each object and specify any dependencies that need to be destroyed along with the parent (unless you want orphaned objects littering your database). Finally, you should add a variant hidden field if you forgot to include it earlier. This facilitates bi-directional relationships through the variant.

class Car < ActiveRecord::Base
  ...
  has_many  :makes, inverse_of: :car, autosave: true, dependent: :destroy
  validates :makes, presence: true
  accepts_nested_attributes_for :makes, allow_destroy: :true, reject_if: :all_blank
  ...
end

class Make < ActiveRecord::Base
  ...
  has_many  :pricings, inverse_of: :make, autosave: true, dependent: :destroy
  validates :car,      presence: true
  accepts_nested_attributes_for :features, :pricings, allow_destroy: true
  ...
end

class Pricing < ActiveRecord::Base
  ...
  validates :make, presence: true
  ...
end

form.haml
= form_for @car do |f|
  ...
  = f.fields_for :makes do |v|
    = v.hidden_field :id

ASSIGNING DROP-DOWN SELECTIONS TO NESTED ATTRIBUTE VIA JOIN TABLE

This is a complex issue because not only are we trying to assign two sub-sets of the same collection (color and body-type) per make, but we’re attempting to do it through a join table.

What’s causing this error:

[...]Controller# (ActionView::Template::Error) "Unknown primary key for [table] in model [...]"

The reason has_many :through associations allow you to do all sorts of things through join tables that you wouldn’t ordinarily be able to with HABTM (such as callbacks and validations), is because there’s a primary key to reference each joined record. If all you’re doing is joining tables and don’t need any special behavior, then it’s quickest to use HABTM and create a join table. That’s what we had, but we needed more.

JOIN MODEL ASSOCIATION: Using has_many :through on a join table

Since we are assigning features before a make_id exists, has_many associations to the join table need to be written so as to allow parent-child validation through bi-directional association. This is done following the same logic as employed in the previous example.

Note that Rails needs a primary key to manage has_many :through associations on join tables. Since my join table already exists, I run a migration to add a primary key to the join table and use first: true to make it the first column. Finally, I update my form builder and nested_attributes on my form to reflect the association to the join table.

Notice that the feature value for each drop-down is simultaneously built along with the join record. Instantiating one of each FeatureType when building the form: (a) takes care of the last drop-down over-writing the first, and (b) gives us a handy new join record to link through to each of our features.

class AddIdToFeaturesMakes < ActiveRecord::Migration
  def change
    add_column :features_makes, :id, :primary_key, first: true
  end
end

class Make < ActiveRecord::Base
  ...
  has_many :features,       through: :features_makes, source: :feature
  has_many :features_makes, inverse_of: :make, autosave: true, dependent: :destroy
  ...
end

class FeaturesMakes < ActiveRecord::Base
  attr_accessible :make_id, :feature_id, :feature_attributes
  belongs_to :make
  belongs_to :feature
  validates  :make, :feature, presence: true
end

class Feature < ActiveRecord::Base
  ...
  has_many :makes,          through: :features_makes
  has_many :features_makes, inverse_of: :feature, autosave: true, dependent: :destroy
  ...
end

CarsController.rb
def new
  @car = Car.new
  make = @car.makes.build
  make.pricings.build
  2.times { |n| make.features_makes.build.build_feature(feature_type_id: n+1) }
end

form.haml
= v.fields_for :features_makes do |ov|
  - features = ::Feature.where(feature_type_id: ov.object.feature.feature_type_id)
  = ov.label :feature_id, "#{ov.object.feature.feature_type.name}"
  = ov.collection_select "feature_id", features.order(:name), :id, :name, { prompt: "Select #{ov.object.feature.feature_type.name}" }, { class: "selectpicker select-block", data: {style: "btn-primary"} }

Why do nested attributes work on #create but not on #update?

If for any reason you decide to leave the primary key out, it is absolutely possible to assign feature values to a new make. Through trial and error – trying to get this to work – I discovered that you can use has_many :through without adding a primary key. You can tell Rails what primary key you want it to use, which can be an array (or composite):

class Featuresmake < ActiveRecord::Base
  attr_accessible :make_id, :feature_id, :feature_attributes
  self.primary_key = [:make_id, :feature_id] <-- [ Setting primary key on a join table ]
  ...
end

But – while possible – what’s the point? It’s a waste of real estate AND you don’t get the full benefit that you get with a primary key. The biggest impediment is that you won’t be able to update drop-down selections. Instead you’ll end up with two new drop-downs anytime you try to update color or body-type for an existing make. The reason for this is that Rails will instantiate a new record when it can’t find a primary key. So, there you go.

FINAL VERSION: THE CODE THAT AUTOMAGICALLY WORKS

JOIN MODEL ASSOCIATION: Add primary key to join table

class AddIdToFeaturesmakes < ActiveRecord::Migration
  def change
    add_column :features_makes, :id, :primary_key, first: true
  end
end

FORM BUILDER: Build instances for desired associations

CarsController.new
def new
  @car = Car.new
  make = @car.makes.build
  make.pricings.build
  2.times { |n| make.features_makes.build.build_feature(feature_type_id: n+1) }
end

BI-DIRECTIONAL ASSOCIATION: Specify parent-child relationship

class Car < ActiveRecord::Base
  attr_accessible :model, :description, :model_code, :makes_attributes
  has_many  :makes,          inverse_of: :car, autosave: true, dependent: :destroy
  has_many  :feature_types,  through: :features
  has_many  :features,       through: :makes
  has_many  :pricings,       through: :makes
  validates :makes,          presence: true <--[ Validates presence of inverse_of object ]
  accepts_nested_attributes_for :makes, allow_destroy: :true, reject_if: :all_blank
  (...)
end

class Make < ActiveRecord::Base
  attr_accessible :name, :car_id, :model_code, :pricings_attributes, :features_attributes
  belongs_to :car,          touch: true
  has_many :pricings,       inverse_of: :make, autosave: true, dependent: :destroy
  has_many :features_makes, inverse_of: :make, autosave: true, dependent: :destroy
  has_many :images,         through: :features
  has_many :feature_types,  through: :features
  has_many :features,       through: :features_makes, source: :feature ### replaces habtm
  validates :car,           presence: true <--[ Validates presence of inverse_of object ]
  accepts_nested_attributes_for :features_makes, :pricings, allow_destroy: true
  (...)
end

class Pricing < ActiveRecord::Base
  attr_accessible :make_id, :currency, :price
  belongs_to :make,     touch: true
  validates  :make,     presence: true <--[ Validates presence of inverse_of object ]
  validates  :currency, presence: true
  (...)
end

class FeatureType < ActiveRecord::Base
  attr_accessible :name
  has_many  :features, dependent: :destroy
  validates :name, presence: true
end

class Feature < ActiveRecord::Base
  attr_accessible :name, :presentation, :feature_type_id
  belongs_to :feature_type
  has_many   :features_makes, inverse_of: :feature, autosave: true, dependent: :destroy
  has_many   :makes,          through: :features_makes
  validates  :name,           presence: true 
  (...)
end

class FeaturesMakes < ActiveRecord::Base
  attr_accessible :make_id, :feature_id, :feature_attributes
  belongs_to :make
  belongs_to :feature
  validates  :make, :feature, presence: true <--[ Validates presence of inverse_of object ]
end

INPUT FORM

form.haml
= form_for @car do |f|
  = f.label :car_model
  = f.text_field :model

  = f.label :model_code
  = f.text_field :model_code

  = f.fields_for :makes do |v|
    = v.hidden_field :id

    = v.label :vin
    = v.text_field :vin

    = v.fields_for :features_makes do |ov|
      - features = ::Feature.where(feature_type_id: ov.object.feature.feature_type_id)
      = ov.label :feature_id, "#{ov.object.feature.feature_type.name}"
      = ov.collection_select "feature_id", features.order(:name), :id, :name, { prompt: "Select #{ov.object.feature.feature_type.name}" }, { class: "selectpicker select-block", data: {style: "btn-primary"} }

    = v.fields_for :pricings do |p|
      = p.label :price
      = p.select :currency, Pricing.all.collect{ |p| [currency_symbol(p.currency), p.currency] }, { include_blank: "Currency" }, { class: "selectpicker", data: {style: "btn-primary currency-dropdown"} }
      = p.text_field :price

PARAMS

"car"=>{"name"=>"delano", "model_code"=>"dx",
  "makes_attributes"=>{"0"=>{"vin"=>"vin98765", 
    "features_makes_attributes"=>{"0"=>{"feature_id"=>"6"}, "1"=>{"feature_id"=>"4"}}, 
    "pricings_attributes"=>{"0"=>{"currency"=>"usd", "price"=>"100000"}}}}}

Sources:
http://robots.thoughtbot.com/accepts-nested-attributes-for-with-has-many-through http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html http://www.sitepoint.com/complex-rails-forms-with-nested-attributes