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 # => #<Category id: 1, name: "Ruby">

    post.category = Category.second   # => #<Category id: 2, name: "Programming">
    post.category_changed?            # => true
    post.category_previously_changed? # => false

    post.save!

    post.category_changed?            # => false
    post.category_previously_changed? # => true
This commit is contained in:
George Claghorn 2021-07-10 23:09:34 -04:00
parent 6442aac195
commit fc3acf2e8d
8 changed files with 193 additions and 7 deletions

View File

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

View File

@ -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
# * <tt>Post#create_author</tt> (similar to <tt>post.author = Author.new; post.author.save; post.author</tt>)
# * <tt>Post#create_author!</tt> (similar to <tt>post.author = Author.new; post.author.save!; post.author</tt>)
# * <tt>Post#reload_author</tt>
# * <tt>Post#author_changed?</tt>
# * <tt>Post#author_previously_changed?</tt>
# The declaration can also include an +options+ hash to specialize the behavior of the association.
#
# === Scopes

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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_number: 123, author_name: "John Doe">
@book.author_changed? # => false
@book.author = Author.second # => #<Book author_number: 456, author_name: "Jane Smith">
@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_number: 123, author_name: "John Doe">
@book.author_previously_changed? # => false
@book.author = Author.second # => #<Book author_number: 456, author_name: "Jane Smith">
@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: