strawberry-orm 0.4.0__tar.gz → 0.5.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.5.0/PKG-INFO +866 -0
  2. strawberry_orm-0.5.0/README.md +831 -0
  3. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/pyproject.toml +1 -1
  4. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/backends/_base.py +57 -15
  5. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/backends/django.py +37 -7
  6. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/backends/sqlalchemy.py +47 -6
  7. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/backends/tortoise.py +29 -3
  8. strawberry_orm-0.4.0/PKG-INFO +0 -1025
  9. strawberry_orm-0.4.0/README.md +0 -990
  10. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/__init__.py +0 -0
  11. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/_async.py +0 -0
  12. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/backends/__init__.py +0 -0
  13. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/backends/protocol.py +0 -0
  14. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/core.py +0 -0
  15. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/fields.py +0 -0
  16. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/filters.py +0 -0
  17. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/mutations.py +0 -0
  18. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/optimizer/__init__.py +0 -0
  19. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/optimizer/extension.py +0 -0
  20. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/optimizer/store.py +0 -0
  21. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/py.typed +0 -0
  22. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/relay/__init__.py +0 -0
  23. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/relay/connection.py +0 -0
  24. {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/types.py +0 -0
@@ -0,0 +1,866 @@
1
+ Metadata-Version: 2.4
2
+ Name: strawberry-orm
3
+ Version: 0.5.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
+ { id: "2" }
190
+ { create: { name: "new-tag" } }
191
+ { delete: { id: "3" } }
192
+ ]) {
193
+ tags { id name }
194
+ }
195
+ }
196
+
197
+ # Create a post with nested author and tags in one recursive mutation
198
+ mutation {
199
+ createNode(input: {
200
+ post: {
201
+ title: "Hello"
202
+ body: "World"
203
+ author: { create: { name: "Alice", email: "alice@example.com" } }
204
+ tags: { items: [{ create: { name: "python" } }], mode: REPLACE }
205
+ }
206
+ }) { __typename }
207
+ }
208
+ ```
209
+
210
+ ---
211
+
212
+ ## Backends
213
+
214
+ | Backend | Constructor | Notes |
215
+ | --- | --- | --- |
216
+ | Django | `StrawberryORM("django")` | Uses Django querysets directly. |
217
+ | SQLAlchemy | `StrawberryORM("sqlalchemy", dialect="...", session_getter=...)` | Requires a `Session` or `AsyncSession` at resolve time. |
218
+ | Tortoise | `StrawberryORM("tortoise")` | Async ORM; use async Strawberry execution. |
219
+
220
+ - **Django** — sync and async schema execution both work. Custom async resolvers that touch the ORM directly still need `sync_to_async(...)`.
221
+ - **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.
222
+ - **Tortoise** — async-first. Use `await` in resolvers and mutations.
223
+
224
+ <details>
225
+ <summary>Backend options reference</summary>
226
+
227
+ Shared options:
228
+
229
+ | Option | Default | Meaning |
230
+ | --- | --- | --- |
231
+ | `default_query_limit` | `None` | Default limit for auto-generated list queries. |
232
+ | `exclude_sensitive_fields` | `True` | Excludes sensitive-looking fields from generated input/filter/order types. |
233
+ | `warn_sensitive` | `True` | Warns when sensitive-looking fields are exposed on output types. |
234
+ | `hard_delete_refs` | `False` | Makes `apply_ref_list(..., delete=...)` delete rows instead of unlinking. |
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
547
+
548
+ TagRef = orm.ref(Tag, create=CreateTagInput, update=UpdateTagInput, delete=True)
549
+ ```
550
+
551
+ Each ref can be one of `{ id }` (link), `{ create }`, `{ update }`, or `{ delete }` (unlink, or hard-delete if `hard_delete_refs=True`).
552
+
553
+ Apply ref operations with `orm.apply_ref_list(parent, "relation_name", refs, info)`. It supports `mode="replace"` (default) and `mode="patch"`, and an optional `authorize` callback `(action, model, obj_id, info) -> bool`.
554
+
555
+ ```graphql
556
+ mutation {
557
+ setPostTags(postId: 1, tags: [
558
+ { id: "2" }
559
+ { update: { id: "1", name: "python3" } }
560
+ { create: { name: "new-tag" } }
561
+ { delete: { id: "3" } }
562
+ ]) {
563
+ tags { id name }
564
+ }
565
+ }
566
+ ```
567
+
568
+ ### Recursive Node Mutations
569
+
570
+ `orm.mutations.create_node()` and `orm.mutations.update_node()` generate catch-all Relay `Node` mutations with recursive nested inputs:
571
+
572
+ ```python
573
+ @orm.type(Post)
574
+ class PostNode(relay.Node):
575
+ id: relay.NodeID[int]
576
+ title: auto
577
+ body: auto
578
+
579
+ @strawberry.type
580
+ class Mutation:
581
+ create_node = orm.mutations.create_node()
582
+ update_node = orm.mutations.update_node()
583
+ ```
584
+
585
+ ```graphql
586
+ mutation {
587
+ createNode(input: {
588
+ post: {
589
+ title: "Hello"
590
+ body: "World"
591
+ author: { create: { name: "Alice", email: "alice@example.com" } }
592
+ tags: {
593
+ items: [{ create: { name: "python" } }]
594
+ mode: REPLACE
595
+ onRemove: DELETE
596
+ }
597
+ }
598
+ }) { __typename }
599
+ }
600
+ ```
601
+
602
+ Generate only the input types (without the resolver) via `orm.mutations.create_node_input()` and `orm.mutations.update_node_input()`.
603
+
604
+ <details>
605
+ <summary>Mutation projection and policy config</summary>
606
+
607
+ Pass `project={...}` to restrict recursion depth and configure relation semantics:
608
+
609
+ ```python
610
+ project = {
611
+ "post": {
612
+ "author": {
613
+ "_meta": {"onReplace": ["DISCONNECT", "DELETE"]},
614
+ },
615
+ "comments": {
616
+ "_meta": {
617
+ "mode": ["PATCH", "REPLACE"],
618
+ "onRemove": ["DISCONNECT", "DELETE"],
619
+ },
620
+ "author": {"_meta": {"onReplace": ["DISCONNECT", "DELETE"]}},
621
+ },
622
+ "tags": {
623
+ "_meta": {"mode": "REPLACE", "onRemove": "DELETE"},
624
+ },
625
+ },
626
+ "comment": {
627
+ "author": {"_meta": {"onReplace": ["DISCONNECT", "DELETE"]}},
628
+ },
629
+ }
630
+
631
+ @strawberry.type
632
+ class Mutation:
633
+ create_node = orm.mutations.create_node(project=project)
634
+ update_node = orm.mutations.update_node(project=project)
635
+ ```
636
+
637
+ Rules:
638
+
639
+ - Root keys are model names (`post`, `comment`, ...).
640
+ - Nested keys are relation names on that model.
641
+ - `_meta` configures behavior for that relation subtree.
642
+ - Omitted relations still appear as shallow inputs (one more level, then stop).
643
+
644
+ `_meta` supports:
645
+
646
+ | Key | Values | Meaning |
647
+ | --- | --- | --- |
648
+ | `mode` | `PATCH`, `REPLACE` | List relation merge strategy. |
649
+ | `onRemove` | `DISCONNECT`, `DELETE` | Action for removed items from a list relation. |
650
+ | `onReplace` | `DISCONNECT`, `DELETE` | Action for the previous object when replacing a singular relation. |
651
+
652
+ Values can be a single string (fixes behavior, omits the GraphQL field) or an array of strings (exposes a choice to the caller). Defaults when omitted: `mode=PATCH`, `onRemove=DISCONNECT`, `onReplace=DISCONNECT`.
653
+
654
+ </details>
655
+
656
+ ---
657
+
658
+ ## Relay Integration
659
+
660
+ `strawberry-orm` works with [Strawberry's Relay support](https://strawberry.rocks/docs/guides/relay) for cursor-based pagination and global node identification.
661
+
662
+ ### Relay Node Types
663
+
664
+ Extend `relay.Node` instead of a plain Strawberry type. Use `relay.NodeID` for the id field:
665
+
666
+ ```python
667
+ from strawberry import relay
668
+ from strawberry_orm import StrawberryORM, auto
669
+
670
+ orm = StrawberryORM("sqlalchemy", dialect="postgresql", session_getter=...)
671
+
672
+ UserFilter = orm.filter(User)
673
+ UserOrder = orm.order(User)
674
+
675
+ @orm.type(User, filters=UserFilter, order=UserOrder)
676
+ class UserNode(relay.Node):
677
+ id: relay.NodeID[int]
678
+ name: auto
679
+ email: auto
680
+ ```
681
+
682
+ ### Connection Fields
683
+
684
+ Use `orm.connection()` with `ORMListConnection` to create paginated connection fields. Filters and ordering from the node type are automatically wired in:
685
+
686
+ ```python
687
+ from collections.abc import Iterable
688
+ from strawberry_orm.relay import ORMListConnection
689
+
690
+ @strawberry.type
691
+ class Query:
692
+ @orm.connection(ORMListConnection[UserNode])
693
+ def users_connection(self) -> Iterable[UserNode]:
694
+ return orm.get_default_queryset(User)
695
+ ```
696
+
697
+ This gives you:
698
+
699
+ ```graphql
700
+ {
701
+ usersConnection(
702
+ filter: { field: { email: { contains: "example.com" } } }
703
+ order: [{ field: { name: DESC } }]
704
+ first: 10
705
+ after: "YXJyYXljb25uZWN0aW9uOjk="
706
+ ) {
707
+ edges {
708
+ cursor
709
+ node { name email }
710
+ }
711
+ pageInfo {
712
+ hasNextPage
713
+ hasPreviousPage
714
+ startCursor
715
+ endCursor
716
+ }
717
+ }
718
+ }
719
+ ```
720
+
721
+ Filters and ordering are applied *before* pagination, so the connection always slices from a correctly filtered and sorted result set.
722
+
723
+ `orm.connection()` accepts the same keyword arguments as `relay.connection()` — `name`, `description`, `deprecation_reason`, `extensions`, and `max_results`.
724
+
725
+ ### Node Mutations
726
+
727
+ `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.
728
+
729
+ ---
730
+
731
+ ## Query Optimization
732
+
733
+ Add the optimizer extension to your schema:
734
+
735
+ ```python
736
+ schema = strawberry.Schema(
737
+ query=Query,
738
+ mutation=Mutation,
739
+ extensions=[orm.optimizer_extension()],
740
+ )
741
+ ```
742
+
743
+ 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.
744
+
745
+ ### Field Hints
746
+
747
+ Inside `@orm.type(...)`, `orm.field(...)` attaches optimizer metadata:
748
+
749
+ ```python
750
+ @orm.type(Post)
751
+ class PostType:
752
+ id: auto
753
+ title: auto
754
+ tags: list[TagType] = orm.field(load=["author"])
755
+ body: auto = orm.field(only=["id", "title", "body"])
756
+ ```
757
+
758
+ | Argument | Meaning |
759
+ | --- | --- |
760
+ | `load=[...]` | Extra eager-load paths. |
761
+ | `load=callable` | Custom queryset for a related field (see below). |
762
+ | `only=[...]` | Restrict loaded columns. |
763
+ | `compute={...}` | Computed-column hints for the optimizer store. |
764
+ | `disable_optimization=True` | Skip optimization for that field. |
765
+ | `description="..."` | Forward a field description to Strawberry. |
766
+
767
+ ### Custom Querysets on Related Fields (`load=callable`)
768
+
769
+ When `load` is a callable, it receives the default queryset and returns a modified one:
770
+
771
+ ```python
772
+ @orm.type(User)
773
+ class UserType:
774
+ id: auto
775
+ name: auto
776
+ posts: list[PostType] = orm.field(
777
+ load=lambda qs: qs.filter(is_published=True)
778
+ )
779
+ ```
780
+
781
+ This composes with `get_queryset` (type-level first, then field-level). The optimizer handles batching to avoid N+1 queries.
782
+
783
+ ### Field Permissions
784
+
785
+ ```python
786
+ from strawberry_orm import make_field
787
+
788
+ @orm.type(User)
789
+ class UserType:
790
+ id: auto
791
+ name: auto
792
+ email: auto = make_field(permission_classes=[IsAuthenticated])
793
+ ```
794
+
795
+ ---
796
+
797
+ ## Async Usage
798
+
799
+ `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:
800
+
801
+ | Backend | Pattern |
802
+ | --- | --- |
803
+ | Django | Sync by default. Wrap direct ORM calls with `sync_to_async(...)` in async resolvers. |
804
+ | SQLAlchemy | Pass a sync `Session` or `AsyncSession` via `session_getter`. Both work transparently. |
805
+ | Tortoise | Async-first. Use `async def` resolvers and `await` ORM calls. |
806
+
807
+ ```python
808
+ # Tortoise example
809
+ @strawberry.type
810
+ class Query:
811
+ @strawberry.field
812
+ async def users(self) -> list[UserType]:
813
+ return await User.all()
814
+
815
+ @strawberry.type
816
+ class Mutation:
817
+ @strawberry.mutation
818
+ async def create_post(self, input: CreatePostInput) -> PostType:
819
+ return await Post.create(title=input.title, body=input.body)
820
+ ```
821
+
822
+ `apply_ref_list` is sync for Django/sync-SQLAlchemy and awaitable for Tortoise/async-SQLAlchemy.
823
+
824
+ ---
825
+
826
+ ## Security
827
+
828
+ `strawberry-orm` has safety-focused defaults, but you still need to make deliberate schema choices.
829
+
830
+ **Defaults:**
831
+
832
+ - `orm.input()`, `orm.filter()`, and `orm.order()` exclude sensitive-looking fields (`password_hash`, `api_key`, `role`, `is_admin`, etc.)
833
+ - String regex filters are disabled by default
834
+ - Filter depth, branch count, and `inList` size are capped
835
+ - `orm.ref(..., delete=True)` unlinks by default; hard deletes require `hard_delete_refs=True`
836
+
837
+ **Your responsibility:**
838
+
839
+ - `orm.type()` does not auto-hide sensitive output fields — use `exclude=[...]` or permission classes
840
+ - List queries are unbounded unless you set `default_query_limit`
841
+ - `apply_ref_list()` only enforces authorization if you provide an `authorize` callback
842
+ - GraphQL introspection, auth, and query-complexity limits are your application's concern
843
+
844
+ A production-oriented configuration:
845
+
846
+ ```python
847
+ orm = StrawberryORM(
848
+ "sqlalchemy",
849
+ dialect="postgresql",
850
+ session_getter=lambda info: info.context["session"],
851
+ default_query_limit=100,
852
+ max_filter_depth=8,
853
+ max_filter_branches=25,
854
+ max_in_list_size=200,
855
+ )
856
+ ```
857
+
858
+ ---
859
+
860
+ ## Public Exports
861
+
862
+ `StrawberryORM`, `auto`, `make_field`, `make_ref_type`, `Ordering`, `FieldDefinition`, `FieldHints`, `OptimizerExtension`, `OptimizerStore`, `UNSET`, and the built-in lookup input classes from `strawberry_orm.filters`.
863
+
864
+ ## License
865
+
866
+ MIT