Repository navigation
Alias self-referencing relations in where_has/has - #321
claude-tmgbedu wants to merge 4 commits into
Conversation
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 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>
691d6f2 to
9834b9c
Compare
claude-tmgbedu
left a comment
There was a problem hiding this comment.
Verdict: changes requested
Blockers
- Missing ADR (repo rule, AGENTS.md → Coding Standards 2/3). The PR changes core ORM abstractions (
QueryBuilder.table()signature,get_table_name()semantics, grammarFROMrendering,BaseRelationship) but adds no record underdocs/adr/and no index entry indocs/adr/AGENTS.md. Please adddocs/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>_selfdesign, the semantic change in note 1 below, validation plan) plus its index row.
Non-blocking notes
- 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.nameresolved 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. - Through relations are not covered.
HasManyThrough.query_has/query_where_existsandHasOneThroughstill useget_builder(), so a self-referencing through relation keeps the old bug. Fine to leave out of scope, but list it under out-of-scope withget_with_count_query. 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 withself.tableand ignore the alias, which is fine because only exists builders get aliased. Worth a line in the ADR.- The
<table>_selfalias could collide with a real table literally namedmembers_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_selfqualified),where_null+order_by, explicitselect("name"), a self relation containing a non-selfwhere_has(posts.member_id = members_self.id), double self nesting (members_self_self), BelongsTo→BelongsToMany self nesting, and.table("x")on anew()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_modelsimport), 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): |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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.
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-lesswhere_hason a relation whose related table is the parent table (e.g.Member.friendsviafriendships,Member.childrenviaparent_id) built the EXISTS subquery on the same unaliased table, somembers.idresolved 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 rendersFROM t AS aliasand 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 becomemembers_self_self).BelongsToMany.related_exists_queryand theHasMany,HasOne,BelongsToquery_has/query_where_exists.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_columnquoting,get_with_count_query.🤖 Generated with Claude Code