Skip to content
This repository
Browse code

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]
  • Loading branch information...
commit 91fd6510563f84ee473bb217bc63ed598abe3f24 1 parent f0b9805
Jon Leighton jonleighton authored
24 activerecord/CHANGELOG
... ... @@ -1,5 +1,29 @@
1 1 *Rails 3.1.0 (unreleased)*
2 2
  3 +* Make has_many :through associations work correctly when you build a record and then save it. This
  4 + requires you to set the :inverse_of option on the source reflection on the join model, like so:
  5 +
  6 + class Post < ActiveRecord::Base
  7 + has_many :taggings
  8 + has_many :tags, :through => :taggings
  9 + end
  10 +
  11 + class Tagging < ActiveRecord::Base
  12 + belongs_to :post
  13 + belongs_to :tag, :inverse_of => :tagging # :inverse_of must be set!
  14 + end
  15 +
  16 + class Tag < ActiveRecord::Base
  17 + has_many :taggings
  18 + has_many :posts, :through => :taggings
  19 + end
  20 +
  21 + post = Post.first
  22 + tag = post.tags.build :name => "ruby"
  23 + tag.save # will save a Taggable linking to the post
  24 +
  25 + [Jon Leighton]
  26 +
3 27 * Support the :dependent option on has_many :through associations. For historical and practical
4 28 reasons, :delete_all is the default deletion strategy employed by association.delete(*records),
5 29 despite the fact that the default strategy is :nullify for regular has_many. Also, this only
38 activerecord/lib/active_record/associations.rb
@@ -523,6 +523,22 @@ def association_instance_set(name, association)
523 523 # @group.avatars << Avatar.new # this would work if User belonged_to Avatar rather than the other way around
524 524 # @group.avatars.delete(@group.avatars.last) # so would this
525 525 #
  526 + # If you are using a +belongs_to+ on the join model, it is a good idea to set the
  527 + # <tt>:inverse_of</tt> option on the +belongs_to+, which will mean that the following example
  528 + # works correctly (where <tt>tags</tt> is a +has_many+ <tt>:through</tt> association):
  529 + #
  530 + # @post = Post.first
  531 + # @tag = @post.tags.build :name => "ruby"
  532 + # @tag.save
  533 + #
  534 + # The last line ought to save the through record (a <tt>Taggable</tt>). This will only work if the
  535 + # <tt>:inverse_of</tt> is set:
  536 + #
  537 + # class Taggable < ActiveRecord::Base
  538 + # belongs_to :post
  539 + # belongs_to :tag, :inverse_of => :taggings
  540 + # end
  541 + #
526 542 # === Polymorphic Associations
527 543 #
528 544 # Polymorphic associations on models are not restricted on what types of models they
@@ -1043,13 +1059,21 @@ module ClassMethods
1043 1059 # [:as]
1044 1060 # Specifies a polymorphic interface (See <tt>belongs_to</tt>).
1045 1061 # [:through]
1046   - # Specifies a join model through which to perform the query. Options for <tt>:class_name</tt>
1047   - # and <tt>:foreign_key</tt> are ignored, as the association uses the source reflection. You
1048   - # can only use a <tt>:through</tt> query through a <tt>belongs_to</tt>, <tt>has_one</tt>
1049   - # or <tt>has_many</tt> association on the join model. The collection of join models
1050   - # can be managed via the collection API. For example, new join models are created for
1051   - # newly associated objects, and if some are gone their rows are deleted (directly,
1052   - # no destroy callbacks are triggered).
  1062 + # Specifies a join model through which to perform the query. Options for <tt>:class_name</tt>,
  1063 + # <tt>:primary_key</tt> and <tt>:foreign_key</tt> are ignored, as the association uses the
  1064 + # source reflection. You can only use a <tt>:through</tt> query through a <tt>belongs_to</tt>,
  1065 + # <tt>has_one</tt> or <tt>has_many</tt> association on the join model.
  1066 + #
  1067 + # If the association on the join model is a +belongs_to+, the collection can be modified
  1068 + # and the records on the <tt>:through</tt> model will be automatically created and removed
  1069 + # as appropriate. Otherwise, the collection is read-only, so you should manipulate the
  1070 + # <tt>:through</tt> association directly.
  1071 + #
  1072 + # If you are going to modify the association (rather than just read from it), then it is
  1073 + # a good idea to set the <tt>:inverse_of</tt> option on the source association on the
  1074 + # join model. This allows associated records to be built which will automatically create
  1075 + # the appropriate join model records when they are saved. (See the 'Association Join Models'
  1076 + # section above.)
1053 1077 # [:source]
1054 1078 # Specifies the source association name used by <tt>has_many :through</tt> queries.
1055 1079 # Only use it if the name cannot be inferred from the association.
50 activerecord/lib/active_record/associations/has_many_through_association.rb
@@ -37,16 +37,44 @@ def <<(*records)
37 37
38 38 def insert_record(record, validate = true)
39 39 return if record.new_record? && !record.save(:validate => validate)
40   -
41   - through_association = @owner.send(@reflection.through_reflection.name)
42   - through_association.create!(construct_join_attributes(record))
43   -
  40 + through_record(record).save!
44 41 update_counter(1)
45 42 record
46 43 end
47 44
48 45 private
49 46
  47 + def through_record(record)
  48 + through_association = @owner.send(:association_proxy, @reflection.through_reflection.name)
  49 + attributes = construct_join_attributes(record)
  50 +
  51 + through_record = Array.wrap(through_association.target).find { |candidate|
  52 + candidate.attributes.slice(*attributes.keys) == attributes
  53 + }
  54 +
  55 + unless through_record
  56 + through_record = through_association.build(attributes)
  57 + through_record.send("#{@reflection.source_reflection.name}=", record)
  58 + end
  59 +
  60 + through_record
  61 + end
  62 +
  63 + def build_record(attributes)
  64 + record = super(attributes)
  65 +
  66 + inverse = @reflection.source_reflection.inverse_of
  67 + if inverse
  68 + if inverse.macro == :has_many
  69 + record.send(inverse.name) << through_record(record)
  70 + elsif inverse.macro == :has_one
  71 + record.send("#{inverse.name}=", through_record(record))
  72 + end
  73 + end
  74 +
  75 + record
  76 + end
  77 +
50 78 def target_reflection_has_associated_record?
51 79 if @reflection.through_reflection.macro == :belongs_to && @owner[@reflection.through_reflection.foreign_key].blank?
52 80 false
@@ -79,6 +107,8 @@ def delete_records(records, method)
79 107 count = scope.delete_all
80 108 end
81 109
  110 + delete_through_records(through, records)
  111 +
82 112 if @reflection.through_reflection.macro == :has_many && update_through_counter?(method)
83 113 update_counter(-count, @reflection.through_reflection)
84 114 end
@@ -86,6 +116,18 @@ def delete_records(records, method)
86 116 update_counter(-count)
87 117 end
88 118
  119 + def delete_through_records(through, records)
  120 + if @reflection.through_reflection.macro == :has_many
  121 + records.each do |record|
  122 + through.target.delete(through_record(record))
  123 + end
  124 + else
  125 + records.each do |record|
  126 + through.target = nil if through.target == through_record(record)
  127 + end
  128 + end
  129 + end
  130 +
89 131 def find_target
90 132 return [] unless target_reflection_has_associated_record?
91 133 scoped.all
18 activerecord/test/cases/associations/has_many_through_associations_test.rb
@@ -113,6 +113,24 @@ def test_associate_new_by_building
113 113 assert posts(:thinking).reload.people(true).collect(&:first_name).include?("Ted")
114 114 end
115 115
  116 + def test_build_then_save_with_has_many_inverse
  117 + post = posts(:thinking)
  118 + person = post.people.build(:first_name => "Bob")
  119 + person.save
  120 + post.reload
  121 +
  122 + assert post.people.include?(person)
  123 + end
  124 +
  125 + def test_build_then_save_with_has_one_inverse
  126 + post = posts(:thinking)
  127 + person = post.single_people.build(:first_name => "Bob")
  128 + person.save
  129 + post.reload
  130 +
  131 + assert post.single_people.include?(person)
  132 + end
  133 +
116 134 def test_delete_association
117 135 assert_queries(2){posts(:welcome);people(:michael); }
118 136
2  activerecord/test/models/person.rb
... ... @@ -1,5 +1,7 @@
1 1 class Person < ActiveRecord::Base
2 2 has_many :readers
  3 + has_one :reader
  4 +
3 5 has_many :posts, :through => :readers
4 6 has_many :posts_with_no_comments, :through => :readers, :source => :post, :include => :comments, :conditions => 'comments.id is null'
5 7
1  activerecord/test/models/post.rb
@@ -95,6 +95,7 @@ def add_joins_and_select
95 95 has_many :readers
96 96 has_many :readers_with_person, :include => :person, :class_name => "Reader"
97 97 has_many :people, :through => :readers
  98 + has_many :single_people, :through => :readers
98 99 has_many :people_with_callbacks, :source=>:person, :through => :readers,
99 100 :before_add => lambda {|owner, reader| log(:added, :before, reader.first_name) },
100 101 :after_add => lambda {|owner, reader| log(:added, :after, reader.first_name) },
3  activerecord/test/models/reader.rb
... ... @@ -1,4 +1,5 @@
1 1 class Reader < ActiveRecord::Base
2 2 belongs_to :post
3   - belongs_to :person
  3 + belongs_to :person, :inverse_of => :readers
  4 + belongs_to :single_person, :class_name => 'Person', :foreign_key => :person_id, :inverse_of => :reader
4 5 end

4 comments on commit 91fd651

Geoff Garside

Is there any chance this commit can be included in next v3.0.x release?

philevans

I agree - I really could do with having this included in the next release. Pretty please with sugar on?

Jon Leighton
Owner

Hiya, the associations code has been pretty much rewritten in master, so it would be non-trivial to port this to 3-0-stable. Also, we don't add new features to the stable branch, only bug fixes. So the answer is no, sorry.

Geoff Garside

Ta, heres looking forward to 3.1 then

Please sign in to comment.
Something went wrong with that request. Please try again.