Skip to content

Introduce decoupled query_constraints for associations#51

Open
nvasilevski wants to merge 2 commits into
mainfrom
introduce-decoupled-query-constraints
Open

Introduce decoupled query_constraints for associations#51
nvasilevski wants to merge 2 commits 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

Today query_constraints on a belongs_to doubles as the foreign key. That
conflation means a sharding/tenant column added to query_constraints is treated
as part of the foreign key, so it gets nulled on clear and reassigned on set
(see rails#49671 and rails#57906).

This change keeps query_constraints query-only and separate from
foreign_key. 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.
  • Listing the foreign key inside query_constraints is allowed, not rejected — it
    is de-duplicated.

Nullify / assign behavior: 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 and can just be derived. In the common case you don't spell it
out at all:

# foreign key is still derived as :post_id by convention;
# account_id is added purely to scope the lookup, and is never written or nulled
belongs_to :post, query_constraints: [:account_id]

query_constraints becomes a small additive layer on top of a conventional foreign
key, rather than something that replaces it.

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

Old-style query_constraints (a plain list of FK columns, no explicit foreign_key)
keeps its previous meaning: the columns become the foreign key and
normalized_query_constraints_mapping returns nil.

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?

@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 from af51443 to 72f9579 Compare July 7, 2026 20:42
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).
@nvasilevski nvasilevski force-pushed the introduce-decoupled-query-constraints branch from 72f9579 to 530077e Compare July 7, 2026 20:59
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