From 26d19b4661f3d89a075b5f05d926c578ff0c730f Mon Sep 17 00:00:00 2001
From: wangjohn <wangjohn@mit.edu>
Date: Fri, 1 Mar 2013 16:49:33 -0500
Subject: Created a method to automatically find inverse associations and cache
 the results. Added tests to check to make sure that inverse associations are
 automatically found when has_many, has_one, or belongs_to associations are
 defined.

---
 .../active_record/associations/builder/has_many.rb |   2 +-
 .../associations/builder/singular_association.rb   |   2 +-
 .../lib/active_record/nested_attributes.rb         |   5 +
 activerecord/lib/active_record/reflection.rb       | 101 ++++++++++++++++++++-
 .../associations/inverse_associations_test.rb      |  82 +++++++++++++++++
 activerecord/test/cases/nested_attributes_test.rb  |   2 +
 activerecord/test/models/club.rb                   |   2 +-
 activerecord/test/models/interest.rb               |   2 +-
 activerecord/test/models/man.rb                    |   2 +-
 activerecord/test/models/member.rb                 |   2 +-
 activerecord/test/models/member_detail.rb          |   2 +-
 11 files changed, 194 insertions(+), 10 deletions(-)

diff --git a/activerecord/lib/active_record/associations/builder/has_many.rb b/activerecord/lib/active_record/associations/builder/has_many.rb
index 0d1bdd21ee..429def5455 100644
--- a/activerecord/lib/active_record/associations/builder/has_many.rb
+++ b/activerecord/lib/active_record/associations/builder/has_many.rb
@@ -5,7 +5,7 @@ module ActiveRecord::Associations::Builder
     end
 
     def valid_options
-      super + [:primary_key, :dependent, :as, :through, :source, :source_type, :inverse_of, :counter_cache]
+      super + [:primary_key, :dependent, :as, :through, :source, :source_type, :inverse_of, :automatic_inverse_of, :counter_cache]
     end
 
     def valid_dependent_options
diff --git a/activerecord/lib/active_record/associations/builder/singular_association.rb b/activerecord/lib/active_record/associations/builder/singular_association.rb
index 6a5830e57f..f06426a09d 100644
--- a/activerecord/lib/active_record/associations/builder/singular_association.rb
+++ b/activerecord/lib/active_record/associations/builder/singular_association.rb
@@ -1,7 +1,7 @@
 module ActiveRecord::Associations::Builder
   class SingularAssociation < Association #:nodoc:
     def valid_options
-      super + [:remote, :dependent, :counter_cache, :primary_key, :inverse_of]
+      super + [:remote, :dependent, :counter_cache, :primary_key, :inverse_of, :automatic_inverse_of]
     end
 
     def constructable?
diff --git a/activerecord/lib/active_record/nested_attributes.rb b/activerecord/lib/active_record/nested_attributes.rb
index 021832de46..8bdaeef924 100644
--- a/activerecord/lib/active_record/nested_attributes.rb
+++ b/activerecord/lib/active_record/nested_attributes.rb
@@ -305,6 +305,11 @@ module ActiveRecord
             reflection.options[:autosave] = true
             add_autosave_association_callbacks(reflection)
 
+            # Clear cached values of any inverse associations found in the
+            # reflection and prevent the reflection from finding inverses
+            # automatically in the future.
+            reflection.remove_automatic_inverse_of!
+
             nested_attributes_options = self.nested_attributes_options.dup
             nested_attributes_options[association_name.to_sym] = options
             self.nested_attributes_options = nested_attributes_options
diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb
index 60eda96f08..0ba860a186 100644
--- a/activerecord/lib/active_record/reflection.rb
+++ b/activerecord/lib/active_record/reflection.rb
@@ -181,6 +181,7 @@ module ActiveRecord
       def initialize(*args)
         super
         @collection = [:has_many, :has_and_belongs_to_many].include?(macro)
+        @automatic_inverse_of = nil
       end
 
       # Returns a new, unsaved instance of the associated class. +attributes+ will
@@ -289,15 +290,32 @@ module ActiveRecord
       alias :source_macro :macro
 
       def has_inverse?
-        @options[:inverse_of]
+        @options[:inverse_of] || find_inverse_of_automatically
       end
 
       def inverse_of
-        if has_inverse?
-          @inverse_of ||= klass.reflect_on_association(options[:inverse_of])
+        @inverse_of ||= if options[:inverse_of]
+          klass.reflect_on_association(options[:inverse_of])
+        else
+          find_inverse_of_automatically
         end
       end
 
+      # Clears the cached value of +@inverse_of+ on this object. This will
+      # not remove the :inverse_of option however, so future calls on the
+      # +inverse_of+ will have to recompute the inverse.
+      def clear_inverse_of_cache!
+        @inverse_of = nil
+      end
+
+      # Removes the cached inverse association that was found automatically
+      # and prevents this object from finding the inverse association
+      # automatically in the future.
+      def remove_automatic_inverse_of!
+        @automatic_inverse_of = nil
+        options[:automatic_inverse_of] = false
+      end
+
       def polymorphic_inverse_of(associated_class)
         if has_inverse?
           if inverse_relationship = associated_class.reflect_on_association(options[:inverse_of])
@@ -366,7 +384,84 @@ module ActiveRecord
         options.key? :polymorphic
       end
 
+      VALID_AUTOMATIC_INVERSE_MACROS = [:has_many, :has_one, :belongs_to]
+      INVALID_AUTOMATIC_INVERSE_OPTIONS = [:conditions, :through, :polymorphic, :foreign_key]
+
       private
+        # Attempts to find the inverse association automatically.
+        # If it cannot find a suitable inverse association, it returns
+        # nil.
+        def find_inverse_of_automatically
+          if @automatic_inverse_of == false
+            nil
+          elsif @automatic_inverse_of.nil?
+            set_automatic_inverse_of
+          else
+            klass.reflect_on_association(@automatic_inverse_of)
+          end
+        end
+
+        # Sets the +@automatic_inverse_of+ instance variable, and returns
+        # either nil or the inverse association that it finds.
+        #
+        # This method caches the inverse association that is found so that
+        # future calls to +find_inverse_of_automatically+ have much less
+        # overhead.
+        def set_automatic_inverse_of
+          if can_find_inverse_of_automatically?(self)
+            inverse_name = active_record.name.downcase.to_sym
+
+            begin
+              reflection = klass.reflect_on_association(inverse_name)
+            rescue NameError
+              # Give up: we couldn't compute the klass type so we won't be able
+              # to find any associations either.
+              reflection = false
+            end
+
+            if valid_inverse_reflection?(reflection)
+              @automatic_inverse_of = inverse_name
+              reflection
+            else
+              @automatic_inverse_of = false
+              nil
+            end
+          else
+            @automatic_inverse_of = false
+            nil
+          end
+        end
+
+        # Checks if the inverse reflection that is returned from the
+        # +set_automatic_inverse_of+ method is a valid reflection. We must
+        # make sure that the reflection's active_record name matches up
+        # with the current reflection's klass name.
+        #
+        # Note: klass will always be valid because when there's a NameError
+        # from calling +klass+, +reflection+ will already be set to false.
+        def valid_inverse_reflection?(reflection)
+          reflection &&
+            klass.name == reflection.active_record.try(:name) &&
+            klass.primary_key == reflection.active_record_primary_key &&
+            can_find_inverse_of_automatically?(reflection)
+        end
+
+        # Checks to see if the reflection doesn't have any options that prevent
+        # us from being able to guess the inverse automatically. First, the
+        # +automatic_inverse_of+ option cannot be set to false. Second, we must
+        # have :has_many, :has_one, :belongs_to associations. Third, we must
+        # not have options such as :class_name or :polymorphic which prevent us
+        # from correctly guessing the inverse association.
+        #
+        # Anything with a scope can additionally ruin our attempt at finding an
+        # inverse, so we exclude reflections with scopes.
+        def can_find_inverse_of_automatically?(reflection)
+          reflection.options[:automatic_inverse_of] != false &&
+            VALID_AUTOMATIC_INVERSE_MACROS.include?(reflection.macro) &&
+            !INVALID_AUTOMATIC_INVERSE_OPTIONS.any? { |opt| reflection.options[opt] } &&
+            !reflection.scope
+        end
+
         def derive_class_name
           class_name = name.to_s.camelize
           class_name = class_name.singularize if collection?
diff --git a/activerecord/test/cases/associations/inverse_associations_test.rb b/activerecord/test/cases/associations/inverse_associations_test.rb
index ec128acf28..b1f0be3204 100644
--- a/activerecord/test/cases/associations/inverse_associations_test.rb
+++ b/activerecord/test/cases/associations/inverse_associations_test.rb
@@ -5,6 +5,88 @@ require 'models/interest'
 require 'models/zine'
 require 'models/club'
 require 'models/sponsor'
+require 'models/rating'
+require 'models/comment'
+require 'models/car'
+require 'models/bulb'
+
+class AutomaticInverseFindingTests < ActiveRecord::TestCase
+  fixtures :ratings, :comments, :cars
+
+  def test_has_one_and_belongs_to_should_find_inverse_automatically
+    car_reflection = Car.reflect_on_association(:bulb)
+    bulb_reflection = Bulb.reflect_on_association(:car)
+
+    assert_respond_to car_reflection, :has_inverse?
+    assert car_reflection.has_inverse?, "The Car reflection should have an inverse"
+    assert_equal bulb_reflection, car_reflection.inverse_of, "The Car reflection's inverse should be the Bulb reflection"
+
+    assert_respond_to bulb_reflection, :has_inverse?
+    assert bulb_reflection.has_inverse?, "The Bulb reflection should have an inverse"
+    assert_equal car_reflection, bulb_reflection.inverse_of, "The Bulb reflection's inverse should be the Car reflection"
+  end
+
+  def test_has_many_and_belongs_to_should_find_inverse_automatically
+    comment_reflection = Comment.reflect_on_association(:ratings)
+    rating_reflection = Rating.reflect_on_association(:comment)
+
+    assert_respond_to comment_reflection, :has_inverse?
+    assert comment_reflection.has_inverse?, "The Comment reflection should have an inverse"
+    assert_equal rating_reflection, comment_reflection.inverse_of, "The Comment reflection's inverse should be the Rating reflection"
+  end
+
+  def test_has_one_and_belongs_to_automatic_inverse_shares_objects
+    car = Car.first
+    bulb = Bulb.create!(car: car)
+
+    assert_equal car.bulb, bulb, "The Car's bulb should be the original bulb"
+
+    car.bulb.color = "Blue"
+    assert_equal car.bulb.color, bulb.color, "Changing the bulb's color on the car association should change the bulb's color"
+
+    bulb.color = "Red"
+    assert_equal bulb.color, car.bulb.color, "Changing the bulb's color should change the bulb's color on the car association"
+  end
+
+  def test_has_many_and_belongs_to_automatic_inverse_shares_objects_on_rating
+    comment = Comment.first
+    rating = Rating.create!(comment: comment)
+
+    assert_equal rating.comment, comment, "The Rating's comment should be the original Comment"
+
+    rating.comment.body = "Brogramming is the act of programming, like a bro."
+    assert_equal rating.comment.body, comment.body, "Changing the Comment's body on the association should change the original Comment's body"
+
+    comment.body = "Broseiden is the king of the sea of bros."
+    assert_equal comment.body, rating.comment.body, "Changing the original Comment's body should change the Comment's body on the association"
+  end
+
+  def test_has_many_and_belongs_to_automatic_inverse_shares_objects_on_comment
+    rating = Rating.create!
+    comment = Comment.first
+    rating.comment = comment
+
+    assert_equal rating.comment, comment, "The Rating's comment should be the original Comment"
+
+    rating.comment.body = "Brogramming is the act of programming, like a bro."
+    assert_equal rating.comment.body, comment.body, "Changing the Comment's body on the association should change the original Comment's body"
+
+    comment.body = "Broseiden is the king of the sea of bros."
+    assert_equal comment.body, rating.comment.body, "Changing the original Comment's body should change the Comment's body on the association"
+  end
+
+  def test_polymorphic_and_has_many_through_relationships_should_not_have_inverses
+    sponsor_reflection = Sponsor.reflect_on_association(:sponsorable)
+
+    assert_respond_to sponsor_reflection, :has_inverse?
+    assert !sponsor_reflection.has_inverse?, "A polymorphic association should not find an inverse automatically"
+
+    club_reflection = Club.reflect_on_association(:members)
+
+    assert_respond_to club_reflection, :has_inverse?
+    assert !club_reflection.has_inverse?, "A has_many_through association should not find an inverse automatically"
+  end
+end
 
 class InverseAssociationTests < ActiveRecord::TestCase
   def test_should_allow_for_inverse_of_options_in_associations
diff --git a/activerecord/test/cases/nested_attributes_test.rb b/activerecord/test/cases/nested_attributes_test.rb
index 165b7454b7..6fe81e0d96 100644
--- a/activerecord/test/cases/nested_attributes_test.rb
+++ b/activerecord/test/cases/nested_attributes_test.rb
@@ -800,7 +800,9 @@ module NestedAttributesOnACollectionAssociationTests
   def test_validate_presence_of_parent_fails_without_inverse_of
     Man.accepts_nested_attributes_for(:interests)
     Man.reflect_on_association(:interests).options.delete(:inverse_of)
+    Man.reflect_on_association(:interests).clear_inverse_of_cache!
     Interest.reflect_on_association(:man).options.delete(:inverse_of)
+    Interest.reflect_on_association(:man).clear_inverse_of_cache!
 
     repair_validations(Interest) do
       Interest.validates_presence_of(:man)
diff --git a/activerecord/test/models/club.rb b/activerecord/test/models/club.rb
index 24a65b0f2f..7d7c205041 100644
--- a/activerecord/test/models/club.rb
+++ b/activerecord/test/models/club.rb
@@ -1,6 +1,6 @@
 class Club < ActiveRecord::Base
   has_one :membership
-  has_many :memberships
+  has_many :memberships, :automatic_inverse_of => false
   has_many :members, :through => :memberships
   has_many :current_memberships
   has_one :sponsor
diff --git a/activerecord/test/models/interest.rb b/activerecord/test/models/interest.rb
index d5d9226204..f772bb1c7f 100644
--- a/activerecord/test/models/interest.rb
+++ b/activerecord/test/models/interest.rb
@@ -1,5 +1,5 @@
 class Interest < ActiveRecord::Base
-  belongs_to :man, :inverse_of => :interests
+  belongs_to :man, :inverse_of => :interests, :automatic_inverse_of => false
   belongs_to :polymorphic_man, :polymorphic => true, :inverse_of => :polymorphic_interests
   belongs_to :zine, :inverse_of => :interests
 end
diff --git a/activerecord/test/models/man.rb b/activerecord/test/models/man.rb
index 4bff92dc98..49f002aa9a 100644
--- a/activerecord/test/models/man.rb
+++ b/activerecord/test/models/man.rb
@@ -1,7 +1,7 @@
 class Man < ActiveRecord::Base
   has_one :face, :inverse_of => :man
   has_one :polymorphic_face, :class_name => 'Face', :as => :polymorphic_man, :inverse_of => :polymorphic_man
-  has_many :interests, :inverse_of => :man
+  has_many :interests, :inverse_of => :man, :automatic_inverse_of => false
   has_many :polymorphic_interests, :class_name => 'Interest', :as => :polymorphic_man, :inverse_of => :polymorphic_man
   # These are "broken" inverse_of associations for the purposes of testing
   has_one :dirty_face, :class_name => 'Face', :inverse_of => :dirty_man
diff --git a/activerecord/test/models/member.rb b/activerecord/test/models/member.rb
index 1134b09d8b..b81304b8e0 100644
--- a/activerecord/test/models/member.rb
+++ b/activerecord/test/models/member.rb
@@ -9,7 +9,7 @@ class Member < ActiveRecord::Base
   has_one :hairy_club, -> { where :clubs => {:name => "Moustache and Eyebrow Fancier Club"} }, :through => :membership, :source => :club
   has_one :sponsor, :as => :sponsorable
   has_one :sponsor_club, :through => :sponsor
-  has_one :member_detail
+  has_one :member_detail, :automatic_inverse_of => false
   has_one :organization, :through => :member_detail
   belongs_to :member_type
 
diff --git a/activerecord/test/models/member_detail.rb b/activerecord/test/models/member_detail.rb
index fe619f8732..a256c73c7e 100644
--- a/activerecord/test/models/member_detail.rb
+++ b/activerecord/test/models/member_detail.rb
@@ -1,5 +1,5 @@
 class MemberDetail < ActiveRecord::Base
-  belongs_to :member
+  belongs_to :member, :automatic_inverse_of => false
   belongs_to :organization
   has_one :member_type, :through => :member
 
-- 
cgit v1.2.3