Skip to content

Commit 6a71a2f

Browse files
committed
Allow to tag serialized attributes as comparable
Fix: rails#28416 A not rare issue when working with serialized attributes is that the serialized representation of an object can change over time. Either because you are migrating from one serializer to the other (e.g. YAML to JSON or to msgpack), or because the serializer used subtly changed it's output. One example is `libyaml` that used to have some extra trailing whitespaces, and recently fixed that: yaml/libyaml#186 When this sorts of thing happen, you end up with lots of records that report being changed even though they aren't, which in the best case leads to a lot more writes to the database and in the worst case lead to nasty bugs. So ideally rather than to compare serialized representations, we'd compare the deserialized objects, however we can't assume the deserialized type has a proper working `==` method that deeply compare objects. So the best thing we can do is to only do this if the type is declared as being comparable.
1 parent f5d5f2e commit 6a71a2f

File tree

4 files changed

+67
-6
lines changed

4 files changed

+67
-6
lines changed

activerecord/CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
* Serialized attributes can now be marked as comparable.
2+
3+
A not rare issue when working with serialized attributes is that the serialized representation of an object
4+
can change over time. Either because you are migrating from one serializer to the other (e.g. YAML to JSON or to msgpack),
5+
or because the serializer used subtly changed it's output.
6+
7+
One example is libyaml that used to have some extra trailing whitespaces, and recently fixed that.
8+
When this sorts of thing happen, you end up with lots of records that report being changed even though
9+
they aren't, which in the best case leads to a lot more writes to the database and in the worst case lead to nasty bugs.
10+
11+
The solution is to instead compare the deserialized representation of the object, however Active Record
12+
can't assume the deserialized object has a working `==` method. Hence why this new functionality is opt-in.
13+
14+
```ruby
15+
serialized :config, type: Hash, coder: JSON, comparable: true
16+
```
17+
18+
*Jean Boussier*
19+
120
* Fix MySQL default functions getting dropped when changing a column's nullability.
221
322
*Bastian Bartmann*

activerecord/lib/active_record/attribute_methods/serialization.rb

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@ module ClassMethods
5151
# ActiveRecord::SerializationTypeMismatch error.
5252
# * If the column is +NULL+ or starting from a new record, the default value
5353
# will set to +type.new+
54+
# * +comparable+ - Specify whether the deserialized object is safely comparable
55+
# for the purpose of detecting changes. Defaults to +false+
56+
# When set to +false+ the old and new values will be compared by their serialized
57+
# representation (e.g. JSON or YAML), which can sometimes cause two objects that are
58+
# semantically equal to be considered different.
59+
# For instance two hashes with the same keys and values but a different order have a
60+
# different serialized representation, but are semantically equal once deserialized.
61+
# If set to +true+ the comparison will be done on the deserialized object. This options
62+
# should only be enabled if the +type+ is known to have a proper +==+ method that deeply
63+
# compare the objects.
5464
# * +yaml+ - Optional. Yaml specific options. The allowed config is:
5565
# * +:permitted_classes+ - +Array+ with the permitted classes.
5666
# * +:unsafe_load+ - Unsafely load YAML blobs, allow YAML to load any class.
@@ -180,7 +190,7 @@ module ClassMethods
180190
# serialize :preferences, coder: Rot13JSON
181191
# end
182192
#
183-
def serialize(attr_name, coder: nil, type: Object, yaml: {}, **options)
193+
def serialize(attr_name, coder: nil, type: Object, comparable: false, yaml: {}, **options)
184194
coder ||= default_column_serializer
185195
unless coder
186196
raise ArgumentError, <<~MSG.squish
@@ -200,7 +210,7 @@ def serialize(attr_name, coder: nil, type: Object, yaml: {}, **options)
200210
end
201211

202212
cast_type = cast_type.subtype if Type::Serialized === cast_type
203-
Type::Serialized.new(cast_type, column_serializer)
213+
Type::Serialized.new(cast_type, column_serializer, comparable: comparable)
204214
end
205215
end
206216

activerecord/lib/active_record/type/serialized.rb

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ class Serialized < DelegateClass(ActiveModel::Type::Value) # :nodoc:
99

1010
attr_reader :subtype, :coder
1111

12-
def initialize(subtype, coder)
12+
def initialize(subtype, coder, comparable: false)
1313
@subtype = subtype
1414
@coder = coder
15+
@comparable = comparable
1516
super(subtype)
1617
end
1718

@@ -34,9 +35,15 @@ def serialize(value)
3435

3536
def changed_in_place?(raw_old_value, value)
3637
return false if value.nil?
37-
raw_new_value = encoded(value)
38-
raw_old_value.nil? != raw_new_value.nil? ||
39-
subtype.changed_in_place?(raw_old_value, raw_new_value)
38+
39+
if @comparable
40+
old_value = deserialize(raw_old_value)
41+
old_value != value
42+
else
43+
raw_new_value = encoded(value)
44+
raw_old_value.nil? != raw_new_value.nil? ||
45+
subtype.changed_in_place?(raw_old_value, raw_new_value)
46+
end
4047
end
4148

4249
def accessor

activerecord/test/cases/serialized_attribute_test.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,4 +697,29 @@ def test_supports_permitted_classes_for_default_column_serializer
697697
topic = Topic.new(content: Time.now)
698698
assert topic.save
699699
end
700+
701+
def test_changed_in_place_compare_serialized_representation
702+
Topic.serialize :content, type: Hash
703+
topic = Topic.create!(content: { "a" => 1, "b" => 2 })
704+
705+
topic.content = { "a" => 1, "b" => 2 }
706+
assert_not_predicate topic, :content_changed?
707+
708+
topic.content = { "b" => 2, "a" => 1 }
709+
assert_predicate topic, :content_changed?
710+
end
711+
712+
def test_changed_in_place_compare_deserialized_representation_when_comparable_is_set
713+
Topic.serialize :content, type: Hash, comparable: true
714+
topic = Topic.create!(content: { "a" => 1, "b" => 2 })
715+
716+
topic.content = { "a" => 1, "b" => 2 }
717+
assert_not_predicate topic, :content_changed?
718+
719+
topic.content = { "b" => 2, "a" => 1 }
720+
assert_not_predicate topic, :content_changed?
721+
722+
topic.content = {}
723+
assert_predicate topic, :content_changed?
724+
end
700725
end

0 commit comments

Comments
 (0)