Skip to content

Commit 514d474

Browse files
committed
Fix a performance regression in attribute methods
Fix: rails#52111 Fix: 5dbc7b4 The above commit caused the size of the `CodeGenerator` method cache to explode, because the dynamic namespace is way too granular. But there is actually a much better fix for that, since `alias_attribute` is now generating exactly the same code as the attribute it's aliasing, we can generated it as the canonical method in the cache, and then just define it in the model as the aliased name. This prevent the cache from growing a lot, and even reduce memory usage further as the original attribute and its alias now share the same method cache.
1 parent 4a4b399 commit 514d474

File tree

7 files changed

+69
-44
lines changed

7 files changed

+69
-44
lines changed

activemodel/lib/active_model/attribute_methods.rb

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,7 @@ def eagerly_generate_alias_attribute_methods(new_name, old_name) # :nodoc:
215215
end
216216

217217
def generate_alias_attribute_methods(code_generator, new_name, old_name)
218-
attribute_method_patterns.each do |pattern|
219-
alias_attribute_method_definition(code_generator, pattern, new_name, old_name)
220-
end
218+
define_attribute_method(old_name, _owner: code_generator, as: new_name)
221219
end
222220

223221
def alias_attribute_method_definition(code_generator, pattern, new_name, old_name) # :nodoc:
@@ -305,22 +303,35 @@ def define_attribute_methods(*attr_names)
305303
# person.name = 'Bob'
306304
# person.name # => "Bob"
307305
# person.name_short? # => true
308-
def define_attribute_method(attr_name, _owner: generated_attribute_methods)
306+
def define_attribute_method(attr_name, _owner: generated_attribute_methods, as: attr_name)
309307
ActiveSupport::CodeGenerator.batch(_owner, __FILE__, __LINE__) do |owner|
310308
attribute_method_patterns.each do |pattern|
311-
method_name = pattern.method_name(attr_name)
309+
define_attribute_method_pattern(pattern, attr_name, owner: owner, as: as)
310+
end
311+
attribute_method_patterns_cache.clear
312+
end
313+
end
312314

313-
unless instance_method_already_implemented?(method_name)
314-
generate_method = "define_method_#{pattern.proxy_target}"
315+
def define_attribute_method_pattern(pattern, attr_name, owner:, as:) # :nodoc:
316+
canonical_method_name = pattern.method_name(attr_name)
317+
public_method_name = pattern.method_name(as)
315318

316-
if respond_to?(generate_method, true)
317-
send(generate_method, attr_name.to_s, owner: owner)
318-
else
319-
define_proxy_call(owner, method_name, pattern.proxy_target, pattern.parameters, attr_name.to_s, namespace: :active_model_proxy)
320-
end
321-
end
319+
unless instance_method_already_implemented?(public_method_name)
320+
generate_method = "define_method_#{pattern.proxy_target}"
321+
322+
if respond_to?(generate_method, true)
323+
send(generate_method, attr_name.to_s, owner: owner, as: as)
324+
else
325+
define_proxy_call(
326+
owner,
327+
canonical_method_name,
328+
pattern.proxy_target,
329+
pattern.parameters,
330+
attr_name.to_s,
331+
namespace: :active_model_proxy,
332+
as: public_method_name
333+
)
322334
end
323-
attribute_method_patterns_cache.clear
324335
end
325336
end
326337

@@ -404,14 +415,19 @@ def attribute_method_patterns_matching(method_name)
404415
# Define a method `name` in `mod` that dispatches to `send`
405416
# using the given `extra` args. This falls back on `send`
406417
# if the called name cannot be compiled.
407-
def define_proxy_call(code_generator, name, proxy_target, parameters, *call_args, namespace:)
418+
def define_proxy_call(code_generator, name, proxy_target, parameters, *call_args, namespace:, as: name)
408419
mangled_name = build_mangled_name(name)
409420

410421
call_args.map!(&:inspect)
411422
call_args << parameters if parameters
412-
namespace = :"#{namespace}_#{proxy_target}_#{call_args.join("_")}}"
413423

414-
define_call(code_generator, name, proxy_target, mangled_name, parameters, call_args, namespace: namespace)
424+
# We have to use a different namespace for every target method, because
425+
# if someone defines an attribute that look like an attribute method we could clash, e.g.
426+
# attribute :title_was
427+
# attribute :title
428+
namespace = :"#{namespace}_#{proxy_target}"
429+
430+
define_call(code_generator, name, proxy_target, mangled_name, parameters, call_args, namespace: namespace, as: as)
415431
end
416432

417433
def build_mangled_name(name)
@@ -424,8 +440,8 @@ def build_mangled_name(name)
424440
mangled_name
425441
end
426442

427-
def define_call(code_generator, name, target_name, mangled_name, parameters, call_args, namespace:)
428-
code_generator.define_cached_method(name, as: mangled_name, namespace: namespace) do |batch|
443+
def define_call(code_generator, name, target_name, mangled_name, parameters, call_args, namespace:, as:)
444+
code_generator.define_cached_method(mangled_name, as: as, namespace: namespace) do |batch|
429445
body = if CALL_COMPILABLE_REGEXP.match?(target_name)
430446
"self.#{target_name}(#{call_args.join(", ")})"
431447
else

activemodel/lib/active_model/attributes.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,11 @@ def attribute_names
8989

9090
##
9191
private
92-
def define_method_attribute=(name, owner:)
92+
def define_method_attribute=(canonical_name, owner:, as: canonical_name)
9393
ActiveModel::AttributeMethods::AttrNames.define_attribute_accessor_method(
94-
owner, name, writer: true,
94+
owner, canonical_name, writer: true,
9595
) do |temp_method_name, attr_name_expr|
96-
owner.define_cached_method("#{name}=", as: temp_method_name, namespace: :active_model) do |batch|
96+
owner.define_cached_method(temp_method_name, as: "#{as}=", namespace: :active_model) do |batch|
9797
batch <<
9898
"def #{temp_method_name}(value)" <<
9999
" _write_attribute(#{attr_name_expr}, value)" <<

activerecord/lib/active_record/attribute_methods.rb

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,19 +77,21 @@ def eagerly_generate_alias_attribute_methods(_new_name, _old_name) # :nodoc:
7777
# alias attributes in Active Record are lazily generated
7878
end
7979

80+
def generate_alias_attribute_methods(code_generator, new_name, old_name) # :nodoc:
81+
attribute_method_patterns.each do |pattern|
82+
alias_attribute_method_definition(code_generator, pattern, new_name, old_name)
83+
end
84+
attribute_method_patterns_cache.clear
85+
end
86+
8087
def alias_attribute_method_definition(code_generator, pattern, new_name, old_name)
8188
old_name = old_name.to_s
8289

8390
if !abstract_class? && !has_attribute?(old_name)
8491
raise ArgumentError, "#{self.name} model aliases `#{old_name}`, but `#{old_name}` is not an attribute. " \
8592
"Use `alias_method :#{new_name}, :#{old_name}` or define the method manually."
8693
else
87-
method_name = pattern.method_name(new_name).to_s
88-
parameters = pattern.parameters
89-
90-
define_proxy_call(code_generator, method_name, pattern.proxy_target, parameters, old_name,
91-
namespace: :proxy_alias_attribute
92-
)
94+
define_attribute_method_pattern(pattern, old_name, owner: code_generator, as: new_name)
9395
end
9496
end
9597

activerecord/lib/active_record/attribute_methods/read.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ module Read
88

99
module ClassMethods # :nodoc:
1010
private
11-
def define_method_attribute(name, owner:)
11+
def define_method_attribute(canonical_name, owner:, as: canonical_name)
1212
ActiveModel::AttributeMethods::AttrNames.define_attribute_accessor_method(
13-
owner, name
13+
owner, canonical_name
1414
) do |temp_method_name, attr_name_expr|
15-
owner.define_cached_method(name, as: temp_method_name, namespace: :active_record) do |batch|
15+
owner.define_cached_method(temp_method_name, as: as, namespace: :active_record) do |batch|
1616
batch <<
1717
"def #{temp_method_name}" <<
1818
" _read_attribute(#{attr_name_expr}) { |n| missing_attribute(n, caller) }" <<

activerecord/lib/active_record/attribute_methods/write.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ module Write
1212

1313
module ClassMethods # :nodoc:
1414
private
15-
def define_method_attribute=(name, owner:)
15+
def define_method_attribute=(canonical_name, owner:, as: canonical_name)
1616
ActiveModel::AttributeMethods::AttrNames.define_attribute_accessor_method(
17-
owner, name, writer: true,
17+
owner, canonical_name, writer: true,
1818
) do |temp_method_name, attr_name_expr|
19-
owner.define_cached_method("#{name}=", as: temp_method_name, namespace: :active_record) do |batch|
19+
owner.define_cached_method(temp_method_name, as: "#{as}=", namespace: :active_record) do |batch|
2020
batch <<
2121
"def #{temp_method_name}(value)" <<
2222
" _write_attribute(#{attr_name_expr}, value)" <<

activerecord/test/cases/attribute_methods_test.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,8 +1220,10 @@ def some_method_that_is_not_on_super
12201220
test "aliases to the same attribute name do not conflict with each other" do
12211221
first_model_object = ToBeLoadedFirst.new(author_name: "author 1")
12221222
assert_equal("author 1", first_model_object.subject)
1223+
assert_equal([nil, "author 1"], first_model_object.subject_change)
12231224
second_model_object = ToBeLoadedSecond.new(title: "foo")
12241225
assert_equal("foo", second_model_object.subject)
1226+
assert_equal([nil, "foo"], second_model_object.subject_change)
12251227
end
12261228

12271229
test "#alias_attribute with an overridden original method does not use the overridden original method" do

activesupport/lib/active_support/code_generator.rb

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,30 @@ def initialize(namespace)
99
@cache = METHOD_CACHES[namespace]
1010
@sources = []
1111
@methods = {}
12+
@canonical_methods = {}
1213
end
1314

14-
def define_cached_method(name, as: name)
15-
name = name.to_sym
16-
as = as.to_sym
17-
@methods.fetch(name) do
18-
unless @cache.method_defined?(as)
15+
def define_cached_method(canonical_name, as: nil)
16+
canonical_name = canonical_name.to_sym
17+
as = (as || canonical_name).to_sym
18+
19+
@methods.fetch(as) do
20+
unless @cache.method_defined?(canonical_name) || @canonical_methods[canonical_name]
1921
yield @sources
2022
end
21-
@methods[name] = as
23+
@canonical_methods[canonical_name] = true
24+
@methods[as] = canonical_name
2225
end
2326
end
2427

2528
def apply(owner, path, line)
2629
unless @sources.empty?
2730
@cache.module_eval("# frozen_string_literal: true\n" + @sources.join(";"), path, line)
2831
end
29-
@methods.each do |name, as|
30-
owner.define_method(name, @cache.instance_method(as))
32+
@canonical_methods.clear
33+
34+
@methods.each do |as, canonical_name|
35+
owner.define_method(as, @cache.instance_method(canonical_name))
3136
end
3237
end
3338
end
@@ -52,8 +57,8 @@ def initialize(owner, path, line)
5257
@namespaces = Hash.new { |h, k| h[k] = MethodSet.new(k) }
5358
end
5459

55-
def define_cached_method(name, namespace:, as: name, &block)
56-
@namespaces[namespace].define_cached_method(name, as: as, &block)
60+
def define_cached_method(canonical_name, namespace:, as: nil, &block)
61+
@namespaces[namespace].define_cached_method(canonical_name, as: as, &block)
5762
end
5863

5964
def execute

0 commit comments

Comments
 (0)