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]

This commit is contained in:
Jon Leighton 2011-02-14 23:14:42 +00:00
parent f0b9805029
commit 91fd651056
7 changed files with 124 additions and 12 deletions

@ -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

@ -523,6 +523,22 @@ def association_instance_set(name, association)
# @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
# <tt>:inverse_of</tt> option on the +belongs_to+, which will mean that the following example
# works correctly (where <tt>tags</tt> is a +has_many+ <tt>:through</tt> association):
#
# @post = Post.first
# @tag = @post.tags.build :name => "ruby"
# @tag.save
#
# The last line ought to save the through record (a <tt>Taggable</tt>). This will only work if the
# <tt>:inverse_of</tt> 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 ClassMethods
# [:as]
# Specifies a polymorphic interface (See <tt>belongs_to</tt>).
# [:through]
# Specifies a join model through which to perform the query. Options for <tt>:class_name</tt>
# and <tt>:foreign_key</tt> are ignored, as the association uses the source reflection. You
# can only use a <tt>:through</tt> query through a <tt>belongs_to</tt>, <tt>has_one</tt>
# or <tt>has_many</tt> 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 <tt>:class_name</tt>,
# <tt>:primary_key</tt> and <tt>:foreign_key</tt> are ignored, as the association uses the
# source reflection. You can only use a <tt>:through</tt> query through a <tt>belongs_to</tt>,
# <tt>has_one</tt> or <tt>has_many</tt> 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 <tt>:through</tt> model will be automatically created and removed
# as appropriate. Otherwise, the collection is read-only, so you should manipulate the
# <tt>:through</tt> 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 <tt>:inverse_of</tt> 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 <tt>has_many :through</tt> queries.
# Only use it if the name cannot be inferred from the association.

@ -37,16 +37,44 @@ def <<(*records)
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 @@ def delete_records(records, method)
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 @@ def delete_records(records, method)
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

@ -113,6 +113,24 @@ def test_associate_new_by_building
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); }

@ -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'

@ -95,6 +95,7 @@ def add_joins_and_select
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) },

@ -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