63

I'm using Rails' accepts_nested_attributes_for method with great success, but how can I have it not create new records if a record already exists?

By way of example:

Say I've got three models, Team, Membership, and Player, and each team has_many players through memberships, and players can belong to many teams. The Team model might then accept nested attributes for players, but that means that each player submitted through the combined team+player(s) form will be created as a new player record.

How should I go about doing things if I want to only create a new player record this way if there isn't already a player with the same name? If there is a player with the same name, no new player records should be created, but instead the correct player should be found and associated with the new team record.

trisignia
  • 1,173
  • 1
  • 10
  • 19
  • Keep in mind that `accepts_nested_attributes_for :some_model` is really just a fancy way of defining a `some_model_attributes=` method, so if you don't like how `accepts_nested_attributes_for` behaves you could always just implement that model yourself. – Ajedi32 Jun 12 '18 at 20:36

8 Answers8

57

When you define a hook for autosave associations, the normal code path is skipped and your method is called instead. Thus, you can do this:

class Post < ActiveRecord::Base
  belongs_to :author, :autosave => true
  accepts_nested_attributes_for :author

  # If you need to validate the associated record, you can add a method like this:
  #     validate_associated_record_for_author
  def autosave_associated_records_for_author
    # Find or create the author by name
    if new_author = Author.find_by_name(author.name)
      self.author = new_author
    else
      self.author.save!
    end
  end
end

This code is untested, but it should be pretty much what you need.

maletor
  • 7,072
  • 7
  • 42
  • 63
François Beausoleil
  • 16,265
  • 11
  • 67
  • 90
  • Can you please point this functionality in the documentation? – dombesz Sep 20 '10 at 08:51
  • 5
    Also i think the correct is def autosave_associated_records_for_author. – dombesz Sep 20 '10 at 09:03
  • 1
    Is this method works on the other side of the relation? for example what if we have has_many :authors ? – dombesz Sep 20 '10 at 11:01
  • 3
    I can't find it anywhere in the docs, but it's very clear from the code it's supposed to be overriden: http://api.rubyonrails.org/classes/ActiveRecord/AutosaveAssociation.html and http://github.com/rails/rails/blob/2-3-stable/activerecord/lib/active_record/autosave_association.rb#L168 – François Beausoleil Sep 20 '10 at 11:41
  • I have different needs, but this answers really help. Here is what I use in my model: https://gist.github.com/2427868 – Donny Kurnia Apr 20 '12 at 11:23
  • 1
    Strangely, I couldn't get this to work as is. That else branch always threw this error: `SQLite3::ConstraintException: posts.author_id may not be NULL`. I solved it by doing the following: `author.save! self.author = author`. – Ashitaka Apr 27 '13 at 16:43
  • 18
    This solution didn't quite work for me (Rails 3.2). I had a similar issue to @Ashitaka and the ID wasn't being set for the child object. In the example above you would need to add a line after `self.author.save!` saying: `self.author_id = self.author.id`. @maletor could you upvote this just so that other people can see it - it's disappearing under the "show more" - ty. – Peter Nixey Jul 11 '13 at 17:57
  • Couldn't you also us a find_or_create_by instead of the if/else block? – Brian Dear Jan 05 '15 at 22:29
  • @BrianDear, hmm, possibly. I didn't think about it when I originally wrote the answer. – François Beausoleil Jan 06 '15 at 23:44
  • For collection use autosave_associated_records_for_authors. And there will be authors variable available for dealing with the save – Lecky Lao Jan 19 '15 at 06:35
  • What happens if any of the attributes for `author` are nil? I tried this out and got the error `undefined method 'name' for nil:NilClass`, where `author` returned as nil. – onebree Dec 02 '15 at 20:57
  • Great answer, François. I was using it in my application and found one corner case. I added the code on top of your solution here - https://stackoverflow.com/a/46932123/1376448 // @ashitaka – kiddorails Oct 25 '17 at 12:13
32

Don't think of it as adding players to teams, think of it as adding memberships to teams. The form doesn't work with the players directly. The Membership model can have a player_name virtual attribute. Behind the scenes this can either look up a player or create one.

class Membership < ActiveRecord::Base
  def player_name
    player && player.name
  end

  def player_name=(name)
    self.player = Player.find_or_create_by_name(name) unless name.blank?
  end
end

And then just add a player_name text field to any Membership form builder.

<%= f.text_field :player_name %>

This way it is not specific to accepts_nested_attributes_for and can be used in any membership form.

Note: With this technique the Player model is created before validation happens. If you don't want this effect then store the player in an instance variable and then save it in a before_save callback.

ryanb
  • 16,227
  • 5
  • 51
  • 46
  • 4
    About that note: if you don't want the Player created before validation, use `find_or_initialize_by_name(name)` instead of `find_or_create_by_name(name)` – Arcolye Jun 12 '13 at 03:40
  • if I could upvote this answer a million times I would. This has been so helpful for complex relationships and avoiding deep nesting! thank you! – Michelle Nov 21 '13 at 21:51
6

A before_validation hook is a good choice: it's a standard mechanism resulting in simpler code than overriding the more obscure autosave_associated_records_for_*.

class Quux < ActiveRecord::Base

  has_and_belongs_to_many :foos
  accepts_nested_attributes_for :foos, reject_if: ->(object){ object[:value].blank? }
  before_validation :find_foos

  def find_foos
    self.foos = self.foos.map do |object|
      Foo.where(value: object.value).first_or_initialize
    end
  end

end
deprecated
  • 5,142
  • 3
  • 41
  • 62
  • How could this handle _destroy? – GN. Aug 14 '19 at 03:46
  • You can handle `_destroy` by adding this into the `foos.map` block: ```foo = Foo.where...; if object._destroy; self.foos.delete(foo); return nil; else foo end``` **Note** that `self.foos.delete(foo)` removes the association from the join table only, and doesn't remove the `foo` record for HABTM relationships. – mineralwasser Jan 25 '20 at 23:13
5

When using :accepts_nested_attributes_for, submitting the id of an existing record will cause ActiveRecord to update the existing record instead of creating a new record. I'm not sure what your markup is like, but try something roughly like this:

<%= text_field_tag "team[player][name]", current_player.name %>
<%= hidden_field_tag "team[player][id]", current_player.id if current_player %>

The Player name will be updated if the id is supplied, but created otherwise.

The approach of defining autosave_associated_record_for_ method is very interesting. I'll certainly use that! However, consider this simpler solution as well.

Anson
  • 6,575
  • 2
  • 39
  • 33
4

Just to round things out in terms of the question (refers to find_or_create), the if block in Francois' answer could be rephrased as:

self.author = Author.find_or_create_by_name(author.name) unless author.name.blank?
self.author.save! 
KenB
  • 6,587
  • 2
  • 35
  • 31
  • In rails 4: self.author = Author.find_or_create_by (name: author.name) unless author.name.blank? self.author.save! – Katarzyna Jul 28 '15 at 17:02
3

This works great if you have a has_one or belongs_to relationship. But fell short with a has_many or has_many through.

I have a tagging system that utilizes a has_many :through relationship. Neither of the solutions here got me where I needed to go so I came up with a solution that may help others. This has been tested on Rails 3.2.

Setup

Here are a basic version of my Models:

Location Object:

class Location < ActiveRecord::Base
    has_many :city_taggables, :as => :city_taggable, :dependent => :destroy
    has_many :city_tags, :through => :city_taggables

    accepts_nested_attributes_for :city_tags, :reject_if => :all_blank, allow_destroy: true
end

Tag Objects

class CityTaggable < ActiveRecord::Base
   belongs_to :city_tag
   belongs_to :city_taggable, :polymorphic => true
end

class CityTag < ActiveRecord::Base
   has_many :city_taggables, :dependent => :destroy
   has_many :ads, :through => :city_taggables
end

Solution

I did indeed override the autosave_associated_recored_for method as follows:

class Location < ActiveRecord::Base
   private

   def autosave_associated_records_for_city_tags
     tags =[]
     #For Each Tag
     city_tags.each do |tag|
       #Destroy Tag if set to _destroy
       if tag._destroy
         #remove tag from object don't destroy the tag
         self.city_tags.delete(tag)
         next
       end

       #Check if the tag we are saving is new (no ID passed)
       if tag.new_record?
         #Find existing tag or use new tag if not found
         tag = CityTag.find_by_label(tag.label) || CityTag.create(label: tag.label)
       else
         #If tag being saved has an ID then it exists we want to see if the label has changed
         #We find the record and compare explicitly, this saves us when we are removing tags.
         existing = CityTag.find_by_id(tag.id)
         if existing    
           #Tag labels are different so we want to find or create a new tag (rather than updating the exiting tag label)
           if tag.label != existing.label
             self.city_tags.delete(tag)
             tag = CityTag.find_by_label(tag.label) || CityTag.create(label: tag.label)
           end
         else
           #Looks like we are removing the tag and need to delete it from this object
           self.city_tags.delete(tag)
           next
         end
       end
       tags << tag
     end
     #Iterate through tags and add to my Location unless they are already associated.
     tags.each do |tag|
       unless tag.in? self.city_tags
         self.city_tags << tag
       end
     end
   end

The above implementation saves, deletes and changes tags the way I needed when using fields_for in a nested form. I'm open to feedback if there are ways to simplify. It is important to point out that I am explicitly changing tags when the label changes rather than updating the tag label.

Dustin M.
  • 2,884
  • 3
  • 20
  • 18
  • Dustin... I _really_ appreciated this method. Is working really well for me. One question: The branch `#Looks like we are removing...` doesn't make sense to me. Under what circumstances would a record _not_ be `new_record?` and also not have an ID? – Lanny Bose Oct 19 '15 at 17:45
  • 1
    @LannyBose it can happen under concurrent requests – deprecated Nov 20 '15 at 11:25
  • @dustin-m how does the form/view look for this? I'm intrigued because I've run into an almost identical problem http://stackoverflow.com/questions/37595050/rails-5-nested-forms-and-existing-associated-objects –  Jun 02 '16 at 15:09
  • @LannyBose you could have nested attributes that contain with them their own record id, these records would be updated, not new. – Ryan Taylor Nov 10 '22 at 20:43
3

Answer by @François Beausoleil is awesome and solved a big problem. Great to learn about the concept of autosave_associated_record_for.

However, I found one corner case in this implementation. In case of update of existing post's author(A1), if a new author name(A2) is passed, it will end up changing the original(A1) author's name.

p = Post.first
p.author #<Author id: 1, name: 'JK Rowling'>
# now edit is triggered, and new author(non existing) is passed(e.g: Cal Newport).

p.author #<Author id: 1, name: 'Cal Newport'>

Oringinal code:

class Post < ActiveRecord::Base
  belongs_to :author, :autosave => true
  accepts_nested_attributes_for :author

  # If you need to validate the associated record, you can add a method like this:
  #     validate_associated_record_for_author
  def autosave_associated_records_for_author
    # Find or create the author by name
    if new_author = Author.find_by_name(author.name)
      self.author = new_author
    else
      self.author.save!
    end
  end
end

It is because, in case of edit, self.author for post will already be an author with id:1, it will go in else, block and will update that author instead of creating new one.

I changed the code(elsif condition) to mitigate this issue:

class Post < ActiveRecord::Base
  belongs_to :author, :autosave => true
  accepts_nested_attributes_for :author

  # If you need to validate the associated record, you can add a method like this:
  #     validate_associated_record_for_author
  def autosave_associated_records_for_author
    # Find or create the author by name
    if new_author = Author.find_by_name(author.name)
      self.author = new_author
    elsif author && author.persisted? && author.changed?
      # New condition: if author is already allocated to post, but is changed, create a new author.
      self.author = Author.new(name: author.name)
    else
      # else create a new author
      self.author.save!
    end
  end
end
kiddorails
  • 12,961
  • 2
  • 32
  • 41
0

@dustin-m's answer was instrumental for me - I am doing something custom with a has_many :through relationship. I have a Topic which has one Trend, which has many children (recursive).

ActiveRecord does not like it when I configure this as a standard has_many :searches, through: trend, source: :children relationship. It retrieves topic.trend and topic.searches but won't do topic.searches.create(name: foo).

So I used the above to construct a custom autosave and am achieving the correct result with accepts_nested_attributes_for :searches, allow_destroy: true

def autosave_associated_records_for_searches
    searches.each do | s |
      if s._destroy
        self.trend.children.delete(s)
      elsif s.new_record?
        self.trend.children << s
      else
        s.save
      end
    end
  end
Goulven
  • 777
  • 9
  • 20
David Hersey
  • 1,049
  • 11
  • 13