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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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")