Skip to content

Commit d1cf6ed

Browse files
Window relation connections on Tortoise, and apply their filter and order (v0.20.0).
Tortoise has no window expression, so it refused eager connections over a relation and every parent would have cost a query. It now wraps the SQL the query builder already produced, in its parameterized form so filter values stay bound rather than pasted into the statement, and the extension awaits an async backend instead of bailing. Sibling parents share the in-flight read as a task, since caching a coroutine would leave each of them to start a read of its own. A connection's own filter and order were silently ignored on every backend: the page is cut before the field's resolver runs, so they never reached the window, and totalCount counted rows the caller had excluded. They are now converted and applied before the window, and each backend orders by the row number so a group comes back in the order the window put it in. CI runs 3.12, 3.13, and 3.14, the versions the classifiers advertise. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3967c67 commit d1cf6ed

13 files changed

Lines changed: 866 additions & 68 deletions

File tree

.github/workflows/tests.yml

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,24 @@ permissions:
1111

1212
jobs:
1313
test:
14-
name: Test (Python 3.12)
14+
name: Test (Python ${{ matrix.python-version }})
1515
runs-on: ubuntu-latest
1616

17+
strategy:
18+
fail-fast: false
19+
matrix:
20+
# Every version here is advertised in the pyproject classifiers; keep
21+
# the two lists in step so a claim of support is always backed by a run.
22+
python-version: ["3.12", "3.13", "3.14"]
23+
1724
steps:
1825
- name: Check out repository
1926
uses: actions/checkout@v4
2027

2128
- name: Set up Python
2229
uses: actions/setup-python@v5
2330
with:
24-
python-version: "3.12"
31+
python-version: ${{ matrix.python-version }}
2532

2633
- name: Set up uv
2734
uses: astral-sh/setup-uv@v6
@@ -30,15 +37,15 @@ jobs:
3037

3138
- name: Install dependencies
3239
run: >-
33-
uv sync --group dev
40+
uv sync --group dev --python ${{ matrix.python-version }}
3441
--extra django --extra sqlalchemy --extra tortoise
3542
3643
- name: Run tests with coverage
37-
run: uv run pytest
44+
run: uv run --python ${{ matrix.python-version }} pytest
3845

3946
- name: Upload coverage to Codecov
4047
uses: codecov/codecov-action@v5
41-
if: always()
48+
if: always() && matrix.python-version == '3.12'
4249
with:
4350
files: coverage.xml
4451
fail_ci_if_error: false

API_REFERENCE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ A Relay connection over a model, split on the same question as fields: does your
196196

197197
`eager` has the library build the query, optionally narrowed by a `(query, info)` scope. The scope applies to `totalCount` and `aggregates` as well as `edges`, since those are computed from the query rather than the returned rows.
198198

199-
On an `@orm.type` an eager connection is served by the parent's relation, and every parent's page is taken in one query using `ROW_NUMBER() OVER (PARTITION BY ...)`, with a second grouped query for the per-parent `totalCount`. That needs a window function and a column on the related rows identifying the parent, so it is refused when the type is defined on Tortoise and for many-to-many relations, both of which point at `lazy`.
199+
On an `@orm.type` an eager connection is served by the parent's relation, and every parent's page is taken in one query using `ROW_NUMBER() OVER (PARTITION BY ...)`, with a second grouped query for the per-parent `totalCount`. All three backends support this; Tortoise, which has no window expression, wraps its own parameterized SQL to do the numbering. It does need a column on the related rows identifying the parent, so a many-to-many is refused when the type is defined and pointed at `lazy`.
200200

201201
`lazy` takes a resolver receiving `self` that returns the rows, and runs once per parent row.
202202

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,9 @@ class UserNode(relay.Node):
197197

198198
`totalCount` is each parent's own total, counted separately rather than read off the page — the page was truncated, so counting it would report the page size.
199199

200-
This needs a window function and a column on the related rows tying them to a parent, so it is refused when the type is defined if either is missing: on Tortoise, whose query builder has no window functions, and for a many-to-many, whose rows carry the parent key in the association table. Both point you at `orm.connection.lazy`, which is the honest spelling for one query per parent.
200+
This works on all three backends. Tortoise has no window expression of its own, so that backend wraps the query it built in SQL that does the numbering; the values you filtered on stay bound rather than pasted into the statement.
201+
202+
What it does need is a column on the related rows tying them to a parent, so a many-to-many is refused when the type is defined — those rows keep the parent key in the association table, leaving the window nothing to partition by. The error points you at `orm.connection.lazy`, which is the honest spelling for one query per parent.
201203

202204
Note that a connection on a plain `@strawberry.type` is a *root* connection wherever it appears — it queries the whole table, because nothing about it knows a parent exists. Put connections over relations on an `@orm.type`.
203205

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "strawberry-orm"
3-
version = "0.19.5"
3+
version = "0.20.0"
44
description = "Unified, backend-agnostic ORM abstraction for Strawberry GraphQL"
55
readme = "README.md"
66
license = "MIT"

src/strawberry_orm/backends/django.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -551,13 +551,20 @@ def batch_group_items(
551551
ordering = [F("pk")]
552552

553553
def _run():
554-
qs = query.annotate(
555-
_rn=Window(
556-
expression=RowNumber(),
557-
partition_by=partition,
558-
order_by=ordering,
554+
qs = (
555+
query.annotate(
556+
_rn=Window(
557+
expression=RowNumber(),
558+
partition_by=partition,
559+
order_by=ordering,
560+
)
559561
)
560-
).filter(_rn__lte=per_group_limit)
562+
.filter(_rn__lte=per_group_limit)
563+
# Ordered by the row number so each group comes back in the
564+
# order the window put it in; without it the rows arrive
565+
# however the database found them and the order is lost.
566+
.order_by("_rn")
567+
)
561568
rows = list(qs)
562569
items_by_key: dict[tuple, list[Any]] = defaultdict(list)
563570
for row in rows:

src/strawberry_orm/backends/sqlalchemy.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,10 @@ def batch_group_items(
476476

477477
subq = query.add_columns(rn).subquery()
478478
ranked_stmt = select(model).from_statement(
479-
select(subq).where(subq.c._rn <= per_group_limit)
479+
# Ordered by the row number so each group comes back in the order
480+
# the window put it in; without it the rows arrive however the
481+
# database found them and the requested order is lost.
482+
select(subq).where(subq.c._rn <= per_group_limit).order_by(subq.c._rn)
480483
)
481484

482485
session = self._get_session(info)

src/strawberry_orm/backends/tortoise.py

Lines changed: 182 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@
3030
requested_aggregates,
3131
)
3232

33+
#: Where the window puts each row's number within its group. Dropped again
34+
#: before the row is turned back into a model, which knows no such column.
35+
_ROW_NUMBER_ALIAS = "_orm_rn"
36+
37+
#: Where the grouped count puts each group's total.
38+
_COUNT_ALIAS = "_orm_count"
39+
3340
_TORTOISE_FIELD_MAP: dict[str, type] = {
3441
"IntField": int,
3542
"SmallIntField": int,
@@ -669,24 +676,45 @@ async def batch_group_items(
669676
per_group_limit: int,
670677
order_input: Any | None = None,
671678
) -> dict[tuple, list[Any]]:
672-
"""Per-group fallback since Tortoise has limited window function support."""
673-
groups_qs = await query.group_by(*group_key_fields).values(*group_key_fields)
674-
items_by_key: dict[tuple, list[Any]] = {}
679+
"""Every group's first rows in one windowed query.
680+
681+
Tortoise has no window expression, so the query it built is wrapped in
682+
SQL that numbers rows within each group and keeps the low numbers. The
683+
wrapping keeps the placeholders and values Tortoise produced, so filter
684+
values are still bound rather than pasted into the statement.
685+
"""
686+
order_clauses = (
687+
_build_tortoise_order_from_input(order_input) if order_input else []
688+
)
689+
sql, values = _windowed_sql(
690+
query, model, group_key_fields, order_clauses, per_group_limit
691+
)
692+
rows = await query._db.execute_query_dict(sql, values)
675693

676-
for group_dict in groups_qs:
694+
items_by_key: dict[tuple, list[Any]] = defaultdict(list)
695+
for row in rows:
677696
key = tuple(
678-
str(group_dict[k]) if group_dict[k] is not None else None
697+
str(row[k]) if row.get(k) is not None else None
679698
for k in group_key_fields
680699
)
681-
scoped = query.filter(**{k: group_dict[k] for k in group_key_fields})
682-
if order_input:
683-
order_clauses = _build_tortoise_order_from_input(order_input)
684-
if order_clauses:
685-
scoped = scoped.order_by(*order_clauses)
686-
items = await scoped.limit(per_group_limit)
687-
items_by_key[key] = list(items)
688-
689-
return items_by_key
700+
row.pop(_ROW_NUMBER_ALIAS, None)
701+
items_by_key[key].append(model._init_from_db(**row))
702+
return dict(items_by_key)
703+
704+
async def group_counts(
705+
self, query: Any, key_field: str, info: Any
706+
) -> dict[Any, int]:
707+
"""Each group's real total, which the windowed page cannot report."""
708+
from tortoise.functions import Count
709+
710+
# Inherited ordering would drag non-grouped columns into the statement.
711+
counted = (
712+
query.order_by()
713+
.group_by(key_field)
714+
.annotate(**{_COUNT_ALIAS: Count(query.model._meta.pk_attr)})
715+
.values(key_field, _COUNT_ALIAS)
716+
)
717+
return {row[key_field]: row[_COUNT_ALIAS] for row in await counted}
690718

691719
# -- Queryset overrides --------------------------------------------------
692720

@@ -760,9 +788,64 @@ async def materialize_query(self, query: Any, info: Any) -> list[Any]:
760788

761789
# -- Optimizer -----------------------------------------------------------
762790

791+
_supports_windowed_pages = True
792+
763793
def optimizer_extension(self, **kwargs: Any) -> type[SchemaExtension]:
764794
return OptimizerExtension.configure(backend=self, store=self._store)
765795

796+
def instance_pk(self, instance: Any) -> Any:
797+
return getattr(instance, "pk", None)
798+
799+
def _relation_connection_spec(
800+
self, model: type, field_name: str, relation: str
801+
) -> Any:
802+
from tortoise.fields.relational import BackwardFKRelation
803+
804+
from strawberry_orm.backends._base import RelationConnectionSpec
805+
806+
field = model._meta.fields_map.get(relation) # type: ignore[attr-defined]
807+
key_field = getattr(field, "relation_field", None)
808+
# Only a reverse foreign key keeps the parent's key on the related row.
809+
# A many-to-many hides it in the through table, leaving the window
810+
# nothing to partition by.
811+
if not isinstance(field, BackwardFKRelation) or key_field is None:
812+
return None
813+
return RelationConnectionSpec(
814+
model=model,
815+
field_name=field_name,
816+
relation=relation,
817+
related_model=self._relation_target_model(model, relation),
818+
key_field=key_field,
819+
)
820+
821+
def relation_base_query(self, spec: Any, pks: list[Any], info: Any) -> Any:
822+
qs = spec.related_model.filter(**{f"{spec.key_field}__in": pks})
823+
restrict = self.relation_scope(
824+
spec.model, spec.field_name, info, on=spec.relation
825+
)
826+
return qs if restrict is None else restrict(qs, info)
827+
828+
def _make_relation_query_resolver(
829+
self, model: type, field_name: str, relation: str
830+
) -> Any:
831+
backend = self
832+
spec = self._relation_connection_spec(model, field_name, relation)
833+
834+
def resolver(self: Any, info: Any) -> Any:
835+
from strawberry_orm.batching import page_attr
836+
837+
page = getattr(self, page_attr(field_name), None)
838+
if page is not None:
839+
return page
840+
qs = spec.related_model.filter(
841+
**{spec.key_field: backend.instance_pk(self)}
842+
)
843+
restrict = backend.relation_scope(model, field_name, info, on=relation)
844+
return qs if restrict is None else restrict(qs, info)
845+
846+
resolver.__name__ = field_name
847+
return resolver
848+
766849
def _apply_nested_queryset(
767850
self,
768851
qs: Any,
@@ -886,6 +969,13 @@ def _walk_selections(
886969
if not is_rel:
887970
continue
888971

972+
# A field that answers for itself will ignore whatever the
973+
# prefetch loads, so do not pay for it.
974+
if self.resolves_itself(
975+
self._type_name_for_model(current_model), field_name
976+
):
977+
continue
978+
889979
related_model = field_obj.related_model
890980

891981
ancestor = _find_custom_ancestor(full_path)
@@ -1795,6 +1885,84 @@ def _extract_tortoise_overlapping_order(
17951885
return clauses
17961886

17971887

1888+
def _db_columns(model: type) -> dict[str, str]:
1889+
"""Field name to column name, for every column the model really has."""
1890+
return dict(model._meta.fields_db_projection) # type: ignore[attr-defined]
1891+
1892+
1893+
def _quote_ident(name: str, model: type, quote_char: str) -> str:
1894+
"""Quote *name* for the dialect, having checked the model owns it.
1895+
1896+
Only ever called with names the caller derived from the model, so an
1897+
unknown one means a bug rather than user input; refusing it anyway keeps
1898+
the window's SQL from being assembled out of anything but real columns.
1899+
"""
1900+
columns = set(_db_columns(model).values())
1901+
if name not in columns:
1902+
raise ValueError(
1903+
f"{model.__name__} has no column {name!r} to build a window from."
1904+
)
1905+
return f"{quote_char}{name}{quote_char}"
1906+
1907+
1908+
def _window_ordering(
1909+
model: type, order_clauses: list[str], quote_char: str
1910+
) -> list[str]:
1911+
"""Render the window's ORDER BY, falling back to the primary key."""
1912+
meta = model._meta # type: ignore[attr-defined]
1913+
projection = _db_columns(model)
1914+
rendered: list[str] = []
1915+
for clause in order_clauses:
1916+
descending = clause.startswith("-")
1917+
field = clause[1:] if descending else clause
1918+
column = projection.get(field, field)
1919+
direction = " DESC" if descending else " ASC"
1920+
rendered.append(_quote_ident(column, model, quote_char) + direction)
1921+
if not rendered:
1922+
pk_column = projection.get(meta.pk_attr, meta.db_pk_column)
1923+
rendered.append(_quote_ident(pk_column, model, quote_char) + " ASC")
1924+
return rendered
1925+
1926+
1927+
def _windowed_sql(
1928+
query: Any,
1929+
model: type,
1930+
group_key_fields: list[str],
1931+
order_clauses: list[str],
1932+
per_group_limit: int,
1933+
) -> tuple[str, list[Any]]:
1934+
"""Wrap *query* in SQL keeping the first rows of every group.
1935+
1936+
Returns the statement and the values its placeholders still expect, so the
1937+
caller binds them rather than embedding them.
1938+
"""
1939+
query._choose_db_if_not_chosen()
1940+
query._make_query()
1941+
inner, values = query.query.get_parameterized_sql()
1942+
1943+
quote_char = query._db.query_class.SQL_CONTEXT.quote_char
1944+
projection = _db_columns(model)
1945+
partition = ", ".join(
1946+
_quote_ident(projection.get(field, field), model, quote_char)
1947+
for field in group_key_fields
1948+
)
1949+
ordering = ", ".join(_window_ordering(model, order_clauses, quote_char))
1950+
alias = f"{quote_char}{_ROW_NUMBER_ALIAS}{quote_char}"
1951+
1952+
sql = (
1953+
f"SELECT * FROM ("
1954+
f"SELECT _orm_inner.*, ROW_NUMBER() OVER ("
1955+
f"PARTITION BY {partition} ORDER BY {ordering}"
1956+
f") AS {alias} FROM ({inner}) _orm_inner"
1957+
f") _orm_windowed WHERE {alias} <= {int(per_group_limit)} "
1958+
# Ordered by the row number so each group comes back in the order the
1959+
# window put it in; without it the rows arrive however the database
1960+
# found them and the requested order is lost.
1961+
f"ORDER BY {alias}"
1962+
)
1963+
return sql, values
1964+
1965+
17981966
def _build_tortoise_order_from_input(order_input: Any) -> list[str]:
17991967
"""Convert an order input to Tortoise order_by strings."""
18001968
order_list = order_input if isinstance(order_input, list) else [order_input]

0 commit comments

Comments
 (0)