diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md
index d7531db6fc..75bdf1f557 100644
--- a/activerecord/CHANGELOG.md
+++ b/activerecord/CHANGELOG.md
@@ -1,3 +1,14 @@
+* Two change tracking methods are added for `belongs_to` associations.
+
+ The `association_changed?` method (assuming an association named `:association`) returns true
+ if a different associated object has been assigned and the foreign key will be updated in the
+ next save.
+
+ The `association_previously_changed?` method returns true if the previous save updated the
+ association to reference a different associated object.
+
+ *George Claghorn*
+
* Add option to disable schema dump per-database
Dumping the schema is on by default for all databases in an application. To turn it off for a
diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb
index 072d3135b9..fdcd7f6cb8 100644
--- a/activerecord/lib/active_record/associations.rb
+++ b/activerecord/lib/active_record/associations.rb
@@ -364,6 +364,8 @@ module ActiveRecord
# create_other(attributes={}) | X | | X
# create_other!(attributes={}) | X | | X
# reload_other | X | X | X
+ # other_changed? | X | X |
+ # other_previously_changed? | X | X |
#
# === Collection associations (one-to-many / many-to-many)
# | | | has_many
@@ -1624,6 +1626,10 @@ module ActiveRecord
# if the record is invalid.
# [reload_association]
# Returns the associated object, forcing a database read.
+ # [association_changed?]
+ # Returns true if a new associate object has been assigned and the next save will update the foreign key.
+ # [association_previously_changed?]
+ # Returns true if the previous save updated the association to reference a new associate object.
#
# === Example
#
@@ -1634,6 +1640,8 @@ module ActiveRecord
# * Post#create_author (similar to post.author = Author.new; post.author.save; post.author)
# * Post#create_author! (similar to post.author = Author.new; post.author.save!; post.author)
# * Post#reload_author
+ # * Post#author_changed?
+ # * Post#author_previously_changed?
# The declaration can also include an +options+ hash to specialize the behavior of the association.
#
# === Scopes
diff --git a/activerecord/lib/active_record/associations/belongs_to_association.rb b/activerecord/lib/active_record/associations/belongs_to_association.rb
index 6aca13fec2..aa31567ba0 100644
--- a/activerecord/lib/active_record/associations/belongs_to_association.rb
+++ b/activerecord/lib/active_record/associations/belongs_to_association.rb
@@ -68,6 +68,14 @@ module ActiveRecord
end
def target_changed?
+ owner.attribute_changed?(reflection.foreign_key) || (!foreign_key_present? && target&.new_record?)
+ end
+
+ def target_previously_changed?
+ owner.attribute_previously_changed?(reflection.foreign_key)
+ end
+
+ def saved_change_to_target?
owner.saved_change_to_attribute?(reflection.foreign_key)
end
diff --git a/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb b/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb
index 15e6ce1e9f..13cab6f56f 100644
--- a/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb
+++ b/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb
@@ -10,6 +10,14 @@ module ActiveRecord
end
def target_changed?
+ super || owner.attribute_changed?(reflection.foreign_type)
+ end
+
+ def target_previously_changed?
+ super || owner.attribute_previously_changed?(reflection.foreign_type)
+ end
+
+ def saved_change_to_target?
super || owner.saved_change_to_attribute?(reflection.foreign_type)
end
diff --git a/activerecord/lib/active_record/associations/builder/association.rb b/activerecord/lib/active_record/associations/builder/association.rb
index b7f45ed5ee..1da4e85d09 100644
--- a/activerecord/lib/active_record/associations/builder/association.rb
+++ b/activerecord/lib/active_record/associations/builder/association.rb
@@ -33,6 +33,7 @@ module ActiveRecord::Associations::Builder # :nodoc:
define_accessors model, reflection
define_callbacks model, reflection
define_validations model, reflection
+ define_change_tracking_methods model, reflection
reflection
end
@@ -117,6 +118,10 @@ module ActiveRecord::Associations::Builder # :nodoc:
# noop
end
+ def self.define_change_tracking_methods(model, reflection)
+ # noop
+ end
+
def self.valid_dependent_options
raise NotImplementedError
end
@@ -158,6 +163,7 @@ module ActiveRecord::Associations::Builder # :nodoc:
private_class_method :build_scope, :macro, :valid_options, :validate_options, :define_extensions,
:define_callbacks, :define_accessors, :define_readers, :define_writers, :define_validations,
- :valid_dependent_options, :check_dependent_options, :add_destroy_callbacks, :add_after_commit_jobs_callback
+ :define_change_tracking_methods, :valid_dependent_options, :check_dependent_options,
+ :add_destroy_callbacks, :add_after_commit_jobs_callback
end
end
diff --git a/activerecord/lib/active_record/associations/builder/belongs_to.rb b/activerecord/lib/active_record/associations/builder/belongs_to.rb
index 584af2c3f2..aa75a33005 100644
--- a/activerecord/lib/active_record/associations/builder/belongs_to.rb
+++ b/activerecord/lib/active_record/associations/builder/belongs_to.rb
@@ -30,7 +30,7 @@ module ActiveRecord::Associations::Builder # :nodoc:
model.after_update lambda { |record|
association = association(reflection.name)
- if association.target_changed?
+ if association.saved_change_to_target?
association.increment_counters
association.decrement_counters_before_last_save
end
@@ -87,7 +87,7 @@ module ActiveRecord::Associations::Builder # :nodoc:
if reflection.counter_cache_column
touch_callback = callback.(:saved_changes)
update_callback = lambda { |record|
- instance_exec(record, &touch_callback) unless association(reflection.name).target_changed?
+ instance_exec(record, &touch_callback) unless association(reflection.name).saved_change_to_target?
}
model.after_update update_callback, if: :saved_changes?
else
@@ -127,7 +127,20 @@ module ActiveRecord::Associations::Builder # :nodoc:
end
end
- private_class_method :macro, :valid_options, :valid_dependent_options, :define_callbacks, :define_validations,
- :add_counter_cache_callbacks, :add_touch_callbacks, :add_default_callbacks, :add_destroy_callbacks
+ def self.define_change_tracking_methods(model, reflection)
+ model.generated_association_methods.class_eval <<-CODE, __FILE__, __LINE__ + 1
+ def #{reflection.name}_changed?
+ association(:#{reflection.name}).target_changed?
+ end
+
+ def #{reflection.name}_previously_changed?
+ association(:#{reflection.name}).target_previously_changed?
+ end
+ CODE
+ end
+
+ private_class_method :macro, :valid_options, :valid_dependent_options, :define_callbacks,
+ :define_validations, :define_change_tracking_methods, :add_counter_cache_callbacks,
+ :add_touch_callbacks, :add_default_callbacks, :add_destroy_callbacks
end
end
diff --git a/activerecord/test/cases/associations/belongs_to_associations_test.rb b/activerecord/test/cases/associations/belongs_to_associations_test.rb
index 53a1ed654c..c9a4abe956 100644
--- a/activerecord/test/cases/associations/belongs_to_associations_test.rb
+++ b/activerecord/test/cases/associations/belongs_to_associations_test.rb
@@ -27,11 +27,13 @@ require "models/treasure"
require "models/parrot"
require "models/book"
require "models/citation"
+require "models/tree"
+require "models/node"
class BelongsToAssociationsTest < ActiveRecord::TestCase
fixtures :accounts, :companies, :developers, :projects, :topics,
:developers_projects, :computers, :authors, :author_addresses,
- :essays, :posts, :tags, :taggings, :comments, :sponsors, :members
+ :essays, :posts, :tags, :taggings, :comments, :sponsors, :members, :nodes
def test_belongs_to
client = Client.find(3)
@@ -1508,6 +1510,104 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase
assert_equal 1, Comment.where(post_id: post.id).count
assert_equal post.id, Comment.last.post.id
end
+
+ test "tracking change from one persisted record to another" do
+ node = nodes(:child_one_of_a)
+ assert_not_nil node.parent
+ assert_not node.parent_changed?
+ assert_not node.parent_previously_changed?
+
+ node.parent = nodes(:grandparent)
+ assert node.parent_changed?
+ assert_not node.parent_previously_changed?
+
+ node.save!
+ assert_not node.parent_changed?
+ assert node.parent_previously_changed?
+ end
+
+ test "tracking change from persisted record to new record" do
+ node = nodes(:child_one_of_a)
+ assert_not_nil node.parent
+ assert_not node.parent_changed?
+ assert_not node.parent_previously_changed?
+
+ node.parent = Node.new(tree: node.tree, parent: nodes(:parent_a), name: "Child three")
+ assert node.parent_changed?
+ assert_not node.parent_previously_changed?
+
+ node.save!
+ assert_not node.parent_changed?
+ assert node.parent_previously_changed?
+ end
+
+ test "tracking change from persisted record to nil" do
+ node = nodes(:child_one_of_a)
+ assert_not_nil node.parent
+ assert_not node.parent_changed?
+ assert_not node.parent_previously_changed?
+
+ node.parent = nil
+ assert node.parent_changed?
+ assert_not node.parent_previously_changed?
+
+ node.save!
+ assert_not node.parent_changed?
+ assert node.parent_previously_changed?
+ end
+
+ test "tracking change from nil to persisted record" do
+ node = nodes(:grandparent)
+ assert_nil node.parent
+ assert_not node.parent_changed?
+ assert_not node.parent_previously_changed?
+
+ node.parent = Node.create!(tree: node.tree, name: "Great-grandparent")
+ assert node.parent_changed?
+ assert_not node.parent_previously_changed?
+
+ node.save!
+ assert_not node.parent_changed?
+ assert node.parent_previously_changed?
+ end
+
+ test "tracking change from nil to new record" do
+ node = nodes(:grandparent)
+ assert_nil node.parent
+ assert_not node.parent_changed?
+ assert_not node.parent_previously_changed?
+
+ node.parent = Node.new(tree: node.tree, name: "Great-grandparent")
+ assert node.parent_changed?
+ assert_not node.parent_previously_changed?
+
+ node.save!
+ assert_not node.parent_changed?
+ assert node.parent_previously_changed?
+ end
+
+ test "tracking polymorphic changes" do
+ comment = comments(:greetings)
+ assert_nil comment.author
+ assert_not comment.author_changed?
+ assert_not comment.author_previously_changed?
+
+ comment.author = authors(:david)
+ assert comment.author_changed?
+
+ comment.save!
+ assert_not comment.author_changed?
+ assert comment.author_previously_changed?
+
+ assert_equal authors(:david).id, companies(:first_firm).id
+
+ comment.author = companies(:first_firm)
+ assert comment.author_changed?
+
+ comment.save!
+ assert_not comment.author_changed?
+ assert comment.author_previously_changed?
+ end
end
class BelongsToWithForeignKeyTest < ActiveRecord::TestCase
diff --git a/guides/source/association_basics.md b/guides/source/association_basics.md
index 53e0c83f47..80ed8f0ae8 100644
--- a/guides/source/association_basics.md
+++ b/guides/source/association_basics.md
@@ -839,7 +839,7 @@ If the table of the other class contains the reference in a one-to-one relation,
#### Methods Added by `belongs_to`
-When you declare a `belongs_to` association, the declaring class automatically gains 6 methods related to the association:
+When you declare a `belongs_to` association, the declaring class automatically gains 8 methods related to the association:
* `association`
* `association=(associate)`
@@ -847,6 +847,8 @@ When you declare a `belongs_to` association, the declaring class automatically g
* `create_association(attributes = {})`
* `create_association!(attributes = {})`
* `reload_association`
+* `association_changed?`
+* `association_previously_changed?`
In all of these methods, `association` is replaced with the symbol passed as the first argument to `belongs_to`. For example, given the declaration:
@@ -865,6 +867,8 @@ build_author
create_author
create_author!
reload_author
+author_changed?
+author_previously_changed?
```
NOTE: When initializing a new `has_one` or `belongs_to` association you must use the `build_` prefix to build the association, rather than the `association.build` method that would be used for `has_many` or `has_and_belongs_to_many` associations. To create one, use the `create_` prefix.
@@ -913,6 +917,34 @@ The `create_association` method returns a new object of the associated type. Thi
Does the same as `create_association` above, but raises `ActiveRecord::RecordInvalid` if the record is invalid.
+##### `association_changed?`
+
+The `association_changed?` method returns true if a new associated object has been assigned and the foreign key will be updated in the next save.
+
+```ruby
+@book.author # => #
+@book.author_changed? # => false
+
+@book.author = Author.second # => #
+@book.author_changed? # => true
+
+@book.save!
+@book.author_changed? # => false
+```
+
+##### `association_previously_changed?`
+
+The `association_previously_changed?` method returns true if the previous save updated the association to reference a new associate object.
+
+```ruby
+@book.author # => #
+@book.author_previously_changed? # => false
+
+@book.author = Author.second # => #
+@book.save!
+@book.author_previously_changed? # => true
+```
+
#### Options for `belongs_to`
While Rails uses intelligent defaults that will work well in most situations, there may be times when you want to customize the behavior of the `belongs_to` association reference. Such customizations can easily be accomplished by passing options and scope blocks when you create the association. For example, this association uses two such options: