Skip to content

Introduce decoupled query constraints for associations - #58266

Open
nvasilevski wants to merge 4 commits into
rails:mainfrom
Shopify:introduce-decoupled-query-constraints
Open

Introduce decoupled query constraints for associations#58266
nvasilevski wants to merge 4 commits into
rails:mainfrom
Shopify:introduce-decoupled-query-constraints

Conversation

@nvasilevski

@nvasilevski nvasilevski commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Introduce decoupled query_constraints for associations

Reintroduces query_constraints on associations, decoupled from foreign_key.

Motivation

query_constraints is deprecated, and the way to scope a belongs_to by an
extra column (a shard or tenant) today is to list it inside foreign_key as an
array:

belongs_to :post, foreign_key: [:account_id, :post_id]

Active Record treats every column in that array as part of the foreign key, so
the tenant column (account_id) gets nulled on clear and reassigned on set
(see #49671 and #57906).

This change reintroduces query_constraints as a query-only option layered
on top of foreign_key, separate from it. With the shard column expressed as
additive query scope rather than part of the foreign key, it is excluded from
nullification by construction: the column was never one of the things clearing
the association touches.

Mental model

query_constraints declares additional columns to match when querying an
association's targets. They participate in loading, preloading, eager loading,
joins, and association predicates. The foreign key always participates because
an association cannot be queried without it.

  • When only foreign_key is given, behavior is unchanged.
  • When query_constraints is given, foreign_key handles writes while queries
    match on the foreign key plus the additional columns.
  • When query_constraints is given without an explicit foreign_key, the
    foreign key is derived by convention.
  • Query constraint columns must be distinct from explicit or derived foreign key
    columns; overlapping configurations raise ArgumentError when the reflection
    is validated.

Nullify / assign behavior: clearing an association nulls only the
foreign_key; query_constraints columns are never written or nulled because
they scope the owner rather than point at the target.

The common case: the foreign key stays conventional

Because query_constraints no longer redefines the foreign key, the common case
only needs to name the additional query column:

# post_id is derived and is the only column written or nulled;
# account_id is added purely to scope queries
belongs_to :post, query_constraints: :account_id

This is equivalent to spelling out the conventional key:

belongs_to :post, foreign_key: :post_id, query_constraints: :account_id

An explicit foreign_key remains available for non-conventional associations.

Two sides of a constraint (advanced)

A column can have a different name on each side. query_constraints accepts
symbols (same name on both sides) and hashes (self_column => target_column):

class BlogPost < ApplicationRecord
  belongs_to :featured_comment,
    class_name: "Comment",
    foreign_key: :featured_comment_id,
    query_constraints: [:blog_id, { id: :blog_post_id }]
  #   :blog_id              -> blog_id on both tables
  #   { id: :blog_post_id } -> BlogPost#id matches Comment#blog_post_id
end

Resulting join keys:

  • self (BlogPost) columns: ["blog_id", "id", "featured_comment_id"]
  • target (Comment) columns: ["blog_id", "blog_post_id", "id"]

A Hash mapping requires an explicit foreign_key because a foreign key cannot
be derived from a renamed pair.

Implementation

  • New reflection methods query_constraints_foreign_key,
    normalized_query_constraints_mapping, and
    join_query_constraints_{primary,foreign}_key /
    join_query_constraints_id_for separate writable keys from query keys.
  • Join (join_scope, AssociationScope) and preload paths resolve keys through
    the query-constraint methods, falling back to existing join_* behavior when
    no association query constraints are present.
  • foreign_key, active_record_primary_key, and autosave continue to expose and
    write only the explicit or conventionally derived foreign key.
  • Array-valued foreign_key options retain their composite writable-key
    semantics; their origin is tracked separately from user-supplied
    query_constraints.
  • ThroughReflection, PolymorphicReflection, and RuntimeReflection delegate
    the new methods in lockstep with their join_* counterparts.

Backward compatibility

Existing composite foreign_key: [...] associations, including composite
primary key associations, keep working as today: all columns remain part of the
foreign key and are written and nulled as a unit.

Associations without query_constraints are unchanged. When
query_constraints is present, decoupled behavior applies with either an
explicit or conventionally derived foreign key. Hash mappings still require an
explicit foreign_key.

@nvasilevski
nvasilevski force-pushed the introduce-decoupled-query-constraints branch 4 times, most recently from 4e25193 to f6b0b1f Compare August 4, 2026 21:55
@nvasilevski
nvasilevski force-pushed the introduce-decoupled-query-constraints branch 2 times, most recently from 8ffa9b3 to 83a2a24 Compare August 7, 2026 18:12
Reintroduce query_constraints as a query-only association option separate from foreign_key, including joins, preloading, and legacy composite-key compatibility.

Assisted-By: devx/43280c01-a333-4b61-b964-1493f6ac271d
@nvasilevski
nvasilevski force-pushed the introduce-decoupled-query-constraints branch from 747d9f5 to 79f5ce6 Compare August 10, 2026 22:41

@adrianna-chang-shopify adrianna-chang-shopify left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When query_constraints is given without an explicit foreign_key, the
foreign key is derived by convention. This is not yet implemented

I'm a little confused here. It seems (from me roughly tracing through the code 😅 ) that specifying query_constraints without a foreign gives us the legacy behaviour of turning it into the set of columns to use for both reading and writing.

This is what Pi is telling me:

(In reflection.rb)
 foreign_key: [:blog_id, :blog_post_id] (array form):                                                                                                                                           
 - Line 535: [:blog_id, :blog_post_id].is_a?(Array) && !nil → true                                                                                                                              
 - Line 536: options[:query_constraints] = options.delete(:foreign_key)                                                                                                                         
 - Result: options = { query_constraints: [:blog_id, :blog_post_id] }, no foreign_key key                                                                                                       
                                                                                                                                                                                                
 query_constraints: [:blog_id, :blog_post_id] (standalone):                                                                                                                                     
 - Line 535: nil.is_a?(Array) → false → skip                                                                                                                                                    
 - Result: options = { query_constraints: [:blog_id, :blog_post_id] }, no foreign_key key

The PR description makes it sound like auto-deriving FK from query_constraints (with no foreign_key: specified) is a future feature, but the code implies otherwise? Shouldn't we continue to guard against the legacy behaviour / using query_constraints without a foreign_key specified?

@nvasilevski

Copy link
Copy Markdown
Contributor Author

that specifying query_constraints without a foreign gives us the legacy behaviour of turning it into the set of columns to use for both reading and writing.

that shouldn't be the case, at least that's wasn't intended design. To be specific, let's consider a typical association such as Comment.belongs_to :post

What we want to support:

Comment.belongs_to :post, foreign_key: :post_id, primary_key: :id, query_constraints: :blog_id

which ideally should look like:

Comment.belongs_to :post, query_constraints: :blog_id

which can be read as "id/post_id pk/fk pair for association (write) purposes and use blog_id as an additional column to pk/fk pair for querying purposes". id/post_id can be ommited because they are conventional and rails should be able to derive them.

what we don't want to allow is:

Comment.belongs_to :post, query_constraints: [:blog_id, :post_id]

because it makes it look like association doesn't have foreign key which is nonsensical for an association while clearly post_id is supposed to be the fk (either an explicit foreign_key option or a derived behind the scenes)

I pushed a commit that should address that but I'm going to evaluate whole proposal against the expectation described above

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants