Skip to content

Introduce decoupled query_constraints for associations - #51

Open
nvasilevski wants to merge 1 commit into
mainfrom
introduce-decoupled-query-constraints
Open

Introduce decoupled query_constraints for associations#51
nvasilevski wants to merge 1 commit into
mainfrom
introduce-decoupled-query-constraints

Conversation

@nvasilevski

@nvasilevski nvasilevski commented Apr 2, 2026

Copy link
Copy Markdown

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 rails#49671 and rails#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's excluded from
nullification by construction - there's no per-column "should I null this
one?" heuristic to get right, because the column was never one of the things
clearing the association touches.

Mental model

query_constraints is a list of additional columns to match when querying an
association's targets (loading and preloading). They are layered on top of the
foreign key - the foreign key always participates, because an association can't be
queried without it.

  • When only foreign_key is given → behaves exactly as today.
  • When both foreign_key and query_constraints are given → foreign_key handles
    writes
    , and querying matches on foreign_key + the extra columns. This is the
    decoupled, nullification-safe mode.
  • When query_constraints is given without an explicit foreign_key, the
    foreign key is derived by convention. This is not yet implemented — for now,
    provide foreign_key: explicitly to get the decoupled behavior.
  • Listing the foreign key inside query_constraints is allowed, not rejected - it
    is de-duplicated.

Nullify / assign behavior (decoupled mode): clearing an association nulls only
the foreign_key; query_constraints columns are never written or nulled, because
they scope the owner, not point at the target.

The common case: the foreign key stays conventional

Because query_constraints no longer redefines the foreign key, the foreign key stays
completely traditional. Note: foreign_key is not yet derived automatically when
query_constraints is given — it must be provided explicitly
to get the decoupled
(nullification-safe) behavior. In the common case you spell it out:

# foreign_key handles writes (and is the only column nulled on clear);
# account_id is added purely to scope the lookup, and is never written or nulled
belongs_to :post, foreign_key: :post_id, query_constraints: [:account_id]

query_constraints becomes a small additive layer on top of a conventional foreign
key, rather than something that replaces it. Auto-deriving foreign_key when only
query_constraints is given is left as a follow-up.

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 (an FK can't be derived from a
renamed pair) - otherwise an ArgumentError is raised.

Implementation

  • New reflection methods query_constraints_foreign_key,
    normalized_query_constraints_mapping, and
    join_query_constraints_{primary,foreign}_key / join_query_constraints_id_for.
    Join (join_scope, AssociationScope) and preload paths resolve keys through
    these, falling back to the existing join_* behavior when no query_constraints
    are present.
  • active_record_primary_key (and autosave's write key) ignore query_constraints
    when an explicit foreign_key is present, so writes and .joins() keep using the
    scalar PK/FK.
  • ThroughReflection / PolymorphicReflection / RuntimeReflection delegate the new
    methods in lockstep with their join_* counterparts.

Backward compatibility

Existing composite foreign_key: [...] associations (e.g. CPK) keep working exactly
as today: the columns act as the foreign key and are written and nulled as a unit.
The decoupled, nullification-safe behavior activates only when query_constraints:
is given alongside an explicit foreign_key: (or a Hash mapping is used).

Comment on lines +532 to +533
# If the foreign key is an array, set query constraints options
if options[:foreign_key].is_a?(Array) && !options[:query_constraints]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If the idea is that we're separating the two concepts, do we still need to copy the value over?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

good catch, I think we should be able to drop the copy over in favor of concatenation to get the full list of "columns to query by". Let me see what breaks

@nvasilevski
nvasilevski force-pushed the introduce-decoupled-query-constraints branch from 27fce20 to af51443 Compare June 19, 2026 20:40
@nvasilevski
nvasilevski force-pushed the introduce-decoupled-query-constraints branch 3 times, most recently from dd20f24 to 75a844b Compare July 9, 2026 15:06
serioushaircut added a commit that referenced this pull request Jul 9, 2026
…source

Add test coverage for a polymorphic has_many :through whose source
belongs_to carries the decoupled query_constraints introduced in
PR #51. PR #51's only :through coverage used a single-type source
(tags_with_decoupled_qc -> Sharded::Tag); the polymorphic shape that
motivates the feature (World Delivery Zone -> region -> Country/Province)
was untested.

The new test confirms PR #51 already handles the polymorphic case
correctly: the middle->target join scopes on the tenant/shard column
(blog_id) in addition to the polymorphic id (taggable_id), so a row
from another shard cannot match on id alone.

The only fix required was in the test scaffolding, not the framework: a
polymorphic belongs_to derives its type column from the association name,
so taggable_with_decoupled_qc looked for a taggable_with_decoupled_qc_type
column while the schema uses the taggable_ prefix. Pinning
foreign_type: :taggable_type aligns the type column with the taggable_id
foreign key. reflection.rb is left untouched.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/fca4b6fb-9f85-47ab-9f87-e68a1d779f82
@nvasilevski
nvasilevski force-pushed the introduce-decoupled-query-constraints branch 4 times, most recently from 3fd92c6 to 10cf21b Compare July 27, 2026 14:41
@nvasilevski
nvasilevski force-pushed the introduce-decoupled-query-constraints branch from 10cf21b to 42f2cf8 Compare July 27, 2026 15:00
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