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.

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.

Two sides of a constraint

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?

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 27fce20 to af51443 Compare June 19, 2026 20:40
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