From 9635e82134fff2687010f73f49676689538ffc5d Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 11 Jul 2026 23:29:01 -0700 Subject: [PATCH 1/2] feat(orm): order_by() accepts a correlated-subquery builder order_by() now accepts a QueryBuilder as its column argument and compiles '(subquery) ASC|DESC', mirroring the existing SubSelectExpression handling in where(). Subquery bindings are propagated in the correct position (after WHERE, before nothing that follows ORDER BY). String-based order_by() is unchanged. Covered by grammar-level to_sql()/to_qmark() assertions with binding-order checks across all four dialects (SQLite, MySQL, Postgres, MSSQL), plus a real sqlite-backed suite (categories not in id order) including the full order_by(subquery) -> paginate chain. --- .../masoniteorm/expressions/expressions.py | 12 ++- .../masoniteorm/models/builder.py | 5 +- .../masoniteorm/query/grammars/BaseGrammar.py | 21 ++++- .../test_order_by_subquery_grammar.py | 86 +++++++++++++++++++ .../builder/test_sqlite_order_by_subquery.py | 69 +++++++++++++++ 5 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 fastapi_startkit/tests/masoniteorm/query/grammars/test_order_by_subquery_grammar.py create mode 100644 fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_order_by_subquery.py diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/expressions/expressions.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/expressions/expressions.py index 4bef40c4..1977727b 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/expressions/expressions.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/expressions/expressions.py @@ -100,13 +100,17 @@ def __init__(self, column, raw=False): class OrderByExpression: """A helper class to manage select expressions.""" - def __init__(self, column, direction="ASC", raw=False, bindings=()): - self.column = column.strip() - + def __init__(self, column, direction="ASC", raw=False, bindings=(), builder=None): self.raw = raw - self.direction = direction self.bindings = bindings + self.builder = builder + + if builder is not None: + self.column = None + return + + self.column = column.strip() if raw is False: if self.column.endswith(" desc"): diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py index 5d818105..ba00b6e9 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py @@ -164,8 +164,11 @@ def offset(self, offset: int) -> "QueryBuilder": self._offset = offset return self - def order_by(self, column: str, direction: str = "asc") -> "QueryBuilder": + def order_by(self, column, direction: str = "asc") -> "QueryBuilder": direction = direction.upper() + if isinstance(column, QueryBuilder): + self._order_by += (OrderByExpression(None, direction, builder=column),) + return self for col in column.split(","): col = col.strip() self._order_by += (OrderByExpression(col, direction),) 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..2e39b151 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/query/grammars/BaseGrammar.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/query/grammars/BaseGrammar.py @@ -93,7 +93,7 @@ def _compile_select(self, qmark=False): limit=self.process_limit(), offset=self.process_offset(), aggregates=self.process_aggregates(), - order_by=self.process_order_by(), + order_by=self.process_order_by(qmark=qmark), group_by=self.process_group_by(), having=self.process_having(), lock=self.process_locks(), @@ -112,7 +112,7 @@ def _compile_select(self, qmark=False): limit=self.process_limit(), offset=self.process_offset(), aggregates=self.process_aggregates(), - order_by=self.process_order_by(), + order_by=self.process_order_by(qmark=qmark), group_by=self.process_group_by(), having=self.process_having(), lock=self.process_locks(), @@ -388,7 +388,7 @@ def process_aggregates(self): return sql - def process_order_by(self): + def process_order_by(self, qmark=False): """Compiles an order by for a query expression. Returns: @@ -408,6 +408,21 @@ def process_order_by(self): continue + if getattr(order_bys, "builder", None) is not None: + if len(order_crit): + order_crit += ", " + if qmark: + subquery_sql = order_bys.builder.to_qmark() + if order_bys.builder._bindings: + self.add_binding(*order_bys.builder._bindings) + else: + subquery_sql = order_bys.builder.to_sql() + column_string = self.subquery_string().format(query=subquery_sql) + order_crit += self.order_by_format().format( + column=column_string, direction=order_bys.direction.upper() + ) + continue + if len(order_crit): order_crit += ", " column = order_bys.column diff --git a/fastapi_startkit/tests/masoniteorm/query/grammars/test_order_by_subquery_grammar.py b/fastapi_startkit/tests/masoniteorm/query/grammars/test_order_by_subquery_grammar.py new file mode 100644 index 00000000..679e462f --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/query/grammars/test_order_by_subquery_grammar.py @@ -0,0 +1,86 @@ +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": { + "sql": 'SELECT * FROM "posts" ORDER BY (SELECT "categories"."name" FROM "categories" WHERE categories.id = posts.category_id) ASC', + "qmark": 'SELECT * FROM "posts" WHERE "posts"."status" = ? ORDER BY (SELECT "categories"."name" FROM "categories" WHERE categories.id = posts.category_id AND "categories"."active" = ?) ASC', + }, + "mysql": { + "sql": "SELECT * FROM `posts` ORDER BY (SELECT `categories`.`name` FROM `categories` WHERE categories.id = posts.category_id) ASC", + "qmark": "SELECT * FROM `posts` WHERE `posts`.`status` = ? ORDER BY (SELECT `categories`.`name` FROM `categories` WHERE categories.id = posts.category_id AND `categories`.`active` = ?) ASC", + }, + "postgres": { + "sql": 'SELECT * FROM "posts" ORDER BY (SELECT "categories"."name" FROM "categories" WHERE categories.id = posts.category_id) ASC', + "qmark": 'SELECT * FROM "posts" WHERE "posts"."status" = ? ORDER BY (SELECT "categories"."name" FROM "categories" WHERE categories.id = posts.category_id AND "categories"."active" = ?) ASC', + }, + "mssql": { + "sql": "SELECT * FROM [posts] ORDER BY (SELECT [categories].[name] FROM [categories] WHERE categories.id = posts.category_id) ASC", + "qmark": "SELECT * FROM [posts] WHERE [posts].[status] = ? ORDER BY (SELECT [categories].[name] FROM [categories] WHERE categories.id = posts.category_id AND [categories].[active] = ?) ASC", + }, +} + + +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") + + +class TestOrderBySubqueryGrammar(unittest.TestCase): + """Grammar-level parity for order_by() with a correlated-subquery builder.""" + + def test_order_by_builder(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + sql = qb(grammar, "posts").order_by(category_subquery(grammar)).to_sql() + self.assertEqual(sql, EXPECTED[name]["sql"]) + + def test_order_by_builder_desc(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + sql = qb(grammar, "posts").order_by(category_subquery(grammar), "desc").to_sql() + self.assertTrue(sql.endswith(") DESC")) + + def test_order_by_builder_qmark_and_binding_order(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + q = ( + qb(grammar, "posts") + .where("posts.status", "active") + .order_by( + qb(grammar, "categories") + .select("name") + .where_column("categories.id", "posts.category_id") + .where("categories.active", 1) + ) + ) + self.assertEqual(q.to_qmark(), EXPECTED[name]["qmark"]) + # WHERE binding precedes the ORDER BY subquery binding. + self.assertEqual(list(q.get_bindings()), ["active", 1]) + + def test_string_order_by_unchanged(self): + for name, grammar in GRAMMARS.items(): + with self.subTest(grammar=name): + sql = qb(grammar, "posts").order_by("id", "desc").to_sql() + self.assertTrue(sql.endswith('"id" DESC') or sql.endswith("`id` DESC") or sql.endswith("[id] DESC")) + + +if __name__ == "__main__": + unittest.main() diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_order_by_subquery.py b/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_order_by_subquery.py new file mode 100644 index 00000000..d8174a09 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_order_by_subquery.py @@ -0,0 +1,69 @@ +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 + + +def category_name_subquery(): + """Correlated subquery selecting the parent category name for each post.""" + return Category.query().select("name").where_column("categories.id", "posts.category_id") + + +class TestSqliteOrderBySubquery(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)") + # Category ids deliberately NOT in alphabetical order, so ordering by the + # correlated name subquery differs from ordering by id/category_id. + 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 def test_order_by_correlated_subquery(self): + posts = await Post.query().order_by(category_name_subquery()).get() + # Apple (ids 2,4) < Mango (3) < Zebra (1) + self.assertEqual([p.title for p in posts], ["a", "a2", "m", "z"]) + + async def test_order_by_correlated_subquery_desc(self): + posts = await Post.query().order_by(category_name_subquery(), "desc").get() + self.assertEqual([p.title for p in posts], ["z", "m", "a", "a2"]) + + async def test_full_chain_order_by_subquery_then_paginate(self): + page = await Post.query().order_by(category_name_subquery()).paginate(per_page=2, page=1) + meta = page.serialize()["meta"] + self.assertEqual(meta["total"], 4) + self.assertEqual(meta["last_page"], 2) + self.assertEqual(meta["current_page"], 1) + self.assertEqual(meta["count"], 2) + self.assertEqual(meta["next_page"], 2) + self.assertIsNone(meta["previous_page"]) + self.assertEqual([d["title"] for d in page.serialize()["data"]], ["a", "a2"]) + + page2 = await Post.query().order_by(category_name_subquery()).paginate(per_page=2, page=2) + meta2 = page2.serialize()["meta"] + self.assertEqual(meta2["current_page"], 2) + self.assertEqual(meta2["previous_page"], 1) + self.assertIsNone(meta2["next_page"]) + self.assertEqual([d["title"] for d in page2.serialize()["data"]], ["m", "z"]) + + async def test_string_order_by_still_works(self): + posts = await Post.query().order_by("id", "desc").get() + self.assertEqual([p.id for p in posts], [4, 3, 2, 1]) From 3ffe9bf82e93f8268d56063d12dcdd1d552cb278 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sun, 12 Jul 2026 10:21:29 -0700 Subject: [PATCH 2/2] test(orm): build order_by-subquery fixtures via Schema + ORM insert Replace raw conn.execute CREATE TABLE / INSERT with the framework's Schema builder and Model.insert(). Same tables and data (categories 1=Zebra, 2=Apple, 3=Mango; posts 1-4), same assertions. --- .../builder/test_sqlite_order_by_subquery.py | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_order_by_subquery.py b/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_order_by_subquery.py index d8174a09..10dbfa19 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_order_by_subquery.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_order_by_subquery.py @@ -1,6 +1,5 @@ from fastapi_startkit.masoniteorm import Model -from ..fixtures.db import DB from ..test_case import TestCase @@ -27,14 +26,25 @@ def category_name_subquery(): class TestSqliteOrderBySubquery(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)") + 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") # Category ids deliberately NOT in alphabetical order, so ordering by the # correlated name subquery differs from ordering by id/category_id. - 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 Category.query().insert( + [{"id": 1, "name": "Zebra"}, {"id": 2, "name": "Apple"}, {"id": 3, "name": "Mango"}] + ) + 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}, + ] ) async def test_order_by_correlated_subquery(self):