You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: README.md
+46-7Lines changed: 46 additions & 7 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -335,13 +335,13 @@ How a field is written decides what scoping it gets.
335
335
|`@orm.field.custom` returning a query object | Optimizer + that type's `scope_rows` — see [Root custom query](#root-custom-query)|
336
336
|`@orm.field.custom` returning `self.author`| As written; scoping only via prefetch |
337
337
|`@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)|
339
339
340
340
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).
341
341
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**.
343
343
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()`.
345
345
346
346
### Root custom query
347
347
@@ -359,6 +359,31 @@ class Query:
359
359
360
360
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.
361
361
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:
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:
`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
+
362
387
### `orm.schema()`
363
388
364
389
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.
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`.
470
495
471
496
**Fragments.** The optimizer walks inline fragments (`... on PostType`) and named fragment spreads, so relations inside them are prefetched normally.
472
497
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
-
475
498
**Field permissions.**`orm.field.auto(permission_classes=[...])` — see [Declaring fields](#declaring-fields).
476
499
477
500
</details>
478
501
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).
480
503
481
504
---
482
505
@@ -1523,6 +1546,22 @@ Filters and ordering are applied *before* pagination, so the connection always s
1523
1546
1524
1547
`orm.connection()` accepts the same keyword arguments as `relay.connection()` — `name`, `description`, `deprecation_reason`, `extensions`, and `max_results`.
1525
1548
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:
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
+
1526
1565
### Node mutations
1527
1566
1528
1567
`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.
0 commit comments