strawberry-orm 0.4.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. strawberry_orm-0.6.0/PKG-INFO +864 -0
  2. strawberry_orm-0.6.0/README.md +829 -0
  3. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/pyproject.toml +1 -1
  4. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/backends/_base.py +61 -17
  5. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/backends/django.py +61 -49
  6. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/backends/protocol.py +3 -5
  7. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/backends/sqlalchemy.py +98 -88
  8. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/backends/tortoise.py +52 -45
  9. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/core.py +4 -5
  10. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/mutations.py +68 -231
  11. strawberry_orm-0.4.0/PKG-INFO +0 -1025
  12. strawberry_orm-0.4.0/README.md +0 -990
  13. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/__init__.py +0 -0
  14. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/_async.py +0 -0
  15. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/backends/__init__.py +0 -0
  16. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/fields.py +0 -0
  17. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/filters.py +0 -0
  18. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/optimizer/__init__.py +0 -0
  19. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/optimizer/extension.py +0 -0
  20. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/optimizer/store.py +0 -0
  21. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/py.typed +0 -0
  22. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/relay/__init__.py +0 -0
  23. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/relay/connection.py +0 -0
  24. {strawberry_orm-0.4.0 → strawberry_orm-0.6.0}/src/strawberry_orm/types.py +0 -0
@@ -0,0 +1,864 @@
1
+ Metadata-Version: 2.4
2
+ Name: strawberry-orm
3
+ Version: 0.6.0
4
+ Summary: Unified, backend-agnostic ORM abstraction for Strawberry GraphQL
5
+ Author: James Davidson, Patrick Arminio
6
+ Author-email: James Davidson <jamie.t.davidson@gmail.com>, Patrick Arminio <patrick.arminio@gmail.com>
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Dist: strawberry-graphql>=0.311.0
17
+ Requires-Dist: greenlet>=3.3.2 ; extra == 'dev'
18
+ Requires-Dist: pytest>=8.0 ; extra == 'dev'
19
+ Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
20
+ Requires-Dist: pytest-django>=4.8 ; extra == 'dev'
21
+ Requires-Dist: typer>=0.9 ; extra == 'dev'
22
+ Requires-Dist: uvicorn>=0.30 ; extra == 'dev'
23
+ Requires-Dist: django>=4.2 ; extra == 'django'
24
+ Requires-Dist: sqlalchemy>=2.0 ; extra == 'sqlalchemy'
25
+ Requires-Dist: tortoise-orm>=0.25.0 ; extra == 'tortoise'
26
+ Requires-Python: >=3.12
27
+ Project-URL: Homepage, https://github.com/strawberry-graphql/strawberry-orm
28
+ Project-URL: Repository, https://github.com/strawberry-graphql/strawberry-orm
29
+ Project-URL: Issues, https://github.com/strawberry-graphql/strawberry-orm/issues
30
+ Provides-Extra: dev
31
+ Provides-Extra: django
32
+ Provides-Extra: sqlalchemy
33
+ Provides-Extra: tortoise
34
+ Description-Content-Type: text/markdown
35
+
36
+ # strawberry-orm
37
+
38
+ Backend-agnostic schema generation for [Strawberry GraphQL](https://strawberry.rocks/) on top of Django ORM, SQLAlchemy, and Tortoise ORM.
39
+
40
+ > **Warning** — `strawberry-orm` is still in **alpha**. Expect breaking changes and incomplete APIs while the package stabilizes.
41
+
42
+ ## Contents
43
+
44
+ - [Installation](#installation)
45
+ - [Quick Start](#quick-start)
46
+ - [Backends](#backends)
47
+ - [Defining Types](#defining-types)
48
+ - [Filters and Ordering](#filters-and-ordering)
49
+ - [Mutations](#mutations)
50
+ - [Relay Integration](#relay-integration)
51
+ - [Query Optimization](#query-optimization)
52
+ - [Async Usage](#async-usage)
53
+ - [Security](#security)
54
+ - [Public Exports](#public-exports)
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ uv add "strawberry-orm[sqlalchemy]" # or [django] or [tortoise]
60
+ ```
61
+
62
+ Or with pip:
63
+
64
+ ```bash
65
+ pip install "strawberry-orm[sqlalchemy]"
66
+ ```
67
+
68
+ Requires Python `>=3.12` and `strawberry-graphql>=0.311.0`.
69
+
70
+ ## Quick Start
71
+
72
+ A blog API with users, posts, tags, and comments — covering types, relations, queryset scoping, optimizer hints, filters, ordering, object traversal, mutations, ref lists, recursive node mutations, and the query optimizer:
73
+
74
+ ```python
75
+ import strawberry
76
+ from strawberry_orm import StrawberryORM, auto
77
+
78
+ orm = StrawberryORM(
79
+ "sqlalchemy",
80
+ dialect="postgresql",
81
+ session_getter=lambda info: info.context["session"],
82
+ )
83
+
84
+ # -- Filters and ordering (register leaf models first) -----------------------
85
+
86
+ UserFilter = orm.filter(User)
87
+ UserOrder = orm.order(User)
88
+ TagFilter = orm.filter(Tag)
89
+ TagOrder = orm.order(Tag)
90
+
91
+ CommentFilter = orm.filter(Comment)
92
+ PostFilter = orm.filter(Post) # picks up author/tags/comments relations
93
+ PostOrder = orm.order(Post)
94
+
95
+ # -- Types -------------------------------------------------------------------
96
+
97
+ @orm.type(User, filters=UserFilter, order=UserOrder)
98
+ class UserType:
99
+ id: auto
100
+ name: auto
101
+ email: auto
102
+ posts: list["PostType"]
103
+
104
+ @orm.type(Tag, filters=TagFilter, order=TagOrder)
105
+ class TagType:
106
+ id: auto
107
+ name: auto
108
+
109
+ @orm.type(Comment, filters=CommentFilter)
110
+ class CommentType:
111
+ id: auto
112
+ body: auto
113
+
114
+ @orm.type(Post, filters=PostFilter, order=PostOrder)
115
+ class PostType:
116
+ id: auto
117
+ title: auto
118
+ body: auto
119
+ is_published: auto
120
+ author: UserType
121
+ tags: list[TagType] = orm.field(load=lambda qs: qs.order_by("name"))
122
+ comments: list[CommentType]
123
+
124
+ @classmethod
125
+ def get_queryset(cls, qs, info):
126
+ return qs.filter(is_published=True) # works on all backends
127
+
128
+ # -- Mutations ---------------------------------------------------------------
129
+
130
+ CreatePostInput = orm.input(Post, include=["title", "body", "author_id"])
131
+ CreateTagInput = orm.input(Tag, include=["name"])
132
+ TagRef = orm.ref(Tag, create=CreateTagInput, delete=True)
133
+
134
+ @strawberry.type
135
+ class Mutation:
136
+ @strawberry.mutation
137
+ def create_post(self, input: CreatePostInput) -> PostType:
138
+ post = Post(title=input.title, body=input.body, author_id=input.author_id)
139
+ ...
140
+ return post
141
+
142
+ @strawberry.mutation
143
+ def set_post_tags(self, post_id: int, tags: list[TagRef]) -> PostType:
144
+ post = ...
145
+ orm.apply_ref_list(post, "tags", tags)
146
+ return post
147
+
148
+ # Recursive node mutation — creates a post with nested relations in one call
149
+ create_node = orm.mutations.create_node()
150
+ update_node = orm.mutations.update_node()
151
+
152
+ # -- Schema ------------------------------------------------------------------
153
+
154
+ @strawberry.type
155
+ class Query:
156
+ users: list[UserType] = orm.field()
157
+ posts: list[PostType] = orm.field()
158
+
159
+ schema = strawberry.Schema(
160
+ query=Query,
161
+ mutation=Mutation,
162
+ extensions=[orm.optimizer_extension()],
163
+ )
164
+ ```
165
+
166
+ That gives you:
167
+
168
+ ```graphql
169
+ # Filter posts by a related author's name, ordered by title
170
+ {
171
+ posts(
172
+ filter: {
173
+ all: [
174
+ { field: { isPublished: { exact: true } } }
175
+ { object: { author: { field: { name: { exact: "Alice" } } } } }
176
+ ]
177
+ }
178
+ order: [{ field: { title: ASC } }]
179
+ ) {
180
+ title
181
+ author { name }
182
+ tags { name }
183
+ }
184
+ }
185
+
186
+ # Manage related tags on a post
187
+ mutation {
188
+ setPostTags(postId: 1, tags: [
189
+ { update: { id: "2" } }
190
+ { create: { name: "new-tag" } }
191
+ { unlink: { id: "3" } }
192
+ { delete: { id: "4" } }
193
+ ]) {
194
+ tags { id name }
195
+ }
196
+ }
197
+
198
+ # Create a post with nested author and tags in one recursive mutation
199
+ mutation {
200
+ createNode(input: {
201
+ post: {
202
+ title: "Hello"
203
+ body: "World"
204
+ author: { create: { name: "Alice", email: "alice@example.com" } }
205
+ tags: [{ create: { name: "python" } }]
206
+ }
207
+ }) { __typename }
208
+ }
209
+ ```
210
+
211
+ ---
212
+
213
+ ## Backends
214
+
215
+ | Backend | Constructor | Notes |
216
+ | --- | --- | --- |
217
+ | Django | `StrawberryORM("django")` | Uses Django querysets directly. |
218
+ | SQLAlchemy | `StrawberryORM("sqlalchemy", dialect="...", session_getter=...)` | Requires a `Session` or `AsyncSession` at resolve time. |
219
+ | Tortoise | `StrawberryORM("tortoise")` | Async ORM; use async Strawberry execution. |
220
+
221
+ - **Django** — sync and async schema execution both work. Custom async resolvers that touch the ORM directly still need `sync_to_async(...)`.
222
+ - **SQLAlchemy** — the session is resolved from `session_getter`, `info.context["session"]`, `info.context.session`, or `info.context.get_session()`. Both sync and async sessions are supported.
223
+ - **Tortoise** — async-first. Use `await` in resolvers and mutations.
224
+
225
+ <details>
226
+ <summary>Backend options reference</summary>
227
+
228
+ Shared options:
229
+
230
+ | Option | Default | Meaning |
231
+ | --- | --- | --- |
232
+ | `default_query_limit` | `None` | Default limit for auto-generated list queries. |
233
+ | `exclude_sensitive_fields` | `True` | Excludes sensitive-looking fields from generated input/filter/order types. |
234
+ | `warn_sensitive` | `True` | Warns when sensitive-looking fields are exposed on output types. |
235
+ | `max_filter_depth` | `10` | Caps recursive filter nesting. |
236
+ | `max_filter_branches` | `50` | Caps `all` / `any` / `oneOf` branch count. |
237
+ | `max_in_list_size` | `500` | Caps `inList` / `notInList` size. |
238
+ | `enable_regex_filters` | `False` | Enables `regex` and `iRegex` string lookups. |
239
+
240
+ SQLAlchemy-only:
241
+
242
+ | Option | Default | Meaning |
243
+ | --- | --- | --- |
244
+ | `dialect` | `"postgresql"` | SQLAlchemy dialect. |
245
+ | `session_getter` | `None` | Callable returning the session for the current request. |
246
+ | `filter_overrides` | `{}` | Maps Python types to custom lookup input types. |
247
+
248
+ </details>
249
+
250
+ ---
251
+
252
+ ## Defining Types
253
+
254
+ ### `@orm.type(Model)`
255
+
256
+ ```python
257
+ from strawberry_orm import auto
258
+
259
+ @orm.type(User)
260
+ class UserType:
261
+ id: auto
262
+ name: auto
263
+ email: auto
264
+ ```
265
+
266
+ `auto` is an alias for `strawberry.auto`. The backend inspects the model and resolves the Python type for each field.
267
+
268
+ Keyword arguments: `include`, `exclude`, `name`, `filters`, `order`.
269
+
270
+ ```python
271
+ @orm.type(User, exclude=["password_hash", "api_key"], name="PublicUser")
272
+ class PublicUserType:
273
+ id: auto
274
+ name: auto
275
+ email: auto
276
+ ```
277
+
278
+ ### Relations
279
+
280
+ Reference other generated types directly. The backend auto-generates resolvers for relationship fields:
281
+
282
+ ```python
283
+ @orm.type(Post)
284
+ class PostType:
285
+ id: auto
286
+ title: auto
287
+ tags: list[TagType]
288
+ ```
289
+
290
+ If the nested type carries `filters` and/or `order`, list relations expose those arguments automatically.
291
+
292
+ ### List Fields and Explicit Resolvers
293
+
294
+ `orm.field()` builds a list resolver from the model attached to the return type:
295
+
296
+ ```python
297
+ @strawberry.type
298
+ class Query:
299
+ users: list[UserType] = orm.field()
300
+ ```
301
+
302
+ For custom scoping, return a backend query object from a regular Strawberry resolver:
303
+
304
+ ```python
305
+ @strawberry.type
306
+ class Query:
307
+ @strawberry.field
308
+ def active_users(self, info: strawberry.types.Info) -> list[UserType]:
309
+ return select(User).where(User.is_active.is_(True)) # SQLAlchemy
310
+ # return User.objects.filter(is_active=True) # Django
311
+ # return User.filter(is_active=True) # Tortoise
312
+ ```
313
+
314
+ ### Type-Level Queryset Scoping
315
+
316
+ Define a `get_queryset` classmethod to scope the model query centrally:
317
+
318
+ ```python
319
+ @orm.type(Post)
320
+ class PublishedPostType:
321
+ id: auto
322
+ title: auto
323
+
324
+ @classmethod
325
+ def get_queryset(cls, qs, info):
326
+ return qs.filter(is_published=True)
327
+ ```
328
+
329
+ Useful for soft-delete filtering, multi-tenant scoping, and authorization-aware model filters.
330
+
331
+ ### Custom Strawberry Fields
332
+
333
+ Mix generated fields with plain Strawberry fields:
334
+
335
+ ```python
336
+ @orm.type(User)
337
+ class UserType:
338
+ id: auto
339
+ name: auto
340
+ email: auto
341
+
342
+ @strawberry.field
343
+ def display_name(self) -> str:
344
+ return f"{self.name} <{self.email}>"
345
+ ```
346
+
347
+ ### `orm.input(Model)` and `orm.partial(Model)`
348
+
349
+ Generate input types from model metadata:
350
+
351
+ ```python
352
+ CreateUserInput = orm.input(User, include=["name", "email"])
353
+ UpdateUserInput = orm.partial(User, include=["name", "email"])
354
+ ```
355
+
356
+ `input()` and `partial()` share the same signature: `include`, `exclude`, `exclude_pk` (default `True`), `name`. Fields are optional (defaulting to `strawberry.UNSET`), skip relations, exclude primary keys by default, and exclude sensitive-looking fields unless explicitly included.
357
+
358
+ ---
359
+
360
+ ## Filters and Ordering
361
+
362
+ ### Filters
363
+
364
+ Generate a filter input and attach it to a type:
365
+
366
+ ```python
367
+ UserFilter = orm.filter(User)
368
+
369
+ @orm.type(User, filters=UserFilter)
370
+ class UserType:
371
+ id: auto
372
+ name: auto
373
+ email: auto
374
+ ```
375
+
376
+ List fields returning `UserType` then accept a `filter` argument:
377
+
378
+ ```graphql
379
+ {
380
+ users(filter: { field: { name: { exact: "Alice" } } }) {
381
+ id
382
+ name
383
+ }
384
+ }
385
+ ```
386
+
387
+ #### Filter Shape
388
+
389
+ Filters are recursive `@oneOf` trees supporting `field`, `all`, `any`, `not`, and `oneOf`:
390
+
391
+ ```graphql
392
+ # OR
393
+ { users(filter: { any: [
394
+ { field: { name: { exact: "Alice" } } }
395
+ { field: { name: { exact: "Bob" } } }
396
+ ] }) { name } }
397
+
398
+ # AND
399
+ { posts(filter: { all: [
400
+ { field: { authorId: { exact: 1 } } }
401
+ { field: { isPublished: { exact: true } } }
402
+ ] }) { title } }
403
+
404
+ # NOT
405
+ { users(filter: {
406
+ not: { field: { email: { contains: "example.com" } } }
407
+ }) { name } }
408
+ ```
409
+
410
+ <details>
411
+ <summary>Built-in lookup types</summary>
412
+
413
+ `StringLookup`, `BooleanLookup`, `IDLookup`, `IntComparisonLookup`, `FloatComparisonLookup`, `DateComparisonLookup`, `TimeComparisonLookup`, `DateTimeComparisonLookup`
414
+
415
+ Typical string lookups: `exact`, `neq`, `contains`, `iContains`, `startsWith`, `iStartsWith`, `endsWith`, `iEndsWith`, `inList`, `notInList`, `isNull`.
416
+
417
+ Regex lookups (`regex`, `iRegex`) are disabled by default. Enable with `enable_regex_filters=True`.
418
+
419
+ </details>
420
+
421
+ #### Object Traversal
422
+
423
+ When filters are registered for related models, the generated filter gains an `object` key for filtering by conditions on related objects:
424
+
425
+ ```python
426
+ UserFilter = orm.filter(User)
427
+ PostFilter = orm.filter(Post) # Post has an "author" relation to User
428
+ ```
429
+
430
+ ```graphql
431
+ {
432
+ posts(filter: {
433
+ object: { author: { field: { name: { exact: "Alice" } } } }
434
+ }) { title }
435
+ }
436
+ ```
437
+
438
+ Object traversal composes with boolean operators and supports multi-level nesting when intermediate models also have registered filters:
439
+
440
+ ```graphql
441
+ # Comments on posts written by Alice
442
+ {
443
+ comments(filter: {
444
+ object: { post: {
445
+ object: { author: { field: { name: { exact: "Alice" } } } }
446
+ } }
447
+ }) { body }
448
+ }
449
+ ```
450
+
451
+ The `object` type is `@oneOf`. Relations only appear in `object` if their target model already has a registered filter at the time `orm.filter()` is called -- register leaf models first.
452
+
453
+ #### Filter Projection
454
+
455
+ Pass `project={...}` to control which relations appear in `object` and how deep traversal can go:
456
+
457
+ ```python
458
+ UserFilter = orm.filter(User)
459
+ TagFilter = orm.filter(Tag)
460
+ CommentFilter = orm.filter(Comment)
461
+
462
+ PostFilter = orm.filter(Post, project={"author": {}}) # only author, not tags/comments
463
+ ```
464
+
465
+ Sub-project dicts control nested traversal. `{}` means "include as a leaf" (no further object traversal). A non-empty dict lists reachable relations:
466
+
467
+ ```python
468
+ CommentFilter = orm.filter(Comment, project={
469
+ "post": {"author": {}}, # Comment -> post -> author (but not post -> tags)
470
+ })
471
+ ```
472
+
473
+ | `project` value | Behavior |
474
+ | --- | --- |
475
+ | `None` (default) | Auto-include all relations with registered filters |
476
+ | `{}` | No `object` type (scalar lookups only) |
477
+ | `{"rel": {}}` | Include `rel` as a leaf |
478
+ | `{"rel": {"nested": {}}}` | Include `rel`, allow traversal to `nested` from it |
479
+
480
+ Projected filters are cached internally and do not overwrite the global filter registry.
481
+
482
+ ### Ordering
483
+
484
+ ```python
485
+ UserOrder = orm.order(User)
486
+ ```
487
+
488
+ Each order entry is a `@oneOf` input with a `field` key (for scalar columns) or an `object` key (for related models). Position in the list determines tie-break priority:
489
+
490
+ ```graphql
491
+ {
492
+ users(order: [{ field: { name: ASC } }, { field: { email: DESC } }]) {
493
+ name
494
+ email
495
+ }
496
+ }
497
+ ```
498
+
499
+ Supported values: `ASC`, `ASC_NULLS_FIRST`, `ASC_NULLS_LAST`, `DESC`, `DESC_NULLS_FIRST`, `DESC_NULLS_LAST`.
500
+
501
+ #### Order by Related Object
502
+
503
+ When order types are registered for related models, the generated order gains an `object` key that lets you sort by fields on related objects — mirroring the [filter object traversal](#object-traversal) structure:
504
+
505
+ ```graphql
506
+ {
507
+ posts(order: [
508
+ { object: { author: { field: { name: ASC } } } }
509
+ { field: { title: DESC } }
510
+ ]) {
511
+ title
512
+ }
513
+ }
514
+ ```
515
+
516
+ Registration order matters: define related orders *before* the parent (e.g. `orm.order(User)` before `orm.order(Post)`).
517
+
518
+ ---
519
+
520
+ ## Mutations
521
+
522
+ Write plain `@strawberry.mutation` resolvers and use `strawberry-orm` for generated input types:
523
+
524
+ ```python
525
+ CreatePostInput = orm.input(Post, include=["title", "body", "author_id"])
526
+
527
+ @strawberry.type
528
+ class Mutation:
529
+ @strawberry.mutation
530
+ def create_post(self, info: strawberry.types.Info, input: CreatePostInput) -> PostType:
531
+ post = Post(title=input.title, body=input.body, author_id=input.author_id)
532
+ ...
533
+ return post
534
+ ```
535
+
536
+ ### Related List Inputs (`orm.ref`)
537
+
538
+ `orm.ref(...)` generates a `@oneOf` input for managing related lists:
539
+
540
+ ```python
541
+ CreateTagInput = orm.input(Tag, include=["name"])
542
+
543
+ @strawberry.input
544
+ class UpdateTagInput:
545
+ id: strawberry.ID
546
+ name: str | None = strawberry.UNSET
547
+
548
+ TagRef = orm.ref(Tag, create=CreateTagInput, update=UpdateTagInput, unlink=True, delete=True)
549
+ ```
550
+
551
+ Each ref is a `@oneOf` with these keys:
552
+
553
+ - `update` — link an existing object by ID, or update its fields. Always present (an ID-only input is auto-generated if no custom `update` type is provided).
554
+ - `create` — create a new related object (present when `create=` is provided).
555
+ - `unlink` — remove the object from the relation without deleting it (present when `unlink=True`).
556
+ - `delete` — hard-delete the related row (present when `delete=True`).
557
+
558
+ All list mutations use **patch semantics**: only the items you mention are affected; existing related objects not listed are left untouched.
559
+
560
+ Apply ref operations with `orm.apply_ref_list(parent, "relation_name", refs, info)`. An optional `authorize` callback `(action, model, obj_id, info) -> bool` can be provided for per-operation authorization.
561
+
562
+ ```graphql
563
+ mutation {
564
+ setPostTags(postId: 1, tags: [
565
+ { update: { id: "2" } }
566
+ { update: { id: "1", name: "python3" } }
567
+ { create: { name: "new-tag" } }
568
+ { unlink: { id: "3" } }
569
+ { delete: { id: "4" } }
570
+ ]) {
571
+ tags { id name }
572
+ }
573
+ }
574
+ ```
575
+
576
+ > **Note:** Whether the order of items in the list affects the final ordering of the relation is an implementation detail that each backend must maintain.
577
+
578
+ ### Recursive Node Mutations
579
+
580
+ `orm.mutations.create_node()` and `orm.mutations.update_node()` generate catch-all Relay `Node` mutations with recursive nested inputs:
581
+
582
+ ```python
583
+ @orm.type(Post)
584
+ class PostNode(relay.Node):
585
+ id: relay.NodeID[int]
586
+ title: auto
587
+ body: auto
588
+
589
+ @strawberry.type
590
+ class Mutation:
591
+ create_node = orm.mutations.create_node()
592
+ update_node = orm.mutations.update_node()
593
+ ```
594
+
595
+ ```graphql
596
+ mutation {
597
+ createNode(input: {
598
+ post: {
599
+ title: "Hello"
600
+ body: "World"
601
+ author: { create: { name: "Alice", email: "alice@example.com" } }
602
+ tags: [{ create: { name: "python" } }]
603
+ }
604
+ }) { __typename }
605
+ }
606
+ ```
607
+
608
+ List relations are flat arrays of ref operations (same `@oneOf` shape as `orm.ref`). Patch semantics apply — only mentioned items are affected.
609
+
610
+ Generate only the input types (without the resolver) via `orm.mutations.create_node_input()` and `orm.mutations.update_node_input()`.
611
+
612
+ <details>
613
+ <summary>Mutation projection and policy config</summary>
614
+
615
+ Pass `project={...}` to restrict recursion depth and configure relation semantics:
616
+
617
+ ```python
618
+ project = {
619
+ "post": {
620
+ "author": {
621
+ "_meta": {"onReplace": ["DISCONNECT", "DELETE"]},
622
+ },
623
+ "comments": {
624
+ "author": {"_meta": {"onReplace": ["DISCONNECT", "DELETE"]}},
625
+ },
626
+ "tags": {},
627
+ },
628
+ "comment": {
629
+ "author": {"_meta": {"onReplace": ["DISCONNECT", "DELETE"]}},
630
+ },
631
+ }
632
+
633
+ @strawberry.type
634
+ class Mutation:
635
+ create_node = orm.mutations.create_node(project=project)
636
+ update_node = orm.mutations.update_node(project=project)
637
+ ```
638
+
639
+ Rules:
640
+
641
+ - Root keys are model names (`post`, `comment`, ...).
642
+ - Nested keys are relation names on that model.
643
+ - `_meta` configures behavior for that relation subtree.
644
+ - Omitted relations still appear as shallow inputs (one more level, then stop).
645
+
646
+ `_meta` supports:
647
+
648
+ - `onReplace` — `"DISCONNECT"` or `"DELETE"`, or an array of both to expose a choice. Controls what happens to the previous object when replacing a singular (FK) relation. Default: `DISCONNECT`.
649
+
650
+ Values can be a single string (fixes behavior, omits the GraphQL field) or an array of strings (exposes a choice to the caller).
651
+
652
+ </details>
653
+
654
+ ---
655
+
656
+ ## Relay Integration
657
+
658
+ `strawberry-orm` works with [Strawberry's Relay support](https://strawberry.rocks/docs/guides/relay) for cursor-based pagination and global node identification.
659
+
660
+ ### Relay Node Types
661
+
662
+ Extend `relay.Node` instead of a plain Strawberry type. Use `relay.NodeID` for the id field:
663
+
664
+ ```python
665
+ from strawberry import relay
666
+ from strawberry_orm import StrawberryORM, auto
667
+
668
+ orm = StrawberryORM("sqlalchemy", dialect="postgresql", session_getter=...)
669
+
670
+ UserFilter = orm.filter(User)
671
+ UserOrder = orm.order(User)
672
+
673
+ @orm.type(User, filters=UserFilter, order=UserOrder)
674
+ class UserNode(relay.Node):
675
+ id: relay.NodeID[int]
676
+ name: auto
677
+ email: auto
678
+ ```
679
+
680
+ ### Connection Fields
681
+
682
+ Use `orm.connection()` with `ORMListConnection` to create paginated connection fields. Filters and ordering from the node type are automatically wired in:
683
+
684
+ ```python
685
+ from collections.abc import Iterable
686
+ from strawberry_orm.relay import ORMListConnection
687
+
688
+ @strawberry.type
689
+ class Query:
690
+ @orm.connection(ORMListConnection[UserNode])
691
+ def users_connection(self) -> Iterable[UserNode]:
692
+ return orm.get_default_queryset(User)
693
+ ```
694
+
695
+ This gives you:
696
+
697
+ ```graphql
698
+ {
699
+ usersConnection(
700
+ filter: { field: { email: { contains: "example.com" } } }
701
+ order: [{ field: { name: DESC } }]
702
+ first: 10
703
+ after: "YXJyYXljb25uZWN0aW9uOjk="
704
+ ) {
705
+ edges {
706
+ cursor
707
+ node { name email }
708
+ }
709
+ pageInfo {
710
+ hasNextPage
711
+ hasPreviousPage
712
+ startCursor
713
+ endCursor
714
+ }
715
+ }
716
+ }
717
+ ```
718
+
719
+ Filters and ordering are applied *before* pagination, so the connection always slices from a correctly filtered and sorted result set.
720
+
721
+ `orm.connection()` accepts the same keyword arguments as `relay.connection()` — `name`, `description`, `deprecation_reason`, `extensions`, and `max_results`.
722
+
723
+ ### Node Mutations
724
+
725
+ `orm.mutations.create_node()` and `orm.mutations.update_node()` generate catch-all Relay Node mutations with recursive nested inputs. See [Recursive Node Mutations](#recursive-node-mutations) for full documentation.
726
+
727
+ ---
728
+
729
+ ## Query Optimization
730
+
731
+ Add the optimizer extension to your schema:
732
+
733
+ ```python
734
+ schema = strawberry.Schema(
735
+ query=Query,
736
+ mutation=Mutation,
737
+ extensions=[orm.optimizer_extension()],
738
+ )
739
+ ```
740
+
741
+ The optimizer executes backend query objects returned by resolvers, eager-loads relations based on the GraphQL selection set, applies field-level hints, and honors `get_queryset` hooks.
742
+
743
+ ### Field Hints
744
+
745
+ Inside `@orm.type(...)`, `orm.field(...)` attaches optimizer metadata:
746
+
747
+ ```python
748
+ @orm.type(Post)
749
+ class PostType:
750
+ id: auto
751
+ title: auto
752
+ tags: list[TagType] = orm.field(load=["author"])
753
+ body: auto = orm.field(only=["id", "title", "body"])
754
+ ```
755
+
756
+ | Argument | Meaning |
757
+ | --- | --- |
758
+ | `load=[...]` | Extra eager-load paths. |
759
+ | `load=callable` | Custom queryset for a related field (see below). |
760
+ | `only=[...]` | Restrict loaded columns. |
761
+ | `compute={...}` | Computed-column hints for the optimizer store. |
762
+ | `disable_optimization=True` | Skip optimization for that field. |
763
+ | `description="..."` | Forward a field description to Strawberry. |
764
+
765
+ ### Custom Querysets on Related Fields (`load=callable`)
766
+
767
+ When `load` is a callable, it receives the default queryset and returns a modified one:
768
+
769
+ ```python
770
+ @orm.type(User)
771
+ class UserType:
772
+ id: auto
773
+ name: auto
774
+ posts: list[PostType] = orm.field(
775
+ load=lambda qs: qs.filter(is_published=True)
776
+ )
777
+ ```
778
+
779
+ This composes with `get_queryset` (type-level first, then field-level). The optimizer handles batching to avoid N+1 queries.
780
+
781
+ ### Field Permissions
782
+
783
+ ```python
784
+ from strawberry_orm import make_field
785
+
786
+ @orm.type(User)
787
+ class UserType:
788
+ id: auto
789
+ name: auto
790
+ email: auto = make_field(permission_classes=[IsAuthenticated])
791
+ ```
792
+
793
+ ---
794
+
795
+ ## Async Usage
796
+
797
+ `strawberry-orm` supports both sync and async execution. The same schema code works everywhere -- the only difference is how you call ORM APIs in resolvers:
798
+
799
+ | Backend | Pattern |
800
+ | --- | --- |
801
+ | Django | Sync by default. Wrap direct ORM calls with `sync_to_async(...)` in async resolvers. |
802
+ | SQLAlchemy | Pass a sync `Session` or `AsyncSession` via `session_getter`. Both work transparently. |
803
+ | Tortoise | Async-first. Use `async def` resolvers and `await` ORM calls. |
804
+
805
+ ```python
806
+ # Tortoise example
807
+ @strawberry.type
808
+ class Query:
809
+ @strawberry.field
810
+ async def users(self) -> list[UserType]:
811
+ return await User.all()
812
+
813
+ @strawberry.type
814
+ class Mutation:
815
+ @strawberry.mutation
816
+ async def create_post(self, input: CreatePostInput) -> PostType:
817
+ return await Post.create(title=input.title, body=input.body)
818
+ ```
819
+
820
+ `apply_ref_list` is sync for Django/sync-SQLAlchemy and awaitable for Tortoise/async-SQLAlchemy.
821
+
822
+ ---
823
+
824
+ ## Security
825
+
826
+ `strawberry-orm` has safety-focused defaults, but you still need to make deliberate schema choices.
827
+
828
+ **Defaults:**
829
+
830
+ - `orm.input()`, `orm.filter()`, and `orm.order()` exclude sensitive-looking fields (`password_hash`, `api_key`, `role`, `is_admin`, etc.)
831
+ - String regex filters are disabled by default
832
+ - Filter depth, branch count, and `inList` size are capped
833
+ - `orm.ref()` provides explicit `unlink` (remove from relation) and `delete` (hard-delete) operations — both opt-in via `unlink=True` and `delete=True`
834
+
835
+ **Your responsibility:**
836
+
837
+ - `orm.type()` does not auto-hide sensitive output fields — use `exclude=[...]` or permission classes
838
+ - List queries are unbounded unless you set `default_query_limit`
839
+ - `apply_ref_list()` only enforces authorization if you provide an `authorize` callback
840
+ - GraphQL introspection, auth, and query-complexity limits are your application's concern
841
+
842
+ A production-oriented configuration:
843
+
844
+ ```python
845
+ orm = StrawberryORM(
846
+ "sqlalchemy",
847
+ dialect="postgresql",
848
+ session_getter=lambda info: info.context["session"],
849
+ default_query_limit=100,
850
+ max_filter_depth=8,
851
+ max_filter_branches=25,
852
+ max_in_list_size=200,
853
+ )
854
+ ```
855
+
856
+ ---
857
+
858
+ ## Public Exports
859
+
860
+ `StrawberryORM`, `auto`, `make_field`, `make_ref_type`, `Ordering`, `FieldDefinition`, `FieldHints`, `OptimizerExtension`, `OptimizerStore`, `UNSET`, and the built-in lookup input classes from `strawberry_orm.filters`.
861
+
862
+ ## License
863
+
864
+ MIT