From 12b609f10c41e39ee8e97a2eb8d8748115f8a2e5 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 26 Oct 2026 14:55:40 -0700 Subject: [PATCH 1/4] Fix BelongsToMany where_has with a query callback 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 --- .../relationships/BelongsToMany.py | 36 +++++--------- .../relationships/test_belongs_to_many.py | 47 +++++++++++++++++++ 2 files changed, 60 insertions(+), 23 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py index d4576fb8..b55854b8 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py @@ -3,6 +3,7 @@ from .BaseRelationship import BaseRelationship, ManyResult, TModel from ..collection import Collection +from ..models.builder import QueryBuilder from ..models.pivot import Pivot @@ -401,31 +402,20 @@ def joins(self, builder, clause=None): return result def query_where_exists(self, builder, callback, method="where_exists"): - query = self.get_builder() - pivot_table = self._table or self.get_pivot_table_name(query, builder) - table = self.get_builder().get_table_name() - - getattr(builder, method)( - query.new() - .table(table) - .join( - f"{pivot_table}", - f"{table}.{self.other_owner_key}", - "=", - f"{pivot_table}.{self.foreign_key}", - ) - .where_column( - f"{pivot_table}.{self.local_key}", - f"{builder.get_table_name()}.{self.local_owner_key}", - ) - .where_in(self.other_owner_key, callback(query.select(self.other_owner_key))) - ) + related = self.related_exists_query(builder) + constrained = callback(related) + if constrained is not None and not isinstance(constrained, QueryBuilder): + related.where_in(f"{related.get_table_name()}.{self.other_owner_key}", constrained) + return getattr(builder, method)(related) def query_has(self, current_query_builder, method="where_exists"): + return getattr(current_query_builder, method)(self.related_exists_query(current_query_builder)) + + def related_exists_query(self, builder): query = self.get_builder() - pivot_table = self._table or self.get_pivot_table_name(query, current_query_builder) - table = self.get_builder().get_table_name() - return getattr(current_query_builder, method)( + pivot_table = self._table or self.get_pivot_table_name(query, builder) + table = query.get_table_name() + return ( query.new() .table(table) .join( @@ -436,7 +426,7 @@ def query_has(self, current_query_builder, method="where_exists"): ) .where_column( f"{pivot_table}.{self.local_key}", - f"{current_query_builder.get_table_name()}.{self.local_owner_key}", + f"{builder.get_table_name()}.{self.local_owner_key}", ) ) diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_belongs_to_many.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_belongs_to_many.py index 075d5415..50ff5f60 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_belongs_to_many.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_belongs_to_many.py @@ -136,3 +136,50 @@ async def test_query_has_returns_builder(self): result = Store.products.query_has(builder, method="where_exists") # The builder has a where clause appended (we just verify no error is raised) self.assertIsNotNone(builder) + + +class TestBelongsToManyWhereHas(TestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + self.downtown = await Store.create({"name": "Downtown"}) + self.uptown = await Store.create({"name": "Uptown"}) + self.empty = await Store.create({"name": "Empty"}) + self.widget = await Product.create({"name": "Widget"}) + self.gadget = await Product.create({"name": "Gadget"}) + await Store.products.attach(self.downtown, self.widget) + await Store.products.attach(self.uptown, self.gadget) + + async def names(self, query): + return sorted(store.name for store in await query.get()) + + def scoped(self): + return Store.where_in("id", [self.downtown.id, self.uptown.id, self.empty.id]) + + async def test_where_has_without_callback(self): + self.assertEqual(await self.names(self.scoped().where_has("products")), ["Downtown", "Uptown"]) + + async def test_where_has_with_query_callback(self): + query = self.scoped().where_has("products", lambda q: q.where("name", "Widget")) + self.assertEqual(await self.names(query), ["Downtown"]) + + async def test_where_has_with_query_callback_matching_nothing(self): + query = self.scoped().where_has("products", lambda q: q.where("name", "Missing")) + self.assertEqual(await self.names(query), []) + + async def test_where_has_with_callback_returning_none(self): + def only_gadgets(q): + q.where("name", "Gadget") + + self.assertEqual(await self.names(self.scoped().where_has("products", only_gadgets)), ["Uptown"]) + + async def test_where_has_with_id_list_callback(self): + query = self.scoped().where_has("products", lambda q: [self.gadget.id]) + self.assertEqual(await self.names(query), ["Uptown"]) + + async def test_or_where_has_with_query_callback(self): + query = Store.where("name", "Empty").or_where_has("products", lambda q: q.where("name", "Gadget")) + self.assertEqual(await self.names(query), ["Empty", "Uptown"]) + + async def test_or_where_has_without_callback(self): + query = Store.where("name", "Empty").or_where_has("products") + self.assertEqual(await self.names(query), ["Downtown", "Empty", "Uptown"]) From 876e241e05e8aab8c07ecd12620f6e611d02c0cc Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 26 Oct 2026 15:20:04 -0700 Subject: [PATCH 2/4] Reject a where_has callback that returns a different QueryBuilder Co-Authored-By: Claude Opus 5.5 --- .../masoniteorm/relationships/BelongsToMany.py | 6 +++++- .../relationships/test_belongs_to_many.py | 17 ++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py index b55854b8..24f6c7a3 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py @@ -404,7 +404,11 @@ def joins(self, builder, clause=None): def query_where_exists(self, builder, callback, method="where_exists"): related = self.related_exists_query(builder) constrained = callback(related) - if constrained is not None and not isinstance(constrained, QueryBuilder): + if isinstance(constrained, QueryBuilder) and constrained is not related: + raise TypeError( + "where_has callback must constrain and return the query it receives, not a different QueryBuilder" + ) + if constrained is not None and constrained is not related: related.where_in(f"{related.get_table_name()}.{self.other_owner_key}", constrained) return getattr(builder, method)(related) diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_belongs_to_many.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_belongs_to_many.py index 50ff5f60..68d6b54d 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_belongs_to_many.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_belongs_to_many.py @@ -130,12 +130,15 @@ async def test_query_has_filters_stores_with_products(self): self.assertIn(self.store.id, store_ids) self.assertNotIn(store2.id, store_ids) - async def test_query_has_returns_builder(self): - # query_has should add a where_exists clause to the builder + async def test_query_has_adds_exists_clause(self): builder = Store.query() - result = Store.products.query_has(builder, method="where_exists") - # The builder has a where clause appended (we just verify no error is raised) - self.assertIsNotNone(builder) + Store.products.query_has(builder, method="where_exists") + self.assertEqual( + builder.to_sql(), + 'SELECT * FROM "stores" WHERE EXISTS (SELECT * FROM "products" ' + 'INNER JOIN "product_store" ON "products"."id" = "product_store"."product_id" ' + "WHERE product_store.store_id = stores.id)", + ) class TestBelongsToManyWhereHas(TestCase): @@ -172,6 +175,10 @@ def only_gadgets(q): self.assertEqual(await self.names(self.scoped().where_has("products", only_gadgets)), ["Uptown"]) + async def test_where_has_rejects_callback_returning_a_different_builder(self): + with self.assertRaisesRegex(TypeError, "must constrain and return the query it receives"): + Store.where_has("products", lambda q: Product.where("name", "Widget")) + async def test_where_has_with_id_list_callback(self): query = self.scoped().where_has("products", lambda q: [self.gadget.id]) self.assertEqual(await self.names(query), ["Uptown"]) From d50264528a0bd1744ddfc4c1b9c3594300f731b9 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 26 Oct 2026 15:25:30 -0700 Subject: [PATCH 3/4] Alias self-referencing relations in where_has/has 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 --- .../masoniteorm/models/builder.py | 9 +- .../masoniteorm/query/grammars/BaseGrammar.py | 15 +- .../relationships/BaseRelationship.py | 9 + .../masoniteorm/relationships/BelongsTo.py | 4 +- .../relationships/BelongsToMany.py | 24 +- .../masoniteorm/relationships/HasMany.py | 4 +- .../masoniteorm/relationships/HasOne.py | 4 +- .../test_self_relation_exists_grammar.py | 215 ++++++++++++++++++ .../test_self_relation_where_has.py | 71 ++++++ 9 files changed, 328 insertions(+), 27 deletions(-) create mode 100644 fastapi_startkit/tests/masoniteorm/query/grammars/test_self_relation_exists_grammar.py create mode 100644 fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_self_relation_where_has.py diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py index 78b23665..35e13d7a 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py @@ -80,6 +80,7 @@ def __init__(self, connection: "Connection", grammar: Any, processor: Any): self._columns: list[Any] = [] self._table = "" + self._alias: str | None = None self._limit = False self._offset = False self._wheres = [] @@ -111,10 +112,11 @@ def with_(self, *eagers) -> "Self": return self def get_table_name(self) -> str: - return self._table + return self._alias or self._table - def table(self, table: str) -> "Self": + def table(self, table: str, alias: str | None = None) -> "Self": self._table = table + self._alias = alias return self def where_in(self, column: str, values: Iterable[Any]) -> "Self": @@ -256,6 +258,7 @@ def get_grammar(self): return self.grammar( columns=self._columns, table=self._table, + alias=self._alias, limit=self._limit, offset=self._offset, wheres=self._wheres, @@ -550,7 +553,7 @@ def new(self): # subgroup) prefixes its columns correctly instead of rendering a # table-less ."column". Callers that want a different table override it # with .table(...) as usual. - return self.connection.query().table(self._table) + return self.connection.query().table(self._table, self._alias) def invalid_operator(self, operator: Any) -> bool: """Determine whether an operator is not supported by the builder.""" diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/query/grammars/BaseGrammar.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/query/grammars/BaseGrammar.py index 1f5d3c10..10eb10f6 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/query/grammars/BaseGrammar.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/query/grammars/BaseGrammar.py @@ -98,9 +98,11 @@ def __init__( lock=False, having=(), connection_details=None, + alias=None, ): self._columns = columns self.table = table + self.alias = alias self.database = database self._wheres = wheres self._limit = limit @@ -161,7 +163,7 @@ def _compile_select(self, qmark=False): .format( columns=self.process_columns(separator=", ", qmark=qmark), keyword="DISTINCT" if self._distinct else "", - table=self.process_table(self.table), + table=self.process_from_table(), joins=self.process_joins(qmark=qmark), wheres=self.process_wheres(qmark=qmark), limit=self.process_limit(), @@ -560,6 +562,11 @@ def process_table(self, table): for t in table.name.split(".") ) + def process_from_table(self): + if not self.alias: + return self.process_table(self.table) + return f"{self.process_table(self.table)} AS {self.process_table(self.alias)}" + def process_limit(self): """Compiles the limit expression. @@ -960,7 +967,7 @@ def process_column(self, column, separator=""): table = None if column and "." in column: table, column = column.split(".") - return self.column_string().format(column=column, separator=separator, table=table or self.table) + return self.column_string().format(column=column, separator=separator, table=table or self.alias or self.table) def _table_column_string(self, column, alias=None, separator=""): """Compiles a column into the column syntax. @@ -982,7 +989,7 @@ def _table_column_string(self, column, alias=None, separator=""): return self.column_strings["select_all"].format( column=column, separator=separator, - table=self.process_table(table or self.table), + table=self.process_table(table or self.alias or self.table), ) alias_string = " " + self.subquery_alias_string().format(alias=alias) if alias else "" @@ -990,7 +997,7 @@ def _table_column_string(self, column, alias=None, separator=""): column=column, separator=separator, alias=alias_string, - table=self.process_table(table or self.table), + table=self.process_table(table or self.alias or self.table), ) def _compile_value(self, value, separator=""): diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py index 0356fae3..1b014072 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py @@ -68,6 +68,15 @@ def model_reference(self) -> str | type: def get_builder(self): return self.fn()().get_builder() + def exists_alias(self, query, parent): + if query.get_table_name() == parent._table: + return f"{parent.get_table_name()}_self" + return None + + def get_exists_builder(self, parent): + query = self.get_builder() + return query.table(query.get_table_name(), self.exists_alias(query, parent)) + @overload def __get__(self, instance: None, owner: Any) -> Self: ... @overload diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsTo.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsTo.py index 8d6a6c70..399c89bb 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsTo.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsTo.py @@ -31,7 +31,7 @@ def apply_query(self, foreign, owner): return foreign.where(self.foreign_key, owner.__attributes__[self.local_key]).first() def query_has(self, current_query_builder, method="where_exists"): - related_builder = self.get_builder() + related_builder = self.get_exists_builder(current_query_builder) getattr(current_query_builder, method)( related_builder.where_column( @@ -43,7 +43,7 @@ def query_has(self, current_query_builder, method="where_exists"): return related_builder def query_where_exists(self, builder, callback, method="where_exists"): - query = self.get_builder() + query = self.get_exists_builder(builder) getattr(builder, method)( callback( query.where_column( diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py index 24f6c7a3..0fa024e3 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py @@ -418,20 +418,16 @@ def query_has(self, current_query_builder, method="where_exists"): def related_exists_query(self, builder): query = self.get_builder() pivot_table = self._table or self.get_pivot_table_name(query, builder) - table = query.get_table_name() - return ( - query.new() - .table(table) - .join( - f"{pivot_table}", - f"{table}.{self.other_owner_key}", - "=", - f"{pivot_table}.{self.foreign_key}", - ) - .where_column( - f"{pivot_table}.{self.local_key}", - f"{builder.get_table_name()}.{self.local_owner_key}", - ) + related = query.new().table(query.get_table_name(), self.exists_alias(query, builder)) + table = related.get_table_name() + return related.join( + f"{pivot_table}", + f"{table}.{self.other_owner_key}", + "=", + f"{pivot_table}.{self.foreign_key}", + ).where_column( + f"{pivot_table}.{self.local_key}", + f"{builder.get_table_name()}.{self.local_owner_key}", ) def get_pivot_table_name(self, query, builder): diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasMany.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasMany.py index 363268f1..3985f478 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasMany.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasMany.py @@ -40,7 +40,7 @@ async def attach(self, current_model, related_record): return await related_record.update({self.foreign_key: local_key_value}) def query_has(self, current_query_builder, method="where_exists"): - related_builder = self.get_builder() + related_builder = self.get_exists_builder(current_query_builder) getattr(current_query_builder, method)( related_builder.where_column( @@ -52,7 +52,7 @@ def query_has(self, current_query_builder, method="where_exists"): return related_builder def query_where_exists(self, builder, callback, method="where_exists"): - query = self.get_builder() + query = self.get_exists_builder(builder) getattr(builder, method)( callback( query.where_column( diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOne.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOne.py index 42b3b1d4..0fc810e0 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOne.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOne.py @@ -41,7 +41,7 @@ def map_related(self, related_result): return related_result def query_has(self, current_query_builder, method="where_exists"): - related_builder = self.get_builder() + related_builder = self.get_exists_builder(current_query_builder) getattr(current_query_builder, method)( related_builder.where_column( @@ -58,7 +58,7 @@ def register_related(self, key, model, collection): model.add_relation({key: related or None}) def query_where_exists(self, builder, callback, method="where_exists"): - query = self.get_builder() + query = self.get_exists_builder(builder) getattr(builder, method)( callback( query.where_column( diff --git a/fastapi_startkit/tests/masoniteorm/query/grammars/test_self_relation_exists_grammar.py b/fastapi_startkit/tests/masoniteorm/query/grammars/test_self_relation_exists_grammar.py new file mode 100644 index 00000000..366dd7ed --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/query/grammars/test_self_relation_exists_grammar.py @@ -0,0 +1,215 @@ +import unittest + +from fastapi_startkit.masoniteorm import BelongsTo, BelongsToMany, HasMany, HasOne, Model +from fastapi_startkit.masoniteorm.models.builder import QueryBuilder +from fastapi_startkit.masoniteorm.query.grammars.MSSQLGrammar import MSSQLGrammar +from fastapi_startkit.masoniteorm.query.grammars.MySQLGrammar import MySQLGrammar +from fastapi_startkit.masoniteorm.query.grammars.PostgresGrammar import PostgresGrammar +from fastapi_startkit.masoniteorm.query.grammars.SQLiteGrammar import SQLiteGrammar + +GRAMMARS = { + "sqlite": SQLiteGrammar, + "mysql": MySQLGrammar, + "postgres": PostgresGrammar, + "mssql": MSSQLGrammar, +} + +QUOTES = {"sqlite": '""', "mysql": "``", "postgres": '""', "mssql": "[]"} + + +def quote(dialect, *parts): + left, right = QUOTES[dialect] + return ".".join(f"{left}{part}{right}" for part in parts) + + +class FakeConnection: + def __init__(self, grammar): + self.grammar = grammar + + def query(self): + return QueryBuilder(connection=self, grammar=self.grammar, processor=None) + + +class FakeManager: + def __init__(self, grammar): + self.grammar = grammar + + def connection(self, name=None): + return FakeConnection(self.grammar) + + +class SelfMember(Model): + __table__ = "members" + + friends: "SelfMember" = BelongsToMany["SelfMember"]( + local_foreign_key="member_id", + other_foreign_key="friend_id", + local_owner_key="id", + other_owner_key="id", + table="friendships", + ) + children: "SelfMember" = HasMany["SelfMember"](local_key="id", foreign_key="parent_id") + successor: "SelfMember" = HasOne["SelfMember"](local_key="id", foreign_key="predecessor_id") + parent: "SelfMember" = BelongsTo["SelfMember"](local_key="parent_id", foreign_key="id") + groups: "SelfGroup" = BelongsToMany["SelfGroup"]( + local_foreign_key="member_id", + other_foreign_key="group_id", + local_owner_key="id", + other_owner_key="id", + table="group_member", + ) + posts: "SelfPost" = HasMany["SelfPost"](local_key="id", foreign_key="member_id") + + +class SelfGroup(Model): + __table__ = "groups" + + +class SelfPost(Model): + __table__ = "posts" + + +class TestSelfRelationExistsGrammar(unittest.TestCase): + def setUp(self): + self.previous_manager = Model.db_manager + + def tearDown(self): + Model.db_manager = self.previous_manager + + def dialects(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + Model.db_manager = FakeManager(grammar) + yield name + + def test_belongs_to_many_where_has_with_callback_aliases_inner_table(self): + for d in self.dialects(): + sql = SelfMember.where_has("friends", lambda q: q.where("name", "Bob")).to_sql() + self.assertEqual( + sql, + f"SELECT * FROM {quote(d, 'members')} WHERE EXISTS (SELECT * FROM {quote(d, 'members')} " + f"AS {quote(d, 'members_self')} INNER JOIN {quote(d, 'friendships')} " + f"ON {quote(d, 'members_self', 'id')} = {quote(d, 'friendships', 'friend_id')} " + f"WHERE friendships.member_id = members.id AND {quote(d, 'members_self', 'name')} = 'Bob')", + ) + + def test_belongs_to_many_where_has_without_callback_aliases_inner_table(self): + for d in self.dialects(): + sql = SelfMember.where_has("friends").to_sql() + self.assertEqual( + sql, + f"SELECT * FROM {quote(d, 'members')} WHERE EXISTS (SELECT * FROM {quote(d, 'members')} " + f"AS {quote(d, 'members_self')} INNER JOIN {quote(d, 'friendships')} " + f"ON {quote(d, 'members_self', 'id')} = {quote(d, 'friendships', 'friend_id')} " + f"WHERE friendships.member_id = members.id)", + ) + + def test_belongs_to_many_or_where_has_aliases_inner_table(self): + for d in self.dialects(): + sql = SelfMember.where("name", "Carol").or_where_has("friends", lambda q: q.where("name", "Bob")).to_sql() + self.assertEqual( + sql, + f"SELECT * FROM {quote(d, 'members')} WHERE {quote(d, 'members', 'name')} = 'Carol' " + f"OR EXISTS (SELECT * FROM {quote(d, 'members')} AS {quote(d, 'members_self')} " + f"INNER JOIN {quote(d, 'friendships')} " + f"ON {quote(d, 'members_self', 'id')} = {quote(d, 'friendships', 'friend_id')} " + f"WHERE friendships.member_id = members.id AND {quote(d, 'members_self', 'name')} = 'Bob')", + ) + + def test_has_many_where_has_with_callback_aliases_inner_table(self): + for d in self.dialects(): + sql = SelfMember.where_has("children", lambda q: q.where("name", "Bob")).to_sql() + self.assertEqual( + sql, + f"SELECT * FROM {quote(d, 'members')} WHERE EXISTS (SELECT * FROM {quote(d, 'members')} " + f"AS {quote(d, 'members_self')} WHERE members_self.parent_id = members.id " + f"AND {quote(d, 'members_self', 'name')} = 'Bob')", + ) + + def test_has_many_where_has_without_callback_aliases_inner_table(self): + for d in self.dialects(): + sql = SelfMember.where_has("children").to_sql() + self.assertEqual( + sql, + f"SELECT * FROM {quote(d, 'members')} WHERE EXISTS (SELECT * FROM {quote(d, 'members')} " + f"AS {quote(d, 'members_self')} WHERE members_self.parent_id = members.id)", + ) + + def test_has_many_or_where_has_aliases_inner_table(self): + for d in self.dialects(): + sql = SelfMember.where("name", "Carol").or_where_has("children").to_sql() + self.assertEqual( + sql, + f"SELECT * FROM {quote(d, 'members')} WHERE {quote(d, 'members', 'name')} = 'Carol' " + f"OR EXISTS (SELECT * FROM {quote(d, 'members')} AS {quote(d, 'members_self')} " + f"WHERE members_self.parent_id = members.id)", + ) + + def test_has_one_where_has_aliases_inner_table(self): + for d in self.dialects(): + sql = SelfMember.where_has("successor", lambda q: q.where("name", "Bob")).to_sql() + self.assertEqual( + sql, + f"SELECT * FROM {quote(d, 'members')} WHERE EXISTS (SELECT * FROM {quote(d, 'members')} " + f"AS {quote(d, 'members_self')} WHERE members_self.predecessor_id = members.id " + f"AND {quote(d, 'members_self', 'name')} = 'Bob')", + ) + sql = SelfMember.where_has("successor").to_sql() + self.assertEqual( + sql, + f"SELECT * FROM {quote(d, 'members')} WHERE EXISTS (SELECT * FROM {quote(d, 'members')} " + f"AS {quote(d, 'members_self')} WHERE members_self.predecessor_id = members.id)", + ) + + def test_belongs_to_where_has_aliases_inner_table(self): + for d in self.dialects(): + sql = SelfMember.where_has("parent", lambda q: q.where("name", "Bob")).to_sql() + self.assertEqual( + sql, + f"SELECT * FROM {quote(d, 'members')} WHERE EXISTS (SELECT * FROM {quote(d, 'members')} " + f"AS {quote(d, 'members_self')} WHERE members_self.id = members.parent_id " + f"AND {quote(d, 'members_self', 'name')} = 'Bob')", + ) + sql = SelfMember.where_has("parent").to_sql() + self.assertEqual( + sql, + f"SELECT * FROM {quote(d, 'members')} WHERE EXISTS (SELECT * FROM {quote(d, 'members')} " + f"AS {quote(d, 'members_self')} WHERE members_self.id = members.parent_id)", + ) + + def test_nested_self_relation_gets_distinct_alias(self): + for d in self.dialects(): + sql = SelfMember.where_has("children", lambda q: q.where_has("children")).to_sql() + self.assertEqual( + sql, + f"SELECT * FROM {quote(d, 'members')} WHERE EXISTS (SELECT * FROM {quote(d, 'members')} " + f"AS {quote(d, 'members_self')} WHERE members_self.parent_id = members.id " + f"AND EXISTS (SELECT * FROM {quote(d, 'members')} AS {quote(d, 'members_self_self')} " + f"WHERE members_self_self.parent_id = members_self.id))", + ) + + def test_non_self_belongs_to_many_is_unchanged(self): + for d in self.dialects(): + sql = SelfMember.where_has("groups", lambda q: q.where("name", "Admins")).to_sql() + self.assertEqual( + sql, + f"SELECT * FROM {quote(d, 'members')} WHERE EXISTS (SELECT * FROM {quote(d, 'groups')} " + f"INNER JOIN {quote(d, 'group_member')} " + f"ON {quote(d, 'groups', 'id')} = {quote(d, 'group_member', 'group_id')} " + f"WHERE group_member.member_id = members.id AND {quote(d, 'groups', 'name')} = 'Admins')", + ) + + def test_non_self_has_many_is_unchanged(self): + for d in self.dialects(): + sql = SelfMember.where_has("posts", lambda q: q.where("title", "Hi")).to_sql() + self.assertEqual( + sql, + f"SELECT * FROM {quote(d, 'members')} WHERE EXISTS (SELECT * FROM {quote(d, 'posts')} " + f"WHERE posts.member_id = members.id AND {quote(d, 'posts', 'title')} = 'Hi')", + ) + sql = SelfMember.where_has("posts").to_sql() + self.assertEqual( + sql, + f"SELECT * FROM {quote(d, 'members')} WHERE EXISTS (SELECT * FROM {quote(d, 'posts')} " + f"WHERE posts.member_id = members.id)", + ) diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_self_relation_where_has.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_self_relation_where_has.py new file mode 100644 index 00000000..ecc40d7e --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_self_relation_where_has.py @@ -0,0 +1,71 @@ +from fastapi_startkit.masoniteorm import BelongsTo, BelongsToMany, HasMany, Model + +from ..test_case import TestCase + + +class Member(Model): + __table__ = "members" + __timestamps__ = False + + friends: "Member" = BelongsToMany["Member"]( + local_foreign_key="member_id", + other_foreign_key="friend_id", + local_owner_key="id", + other_owner_key="id", + table="friendships", + ) + children: "Member" = HasMany["Member"](local_key="id", foreign_key="parent_id") + parent: "Member" = BelongsTo["Member"](local_key="parent_id", foreign_key="id") + + +class TestSelfRelationWhereHas(TestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + async with await self.schema.create_table_if_not_exists("members") as table: + table.id() + table.string("name") + table.integer("parent_id").nullable() + async with await self.schema.create_table_if_not_exists("friendships") as table: + table.id() + table.integer("member_id") + table.integer("friend_id") + + self.alice = await Member.create({"name": "Alice"}) + self.bob = await Member.create({"name": "Bob", "parent_id": self.alice.id}) + self.carol = await Member.create({"name": "Carol", "parent_id": self.bob.id}) + self.dave = await Member.create({"name": "Dave"}) + await Member.friends.attach(self.alice, self.bob) + await Member.friends.attach(self.carol, self.dave) + + async def names(self, query): + return sorted(member.name for member in await query.get()) + + async def test_where_has_friends_with_callback(self): + query = Member.where_has("friends", lambda q: q.where("name", "Bob")) + self.assertEqual(await self.names(query), ["Alice"]) + + async def test_where_has_friends_without_callback(self): + self.assertEqual(await self.names(Member.where_has("friends")), ["Alice", "Carol"]) + + async def test_or_where_has_friends_with_callback(self): + query = Member.where("name", "Bob").or_where_has("friends", lambda q: q.where("name", "Dave")) + self.assertEqual(await self.names(query), ["Bob", "Carol"]) + + async def test_where_has_children_with_callback(self): + query = Member.where_has("children", lambda q: q.where("name", "Carol")) + self.assertEqual(await self.names(query), ["Bob"]) + + async def test_where_has_children_without_callback(self): + self.assertEqual(await self.names(Member.where_has("children")), ["Alice", "Bob"]) + + async def test_or_where_has_children(self): + query = Member.where("name", "Dave").or_where_has("children") + self.assertEqual(await self.names(query), ["Alice", "Bob", "Dave"]) + + async def test_where_has_parent_with_callback(self): + query = Member.where_has("parent", lambda q: q.where("name", "Alice")) + self.assertEqual(await self.names(query), ["Bob"]) + + async def test_nested_where_has_children(self): + query = Member.where_has("children", lambda q: q.where_has("children")) + self.assertEqual(await self.names(query), ["Alice"]) From 9834b9c6200baf2ddb25835984837761ba49817f Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 26 Oct 2026 15:32:16 -0700 Subject: [PATCH 4/4] Cover id-list and foreign-builder where_has callbacks on self relations Co-Authored-By: Claude Opus 5.5 --- .../grammars/test_self_relation_exists_grammar.py | 11 +++++++++++ .../relationships/test_self_relation_where_has.py | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/fastapi_startkit/tests/masoniteorm/query/grammars/test_self_relation_exists_grammar.py b/fastapi_startkit/tests/masoniteorm/query/grammars/test_self_relation_exists_grammar.py index 366dd7ed..1b28ab3d 100644 --- a/fastapi_startkit/tests/masoniteorm/query/grammars/test_self_relation_exists_grammar.py +++ b/fastapi_startkit/tests/masoniteorm/query/grammars/test_self_relation_exists_grammar.py @@ -104,6 +104,17 @@ def test_belongs_to_many_where_has_without_callback_aliases_inner_table(self): f"WHERE friendships.member_id = members.id)", ) + def test_belongs_to_many_id_list_callback_uses_alias(self): + for d in self.dialects(): + sql = SelfMember.where_has("friends", lambda q: [2, 3]).to_sql() + self.assertEqual( + sql, + f"SELECT * FROM {quote(d, 'members')} WHERE EXISTS (SELECT * FROM {quote(d, 'members')} " + f"AS {quote(d, 'members_self')} INNER JOIN {quote(d, 'friendships')} " + f"ON {quote(d, 'members_self', 'id')} = {quote(d, 'friendships', 'friend_id')} " + f"WHERE friendships.member_id = members.id AND {quote(d, 'members_self', 'id')} IN ('2','3'))", + ) + def test_belongs_to_many_or_where_has_aliases_inner_table(self): for d in self.dialects(): sql = SelfMember.where("name", "Carol").or_where_has("friends", lambda q: q.where("name", "Bob")).to_sql() diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_self_relation_where_has.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_self_relation_where_has.py index ecc40d7e..aa42b78c 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_self_relation_where_has.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_self_relation_where_has.py @@ -69,3 +69,11 @@ async def test_where_has_parent_with_callback(self): async def test_nested_where_has_children(self): query = Member.where_has("children", lambda q: q.where_has("children")) self.assertEqual(await self.names(query), ["Alice"]) + + async def test_where_has_friends_with_id_list_callback(self): + query = Member.where_has("friends", lambda q: [self.dave.id]) + self.assertEqual(await self.names(query), ["Carol"]) + + async def test_where_has_friends_rejects_different_query_builder(self): + with self.assertRaises(TypeError): + Member.where_has("friends", lambda q: Member.where("name", "Bob"))