3

Scenario: I have a has_many association (Post has many Authors), and I have a nested Post form to accept attributes for Authors.

What I found is that when I call post.update_attributes(params[:post]) where params[:post] is a hash with post and all author attributes to add, there doesn't seem to be a way to ask Rails to only create Authors if certain criteria is met, e.g. the username for the Author already exists. What Rails would do is just failing and rollback update_attributes routine if username has uniqueness validation in the model. If not, then Rails would add a new record Author if one that does not have an id is in the hash.

Now my code for the update action in the Post controller becomes this:

def update
  @post = Post.find(params[:id])

  # custom code to work around by inspecting the author attributes
  # and pre-inserting the association of existing authors into the testrun's author
  # collection
  params[:post][:authors_attributes].values.each do |author_attribute|
    if author_attribute[:id].nil? and author_attribute[:username].present?
      existing_author = Author.find_by_username(author_attribute[:username])
      if existing_author.present?
        author_attribute[:id] = existing_author.id 
        @testrun.authors << existing_author
      end
    end
  end

  if @post.update_attributes(params[:post])
    flash[:success] = 'great!'
  else
    flash[:error] = 'Urgg!'
  end

  redirect_to ...
end

Are there better ways to handle this that I missed?

EDIT: Thanks for @Robd'Apice who lead me to look into overriding the default authors_attributes= function that accepts_nested_attributes_for inserts into the model on my behalf, I was able to come up with something that is better:

def authors_attributes=(authors_attributes)
  authors_attributes.values.each do |author_attributes|
    if author_attributes[:id].nil? and author_attributes[:username].present?
      author = Radar.find_by_username(radar_attributes[:username])
      if author.present?
        author_attributes[:id] = author.id
        self.authors << author
      end
    end
  end
  assign_nested_attributes_for_collection_association(:authors, authors_attributes, mass_assignment_options)
end

But I'm not completely satisfied with it, for one, I'm still mucking the attribute hashes from the caller directly which requires understanding of how the logic works for these hashes (:id set or not set, for instance), and two, I'm calling a function that is not trivial to fit here. It would be nice if there are ways to tell 'accepts_nested_attributes_for' to only create new record when certain condition is not met. The one-to-one association has a :update_only flag that does something similar but this is lacking for one-to-many relationship.

Are there better solutions out there?

yuklai
  • 1,595
  • 1
  • 14
  • 26

2 Answers2

1

This kind of logic probably belongs in your model, not your controller. I'd consider re-writing the author_attributes= method that is created by default for your association.

def authors_attributes=(authors_attributes)
  authors_attributes.values.each do |author_attributes|
    author_to_update = Author.find_by_id(author_attributes[:id]) || Author.find_by_username(author_attributes[:username]) || self.authors.build
    author_to_update.update_attributes(author_attributes)
  end
end

I haven't tested that code, but I think that should work.

EDIT: To retain the other functionality of accepts_nested_Attributes_for, you could use super:

def authors_attributes=(authors_attributes)
  authors_attributes.each do |key, author_attributes|
    authors_attributes[key][:id] = Author.find_by_username(author_attributes[:username]).id if author_attributes[:username] && !author_attributes[:username].present?
  end
  super(authors_attributes)
end 

If that implementation with super doesn't work, you probably have two options: continue with the 'processing' of the attributes hash in the controller (but turn it into a private method of your controller to clean it up a bit), or continue with my first solution by adding in the functionality you've lost from :destroy => true and reject_if with your own code (which wouldn't be too hard to do). I'd probably go with the first option.

BenMorel
  • 34,448
  • 50
  • 182
  • 322
Rob d'Apice
  • 2,416
  • 1
  • 19
  • 29
  • No doubt the logic does belong to my implementation, but my question is how to improve it such that I don't have to parse the attribute hash. I will look into author_attributes= method to see if I can catch it there. Thanks! – yuklai Jun 29 '12 at 07:28
  • @fatshu Edited my answer with an implementation that I think should work. You should be able to drop your chunk of code from the controller if this works! – Rob d'Apice Jun 29 '12 at 07:34
  • @Robd'Apice Thanks for the pointer, though I think the solution you mentioned probably won't work. I think there are a couple problems. But most importantly, the :reject_if and :allow_destroy options for accepts_nested_attributes_for are ignored in the implementation. In fact when you call update_attributes(author_attributes), the update routine would raise an exception on unknown attribute _destroy. So this breaks the behavior of accepts_nested_attributes_for. – yuklai Jun 29 '12 at 18:04
  • In order for this to work, the only way I can find so far is changing the attributes sort of like my original solution, but calling assign_nested_attributes_for_collection_association that ActiveRecord implements. But I found this only by reading the rails source code, so I'm not too thrilled of it. I will post my finding so far in the edit of the original question. – yuklai Jun 29 '12 at 18:08
0

I'd suggest using a form object instead of trying to get accepts_nested_attributes to work. I find that form object are often much cleaner and much more flexible. Check out this railscast

Sam Backus
  • 1,693
  • 14
  • 19