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 Expand Up @@ -245,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
Expand Down Expand Up @@ -502,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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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 @@ -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"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -460,10 +460,9 @@ 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("*")
q.select_count("*")
.where_column(
f"{builder.get_table_name()}.{self.local_owner_key}",
f"{self._table}.{self.local_key}",
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,10 +178,9 @@ 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("*")
q.select_count("*")
.join(
f"{intermediate_table}",
f"{intermediate_table}.{self.foreign_key}",
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,10 +204,9 @@ 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("*")
q.select_count("*")
.join(
f"{int_table}",
f"{int_table}.{self.foreign_key}",
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
Expand Up @@ -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)
Expand Down
Loading
Loading