Let’s assume a basic has_many :through
situation like this…
class Paper < ActiveRecord::Base
has_many :categorizations, :dependent => :destroy
has_many :categories, :through => :categorizations
end
class Category < ActiveRecord::Base
has_many :categorizations, :dependent => :destroy
has_many :papers, :through => :categorizations
end
class Categorization < ActiveRecord::Base
belongs_to :category
belongs_to :paper
end
…ever since rails 2.something there’s been a setter method that you get “for
free” when you declare relationships like this (it used to only work on a normal
HABTM
, but now also works on HMT
associations). In this case, you will get
a #category_ids=
method on Paper
, which takes an array of id values for
Category
records, and updates the categorizations between the two to reflect
what’s sent in. Works like this (I’ve stripped out some of the SQL related to validations) …
>> Paper.first.categories.size
# SELECT count(*) AS count_all
# FROM `categories`
# INNER JOIN categorizations ON categories.id = categorizations.category_id
# WHERE ((`categorizations`.paper_id = 4))
=> 0
>> Paper.first.category_ids = [Category.first.id, Category.last.id]
# INSERT INTO `categorizations` (`category_id`, `paper_id`) VALUES(1, 4)
# INSERT INTO `categorizations` (`category_id`, `paper_id`) VALUES(79, 4)
=> [1, 79]
>> Paper.first.categories.size
# SELECT count(*) AS count_all
# FROM `categories`
# INNER JOIN categorizations ON categories.id = categorizations.category_id
# WHERE ((`categorizations`.paper_id = 4))
=> 2
This is great for a scenario where you have a view that lists a collection of checkboxes in a form for a user to select which categories should be associated with a paper, you might have markup like this…
<% @categories.each do |category| -%>
<%= check_box_tag 'paper[category_ids][]',
category.id,
paper.categories.include?(category),
:id => "category_#{category.id}" %>
<label for="category_<%= category.id %>"><%=h category.name %></label>
<% end -%>
…in this case, when a user selects categories from the resulting set of
checkboxes in the view, there will be a params[:category_ids]
array present,
which will in turn call the #category_ids=
setter on the Paper record when we
call #update_attributes
and #save
in the #update
action in the controller.
Now, what happens if a user unchecks ALL of the checkboxes, but the Paper record
previously had categories associated with it? The user is clearly indicating
that all categorization records connecting the Paper in question to it’s current
Category associations should be destroyed. However, because of the way HTML works and browsers submit forms,
there will NOT be any value sent in for params[:category_ids]
, thus the
#category_ids=
setter will never be called, and so the previous associations
will stick around even though they should have been deleted.
One solution to this is to update your view to include one more line…
<%= hidden_field_tag 'paper[category_ids][]', nil %>
<% @categories.each do |category| -%>
<%= check_box_tag 'paper[category_ids][]',
category.id,
paper.categories.include?(category),
:id => "category_#{category.id}" %>
<label for="category_<%= category.id %>"><%=h category.name %></label>
<% end -%>
This will ensure that there is always a category_ids
Array in your params,
and will have the effect of setting the association to nil (empty collection in
this case) if all the boxes are unchecked.
You could probably argue that this should be in a beforefilter which massages
the params on the #update
action, and I wouldn’t stop you from doing that—but
I’m comfortable with this being in the view. I look at the params that result
from the view as the most direct method which captures the intent of the user
and communicates it to the controller in the HTTP request – and it’s also in
line with what Rails already does with normal checkbox tags. From that
perspective, it’s fine to have the view generate an empty categoryids array by
default, when the user does not want to save any categories with a Paper, and
this technique accomplishes that.
Also, check out this GAS POWERED blender