Pneumatic cylinders
Posted by Matt Jankowski
Aug 19
Let’s assume a basic has_many :through situation like this…
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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) ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
>> 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…
1 2 3 4 5 6 7 8 |
<% @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…
1 2 3 4 5 6 7 8 9 |
<%= 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 before_filter 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 category_ids 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
Comments on this post
Aug 20
Seban said,
I prefer to add in controller def some_action params[:category_ids] ||= [] # some updates and so … end
Aug 20
Tammer Saleh said,
Seban: We thought about that, but eventually agreed that the “bug” was specific to forms, and should be dealt with in the form.
As an example, suppose we had the #update action handle XML as well. We’d want the API user to be able to post just the fields he expects to be updated, without accidentally clearing out the paper’s categories.
Aug 20
2 College Bums said,
How would you validate ensuring that there is at least 1 category selected? The issue is that Paper.first.category_ids = [] automatically saves without running any validations. We need the record to only update after all of the validations have run.
Aug 20
Matt Jankowski said,
If you update the record within a #save or #update_attributes call, those will wrap the entire thing in a transaction and roll back (including the changes from the #category_ids= assignment) in the event of a validation failure.
Aug 20
2 College Bums said,
Thanks Matt for the quick response. We’re still having difficulty triggering the rollback after validation fails.
Here’s an example of an IRB session assuming a paper must have at least one category, and has a title attribute:Please help us see where we’re going wrong.
Aug 20
Matt Jankowski said,
Try something like..
<filter> a = Paper.first a.update_attributes :category_ids => [] </filter:code>That should trigger validations.
I believe you are correct in thinking that when you use that generated setter method on it’s own (ie, not wrapped in a save) it will immediately update the db and not validate.
Aug 20
2 College Bums said,
You are correct in our initial test didn’t accurately demonstrate how we update models in our application. However a second IRB session highlights the same problem with update attribute.
We cannot get the entire transaction to rollback properly. Thanks for your continued help.
Aug 24
Paweł Gościcki said,
Using
check_boxinstead ofcheck_box_taglets Rails fix this issue (and is a much better solution):Based on ad <-> ad_sizes habtm association.
Aug 24
Matt Jankowski said,
Interesting – and yes, that would work here.
You could include this line (relevant to above code):
...and then you could remove the “hack” line.
So yeah, good suggestion.
Sorry, comments are closed for this article.
© 2000 - 2009 by thoughtbot, inc.
written by a bushel of tiny robots
Widgetfinger: Simple content management for simple websites.
Come “ride the toad” on Hoptoad, the app error app.
Tee-Bot, funny shirts your friends won't understand!
Umbrella Today: “It’s like totally the simplest weather report ever, Julie.”
Thoughtbot
thoughtbot is a technology consulting firm that provides web application development and design services. We focus on building modern systems, embracing good ideas and delivering elegant solutions.
Interested in learning Rails?
Sign up for our training.
Archives