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.
- strawberry_orm-0.5.0/PKG-INFO +866 -0
- strawberry_orm-0.5.0/README.md +831 -0
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/pyproject.toml +1 -1
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/backends/_base.py +57 -15
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/backends/django.py +37 -7
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/backends/sqlalchemy.py +47 -6
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/backends/tortoise.py +29 -3
- strawberry_orm-0.4.0/PKG-INFO +0 -1025
- strawberry_orm-0.4.0/README.md +0 -990
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/__init__.py +0 -0
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/_async.py +0 -0
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/backends/__init__.py +0 -0
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/backends/protocol.py +0 -0
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/core.py +0 -0
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/fields.py +0 -0
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/filters.py +0 -0
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/mutations.py +0 -0
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/optimizer/__init__.py +0 -0
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/optimizer/extension.py +0 -0
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/optimizer/store.py +0 -0
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/py.typed +0 -0
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/relay/__init__.py +0 -0
- {strawberry_orm-0.4.0 → strawberry_orm-0.5.0}/src/strawberry_orm/relay/connection.py +0 -0
- {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
|