Skip to content

Commit fc3acf2

Browse files
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
1 parent 6442aac commit fc3acf2

File tree

8 files changed

+193
-7
lines changed

8 files changed

+193
-7
lines changed

activerecord/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
* Two change tracking methods are added for `belongs_to` associations.
2+
3+
The `association_changed?` method (assuming an association named `:association`) returns true
4+
if a different associated object has been assigned and the foreign key will be updated in the
5+
next save.
6+
7+
The `association_previously_changed?` method returns true if the previous save updated the
8+
association to reference a different associated object.
9+
10+
*George Claghorn*
11+
112
* Add option to disable schema dump per-database
213

314
Dumping the schema is on by default for all databases in an application. To turn it off for a

activerecord/lib/active_record/associations.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,8 @@ def association_instance_set(name, association)
364364
# create_other(attributes={}) | X | | X
365365
# create_other!(attributes={}) | X | | X
366366
# reload_other | X | X | X
367+
# other_changed? | X | X |
368+
# other_previously_changed? | X | X |
367369
#
368370
# === Collection associations (one-to-many / many-to-many)
369371
# | | | has_many
@@ -1624,6 +1626,10 @@ def has_one(name, scope = nil, **options)
16241626
# if the record is invalid.
16251627
# [reload_association]
16261628
# Returns the associated object, forcing a database read.
1629+
# [association_changed?]
1630+
# Returns true if a new associate object has been assigned and the next save will update the foreign key.
1631+
# [association_previously_changed?]
1632+
# Returns true if the previous save updated the association to reference a new associate object.
16271633
#
16281634
# === Example
16291635
#
@@ -1634,6 +1640,8 @@ def has_one(name, scope = nil, **options)
16341640
# * <tt>Post#create_author</tt> (similar to <tt>post.author = Author.new; post.author.save; post.author</tt>)
16351641
# * <tt>Post#create_author!</tt> (similar to <tt>post.author = Author.new; post.author.save!; post.author</tt>)
16361642
# * <tt>Post#reload_author</tt>
1643+
# * <tt>Post#author_changed?</tt>
1644+
# * <tt>Post#author_previously_changed?</tt>
16371645
# The declaration can also include an +options+ hash to specialize the behavior of the association.
16381646
#
16391647
# === Scopes

activerecord/lib/active_record/associations/belongs_to_association.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ def decrement_counters_before_last_save
6868
end
6969

7070
def target_changed?
71+
owner.attribute_changed?(reflection.foreign_key) || (!foreign_key_present? && target&.new_record?)
72+
end
73+
74+
def target_previously_changed?
75+
owner.attribute_previously_changed?(reflection.foreign_key)
76+
end
77+
78+
def saved_change_to_target?
7179
owner.saved_change_to_attribute?(reflection.foreign_key)
7280
end
7381

activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ def klass
1010
end
1111

1212
def target_changed?
13+
super || owner.attribute_changed?(reflection.foreign_type)
14+
end
15+
16+
def target_previously_changed?
17+
super || owner.attribute_previously_changed?(reflection.foreign_type)
18+
end
19+
20+
def saved_change_to_target?
1321
super || owner.saved_change_to_attribute?(reflection.foreign_type)
1422
end
1523

activerecord/lib/active_record/associations/builder/association.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def self.build(model, name, scope, options, &block)
3333
define_accessors model, reflection
3434
define_callbacks model, reflection
3535
define_validations model, reflection
36+
define_change_tracking_methods model, reflection
3637
reflection
3738
end
3839

@@ -117,6 +118,10 @@ def self.define_validations(model, reflection)
117118
# noop
118119
end
119120

121+
def self.define_change_tracking_methods(model, reflection)
122+
# noop
123+
end
124+
120125
def self.valid_dependent_options
121126
raise NotImplementedError
122127
end
@@ -158,6 +163,7 @@ def _after_commit_jobs
158163

159164
private_class_method :build_scope, :macro, :valid_options, :validate_options, :define_extensions,
160165
:define_callbacks, :define_accessors, :define_readers, :define_writers, :define_validations,
161-
:valid_dependent_options, :check_dependent_options, :add_destroy_callbacks, :add_after_commit_jobs_callback
166+
:define_change_tracking_methods, :valid_dependent_options, :check_dependent_options,
167+
:add_destroy_callbacks, :add_after_commit_jobs_callback
162168
end
163169
end

activerecord/lib/active_record/associations/builder/belongs_to.rb

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def self.add_counter_cache_callbacks(model, reflection)
3030
model.after_update lambda { |record|
3131
association = association(reflection.name)
3232

33-
if association.target_changed?
33+
if association.saved_change_to_target?
3434
association.increment_counters
3535
association.decrement_counters_before_last_save
3636
end
@@ -87,7 +87,7 @@ def self.add_touch_callbacks(model, reflection)
8787
if reflection.counter_cache_column
8888
touch_callback = callback.(:saved_changes)
8989
update_callback = lambda { |record|
90-
instance_exec(record, &touch_callback) unless association(reflection.name).target_changed?
90+
instance_exec(record, &touch_callback) unless association(reflection.name).saved_change_to_target?
9191
}
9292
model.after_update update_callback, if: :saved_changes?
9393
else
@@ -127,7 +127,20 @@ def self.define_validations(model, reflection)
127127
end
128128
end
129129

130-
private_class_method :macro, :valid_options, :valid_dependent_options, :define_callbacks, :define_validations,
131-
:add_counter_cache_callbacks, :add_touch_callbacks, :add_default_callbacks, :add_destroy_callbacks
130+
def self.define_change_tracking_methods(model, reflection)
131+
model.generated_association_methods.class_eval <<-CODE, __FILE__, __LINE__ + 1
132+
def #{reflection.name}_changed?
133+
association(:#{reflection.name}).target_changed?
134+
end
135+
136+
def #{reflection.name}_previously_changed?
137+
association(:#{reflection.name}).target_previously_changed?
138+
end
139+
CODE
140+
end
141+
142+
private_class_method :macro, :valid_options, :valid_dependent_options, :define_callbacks,
143+
:define_validations, :define_change_tracking_methods, :add_counter_cache_callbacks,
144+
:add_touch_callbacks, :add_default_callbacks, :add_destroy_callbacks
132145
end
133146
end

activerecord/test/cases/associations/belongs_to_associations_test.rb

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@
2727
require "models/parrot"
2828
require "models/book"
2929
require "models/citation"
30+
require "models/tree"
31+
require "models/node"
3032

3133
class BelongsToAssociationsTest < ActiveRecord::TestCase
3234
fixtures :accounts, :companies, :developers, :projects, :topics,
3335
:developers_projects, :computers, :authors, :author_addresses,
34-
:essays, :posts, :tags, :taggings, :comments, :sponsors, :members
36+
:essays, :posts, :tags, :taggings, :comments, :sponsors, :members, :nodes
3537

3638
def test_belongs_to
3739
client = Client.find(3)
@@ -1508,6 +1510,104 @@ def test_multiple_counter_cache_with_after_create_update
15081510
assert_equal 1, Comment.where(post_id: post.id).count
15091511
assert_equal post.id, Comment.last.post.id
15101512
end
1513+
1514+
test "tracking change from one persisted record to another" do
1515+
node = nodes(:child_one_of_a)
1516+
assert_not_nil node.parent
1517+
assert_not node.parent_changed?
1518+
assert_not node.parent_previously_changed?
1519+
1520+
node.parent = nodes(:grandparent)
1521+
assert node.parent_changed?
1522+
assert_not node.parent_previously_changed?
1523+
1524+
node.save!
1525+
assert_not node.parent_changed?
1526+
assert node.parent_previously_changed?
1527+
end
1528+
1529+
test "tracking change from persisted record to new record" do
1530+
node = nodes(:child_one_of_a)
1531+
assert_not_nil node.parent
1532+
assert_not node.parent_changed?
1533+
assert_not node.parent_previously_changed?
1534+
1535+
node.parent = Node.new(tree: node.tree, parent: nodes(:parent_a), name: "Child three")
1536+
assert node.parent_changed?
1537+
assert_not node.parent_previously_changed?
1538+
1539+
node.save!
1540+
assert_not node.parent_changed?
1541+
assert node.parent_previously_changed?
1542+
end
1543+
1544+
test "tracking change from persisted record to nil" do
1545+
node = nodes(:child_one_of_a)
1546+
assert_not_nil node.parent
1547+
assert_not node.parent_changed?
1548+
assert_not node.parent_previously_changed?
1549+
1550+
node.parent = nil
1551+
assert node.parent_changed?
1552+
assert_not node.parent_previously_changed?
1553+
1554+
node.save!
1555+
assert_not node.parent_changed?
1556+
assert node.parent_previously_changed?
1557+
end
1558+
1559+
test "tracking change from nil to persisted record" do
1560+
node = nodes(:grandparent)
1561+
assert_nil node.parent
1562+
assert_not node.parent_changed?
1563+
assert_not node.parent_previously_changed?
1564+
1565+
node.parent = Node.create!(tree: node.tree, name: "Great-grandparent")
1566+
assert node.parent_changed?
1567+
assert_not node.parent_previously_changed?
1568+
1569+
node.save!
1570+
assert_not node.parent_changed?
1571+
assert node.parent_previously_changed?
1572+
end
1573+
1574+
test "tracking change from nil to new record" do
1575+
node = nodes(:grandparent)
1576+
assert_nil node.parent
1577+
assert_not node.parent_changed?
1578+
assert_not node.parent_previously_changed?
1579+
1580+
node.parent = Node.new(tree: node.tree, name: "Great-grandparent")
1581+
assert node.parent_changed?
1582+
assert_not node.parent_previously_changed?
1583+
1584+
node.save!
1585+
assert_not node.parent_changed?
1586+
assert node.parent_previously_changed?
1587+
end
1588+
1589+
test "tracking polymorphic changes" do
1590+
comment = comments(:greetings)
1591+
assert_nil comment.author
1592+
assert_not comment.author_changed?
1593+
assert_not comment.author_previously_changed?
1594+
1595+
comment.author = authors(:david)
1596+
assert comment.author_changed?
1597+
1598+
comment.save!
1599+
assert_not comment.author_changed?
1600+
assert comment.author_previously_changed?
1601+
1602+
assert_equal authors(:david).id, companies(:first_firm).id
1603+
1604+
comment.author = companies(:first_firm)
1605+
assert comment.author_changed?
1606+
1607+
comment.save!
1608+
assert_not comment.author_changed?
1609+
assert comment.author_previously_changed?
1610+
end
15111611
end
15121612

15131613
class BelongsToWithForeignKeyTest < ActiveRecord::TestCase

guides/source/association_basics.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -839,14 +839,16 @@ If the table of the other class contains the reference in a one-to-one relation,
839839

840840
#### Methods Added by `belongs_to`
841841

842-
When you declare a `belongs_to` association, the declaring class automatically gains 6 methods related to the association:
842+
When you declare a `belongs_to` association, the declaring class automatically gains 8 methods related to the association:
843843

844844
* `association`
845845
* `association=(associate)`
846846
* `build_association(attributes = {})`
847847
* `create_association(attributes = {})`
848848
* `create_association!(attributes = {})`
849849
* `reload_association`
850+
* `association_changed?`
851+
* `association_previously_changed?`
850852

851853
In all of these methods, `association` is replaced with the symbol passed as the first argument to `belongs_to`. For example, given the declaration:
852854

@@ -865,6 +867,8 @@ build_author
865867
create_author
866868
create_author!
867869
reload_author
870+
author_changed?
871+
author_previously_changed?
868872
```
869873

870874
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
913917

914918
Does the same as `create_association` above, but raises `ActiveRecord::RecordInvalid` if the record is invalid.
915919

920+
##### `association_changed?`
921+
922+
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.
923+
924+
```ruby
925+
@book.author # => #<Book author_number: 123, author_name: "John Doe">
926+
@book.author_changed? # => false
927+
928+
@book.author = Author.second # => #<Book author_number: 456, author_name: "Jane Smith">
929+
@book.author_changed? # => true
930+
931+
@book.save!
932+
@book.author_changed? # => false
933+
```
934+
935+
##### `association_previously_changed?`
936+
937+
The `association_previously_changed?` method returns true if the previous save updated the association to reference a new associate object.
938+
939+
```ruby
940+
@book.author # => #<Book author_number: 123, author_name: "John Doe">
941+
@book.author_previously_changed? # => false
942+
943+
@book.author = Author.second # => #<Book author_number: 456, author_name: "Jane Smith">
944+
@book.save!
945+
@book.author_previously_changed? # => true
946+
```
947+
916948
#### Options for `belongs_to`
917949

918950
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:

0 commit comments

Comments
 (0)