From 1107a49a18d39f132266a1a3e4bea527e9622dc4 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 11 Jul 2026 23:31:35 -0700 Subject: [PATCH 1/3] feat(orm): select_sub subquery-as-column + Laravel-style add_select + table() - select_sub(subquery, alias): the subquery-as-column primitive. Renders (subquery) AS alias with bindings in SELECT position; accepts a builder or a callable that builds one. Matches Laravel selectSub(query, as). - add_select(*columns): Laravel's variadic column-adder. Appends plain string columns with a dedup guard; an associative {alias: subquery} entry (string key + queryable value) delegates to select_sub, seeding select({table}.*) first when nothing is selected yet. A string value under a string key stays a plain column. - table(name): sets the target table on a bare builder; backs the callable form of select_sub. - Migrate the *Through/BelongsToMany with-count callers from the old add_select(alias, callable) shape to select_sub(callable, alias). - process_columns: route the subquery alias through subquery_alias_string() instead of a hardcoded AS (single source of alias emission; SQL unchanged). Existing string-based select() is unchanged. Covered by grammar-level to_sql()/to_qmark() with binding-order assertions across all four dialects, plus a real sqlite suite incl. the Laravel addSelect({alias: subquery}) acceptance case. End-to-end relationship with-count remains gated on the async count() fix (#886). --- .../masoniteorm/models/builder.py | 57 ++++++ .../masoniteorm/query/grammars/BaseGrammar.py | 2 +- .../relationships/BelongsToMany.py | 4 +- .../relationships/HasManyThrough.py | 4 +- .../relationships/HasOneThrough.py | 4 +- .../query/grammars/test_select_sub_grammar.py | 187 ++++++++++++++++++ .../sqlite/builder/test_sqlite_select_sub.py | 161 +++++++++++++++ 7 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 fastapi_startkit/tests/masoniteorm/query/grammars/test_select_sub_grammar.py create mode 100644 fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_select_sub.py diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py index 5d818105..432a144d 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py @@ -80,6 +80,63 @@ def select(self, *args) -> "QueryBuilder": self._columns += (SelectExpression(column),) return self + @staticmethod + def _is_queryable(value) -> bool: + """A subquery source: a builder or a callable that returns one.""" + return isinstance(value, QueryBuilder) or (callable(value) and not isinstance(value, str)) + + def select_sub(self, subquery, alias: str) -> "QueryBuilder": + """Add a subquery as an aliased column: ``(subquery) AS alias``. + + ``subquery`` is a ``QueryBuilder`` or a callable that receives a fresh + builder and returns one. Mirrors Laravel's ``selectSub($query, $as)``. + """ + if callable(subquery) and not isinstance(subquery, QueryBuilder): + subquery = subquery(QueryBuilder(self.connection, self.grammar, self.processor)) + if not isinstance(subquery, QueryBuilder): + raise TypeError("select_sub() expects a QueryBuilder subquery or a callable returning one.") + self._columns += (SubGroupExpression(subquery, alias),) + return self + + def add_select(self, *columns) -> "QueryBuilder": + """Append columns to the current selection — Laravel's ``addSelect``. + + Accepts plain string columns (variadic, or a single list) appended with + a dedup guard. An associative ``{alias: subquery}`` entry — a string key + with a *queryable* value — adds a correlated column via + :meth:`select_sub`, seeding the selection with ``{table}.*`` first when + nothing is selected yet (so the base columns are not lost). A string + value under a string key is still treated as a plain column. + """ + if len(columns) == 1 and isinstance(columns[0], (list, tuple)): + columns = tuple(columns[0]) + + for column in columns: + pairs = column.items() if isinstance(column, dict) else ((None, column),) + for alias, value in pairs: + if self._is_queryable(value): + if not isinstance(alias, str): + raise TypeError( + "add_select() received a subquery without a string alias; " + "pass {alias: subquery} or use select_sub(subquery, alias)." + ) + if not self._columns: + self.select(f"{self._table}.*") + self.select_sub(value, alias) + continue + + already_selected = any( + isinstance(existing, SelectExpression) and existing.alias is None and existing.column == value + for existing in self._columns + ) + if not already_selected: + self._columns += (SelectExpression(value),) + return self + + def table(self, name: str) -> "QueryBuilder": + self._table = name + return self + def limit(self, limit: int) -> "QueryBuilder": self._limit = limit return self 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 44822a51..f617fa88 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/query/grammars/BaseGrammar.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/query/grammars/BaseGrammar.py @@ -826,7 +826,7 @@ def process_columns(self, separator="", action="select", qmark=False): self.add_binding(*column.builder._bindings) else: builder_sql = column.builder.to_sql() - sql += f"({builder_sql}) AS {column.alias}, " + sql += f"({builder_sql}) " + self.subquery_alias_string().format(alias=column.alias) + ", " continue sql += self._table_column_string(column, alias=alias, separator=separator) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py index 61c6a3c9..8248d287 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py @@ -460,8 +460,7 @@ def get_with_count_query(self, builder, callback): if not builder._columns: builder = builder.select("*") - return_query = builder.add_select( - f"{query.get_table_name()}_count", + return_query = builder.select_sub( lambda q: ( q.count("*") .where_column( @@ -477,6 +476,7 @@ def get_with_count_query(self, builder, callback): ), ) ), + f"{query.get_table_name()}_count", ) return return_query diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py index ed9ace29..54313efe 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py @@ -178,8 +178,7 @@ def get_with_count_query(self, current_builder, callback): if not current_builder._columns: current_builder.select("*") - return_query = current_builder.add_select( - f"{self.attribute}_count", + return_query = current_builder.select_sub( lambda q: ( q.count("*") .join( @@ -201,6 +200,7 @@ def get_with_count_query(self, current_builder, callback): ), ) ), + f"{self.attribute}_count", ) return return_query diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py index 2cb07761..5279dc89 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py @@ -204,8 +204,7 @@ def get_with_count_query(self, current_builder, callback): if not current_builder._columns: current_builder.select("*") - return_query = current_builder.add_select( - f"{self.attribute}_count", + return_query = current_builder.select_sub( lambda q: ( q.count("*") .join( @@ -227,6 +226,7 @@ def get_with_count_query(self, current_builder, callback): ), ) ), + f"{self.attribute}_count", ) return return_query diff --git a/fastapi_startkit/tests/masoniteorm/query/grammars/test_select_sub_grammar.py b/fastapi_startkit/tests/masoniteorm/query/grammars/test_select_sub_grammar.py new file mode 100644 index 00000000..4c4edcd7 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/query/grammars/test_select_sub_grammar.py @@ -0,0 +1,187 @@ +import unittest + +from fastapi_startkit.masoniteorm.models.builder import QueryBuilder +from fastapi_startkit.masoniteorm.query.grammars.SQLiteGrammar import SQLiteGrammar +from fastapi_startkit.masoniteorm.query.grammars.MySQLGrammar import MySQLGrammar +from fastapi_startkit.masoniteorm.query.grammars.PostgresGrammar import PostgresGrammar +from fastapi_startkit.masoniteorm.query.grammars.MSSQLGrammar import MSSQLGrammar + +GRAMMARS = { + "sqlite": SQLiteGrammar, + "mysql": MySQLGrammar, + "postgres": PostgresGrammar, + "mssql": MSSQLGrammar, +} + +EXPECTED = { + "sqlite": { + "select_sub": 'SELECT "posts"."id", (SELECT "categories"."name" FROM "categories" WHERE categories.id = posts.category_id LIMIT 1) AS cat_name FROM "posts"', + "variadic": 'SELECT "posts"."id", "posts"."title", "posts"."category_id" FROM "posts"', + "assoc_empty": 'SELECT "posts".*, (SELECT "categories"."name" FROM "categories" WHERE categories.id = posts.category_id LIMIT 1) AS cat FROM "posts"', + "assoc_after_select": 'SELECT "posts"."id", (SELECT "categories"."name" FROM "categories" WHERE categories.id = posts.category_id LIMIT 1) AS cat FROM "posts"', + "select_sub_qmark": 'SELECT "posts"."id", (SELECT "categories"."name" FROM "categories" WHERE "categories"."active" = ? LIMIT 1) AS cat FROM "posts" WHERE "posts"."status" = ?', + }, + "mysql": { + "select_sub": "SELECT `posts`.`id`, (SELECT `categories`.`name` FROM `categories` WHERE categories.id = posts.category_id LIMIT 1) AS cat_name FROM `posts`", + "variadic": "SELECT `posts`.`id`, `posts`.`title`, `posts`.`category_id` FROM `posts`", + "assoc_empty": "SELECT `posts`.*, (SELECT `categories`.`name` FROM `categories` WHERE categories.id = posts.category_id LIMIT 1) AS cat FROM `posts`", + "assoc_after_select": "SELECT `posts`.`id`, (SELECT `categories`.`name` FROM `categories` WHERE categories.id = posts.category_id LIMIT 1) AS cat FROM `posts`", + "select_sub_qmark": "SELECT `posts`.`id`, (SELECT `categories`.`name` FROM `categories` WHERE `categories`.`active` = ? LIMIT 1) AS cat FROM `posts` WHERE `posts`.`status` = ?", + }, + "postgres": { + "select_sub": 'SELECT "posts"."id", (SELECT "categories"."name" FROM "categories" WHERE categories.id = posts.category_id LIMIT 1) AS cat_name FROM "posts"', + "variadic": 'SELECT "posts"."id", "posts"."title", "posts"."category_id" FROM "posts"', + "assoc_empty": 'SELECT "posts".*, (SELECT "categories"."name" FROM "categories" WHERE categories.id = posts.category_id LIMIT 1) AS cat FROM "posts"', + "assoc_after_select": 'SELECT "posts"."id", (SELECT "categories"."name" FROM "categories" WHERE categories.id = posts.category_id LIMIT 1) AS cat FROM "posts"', + "select_sub_qmark": 'SELECT "posts"."id", (SELECT "categories"."name" FROM "categories" WHERE "categories"."active" = ? LIMIT 1) AS cat FROM "posts" WHERE "posts"."status" = ?', + }, + "mssql": { + "select_sub": "SELECT [posts].[id], (SELECT TOP 1 [categories].[name] FROM [categories] WHERE categories.id = posts.category_id) AS cat_name FROM [posts]", + "variadic": "SELECT [posts].[id], [posts].[title], [posts].[category_id] FROM [posts]", + "assoc_empty": "SELECT [posts].*, (SELECT TOP 1 [categories].[name] FROM [categories] WHERE categories.id = posts.category_id) AS cat FROM [posts]", + "assoc_after_select": "SELECT [posts].[id], (SELECT TOP 1 [categories].[name] FROM [categories] WHERE categories.id = posts.category_id) AS cat FROM [posts]", + "select_sub_qmark": "SELECT [posts].[id], (SELECT TOP 1 [categories].[name] FROM [categories] WHERE [categories].[active] = ?) AS cat FROM [posts] WHERE [posts].[status] = ?", + }, +} + + +def qb(grammar, table): + q = QueryBuilder(connection=None, grammar=grammar, processor=None) + q._table = table + return q + + +def category_subquery(grammar): + return qb(grammar, "categories").select("name").where_column("categories.id", "posts.category_id").limit(1) + + +class TestSelectSubGrammar(unittest.TestCase): + """Grammar-level parity for select_sub (subquery-as-column) across all dialects.""" + + def test_select_sub_builder(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + sql = qb(grammar, "posts").select("id").select_sub(category_subquery(grammar), "cat_name").to_sql() + self.assertEqual(sql, EXPECTED[name]["select_sub"]) + + def test_select_sub_callable(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + sql = ( + qb(grammar, "posts") + .select("id") + .select_sub( + lambda q: ( + q.table("categories") + .select("name") + .where_column("categories.id", "posts.category_id") + .limit(1) + ), + "cat_name", + ) + .to_sql() + ) + self.assertEqual(sql, EXPECTED[name]["select_sub"]) + + def test_select_sub_rejects_non_builder(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + with self.assertRaises(TypeError): + qb(grammar, "posts").select_sub("not-a-builder", "cat") + + def test_select_sub_qmark_binding_order(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + sub = qb(grammar, "categories").select("name").where("categories.active", 1).limit(1) + q = qb(grammar, "posts").select("id").select_sub(sub, "cat").where("posts.status", "active") + self.assertEqual(q.to_qmark(), EXPECTED[name]["select_sub_qmark"]) + # SELECT-clause subquery binding precedes the WHERE binding. + self.assertEqual(list(q.get_bindings()), [1, "active"]) + + +class TestAddSelectGrammar(unittest.TestCase): + """add_select() is Laravel's variadic column-adder, not a subquery method.""" + + def test_add_select_variadic_strings(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + sql = qb(grammar, "posts").select("id").add_select("title", "category_id").to_sql() + self.assertEqual(sql, EXPECTED[name]["variadic"]) + + def test_add_select_single_list(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + sql = qb(grammar, "posts").select("id").add_select(["title", "category_id"]).to_sql() + self.assertEqual(sql, EXPECTED[name]["variadic"]) + + def test_add_select_dedup_guard(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + # 'title' already selected -> not duplicated + sql = qb(grammar, "posts").select("id", "title").add_select("title", "category_id").to_sql() + self.assertEqual(sql, EXPECTED[name]["variadic"]) + + def test_add_select_assoc_seeds_table_star_when_empty(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + sql = qb(grammar, "posts").add_select({"cat": category_subquery(grammar)}).to_sql() + self.assertEqual(sql, EXPECTED[name]["assoc_empty"]) + + def test_add_select_assoc_keeps_existing_columns(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + sql = qb(grammar, "posts").select("id").add_select({"cat": category_subquery(grammar)}).to_sql() + self.assertEqual(sql, EXPECTED[name]["assoc_after_select"]) + + def test_add_select_assoc_callable(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + sql = ( + qb(grammar, "posts") + .add_select( + { + "cat": lambda q: ( + q.table("categories") + .select("name") + .where_column("categories.id", "posts.category_id") + .limit(1) + ) + } + ) + .to_sql() + ) + self.assertEqual(sql, EXPECTED[name]["assoc_empty"]) + + def test_add_select_string_value_under_string_key_is_plain_column(self): + # Laravel: only string-key + queryable-value delegates; a string value stays a column. + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + sql = qb(grammar, "posts").select("id").add_select({"ignored_key": "title"}).to_sql() + plain = qb(grammar, "posts").select("id").add_select("title").to_sql() + self.assertEqual(sql, plain) + + def test_add_select_list_mixed_columns_and_subquery(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + sql = qb(grammar, "posts").add_select(["id", {"cat": category_subquery(grammar)}]).to_sql() + self.assertIn("AS cat", sql) + # both the plain column and the subquery column are present + self.assertIn("id", sql) + + def test_add_select_bare_subquery_without_alias_raises(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + with self.assertRaises(TypeError): + qb(grammar, "posts").add_select(qb(grammar, "categories").select("name")) + + def test_add_select_assoc_qmark_binding_order(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + sub = qb(grammar, "categories").select("name").where("categories.active", 1).limit(1) + q = qb(grammar, "posts").select("id").add_select({"cat": sub}).where("posts.status", "active") + self.assertEqual(q.to_qmark(), EXPECTED[name]["select_sub_qmark"]) + self.assertEqual(list(q.get_bindings()), [1, "active"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_select_sub.py b/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_select_sub.py new file mode 100644 index 00000000..90b4c88d --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_select_sub.py @@ -0,0 +1,161 @@ +from fastapi_startkit.masoniteorm import Model + +from ..fixtures.db import DB +from ..test_case import TestCase + + +class Category(Model): + __table__ = "categories" + __timestamps__ = None + id: int + name: str + + +class Post(Model): + __table__ = "posts" + __timestamps__ = None + id: int + title: str + category_id: int + + +class Account(Model): + __table__ = "accounts" + __timestamps__ = None + id: int + name: str + + +class Login(Model): + __table__ = "logins" + __timestamps__ = None + id: int + user_id: int + created_at: str + + +def category_name_subquery(): + return Category.query().select("name").where_column("categories.id", "posts.category_id").limit(1) + + +class TestSqliteSelectSub(TestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + conn = DB.connection("default") + await conn.execute("CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT)") + await conn.execute("CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT, category_id INTEGER)") + await conn.execute("INSERT INTO categories (id, name) VALUES (1, 'Zebra'), (2, 'Apple'), (3, 'Mango')") + await conn.execute( + "INSERT INTO posts (id, title, category_id) VALUES (1, 'z', 1), (2, 'a', 2), (3, 'm', 3), (4, 'a2', 2)" + ) + await conn.execute("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT)") + await conn.execute("CREATE TABLE logins (id INTEGER PRIMARY KEY, user_id INTEGER, created_at TEXT)") + await conn.execute("INSERT INTO accounts (id, name) VALUES (1, 'Alice'), (2, 'Bob')") + await conn.execute( + "INSERT INTO logins (id, user_id, created_at) VALUES " + "(1, 1, '2024-01-01'), (2, 1, '2024-03-01'), (3, 2, '2024-02-01')" + ) + + async def test_select_sub_with_builder(self): + posts = await ( + Post.query() + .select("id", "title") + .select_sub(category_name_subquery(), "category_name") + .order_by("id") + .get() + ) + pairs = [(p.id, p.serialize()["category_name"]) for p in posts] + self.assertEqual(pairs, [(1, "Zebra"), (2, "Apple"), (3, "Mango"), (4, "Apple")]) + + async def test_select_sub_with_callable(self): + posts = await ( + Post.query() + .select("id") + .select_sub( + lambda q: ( + q.table("categories").select("name").where_column("categories.id", "posts.category_id").limit(1) + ), + "category_name", + ) + .order_by("id") + .get() + ) + self.assertEqual([p.serialize()["category_name"] for p in posts], ["Zebra", "Apple", "Mango", "Apple"]) + + async def test_add_select_variadic_columns(self): + posts = await Post.query().select("id").add_select("title", "category_id").order_by("id").get() + self.assertEqual( + [(p.id, p.title, p.category_id) for p in posts], [(1, "z", 1), (2, "a", 2), (3, "m", 3), (4, "a2", 2)] + ) + + async def test_add_select_associative_subquery_acceptance_case(self): + # Mirrors Laravel: addSelect(['last_login_at' => Logins::select('created_at') + # ->whereColumn('user_id','users.id')->latest()->take(1)]) — take(1) => limit(1). + accounts = ( + await Account.query() + .add_select( + { + "last_login_at": Login.query() + .select("created_at") + .where_column("user_id", "accounts.id") + .latest() + .limit(1) + } + ) + .order_by("id") + .get() + ) + result = {a.name: a.serialize()["last_login_at"] for a in accounts} + self.assertEqual(result, {"Alice": "2024-03-01", "Bob": "2024-02-01"}) + + async def test_add_select_associative_seeds_table_star(self): + # No prior select(): base columns must still be present (seeded via {table}.*). + accounts = ( + await Account.query() + .add_select( + { + "last_login_at": Login.query() + .select("created_at") + .where_column("user_id", "accounts.id") + .latest() + .limit(1) + } + ) + .order_by("id") + .get() + ) + self.assertEqual([a.name for a in accounts], ["Alice", "Bob"]) + + async def test_table_setter_overrides_target_table(self): + rows = await Post.query().table("categories").select("name").order_by("id").get() + self.assertEqual([r.serialize()["name"] for r in rows], ["Zebra", "Apple", "Mango"]) + + async def test_select_sub_with_join_shaped_callable_compiles_and_runs(self): + # The shape the migrated *Through/BelongsToMany with-count callers now use: + # select_sub(callable that sets table + joins + where_column, alias). This + # asserts the real correlated subquery-as-column compiles (JOIN, correlated + # column, alias all present) and executes. The relationship methods' own + # end-to-end with-count is fixed separately — it needs a synchronous COUNT + # subquery instead of the async count() coroutine (see #886). + query = ( + Post.query() + .select("posts.id") + .select_sub( + lambda q: ( + q.table("categories") + .select("name") + .join("posts", "posts.category_id", "=", "categories.id") + .where_column("categories.id", "posts.category_id") + .limit(1) + ), + "joined_name", + ) + ) + sql = query.to_sql() + self.assertIn("JOIN", sql) + self.assertIn("categories.id = posts.category_id", sql) + self.assertIn("AS joined_name", sql) + + rows = await query.order_by("posts.id").get() + self.assertEqual(len(rows), 4) + self.assertTrue(all("joined_name" in r.serialize() for r in rows)) From da46c3114fdb452e66dfc3fdf1068a20b982a3a5 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sun, 12 Jul 2026 10:22:51 -0700 Subject: [PATCH 2/3] test(orm): build select_sub fixtures via Schema + ORM insert Replace raw conn.execute CREATE TABLE / INSERT with the framework's Schema builder and Model.insert() for the categories/posts and accounts/logins fixtures. Same data and same assertions (incl. the Laravel addSelect last_login_at acceptance case). --- .../sqlite/builder/test_sqlite_select_sub.py | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_select_sub.py b/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_select_sub.py index 90b4c88d..a0b9ee90 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_select_sub.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_select_sub.py @@ -1,6 +1,5 @@ from fastapi_startkit.masoniteorm import Model -from ..fixtures.db import DB from ..test_case import TestCase @@ -41,19 +40,39 @@ def category_name_subquery(): class TestSqliteSelectSub(TestCase): async def asyncSetUp(self): await super().asyncSetUp() - conn = DB.connection("default") - await conn.execute("CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT)") - await conn.execute("CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT, category_id INTEGER)") - await conn.execute("INSERT INTO categories (id, name) VALUES (1, 'Zebra'), (2, 'Apple'), (3, 'Mango')") - await conn.execute( - "INSERT INTO posts (id, title, category_id) VALUES (1, 'z', 1), (2, 'a', 2), (3, 'm', 3), (4, 'a2', 2)" + async with await self.schema.create("categories") as table: + table.id() + table.string("name") + async with await self.schema.create("posts") as table: + table.id() + table.string("title") + table.integer("category_id") + async with await self.schema.create("accounts") as table: + table.id() + table.string("name") + async with await self.schema.create("logins") as table: + table.id() + table.integer("user_id") + table.string("created_at") + + await Category.query().insert( + [{"id": 1, "name": "Zebra"}, {"id": 2, "name": "Apple"}, {"id": 3, "name": "Mango"}] ) - await conn.execute("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT)") - await conn.execute("CREATE TABLE logins (id INTEGER PRIMARY KEY, user_id INTEGER, created_at TEXT)") - await conn.execute("INSERT INTO accounts (id, name) VALUES (1, 'Alice'), (2, 'Bob')") - await conn.execute( - "INSERT INTO logins (id, user_id, created_at) VALUES " - "(1, 1, '2024-01-01'), (2, 1, '2024-03-01'), (3, 2, '2024-02-01')" + await Post.query().insert( + [ + {"id": 1, "title": "z", "category_id": 1}, + {"id": 2, "title": "a", "category_id": 2}, + {"id": 3, "title": "m", "category_id": 3}, + {"id": 4, "title": "a2", "category_id": 2}, + ] + ) + await Account.query().insert([{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]) + await Login.query().insert( + [ + {"id": 1, "user_id": 1, "created_at": "2024-01-01"}, + {"id": 2, "user_id": 1, "created_at": "2024-03-01"}, + {"id": 3, "user_id": 2, "created_at": "2024-02-01"}, + ] ) async def test_select_sub_with_builder(self): From 1384ecbf8bcb207665027954a0f0d4bc62d42926 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sun, 12 Jul 2026 10:19:52 -0700 Subject: [PATCH 3/3] feat(orm): with_count() correlated relationship counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up relationship count subqueries end-to-end: - QueryBuilder.select_count(column='*', alias=None): synchronous COUNT aggregate builder (the query-building counterpart to the async count()), used to compose correlated COUNT subqueries. - QueryBuilder.with_count(relation, callback=None) + Model.with_count(): adds a correlated '(SELECT COUNT(*) ...) AS {relation}_count' column. - BaseRelationship.get_with_count_query: real default for direct relations (HasMany/HasOne/BelongsTo) — was NotImplementedError. - BelongsToMany/HasOneThrough/HasManyThrough with-count queries now build the COUNT subquery synchronously via select_count() instead of chaining on the async count() coroutine (the #886 blocker). - Fix BelongsToMany.__init__ resolver lambda (took a spurious arg, breaking get_builder()) so its correlated queries resolve. Real sqlite end-to-end tests assert actual counts (0-related returns 0, callback-constrained counts) for HasMany, HasManyThrough, HasOneThrough and BelongsToMany; fixtures use ORM insert APIs. Relabel three factory/collection tests whose names misleadingly contained 'with_count'. --- .../masoniteorm/models/builder.py | 18 +++++ .../masoniteorm/models/model.py | 4 ++ .../relationships/BaseRelationship.py | 22 +++++- .../relationships/BelongsToMany.py | 4 +- .../relationships/HasManyThrough.py | 2 +- .../relationships/HasOneThrough.py | 2 +- .../masoniteorm/collection/test_collection.py | 2 +- .../sqlite/builder/test_sqlite_with_count.py | 71 +++++++++++++++++++ .../tests/masoniteorm/sqlite/test_factory.py | 4 +- 9 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_with_count.py diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py index 432a144d..61ce38c2 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py @@ -302,6 +302,15 @@ async def aggregate(self, function: str, column: str): async def count(self, column: str = "*"): return await self.aggregate("COUNT", column) + def select_count(self, column: str = "*", alias: str | None = None) -> "QueryBuilder": + """Add a ``COUNT`` aggregate to the selection and return the builder. + + The synchronous, query-building counterpart to :meth:`count` (which + executes). Used to compose correlated ``COUNT`` subqueries. + """ + self._aggregates += (AggregateExpression("COUNT", column, alias if alias is not None else False),) + return self + async def exists(self) -> bool: """Return True if any record matches the current query, False otherwise.""" return (await self.count() or 0) > 0 @@ -559,6 +568,15 @@ def or_where_has(self, relation: str, callback=None) -> "QueryBuilder": related.query_has(self, method="or_where_exists") return self + def with_count(self, relation: str, callback=None) -> "QueryBuilder": + """Add a correlated ``{relation}_count`` column to the selection. + + The optional ``callback`` receives the count subquery builder to add + further constraints (e.g. ``lambda q: q.where(...)``). + """ + related = getattr(self._model.__class__, relation) + return related.get_with_count_query(self, callback) + @classmethod def clean_bindings(cls, values): if isinstance(values, dict): diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py index 79c3614c..1402da2a 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py @@ -91,6 +91,10 @@ def get_related(self, key: str): def with_(cls, *eagers) -> "QueryBuilder": return cls.query().with_(*eagers) + @classmethod + def with_count(cls, relation: str, callback=None) -> "QueryBuilder": + return cls.query().with_count(relation, callback) + @classmethod def where(cls, column, *args) -> "QueryBuilder": return cls.query().where(column, *args) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py index e6123e2b..f9b2f8f1 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py @@ -79,9 +79,25 @@ def joins(self, builder, clause=None): ) def get_with_count_query(self, builder, callback): - """Adds a clause to the query to get the record count of the relationship""" - klass = self.__class__.__name__ - raise NotImplementedError(f"{klass} relationship does not implement the 'get_with_count_query' method") + """Add a correlated ``{attribute}_count`` column to ``builder``. + + Default implementation for direct relationships (HasMany/HasOne/ + BelongsTo): counts related rows whose foreign key matches the owner's + local key. Through/pivot relationships override this. + """ + related = self.get_builder() + related_table = related.get_table_name() + + subquery = related.select_count("*").where_column( + f"{related_table}.{self.foreign_key}", + f"{builder.get_table_name()}.{self.local_key}", + ) + if callback: + callback(subquery) + + if not builder._columns: + builder.select(f"{builder.get_table_name()}.*") + return builder.select_sub(subquery, f"{self.attribute}_count") def attach(self, current_model, related_record): """Link a related model to the current model""" diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py index 8248d287..ba80cce6 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py @@ -24,7 +24,7 @@ def __init__( with_fields=[], ): if isinstance(fn, str): - self.fn = self.fn = lambda x: registry.Registry.resolve(fn) + self.fn = lambda: registry.Registry.resolve(fn) self.local_key = local_foreign_key self.foreign_key = other_foreign_key @@ -462,7 +462,7 @@ def get_with_count_query(self, builder, callback): return_query = builder.select_sub( lambda q: ( - q.count("*") + q.select_count("*") .where_column( f"{builder.get_table_name()}.{self.local_owner_key}", f"{self._table}.{self.local_key}", diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py index 54313efe..d562592f 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasManyThrough.py @@ -180,7 +180,7 @@ def get_with_count_query(self, current_builder, callback): return_query = current_builder.select_sub( lambda q: ( - q.count("*") + q.select_count("*") .join( f"{intermediate_table}", f"{intermediate_table}.{self.foreign_key}", diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py index 5279dc89..f07ca0a6 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py @@ -206,7 +206,7 @@ def get_with_count_query(self, current_builder, callback): return_query = current_builder.select_sub( lambda q: ( - q.count("*") + q.select_count("*") .join( f"{int_table}", f"{int_table}.{self.foreign_key}", diff --git a/fastapi_startkit/tests/masoniteorm/collection/test_collection.py b/fastapi_startkit/tests/masoniteorm/collection/test_collection.py index 873b4837..43f6e1e9 100644 --- a/fastapi_startkit/tests/masoniteorm/collection/test_collection.py +++ b/fastapi_startkit/tests/masoniteorm/collection/test_collection.py @@ -721,7 +721,7 @@ def test_random(self): item = collection.random() self.assertEqual(item, 3) - def test_random_with_count(self): + def test_random_returns_subset(self): collection = Collection([1, 2, 3, 4]) items = collection.random(2) self.assertEqual(items.count(), 2) diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_with_count.py b/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_with_count.py new file mode 100644 index 00000000..31becdc4 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_with_count.py @@ -0,0 +1,71 @@ +from ...fixtures.model import IncomingShipment, Product, Store, User +from ..fixtures.db import DB +from ..test_case import TestCase + + +class TestSqliteWithCount(TestCase): + """End-to-end with_count() across every relationship type on seeded data. + + Seeder gives: user Joe (id 1) with 1 article + 1 logo (through the article); + user Jane (id 2) with none. This suite adds store/product pivot rows for the + BelongsToMany case and reuses the seeded ports/shipments for HasOneThrough. + """ + + async def asyncSetUp(self): + await super().asyncSetUp() + await Store.query().insert([{"id": 1, "name": "A"}, {"id": 2, "name": "B"}]) + await Product.query().insert([{"id": 2, "name": "Gadget"}, {"id": 3, "name": "Gizmo"}]) + # store 1 -> products 1 & 2; store 2 -> none + await ( + DB.connection("default") + .query() + .table("product_store") + .insert([{"id": 1, "store_id": 1, "product_id": 1}, {"id": 2, "store_id": 1, "product_id": 2}]) + ) + + async def test_has_many_count(self): + users = await User.query().with_count("articles").order_by("id").get() + self.assertEqual({u.id: u.serialize()["articles_count"] for u in users}, {1: 1, 2: 0}) + + async def test_has_many_count_seeds_base_columns(self): + # with_count on an unselected query must still return the owner's columns. + user = (await User.query().with_count("articles").where("id", 1).get()).first() + self.assertEqual(user.name, "Joe") + self.assertEqual(user.serialize()["articles_count"], 1) + + async def test_has_many_count_with_callback_constraint(self): + users = ( + await User.query().with_count("articles", lambda q: q.where("title", "Masonite ORM")).order_by("id").get() + ) + self.assertEqual({u.id: u.serialize()["articles_count"] for u in users}, {1: 1, 2: 0}) + + async def test_has_many_count_with_callback_excluding_all(self): + users = ( + await User.query().with_count("articles", lambda q: q.where("title", "does-not-exist")).order_by("id").get() + ) + self.assertEqual({u.id: u.serialize()["articles_count"] for u in users}, {1: 0, 2: 0}) + + async def test_has_many_through_count(self): + users = await User.query().with_count("logos").order_by("id").get() + self.assertEqual({u.id: u.serialize()["logos_count"] for u in users}, {1: 1, 2: 0}) + + async def test_belongs_to_many_count(self): + stores = await Store.query().with_count("products").order_by("id").get() + self.assertEqual({s.id: s.serialize()["products_count"] for s in stores}, {1: 2, 2: 0}) + + async def test_has_one_through_count(self): + # Every seeded shipment routes through a port that maps to exactly one country. + ships = await IncomingShipment.query().with_count("from_country").get() + counts = [s.serialize()["from_country_count"] for s in ships] + self.assertEqual(len(ships), 7) + self.assertTrue(all(c == 1 for c in counts)) + + async def test_with_count_model_classmethod(self): + users = await User.with_count("articles").order_by("id").get() + self.assertEqual({u.id: u.serialize()["articles_count"] for u in users}, {1: 1, 2: 0}) + + async def test_with_count_sql_is_a_correlated_subquery(self): + sql = User.query().with_count("articles").to_sql() + self.assertIn("SELECT COUNT(*) FROM", sql) + self.assertIn("articles.user_id = users.id", sql) + self.assertIn("AS articles_count", sql) diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/test_factory.py b/fastapi_startkit/tests/masoniteorm/sqlite/test_factory.py index fad8088f..3d0dc432 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/test_factory.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/test_factory.py @@ -312,7 +312,7 @@ async def test_has_creates_child_with_fk(self): self.assertIsNotNone(article) self.assertEqual(article.get_attribute("user_id"), user_id) - async def test_has_with_count_creates_multiple_children(self): + async def test_has_creates_multiple_children(self): user = await UserFactory.new().has(ArticleFactory.new().count(3)).create() user_id = user.get_attribute("id") articles = await Articles.where("user_id", user_id).get() @@ -330,7 +330,7 @@ async def test_for_creates_parent_and_injects_fk(self): user = await User.find(user_id) self.assertIsNotNone(user) - async def test_for_with_count_injects_same_parent(self): + async def test_for_with_multiple_children_injects_same_parent(self): # All profiles share the same parent User profiles = await ProfileFactory.new().count(2).for_(UserFactory.new()).create() user_ids = {p.get_attribute("user_id") for p in profiles}