Make the Relation -> Model delegation stricter

In https://github.com/rails/rails/pull/50395 I noticed lots of
methods are delegated from `Relation` to the model. The intent of
this code is to allow using use defined class methods like scopes.

But because of this autmated delegation it allows calling any
`ActiveRecord::Base` class method on a `Relation`, which in itself
may be desireable, however we very wastefully define the delegator
on the first call, and worse we wrap it with a global scope setter.

This also has led to bugs in the past, like https://github.com/rails/rails/pull/51776

So I think we should be more strict about it.

We can't deprecate this behavior because gems might depend on it, however we
can ban it from Active Record's own test suite to avoid regressions.
This commit is contained in:
Jean Boussier 2023-12-19 11:46:11 +01:00
parent f6918a5b5d
commit fd5bd98b34
13 changed files with 56 additions and 50 deletions

View File

@ -17,12 +17,12 @@ module ActiveRecord
def eql?(other)
association_key_name == other.association_key_name &&
scope.table_name == other.scope.table_name &&
scope.connection_specification_name == other.scope.connection_specification_name &&
scope.model.connection_specification_name == other.scope.model.connection_specification_name &&
scope.values_for_queries == other.scope.values_for_queries
end
def hash
[association_key_name, scope.table_name, scope.connection_specification_name, scope.values_for_queries].hash
[association_key_name, scope.model.table_name, scope.model.connection_specification_name, scope.values_for_queries].hash
end
def records_for(loaders)

View File

@ -41,6 +41,8 @@ module ActiveRecord
module EncryptedQuery # :nodoc:
class << self
def process_arguments(owner, args, check_for_additional_values)
owner = owner.model if owner.is_a?(Relation)
return args if owner.deterministic_encrypted_attributes&.empty?
if args.is_a?(Array) && (options = args.first).is_a?(Hash)

View File

@ -437,7 +437,7 @@ module ActiveRecord
query_signature = ActiveSupport::Digest.hexdigest(to_sql)
key = "#{klass.model_name.cache_key}/query-#{query_signature}"
if collection_cache_versioning
if model.collection_cache_versioning
key
else
"#{key}-#{compute_cache_version(timestamp_column)}"
@ -456,7 +456,7 @@ module ActiveRecord
#
# SELECT COUNT(*), MAX("products"."updated_at") FROM "products" WHERE (name like '%Cosmic Encounter%')
def cache_version(timestamp_column = :updated_at)
if collection_cache_versioning
if model.collection_cache_versioning
@cache_versions ||= {}
@cache_versions[timestamp_column] ||= compute_cache_version(timestamp_column)
end
@ -475,7 +475,7 @@ module ActiveRecord
with_connection do |c|
column = c.visitor.compile(table[timestamp_column])
select_values = "COUNT(*) AS #{adapter_class.quote_column_name("size")}, MAX(%s) AS timestamp"
select_values = "COUNT(*) AS #{klass.adapter_class.quote_column_name("size")}, MAX(%s) AS timestamp"
if collection.has_limit_or_offset?
query = collection.select("#{column} AS collection_cache_key_timestamp")
@ -501,7 +501,7 @@ module ActiveRecord
end
if timestamp
"#{size}-#{timestamp.utc.to_fs(cache_timestamp_format)}"
"#{size}-#{timestamp.utc.to_fs(model.cache_timestamp_format)}"
else
"#{size}"
end
@ -1291,7 +1291,7 @@ module ActiveRecord
end
def alias_tracker(joins = [], aliases = nil) # :nodoc:
ActiveRecord::Associations::AliasTracker.create(connection_pool, table.name, joins, aliases)
ActiveRecord::Associations::AliasTracker.create(klass.connection_pool, table.name, joins, aliases)
end
class StrictLoadingScope # :nodoc:
@ -1451,7 +1451,7 @@ module ActiveRecord
def skip_query_cache_if_necessary(&block)
if skip_query_cache_value
uncached(&block)
model.uncached(&block)
else
yield
end

View File

@ -327,8 +327,8 @@ module ActiveRecord
if raise_error
raise ArgumentError.new(ORDER_IGNORE_MESSAGE)
elsif logger
logger.warn(ORDER_IGNORE_MESSAGE)
elsif model.logger
model.logger.warn(ORDER_IGNORE_MESSAGE)
end
end

View File

@ -522,13 +522,13 @@ module ActiveRecord
column = aggregate_column(column_name)
column_alias = column_alias_tracker.alias_for("#{operation} #{column_name.to_s.downcase}")
select_value = operation_over_aggregate_column(column, operation, distinct)
select_value.as(adapter_class.quote_column_name(column_alias))
select_value.as(klass.adapter_class.quote_column_name(column_alias))
select_values = [select_value]
select_values += self.select_values unless having_clause.empty?
select_values.concat group_columns.map { |aliaz, field|
aliaz = adapter_class.quote_column_name(aliaz)
aliaz = klass.adapter_class.quote_column_name(aliaz)
if field.respond_to?(:as)
field.as(aliaz)
else
@ -633,6 +633,7 @@ module ActiveRecord
if select_values.present?
return select_values.first if select_values.one?
adapter_class = klass.adapter_class
select_values.map do |field|
column = arel_column(field.to_s) do |attr_name|
Arel.sql(attr_name)

View File

@ -22,6 +22,9 @@ module ActiveRecord
end
module DelegateCache # :nodoc:
@delegate_base_methods = true
singleton_class.attr_accessor :delegate_base_methods
def relation_delegate_class(klass)
@relation_delegate_cache[klass]
end
@ -100,7 +103,7 @@ module ActiveRecord
:to_sentence, :to_fs, :to_formatted_s, :as_json,
:shuffle, :split, :slice, :index, :rindex, to: :records
delegate :primary_key, :lease_connection, :connection, :with_connection, :transaction, to: :klass
delegate :primary_key, :with_connection, :connection, :table_name, :transaction, :sanitize_sql_like, :unscoped, to: :klass
module ClassSpecificRelation # :nodoc:
extend ActiveSupport::Concern
@ -114,9 +117,17 @@ module ActiveRecord
private
def method_missing(method, ...)
if @klass.respond_to?(method)
unless Delegation.uncacheable_methods.include?(method)
if !DelegateCache.delegate_base_methods && Base.respond_to?(method)
# A common mistake in Active Record's own code is to call `ActiveRecord::Base`
# class methods on Association. It works because it's automatically delegated, but
# can introduce subtle bugs because it sets the global scope.
# We can't deprecate this behavior because gems might depend on it, however we
# can ban it from Active Record's own test suite to avoid regressions.
raise NotImplementedError, "Active Record code shouldn't rely on association delegation into ActiveRecord::Base methods"
elsif !Delegation.uncacheable_methods.include?(method)
@klass.generate_relation_method(method)
end
scoping { @klass.public_send(method, ...) }
else
super

View File

@ -145,10 +145,10 @@ module ActiveRecord
if found.nil?
raise_record_not_found_exception!
elsif undesired.present?
raise ActiveRecord::SoleRecordExceeded.new(self)
else
elsif undesired.nil?
found
else
raise ActiveRecord::SoleRecordExceeded.new(model)
end
end
@ -376,7 +376,7 @@ module ActiveRecord
skip_query_cache_if_necessary do
with_connection do |c|
c.select_rows(relation.arel, "#{name} Exists?").size == 1
c.select_rows(relation.arel, "#{klass.name} Exists?").size == 1
end
end
end
@ -638,7 +638,7 @@ module ActiveRecord
end
def ordered_relation
if order_values.empty? && (implicit_order_column || !query_constraints_list.nil? || primary_key)
if order_values.empty? && (model.implicit_order_column || !model.query_constraints_list.nil? || primary_key)
order(_order_columns.map { |column| table[column].asc })
else
self
@ -648,11 +648,11 @@ module ActiveRecord
def _order_columns
oc = []
oc << implicit_order_column if implicit_order_column
oc << query_constraints_list if query_constraints_list
oc << model.implicit_order_column if model.implicit_order_column
oc << model.query_constraints_list if model.query_constraints_list
if primary_key && query_constraints_list.nil?
oc << primary_key
if model.primary_key && model.query_constraints_list.nil?
oc << model.primary_key
end
oc.flatten.uniq.compact

View File

@ -136,9 +136,10 @@ module ActiveRecord
private
def scope_association_reflection(association)
reflection = @scope.klass._reflect_on_association(association)
model = @scope.model
reflection = model._reflect_on_association(association)
unless reflection
raise ArgumentError.new("An association named `:#{association}` does not exist on the model `#{@scope.name}`.")
raise ArgumentError.new("An association named `:#{association}` does not exist on the model `#{model.name}`.")
end
reflection
end
@ -254,6 +255,10 @@ module ActiveRecord
self
end
def all # :nodoc:
spawn
end
# Specify associations +args+ to be eager loaded using a <tt>LEFT OUTER JOIN</tt>.
# Performs a single query joining all specified associations. For example:
#
@ -703,7 +708,7 @@ module ActiveRecord
references = column_references([column])
self.references_values |= references unless references.empty?
values = values.map { |value| type_caster.type_cast_for_database(column, value) }
values = values.map { |value| model.type_caster.type_cast_for_database(column, value) }
arel_column = column.is_a?(Arel::Nodes::SqlLiteral) ? column : order_column(column.to_s)
where_clause =
@ -1914,7 +1919,7 @@ module ActiveRecord
case field
when Symbol
arel_column(field.to_s) do |attr_name|
adapter_class.quote_table_name(attr_name)
klass.adapter_class.quote_table_name(attr_name)
end
when String
arel_column(field, &:itself)
@ -1946,7 +1951,7 @@ module ActiveRecord
def table_name_matches?(from)
table_name = Regexp.escape(table.name)
quoted_table_name = Regexp.escape(adapter_class.quote_table_name(table.name))
quoted_table_name = Regexp.escape(klass.adapter_class.quote_table_name(table.name))
/(?:\A|(?<!FROM)\s)(?:\b#{table_name}\b|#{quoted_table_name})(?!\.)/i.match?(from.to_s)
end
@ -2081,7 +2086,7 @@ module ActiveRecord
if attr_name == "count" && !group_values.empty?
table[attr_name]
else
Arel.sql(adapter_class.quote_table_name(attr_name), retryable: true)
Arel.sql(klass.adapter_class.quote_table_name(attr_name), retryable: true)
end
end
end

View File

@ -20,9 +20,9 @@ module ActiveRecord
QueryRegistry.reset
super.tap do |records|
if logger && ActiveRecord.warn_on_records_fetched_greater_than
if model.logger && ActiveRecord.warn_on_records_fetched_greater_than
if records.length > ActiveRecord.warn_on_records_fetched_greater_than
logger.warn "Query fetched #{records.size} #{@klass} records: #{QueryRegistry.queries.join(";")}"
model.logger.warn "Query fetched #{records.size} #{@klass} records: #{QueryRegistry.queries.join(";")}"
end
end
end

View File

@ -823,21 +823,6 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase
end
end
def test_caching_of_columns
david = Developer.find(1)
# clear cache possibly created by other tests
david.projects.reset_column_information
assert_queries_count(include_schema: true) { david.projects.columns }
assert_no_queries { david.projects.columns }
## and again to verify that reset_column_information clears the cache correctly
david.projects.reset_column_information
assert_queries_count(include_schema: true) { david.projects.columns }
assert_no_queries { david.projects.columns }
end
def test_attributes_are_being_set_when_initialized_from_habtm_association_with_where_clause
new_developer = projects(:action_controller).developers.where(name: "Marcelo").build
assert_equal "Marcelo", new_developer.name

View File

@ -26,6 +26,8 @@ ActiveRecord.deprecator.debug = true
# to ensure it's not used internally.
ActiveRecord.permanent_connection_checkout = :disallowed
ActiveRecord::Delegation::DelegateCache.delegate_base_methods = false
# Disable available locale checks to avoid warnings running the test suite.
I18n.enforce_available_locales = false

View File

@ -62,7 +62,7 @@ module ActiveRecord
ActiveRecord::SpawnMethods.public_instance_methods(false) - [:spawn, :merge!] +
ActiveRecord::QueryMethods.public_instance_methods(false).reject { |method|
method.end_with?("=", "!", "?", "value", "values", "clause")
} - [:reverse_order, :arel, :extensions, :construct_join_dependency] + [
} - [:all, :reverse_order, :arel, :extensions, :construct_join_dependency] + [
:any?, :many?, :none?, :one?,
:first_or_create, :first_or_create!, :first_or_initialize,
:find_or_create_by, :find_or_create_by!, :find_or_initialize_by,

View File

@ -209,10 +209,10 @@ class Author < ActiveRecord::Base
has_many :posts_with_default_include, class_name: "PostWithDefaultInclude"
has_many :comments_on_posts_with_default_include, through: :posts_with_default_include, source: :comments
has_many :posts_with_signature, ->(record) { where(arel_table[:title].matches("%by #{record.name.downcase}%")) }, class_name: "Post"
has_many :posts_mentioning_author, ->(record = nil) { where(arel_table[:body].matches("%#{record&.name&.downcase}%")) }, class_name: "Post"
has_many :posts_with_signature, ->(record) { where(model.arel_table[:title].matches("%by #{record.name.downcase}%")) }, class_name: "Post"
has_many :posts_mentioning_author, ->(record = nil) { where(model.arel_table[:body].matches("%#{record&.name&.downcase}%")) }, class_name: "Post"
has_many :comments_on_posts_mentioning_author, through: :posts_mentioning_author, source: :comments
has_many :comments_mentioning_author, ->(record) { where(arel_table[:body].matches("%#{record.name.downcase}%")) }, through: :posts, source: :comments
has_many :comments_mentioning_author, ->(record) { where(model.arel_table[:body].matches("%#{record.name.downcase}%")) }, through: :posts, source: :comments
has_one :recent_post, -> { order(id: :desc) }, class_name: "Post"
has_one :recent_response, through: :recent_post, source: :comments