Skip to content

Commit 4885a00

Browse files
committed
ActiveModel::Attribute: elide dup for immutable types
`Attribute` is mostly immutable, but the deserialized value is memoized, and that value can potentially be mutable, and mutated. When the value is immutable however, we can share instances. Benchmark: https://gist.github.com/casperisfine/ae56bec1e7eecbff3a696b367e2bafa2 Before: ``` ruby 3.4.0dev (2024-12-12T09:30:43Z master 197a3efc75) +YJIT +PRISM [arm64-darwin23] Warming up -------------------------------------- ActiveRecord 4.664k i/100ms Calculating ------------------------------------- ActiveRecord 46.983k (± 1.0%) i/s (21.28 μs/i) - 237.864k in 5.063292s ``` After: ``` ruby 3.4.0dev (2024-12-12T09:30:43Z master 197a3efc75) +YJIT +PRISM [arm64-darwin23] Warming up -------------------------------------- ActiveRecord 5.404k i/100ms Calculating ------------------------------------- ActiveRecord 53.502k (± 1.4%) i/s (18.69 μs/i) - 270.200k in 5.051328s ```
1 parent 6a88a10 commit 4885a00

File tree

6 files changed

+39
-1
lines changed

6 files changed

+39
-1
lines changed

activemodel/lib/active_model/attribute.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ def with_type(type)
9696
end
9797
end
9898

99+
def dup_or_share # :nodoc:
100+
if @type.mutable?
101+
dup
102+
else
103+
self # If the underlying type is immutable we can get away with not duping
104+
end
105+
end
106+
99107
def type_cast(*)
100108
raise NotImplementedError
101109
end

activemodel/lib/active_model/attribute/user_provided_default.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ def with_type(type)
2626
self.class.new(name, user_provided_value, type, original_attribute)
2727
end
2828

29+
def dup_or_share # :nodoc:
30+
# Can't elide dup when the default is a Proc
31+
# See Attribute#dup_or_share
32+
if @user_provided_value.is_a?(Proc)
33+
dup
34+
else
35+
super
36+
end
37+
end
38+
2939
def marshal_dump
3040
result = [
3141
name,

activemodel/lib/active_model/attribute_set.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def freeze
7171
end
7272

7373
def deep_dup
74-
AttributeSet.new(attributes.transform_values(&:deep_dup))
74+
AttributeSet.new(attributes.transform_values(&:dup_or_share))
7575
end
7676

7777
def initialize_dup(_)

activemodel/lib/active_model/type/date_time.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ def type
5050
:datetime
5151
end
5252

53+
def mutable? # :nodoc:
54+
# Time#zone can be mutated by #utc or #localtime
55+
# However when serializing the time zone will always
56+
# be coerced and even if the zone was mutated Time instances
57+
# remain equal, so we don't need to implement `#changed_in_place?`
58+
true
59+
end
60+
5361
private
5462
def cast_value(value)
5563
return apply_seconds_precision(value) unless value.is_a?(::String)

activemodel/lib/active_model/type/string.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ def changed_in_place?(raw_old_value, new_value)
1919
end
2020
end
2121

22+
def mutable? # :nodoc:
23+
true
24+
end
25+
2226
def to_immutable_string
2327
ImmutableString.new(
2428
true: @true,

activemodel/lib/active_model/type/time.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ def type
4646
:time
4747
end
4848

49+
def mutable? # :nodoc:
50+
# Time#zone can be mutated by #utc or #localtime
51+
# However when serializing the time zone will always
52+
# be coerced and even if the zone was mutated Time instances
53+
# remain equal, so we don't need to implement `#changed_in_place?`
54+
true
55+
end
56+
4957
def user_input_in_time_zone(value)
5058
return unless value.present?
5159

0 commit comments

Comments
 (0)