Skip to content
Open
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 @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -477,6 +476,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
Expand Up @@ -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(
Expand All @@ -201,6 +200,7 @@ def get_with_count_query(self, current_builder, callback):
),
)
),
f"{self.attribute}_count",
)

return return_query
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -227,6 +226,7 @@ def get_with_count_query(self, current_builder, callback):
),
)
),
f"{self.attribute}_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