Skip to content

Commit

Permalink
Call proxy methods from alias_attribute generated methods
Browse files Browse the repository at this point in the history
This commit changes bodies of methods generated by `alias_attribute`
along with generating these methods lazily.

Previously the body of the `alias_attribute :new_title, :title` was
`def new_title; title; end`. This commit changes it to
`def new_title; attribute("title"); end`.

This allows for `alias_attribute` to be used to alias attributes named
with a reserved names like `id`:
```ruby
  class Topic < ActiveRecord::Base
    self.primary_key = :title
    alias_attribute :id_value, :id
  end

  Topic.new.id_value # => 1
```
  • Loading branch information
nvasilevski committed Jul 17, 2023
1 parent 99f8b04 commit 1818beb
Show file tree
Hide file tree
Showing 8 changed files with 188 additions and 23 deletions.
58 changes: 35 additions & 23 deletions activemodel/lib/active_model/attribute_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -203,34 +203,46 @@ def attribute_method_affix(*affixes)
# person.nickname_short? # => true
def alias_attribute(new_name, old_name)
self.attribute_aliases = attribute_aliases.merge(new_name.to_s => old_name.to_s)
ActiveSupport::CodeGenerator.batch(self, __FILE__, __LINE__) do |code_generator|
attribute_method_patterns.each do |pattern|
method_name = pattern.method_name(new_name).to_s
target_name = pattern.method_name(old_name).to_s
parameters = pattern.parameters
eagerly_generate_alias_attribute_methods(new_name, old_name)
end

mangled_name = target_name
unless NAME_COMPILABLE_REGEXP.match?(target_name)
mangled_name = "__temp__#{target_name.unpack1("h*")}"
end
def eagerly_generate_alias_attribute_methods(new_name, old_name) # :nodoc:
ActiveSupport::CodeGenerator.batch(generated_attribute_methods, __FILE__, __LINE__) do |code_generator|
generate_alias_attribute_methods(code_generator, new_name, old_name)
end
end

code_generator.define_cached_method(method_name, as: mangled_name, namespace: :alias_attribute) do |batch|
body = if CALL_COMPILABLE_REGEXP.match?(target_name)
"self.#{target_name}(#{parameters || ''})"
else
call_args = [":'#{target_name}'"]
call_args << parameters if parameters
"send(#{call_args.join(", ")})"
end
def generate_alias_attribute_methods(code_generator, new_name, old_name)
attribute_method_patterns.each do |pattern|
alias_attribute_method_definition(code_generator, pattern, new_name, old_name)
end
end

modifier = pattern.parameters == FORWARD_PARAMETERS ? "ruby2_keywords " : ""
def alias_attribute_method_definition(code_generator, pattern, new_name, old_name) # :nodoc:
method_name = pattern.method_name(new_name).to_s
target_name = pattern.method_name(old_name).to_s
parameters = pattern.parameters
mangled_name = target_name

batch <<
"#{modifier}def #{mangled_name}(#{parameters || ''})" <<
body <<
"end"
end
unless NAME_COMPILABLE_REGEXP.match?(target_name)
mangled_name = "__temp__#{target_name.unpack1("h*")}"
end

code_generator.define_cached_method(method_name, as: mangled_name, namespace: :alias_attribute) do |batch|
body = if CALL_COMPILABLE_REGEXP.match?(target_name)
"self.#{target_name}(#{parameters || ''})"
else
call_args = [":'#{target_name}'"]
call_args << parameters if parameters
"send(#{call_args.join(", ")})"
end

modifier = parameters == FORWARD_PARAMETERS ? "ruby2_keywords " : ""

batch <<
"#{modifier}def #{mangled_name}(#{parameters || ''})" <<
body <<
"end"
end
end

Expand Down
59 changes: 59 additions & 0 deletions activerecord/lib/active_record/attribute_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,68 @@ def initialize_generated_modules # :nodoc:
@generated_attribute_methods = const_set(:GeneratedAttributeMethods, GeneratedAttributeMethods.new)
private_constant :GeneratedAttributeMethods
@attribute_methods_generated = false
@alias_attributes_mass_generated = false
include @generated_attribute_methods

super
end

def alias_attribute(new_name, old_name)
super

if @alias_attributes_mass_generated
ActiveSupport::CodeGenerator.batch(generated_attribute_methods, __FILE__, __LINE__) do |code_generator|
generate_alias_attribute_methods(code_generator, new_name, old_name)
end
end
end

def eagerly_generate_alias_attribute_methods(_new_name, _old_name) # :nodoc:
# alias attributes in Active Record are lazily generated
end

def generate_alias_attributes # :nodoc:
return if @alias_attributes_mass_generated

generated_attribute_methods.synchronize do
return if @alias_attributes_mass_generated
ActiveSupport::CodeGenerator.batch(generated_attribute_methods, __FILE__, __LINE__) do |code_generator|
attribute_aliases.each do |new_name, old_name|
generate_alias_attribute_methods(code_generator, new_name, old_name)
end
end

@alias_attributes_mass_generated = true
end
end

def alias_attribute_method_definition(code_generator, pattern, new_name, old_name)
method_name = pattern.method_name(new_name).to_s
target_name = pattern.method_name(old_name).to_s
parameters = pattern.parameters
old_name = old_name.to_s

method_defined = method_defined?(target_name) || private_method_defined?(target_name)
manually_defined = method_defined && self.instance_method(target_name).owner != generated_attribute_methods
reserved_method_name = ::ActiveRecord::AttributeMethods.dangerous_attribute_methods.include?(target_name)

if manually_defined && !reserved_method_name
aliased_method_redefined_as_well = method_defined_within?(method_name, self)
return if aliased_method_redefined_as_well

ActiveModel.deprecator.warn(
"#{self} model aliases `#{old_name}` and has a method called `#{target_name}` defined. " \
"Since Rails 7.2 `#{method_name}` will not be calling `#{target_name}` anymore. " \
"You may want to additionally define `#{method_name}` to preserve the current behavior."
)
super
else
define_proxy_call(code_generator, method_name, pattern.proxy_target, parameters, old_name,
namespace: :proxy_alias_attribute
)
end
end

# Generates all the attribute related methods for columns in the database
# accessors, mutators and query methods.
def define_attribute_methods # :nodoc:
Expand All @@ -67,6 +124,7 @@ def undefine_attribute_methods # :nodoc:
generated_attribute_methods.synchronize do
super if defined?(@attribute_methods_generated) && @attribute_methods_generated
@attribute_methods_generated = false
@alias_attributes_mass_generated = false
end
end

Expand Down Expand Up @@ -188,6 +246,7 @@ def inherited(child_class)
super
child_class.initialize_generated_modules
child_class.class_eval do
@alias_attributes_mass_generated = false
@attribute_names = nil
end
end
Expand Down
1 change: 1 addition & 0 deletions activerecord/lib/active_record/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,7 @@ def init_internals
@strict_loading_mode = :all

klass.define_attribute_methods
klass.generate_alias_attributes
end

def initialize_internals_callback
Expand Down
87 changes: 87 additions & 0 deletions activerecord/test/cases/attribute_methods_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
require "models/contact"
require "models/keyboard"
require "models/numeric_data"
require "models/cpk"

class AttributeMethodsTest < ActiveRecord::TestCase
include InTimeZone
Expand All @@ -30,6 +31,19 @@ def setup
ActiveRecord::Base.send(:attribute_method_patterns).concat(@old_matchers)
end

test "aliasing `id` attribute allows reading the column value" do
topic = Topic.create(id: 123_456, title: "title").becomes(TitlePrimaryKeyTopic)

assert_equal(123_456, topic.id_value)
end

test "aliasing `id` attribute allows reading the column value for a CPK model" do
order = ::Cpk::Order.create(id: [1, 123_456])

assert_not_nil(order.id_value)
assert_equal(123_456, order.id_value)
end

test "attribute_for_inspect with a string" do
t = topics(:first)
t.title = "The First Topic Now Has A Title With\nNewlines And More Than 50 Characters"
Expand Down Expand Up @@ -1130,6 +1144,79 @@ def some_method_that_is_not_on_super
assert_equal "abcd", model.read_attribute_before_type_cast("new_bank_balance")
end

ClassWithDeprecatedAliasAttributeBehavior = Class.new(ActiveRecord::Base) do
self.table_name = "topics"
alias_attribute :subject, :title

def title_was
"overridden_title_was"
end
end

test "#alias_attribute with an overridden original method issues a deprecation" do
message = <<~MESSAGE.gsub("\n", " ")
AttributeMethodsTest::ClassWithDeprecatedAliasAttributeBehavior model aliases `title` and has a method called
`title_was` defined. Since Rails 7.2 `subject_was` will not be calling `title_was` anymore.
You may want to additionally define `subject_was` to preserve the current behavior.
MESSAGE

obj = assert_deprecated(message, ActiveModel.deprecator) do
ClassWithDeprecatedAliasAttributeBehavior.new
end
obj.title = "hey"
assert_equal("hey", obj.subject)
assert_equal("overridden_title_was", obj.subject_was)
end

TitleWasOverride = Module.new do
def title_was
"overridden_title_was"
end
end

ClassWithDeprecatedAliasAttributeBehaviorFromModule = Class.new(ActiveRecord::Base) do
self.table_name = "topics"
include TitleWasOverride
alias_attribute :subject, :title
end

test "#alias_attribute with an overridden original method from a module issues a deprecation" do
message = <<~MESSAGE.gsub("\n", " ")
AttributeMethodsTest::ClassWithDeprecatedAliasAttributeBehaviorFromModule model aliases `title` and has a method
called `title_was` defined. Since Rails 7.2 `subject_was` will not be calling `title_was` anymore.
You may want to additionally define `subject_was` to preserve the current behavior.
MESSAGE

obj = assert_deprecated(message, ActiveModel.deprecator) do
ClassWithDeprecatedAliasAttributeBehaviorFromModule.new
end
obj.title = "hey"
assert_equal("hey", obj.subject)
assert_equal("overridden_title_was", obj.subject_was)
end

ClassWithDeprecatedAliasAttributeBehaviorResolved = Class.new(ActiveRecord::Base) do
self.table_name = "topics"
alias_attribute :subject, :title

def title_was
"overridden_title_was"
end

def subject_was
"overridden_subject_was"
end
end

test "#alias_attribute with an overridden original method along with an overridden alias method doesn't issue a deprecation" do
obj = assert_not_deprecated(ActiveModel.deprecator) do
ClassWithDeprecatedAliasAttributeBehaviorResolved.new
end
obj.title = "hey"
assert_equal("hey", obj.subject)
assert_equal("overridden_subject_was", obj.subject_was)
end

private
def new_topic_like_ar_class(&block)
klass = Class.new(ActiveRecord::Base) do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

class NumericalityValidationTest < ActiveRecord::TestCase
def setup
NumericData.generate_alias_attributes
@model_class = NumericData.dup
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class ReplyWithTitleObject < Reply
validates_uniqueness_of :content, scope: :title

def title; ReplyTitle.new; end
alias heading title
end

class TopicWithEvent < Topic
Expand Down
2 changes: 2 additions & 0 deletions activerecord/test/models/cpk/order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ class Order < ActiveRecord::Base
# to be shared between different databases
self.primary_key = [:shop_id, :id]

alias_attribute :id_value, :id

has_many :order_agreements, primary_key: :id
has_many :books, query_constraints: [:shop_id, :order_id]
has_one :book, query_constraints: [:shop_id, :order_id]
Expand Down
2 changes: 2 additions & 0 deletions activerecord/test/models/topic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ def blank?

class TitlePrimaryKeyTopic < Topic
self.primary_key = :title

alias_attribute :id_value, :id
end

module Web
Expand Down

0 comments on commit 1818beb

Please sign in to comment.