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..10dbfa19 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/sqlite/builder/test_sqlite_order_by_subquery.py @@ -0,0 +1,79 @@ +from fastapi_startkit.masoniteorm import Model + +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() + 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 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): + 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])