Skip to content

Commit 07723c2

Browse files
committed
Further encapsulate dirty checking on Attribute
We can skip the allocation of a full `AttributeSet` by changing the semantics of how we structure things. Instead of comparing two separate `AttributeSet` objects, and `Attribute` is now a singly linked list of every change that has happened to it. Since the attribute objects are immutable, to apply the changes we simply need to copy the head of the list. It's worth noting that this causes one subtle change in the behavior of AR. When a record is saved successfully, the `before_type_cast` version of everything will be what was sent to the database. I honestly think these semantics make more sense, as we could have just as easily had the DB do `RETURNING *` and updated the record with those if we had things like timestamps implemented at the DB layer. This brings our performance closer to 4.2, but we're still not quite there.
1 parent 9db73a2 commit 07723c2

File tree

6 files changed

+99
-51
lines changed

6 files changed

+99
-51
lines changed

activerecord/lib/active_record/attribute.rb

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ def from_database(name, value, type)
55
FromDatabase.new(name, value, type)
66
end
77

8-
def from_user(name, value, type)
9-
FromUser.new(name, value, type)
8+
def from_user(name, value, type, original_attribute = nil)
9+
FromUser.new(name, value, type, original_attribute)
1010
end
1111

1212
def with_cast_value(name, value, type)
@@ -26,10 +26,11 @@ def uninitialized(name, type)
2626

2727
# This method should not be called directly.
2828
# Use #from_database or #from_user
29-
def initialize(name, value_before_type_cast, type)
29+
def initialize(name, value_before_type_cast, type, original_attribute = nil)
3030
@name = name
3131
@value_before_type_cast = value_before_type_cast
3232
@type = type
33+
@original_attribute = original_attribute
3334
end
3435

3536
def value
@@ -38,21 +39,33 @@ def value
3839
@value
3940
end
4041

42+
def original_value
43+
if assigned?
44+
original_attribute.original_value
45+
else
46+
type_cast(value_before_type_cast)
47+
end
48+
end
49+
4150
def value_for_database
4251
type.serialize(value)
4352
end
4453

45-
def changed_from?(old_value)
46-
type.changed?(old_value, value, value_before_type_cast)
54+
def changed?
55+
changed_from_assignment? || changed_in_place?
56+
end
57+
58+
def changed_in_place?
59+
has_been_read? && type.changed_in_place?(original_value_for_database, value)
4760
end
4861

49-
def changed_in_place_from?(old_value)
50-
has_been_read? && type.changed_in_place?(old_value, value)
62+
def forgetting_assignment
63+
with_value_from_database(value_for_database)
5164
end
5265

5366
def with_value_from_user(value)
5467
type.assert_valid_value(value)
55-
self.class.from_user(name, value, type)
68+
self.class.from_user(name, value, type, self)
5669
end
5770

5871
def with_value_from_database(value)
@@ -64,7 +77,7 @@ def with_cast_value(value)
6477
end
6578

6679
def with_type(type)
67-
self.class.new(name, value_before_type_cast, type)
80+
self.class.new(name, value_before_type_cast, type, original_attribute)
6881
end
6982

7083
def type_cast(*)
@@ -97,16 +110,39 @@ def hash
97110

98111
protected
99112

113+
attr_reader :original_attribute
114+
alias_method :assigned?, :original_attribute
115+
100116
def initialize_dup(other)
101117
if defined?(@value) && @value.duplicable?
102118
@value = @value.dup
103119
end
104120
end
105121

122+
def changed_from_assignment?
123+
assigned? && type.changed?(original_value, value, value_before_type_cast)
124+
end
125+
126+
def original_value_for_database
127+
if assigned?
128+
original_attribute.original_value_for_database
129+
else
130+
_original_value_for_database
131+
end
132+
end
133+
134+
def _original_value_for_database
135+
value_for_database
136+
end
137+
106138
class FromDatabase < Attribute # :nodoc:
107139
def type_cast(value)
108140
type.deserialize(value)
109141
end
142+
143+
def _original_value_for_database
144+
value_before_type_cast
145+
end
110146
end
111147

112148
class FromUser < Attribute # :nodoc:

activerecord/lib/active_record/attribute/user_provided_default.rb

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ module ActiveRecord
44
class Attribute # :nodoc:
55
class UserProvidedDefault < FromUser
66
def initialize(name, value, type, database_default)
7-
super(name, value, type)
8-
@database_default = database_default
7+
super(name, value, type, database_default)
98
end
109

1110
def type_cast(value)
@@ -16,17 +15,9 @@ def type_cast(value)
1615
end
1716
end
1817

19-
def changed_from?(old_value)
20-
super || changed_from?(database_default.value)
21-
end
22-
2318
def with_type(type)
24-
self.class.new(name, value_before_type_cast, type, database_default)
19+
self.class.new(name, value_before_type_cast, type, original_attribute)
2520
end
26-
27-
protected
28-
29-
attr_reader :database_default
3021
end
3122
end
3223
end

activerecord/lib/active_record/attribute_methods/dirty.rb

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,15 @@ def reload(*)
4141

4242
def init_internals
4343
super
44-
@mutation_tracker = AttributeMutationTracker.new(@attributes, @attributes.dup)
44+
@mutation_tracker = AttributeMutationTracker.new(@attributes)
4545
end
4646

4747
def initialize_dup(other) # :nodoc:
4848
super
49-
@mutation_tracker = AttributeMutationTracker.new(@attributes,
50-
self.class._default_attributes.deep_dup)
49+
@attributes = self.class._default_attributes.map do |attr|
50+
attr.with_value_from_user(@attributes.fetch_value(attr.name))
51+
end
52+
@mutation_tracker = AttributeMutationTracker.new(@attributes)
5153
end
5254

5355
def changes_applied
@@ -122,7 +124,8 @@ def keys_for_partial_write
122124
end
123125

124126
def store_original_attributes
125-
@mutation_tracker = @mutation_tracker.now_tracking(@attributes)
127+
@attributes = @attributes.map(&:forgetting_assignment)
128+
@mutation_tracker = AttributeMutationTracker.new(@attributes)
126129
end
127130

128131
def previous_mutation_tracker

activerecord/lib/active_record/attribute_mutation_tracker.rb

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,48 @@
11
module ActiveRecord
22
class AttributeMutationTracker # :nodoc:
3-
def initialize(attributes, original_attributes)
3+
def initialize(attributes)
44
@attributes = attributes
5-
@original_attributes = original_attributes
65
end
76

87
def changed_values
98
attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result|
109
if changed?(attr_name)
11-
result[attr_name] = original_attributes.fetch_value(attr_name)
10+
result[attr_name] = attributes[attr_name].original_value
1211
end
1312
end
1413
end
1514

1615
def changes
1716
attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result|
1817
if changed?(attr_name)
19-
result[attr_name] = [original_attributes.fetch_value(attr_name), attributes.fetch_value(attr_name)]
18+
result[attr_name] = [attributes[attr_name].original_value, attributes.fetch_value(attr_name)]
2019
end
2120
end
2221
end
2322

2423
def changed?(attr_name)
2524
attr_name = attr_name.to_s
26-
modified?(attr_name) || changed_in_place?(attr_name)
25+
attributes[attr_name].changed?
2726
end
2827

2928
def changed_in_place?(attr_name)
30-
original_database_value = original_attributes[attr_name].value_before_type_cast
31-
attributes[attr_name].changed_in_place_from?(original_database_value)
29+
attributes[attr_name].changed_in_place?
3230
end
3331

3432
def forget_change(attr_name)
3533
attr_name = attr_name.to_s
36-
original_attributes[attr_name] = attributes[attr_name].dup
37-
end
38-
39-
def now_tracking(new_attributes)
40-
AttributeMutationTracker.new(new_attributes, clean_copy_of(new_attributes))
34+
attributes[attr_name] = attributes[attr_name].forgetting_assignment
4135
end
4236

4337
protected
4438

45-
attr_reader :attributes, :original_attributes
39+
attr_reader :attributes
4640

4741
private
4842

4943
def attr_names
5044
attributes.keys
5145
end
52-
53-
def modified?(attr_name)
54-
attributes[attr_name].changed_from?(original_attributes.fetch_value(attr_name))
55-
end
56-
57-
def clean_copy_of(attributes)
58-
attributes.map do |attr|
59-
attr.with_value_from_database(attr.value_for_database)
60-
end
61-
end
6246
end
6347

6448
class NullMutationTracker # :nodoc:

activerecord/test/cases/attribute_methods_test.rb

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -542,9 +542,6 @@ def test_converted_values_are_returned_after_assignment
542542

543543
developer.save!
544544

545-
assert_equal "50000", developer.salary_before_type_cast
546-
assert_equal 1337, developer.name_before_type_cast
547-
548545
assert_equal 50000, developer.salary
549546
assert_equal "1337", developer.name
550547
end

activerecord/test/cases/attribute_test.rb

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,12 +182,49 @@ def assert_valid_value(*)
182182
assert attribute.has_been_read?
183183
end
184184

185+
test "an attribute is not changed if it hasn't been assigned or mutated" do
186+
attribute = Attribute.from_database(:foo, 1, Type::Value.new)
187+
188+
refute attribute.changed?
189+
end
190+
191+
test "an attribute is changed if it's been assigned a new value" do
192+
attribute = Attribute.from_database(:foo, 1, Type::Value.new)
193+
changed = attribute.with_value_from_user(2)
194+
195+
assert changed.changed?
196+
end
197+
198+
test "an attribute is not changed if it's assigned the same value" do
199+
attribute = Attribute.from_database(:foo, 1, Type::Value.new)
200+
unchanged = attribute.with_value_from_user(1)
201+
202+
refute unchanged.changed?
203+
end
204+
185205
test "an attribute can not be mutated if it has not been read,
186206
and skips expensive calculations" do
187207
type_which_raises_from_all_methods = Object.new
188208
attribute = Attribute.from_database(:foo, "bar", type_which_raises_from_all_methods)
189209

190-
assert_not attribute.changed_in_place_from?("bar")
210+
assert_not attribute.changed_in_place?
211+
end
212+
213+
test "an attribute is changed if it has been mutated" do
214+
attribute = Attribute.from_database(:foo, "bar", Type::String.new)
215+
attribute.value << "!"
216+
217+
assert attribute.changed_in_place?
218+
assert attribute.changed?
219+
end
220+
221+
test "an attribute can forget its changes" do
222+
attribute = Attribute.from_database(:foo, "bar", Type::String.new)
223+
changed = attribute.with_value_from_user("foo")
224+
forgotten = changed.forgetting_assignment
225+
226+
assert changed.changed? # sanity check
227+
refute forgotten.changed?
191228
end
192229

193230
test "with_value_from_user validates the value" do

0 commit comments

Comments
 (0)