Skip to content

Commit fd5bd98

Browse files
committed
Make the Relation -> Model delegation stricter
In rails#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 rails#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.
1 parent f6918a5 commit fd5bd98

File tree

13 files changed

+56
-50
lines changed

13 files changed

+56
-50
lines changed

activerecord/lib/active_record/associations/preloader/association.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ def initialize(scope, association_key_name)
1717
def eql?(other)
1818
association_key_name == other.association_key_name &&
1919
scope.table_name == other.scope.table_name &&
20-
scope.connection_specification_name == other.scope.connection_specification_name &&
20+
scope.model.connection_specification_name == other.scope.model.connection_specification_name &&
2121
scope.values_for_queries == other.scope.values_for_queries
2222
end
2323

2424
def hash
25-
[association_key_name, scope.table_name, scope.connection_specification_name, scope.values_for_queries].hash
25+
[association_key_name, scope.model.table_name, scope.model.connection_specification_name, scope.values_for_queries].hash
2626
end
2727

2828
def records_for(loaders)

activerecord/lib/active_record/encryption/extended_deterministic_queries.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ def self.install_support
4141
module EncryptedQuery # :nodoc:
4242
class << self
4343
def process_arguments(owner, args, check_for_additional_values)
44+
owner = owner.model if owner.is_a?(Relation)
45+
4446
return args if owner.deterministic_encrypted_attributes&.empty?
4547

4648
if args.is_a?(Array) && (options = args.first).is_a?(Hash)

activerecord/lib/active_record/relation.rb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ def compute_cache_key(timestamp_column = :updated_at) # :nodoc:
437437
query_signature = ActiveSupport::Digest.hexdigest(to_sql)
438438
key = "#{klass.model_name.cache_key}/query-#{query_signature}"
439439

440-
if collection_cache_versioning
440+
if model.collection_cache_versioning
441441
key
442442
else
443443
"#{key}-#{compute_cache_version(timestamp_column)}"
@@ -456,7 +456,7 @@ def compute_cache_key(timestamp_column = :updated_at) # :nodoc:
456456
#
457457
# SELECT COUNT(*), MAX("products"."updated_at") FROM "products" WHERE (name like '%Cosmic Encounter%')
458458
def cache_version(timestamp_column = :updated_at)
459-
if collection_cache_versioning
459+
if model.collection_cache_versioning
460460
@cache_versions ||= {}
461461
@cache_versions[timestamp_column] ||= compute_cache_version(timestamp_column)
462462
end
@@ -475,7 +475,7 @@ def compute_cache_version(timestamp_column) # :nodoc:
475475

476476
with_connection do |c|
477477
column = c.visitor.compile(table[timestamp_column])
478-
select_values = "COUNT(*) AS #{adapter_class.quote_column_name("size")}, MAX(%s) AS timestamp"
478+
select_values = "COUNT(*) AS #{klass.adapter_class.quote_column_name("size")}, MAX(%s) AS timestamp"
479479

480480
if collection.has_limit_or_offset?
481481
query = collection.select("#{column} AS collection_cache_key_timestamp")
@@ -501,7 +501,7 @@ def compute_cache_version(timestamp_column) # :nodoc:
501501
end
502502

503503
if timestamp
504-
"#{size}-#{timestamp.utc.to_fs(cache_timestamp_format)}"
504+
"#{size}-#{timestamp.utc.to_fs(model.cache_timestamp_format)}"
505505
else
506506
"#{size}"
507507
end
@@ -1291,7 +1291,7 @@ def has_limit_or_offset? # :nodoc:
12911291
end
12921292

12931293
def alias_tracker(joins = [], aliases = nil) # :nodoc:
1294-
ActiveRecord::Associations::AliasTracker.create(connection_pool, table.name, joins, aliases)
1294+
ActiveRecord::Associations::AliasTracker.create(klass.connection_pool, table.name, joins, aliases)
12951295
end
12961296

12971297
class StrictLoadingScope # :nodoc:
@@ -1451,7 +1451,7 @@ def instantiate_records(rows, &block)
14511451

14521452
def skip_query_cache_if_necessary(&block)
14531453
if skip_query_cache_value
1454-
uncached(&block)
1454+
model.uncached(&block)
14551455
else
14561456
yield
14571457
end

activerecord/lib/active_record/relation/batches.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,8 +327,8 @@ def act_on_ignored_order(error_on_ignore)
327327

328328
if raise_error
329329
raise ArgumentError.new(ORDER_IGNORE_MESSAGE)
330-
elsif logger
331-
logger.warn(ORDER_IGNORE_MESSAGE)
330+
elsif model.logger
331+
model.logger.warn(ORDER_IGNORE_MESSAGE)
332332
end
333333
end
334334

activerecord/lib/active_record/relation/calculations.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -522,13 +522,13 @@ def execute_grouped_calculation(operation, column_name, distinct) # :nodoc:
522522
column = aggregate_column(column_name)
523523
column_alias = column_alias_tracker.alias_for("#{operation} #{column_name.to_s.downcase}")
524524
select_value = operation_over_aggregate_column(column, operation, distinct)
525-
select_value.as(adapter_class.quote_column_name(column_alias))
525+
select_value.as(klass.adapter_class.quote_column_name(column_alias))
526526

527527
select_values = [select_value]
528528
select_values += self.select_values unless having_clause.empty?
529529

530530
select_values.concat group_columns.map { |aliaz, field|
531-
aliaz = adapter_class.quote_column_name(aliaz)
531+
aliaz = klass.adapter_class.quote_column_name(aliaz)
532532
if field.respond_to?(:as)
533533
field.as(aliaz)
534534
else
@@ -633,6 +633,7 @@ def select_for_count
633633
if select_values.present?
634634
return select_values.first if select_values.one?
635635

636+
adapter_class = klass.adapter_class
636637
select_values.map do |field|
637638
column = arel_column(field.to_s) do |attr_name|
638639
Arel.sql(attr_name)

activerecord/lib/active_record/relation/delegation.rb

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ def uncacheable_methods
2222
end
2323

2424
module DelegateCache # :nodoc:
25+
@delegate_base_methods = true
26+
singleton_class.attr_accessor :delegate_base_methods
27+
2528
def relation_delegate_class(klass)
2629
@relation_delegate_cache[klass]
2730
end
@@ -100,7 +103,7 @@ def #{method}(...)
100103
:to_sentence, :to_fs, :to_formatted_s, :as_json,
101104
:shuffle, :split, :slice, :index, :rindex, to: :records
102105

103-
delegate :primary_key, :lease_connection, :connection, :with_connection, :transaction, to: :klass
106+
delegate :primary_key, :with_connection, :connection, :table_name, :transaction, :sanitize_sql_like, :unscoped, to: :klass
104107

105108
module ClassSpecificRelation # :nodoc:
106109
extend ActiveSupport::Concern
@@ -114,9 +117,17 @@ def name
114117
private
115118
def method_missing(method, ...)
116119
if @klass.respond_to?(method)
117-
unless Delegation.uncacheable_methods.include?(method)
120+
if !DelegateCache.delegate_base_methods && Base.respond_to?(method)
121+
# A common mistake in Active Record's own code is to call `ActiveRecord::Base`
122+
# class methods on Association. It works because it's automatically delegated, but
123+
# can introduce subtle bugs because it sets the global scope.
124+
# We can't deprecate this behavior because gems might depend on it, however we
125+
# can ban it from Active Record's own test suite to avoid regressions.
126+
raise NotImplementedError, "Active Record code shouldn't rely on association delegation into ActiveRecord::Base methods"
127+
elsif !Delegation.uncacheable_methods.include?(method)
118128
@klass.generate_relation_method(method)
119129
end
130+
120131
scoping { @klass.public_send(method, ...) }
121132
else
122133
super

activerecord/lib/active_record/relation/finder_methods.rb

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,10 @@ def sole
145145

146146
if found.nil?
147147
raise_record_not_found_exception!
148-
elsif undesired.present?
149-
raise ActiveRecord::SoleRecordExceeded.new(self)
150-
else
148+
elsif undesired.nil?
151149
found
150+
else
151+
raise ActiveRecord::SoleRecordExceeded.new(model)
152152
end
153153
end
154154

@@ -376,7 +376,7 @@ def exists?(conditions = :none)
376376

377377
skip_query_cache_if_necessary do
378378
with_connection do |c|
379-
c.select_rows(relation.arel, "#{name} Exists?").size == 1
379+
c.select_rows(relation.arel, "#{klass.name} Exists?").size == 1
380380
end
381381
end
382382
end
@@ -638,7 +638,7 @@ def find_last(limit)
638638
end
639639

640640
def ordered_relation
641-
if order_values.empty? && (implicit_order_column || !query_constraints_list.nil? || primary_key)
641+
if order_values.empty? && (model.implicit_order_column || !model.query_constraints_list.nil? || primary_key)
642642
order(_order_columns.map { |column| table[column].asc })
643643
else
644644
self
@@ -648,11 +648,11 @@ def ordered_relation
648648
def _order_columns
649649
oc = []
650650

651-
oc << implicit_order_column if implicit_order_column
652-
oc << query_constraints_list if query_constraints_list
651+
oc << model.implicit_order_column if model.implicit_order_column
652+
oc << model.query_constraints_list if model.query_constraints_list
653653

654-
if primary_key && query_constraints_list.nil?
655-
oc << primary_key
654+
if model.primary_key && model.query_constraints_list.nil?
655+
oc << model.primary_key
656656
end
657657

658658
oc.flatten.uniq.compact

activerecord/lib/active_record/relation/query_methods.rb

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,10 @@ def missing(*associations)
136136

137137
private
138138
def scope_association_reflection(association)
139-
reflection = @scope.klass._reflect_on_association(association)
139+
model = @scope.model
140+
reflection = model._reflect_on_association(association)
140141
unless reflection
141-
raise ArgumentError.new("An association named `:#{association}` does not exist on the model `#{@scope.name}`.")
142+
raise ArgumentError.new("An association named `:#{association}` does not exist on the model `#{model.name}`.")
142143
end
143144
reflection
144145
end
@@ -254,6 +255,10 @@ def includes!(*args) # :nodoc:
254255
self
255256
end
256257

258+
def all # :nodoc:
259+
spawn
260+
end
261+
257262
# Specify associations +args+ to be eager loaded using a <tt>LEFT OUTER JOIN</tt>.
258263
# Performs a single query joining all specified associations. For example:
259264
#
@@ -703,7 +708,7 @@ def in_order_of(column, values)
703708
references = column_references([column])
704709
self.references_values |= references unless references.empty?
705710

706-
values = values.map { |value| type_caster.type_cast_for_database(column, value) }
711+
values = values.map { |value| model.type_caster.type_cast_for_database(column, value) }
707712
arel_column = column.is_a?(Arel::Nodes::SqlLiteral) ? column : order_column(column.to_s)
708713

709714
where_clause =
@@ -1914,7 +1919,7 @@ def arel_columns(columns)
19141919
case field
19151920
when Symbol
19161921
arel_column(field.to_s) do |attr_name|
1917-
adapter_class.quote_table_name(attr_name)
1922+
klass.adapter_class.quote_table_name(attr_name)
19181923
end
19191924
when String
19201925
arel_column(field, &:itself)
@@ -1946,7 +1951,7 @@ def arel_column(field)
19461951

19471952
def table_name_matches?(from)
19481953
table_name = Regexp.escape(table.name)
1949-
quoted_table_name = Regexp.escape(adapter_class.quote_table_name(table.name))
1954+
quoted_table_name = Regexp.escape(klass.adapter_class.quote_table_name(table.name))
19501955
/(?:\A|(?<!FROM)\s)(?:\b#{table_name}\b|#{quoted_table_name})(?!\.)/i.match?(from.to_s)
19511956
end
19521957

@@ -2081,7 +2086,7 @@ def order_column(field)
20812086
if attr_name == "count" && !group_values.empty?
20822087
table[attr_name]
20832088
else
2084-
Arel.sql(adapter_class.quote_table_name(attr_name), retryable: true)
2089+
Arel.sql(klass.adapter_class.quote_table_name(attr_name), retryable: true)
20852090
end
20862091
end
20872092
end

activerecord/lib/active_record/relation/record_fetch_warning.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ def exec_queries
2020
QueryRegistry.reset
2121

2222
super.tap do |records|
23-
if logger && ActiveRecord.warn_on_records_fetched_greater_than
23+
if model.logger && ActiveRecord.warn_on_records_fetched_greater_than
2424
if records.length > ActiveRecord.warn_on_records_fetched_greater_than
25-
logger.warn "Query fetched #{records.size} #{@klass} records: #{QueryRegistry.queries.join(";")}"
25+
model.logger.warn "Query fetched #{records.size} #{@klass} records: #{QueryRegistry.queries.join(";")}"
2626
end
2727
end
2828
end

activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -823,21 +823,6 @@ def test_association_proxy_transaction_method_starts_transaction_in_association_
823823
end
824824
end
825825

826-
def test_caching_of_columns
827-
david = Developer.find(1)
828-
# clear cache possibly created by other tests
829-
david.projects.reset_column_information
830-
831-
assert_queries_count(include_schema: true) { david.projects.columns }
832-
assert_no_queries { david.projects.columns }
833-
834-
## and again to verify that reset_column_information clears the cache correctly
835-
david.projects.reset_column_information
836-
837-
assert_queries_count(include_schema: true) { david.projects.columns }
838-
assert_no_queries { david.projects.columns }
839-
end
840-
841826
def test_attributes_are_being_set_when_initialized_from_habtm_association_with_where_clause
842827
new_developer = projects(:action_controller).developers.where(name: "Marcelo").build
843828
assert_equal "Marcelo", new_developer.name

0 commit comments

Comments
 (0)