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

Seban

Aug 20

Seban said,

I prefer to add in controller def some_action params[:category_ids] ||= [] # some updates and so … end

Tammer Saleh

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.

2 College Bums

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.

Matt Jankowski

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.

2 College Bums

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:
1
2
3
4
5
6
7
8
9
10
11
12
13

>> a = Paper.first
>>a.categories # => [#category1, #category2]
>> a.category_ids = [] # => []
>> a.categories # => []
>> a.title = "invalid" 
>> a.valid? # => false
>> a.errors.full_messages # => ["title cannot be invalid", "At least one category must be assigned"]
>> a.save # => false
>> a.reload
>> a.title # => "Original"
>> a.categories # => []
>> a.valid? # => false

Please help us see where we’re going wrong.

Matt Jankowski

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.

2 College Bums

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.

1
2
3
4
5
6
7
8
9
10
11
12

>> a = Paper.first
>> a.categories # => [#category1, #category2]
>> a.update_attributes(:category_ids => [], :title => "invalid") # => false
>> a.categories # => []
>> a.title # => "invalid" 
>> a.valid? # => false
>> a.errors.full_messages # => ["title cannot be invalid", "At least one category must be assigned"]
>> a.reload
>> a.title # => "Original"
>> a.categories # => []
>> a.valid? # => false

We cannot get the entire transaction to rollback properly. Thanks for your continued help.

Paweł Gościcki

Aug 24

Paweł Gościcki said,

Using check_box instead of check_box_tag lets Rails fix this issue (and is a much better solution):

1
2
3
4


<%= check_box "ad", "ad_size_ids", {:checked => @ad.ad_sizes.include?(s), :id => "ad_size_#{s.name}", :class => 'checkbox', :name => 'ad[ad_size_ids][]'}, s.id, nil %>

Based on ad <-> ad_sizes habtm association.

Matt Jankowski

Aug 24

Matt Jankowski said,

Interesting – and yes, that would work here.

You could include this line (relevant to above code):

1
2

<%= form.check_box 'category_ids', { :checked => paper.categories.include?(category), :id => "other_category_#{category.id}", :name => 'paper[category_ids][]' }, category.id, nil %>

...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