Skip to content

Commit 9424179

Browse files
authored
Merge pull request rails#53946 from byroot/comparable-serialized-types
Allow to tag serialized attributes as comparable
2 parents f5d5f2e + 6a71a2f commit 9424179

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)