Skip to content

Alias self-referencing relations in where_has/has - #321

Open
claude-tmgbedu wants to merge 4 commits into
mainfrom
fix/self-relation-where-has-alias
Open

claude-tmgbedu wants to merge 4 commits into
mainfrom
fix/self-relation-where-has-alias

Conversation

@claude-tmgbedu

Copy link
Copy Markdown

Depends on #319 (stacked on fix/belongs-to-many-where-has-callback; will rebase onto main once #319 merges).

Problem

where_has / or_where_has / callback-less where_has on a relation whose related table is the parent table (e.g. Member.friends via friendships, Member.children via parent_id) built the EXISTS subquery on the same unaliased table, so members.id resolved to the inner row and only self-relations matched.

Fix

  • QueryBuilder.table(table, alias=None); get_table_name() returns the alias when set; new() carries it; the grammar renders FROM t AS alias and qualifies unqualified columns with the alias.
  • BaseRelationship.exists_alias / get_exists_builder: when the related table equals the parent table, alias it as <parent>_self (nested self relations become members_self_self).
  • Used by BelongsToMany.related_exists_query and the HasMany, HasOne, BelongsTo query_has / query_where_exists.
  • Non-self relations compile exactly as before (asserted).

Tests

  • tests/masoniteorm/query/grammars/test_self_relation_exists_grammar.py: to_sql on sqlite/mysql/postgres/mssql for BelongsToMany, HasMany, HasOne, BelongsTo self relations (where_has, or_where_has, no callback, nested) plus unchanged non-self SQL.
  • tests/masoniteorm/sqlite/relationships/test_self_relation_where_has.py: sqlite integration (members + friendships).

Out of scope: unquoted where_column quoting, get_with_count_query.

🤖 Generated with Claude Code

tmgbedu and others added 3 commits October 26, 2026 14:55
query_where_exists passed the callback's QueryBuilder to where_in, so
where_has/or_where_has with q.where(...) raised TypeError. Apply the
callback to the related EXISTS query joined through the pivot, keeping
id-list callbacks as a where_in on the related key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Existence subqueries for relations whose related table equals the parent
table selected from the same unaliased table, so correlation columns like
members.id bound to the inner query and only self-matches qualified.
Alias the inner table ("members AS members_self") and qualify the join,
correlation and callback columns with the alias for BelongsToMany,
HasMany, HasOne and BelongsTo. Non-self relations compile unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@claude-tmgbedu
claude-tmgbedu force-pushed the fix/self-relation-where-has-alias branch from 691d6f2 to 9834b9c Compare October 26, 2026 22:32

@claude-tmgbedu claude-tmgbedu left a comment

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.

Verdict: changes requested

Blockers

  1. Missing ADR (repo rule, AGENTS.md → Coding Standards 2/3). The PR changes core ORM abstractions (QueryBuilder.table() signature, get_table_name() semantics, grammar FROM rendering, BaseRelationship) but adds no record under docs/adr/ and no index entry in docs/adr/AGENTS.md. Please add docs/adr/039-alias-self-referencing-relations-in-where-has.md (problem, alternatives such as a caller-supplied alias or an always-on alias, chosen <parent>_self design, the semantic change in note 1 below, validation plan) plus its index row.

Non-blocking notes

  1. Qualified columns in callbacks now point at the outer row. Member.where_has("friends", lambda q: q.where("members.name", "Bob")) now compiles to ... FROM "members" AS "members_self" ... AND "members"."name" = 'Bob', which filters on the outer member. Before, members.name resolved to the inner row. That is arguably correct SQL, but it silently changes the meaning of existing callbacks that qualify columns. Please document it in the ADR and add a test that pins the behaviour.
  2. Through relations are not covered. HasManyThrough.query_has/query_where_exists and HasOneThrough still use get_builder(), so a self-referencing through relation keeps the old bug. Fine to leave out of scope, but list it under out-of-scope with get_with_count_query.
  3. get_table_name() now returns the alias. All current callers look correct for exists builders, but this is a public builder method. Insert, update and delete compile with self.table and ignore the alias, which is fine because only exists builders get aliased. Worth a line in the ADR.
  4. The <table>_self alias could collide with a real table literally named members_self. This is unlikely, so it's a note only.

Verified

  • I probed the compiled SQL (sqlite) and it is correct for: nested lambda groups (members_self qualified), where_null + order_by, explicit select("name"), a self relation containing a non-self where_has (posts.member_id = members_self.id), double self nesting (members_self_self), BelongsTo→BelongsToMany self nesting, and .table("x") on a new() builder resetting the alias.
  • MSSQL FROM [t] AS [a] WITH (lock) ordering is valid. Non-self relations compile unchanged (asserted in tests).
  • pytest --ignore=tests/masoniteorm/postgres --cov: 3351 passed, 10 skipped, coverage 91.71% (≥80).
  • ruff check / ruff format --check: clean.
  • basedpyright src: 1 error (langchain.chat_models import), also on main. No new diagnostics in the changed files.

def get_builder(self):
return self.fn()().get_builder()

def exists_alias(self, query, parent):

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.

Blocker (process): this core relationship change needs an ADR in docs/adr/ plus an index entry in docs/adr/AGENTS.md. The ADR should cover why the alias is derived as <parent>_self rather than passed in by the caller, and it should mention that a real table named <table>_self would collide.


def get_table_name(self) -> str:
return self._table
return self._alias or self._table

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.

Non-blocking: get_table_name() now returns the alias. That works for every relationship caller I traced. Note that a callback qualifying the real table name (e.g. q.where("members.name", ...)) now binds to the outer row instead of the inner one. Please document this and pin it with a test.

for t in table.name.split(".")
)

def process_from_table(self):

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.

Looks good. Only SELECT honours the alias, and insert/update/delete still use self.table. That's fine because only exists builders get aliased, but please state it in the ADR.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants