Skip to content

Commit

Permalink
Improved strategy for updating a belongs_to association when the fore…
Browse files Browse the repository at this point in the history
…ign key changes. Rather than resetting each affected association when the foreign key changes, we should lazily check for 'staleness' (where fk does not match target id) when the association is accessed.
  • Loading branch information
jonleighton authored and tenderlove committed Dec 23, 2010
1 parent 3f17ed4 commit 2d9626f
Show file tree
Hide file tree
Showing 7 changed files with 37 additions and 45 deletions.
43 changes: 1 addition & 42 deletions activerecord/lib/active_record/associations.rb
Expand Up @@ -1229,14 +1229,12 @@ def belongs_to(association_id, options = {})


if reflection.options[:polymorphic] if reflection.options[:polymorphic]
association_accessor_methods(reflection, BelongsToPolymorphicAssociation) association_accessor_methods(reflection, BelongsToPolymorphicAssociation)
association_foreign_type_setter_method(reflection)
else else
association_accessor_methods(reflection, BelongsToAssociation) association_accessor_methods(reflection, BelongsToAssociation)
association_constructor_method(:build, reflection, BelongsToAssociation) association_constructor_method(:build, reflection, BelongsToAssociation)
association_constructor_method(:create, reflection, BelongsToAssociation) association_constructor_method(:create, reflection, BelongsToAssociation)
end end


association_foreign_key_setter_method(reflection)
add_counter_cache_callbacks(reflection) if options[:counter_cache] add_counter_cache_callbacks(reflection) if options[:counter_cache]
add_touch_callbacks(reflection, options[:touch]) if options[:touch] add_touch_callbacks(reflection, options[:touch]) if options[:touch]


Expand Down Expand Up @@ -1456,7 +1454,7 @@ def association_accessor_methods(reflection, association_proxy_class)
force_reload = params.first unless params.empty? force_reload = params.first unless params.empty?
association = association_instance_get(reflection.name) association = association_instance_get(reflection.name)


if association.nil? || force_reload if association.nil? || force_reload || association.stale_target?
association = association_proxy_class.new(self, reflection) association = association_proxy_class.new(self, reflection)
retval = force_reload ? reflection.klass.uncached { association.reload } : association.reload retval = force_reload ? reflection.klass.uncached { association.reload } : association.reload
if retval.nil? and association_proxy_class == BelongsToAssociation if retval.nil? and association_proxy_class == BelongsToAssociation
Expand Down Expand Up @@ -1556,45 +1554,6 @@ def association_constructor_method(constructor, reflection, association_proxy_cl
end end
end end


def association_foreign_key_setter_method(reflection)
setters = reflect_on_all_associations(:belongs_to).map do |belongs_to_reflection|
if belongs_to_reflection.primary_key_name == reflection.primary_key_name
"association_instance_set(:#{belongs_to_reflection.name}, nil);"
end
end.compact.join

if method_defined?(:"#{reflection.primary_key_name}=")
undef_method :"#{reflection.primary_key_name}="
end

class_eval <<-FILE, __FILE__, __LINE__ + 1
def #{reflection.primary_key_name}=(new_id)
write_attribute :#{reflection.primary_key_name}, new_id
if #{reflection.primary_key_name}_changed?
#{ setters }
end
end
FILE
end

def association_foreign_type_setter_method(reflection)
setters = reflect_on_all_associations(:belongs_to).map do |belongs_to_reflection|
if belongs_to_reflection.options[:foreign_type] == reflection.options[:foreign_type]
"association_instance_set(:#{belongs_to_reflection.name}, nil);"
end
end.compact.join

field = reflection.options[:foreign_type]
class_eval <<-FILE, __FILE__, __LINE__ + 1
def #{field}=(new_id)
write_attribute :#{field}, new_id
if #{field}_changed?
#{ setters }
end
end
FILE
end

def add_counter_cache_callbacks(reflection) def add_counter_cache_callbacks(reflection)
cache_column = reflection.counter_cache_column cache_column = reflection.counter_cache_column


Expand Down
10 changes: 10 additions & 0 deletions activerecord/lib/active_record/associations/association_proxy.rb
Expand Up @@ -130,6 +130,16 @@ def loaded
@loaded = true @loaded = true
end end


# The target is stale if the target no longer points to the record(s) that the
# relevant foreign_key(s) refers to. If stale, the association accessor method
# on the owner will reload the target. It's up to subclasses to implement this
# method if relevant.
#
# Note that if the target has not been loaded, it is not considered stale.
def stale_target?
false
end

# Returns the target of this proxy, same as +proxy_target+. # Returns the target of this proxy, same as +proxy_target+.
def target def target
@target @target
Expand Down
Expand Up @@ -42,6 +42,14 @@ def updated?
@updated @updated
end end


def stale_target?
if @target && @target.persisted?
@target.send(@reflection.association_primary_key).to_i != @owner.send(@reflection.primary_key_name).to_i
else
false
end
end

private private
def find_target def find_target
find_method = if @reflection.options[:primary_key] find_method = if @reflection.options[:primary_key]
Expand Down
Expand Up @@ -23,6 +23,15 @@ def updated?
@updated @updated
end end


def stale_target?
if @target && @target.persisted?
@target.send(@reflection.association_primary_key).to_i != @owner.send(@reflection.primary_key_name).to_i ||
@target.class.base_class.name.to_s != @owner.send(@reflection.options[:foreign_type]).to_s
else
false
end
end

private private


# NOTE - for now, we're only supporting inverse setting from belongs_to back onto # NOTE - for now, we're only supporting inverse setting from belongs_to back onto
Expand Down
5 changes: 4 additions & 1 deletion activerecord/lib/active_record/reflection.rb
Expand Up @@ -209,7 +209,10 @@ def association_foreign_key
end end


def association_primary_key def association_primary_key
@association_primary_key ||= options[:primary_key] || klass.primary_key @association_primary_key ||=
options[:primary_key] ||
!options[:polymorphic] && klass.primary_key ||
'id'
end end


def active_record_primary_key def active_record_primary_key
Expand Down
5 changes: 3 additions & 2 deletions activerecord/test/cases/nested_attributes_test.rb
Expand Up @@ -298,7 +298,7 @@ def test_should_accept_update_only_option


def test_should_create_new_model_when_nothing_is_there_and_update_only_is_true def test_should_create_new_model_when_nothing_is_there_and_update_only_is_true
@ship.delete @ship.delete

@pirate.reload.update_attributes(:update_only_ship_attributes => { :name => 'Mayflower' }) @pirate.reload.update_attributes(:update_only_ship_attributes => { :name => 'Mayflower' })


assert_not_nil @pirate.ship assert_not_nil @pirate.ship
Expand Down Expand Up @@ -411,6 +411,7 @@ def test_should_take_a_hash_with_string_keys_and_update_the_associated_model


def test_should_modify_an_existing_record_if_there_is_a_matching_composite_id def test_should_modify_an_existing_record_if_there_is_a_matching_composite_id
@pirate.stubs(:id).returns('ABC1X') @pirate.stubs(:id).returns('ABC1X')
@ship.stubs(:pirate_id).returns(@pirate.id) # prevents pirate from being reloaded due to non-matching foreign key
@ship.pirate_attributes = { :id => @pirate.id, :catchphrase => 'Arr' } @ship.pirate_attributes = { :id => @pirate.id, :catchphrase => 'Arr' }


assert_equal 'Arr', @ship.pirate.catchphrase assert_equal 'Arr', @ship.pirate.catchphrase
Expand Down Expand Up @@ -451,7 +452,7 @@ def test_should_work_with_update_attributes_as_well


def test_should_not_destroy_the_associated_model_until_the_parent_is_saved def test_should_not_destroy_the_associated_model_until_the_parent_is_saved
pirate = @ship.pirate pirate = @ship.pirate

@ship.attributes = { :pirate_attributes => { :id => pirate.id, '_destroy' => true } } @ship.attributes = { :pirate_attributes => { :id => pirate.id, '_destroy' => true } }
assert_nothing_raised(ActiveRecord::RecordNotFound) { Pirate.find(pirate.id) } assert_nothing_raised(ActiveRecord::RecordNotFound) { Pirate.find(pirate.id) }
@ship.save @ship.save
Expand Down
2 changes: 2 additions & 0 deletions activerecord/test/cases/reflection_test.rb
Expand Up @@ -7,6 +7,7 @@
require 'models/ship' require 'models/ship'
require 'models/pirate' require 'models/pirate'
require 'models/price_estimate' require 'models/price_estimate'
require 'models/tagging'


class ReflectionTest < ActiveRecord::TestCase class ReflectionTest < ActiveRecord::TestCase
include ActiveRecord::Reflection include ActiveRecord::Reflection
Expand Down Expand Up @@ -194,6 +195,7 @@ def test_has_many_through_reflection
def test_association_primary_key def test_association_primary_key
assert_equal "id", Author.reflect_on_association(:posts).association_primary_key.to_s assert_equal "id", Author.reflect_on_association(:posts).association_primary_key.to_s
assert_equal "name", Author.reflect_on_association(:essay).association_primary_key.to_s assert_equal "name", Author.reflect_on_association(:essay).association_primary_key.to_s
assert_equal "id", Tagging.reflect_on_association(:taggable).association_primary_key.to_s
end end


def test_active_record_primary_key def test_active_record_primary_key
Expand Down

0 comments on commit 2d9626f

Please sign in to comment.