From 3794440e0808295ab7be4aeab05dad29831ee5b4 Mon Sep 17 00:00:00 2001 From: Nikita Vasilevsky Date: Thu, 2 Apr 2026 11:42:58 -0400 Subject: [PATCH 1/3] Introduce decoupled query_constraints for associations Reintroduces query_constraints on associations, decoupled from foreign_key. When both are specified, foreign_key handles writes and query_constraints is used for querying association targets (loading and preloading). --- activerecord/CHANGELOG.md | 22 +++ .../associations/association_scope.rb | 6 +- .../lib/active_record/associations/errors.rb | 4 +- .../associations/preloader/association.rb | 4 +- activerecord/lib/active_record/reflection.rb | 160 ++++++++++++++++-- .../belongs_to_associations_test.rb | 94 +++++++++- .../has_many_associations_test.rb | 8 +- .../associations/has_one_associations_test.rb | 8 +- activerecord/test/cases/associations_test.rb | 23 +-- activerecord/test/cases/reflection_test.rb | 101 ++++++++--- .../test/fixtures/sharded_blog_posts.yml | 1 + activerecord/test/models/sharded/blog_post.rb | 7 +- .../models/sharded/blog_post_destroy_async.rb | 4 +- .../models/sharded/blog_post_with_revision.rb | 2 +- activerecord/test/models/sharded/comment.rb | 1 + .../models/sharded/comment_destroy_async.rb | 2 +- activerecord/test/schema/schema.rb | 1 + 17 files changed, 372 insertions(+), 76 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index f96abde91ea6d..fe13b7f538437 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,25 @@ +* Support `query_constraints` on associations, decoupled from `foreign_key`. + + `query_constraints` declares *additional* columns to match when querying an + association's targets (loading and preloading), layered on top of the foreign + key. When both `foreign_key` and `query_constraints` are given, the foreign key + handles writes while querying matches on the foreign key plus the extra columns. + + A column may map to a different name on the other side using a Hash, and + listing the foreign key itself is allowed (it is de-duplicated): + + ``` ruby + class BlogPost < ApplicationRecord + # match blog_id on both tables, and BlogPost#id -> Comment#blog_post_id + belongs_to :featured_comment, + class_name: "Comment", + foreign_key: :featured_comment_id, + query_constraints: [:blog_id, { id: :blog_post_id }] + end + ``` + + *Nikita Vasilevsky* + * Report PostgreSQL default timestamp and time precision as 6. Bare PostgreSQL `timestamp` and `time` columns now use their effective diff --git a/activerecord/lib/active_record/associations/association_scope.rb b/activerecord/lib/active_record/associations/association_scope.rb index bb8c0b9789469..c00077e7f17d2 100644 --- a/activerecord/lib/active_record/associations/association_scope.rb +++ b/activerecord/lib/active_record/associations/association_scope.rb @@ -36,7 +36,7 @@ def self.get_bind_values(owner, chain) binds = [] last_reflection = chain.last - binds.push(*last_reflection.join_id_for(owner)) + binds.push(*last_reflection.join_query_constraints_id_for(owner)) if last_reflection.type binds << owner.class.polymorphic_name end @@ -57,8 +57,8 @@ def join(table, constraint) end def last_chain_scope(scope, reflection, owner) - primary_key = Array(reflection.join_primary_key) - foreign_key = Array(reflection.join_foreign_key) + primary_key = Array(reflection.join_query_constraints_primary_key) + foreign_key = Array(reflection.join_query_constraints_foreign_key) table = reflection.aliased_table primary_key_foreign_key_pairs = primary_key.zip(foreign_key) diff --git a/activerecord/lib/active_record/associations/errors.rb b/activerecord/lib/active_record/associations/errors.rb index 66f57aada003f..acee42badac37 100644 --- a/activerecord/lib/active_record/associations/errors.rb +++ b/activerecord/lib/active_record/associations/errors.rb @@ -190,9 +190,9 @@ class CompositePrimaryKeyMismatchError < ActiveRecordError # :nodoc: def initialize(reflection = nil) if reflection if reflection.has_one? || reflection.collection? - super("Association #{reflection.active_record}##{reflection.name} primary key #{reflection.active_record_primary_key} doesn't match with foreign key #{reflection.foreign_key}. Please specify query_constraints, or primary_key and foreign_key values.") + super("Association #{reflection.active_record}##{reflection.name} primary key `#{reflection.active_record_primary_key}` doesn't match with foreign key `#{reflection.foreign_key}`. Please specify query_constraints, or primary_key and foreign_key values.") else - super("Association #{reflection.active_record}##{reflection.name} primary key #{reflection.association_primary_key} doesn't match with foreign key #{reflection.foreign_key}. Please specify query_constraints, or primary_key and foreign_key values.") + super("Association #{reflection.active_record}##{reflection.name} primary key `#{reflection.association_primary_key}` doesn't match with foreign key `#{reflection.foreign_key}`. Please specify query_constraints, or primary_key and foreign_key values.") end else super("Association primary key doesn't match with foreign key.") diff --git a/activerecord/lib/active_record/associations/preloader/association.rb b/activerecord/lib/active_record/associations/preloader/association.rb index b683f1c2740a5..2cfe293622487 100644 --- a/activerecord/lib/active_record/associations/preloader/association.rb +++ b/activerecord/lib/active_record/associations/preloader/association.rb @@ -159,7 +159,7 @@ def preloaded_records # The name of the key on the associated records def association_key_name - reflection.join_primary_key(klass) + reflection.join_query_constraints_primary_key(klass) end def loader_query @@ -239,7 +239,7 @@ def associate_records_from_unscoped(unscoped_records) # The name of the key on the model which declares the association def owner_key_name - reflection.join_foreign_key + reflection.join_query_constraints_foreign_key end def associate_records_to_owner(owner, records) diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 0baea5bebfcc3..0c9992ad4a665 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -528,15 +528,9 @@ def initialize(name, scope, options, active_record) @foreign_key = nil @association_foreign_key = nil @association_primary_key = nil - if options[:query_constraints] - raise ConfigurationError, <<~MSG.squish - Setting `query_constraints:` option on `#{active_record}.#{macro} :#{name}` is not allowed. - To get the same behavior, use the `foreign_key` option instead. - MSG - end - # If the foreign key is an array, set query constraints options and don't use the foreign key - if options[:foreign_key].is_a?(Array) + # If the foreign key is an array, set query constraints options + if options[:foreign_key].is_a?(Array) && !options[:query_constraints] options[:query_constraints] = options.delete(:foreign_key) end @@ -559,11 +553,57 @@ def join_table @join_table ||= -(options[:join_table]&.to_s || derive_join_table) end + # Normalizes the `query_constraints` option into [self_column, target_column] pairs. + # Returns nil for old-style query_constraints (plain FK column arrays without explicit foreign_key). + # + # Activated when `foreign_key` is also specified, or when any element is a Hash. + # Key = column on this model's table (self), Value = column on the other table (target). + # + # Examples: + # query_constraints: :shop_id => [["shop_id", "shop_id"]] + # query_constraints: [:blog_id, { id: :blog_post_id }] => [["blog_id", "blog_id"], ["id", "blog_post_id"]] + def normalized_query_constraints_mapping + return unless options[:query_constraints] + return unless options[:foreign_key] || Array(options[:query_constraints]).any?(Hash) + + @normalized_query_constraints_mapping ||= Array(options[:query_constraints]).flat_map { |element| + case element + when Symbol, String + [[element.to_s, element.to_s]] + when Hash + element.map { |self_col, target_col| [self_col.to_s, target_col.to_s] } + end + }.freeze + end + + # The set of columns used to query association targets (loading, preloading). + # + # `query_constraints` is a list of *additional* columns layered on top of the + # `foreign_key` — the foreign key always participates, since an association + # cannot be queried without it. Listing the foreign key in `query_constraints` + # is allowed and harmless: it is de-duplicated here rather than rejected. + # + # Defaults to `foreign_key` when no `query_constraints` are given. + def query_constraints_foreign_key + @query_constraints_foreign_key ||= if options[:query_constraints] + qc = Array(options[:query_constraints]).map { |fk| fk.to_s.freeze } + [*qc, *Array(foreign_key)].uniq.freeze + else + foreign_key + end + end + def foreign_key(infer_from_inverse_of: true) @foreign_key ||= if options[:foreign_key] ActiveRecord::Key.for(options[:foreign_key]).name elsif options[:query_constraints] - options[:query_constraints].map { |fk| -fk.to_s.freeze }.freeze + qc = Array(options[:query_constraints]) + if qc.any?(Hash) + raise ArgumentError, + "`query_constraints` with column mapping (Hash) on `#{active_record}.#{macro} :#{name}` " \ + "requires an explicit `foreign_key` option." + end + qc.map { |fk| -fk.to_s.freeze }.freeze else derived_fk = derive_foreign_key(infer_from_inverse_of: infer_from_inverse_of) @@ -583,15 +623,36 @@ def association_primary_key(klass = nil) primary_key(klass || self.klass) end + def active_record_query_constraints_primary_key + if options[:primary_key] + active_record_primary_key + elsif active_record.has_query_constraints? || options[:query_constraints] + @active_record_query_constraints_primary_key ||= active_record.query_constraints_list + else + active_record_primary_key + end + end + def active_record_primary_key @active_record_primary_key ||= if options[:primary_key] ActiveRecord::Key.for(options[:primary_key]).name + elsif active_record.has_query_constraints? || (options[:query_constraints] && !options[:foreign_key]) + active_record.query_constraints_list else derive_primary_key(active_record) { |model| model.query_constraints_list } end end + def join_query_constraints_primary_key(klass = nil) + if (mapping = normalized_query_constraints_mapping) + # target (child) columns: mapping values + foreign_key + [*mapping.map(&:last), *Array(foreign_key)].uniq.freeze + else + query_constraints_foreign_key + end + end + def join_primary_key(klass = nil) foreign_key end @@ -600,6 +661,15 @@ def join_primary_type type end + def join_query_constraints_foreign_key + if (mapping = normalized_query_constraints_mapping) + # self (parent) columns: mapping keys + active_record_primary_key + [*mapping.map(&:first), *Array(active_record_primary_key)].uniq.freeze + else + active_record_query_constraints_primary_key + end + end + def join_foreign_key active_record_primary_key end @@ -632,6 +702,10 @@ def check_eager_loadable! end end + def join_query_constraints_id_for(owner) + Array(join_query_constraints_foreign_key).map { |key| owner._read_attribute(key) } + end + def join_id_for(owner) # :nodoc: Array(join_foreign_key).map { |key| owner._read_attribute(key) } end @@ -818,8 +892,12 @@ def scope_allows_automatic_inverse_of?(reflection, inverse_reflection) # Shared by +active_record_primary_key+ and +association_primary_key+ to # resolve the key from +model+ once a custom +primary_key+ is ruled out. # The block is yielded +model+ to supply its query-constraints list. + # + # Note: callers handle the association-level +query_constraints+ option + # themselves, since its meaning differs by side (and it is decoupled from + # +foreign_key+). This only considers the model-level query constraints. def derive_primary_key(model) - if model.has_query_constraints? || options[:query_constraints] + if model.has_query_constraints? yield model else # inferred_id is nil unless the key is composite; otherwise fall back @@ -933,6 +1011,20 @@ def association_class end end + def association_query_constraints_primary_key(klass = nil) + # An explicit `primary_key`/`foreign_key` pins a single-column join on the + # target's writable key, so defer to +association_primary_key+ even when the + # target is query-constrained (e.g. a polymorphic FK to a sharded model). + if !options[:primary_key] && !options[:foreign_key] && + ((klass || self.klass).has_query_constraints? || options[:query_constraints]) + # Not memoized: +klass+ varies for polymorphic associations, so each + # target may resolve to a different query-constraints list. + (klass || self.klass).query_constraints_list + else + association_primary_key(klass) + end + end + # klass option is necessary to support loading polymorphic associations def association_primary_key(klass = nil) if options[:primary_key] @@ -948,17 +1040,46 @@ def association_primary_key(klass = nil) klass ||= self.klass - if klass.has_query_constraints? && options[:foreign_key] && !options[:query_constraints] + # An explicit `foreign_key` handles writes, so the association's writable + # key is the target's single primary key even when extra `query_constraints` + # are layered on for reads (the decoupled query-constraints feature). + if klass.has_query_constraints? && options[:foreign_key] return klass.primary_key_definition.inferred_id || primary_key(klass).freeze end + # `query_constraints` without an explicit `foreign_key` (e.g. a composite + # array `foreign_key` normalized into `query_constraints`) keys off the + # target's declared primary key, which may itself be composite. + if options[:query_constraints] + return primary_key(klass) + end + derive_primary_key(klass) { |model| model.composite_query_constraints_list } end + def join_query_constraints_primary_key(klass = nil) + if (mapping = normalized_query_constraints_mapping) + # target (parent) columns: mapping values + association_primary_key + target_klass = polymorphic? ? klass : (klass || self.klass) + [*mapping.map(&:last), *Array(association_primary_key(target_klass))].uniq.freeze + else + polymorphic? ? association_query_constraints_primary_key(klass) : association_query_constraints_primary_key + end + end + def join_primary_key(klass = nil) polymorphic? ? association_primary_key(klass) : association_primary_key end + def join_query_constraints_foreign_key + if (mapping = normalized_query_constraints_mapping) + # self (child) columns: mapping keys + foreign_key + [*mapping.map(&:first), *Array(foreign_key)].uniq.freeze + else + query_constraints_foreign_key + end + end + def join_foreign_key foreign_key end @@ -984,6 +1105,10 @@ def collection? # Holds all the metadata about a :through association as it was specified # in the Active Record class. class ThroughReflection < AbstractReflection # :nodoc: + # The `join_query_constraints_*` methods must be delegated the same way as + # their `join_*` counterparts (foreign key and id-for to +source_reflection+ + # here, primary key overridden below), otherwise query-constraint joins would + # resolve to the wrong reflection on the chain. Keep the two sets in sync. delegate :foreign_key, :foreign_type, :association_foreign_key, :join_id_for, :type, :active_record_primary_key, :join_foreign_key, to: :source_reflection @@ -1106,6 +1231,10 @@ def association_primary_key(klass = nil) end end + def join_query_constraints_primary_key(klass = self.klass) + source_reflection.join_query_constraints_primary_key(klass) + end + def join_primary_key(klass = self.klass) source_reflection.join_primary_key(klass) end @@ -1263,8 +1392,7 @@ def collect_deprecated_nested_reflections end class PolymorphicReflection < AbstractReflection # :nodoc: - delegate :klass, :scope, :plural_name, :type, :join_primary_key, :join_foreign_key, - :name, :scope_for, to: :@reflection + delegate :klass, :scope, :plural_name, :type, :join_primary_key, :join_foreign_key, :name, :scope_for, :join_query_constraints_primary_key, :join_query_constraints_foreign_key, to: :@reflection def initialize(reflection, previous_reflection) super() @@ -1293,7 +1421,7 @@ def source_type_scope end class RuntimeReflection < AbstractReflection # :nodoc: - delegate :scope, :type, :constraints, :join_foreign_key, to: :@reflection + delegate :scope, :type, :constraints, :join_foreign_key, :join_query_constraints_foreign_key, to: :@reflection def initialize(reflection, association) super() @@ -1309,6 +1437,10 @@ def aliased_table klass.arel_table end + def join_query_constraints_primary_key(klass = self.klass) + @reflection.join_query_constraints_primary_key(klass) + end + def join_primary_key(klass = self.klass) @reflection.join_primary_key(klass) end diff --git a/activerecord/test/cases/associations/belongs_to_associations_test.rb b/activerecord/test/cases/associations/belongs_to_associations_test.rb index d0552c0ff3325..cdfe8e201151e 100644 --- a/activerecord/test/cases/associations/belongs_to_associations_test.rb +++ b/activerecord/test/cases/associations/belongs_to_associations_test.rb @@ -42,6 +42,7 @@ require "models/shipment" require "models/adjustment" require "models/dats" +require "models/sharded" class BelongsToAssociationsTest < ActiveRecord::TestCase fixtures :accounts, :companies, :developers, :projects, :topics, @@ -1895,8 +1896,8 @@ def self.name; "Temp"; end end assert_equal(<<~MESSAGE.squish, error.message) - Association Cpk::BrokenBook#order primary key ["shop_id", "status"] - doesn't match with foreign key order_id. Please specify query_constraints, or primary_key and foreign_key values. + Association Cpk::BrokenBook#order primary key `["shop_id", "status"]` + doesn't match with foreign key `order_id`. Please specify query_constraints, or primary_key and foreign_key values. MESSAGE end @@ -1907,8 +1908,8 @@ def self.name; "Temp"; end end assert_equal(<<~MESSAGE.squish, error.message) - Association Cpk::BrokenBookWithNonCpkOrder#order primary key ["id"] - doesn't match with foreign key ["shop_id", "order_id"]. Please specify query_constraints, or primary_key and foreign_key values. + Association Cpk::BrokenBookWithNonCpkOrder#order primary key `id` + doesn't match with foreign key `["shop_id", "order_id"]`. Please specify query_constraints, or primary_key and foreign_key values. MESSAGE end @@ -2175,3 +2176,88 @@ def test_inverse_with_composite_query_constraints_resolves_single_primary_key assert_equal "id", reflection.association_primary_key(Shipment) end end + +class BelongsToWithDecoupledQueryConstraintsTest < ActiveRecord::TestCase + fixtures :sharded_comments, :sharded_blog_posts + + def test_belongs_to_with_decoupled_qc_queries_record_using_all_constraints + comment = sharded_comments(:great_comment_blog_post_one) + expected_blog_post = sharded_blog_posts(:great_post_blog_one) + + sql = capture_sql do + assert_equal expected_blog_post, comment.blog_post_with_decoupled_qc + end.first + + assert_match(/#{Regexp.escape(Sharded::BlogPost.lease_connection.quote_table_name("sharded_blog_posts.blog_id"))} =/, sql) + assert_match(/#{Regexp.escape(Sharded::BlogPost.lease_connection.quote_table_name("sharded_blog_posts.id"))} =/, sql) + end + + def test_belongs_to_association_preload_with_decoupled_qc + comment = sharded_comments(:great_comment_blog_post_one) + expected_blog_post = sharded_blog_posts(:great_post_blog_one) + + sql = capture_sql do + comment = Sharded::Comment.where(id: comment.id).preload(:blog_post_with_decoupled_qc).to_a.first + loaded_blog_post = assert_no_queries { comment.blog_post_with_decoupled_qc } + assert_equal expected_blog_post, loaded_blog_post + end.last + + assert_match(/#{Regexp.escape(Sharded::BlogPost.lease_connection.quote_table_name("sharded_blog_posts.blog_id"))} =/, sql) + assert_match(/#{Regexp.escape(Sharded::BlogPost.lease_connection.quote_table_name("sharded_blog_posts.id"))} =/, sql) + end + + def test_nullifiying_belongs_to_association_with_decoupled_query_constraints_doesnt_reset_tenant_key + comment = sharded_comments(:great_comment_blog_post_one) + comment.blog_post_with_decoupled_qc = nil + comment.save! + + assert comment.blog_id + assert_nil comment.blog_post_id + end + + def test_setting_belongs_to_association_with_decoupled_query_constraints_doesnt_set_tenant_key + blog_post = sharded_blog_posts(:great_post_blog_one) + comment = Sharded::Comment.new(blog_post_with_decoupled_qc: blog_post) + + assert_nil comment.blog_id + assert_equal comment.blog_post_id, blog_post.id + + comment.blog_id = 123_456 + comment.save! + + assert_equal 123_456, comment.blog_id + assert_equal comment.blog_post_id, blog_post.id + end + + def test_belongs_to_with_query_constraints_column_mapping + blog_post = sharded_blog_posts(:great_post_blog_one) + expected_comment = sharded_comments(:great_comment_blog_post_one) + + assert_equal expected_comment.id, blog_post.featured_comment_id + + sql = capture_sql do + loaded_comment = blog_post.featured_comment + assert_equal expected_comment, loaded_comment + end.first + + # All three constraints must appear in the query + assert_match(/#{Regexp.escape(Sharded::Comment.lease_connection.quote_table_name("sharded_comments.blog_id"))} =/, sql) + assert_match(/#{Regexp.escape(Sharded::Comment.lease_connection.quote_table_name("sharded_comments.blog_post_id"))} =/, sql) + assert_match(/#{Regexp.escape(Sharded::Comment.lease_connection.quote_table_name("sharded_comments.id"))} =/, sql) + end + + def test_preload_belongs_to_with_query_constraints_column_mapping + blog_post = sharded_blog_posts(:great_post_blog_one) + expected_comment = sharded_comments(:great_comment_blog_post_one) + + sql = capture_sql do + bp = Sharded::BlogPost.where(id: blog_post.id).preload(:featured_comment).to_a.first + loaded_comment = assert_no_queries { bp.featured_comment } + assert_equal expected_comment, loaded_comment + end.last + + assert_match(/#{Regexp.escape(Sharded::Comment.lease_connection.quote_table_name("sharded_comments.blog_id"))} =/, sql) + assert_match(/#{Regexp.escape(Sharded::Comment.lease_connection.quote_table_name("sharded_comments.blog_post_id"))} =/, sql) + assert_match(/#{Regexp.escape(Sharded::Comment.lease_connection.quote_table_name("sharded_comments.id"))} =/, sql) + end +end diff --git a/activerecord/test/cases/associations/has_many_associations_test.rb b/activerecord/test/cases/associations/has_many_associations_test.rb index 386a927abf1f0..9a9e23c0603d5 100644 --- a/activerecord/test/cases/associations/has_many_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_associations_test.rb @@ -3282,8 +3282,8 @@ def test_key_ensuring_owner_was_is_valid_when_dependent_option_is_destroy_async end assert_equal(<<~MESSAGE.squish, error.message) - Association Cpk::BrokenOrder#books primary key ["shop_id", "status"] - doesn't match with foreign key broken_order_id. Please specify query_constraints, or primary_key and foreign_key values. + Association Cpk::BrokenOrder#books primary key `["shop_id", "status"]` + doesn't match with foreign key `broken_order_id`. Please specify query_constraints, or primary_key and foreign_key values. MESSAGE end @@ -3294,8 +3294,8 @@ def test_key_ensuring_owner_was_is_valid_when_dependent_option_is_destroy_async end assert_equal(<<~MESSAGE.squish, error.message) - Association Cpk::BrokenOrderWithNonCpkBooks#books primary key [\"shop_id\", \"status\"] - doesn't match with foreign key broken_order_with_non_cpk_books_id. Please specify query_constraints, or primary_key and foreign_key values. + Association Cpk::BrokenOrderWithNonCpkBooks#books primary key `["shop_id", "status"]` + doesn't match with foreign key `broken_order_with_non_cpk_books_id`. Please specify query_constraints, or primary_key and foreign_key values. MESSAGE end diff --git a/activerecord/test/cases/associations/has_one_associations_test.rb b/activerecord/test/cases/associations/has_one_associations_test.rb index 62d88b3ccb3b3..33e0c62d19258 100644 --- a/activerecord/test/cases/associations/has_one_associations_test.rb +++ b/activerecord/test/cases/associations/has_one_associations_test.rb @@ -998,8 +998,8 @@ def test_has_one_with_touch_option_on_nonpersisted_built_associations_doesnt_upd end assert_equal(<<~MESSAGE.squish, error.message) - Association Cpk::BrokenOrder#book primary key ["shop_id", "status"] - doesn't match with foreign key broken_order_id. Please specify query_constraints, or primary_key and foreign_key values. + Association Cpk::BrokenOrder#book primary key `["shop_id", "status"]` + doesn't match with foreign key `broken_order_id`. Please specify query_constraints, or primary_key and foreign_key values. MESSAGE end @@ -1010,8 +1010,8 @@ def test_has_one_with_touch_option_on_nonpersisted_built_associations_doesnt_upd end assert_equal(<<~MESSAGE.squish, error.message) - Association Cpk::BrokenOrderWithNonCpkBooks#book primary key [\"shop_id\", \"status\"] - doesn't match with foreign key broken_order_with_non_cpk_books_id. Please specify query_constraints, or primary_key and foreign_key values. + Association Cpk::BrokenOrderWithNonCpkBooks#book primary key `["shop_id", "status"]` + doesn't match with foreign key `broken_order_with_non_cpk_books_id`. Please specify query_constraints, or primary_key and foreign_key values. MESSAGE end end diff --git a/activerecord/test/cases/associations_test.rb b/activerecord/test/cases/associations_test.rb index 537ce1e32fc04..f295e9bc6e40b 100644 --- a/activerecord/test/cases/associations_test.rb +++ b/activerecord/test/cases/associations_test.rb @@ -486,26 +486,17 @@ def test_nullify_composite_has_many_through_association assert_not_predicate Sharded::BlogPostTag.where(blog_post_id: blog_post.id, blog_id: blog_post.blog_id), :exists? end - def test_using_query_constraints_warns_about_changing_behavior - has_many_expected_message = <<~MSG.squish - Setting `query_constraints:` option on `Sharded::BlogPost.has_many :qc_deprecated_comments` is not allowed. - To get the same behavior, use the `foreign_key` option instead. - MSG - - assert_raises(ActiveRecord::ConfigurationError, match: has_many_expected_message) do - Sharded::BlogPost.has_many :qc_deprecated_comments, + def test_using_query_constraints_is_allowed + assert_nothing_raised do + Sharded::BlogPost.has_many :qc_configured_comments, class_name: "Sharded::Comment", query_constraints: [:blog_id, :blog_post_id] - end - belongs_to_expected_message = <<~MSG.squish - Setting `query_constraints:` option on `Sharded::Comment.belongs_to :qc_deprecated_blog_post` is not allowed. - To get the same behavior, use the `foreign_key` option instead. - MSG - - assert_raises(ActiveRecord::ConfigurationError, match: belongs_to_expected_message) do - Sharded::Comment.belongs_to :qc_deprecated_blog_post, + Sharded::Comment.belongs_to :qc_configured_blog_post, class_name: "Sharded::Blog", query_constraints: [:blog_id, :blog_post_id] end + + assert Sharded::BlogPost.reflect_on_association(:qc_configured_comments) + assert Sharded::Comment.reflect_on_association(:qc_configured_blog_post) end end diff --git a/activerecord/test/cases/reflection_test.rb b/activerecord/test/cases/reflection_test.rb index b2080ed394b13..31720c814ceb7 100644 --- a/activerecord/test/cases/reflection_test.rb +++ b/activerecord/test/cases/reflection_test.rb @@ -717,35 +717,92 @@ def test_habtm_composite_keys_are_returned_as_arrays assert_equal ["blog_id", "tag_id"], reflection.association_foreign_key end - def test_using_query_constraints_warns_about_changing_behavior - has_many_expected_message = <<~MSG.squish - Setting `query_constraints:` option on `Firm.has_many :clients` is not allowed. - To get the same behavior, use the `foreign_key` option instead. - MSG - - assert_raises(ActiveRecord::ConfigurationError, match: has_many_expected_message) do - ActiveRecord::Reflection.create(:has_many, :clients, nil, { query_constraints: [:firm_id, :firm_name] }, Firm) - end + def test_using_query_constraints_is_allowed + has_many = ActiveRecord::Reflection.create(:has_many, :clients, nil, { query_constraints: [:firm_id, :firm_name] }, Firm) + has_one = ActiveRecord::Reflection.create(:has_one, :account, nil, { query_constraints: [:firm_id, :firm_name] }, Firm) + belongs_to = ActiveRecord::Reflection.create(:belongs_to, :client, nil, { query_constraints: [:firm_id, :firm_name] }, Firm) + + assert_equal ["firm_id", "firm_name"], has_many.query_constraints_foreign_key + assert_equal ["firm_id", "firm_name"], has_one.query_constraints_foreign_key + assert_equal ["firm_id", "firm_name"], belongs_to.query_constraints_foreign_key + end - has_one_expected_message = <<~MSG.squish - Setting `query_constraints:` option on `Firm.has_one :account` is not allowed. - To get the same behavior, use the `foreign_key` option instead. - MSG + def test_normalized_query_constraints_mapping_with_symbols + reflection = Sharded::Comment.reflect_on_association(:blog_post_with_decoupled_qc) - assert_raises(ActiveRecord::ConfigurationError, match: has_one_expected_message) do - ActiveRecord::Reflection.create(:has_one, :account, nil, { query_constraints: [:firm_id, :firm_name] }, Firm) - end + assert_equal [["blog_id", "blog_id"]], reflection.normalized_query_constraints_mapping + end + + def test_normalized_query_constraints_mapping_with_hash + reflection = Sharded::BlogPost.reflect_on_association(:featured_comment) + + assert_equal [["blog_id", "blog_id"], ["id", "blog_post_id"]], reflection.normalized_query_constraints_mapping + end + + def test_normalized_query_constraints_mapping_returns_nil_for_old_style + # Old-style: all symbols, no explicit foreign_key → nil (use legacy behavior) + reflection = Sharded::BlogPost.reflect_on_association(:comments_with_composite_pk) + + assert_nil reflection.normalized_query_constraints_mapping + end - belongs_to_expected_message = <<~MSG.squish - Setting `query_constraints:` option on `Firm.belongs_to :client` is not allowed. - To get the same behavior, use the `foreign_key` option instead. - MSG + def test_belongs_to_join_keys_with_query_constraints_mapping + reflection = Sharded::BlogPost.reflect_on_association(:featured_comment) - assert_raises(ActiveRecord::ConfigurationError, match: belongs_to_expected_message) do - ActiveRecord::Reflection.create(:belongs_to, :client, nil, { query_constraints: [:firm_id, :firm_name] }, Firm) + # target (Comment) columns for WHERE clause + assert_equal ["blog_id", "blog_post_id", "id"], reflection.join_query_constraints_primary_key + # self (BlogPost) columns to read values from + assert_equal ["blog_id", "id", "featured_comment_id"], reflection.join_query_constraints_foreign_key + end + + def test_query_constraints_mapping_requires_foreign_key_when_hash_present + assert_raises(ArgumentError, match: /requires an explicit `foreign_key` option/) do + ActiveRecord::Reflection.create( + :belongs_to, :comment, nil, + { query_constraints: [:blog_id, { id: :blog_post_id }], class_name: "Sharded::Comment" }, + Sharded::BlogPost + ).foreign_key end end + def test_has_many_with_decoupled_query_constraints_active_record_primary_key_is_scalar + # When both foreign_key and query_constraints are specified, active_record_primary_key + # should return the scalar primary key, not the query_constraints_list array. + # This ensures .joins() works correctly. + reflection = ActiveRecord::Reflection.create( + :has_many, :comments, nil, + { foreign_key: :blog_post_id, query_constraints: :blog_id, class_name: "Sharded::Comment" }, + Sharded::BlogPost + ) + + assert_equal "id", reflection.active_record_primary_key + assert_equal "id", reflection.join_foreign_key + end + + def test_has_many_decoupled_query_constraints_join_query_constraints_keys + reflection = ActiveRecord::Reflection.create( + :has_many, :comments, nil, + { foreign_key: :blog_post_id, query_constraints: :blog_id, class_name: "Sharded::Comment" }, + Sharded::BlogPost + ) + + # For querying: parent columns (self) = [blog_id, id], child columns (target) = [blog_id, blog_post_id] + assert_equal ["blog_id", "id"], reflection.join_query_constraints_foreign_key + assert_equal ["blog_id", "blog_post_id"], reflection.join_query_constraints_primary_key + end + + def test_query_constraints_allows_the_foreign_key_to_be_listed + # query_constraints are *additional* columns layered on the foreign key, so + # listing the foreign key itself is allowed (not rejected) and de-duplicated. + reflection = ActiveRecord::Reflection.create( + :has_many, :comments, nil, + { foreign_key: :blog_post_id, query_constraints: [:blog_id, :blog_post_id], class_name: "Sharded::Comment" }, + Sharded::BlogPost + ) + + assert_equal ["blog_id", "blog_post_id"], reflection.query_constraints_foreign_key + end + def test_counter_cache_column_defaults_when_counter_cache_is_true model = Class.new(ActiveRecord::Base) do def self.name = "CounterCacheTrueAuthor" diff --git a/activerecord/test/fixtures/sharded_blog_posts.yml b/activerecord/test/fixtures/sharded_blog_posts.yml index 4370edbfefb3d..e970d32e5a706 100644 --- a/activerecord/test/fixtures/sharded_blog_posts.yml +++ b/activerecord/test/fixtures/sharded_blog_posts.yml @@ -4,6 +4,7 @@ _fixture: great_post_blog_one: title: "My first post in my Blog1!" blog_id: <%= ActiveRecord::FixtureSet.identify(:sharded_blog_one) %> + featured_comment_id: <%= ActiveRecord::FixtureSet.identify(:great_comment_blog_post_one) %> second_post_blog_one: title: "This is my second post in my Blog1!" diff --git a/activerecord/test/models/sharded/blog_post.rb b/activerecord/test/models/sharded/blog_post.rb index 96b165be322a4..fe9e3b4526e77 100644 --- a/activerecord/test/models/sharded/blog_post.rb +++ b/activerecord/test/models/sharded/blog_post.rb @@ -23,10 +23,15 @@ class BlogPost < ActiveRecord::Base has_many :comments_with_composite_pk, class_name: "Sharded::Comment", primary_key: [:blog_id, :id], - foreign_key: [:blog_id, :blog_post_id] + query_constraints: [:blog_id, :blog_post_id] has_many :comments_with_inverse, class_name: "Sharded::Comment", inverse_of: :blog_post_with_inverse + + belongs_to :featured_comment, + class_name: "Sharded::Comment", + foreign_key: :featured_comment_id, + query_constraints: [:blog_id, { id: :blog_post_id }] end end diff --git a/activerecord/test/models/sharded/blog_post_destroy_async.rb b/activerecord/test/models/sharded/blog_post_destroy_async.rb index 0e4f6651084d3..ae6387459d2b8 100644 --- a/activerecord/test/models/sharded/blog_post_destroy_async.rb +++ b/activerecord/test/models/sharded/blog_post_destroy_async.rb @@ -6,9 +6,9 @@ class BlogPostDestroyAsync < ActiveRecord::Base query_constraints :blog_id, :id belongs_to :blog - has_many :comments, dependent: :destroy_async, foreign_key: [:blog_id, :blog_post_id], class_name: "Sharded::CommentDestroyAsync" + has_many :comments, dependent: :destroy_async, query_constraints: [:blog_id, :blog_post_id], class_name: "Sharded::CommentDestroyAsync" - has_many :blog_post_tags, foreign_key: [:blog_id, :blog_post_id], class_name: "Sharded::BlogPostTag" + has_many :blog_post_tags, query_constraints: [:blog_id, :blog_post_id], class_name: "Sharded::BlogPostTag" has_many :tags, through: :blog_post_tags, dependent: :destroy_async, class_name: "Sharded::Tag" end end diff --git a/activerecord/test/models/sharded/blog_post_with_revision.rb b/activerecord/test/models/sharded/blog_post_with_revision.rb index 1c947cdcab4e7..c86b571955be5 100644 --- a/activerecord/test/models/sharded/blog_post_with_revision.rb +++ b/activerecord/test/models/sharded/blog_post_with_revision.rb @@ -6,6 +6,6 @@ class BlogPostWithRevision < ActiveRecord::Base self.table_name = :sharded_blog_posts query_constraints :blog_id, :revision, :id - has_many :comments, primary_key: [:blog_id, :id], foreign_key: [:blog_id, :blog_post_id] + has_many :comments, primary_key: [:blog_id, :id], query_constraints: [:blog_id, :blog_post_id] end end diff --git a/activerecord/test/models/sharded/comment.rb b/activerecord/test/models/sharded/comment.rb index e50a64c855bed..4d991455562f6 100644 --- a/activerecord/test/models/sharded/comment.rb +++ b/activerecord/test/models/sharded/comment.rb @@ -12,6 +12,7 @@ class Comment < ActiveRecord::Base foreign_key: [:blog_id, :blog_post_id], primary_key: [:blog_id, :id], inverse_of: :comments_with_inverse + belongs_to :blog_post_with_decoupled_qc, class_name: "Sharded::BlogPost", foreign_key: :blog_post_id, query_constraints: :blog_id belongs_to :blog end end diff --git a/activerecord/test/models/sharded/comment_destroy_async.rb b/activerecord/test/models/sharded/comment_destroy_async.rb index 69f04a5d9dcf0..a50036af0f2cc 100644 --- a/activerecord/test/models/sharded/comment_destroy_async.rb +++ b/activerecord/test/models/sharded/comment_destroy_async.rb @@ -5,7 +5,7 @@ class CommentDestroyAsync < ActiveRecord::Base self.table_name = :sharded_comments query_constraints :blog_id, :id - belongs_to :blog_post, dependent: :destroy_async, foreign_key: [:blog_id, :blog_post_id], class_name: "Sharded::BlogPostDestroyAsync" + belongs_to :blog_post, dependent: :destroy_async, query_constraints: [:blog_id, :blog_post_id], class_name: "Sharded::BlogPostDestroyAsync" belongs_to :blog_post_by_id, class_name: "Sharded::BlogPostDestroyAsync", foreign_key: :blog_post_id belongs_to :blog end diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index 581502cb7d9eb..1423b588a4332 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -357,6 +357,7 @@ t.references :parent, polymorphic: true t.integer :blog_id t.integer :revision + t.integer :featured_comment_id end create_table :sharded_comments, force: true do |t| From 530077e10a6a4907c23ba4aaee84dbaea728d57e Mon Sep 17 00:00:00 2001 From: Nikita Vasilevsky Date: Mon, 4 May 2026 15:32:07 -0400 Subject: [PATCH 2/3] Use decoupled query constraints for association joins --- activerecord/lib/active_record/reflection.rb | 16 ++--- .../belongs_to_associations_test.rb | 63 +++++++++++++++++++ 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 0c9992ad4a665..a4bca9a9232cd 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -208,8 +208,8 @@ def join_scope(table, foreign_table, foreign_klass) scope_chain_items.inject(klass_scope, &:merge!) - primary_key_column_names = Array(join_primary_key) - foreign_key_column_names = Array(join_foreign_key) + primary_key_column_names = Array(join_query_constraints_primary_key) + foreign_key_column_names = Array(join_query_constraints_foreign_key) primary_foreign_key_pairs = primary_key_column_names.zip(foreign_key_column_names) @@ -637,10 +637,12 @@ def active_record_primary_key @active_record_primary_key ||= if options[:primary_key] ActiveRecord::Key.for(options[:primary_key]).name - elsif active_record.has_query_constraints? || (options[:query_constraints] && !options[:foreign_key]) + elsif (active_record.has_query_constraints? || options[:query_constraints]) && !options[:foreign_key] + # query_constraints drive the key only when no foreign_key is given; + # an explicit foreign_key handles writes and takes precedence here. active_record.query_constraints_list else - derive_primary_key(active_record) { |model| model.query_constraints_list } + active_record.primary_key_definition.inferred_id || primary_key(active_record).freeze end end @@ -889,8 +891,7 @@ def scope_allows_automatic_inverse_of?(reflection, inverse_reflection) end end - # Shared by +active_record_primary_key+ and +association_primary_key+ to - # resolve the key from +model+ once a custom +primary_key+ is ruled out. + # Resolves the key from +model+ once a custom +primary_key+ is ruled out. # The block is yielded +model+ to supply its query-constraints list. # # Note: callers handle the association-level +query_constraints+ option @@ -1110,7 +1111,8 @@ class ThroughReflection < AbstractReflection # :nodoc: # here, primary key overridden below), otherwise query-constraint joins would # resolve to the wrong reflection on the chain. Keep the two sets in sync. delegate :foreign_key, :foreign_type, :association_foreign_key, :join_id_for, :type, - :active_record_primary_key, :join_foreign_key, to: :source_reflection + :active_record_primary_key, :join_foreign_key, + :join_query_constraints_id_for, :join_query_constraints_foreign_key, to: :source_reflection def initialize(delegate_reflection) super() diff --git a/activerecord/test/cases/associations/belongs_to_associations_test.rb b/activerecord/test/cases/associations/belongs_to_associations_test.rb index cdfe8e201151e..1b664bdb6c53d 100644 --- a/activerecord/test/cases/associations/belongs_to_associations_test.rb +++ b/activerecord/test/cases/associations/belongs_to_associations_test.rb @@ -2206,6 +2206,32 @@ def test_belongs_to_association_preload_with_decoupled_qc assert_match(/#{Regexp.escape(Sharded::BlogPost.lease_connection.quote_table_name("sharded_blog_posts.id"))} =/, sql) end + def test_belongs_to_association_join_with_decoupled_qc_uses_all_constraints + comment = sharded_comments(:great_comment_blog_post_one) + + sql = capture_sql do + loaded_comment = Sharded::Comment.joins(:blog_post_with_decoupled_qc).where(id: comment.id).first + assert_equal comment, loaded_comment + end.first + + assert_sql_join_constraint(sql, Sharded::BlogPost, "sharded_blog_posts.blog_id", Sharded::Comment, "sharded_comments.blog_id") + assert_sql_join_constraint(sql, Sharded::BlogPost, "sharded_blog_posts.id", Sharded::Comment, "sharded_comments.blog_post_id") + end + + def test_belongs_to_association_eager_load_with_decoupled_qc_uses_all_constraints + comment = sharded_comments(:great_comment_blog_post_one) + expected_blog_post = sharded_blog_posts(:great_post_blog_one) + + sql = capture_sql do + loaded_comment = Sharded::Comment.eager_load(:blog_post_with_decoupled_qc).where(id: comment.id).first + loaded_blog_post = assert_no_queries { loaded_comment.blog_post_with_decoupled_qc } + assert_equal expected_blog_post, loaded_blog_post + end.first + + assert_sql_join_constraint(sql, Sharded::BlogPost, "sharded_blog_posts.blog_id", Sharded::Comment, "sharded_comments.blog_id") + assert_sql_join_constraint(sql, Sharded::BlogPost, "sharded_blog_posts.id", Sharded::Comment, "sharded_comments.blog_post_id") + end + def test_nullifiying_belongs_to_association_with_decoupled_query_constraints_doesnt_reset_tenant_key comment = sharded_comments(:great_comment_blog_post_one) comment.blog_post_with_decoupled_qc = nil @@ -2246,6 +2272,35 @@ def test_belongs_to_with_query_constraints_column_mapping assert_match(/#{Regexp.escape(Sharded::Comment.lease_connection.quote_table_name("sharded_comments.id"))} =/, sql) end + def test_belongs_to_join_with_query_constraints_column_mapping_uses_all_constraints + blog_post = sharded_blog_posts(:great_post_blog_one) + expected_comment = sharded_comments(:great_comment_blog_post_one) + + sql = capture_sql do + loaded_blog_post = Sharded::BlogPost.joins(:featured_comment).where(id: blog_post.id).first + assert_equal expected_comment.id, loaded_blog_post.featured_comment_id + end.first + + assert_sql_join_constraint(sql, Sharded::Comment, "sharded_comments.blog_id", Sharded::BlogPost, "sharded_blog_posts.blog_id") + assert_sql_join_constraint(sql, Sharded::Comment, "sharded_comments.blog_post_id", Sharded::BlogPost, "sharded_blog_posts.id") + assert_sql_join_constraint(sql, Sharded::Comment, "sharded_comments.id", Sharded::BlogPost, "sharded_blog_posts.featured_comment_id") + end + + def test_belongs_to_eager_load_with_query_constraints_column_mapping_uses_all_constraints + blog_post = sharded_blog_posts(:great_post_blog_one) + expected_comment = sharded_comments(:great_comment_blog_post_one) + + sql = capture_sql do + loaded_blog_post = Sharded::BlogPost.eager_load(:featured_comment).where(id: blog_post.id).first + loaded_comment = assert_no_queries { loaded_blog_post.featured_comment } + assert_equal expected_comment, loaded_comment + end.first + + assert_sql_join_constraint(sql, Sharded::Comment, "sharded_comments.blog_id", Sharded::BlogPost, "sharded_blog_posts.blog_id") + assert_sql_join_constraint(sql, Sharded::Comment, "sharded_comments.blog_post_id", Sharded::BlogPost, "sharded_blog_posts.id") + assert_sql_join_constraint(sql, Sharded::Comment, "sharded_comments.id", Sharded::BlogPost, "sharded_blog_posts.featured_comment_id") + end + def test_preload_belongs_to_with_query_constraints_column_mapping blog_post = sharded_blog_posts(:great_post_blog_one) expected_comment = sharded_comments(:great_comment_blog_post_one) @@ -2260,4 +2315,12 @@ def test_preload_belongs_to_with_query_constraints_column_mapping assert_match(/#{Regexp.escape(Sharded::Comment.lease_connection.quote_table_name("sharded_comments.blog_post_id"))} =/, sql) assert_match(/#{Regexp.escape(Sharded::Comment.lease_connection.quote_table_name("sharded_comments.id"))} =/, sql) end + + private + def assert_sql_join_constraint(sql, left_model, left_column_name, right_model, right_column_name) + left_column = Regexp.escape(left_model.lease_connection.quote_table_name(left_column_name)) + right_column = Regexp.escape(right_model.lease_connection.quote_table_name(right_column_name)) + + assert_match(/#{left_column} = #{right_column}/, sql) + end end From 75a844ba1fc9bc999ff88e059cac8b8472ffb574 Mon Sep 17 00:00:00 2001 From: Nikita Vasilevsky Date: Wed, 8 Jul 2026 18:31:06 -0400 Subject: [PATCH 3/3] Apply decoupled query_constraints to through-association middle joins next_chain_scope (the middle links of a through-association chain) still used join_primary_key/join_foreign_key, which under decoupled query_constraints resolve to the scalar foreign key alone. That dropped the extra query-constraint columns (e.g. blog_id) from the through JOIN, so a belongs_to source with `foreign_key: :tag_id, query_constraints: :blog_id` would join tags to blog_post_tags on tag_id only, allowing records from other shards to match. Switch next_chain_scope to join_query_constraints_primary_key / join_query_constraints_foreign_key, matching last_chain_scope and join_scope. Old-style (array foreign_key) and no-qc paths are unchanged since those methods return the same values as join_* there. Add a through-association test with a decoupled source that fails on the pre-fix code (blog_id missing from the join) and passes with it. Migrate the sharded-style array foreign_key test models to the explicit decoupled form (foreign_key + query_constraints) so nullification is spelled out rather than derived from the array-FK compatibility shim: Shipment#adjustments (foreign_key: :adjustable_id, query_constraints: [:region_id]) and Sharded::Comment#blog_post_with_inverse (foreign_key: :blog_post_id, query_constraints: :blog_id). The inverse has_many (comments_with_inverse) auto-composes query_constraints + foreign_key from the belongs_to, so its derived key and join are unchanged. Update the Shipment inverse test to assert the decoupled form. Add CompositeFkAndQueryConstraintsTest covering a composite foreign_key alongside a query_constraints column that is NOT part of the foreign_key: load scopes on the FK columns and the additive query_constraint column, and clearing nulls only the foreign_key columns, leaving the query_constraint column intact (asserted in-memory, before save). Add a region_id column to the sharded_blog_posts/sharded_comments schema and fixtures so the test has a query_constraint column outside the foreign_key. Assisted-By: devx/2841a3fd-69fe-437c-a5a8-81450ba29e54 --- .../associations/association_scope.rb | 4 +- .../belongs_to_associations_test.rb | 8 ++-- activerecord/test/cases/associations_test.rb | 23 ++++++++++ ...composite_fk_and_query_constraints_test.rb | 43 +++++++++++++++++++ .../test/fixtures/sharded_blog_posts.yml | 1 + .../test/fixtures/sharded_comments.yml | 1 + activerecord/test/models/sharded/blog_post.rb | 7 +++ .../test/models/sharded/blog_post_tag.rb | 1 + activerecord/test/models/sharded/comment.rb | 9 +++- activerecord/test/models/shipment.rb | 4 +- activerecord/test/schema/schema.rb | 2 + 11 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 activerecord/test/cases/composite_fk_and_query_constraints_test.rb diff --git a/activerecord/lib/active_record/associations/association_scope.rb b/activerecord/lib/active_record/associations/association_scope.rb index c00077e7f17d2..b86843662790c 100644 --- a/activerecord/lib/active_record/associations/association_scope.rb +++ b/activerecord/lib/active_record/associations/association_scope.rb @@ -80,8 +80,8 @@ def transform_value(value) end def next_chain_scope(scope, reflection, next_reflection) - primary_key = Array(reflection.join_primary_key) - foreign_key = Array(reflection.join_foreign_key) + primary_key = Array(reflection.join_query_constraints_primary_key) + foreign_key = Array(reflection.join_query_constraints_foreign_key) table = reflection.aliased_table foreign_table = next_reflection.aliased_table diff --git a/activerecord/test/cases/associations/belongs_to_associations_test.rb b/activerecord/test/cases/associations/belongs_to_associations_test.rb index 1b664bdb6c53d..b761f4aa91f9d 100644 --- a/activerecord/test/cases/associations/belongs_to_associations_test.rb +++ b/activerecord/test/cases/associations/belongs_to_associations_test.rb @@ -2167,12 +2167,14 @@ def test_explicit_foreign_key_to_sharded_target_resolves_single_primary_key assert_equal "id", reflection.association_primary_key(Sharded::BlogPost) end - def test_inverse_with_composite_query_constraints_resolves_single_primary_key + def test_inverse_with_decoupled_query_constraints_resolves_single_primary_key reflection = Adjustment.reflect_on_association(:adjustable) inverse = Shipment.reflect_on_association(:adjustments) - assert_equal [:region_id, :adjustable_id], inverse.options[:query_constraints] - assert_equal [:region_id, :id], inverse.options[:primary_key] + # Decoupled form: foreign_key handles the writable join (adjustable_id -> id), + # query_constraints scopes by the tenant column (region_id) without being written. + assert_equal :adjustable_id, inverse.options[:foreign_key] + assert_equal [:region_id], inverse.options[:query_constraints] assert_equal "id", reflection.association_primary_key(Shipment) end end diff --git a/activerecord/test/cases/associations_test.rb b/activerecord/test/cases/associations_test.rb index f295e9bc6e40b..db3679b3aabc0 100644 --- a/activerecord/test/cases/associations_test.rb +++ b/activerecord/test/cases/associations_test.rb @@ -485,6 +485,29 @@ def test_nullify_composite_has_many_through_association assert_empty(blog_post.reload.tags) assert_not_predicate Sharded::BlogPostTag.where(blog_post_id: blog_post.id, blog_id: blog_post.blog_id), :exists? end + def test_has_many_through_with_decoupled_query_constraints_uses_all_constraints_in_join + blog_post = sharded_blog_posts(:great_post_blog_one) + expected_tag_ids = [ + sharded_tags(:short_read_blog_one).id, + sharded_tags(:technical_blog_one).id, + ].sort + + sql = capture_sql do + loaded_tags = blog_post.tags_with_decoupled_qc.to_a + assert_equal expected_tag_ids, loaded_tags.map(&:id).sort + end.first + + # The through join between sharded_tags and sharded_blog_posts_tags must + # scope on blog_id (the decoupled query constraint), not just tag_id — + # otherwise tags from other blogs could match on tag_id alone. + tags_col = Regexp.escape(Sharded::Tag.lease_connection.quote_table_name("sharded_tags.blog_id")) + join_col = Regexp.escape(Sharded::BlogPostTag.lease_connection.quote_table_name("sharded_blog_posts_tags.blog_id")) + assert_match(/#{tags_col} = #{join_col}/, sql) + + tags_col = Regexp.escape(Sharded::Tag.lease_connection.quote_table_name("sharded_tags.id")) + join_col = Regexp.escape(Sharded::BlogPostTag.lease_connection.quote_table_name("sharded_blog_posts_tags.tag_id")) + assert_match(/#{tags_col} = #{join_col}/, sql) + end def test_using_query_constraints_is_allowed assert_nothing_raised do diff --git a/activerecord/test/cases/composite_fk_and_query_constraints_test.rb b/activerecord/test/cases/composite_fk_and_query_constraints_test.rb new file mode 100644 index 0000000000000..c0de7fe050a37 --- /dev/null +++ b/activerecord/test/cases/composite_fk_and_query_constraints_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/sharded/comment" +require "models/sharded/blog_post" + +# Verifies that a composite (array) foreign_key and a query_constraints column +# that is NOT part of the foreign_key can be declared together on one +# association. The defining behavior is decoupled nullification: clearing the +# association nulls the foreign_key columns but leaves the query_constraint column. +class CompositeFkAndQueryConstraintsTest < ActiveRecord::TestCase + fixtures :sharded_comments, :sharded_blog_posts + + def test_load_uses_fk_columns_and_the_query_constraint_column + comment = sharded_comments(:great_comment_blog_post_one) + expected_blog_post = sharded_blog_posts(:great_post_blog_one) + + sql = capture_sql do + assert_equal expected_blog_post, comment.blog_post_composite_fk_with_qc + end.first + + quoted = ->(col) { Regexp.escape(Sharded::BlogPost.lease_connection.quote_table_name("sharded_blog_posts.#{col}")) } + # FK columns (composite) participate... + assert_match(/#{quoted.call("blog_id")} =/, sql) + assert_match(/#{quoted.call("id")} =/, sql) + # ...and so does the additive query_constraint column that is NOT in the FK. + assert_match(/#{quoted.call("region_id")} =/, sql) + end + + def test_clearing_nulls_only_foreign_key_columns_not_the_query_constraint_column + comment = sharded_comments(:great_comment_blog_post_one) + assert_equal 1, comment.region_id + + # Assert in-memory, before any save: clearing writes only the FK columns. + # (Saving is avoided because blog_id is also a component of Comment's own + # composite primary key, so persisting a nulled PK is a synthetic artifact + # unrelated to which columns the association clear touches.) + comment.blog_post_composite_fk_with_qc = nil + assert_nil comment.blog_id + assert_nil comment.blog_post_id + assert_equal 1, comment.region_id + end +end diff --git a/activerecord/test/fixtures/sharded_blog_posts.yml b/activerecord/test/fixtures/sharded_blog_posts.yml index e970d32e5a706..481d02c10950a 100644 --- a/activerecord/test/fixtures/sharded_blog_posts.yml +++ b/activerecord/test/fixtures/sharded_blog_posts.yml @@ -5,6 +5,7 @@ great_post_blog_one: title: "My first post in my Blog1!" blog_id: <%= ActiveRecord::FixtureSet.identify(:sharded_blog_one) %> featured_comment_id: <%= ActiveRecord::FixtureSet.identify(:great_comment_blog_post_one) %> + region_id: 1 second_post_blog_one: title: "This is my second post in my Blog1!" diff --git a/activerecord/test/fixtures/sharded_comments.yml b/activerecord/test/fixtures/sharded_comments.yml index 4601b73886d79..7151c4cc2ec1d 100644 --- a/activerecord/test/fixtures/sharded_comments.yml +++ b/activerecord/test/fixtures/sharded_comments.yml @@ -5,6 +5,7 @@ great_comment_blog_post_one: body: "I really enjoyed the post!" blog_post_id: <%= ActiveRecord::FixtureSet.identify(:great_post_blog_one) %> blog_id: <%= ActiveRecord::FixtureSet.identify(:sharded_blog_one) %> + region_id: 1 wow_comment_blog_post_one: body: "Wow!" diff --git a/activerecord/test/models/sharded/blog_post.rb b/activerecord/test/models/sharded/blog_post.rb index fe9e3b4526e77..9e8ee87df0d14 100644 --- a/activerecord/test/models/sharded/blog_post.rb +++ b/activerecord/test/models/sharded/blog_post.rb @@ -13,6 +13,13 @@ class BlogPost < ActiveRecord::Base has_many :blog_post_tags has_many :tags, through: :blog_post_tags + has_many :blog_post_tags_with_decoupled_qc, + class_name: "Sharded::BlogPostTag", + foreign_key: :blog_post_id, + query_constraints: :blog_id + has_many :tags_with_decoupled_qc, + through: :blog_post_tags_with_decoupled_qc, + source: :tag_with_decoupled_qc has_and_belongs_to_many :tags_with_composite_fk, class_name: "Sharded::Tag", diff --git a/activerecord/test/models/sharded/blog_post_tag.rb b/activerecord/test/models/sharded/blog_post_tag.rb index 2ce5ef410d72d..98a339dfb39d7 100644 --- a/activerecord/test/models/sharded/blog_post_tag.rb +++ b/activerecord/test/models/sharded/blog_post_tag.rb @@ -7,5 +7,6 @@ class BlogPostTag < ActiveRecord::Base belongs_to :blog_post belongs_to :tag + belongs_to :tag_with_decoupled_qc, class_name: "Sharded::Tag", foreign_key: :tag_id, query_constraints: :blog_id end end diff --git a/activerecord/test/models/sharded/comment.rb b/activerecord/test/models/sharded/comment.rb index 4d991455562f6..c005203ee28ad 100644 --- a/activerecord/test/models/sharded/comment.rb +++ b/activerecord/test/models/sharded/comment.rb @@ -9,10 +9,15 @@ class Comment < ActiveRecord::Base belongs_to :blog_post_by_id, class_name: "Sharded::BlogPost", foreign_key: :blog_post_id, primary_key: :id belongs_to :blog_post_with_inverse, class_name: "Sharded::BlogPost", - foreign_key: [:blog_id, :blog_post_id], - primary_key: [:blog_id, :id], + foreign_key: :blog_post_id, + query_constraints: :blog_id, inverse_of: :comments_with_inverse belongs_to :blog_post_with_decoupled_qc, class_name: "Sharded::BlogPost", foreign_key: :blog_post_id, query_constraints: :blog_id + belongs_to :blog_post_composite_fk_with_qc, + class_name: "Sharded::BlogPost", + foreign_key: [:blog_id, :blog_post_id], + primary_key: [:blog_id, :id], + query_constraints: [:region_id] belongs_to :blog end end diff --git a/activerecord/test/models/shipment.rb b/activerecord/test/models/shipment.rb index 7993f3663f365..02d67bf66b244 100644 --- a/activerecord/test/models/shipment.rb +++ b/activerecord/test/models/shipment.rb @@ -3,7 +3,7 @@ class Shipment < ActiveRecord::Base has_many :adjustments, as: :adjustable, - foreign_key: [:region_id, :adjustable_id], - primary_key: [:region_id, :id], + foreign_key: :adjustable_id, + query_constraints: [:region_id], inverse_of: :adjustable end diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index 1423b588a4332..c6e823e33efaa 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -358,12 +358,14 @@ t.integer :blog_id t.integer :revision t.integer :featured_comment_id + t.integer :region_id end create_table :sharded_comments, force: true do |t| t.string :body t.integer :blog_post_id t.integer :blog_id + t.integer :region_id end create_table :sharded_tags, force: true do |t|