Topic: "has_many :through" and checkboxes

I have the following Models:

class Document < ActiveRecord::Base    
    has_many :document_topics, :dependent => true
    has_many :topics, :through => :document_topics
end

class Topic < ActiveRecord::Base    
    has_many :document_topics, :dependent => true
    has_many :documents, :through => :document_topics   
end

and the following is and excert from the document controller:
  def update
    @document = Document.find(params[:id])
    if @document.update_attributes(params[:document])
      flash[:notice] = 'Document was successfully updated.'
      redirect_to :action => 'show', :id => @document
    else
      render :action => 'edit'
    end
  end

I'm trying to do the following in a document view:
    
<% Topic.find(:all).each do |topic| %>
  <input type="checkbox" name="document[topic_ids][]" value="<%= topic.id %>" <% if @document.topics.include?(topic) %> checked="checked" <% end %> />
  <%= topic.title %>
<% end %>

But im getting the following error when I try to update a document:

undefined method `topic_ids=' for #<Document:0x35c46f0>

I thought 'topic_ids' was automatically generate? What am I doing wrong?

Last edited by mip (2006-10-05 11:21:06)

Re: "has_many :through" and checkboxes

"topic_ids=" is only automatically generated for a has_and_belongs_to_many relationship. Here you have roughly the same relationship, but you are using has_many :through. I think you need to create/remove each join model manually in the controller.

Railscasts - Free Ruby on Rails Screencasts

Re: "has_many :through" and checkboxes

ryanb wrote:

"topic_ids=" is only automatically generated for a has_and_belongs_to_many relationship. Here you have roughly the same relationship, but you are using has_many :through.

Hmm thanks. I suspected that might be the case.

ryanb wrote:

I think you need to create/remove each join model manually in the controller.

How do I do that? Are there any examples around?

Re: "has_many :through" and checkboxes

Adding the following to the document controller doesn't help.

 def topics=(list)
def topic_ids=(list)

Anyone got any suggestions?

Re: "has_many :through" and checkboxes

The topic_ids= method should be called if you create it in the Document model. What you place in it is the tricky part.

You can try something like this. There's probably a better way, but I would need to do testing before I find it. Untested.

def topic_ids=(topic_ids)
  document_topics.each do |doc_topic|
    # destroying the item you are enumerating over usually isn't a good idea.
    # So this may not work:
    doc_topic.destroy unless topic_ids.include? doc_topic.topic_id
  end

  topic_ids.each do |topic_id|
    self.document_topics.create(:topic_id => topic_id) unless
  end
end


The reason helper methods like this are not added for a has_many :through association is Rails expects the join model to have more attributes (possibly with validations, etc.) so it isn't as simple as a has_and_belongs_to_many join.

Railscasts - Free Ruby on Rails Screencasts

Re: "has_many :through" and checkboxes

Much appreciated. I'll give this a go and get back to you.

I expected this to be a pretty common operation and for there to be a simple way of achieving this. Am I going about it the wrong way?

Last edited by mip (2006-10-08 12:58:56)

Re: "has_many :through" and checkboxes

mip wrote:

I expected this to be a pretty common operation and for there to be a simple way of achieving this. Am I going about it the wrong way?

Often times has_many :through will have other attributes in the join model, so a simple check box will then not be enough. If you don't ever expect to add attributes or logic to the DocumentType join model then you may want to consider the has_and_belongs_to_many association. That seems to be used less and less in favor of has_many :through, but IMO it still has its uses.


You may be interested in this article describing an addition to edge rails that makes stuff like this easier. It still doesn't have the _ids= method, but it's getting there.

Railscasts - Free Ruby on Rails Screencasts

Re: "has_many :through" and checkboxes

ryanb wrote:

def topic_ids=(topic_ids)
  document_topics.each do |doc_topic|
    # destroying the item you are enumerating over usually isn't a good idea.
    # So this may not work:
    doc_topic.destroy unless topic_ids.include? doc_topic.topic_id
  end

  topic_ids.each do |topic_id|
    self.document_topics.create(:topic_id => topic_id) unless
  end
end

Hmm. I assume this code goes in the document model? What exactly is it meant to do?

There appears to be a syntax error in these lines:

  topic_ids.each do |topic_id|
    self.document_topics.create(:topic_id => topic_id) unless
  end

What does the unless do?

Edit: It seems to work if I remove the unless.

Last edited by mip (2006-10-09 06:53:13)

Re: "has_many :through" and checkboxes

mip wrote:

What exactly is it meant to do?

It destroys all entries from document_topics which have not been selected using checkboxes (i.e. those which have been removed) and then adds any new ones?

Last edited by mip (2006-10-09 06:49:51)

10

Re: "has_many :through" and checkboxes

ryanb wrote:

def topic_ids=(topic_ids)
  document_topics.each do |doc_topic|
    # destroying the item you are enumerating over usually isn't a good idea.
    # So this may not work:
    doc_topic.destroy unless topic_ids.include? doc_topic.topic_id
  end

Hmm. This doesnt allow me to destroy all doc_topics. If I try to destroy them all it always leaves one behind.

Re: "has_many :through" and checkboxes

mip wrote:

There appears to be a syntax error in these lines:

  topic_ids.each do |topic_id|
    self.document_topics.create(:topic_id => topic_id) unless
  end

What does the unless do?

Edit: It seems to work if I remove the unless.

Sorry about that. It should be:

topic_ids.each do |topic_id|
    self.document_topics.create(:topic_id => topic_id) unless document_topics.any? { |d| d.topic_id == topic_id }
  end

That should add it if it doesn't already exist.

mip wrote:

Hmm. This doesnt allow me to destroy all doc_topics. If I try to destroy them all it always leaves one behind.

I'm not certain why. I'll have to do some testing and get back to you on it. Hopefully I will get around to that today.

Last edited by ryanb (2006-10-09 10:29:06)

Railscasts - Free Ruby on Rails Screencasts

12

Re: "has_many :through" and checkboxes

ryanb wrote:

I'm not certain why. I'll have to do some testing and get back to you on it. Hopefully I will get around to that today.

That would be greatly appreciated. I'm a bit confused with what is happening.

Last edited by mip (2006-10-10 04:48:43)

Re: "has_many :through" and checkboxes

Sorry, I think this is a little beyond me. I have no experience with SQL server. I wonder if the error is specific to that?

Railscasts - Free Ruby on Rails Screencasts

14

Re: "has_many :through" and checkboxes

ryanb wrote:

Sorry, I think this is a little beyond me. I have no experience with SQL server. I wonder if the error is specific to that?

Oops posted to the wrong thread smile

Re: "has_many :through" and checkboxes

I think the problem you are experiencing is that if no checkboxes are checked, the params[:document][:topic_ids] array is not getting set at all (it is nil). Instead, you want this to be an empty array. To fix this problem, try adding this to your update action:

  def update
    params[:document][:topic_ids] ||= []
    @document = Document.find(params[:id])
    #...
  end

Railscasts - Free Ruby on Rails Screencasts

16

Re: "has_many :through" and checkboxes

ryanb wrote:

I think the problem you are experiencing is that if no checkboxes are checked, the params[:document][:topic_ids] array is not getting set at all (it is nil). Instead, you want this to be an empty array. To fix this problem, try adding this to your update action:

  def update
    params[:document][:topic_ids] ||= []
    @document = Document.find(params[:id])
    #...
  end

Great. That seems to work. Can you explain exactly what this line of code does?

Much appreciated.

Re: "has_many :through" and checkboxes

The ||= is a little strange, but it makes sense once you get used to it. It works just like an equal sign, but it only sets the value if the original is nil - this is very useful. For example:

foo = nil
foo || = 1
foo # => 1
foo ||= 2
foo # => 1

We are setting the topic_ids to an empty array here if they aren't set, because the way forms work, they don't send anything if no checkboxes are checked so params[:document][:topic_ids] is nil.

Railscasts - Free Ruby on Rails Screencasts

18

Re: "has_many :through" and checkboxes

Thanks for the explanation!

19

Re: "has_many :through" and checkboxes

Thanks, ryanb, for all your help here. I have a similar situation that I am making my way through.

I am running into a problem though as I am using the above code and acts_as_list on the join model (position in the join table). So each time I edit a document, the join record(s) is(are) destroyed and recreated, thereby deleting all position info.

Do you happen to see any way around this? Thanks!

Re: "has_many :through" and checkboxes

Are you doing this?

def topic_ids=(topic_ids)
  document_topics.each do |doc_topic|
    doc_topic.destroy unless topic_ids.include? doc_topic.topic_id
  end

  topic_ids.each do |topic_id|
    self.document_topics.create(:topic_id => topic_id) unless document_topics.any? { |d| d.topic_id == topic_id }
  end
end


This shouldn't destroy all records. It should just destroy the records it doesn't need anymore, and create the ones it needs. Any records that stay the same should persist. Not sure what the problem is.

BTW, I know there is a nicer way to do this using array subtraction. I just need to figure it out...

Railscasts - Free Ruby on Rails Screencasts