You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
With #48533 circa Rails 7.1, attribute methods and aliases are defined "lazily" on AR subclasses, meaning they get defined on the first call to ActiveRecord::Base#initialize via #init_internals:
Since defining attribute methods (and thus aliases atop of them) may involve querying the database, it's preferable to evaluate these lazily, rather than (say) tying them to class definition time as the Rails app starts up.
However, when loading a model from a cache in a process that has yet to connect to the database, we can manage to create a new instance of an ActiveRecord::Base subclass without calling the initializer, so the attribute methods & aliases never get defined by #init_internals. (Yes, we probably don't want to be storing models in the cache due to other issues like #49826. But the lazy evaluation does still cause a regression in behavior versus Rails 7.0, so I thought it was worth bringing up.)
This works out fine for regular attribute methods, since the #attribute_missing hook will dynamically look up values in the @attributes at run time:
However, attribute aliases perform no such method_missing magic. This can lead us to fail with a NoMethodError—or perhaps more insidiously, silently delegate to a supermethod that would have been clobbered by the alias.
Steps to reproduce
# frozen_string_literal: truerequire"bundler/inline"gemfile(true)dosource"https://rubygems.org"gem"rails","~> 7.1.0"gem"sqlite3","~> 1.4"endrequire"active_record"require"active_support/cache"require"minitest/autorun"require"logger"require"tempfile"database=Tempfile.create# shared database file so separate processes can reference itActiveRecord::Base.establish_connection(adapter: "sqlite3",database: database.path)ActiveRecord::Base.logger=Logger.new(STDOUT)ActiveRecord::Schema.definedocreate_table:models,force: truedo |t|
t.string:old_namet.string:value_from_attributeendendclassModel < ActiveRecord::Basealias_attribute:new_name,:old_namedefvalue_from_attribute_or_supermethod"value from supermethod"endendclassModelWithSupermethod < Modelalias_attribute:value_from_attribute_or_supermethod,:value_from_attributeend
$cache =ActiveSupport::Cache::FileStore.new(Dir.mktmpdir("file-store-"))# file store so separate processes can reference itclassAliasedAttributesTest < Minitest::Testdefsetup
$cache.clearenddeftest_read_model_from_cache_without_connecting_to_databasepid=forkdorefuteModel.instance_methods.include?(:old_name),"expected model not to define attribute methods until initialization"refuteModel.instance_methods.include?(:new_name),"expected model not to define attribute aliases until initialization"model=Model.create!(old_name: "test")assertModel.instance_methods.include?(:old_name),"expected model to have lazily defined attribute methods on initialization"assertModel.instance_methods.include?(:new_name),"expected model to have lazily defined attribute aliases on initialization"assert_equal"test",model.old_nameassert_equal"test",model.new_name
$cache.write('model',model)endProcess.wait(pid)assert $cache.exist?('model'),"expected cache to be populated by a separate process"refuteModel.instance_methods.include?(:old_name),"expected separate process not to affect method definitions in this process"refuteModel.instance_methods.include?(:new_name),"expected separate process not to affect method definitions in this process"model= $cache.read('model')refuteModel.instance_methods.include?(:old_name),"expected model not to have defined attribute methods yet, since we haven't initialized"refuteModel.instance_methods.include?(:new_name),"expected model not to have defined attribute aliases yet, since we haven't initialized"assert_instance_ofModel,modelassert_equal"test",model.old_name,"expected attribute method to still work dynamically due to attribute_missing hook"beginassert_equal"test",model.new_namerescueNoMethodError=>eflunk"attribute aliases do not perform any method_missing magic\n#{e.class}: #{e.message}"endenddeftest_alias_that_should_have_clobbered_supermethodpid=forkdomodel=ModelWithSupermethod.new(value_from_attribute: "value from attribute")assert_equal"value from attribute",model.value_from_attribute_or_supermethod
$cache.write('model',model)endProcess.wait(pid)assert $cache.exist?('model'),"expected cache to be populated by a separate process"model= $cache.read('model')assert_instance_ofModelWithSupermethod,modelassert_equal"value from attribute",model.value_from_attribute_or_supermethod,"supermethod did not get shadowed by attribute alias"endend
Expected behavior
Test cases above should pass.
Actual behavior
F
Failure:
AliasedAttributesTest#test_read_model_from_cache_without_connecting_to_database [repro.rb:71]:
attribute aliases do not perform any method_missing magic
NoMethodError: undefined method `new_name' for #<Model id: 1, old_name: "test", value_from_attribute: nil>
bin/rails test repro.rb:48
F
Failure:
AliasedAttributesTest#test_alias_that_should_have_clobbered_supermethod [repro.rb:85]:
supermethod did not get shadowed by attribute alias.
Expected: "value from attribute"
Actual: "value from supermethod"
bin/rails test repro.rb:75
System configuration
Rails version: 7.1
Ruby version: 3.2.2
The text was updated successfully, but these errors were encountered:
justinko
added a commit
to justinko/rails
that referenced
this issue
May 4, 2024
If attribute methods are lazily loaded, and aliases are tied to attributes (and should only be tied to attributes), then how would it be possible to make aliases lazy?
With #48533 circa Rails 7.1, attribute methods and aliases are defined "lazily" on AR subclasses, meaning they get defined on the first call to
ActiveRecord::Base#initialize
via#init_internals
:rails/activerecord/lib/active_record/core.rb
Lines 755 to 756 in 1818beb
Since defining attribute methods (and thus aliases atop of them) may involve querying the database, it's preferable to evaluate these lazily, rather than (say) tying them to class definition time as the Rails app starts up.
However, when loading a model from a cache in a process that has yet to connect to the database, we can manage to create a new instance of an
ActiveRecord::Base
subclass without calling the initializer, so the attribute methods & aliases never get defined by#init_internals
. (Yes, we probably don't want to be storing models in the cache due to other issues like #49826. But the lazy evaluation does still cause a regression in behavior versus Rails 7.0, so I thought it was worth bringing up.)This works out fine for regular attribute methods, since the
#attribute_missing
hook will dynamically look up values in the@attributes
at run time:rails/activemodel/lib/active_model/attribute_methods.rb
Lines 484 to 491 in c3c164c
rails/activemodel/lib/active_model/attribute_methods.rb
Lines 498 to 500 in c3c164c
However, attribute aliases perform no such
method_missing
magic. This can lead us to fail with aNoMethodError
—or perhaps more insidiously, silently delegate to a supermethod that would have been clobbered by the alias.Steps to reproduce
Expected behavior
Test cases above should pass.
Actual behavior
System configuration
Rails version: 7.1
Ruby version: 3.2.2
The text was updated successfully, but these errors were encountered: