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 d4576fb8..0fa024e3 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,43 +402,32 @@ 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}", + related = self.related_exists_query(builder) + constrained = callback(related) + 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" ) - .where_in(self.other_owner_key, callback(query.select(self.other_owner_key))) - ) + 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) 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)( - 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"{current_query_builder.get_table_name()}.{self.local_owner_key}", - ) + pivot_table = self._table or self.get_pivot_table_name(query, builder) + 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..1b28ab3d --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/query/grammars/test_self_relation_exists_grammar.py @@ -0,0 +1,226 @@ +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_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() + 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_belongs_to_many.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_belongs_to_many.py index 075d5415..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,9 +130,63 @@ 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): + 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_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"]) + + 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"]) 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..aa42b78c --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_self_relation_where_has.py @@ -0,0 +1,79 @@ +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"]) + + 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"))