Skip to content

Commit eba4aa0

Browse files
Apply row scoping when relations load lazily, and add orm.optimize (v0.16.0).
Security: a resolver returning materialized rows instead of a query object silently disabled row-level access control on every relation below it. The optimizer applies scope_rows while building the eager load, so with nothing to build, Django and SQLAlchemy read the relation straight off each parent and never scoped it. A single list() call exposed rows the schema was written to hide, in both directions of the relation. Both backends now reapply the related type's scope_rows and the field-level scope= at resolve time, which is what Tortoise already did; Tortoise gains the same for to-one relations. A relation the optimizer already loaded was scoped on the way in and is returned from cache, so the fast path costs nothing. Two tests had encoded the leak as a guarantee, asserting that disabling the optimizer disabled the scoping hooks. They now assert the opposite. New: orm.optimize(data, info, at=...) eager-loads what the selection needs from rows a resolver has already materialized, loading relations onto the instances given so in-memory values survive. orm.connection(resolver=...) supplies the rows for a connection while the library still builds the filter/order/groupBy arguments and the grouped connection type. Fixes: grouped connections never worked on Tortoise - post-processing was synchronous while the backend's grouping is async, so groups received an un-awaited coroutine. Tortoise also folded an inherited ORDER BY into the GROUP BY of an aggregate, turning a total into a per-row count. Tortoise relation resolvers re-queried prefetched relations, discarding nested eager loads; fixing that resolves a known-broken forward-FK case. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b49c98e commit eba4aa0

30 files changed

Lines changed: 2723 additions & 198 deletions

README.md

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -335,13 +335,13 @@ How a field is written decides what scoping it gets.
335335
| `@orm.field.custom` returning a query object | Optimizer + that type's `scope_rows` — see [Root custom query](#root-custom-query) |
336336
| `@orm.field.custom` returning `self.author` | As written; scoping only via prefetch |
337337
| `@strawberry.field`, fully custom | You own scoping and auth |
338-
| A resolver returning instances | Optimizer skipped; nested relations may be unscoped |
338+
| A resolver returning instances | Nested relations still scoped, one query each — see [`orm.optimize`](#ormoptimize) |
339339

340340
The first four rows are all `orm.field`, which has four named forms that run at different times and take different arguments — see [The four kinds of field](#the-four-kinds-of-field).
341341

342-
Type-level and field-level scopes compose in that order — `scope_rows` first, then `scope=` — and both run **before SQL executes**, while the prefetch is being built. `using=` is not a filter; it only adds eager-load paths.
342+
Type-level and field-level scopes compose in that order — `scope_rows` first, then `scope=` — and both run **before SQL executes**.
343343

344-
Scoping hooks do **not** run when you build with `strawberry.Schema(query=Query)` instead of `orm.schema()` on Django or SQLAlchemy, or when a resolver returns materialized instances.
344+
Relation scoping does not depend on the optimizer. When the optimizer runs it applies the scope once while building the eager load; when it does not, the scope is applied again as each parent's relation is read. Either way the rows are scoped — the difference is how many queries it takes. The **root** field is the exception: `scope_rows` on a root query object is applied by the optimizer, so build with `orm.schema()`.
345345

346346
### Root custom query
347347

@@ -359,6 +359,31 @@ class Query:
359359

360360
Use `scope_rows` when the same rule applies everywhere the model loads, and a custom root resolver when the criteria belong to that one entry point. See [List Fields](#list-fields) for a comparison.
361361

362+
### `orm.optimize`
363+
364+
Sometimes a resolver cannot return a query object — it has just written the rows, or it returns them inside a wrapper. The rows are still scoped when their relations are read, but each relation costs a query per parent. `orm.optimize` puts them back on the eager-loaded path:
365+
366+
```python
367+
# Django
368+
@strawberry.field
369+
def create_post(self, info: strawberry.Info, ...) -> PostType:
370+
post = Post.objects.create(...)
371+
return orm.optimize(post, info)
372+
```
373+
374+
It takes a query object, a model instance, or a list, and returns anything else untouched — so it is safe to wrap a whole payload. Relations are loaded **onto the instances you pass in**, so values you have just set in memory are never overwritten by a re-read of the database. On an async backend the result is awaitable.
375+
376+
When the rows sit below the field being resolved, point it at them with `at`. The optimizer reads the selection set from the current field, and for a payload the relations to load are named under `data`, not beside it:
377+
378+
```python
379+
# Django
380+
@strawberry.field
381+
def recent_posts(self, info: strawberry.Info) -> Payload:
382+
return Payload(data=orm.optimize(rows, info, at="data"), errors=None)
383+
```
384+
385+
`at` also takes a sequence for a deeper path, and matches either `camelCase` or `snake_case`. Getting it wrong is not an error — nothing is eager-loaded and the rows come back as they would have anyway.
386+
362387
### `orm.schema()`
363388

364389
Build schemas with `orm.schema()`. The optimizer is enabled by default: it executes query objects, eager-loads relations from the selection set, applies field hints, and honours `scope_rows`. On Django and SQLAlchemy nested scoping depends on it, so this is not an optional performance tweak.
@@ -466,17 +491,15 @@ posts: list[PostType] = orm.field.scoped(
466491
)
467492
```
468493

469-
For `{ users { name posts { title } } }` the order is always `PostType.scope_rows` then `UserType.posts.load`. With a plain annotation and no `scope=`, only the first line appears. Neither hook runs again as GraphQL reads each `user.posts`. The repo asserts this by patching `print` — see `tests/backends/*/test_query_scoping_hook_order.py`.
494+
For `{ users { name posts { title } } }` the order is always `PostType.scope_rows` then `UserType.posts.load`. With a plain annotation and no `scope=`, only the first line appears. When the relation was eager-loaded, the hooks run once for the whole batch; when it was not, they run again for each parent as the relation is read. Either way they run. The repo asserts this by patching `print` — see `tests/backends/*/test_query_scoping_hook_order.py`.
470495

471496
**Fragments.** The optimizer walks inline fragments (`... on PostType`) and named fragment spreads, so relations inside them are prefetched normally.
472497

473-
**Tortoise.** Annotation-only list relations also apply `_apply_nested_queryset` at resolve time when prefetch did not run. The `scope_rows` then `scope=` order is the same there.
474-
475498
**Field permissions.** `orm.field.auto(permission_classes=[...])` — see [Declaring fields](#declaring-fields).
476499

477500
</details>
478501

479-
> If nested rows come back unscoped, check three things in order: that the schema was built with `orm.schema()`, that root resolvers return query objects, and that `scope_rows` exists on every exposed type. See [Security](#security).
502+
> If nested rows come back unscoped, check that `scope_rows` exists on every exposed type. Scoping does not depend on the optimizer: a resolver returning a list is slower than one returning a query object, but it is not less scoped. See [Security](#security).
480503
481504
---
482505

@@ -1523,6 +1546,22 @@ Filters and ordering are applied *before* pagination, so the connection always s
15231546

15241547
`orm.connection()` accepts the same keyword arguments as `relay.connection()``name`, `description`, `deprecation_reason`, `extensions`, and `max_results`.
15251548

1549+
### Supplying the queryset yourself
1550+
1551+
The decorator above is one way to give `orm.connection()` a resolver. You can also pass one by keyword, which is what you want when the connection is a field on a type you are assembling rather than a method you are writing:
1552+
1553+
```python
1554+
# Django
1555+
def recent_users(info: strawberry.types.Info) -> Iterable[UserNode]:
1556+
return User.objects.order_by("-created_at")
1557+
1558+
@strawberry.type
1559+
class Query:
1560+
users = orm.connection(ORMListConnection[UserNode], resolver=recent_users)
1561+
```
1562+
1563+
Either way the library still builds everything around your rows: the generated `filter`, `order`, and `groupBy` arguments, the grouped connection type when the node declares a group-by, `totalCount`, and optimizer integration. Your resolver does not need to accept `filter` or `order` — they are applied to the query object you return. Arguments of your own are passed through and appear on the field.
1564+
15261565
### Node mutations
15271566

15281567
`orm.mutations.create_node_input()` and `orm.mutations.update_node_input()` generate catch-all Relay Node mutation *inputs* with recursive nested refs; you supply the resolver. See [Node Mutation Inputs](#node-mutation-inputs) for full documentation.

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.15.1"
3+
version = "0.16.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: 174 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -242,33 +242,45 @@ def is_type_of(inner_cls: type, obj: object, info: Any) -> bool:
242242
)
243243
else:
244244

245-
def _make_resolver(fname: str, return_ann: Any) -> Any:
246-
def resolver(self: Any) -> Any:
247-
return list(getattr(self, fname).all())
245+
def _make_resolver(
246+
fname: str, return_ann: Any, backend: Any
247+
) -> Any:
248+
def resolver(self: Any, info: Any) -> Any:
249+
return list(
250+
_scoped_related_queryset(backend, self, fname, info)
251+
)
248252

249253
resolver.__name__ = fname
250-
resolver.__annotations__ = {"return": return_ann}
251-
if self._django_async_safe:
254+
resolver.__annotations__ = {
255+
"info": strawberry.types.Info,
256+
"return": return_ann,
257+
}
258+
if backend._django_async_safe:
252259
resolver = async_safe_resolver(resolver)
253260
return strawberry.field(resolver=resolver)
254261

255-
setattr(cls, field_name, _make_resolver(field_name, ann))
262+
setattr(cls, field_name, _make_resolver(field_name, ann, self))
256263
elif kind in ("fk", "one"):
257264

258-
def _make_fk_resolver(fname: str, return_ann: Any) -> Any:
259-
def resolver(self: Any) -> Any:
260-
return getattr(self, fname)
265+
def _make_fk_resolver(
266+
fname: str, return_ann: Any, backend: Any
267+
) -> Any:
268+
def resolver(self: Any, info: Any) -> Any:
269+
return _scoped_related_instance(backend, self, fname, info)
261270

262271
resolver.__name__ = fname
263-
resolver.__annotations__ = {"return": return_ann}
264-
if self._django_async_safe:
272+
resolver.__annotations__ = {
273+
"info": strawberry.types.Info,
274+
"return": return_ann,
275+
}
276+
if backend._django_async_safe:
265277
resolver = async_safe_resolver(
266278
resolver,
267279
materialize=False,
268280
)
269281
return strawberry.field(resolver=resolver)
270282

271-
setattr(cls, field_name, _make_fk_resolver(field_name, ann))
283+
setattr(cls, field_name, _make_fk_resolver(field_name, ann, self))
272284

273285
annotations = getattr(cls, "__annotations__", {})
274286
self._check_lazy_relation_fields(cls, model, annotations)
@@ -571,6 +583,11 @@ def is_query_object(self, value: Any) -> bool:
571583

572584
return isinstance(value, QuerySet)
573585

586+
def is_model_instance(self, value: Any) -> bool:
587+
from django.db.models import Model
588+
589+
return isinstance(value, Model)
590+
574591
def relation_names(self, model: type) -> set[str]:
575592
return {
576593
f.name
@@ -719,7 +736,14 @@ def _resolve_basic(root: Any) -> Any:
719736
def optimizer_extension(self, **kwargs: Any) -> type[SchemaExtension]:
720737
return OptimizerExtension.configure(backend=self, store=self._store)
721738

722-
def apply_optimizer_hints(self, store: Any, query: Any, info: Any) -> Any:
739+
def _relation_lookups(
740+
self, store: Any, model: type, info: Any
741+
) -> tuple[list[str], list[Any]]:
742+
"""Return the lookups the current selection needs for *model*.
743+
744+
Shared by query optimization and by loading relations onto instances
745+
the caller already holds, so both apply the same row scoping.
746+
"""
723747
import re
724748

725749
from strawberry_orm.optimizer.selections import (
@@ -728,17 +752,7 @@ def apply_optimizer_hints(self, store: Any, query: Any, info: Any) -> Any:
728752
iter_field_nodes,
729753
)
730754

731-
def optimize() -> Any:
732-
try:
733-
model = query.model
734-
except AttributeError:
735-
return query
736-
737-
optimized_query = query
738-
get_qs = self._type_querysets.get(model)
739-
if get_qs is not None:
740-
optimized_query = get_qs(optimized_query, info)
741-
755+
def compute() -> tuple[list[str], list[Any]]:
742756
select_related: list[str] = []
743757
prefetch_related: list[Any] = []
744758
fragments = fragments_from_info(info)
@@ -924,6 +938,25 @@ def _walk_selections(
924938
for field_node in field_nodes_from_info(info):
925939
_walk_selections(field_node.selection_set, model)
926940

941+
return select_related, prefetch_related
942+
943+
return compute()
944+
945+
def apply_optimizer_hints(self, store: Any, query: Any, info: Any) -> Any:
946+
def optimize() -> Any:
947+
try:
948+
model = query.model
949+
except AttributeError:
950+
return query
951+
952+
optimized_query = query
953+
get_qs = self._type_querysets.get(model)
954+
if get_qs is not None:
955+
optimized_query = get_qs(optimized_query, info)
956+
957+
select_related, prefetch_related = self._relation_lookups(
958+
store, model, info
959+
)
927960
if select_related:
928961
optimized_query = optimized_query.select_related(*select_related)
929962
if prefetch_related:
@@ -933,12 +966,121 @@ def _walk_selections(
933966

934967
return run_sync(optimize, thread_sensitive=True)
935968

969+
def load_relations(self, store: Any, instances: list[Any], info: Any) -> list[Any]:
970+
"""Eager-load the selected relations onto instances already in memory.
971+
972+
``prefetch_related_objects`` fills the relation caches in place, so
973+
scalar values the caller is holding - which may be fresher than the
974+
database, straight out of a mutation - are never overwritten.
975+
"""
976+
977+
def load() -> list[Any]:
978+
from django.db.models import prefetch_related_objects
979+
980+
by_model: dict[type, list[Any]] = {}
981+
for instance in instances:
982+
by_model.setdefault(type(instance), []).append(instance)
983+
984+
for model, rows in by_model.items():
985+
select_related, prefetch_related = self._relation_lookups(
986+
store, model, info
987+
)
988+
lookups = _dedupe_lookups([*select_related, *prefetch_related])
989+
if lookups:
990+
prefetch_related_objects(rows, *lookups)
991+
return instances
992+
993+
return run_sync(load, thread_sensitive=True)
994+
936995

937996
# ---------------------------------------------------------------------------
938997
# Internal helpers
939998
# ---------------------------------------------------------------------------
940999

9411000

1001+
def _scoped_related_queryset(
1002+
backend: Any, instance: Any, field_name: str, info: Any
1003+
) -> Any:
1004+
"""Return the related rows for *field_name*, with row scoping applied.
1005+
1006+
The optimizer scopes relations when it builds the prefetch, but a resolver
1007+
that returns materialized rows never gives it a query to work on. Reading
1008+
the relation off the instance would then skip the related type's
1009+
``scope_rows`` entirely and hand back rows the caller may not read, so the
1010+
scope is applied here instead.
1011+
1012+
A prefetched relation was already scoped on the way in; re-filtering it
1013+
would throw the cache away and issue the query this path exists to avoid.
1014+
"""
1015+
from strawberry_orm.lazy_resolution import _django_relation_prefetched
1016+
1017+
manager = getattr(instance, field_name)
1018+
if _django_relation_prefetched(instance, field_name):
1019+
return manager.all()
1020+
1021+
restrict = backend.relation_scope(type(instance), field_name, info)
1022+
queryset = manager.all()
1023+
return queryset if restrict is None else restrict(queryset, info)
1024+
1025+
1026+
def _scoped_related_instance(
1027+
backend: Any, instance: Any, field_name: str, info: Any
1028+
) -> Any:
1029+
"""Return the related row for a to-one *field_name*, with scoping applied.
1030+
1031+
A scoped-out row reads as absent, matching what the optimizer produces
1032+
when its scoped load finds nothing on the other end.
1033+
"""
1034+
from strawberry_orm.lazy_resolution import _django_relation_prefetched
1035+
1036+
restrict = backend.relation_scope(type(instance), field_name, info)
1037+
if restrict is None:
1038+
return getattr(instance, field_name)
1039+
1040+
# With a scope in play a removed row has to read as absent. The scoped
1041+
# eager load leaves the cache empty, and Django's forward descriptor
1042+
# raises rather than returning None for a non-nullable column.
1043+
if _django_relation_prefetched(instance, field_name):
1044+
return getattr(instance, field_name, None)
1045+
1046+
related = getattr(instance, field_name, None)
1047+
if related is None:
1048+
return None
1049+
queryset = type(related)._default_manager.filter(pk=related.pk)
1050+
return restrict(queryset, info).first()
1051+
1052+
1053+
def _dedupe_lookups(lookups: list[Any]) -> list[Any]:
1054+
"""Collapse repeated lookups for one path, keeping the scoped one.
1055+
1056+
The same relation can be reached twice - two aliases of one field, or a
1057+
field both selected and named in ``using=`` - and Django rejects a repeated
1058+
path when either occurrence carries a queryset. Which duplicate survives
1059+
matters: only the ``Prefetch`` carries the row scope, so a bare path must
1060+
never be allowed to shadow it.
1061+
"""
1062+
position: dict[str, int] = {}
1063+
unique: list[Any] = []
1064+
for lookup in lookups:
1065+
path = (
1066+
lookup if isinstance(lookup, str) else getattr(lookup, "prefetch_to", None)
1067+
)
1068+
if path is None:
1069+
unique.append(lookup)
1070+
continue
1071+
if path not in position:
1072+
position[path] = len(unique)
1073+
unique.append(lookup)
1074+
continue
1075+
kept = unique[position[path]]
1076+
if (
1077+
getattr(kept, "queryset", None) is None
1078+
and getattr(lookup, "queryset", None) is not None
1079+
):
1080+
unique[position[path]] = lookup
1081+
return unique
1082+
1083+
9421084
def _is_parent_predicate(
9431085
child: Any, parent_pk: Any, base_alias: str | None = None
9441086
) -> bool:
@@ -990,7 +1132,15 @@ def _resolve(
9901132
filter: Any = None,
9911133
order: Any = None,
9921134
) -> list[Any]:
1135+
if filter is None and order is None:
1136+
return list(_scoped_related_queryset(backend, self, fname, info))
1137+
1138+
# Filtering or ordering re-runs the query, so a prefetched result
1139+
# cannot carry the scope through - it has to be reapplied here.
1140+
restrict = backend.relation_scope(type(self), fname, info)
9931141
qs = getattr(self, fname).all()
1142+
if restrict is not None:
1143+
qs = restrict(qs, info)
9941144
if filter is not None:
9951145
qs = backend.apply_filters(qs, filter, rel_model, info=info)
9961146
if order is not None:

src/strawberry_orm/backends/protocol.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,16 @@ def is_query_object(self, value: Any) -> bool:
201201
"""Return ``True`` if *value* is a query object the optimizer can handle."""
202202
...
203203

204+
def is_model_instance(self, value: Any) -> bool:
205+
"""Return ``True`` if *value* is a persisted model instance."""
206+
...
207+
208+
def load_relations(
209+
self, store: Any, instances: list[Any], info: Any
210+
) -> AwaitableOrValue[list[Any]]:
211+
"""Eager-load the selected relations onto *instances*, in place."""
212+
...
213+
204214
def relation_names(self, model: type) -> set[str]:
205215
"""Return the names of *model*'s relations, for hint validation."""
206216
...

0 commit comments

Comments
 (0)