Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
# returns it, which the parent renders as a parenthesised subgroup.
type WhereGroup[M: "Model"] = Callable[["QueryBuilder[M]"], "QueryBuilder[M]"]

# select_sub()/add_select() subquery source: a builder, or a callable that
# receives a fresh builder and returns the subquery.
type Subquery = "QueryBuilder[Any] | Callable[[QueryBuilder[Any]], QueryBuilder[Any]]"
type SelectEntry = "str | dict[str, str | Subquery]"


class QueryBuilder(EagerLoadMixin, SupportMixin, Generic[TModel]):
operators = [
Expand Down Expand Up @@ -129,6 +134,59 @@ def select(self, *args: "str | list[str]") -> "Self":
self._columns += (SelectExpression(column),)
return self

def select_sub(self, subquery: "Subquery", alias: str) -> "Self":
"""Add ``(subquery) AS alias`` to the selection.

``subquery`` is a builder, or a callable that receives a fresh table-less
builder and returns one.
"""
if not isinstance(subquery, QueryBuilder) and callable(subquery):
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.append(SubGroupExpression(subquery, alias))
return self

def add_select(self, *columns: "SelectEntry | list[SelectEntry]") -> "Self":
"""Append columns to the current selection without replacing it.

Plain string columns are appended once. A ``{alias: subquery}`` entry
adds a correlated column via :meth:`select_sub`, first selecting
``{table}.*`` when nothing is selected yet so the base columns survive.
A string value under a string key is still a plain column.
"""
entries: list[SelectEntry] = []
for column in columns:
if isinstance(column, list):
entries.extend(column)
else:
entries.append(column)

for entry in entries:
pairs: Iterable[tuple[str | None, str | Subquery]] = (
entry.items() if isinstance(entry, dict) else [(None, entry)]
)
for alias, value in pairs:
if isinstance(value, str):
if not self._is_selected(value):
self._columns.append(SelectExpression(value))
continue
if alias is None:
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)
return self

def _is_selected(self, column: str) -> bool:
return any(
isinstance(existing, SelectExpression) and existing.alias is None and existing.column == column
for existing in self._columns
)

def limit(self, limit: int) -> "Self":
self._limit = limit
return self
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -898,7 +898,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -456,8 +456,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(
Expand All @@ -473,6 +472,7 @@ def get_with_count_query(self, builder, callback):
),
)
),
f"{query.get_table_name()}_count",
)

return return_query
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading