Skip to content

Commit 3bd7f02

Browse files
fix: do not fetch entire table for nullable connection queries (#953)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7fa0a54 commit 3bd7f02

5 files changed

Lines changed: 61 additions & 0 deletions

File tree

RELEASE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
release type: patch
3+
---
4+
5+
Fix nullable connections (e.g. when guarded by a permission extension) eagerly
6+
fetching the entire table before the connection applied its pagination. The
7+
return type is now unwrapped from `StrawberryOptional`/other containers so the
8+
queryset evaluation stays deferred and the connection can apply a `LIMIT`.

strawberry_django/relay/utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from django.db import models
1414
from strawberry import relay
1515
from strawberry.relay.exceptions import NodeIDAnnotationError
16+
from strawberry.types.base import StrawberryContainer
1617
from strawberry.types.info import Info
1718
from strawberry.utils.await_maybe import AwaitableOrValue
1819

@@ -161,6 +162,9 @@ def resolve_model_nodes(
161162
# Connection will filter the results when its is being resolved.
162163
# We don't want to fetch everything before it does that
163164
return_type = info.return_type
165+
# Unwrap containers (e.g. `StrawberryOptional` for a nullable connection)
166+
while isinstance(return_type, StrawberryContainer):
167+
return_type = return_type.of_type
164168
if isinstance(return_type, type) and issubclass(return_type, relay.Connection):
165169
extra_args["qs_hook"] = lambda qs: qs
166170

tests/relay/schema.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ class Query:
5858
node_optional: relay.Node | None = strawberry_django.node()
5959
nodes_optional: list[relay.Node | None] = strawberry_django.node()
6060
fruits: DjangoListConnection[Fruit] = strawberry_django.connection()
61+
# Nullable connection (e.g. as produced by a permission extension)
62+
fruits_optional: DjangoListConnection[Fruit] | None = strawberry_django.connection()
6163
fruits_lazy: DjangoListConnection[
6264
Annotated["Fruit", strawberry.lazy("tests.relay.schema")]
6365
] = strawberry_django.connection()

tests/relay/snapshots/schema.gql

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,19 @@ type Query {
104104
"""Returns the items in the list that come after the specified cursor."""
105105
last: Int = null
106106
): FruitConnection!
107+
fruitsOptional(
108+
"""Returns the items in the list that come before the specified cursor."""
109+
before: String = null
110+
111+
"""Returns the items in the list that come after the specified cursor."""
112+
after: String = null
113+
114+
"""Returns the first n items from the list."""
115+
first: Int = null
116+
117+
"""Returns the items in the list that come after the specified cursor."""
118+
last: Int = null
119+
): FruitConnection
107120
fruitsLazy(
108121
"""Returns the items in the list that come before the specified cursor."""
109122
before: String = null

tests/relay/test_fields.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import pytest
2+
from django.db import connection
3+
from django.test.utils import CaptureQueriesContext
24
from pytest_django import DjangoAssertNumQueries
35
from strawberry.relay.utils import to_base64
46

@@ -1177,3 +1179,35 @@ def test_query_connection_total_count_sql_queries(
11771179
assert result.data == {
11781180
query_attr: {"totalCount": 5},
11791181
}
1182+
1183+
1184+
def test_nullable_connection_does_not_fetch_whole_table():
1185+
# Regression test: a nullable connection (e.g. wrapped in `StrawberryOptional`
1186+
# by a permission extension) used to fetch the entire table before the
1187+
# connection sliced the results, because the return type was not unwrapped
1188+
# and the queryset was evaluated eagerly instead of being deferred with a
1189+
# LIMIT applied by the connection.
1190+
with CaptureQueriesContext(connection) as ctx:
1191+
result = schema.execute_sync(
1192+
"""
1193+
query {
1194+
fruitsOptional (first: 2) {
1195+
edges {
1196+
node { id name }
1197+
}
1198+
}
1199+
}
1200+
""",
1201+
)
1202+
1203+
assert result.errors is None
1204+
assert result.data is not None
1205+
edges = result.data["fruitsOptional"]["edges"]
1206+
assert len(edges) == 2
1207+
1208+
# The query that fetches the fruit rows must use a LIMIT so it doesn't
1209+
# pull the entire table into memory.
1210+
fetch_queries = [
1211+
q["sql"] for q in ctx.captured_queries if "fruit" in q["sql"].lower()
1212+
]
1213+
assert any("LIMIT 3" in sql for sql in fetch_queries), fetch_queries

0 commit comments

Comments
 (0)