Creating associations to existing data part 3: has_many :through scaffolding

I just updated View Mapper to support scaffolding for models in a has_many, :through relationship. It generates a complex form that is a combination of the “belongs_to” scaffolding from part 1 of this series and the nested attributes scaffolding I wrote about in November:

Based on the programmer/assignment/project example from the ActiveRecord documentation page, this form will create a new programmer record and allow the user to add one or more assignments, each of which also has a name text field. For each new assignment the user can also select an existing project record. Here’s the Programmer model with the has_many :through association:

class Programmer < ActiveRecord::Base
  has_many :projects, :through => :assignments
  has_many :assignments
  accepts_nested_attributes_for :assignments,
                                :allow_destroy => true,
                                :reject_if => proc { |attrs|
                                  attrs['name'].blank? &&
                                  attrs['project_id'].blank?
                                }
end

This implements a many-many relationship between programmers and projects; the assignments model is used to map the projects with the programmers. I’ve also specified that the programmer model accepts_nested_attributes_for assignments… more on that below.

You can now use the “view_for” generator from View Mapper to generate the form above for your models using a new view called “has_many_existing:”

$ sudo gem install view_mapper
$ script/generate view_for programmer --view has_many_existing:projects

Assumptions and requirements

Nested Attributes: the form above works by using ActiveRecord’s nested attributes feature to save multiple assignments for a single programmer. Therefore, you need to be sure you call accepts_nested_attributes_for in your target model; if you forget to do this, you’ll get an error from View Mapper:

$ script/generate view_for programmer --view has_many_existing:projects
    warning  Model Programmer does not accept nested attributes
    for model Assignment.

To fix this problem you can use the code I showed above:

class Programmer < ActiveRecord::Base
   has_many :projects, :through => :assignments
   has_many :assignments
accepts_nested_attributes_for :assignments, :allow_destroy => true, :reject_if => proc { |attrs| attrs['name'].blank? && attrs['project_id'].blank? }
end

The options I’ve specified here tell ActiveRecord it is allowed to delete assignment records (when the user clicks “remove” in the form) and to avoid creating empty assignment records if all of their attributes are blank (if the user clicked “Add Assignment” an extra time).

Or if you prefer you can generate the entire target model including the nested attributes call using the scaffold_for_view generator like this – specify the new model’s columns using the same syntax as the standard Rails scaffold generator:

$ script/generate scaffold_for_view programmer
                  first_name:string last_name:string
                  --view has_many_existing:projects

It’s easy to overlook one very elegant detail here about ActiveRecord’s nested attribtues feature: note that “project_id” is one of the nested attributes, generated by each of the project select list boxes. (They are implemented with collection_select; see part 1 of this series). Now when the new programmer form is submitted all of the associations for each assignment – and for the new programmer – are setup. In other words, after you save the new programmer record this way you can immediately access the associated projects through assignments: “programmer.projects” – very cool! And it's all seamless: I don't have to write any code in my controller to associate the projects or assignments with the new programmer.

Correct associations among your models: if you forget to put the proper associations in your three models the has_many :through behavior will not work. You need to have six associations setup among your three models like this:

class Programmer < ActiveRecord::Base
  has_many :projects, :through => :assignments
  has_many :assignments
end

class Project < ActiveRecord::Base
  has_many :programmers, :through => :assignments
  has_many :assignments
end

class Assignment < ActiveRecord::Base
  belongs_to :project
  belongs_to :programmer
end

View Mapper will help you out by displaying an error message if you’re missing one of these:

$ script/generate scaffold_for_view programmer name:string
                  --view has_many_existing:projects
   warning  Model Project does not contain a has_many association for Assignment.

…or if you’re missing one of the corresponding foreign key columns in the “through” model:

$ script/generate scaffold_for_view programmer name:string
                  --view has_many_existing:projects
    warning  Model Assignment does not contain a foreign key for Programmer.

Has many existing model identified by name attribute: In the form above, the Project records were identified in the select list boxes using their “name” attribute. Therefore, you need to insure that your existing model has a name column or method; if it does not View Mapper will display an error message like this:

$ script/generate scaffold_for_view programmer name:string
                  --view has_many_existing:projects
    warning  Model Project does not have a name attribute.

To fix this problem, add a “name” method to your existing model, or else you can specify that View Mapper use a different attribute (e.g. “code”) instead with this syntax:

$ script/generate scaffold_for_view programmer name:string
                  --view has_many_existing:projects[code]

Associated model name method in through model: The last requirement is that the through model, Assignment in this example, have a method (“project_name”) to display the name of its associated existing model. View Mapper requires this to avoid putting this code into the view:

class Assignment < ActiveRecord::Base
   belongs_to :project
   belongs_to :programmer
def project_name project.name if project end
end

If you forget this method, View Mapper will remind you with this error message:

$ script/generate scaffold_for_view programmer name:string
                  --view has_many_existing:projects[code]
     warning  Model Assignment does not have a method project_code.

Detailed Example

Here’s a step by step example of how to create a Rails application from scratch that contains the has_many :through scaffolding:

$ rails hmt_example

Here’s our model to hold the existing data:

$ cd hmt_example
$ script/generate model project code:string

And the through model to associate projects with programmers; note I’ve included integer attributes as the foreign keys for both the existing model and the new model:

$ script/generate model assignment name:string
                        project_id:integer programmer_id:integer
$ rake db:migrate

Next edit the new models and enter the required associations along with the project_code method:

class Project < ActiveRecord::Base
  has_many :programmers, :through => :assignments
  has_many :assignments
end

class Assignment < ActiveRecord::Base
  belongs_to :programmer
  belongs_to :project
  def project_code
    project.code if project
  end
end

Now we’re ready to create the programmer has_many :through scaffolding; note I’ve specified “code” as the attribute to use to identify each project:

$ sudo gem install view_mapper
$ script/generate scaffold_for_view programmer
                  first_name:string last_name:string
                  --view has_many_existing:projects[code]
$ rake db:migrate

Note this won’t work yet for a has_and_belongs_to_many association; dealing with that is next on my View Mapper to do list.