From 91fd6510563f84ee473bb217bc63ed598abe3f24 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Mon, 14 Feb 2011 23:14:42 +0000 Subject: Allow building and then later saving has_many :through records, such that the join record is automatically saved too. This requires the :inverse_of option to be set on the source association in the join model. See the CHANGELOG for details. [#4329 state:resolved] --- activerecord/CHANGELOG | 24 +++++++++++ activerecord/lib/active_record/associations.rb | 38 +++++++++++++--- .../associations/has_many_through_association.rb | 50 ++++++++++++++++++++-- .../has_many_through_associations_test.rb | 18 ++++++++ activerecord/test/models/person.rb | 2 + activerecord/test/models/post.rb | 1 + activerecord/test/models/reader.rb | 3 +- 7 files changed, 124 insertions(+), 12 deletions(-) (limited to 'activerecord') diff --git a/activerecord/CHANGELOG b/activerecord/CHANGELOG index 72bbeeec61..e434372fb7 100644 --- a/activerecord/CHANGELOG +++ b/activerecord/CHANGELOG @@ -1,5 +1,29 @@ *Rails 3.1.0 (unreleased)* +* Make has_many :through associations work correctly when you build a record and then save it. This + requires you to set the :inverse_of option on the source reflection on the join model, like so: + + class Post < ActiveRecord::Base + has_many :taggings + has_many :tags, :through => :taggings + end + + class Tagging < ActiveRecord::Base + belongs_to :post + belongs_to :tag, :inverse_of => :tagging # :inverse_of must be set! + end + + class Tag < ActiveRecord::Base + has_many :taggings + has_many :posts, :through => :taggings + end + + post = Post.first + tag = post.tags.build :name => "ruby" + tag.save # will save a Taggable linking to the post + + [Jon Leighton] + * Support the :dependent option on has_many :through associations. For historical and practical reasons, :delete_all is the default deletion strategy employed by association.delete(*records), despite the fact that the default strategy is :nullify for regular has_many. Also, this only diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 529d8ca2d3..bf0f6dc542 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -523,6 +523,22 @@ module ActiveRecord # @group.avatars << Avatar.new # this would work if User belonged_to Avatar rather than the other way around # @group.avatars.delete(@group.avatars.last) # so would this # + # If you are using a +belongs_to+ on the join model, it is a good idea to set the + # :inverse_of option on the +belongs_to+, which will mean that the following example + # works correctly (where tags is a +has_many+ :through association): + # + # @post = Post.first + # @tag = @post.tags.build :name => "ruby" + # @tag.save + # + # The last line ought to save the through record (a Taggable). This will only work if the + # :inverse_of is set: + # + # class Taggable < ActiveRecord::Base + # belongs_to :post + # belongs_to :tag, :inverse_of => :taggings + # end + # # === Polymorphic Associations # # Polymorphic associations on models are not restricted on what types of models they @@ -1043,13 +1059,21 @@ module ActiveRecord # [:as] # Specifies a polymorphic interface (See belongs_to). # [:through] - # Specifies a join model through which to perform the query. Options for :class_name - # and :foreign_key are ignored, as the association uses the source reflection. You - # can only use a :through query through a belongs_to, has_one - # or has_many association on the join model. The collection of join models - # can be managed via the collection API. For example, new join models are created for - # newly associated objects, and if some are gone their rows are deleted (directly, - # no destroy callbacks are triggered). + # Specifies a join model through which to perform the query. Options for :class_name, + # :primary_key and :foreign_key are ignored, as the association uses the + # source reflection. You can only use a :through query through a belongs_to, + # has_one or has_many association on the join model. + # + # If the association on the join model is a +belongs_to+, the collection can be modified + # and the records on the :through model will be automatically created and removed + # as appropriate. Otherwise, the collection is read-only, so you should manipulate the + # :through association directly. + # + # If you are going to modify the association (rather than just read from it), then it is + # a good idea to set the :inverse_of option on the source association on the + # join model. This allows associated records to be built which will automatically create + # the appropriate join model records when they are saved. (See the 'Association Join Models' + # section above.) # [:source] # Specifies the source association name used by has_many :through queries. # Only use it if the name cannot be inferred from the association. diff --git a/activerecord/lib/active_record/associations/has_many_through_association.rb b/activerecord/lib/active_record/associations/has_many_through_association.rb index 9f74d57c4d..c4d3ef8fef 100644 --- a/activerecord/lib/active_record/associations/has_many_through_association.rb +++ b/activerecord/lib/active_record/associations/has_many_through_association.rb @@ -37,16 +37,44 @@ module ActiveRecord def insert_record(record, validate = true) return if record.new_record? && !record.save(:validate => validate) - - through_association = @owner.send(@reflection.through_reflection.name) - through_association.create!(construct_join_attributes(record)) - + through_record(record).save! update_counter(1) record end private + def through_record(record) + through_association = @owner.send(:association_proxy, @reflection.through_reflection.name) + attributes = construct_join_attributes(record) + + through_record = Array.wrap(through_association.target).find { |candidate| + candidate.attributes.slice(*attributes.keys) == attributes + } + + unless through_record + through_record = through_association.build(attributes) + through_record.send("#{@reflection.source_reflection.name}=", record) + end + + through_record + end + + def build_record(attributes) + record = super(attributes) + + inverse = @reflection.source_reflection.inverse_of + if inverse + if inverse.macro == :has_many + record.send(inverse.name) << through_record(record) + elsif inverse.macro == :has_one + record.send("#{inverse.name}=", through_record(record)) + end + end + + record + end + def target_reflection_has_associated_record? if @reflection.through_reflection.macro == :belongs_to && @owner[@reflection.through_reflection.foreign_key].blank? false @@ -79,6 +107,8 @@ module ActiveRecord count = scope.delete_all end + delete_through_records(through, records) + if @reflection.through_reflection.macro == :has_many && update_through_counter?(method) update_counter(-count, @reflection.through_reflection) end @@ -86,6 +116,18 @@ module ActiveRecord update_counter(-count) end + def delete_through_records(through, records) + if @reflection.through_reflection.macro == :has_many + records.each do |record| + through.target.delete(through_record(record)) + end + else + records.each do |record| + through.target = nil if through.target == through_record(record) + end + end + end + def find_target return [] unless target_reflection_has_associated_record? scoped.all diff --git a/activerecord/test/cases/associations/has_many_through_associations_test.rb b/activerecord/test/cases/associations/has_many_through_associations_test.rb index 7e77539fe7..efdecd4b09 100644 --- a/activerecord/test/cases/associations/has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb @@ -113,6 +113,24 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase assert posts(:thinking).reload.people(true).collect(&:first_name).include?("Ted") end + def test_build_then_save_with_has_many_inverse + post = posts(:thinking) + person = post.people.build(:first_name => "Bob") + person.save + post.reload + + assert post.people.include?(person) + end + + def test_build_then_save_with_has_one_inverse + post = posts(:thinking) + person = post.single_people.build(:first_name => "Bob") + person.save + post.reload + + assert post.single_people.include?(person) + end + def test_delete_association assert_queries(2){posts(:welcome);people(:michael); } diff --git a/activerecord/test/models/person.rb b/activerecord/test/models/person.rb index a18b9e44df..cc3a4f5f9d 100644 --- a/activerecord/test/models/person.rb +++ b/activerecord/test/models/person.rb @@ -1,5 +1,7 @@ class Person < ActiveRecord::Base has_many :readers + has_one :reader + has_many :posts, :through => :readers has_many :posts_with_no_comments, :through => :readers, :source => :post, :include => :comments, :conditions => 'comments.id is null' diff --git a/activerecord/test/models/post.rb b/activerecord/test/models/post.rb index 1bbcab3a39..a342aaf60b 100644 --- a/activerecord/test/models/post.rb +++ b/activerecord/test/models/post.rb @@ -95,6 +95,7 @@ class Post < ActiveRecord::Base has_many :readers has_many :readers_with_person, :include => :person, :class_name => "Reader" has_many :people, :through => :readers + has_many :single_people, :through => :readers has_many :people_with_callbacks, :source=>:person, :through => :readers, :before_add => lambda {|owner, reader| log(:added, :before, reader.first_name) }, :after_add => lambda {|owner, reader| log(:added, :after, reader.first_name) }, diff --git a/activerecord/test/models/reader.rb b/activerecord/test/models/reader.rb index 27527bf566..0207a2bd92 100644 --- a/activerecord/test/models/reader.rb +++ b/activerecord/test/models/reader.rb @@ -1,4 +1,5 @@ class Reader < ActiveRecord::Base belongs_to :post - belongs_to :person + belongs_to :person, :inverse_of => :readers + belongs_to :single_person, :class_name => 'Person', :foreign_key => :person_id, :inverse_of => :reader end -- cgit v1.2.3