From fc3acf2e8dcf01f077664f98ad0c0e52539c27e2 Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Sat, 10 Jul 2021 23:09:34 -0400 Subject: [PATCH] Add change tracking methods for belongs_to associations Permit checking whether a belongs_to association has been pointed to a new target record in the previous save and whether it will point to a new target record in the next save. post.category # => # post.category = Category.second # => # post.category_changed? # => true post.category_previously_changed? # => false post.save! post.category_changed? # => false post.category_previously_changed? # => true --- activerecord/CHANGELOG.md | 11 ++ .../lib/active_record/associations.rb | 8 ++ .../associations/belongs_to_association.rb | 8 ++ .../belongs_to_polymorphic_association.rb | 8 ++ .../associations/builder/association.rb | 8 +- .../associations/builder/belongs_to.rb | 21 +++- .../belongs_to_associations_test.rb | 102 +++++++++++++++++- guides/source/association_basics.md | 34 +++++- 8 files changed, 193 insertions(+), 7 deletions(-) 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: