django-graphql 0.1.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.
- django_graphql-0.1.0/LICENSE +21 -0
- django_graphql-0.1.0/PKG-INFO +255 -0
- django_graphql-0.1.0/README.md +220 -0
- django_graphql-0.1.0/pyproject.toml +50 -0
- django_graphql-0.1.0/setup.cfg +4 -0
- django_graphql-0.1.0/src/django_graphql/__init__.py +21 -0
- django_graphql-0.1.0/src/django_graphql/checks.py +101 -0
- django_graphql-0.1.0/src/django_graphql/directives.py +56 -0
- django_graphql-0.1.0/src/django_graphql/naming.py +18 -0
- django_graphql-0.1.0/src/django_graphql/optimizer.py +94 -0
- django_graphql-0.1.0/src/django_graphql/py.typed +0 -0
- django_graphql-0.1.0/src/django_graphql/resolvers.py +116 -0
- django_graphql-0.1.0/src/django_graphql/schema.py +186 -0
- django_graphql-0.1.0/src/django_graphql/views.py +121 -0
- django_graphql-0.1.0/src/django_graphql.egg-info/PKG-INFO +255 -0
- django_graphql-0.1.0/src/django_graphql.egg-info/SOURCES.txt +22 -0
- django_graphql-0.1.0/src/django_graphql.egg-info/dependency_links.txt +1 -0
- django_graphql-0.1.0/src/django_graphql.egg-info/requires.txt +6 -0
- django_graphql-0.1.0/src/django_graphql.egg-info/top_level.txt +1 -0
- django_graphql-0.1.0/tests/test_checks.py +43 -0
- django_graphql-0.1.0/tests/test_optimizer.py +56 -0
- django_graphql-0.1.0/tests/test_queries.py +90 -0
- django_graphql-0.1.0/tests/test_schema.py +65 -0
- django_graphql-0.1.0/tests/test_views.py +93 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nehz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-graphql
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SDL-first GraphQL for Django: bind schema types to models with directives and get N+1-aware resolvers.
|
|
5
|
+
Author: nehz
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: django,graphql,sdl,schema-first,api
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Web Environment
|
|
10
|
+
Classifier: Framework :: Django
|
|
11
|
+
Classifier: Framework :: Django :: 4.2
|
|
12
|
+
Classifier: Framework :: Django :: 5.0
|
|
13
|
+
Classifier: Framework :: Django :: 5.1
|
|
14
|
+
Classifier: Framework :: Django :: 5.2
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: Django>=4.2
|
|
30
|
+
Requires-Dist: graphql-core<3.4,>=3.2
|
|
31
|
+
Provides-Extra: test
|
|
32
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
33
|
+
Requires-Dist: pytest-django>=4.5; extra == "test"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# django-graphql
|
|
37
|
+
|
|
38
|
+
**SDL-first GraphQL for Django.** Write your API contract in plain GraphQL SDL, bind
|
|
39
|
+
object types to Django models with a `@model` directive, and let `@queryset` / `@get`
|
|
40
|
+
generate resolvers that filter, page and join efficiently. No `DjangoObjectType`
|
|
41
|
+
classes, no Python mirror of your schema: the `.graphql` text *is* the schema.
|
|
42
|
+
|
|
43
|
+
Because the SDL and the models are separate sources of truth, django-graphql ships a
|
|
44
|
+
**drift checker** that reports any SDL field or filter argument your models no longer
|
|
45
|
+
back, and can plug it into `manage.py check`.
|
|
46
|
+
|
|
47
|
+
## Features
|
|
48
|
+
|
|
49
|
+
- **Schema-first**: build an executable schema from an SDL string with `Schema(sdl)`.
|
|
50
|
+
- **Model binding**: `type Book @model(name: "library.Book")` maps a type to a model;
|
|
51
|
+
fields resolve by attribute, with automatic `camelCase` to `snake_case` fallback
|
|
52
|
+
(`publishedYear` reads `published_year`). Properties and methods work too.
|
|
53
|
+
- **Generated resolvers**:
|
|
54
|
+
- `@queryset` on a list field: every argument becomes an ORM lookup
|
|
55
|
+
(`title__icontains`, `publishedYear__gte`, `author__name`). `limit`, `offset` and
|
|
56
|
+
`orderBy` are reserved for paging and ordering. Null arguments are ignored.
|
|
57
|
+
- `@get` on a single-object field: arguments become a `.get()` lookup. Returns
|
|
58
|
+
`null` when nothing matches.
|
|
59
|
+
- **N+1 avoidance**: the selection set is inspected, then forward FKs and one-to-ones
|
|
60
|
+
are `select_related` while reverse FKs and many-to-many are `prefetch_related`
|
|
61
|
+
(one level deep, fragments included).
|
|
62
|
+
- **Safe paging**: `max_limit` caps every `@queryset` result (default 100).
|
|
63
|
+
- **Custom resolvers**: register them with `@schema.resolver("Type.field")`.
|
|
64
|
+
- **Drift checks**: `schema.check()` returns a list of `SchemaIssue`s, and
|
|
65
|
+
`register_django_check(schema)` reports them through Django's system checks.
|
|
66
|
+
- **Plain Django view**: GET and POST, JSON or form-encoded, CSRF-exempt.
|
|
67
|
+
Mutations over GET are refused, and `info.context` is the `HttpRequest`.
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install django-graphql
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Requires Python 3.10+, Django 4.2+ and graphql-core 3.2/3.3. Adding the package to
|
|
76
|
+
`INSTALLED_APPS` is not required.
|
|
77
|
+
|
|
78
|
+
## Quickstart
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
# library/models.py
|
|
82
|
+
from django.db import models
|
|
83
|
+
|
|
84
|
+
class Author(models.Model):
|
|
85
|
+
name = models.CharField(max_length=100)
|
|
86
|
+
|
|
87
|
+
class Book(models.Model):
|
|
88
|
+
title = models.CharField(max_length=200)
|
|
89
|
+
published_year = models.IntegerField()
|
|
90
|
+
author = models.ForeignKey(Author, related_name="books", on_delete=models.CASCADE)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
# library/schema.py
|
|
95
|
+
from django_graphql import Schema, register_django_check
|
|
96
|
+
|
|
97
|
+
schema = Schema("""
|
|
98
|
+
type Author @model(name: "library.Author") {
|
|
99
|
+
id: ID!
|
|
100
|
+
name: String!
|
|
101
|
+
books: [Book!]!
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type Book @model(name: "library.Book") {
|
|
105
|
+
id: ID!
|
|
106
|
+
title: String!
|
|
107
|
+
publishedYear: Int!
|
|
108
|
+
author: Author!
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type Query {
|
|
112
|
+
books(title__icontains: String, publishedYear__gte: Int,
|
|
113
|
+
limit: Int, offset: Int, orderBy: [String!]): [Book!]! @queryset
|
|
114
|
+
book(id: ID!): Book @get
|
|
115
|
+
me: String
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type Mutation {
|
|
119
|
+
renameAuthor(id: ID!, name: String!): Author
|
|
120
|
+
}
|
|
121
|
+
""", max_limit=50)
|
|
122
|
+
|
|
123
|
+
@schema.resolver("Query.me")
|
|
124
|
+
def resolve_me(root, info):
|
|
125
|
+
return info.context.user.get_username() # info.context is the HttpRequest
|
|
126
|
+
|
|
127
|
+
@schema.resolver("Mutation.renameAuthor")
|
|
128
|
+
def rename_author(root, info, id, name):
|
|
129
|
+
from library.models import Author
|
|
130
|
+
author = Author.objects.get(pk=id)
|
|
131
|
+
author.name = name
|
|
132
|
+
author.save()
|
|
133
|
+
return author
|
|
134
|
+
|
|
135
|
+
register_django_check(schema) # `manage.py check` now reports SDL/model drift
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
# urls.py
|
|
140
|
+
from django.urls import path
|
|
141
|
+
from library.schema import schema
|
|
142
|
+
|
|
143
|
+
urlpatterns = [path("graphql/", schema.as_view())]
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```graphql
|
|
147
|
+
{
|
|
148
|
+
books(publishedYear__gte: 1970, orderBy: ["-publishedYear"], limit: 10) {
|
|
149
|
+
title
|
|
150
|
+
author { name } # select_related("author"): one SQL query total
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
You can also execute without HTTP:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
result = schema.execute("query($id: ID!) { book(id: $id) { title } }", {"id": "1"})
|
|
159
|
+
result.data, result.errors
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## API overview
|
|
163
|
+
|
|
164
|
+
All names below are importable from `django_graphql`.
|
|
165
|
+
|
|
166
|
+
### SDL directives (definitions are injected automatically)
|
|
167
|
+
|
|
168
|
+
| Directive | Location | Meaning |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `@model(name: String!)` | object type | Bind the type to the model `"app_label.ModelName"`. |
|
|
171
|
+
| `@queryset` | field | Return type must be a list of a `@model` type. Non-reserved arguments become `QuerySet.filter(**lookups)` (names converted to snake_case). Reserved: `limit`, `offset`, `orderBy` (`[String!]`, `-` prefix for descending). |
|
|
172
|
+
| `@get` | field | Return type must be a single `@model` type. All arguments become a `.get()` lookup. Returns `null` on no match and an error if several rows match. |
|
|
173
|
+
|
|
174
|
+
### `Schema(sdl, *, resolvers=None, max_limit=100)`
|
|
175
|
+
|
|
176
|
+
Builds the executable schema. Raises `SchemaError` for an unknown model, a misplaced
|
|
177
|
+
directive (`@queryset` on a non-list, `@get` on a list, either on a non-`@model`
|
|
178
|
+
type, or both on one field) or an unknown resolver path.
|
|
179
|
+
|
|
180
|
+
- `resolvers`: optional `{"Type.field": callable}` mapping, the same as applying `resolver()` to each.
|
|
181
|
+
- `max_limit`: cap (and default) for `limit` on every `@queryset` field. `None` means no cap.
|
|
182
|
+
|
|
183
|
+
Attributes:
|
|
184
|
+
|
|
185
|
+
- `sdl: str`: the SDL you passed in.
|
|
186
|
+
- `max_limit: int | None`
|
|
187
|
+
- `graphql_schema: graphql.GraphQLSchema`: the underlying graphql-core schema.
|
|
188
|
+
- `models: dict[str, type[Model]]`: type name to bound model.
|
|
189
|
+
- `bound_fields: dict[str, tuple[type[Model], "queryset" | "get"]]`: `"Type.field"` to its generated binding.
|
|
190
|
+
- `custom_resolvers: dict[str, Callable]`: `"Type.field"` to the registered resolver.
|
|
191
|
+
|
|
192
|
+
Methods:
|
|
193
|
+
|
|
194
|
+
- `resolver(path: str)`: decorator registering `func(source, info, **args)` for
|
|
195
|
+
`"Type.field"`. It replaces a generated resolver, evaluates returned querysets and
|
|
196
|
+
managers to lists, and returns the original function.
|
|
197
|
+
- `execute(query, variables=None, *, operation_name=None, context=None, root=None) -> graphql.ExecutionResult`:
|
|
198
|
+
runs the query synchronously.
|
|
199
|
+
- `check() -> list[SchemaIssue]`: reports drift (see below).
|
|
200
|
+
- `as_view(**initkwargs)`: a CSRF-exempt Django view (`GraphQLView`) for this schema.
|
|
201
|
+
|
|
202
|
+
### `SchemaError(Exception)`
|
|
203
|
+
|
|
204
|
+
Raised when the SDL cannot be bound to Django models.
|
|
205
|
+
|
|
206
|
+
### `SchemaIssue(path: str, message: str)`
|
|
207
|
+
|
|
208
|
+
A frozen dataclass. `path` is `"Type.field"` or `"Type.field(arg)"`, and
|
|
209
|
+
`str(issue)` gives `"path: message"`. `schema.check()` reports:
|
|
210
|
+
|
|
211
|
+
- a field on a `@model` type that is not a model field, reverse accessor, property
|
|
212
|
+
or method (camelCase is also tried as snake_case). Fields with a custom resolver
|
|
213
|
+
are skipped.
|
|
214
|
+
- a `@queryset` / `@get` argument whose first lookup segment (`publisher` in
|
|
215
|
+
`publisher__name`) is not a model field or `pk`.
|
|
216
|
+
|
|
217
|
+
### `register_django_check(schema, *, check_id="django_graphql.E001")`
|
|
218
|
+
|
|
219
|
+
Registers a system check (tag `django_graphql`) that turns each `SchemaIssue` into a
|
|
220
|
+
`checks.Error` with `obj=issue.path`.
|
|
221
|
+
|
|
222
|
+
### `GraphQLView`
|
|
223
|
+
|
|
224
|
+
A Django `View` with a class attribute `schema`. Usually you create it with
|
|
225
|
+
`schema.as_view()`, or `GraphQLView.as_view(schema=schema)`.
|
|
226
|
+
|
|
227
|
+
- `POST` accepts `application/json` `{"query", "variables", "operationName"}` or
|
|
228
|
+
form fields with the same names (`variables` as a JSON string).
|
|
229
|
+
- `GET` accepts `?query=&variables=&operationName=`, for queries only. A mutation gets
|
|
230
|
+
`405` with `Allow: POST`.
|
|
231
|
+
- The response is `{"data": ..., "errors": [...]}`. A malformed request, a syntax
|
|
232
|
+
error or a validation error returns `400`. An execution error returns `200` with
|
|
233
|
+
partial data, or `400` if `data` is entirely `null`.
|
|
234
|
+
- `info.context` is the `HttpRequest`.
|
|
235
|
+
|
|
236
|
+
### `default_field_resolver(source, info, **args)`
|
|
237
|
+
|
|
238
|
+
The resolver used for every field without a generated or custom resolver. It reads
|
|
239
|
+
`source.<name>` or `source.<snake_name>` (or the dict keys of the same names),
|
|
240
|
+
calls callables with the field arguments, and evaluates managers and querysets to
|
|
241
|
+
lists.
|
|
242
|
+
|
|
243
|
+
## Development
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
python3 -m venv .venv
|
|
247
|
+
.venv/bin/pip install -e ".[test]"
|
|
248
|
+
.venv/bin/python -m pytest
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
The tests configure Django in `tests/conftest.py` (in-memory SQLite, no settings module).
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
MIT
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# django-graphql
|
|
2
|
+
|
|
3
|
+
**SDL-first GraphQL for Django.** Write your API contract in plain GraphQL SDL, bind
|
|
4
|
+
object types to Django models with a `@model` directive, and let `@queryset` / `@get`
|
|
5
|
+
generate resolvers that filter, page and join efficiently. No `DjangoObjectType`
|
|
6
|
+
classes, no Python mirror of your schema: the `.graphql` text *is* the schema.
|
|
7
|
+
|
|
8
|
+
Because the SDL and the models are separate sources of truth, django-graphql ships a
|
|
9
|
+
**drift checker** that reports any SDL field or filter argument your models no longer
|
|
10
|
+
back, and can plug it into `manage.py check`.
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- **Schema-first**: build an executable schema from an SDL string with `Schema(sdl)`.
|
|
15
|
+
- **Model binding**: `type Book @model(name: "library.Book")` maps a type to a model;
|
|
16
|
+
fields resolve by attribute, with automatic `camelCase` to `snake_case` fallback
|
|
17
|
+
(`publishedYear` reads `published_year`). Properties and methods work too.
|
|
18
|
+
- **Generated resolvers**:
|
|
19
|
+
- `@queryset` on a list field: every argument becomes an ORM lookup
|
|
20
|
+
(`title__icontains`, `publishedYear__gte`, `author__name`). `limit`, `offset` and
|
|
21
|
+
`orderBy` are reserved for paging and ordering. Null arguments are ignored.
|
|
22
|
+
- `@get` on a single-object field: arguments become a `.get()` lookup. Returns
|
|
23
|
+
`null` when nothing matches.
|
|
24
|
+
- **N+1 avoidance**: the selection set is inspected, then forward FKs and one-to-ones
|
|
25
|
+
are `select_related` while reverse FKs and many-to-many are `prefetch_related`
|
|
26
|
+
(one level deep, fragments included).
|
|
27
|
+
- **Safe paging**: `max_limit` caps every `@queryset` result (default 100).
|
|
28
|
+
- **Custom resolvers**: register them with `@schema.resolver("Type.field")`.
|
|
29
|
+
- **Drift checks**: `schema.check()` returns a list of `SchemaIssue`s, and
|
|
30
|
+
`register_django_check(schema)` reports them through Django's system checks.
|
|
31
|
+
- **Plain Django view**: GET and POST, JSON or form-encoded, CSRF-exempt.
|
|
32
|
+
Mutations over GET are refused, and `info.context` is the `HttpRequest`.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install django-graphql
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Requires Python 3.10+, Django 4.2+ and graphql-core 3.2/3.3. Adding the package to
|
|
41
|
+
`INSTALLED_APPS` is not required.
|
|
42
|
+
|
|
43
|
+
## Quickstart
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# library/models.py
|
|
47
|
+
from django.db import models
|
|
48
|
+
|
|
49
|
+
class Author(models.Model):
|
|
50
|
+
name = models.CharField(max_length=100)
|
|
51
|
+
|
|
52
|
+
class Book(models.Model):
|
|
53
|
+
title = models.CharField(max_length=200)
|
|
54
|
+
published_year = models.IntegerField()
|
|
55
|
+
author = models.ForeignKey(Author, related_name="books", on_delete=models.CASCADE)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
# library/schema.py
|
|
60
|
+
from django_graphql import Schema, register_django_check
|
|
61
|
+
|
|
62
|
+
schema = Schema("""
|
|
63
|
+
type Author @model(name: "library.Author") {
|
|
64
|
+
id: ID!
|
|
65
|
+
name: String!
|
|
66
|
+
books: [Book!]!
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type Book @model(name: "library.Book") {
|
|
70
|
+
id: ID!
|
|
71
|
+
title: String!
|
|
72
|
+
publishedYear: Int!
|
|
73
|
+
author: Author!
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type Query {
|
|
77
|
+
books(title__icontains: String, publishedYear__gte: Int,
|
|
78
|
+
limit: Int, offset: Int, orderBy: [String!]): [Book!]! @queryset
|
|
79
|
+
book(id: ID!): Book @get
|
|
80
|
+
me: String
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type Mutation {
|
|
84
|
+
renameAuthor(id: ID!, name: String!): Author
|
|
85
|
+
}
|
|
86
|
+
""", max_limit=50)
|
|
87
|
+
|
|
88
|
+
@schema.resolver("Query.me")
|
|
89
|
+
def resolve_me(root, info):
|
|
90
|
+
return info.context.user.get_username() # info.context is the HttpRequest
|
|
91
|
+
|
|
92
|
+
@schema.resolver("Mutation.renameAuthor")
|
|
93
|
+
def rename_author(root, info, id, name):
|
|
94
|
+
from library.models import Author
|
|
95
|
+
author = Author.objects.get(pk=id)
|
|
96
|
+
author.name = name
|
|
97
|
+
author.save()
|
|
98
|
+
return author
|
|
99
|
+
|
|
100
|
+
register_django_check(schema) # `manage.py check` now reports SDL/model drift
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
# urls.py
|
|
105
|
+
from django.urls import path
|
|
106
|
+
from library.schema import schema
|
|
107
|
+
|
|
108
|
+
urlpatterns = [path("graphql/", schema.as_view())]
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```graphql
|
|
112
|
+
{
|
|
113
|
+
books(publishedYear__gte: 1970, orderBy: ["-publishedYear"], limit: 10) {
|
|
114
|
+
title
|
|
115
|
+
author { name } # select_related("author"): one SQL query total
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
You can also execute without HTTP:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
result = schema.execute("query($id: ID!) { book(id: $id) { title } }", {"id": "1"})
|
|
124
|
+
result.data, result.errors
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## API overview
|
|
128
|
+
|
|
129
|
+
All names below are importable from `django_graphql`.
|
|
130
|
+
|
|
131
|
+
### SDL directives (definitions are injected automatically)
|
|
132
|
+
|
|
133
|
+
| Directive | Location | Meaning |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `@model(name: String!)` | object type | Bind the type to the model `"app_label.ModelName"`. |
|
|
136
|
+
| `@queryset` | field | Return type must be a list of a `@model` type. Non-reserved arguments become `QuerySet.filter(**lookups)` (names converted to snake_case). Reserved: `limit`, `offset`, `orderBy` (`[String!]`, `-` prefix for descending). |
|
|
137
|
+
| `@get` | field | Return type must be a single `@model` type. All arguments become a `.get()` lookup. Returns `null` on no match and an error if several rows match. |
|
|
138
|
+
|
|
139
|
+
### `Schema(sdl, *, resolvers=None, max_limit=100)`
|
|
140
|
+
|
|
141
|
+
Builds the executable schema. Raises `SchemaError` for an unknown model, a misplaced
|
|
142
|
+
directive (`@queryset` on a non-list, `@get` on a list, either on a non-`@model`
|
|
143
|
+
type, or both on one field) or an unknown resolver path.
|
|
144
|
+
|
|
145
|
+
- `resolvers`: optional `{"Type.field": callable}` mapping, the same as applying `resolver()` to each.
|
|
146
|
+
- `max_limit`: cap (and default) for `limit` on every `@queryset` field. `None` means no cap.
|
|
147
|
+
|
|
148
|
+
Attributes:
|
|
149
|
+
|
|
150
|
+
- `sdl: str`: the SDL you passed in.
|
|
151
|
+
- `max_limit: int | None`
|
|
152
|
+
- `graphql_schema: graphql.GraphQLSchema`: the underlying graphql-core schema.
|
|
153
|
+
- `models: dict[str, type[Model]]`: type name to bound model.
|
|
154
|
+
- `bound_fields: dict[str, tuple[type[Model], "queryset" | "get"]]`: `"Type.field"` to its generated binding.
|
|
155
|
+
- `custom_resolvers: dict[str, Callable]`: `"Type.field"` to the registered resolver.
|
|
156
|
+
|
|
157
|
+
Methods:
|
|
158
|
+
|
|
159
|
+
- `resolver(path: str)`: decorator registering `func(source, info, **args)` for
|
|
160
|
+
`"Type.field"`. It replaces a generated resolver, evaluates returned querysets and
|
|
161
|
+
managers to lists, and returns the original function.
|
|
162
|
+
- `execute(query, variables=None, *, operation_name=None, context=None, root=None) -> graphql.ExecutionResult`:
|
|
163
|
+
runs the query synchronously.
|
|
164
|
+
- `check() -> list[SchemaIssue]`: reports drift (see below).
|
|
165
|
+
- `as_view(**initkwargs)`: a CSRF-exempt Django view (`GraphQLView`) for this schema.
|
|
166
|
+
|
|
167
|
+
### `SchemaError(Exception)`
|
|
168
|
+
|
|
169
|
+
Raised when the SDL cannot be bound to Django models.
|
|
170
|
+
|
|
171
|
+
### `SchemaIssue(path: str, message: str)`
|
|
172
|
+
|
|
173
|
+
A frozen dataclass. `path` is `"Type.field"` or `"Type.field(arg)"`, and
|
|
174
|
+
`str(issue)` gives `"path: message"`. `schema.check()` reports:
|
|
175
|
+
|
|
176
|
+
- a field on a `@model` type that is not a model field, reverse accessor, property
|
|
177
|
+
or method (camelCase is also tried as snake_case). Fields with a custom resolver
|
|
178
|
+
are skipped.
|
|
179
|
+
- a `@queryset` / `@get` argument whose first lookup segment (`publisher` in
|
|
180
|
+
`publisher__name`) is not a model field or `pk`.
|
|
181
|
+
|
|
182
|
+
### `register_django_check(schema, *, check_id="django_graphql.E001")`
|
|
183
|
+
|
|
184
|
+
Registers a system check (tag `django_graphql`) that turns each `SchemaIssue` into a
|
|
185
|
+
`checks.Error` with `obj=issue.path`.
|
|
186
|
+
|
|
187
|
+
### `GraphQLView`
|
|
188
|
+
|
|
189
|
+
A Django `View` with a class attribute `schema`. Usually you create it with
|
|
190
|
+
`schema.as_view()`, or `GraphQLView.as_view(schema=schema)`.
|
|
191
|
+
|
|
192
|
+
- `POST` accepts `application/json` `{"query", "variables", "operationName"}` or
|
|
193
|
+
form fields with the same names (`variables` as a JSON string).
|
|
194
|
+
- `GET` accepts `?query=&variables=&operationName=`, for queries only. A mutation gets
|
|
195
|
+
`405` with `Allow: POST`.
|
|
196
|
+
- The response is `{"data": ..., "errors": [...]}`. A malformed request, a syntax
|
|
197
|
+
error or a validation error returns `400`. An execution error returns `200` with
|
|
198
|
+
partial data, or `400` if `data` is entirely `null`.
|
|
199
|
+
- `info.context` is the `HttpRequest`.
|
|
200
|
+
|
|
201
|
+
### `default_field_resolver(source, info, **args)`
|
|
202
|
+
|
|
203
|
+
The resolver used for every field without a generated or custom resolver. It reads
|
|
204
|
+
`source.<name>` or `source.<snake_name>` (or the dict keys of the same names),
|
|
205
|
+
calls callables with the field arguments, and evaluates managers and querysets to
|
|
206
|
+
lists.
|
|
207
|
+
|
|
208
|
+
## Development
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
python3 -m venv .venv
|
|
212
|
+
.venv/bin/pip install -e ".[test]"
|
|
213
|
+
.venv/bin/python -m pytest
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The tests configure Django in `tests/conftest.py` (in-memory SQLite, no settings module).
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
MIT
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-graphql"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "SDL-first GraphQL for Django: bind schema types to models with directives and get N+1-aware resolvers."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "nehz" }]
|
|
13
|
+
keywords = ["django", "graphql", "sdl", "schema-first", "api"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"Django>=4.2",
|
|
16
|
+
"graphql-core>=3.2,<3.4",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Environment :: Web Environment",
|
|
21
|
+
"Framework :: Django",
|
|
22
|
+
"Framework :: Django :: 4.2",
|
|
23
|
+
"Framework :: Django :: 5.0",
|
|
24
|
+
"Framework :: Django :: 5.1",
|
|
25
|
+
"Framework :: Django :: 5.2",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"Operating System :: OS Independent",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
30
|
+
"Programming Language :: Python :: 3.10",
|
|
31
|
+
"Programming Language :: Python :: 3.11",
|
|
32
|
+
"Programming Language :: Python :: 3.12",
|
|
33
|
+
"Programming Language :: Python :: 3.13",
|
|
34
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
35
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
36
|
+
"Typing :: Typed",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
test = ["pytest>=7", "pytest-django>=4.5"]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
where = ["src"]
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.package-data]
|
|
46
|
+
django_graphql = ["py.typed"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
pythonpath = ["."]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""SDL-first GraphQL for Django.
|
|
2
|
+
|
|
3
|
+
Write your schema in GraphQL SDL, bind object types to Django models with
|
|
4
|
+
``@model``, and let ``@queryset``/``@get`` generate N+1-aware resolvers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .checks import SchemaIssue, register_django_check
|
|
8
|
+
from .resolvers import default_field_resolver
|
|
9
|
+
from .schema import Schema, SchemaError
|
|
10
|
+
from .views import GraphQLView
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"GraphQLView",
|
|
14
|
+
"Schema",
|
|
15
|
+
"SchemaError",
|
|
16
|
+
"SchemaIssue",
|
|
17
|
+
"default_field_resolver",
|
|
18
|
+
"register_django_check",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Drift detection between SDL object types and their bound Django models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from django.core import checks as django_checks
|
|
9
|
+
from django.core.exceptions import FieldDoesNotExist
|
|
10
|
+
from django.db import models
|
|
11
|
+
|
|
12
|
+
from .directives import RESERVED_ARGS
|
|
13
|
+
from .naming import to_snake
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .schema import Schema
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class SchemaIssue:
|
|
21
|
+
"""A mismatch between the SDL and the Django models it is bound to.
|
|
22
|
+
|
|
23
|
+
``path`` is ``"Type.field"`` or ``"Type.field(arg)"``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
path: str
|
|
27
|
+
message: str
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
return f"{self.path}: {self.message}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _has_attribute(model: type[models.Model], name: str) -> bool:
|
|
34
|
+
for candidate in (name, to_snake(name)):
|
|
35
|
+
try:
|
|
36
|
+
model._meta.get_field(candidate)
|
|
37
|
+
return True
|
|
38
|
+
except FieldDoesNotExist:
|
|
39
|
+
pass
|
|
40
|
+
if hasattr(model, candidate):
|
|
41
|
+
return True
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _lookup_root_exists(model: type[models.Model], arg: str) -> bool:
|
|
46
|
+
root = to_snake(arg).split("__", 1)[0]
|
|
47
|
+
if root == "pk":
|
|
48
|
+
return True
|
|
49
|
+
try:
|
|
50
|
+
model._meta.get_field(root)
|
|
51
|
+
return True
|
|
52
|
+
except FieldDoesNotExist:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check_schema(schema: Schema) -> list[SchemaIssue]:
|
|
57
|
+
"""Return every place where ``schema`` refers to something its models lack.
|
|
58
|
+
|
|
59
|
+
* Fields of ``@model`` types must exist on the model (field, reverse
|
|
60
|
+
accessor, property or method; camelCase is matched against snake_case).
|
|
61
|
+
* Filter arguments of ``@queryset``/``@get`` fields must start with a
|
|
62
|
+
model field name (``title__icontains`` -> ``title``).
|
|
63
|
+
* Fields with a custom resolver (see :meth:`Schema.resolver`) are skipped,
|
|
64
|
+
since they do not read from the model instance.
|
|
65
|
+
"""
|
|
66
|
+
issues: list[SchemaIssue] = []
|
|
67
|
+
for type_name, model in schema.models.items():
|
|
68
|
+
gql_type = schema.graphql_schema.get_type(type_name)
|
|
69
|
+
for field_name in gql_type.fields: # type: ignore[union-attr]
|
|
70
|
+
path = f"{type_name}.{field_name}"
|
|
71
|
+
if path in schema.custom_resolvers:
|
|
72
|
+
continue
|
|
73
|
+
if not _has_attribute(model, field_name):
|
|
74
|
+
issues.append(
|
|
75
|
+
SchemaIssue(path, f"{model._meta.label} has no field or attribute '{field_name}'.")
|
|
76
|
+
)
|
|
77
|
+
for path, (model, kind) in schema.bound_fields.items():
|
|
78
|
+
type_name, field_name = path.split(".")
|
|
79
|
+
field = schema.graphql_schema.get_type(type_name).fields[field_name] # type: ignore[union-attr]
|
|
80
|
+
reserved = RESERVED_ARGS if kind == "queryset" else frozenset()
|
|
81
|
+
for arg in field.args:
|
|
82
|
+
if arg in reserved:
|
|
83
|
+
continue
|
|
84
|
+
if not _lookup_root_exists(model, arg):
|
|
85
|
+
issues.append(
|
|
86
|
+
SchemaIssue(f"{path}({arg})", f"'{arg}' is not a lookup on {model._meta.label}.")
|
|
87
|
+
)
|
|
88
|
+
return issues
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def register_django_check(schema: Schema, *, check_id: str = "django_graphql.E001") -> None:
|
|
92
|
+
"""Register ``schema`` with Django's system check framework.
|
|
93
|
+
|
|
94
|
+
Every :class:`SchemaIssue` is reported as a ``checks.Error`` so
|
|
95
|
+
``manage.py check`` (and therefore ``runserver``/``migrate``) surface drift.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def _check(app_configs: object = None, **kwargs: object) -> list[django_checks.CheckMessage]:
|
|
99
|
+
return [django_checks.Error(str(issue), obj=issue.path, id=check_id) for issue in check_schema(schema)]
|
|
100
|
+
|
|
101
|
+
django_checks.register(_check, "django_graphql")
|