django-graphql 0.1.0__py3-none-any.whl
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/__init__.py +21 -0
- django_graphql/checks.py +101 -0
- django_graphql/directives.py +56 -0
- django_graphql/naming.py +18 -0
- django_graphql/optimizer.py +94 -0
- django_graphql/py.typed +0 -0
- django_graphql/resolvers.py +116 -0
- django_graphql/schema.py +186 -0
- django_graphql/views.py +121 -0
- django_graphql-0.1.0.dist-info/METADATA +255 -0
- django_graphql-0.1.0.dist-info/RECORD +14 -0
- django_graphql-0.1.0.dist-info/WHEEL +5 -0
- django_graphql-0.1.0.dist-info/licenses/LICENSE +21 -0
- django_graphql-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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"
|
django_graphql/checks.py
ADDED
|
@@ -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")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""SDL directives understood by :class:`django_graphql.Schema`.
|
|
2
|
+
|
|
3
|
+
The directive definitions are prepended to every SDL document automatically,
|
|
4
|
+
so schemas never need to declare them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from graphql import (
|
|
12
|
+
DirectiveLocation,
|
|
13
|
+
GraphQLArgument,
|
|
14
|
+
GraphQLDirective,
|
|
15
|
+
GraphQLNonNull,
|
|
16
|
+
GraphQLString,
|
|
17
|
+
get_directive_values,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
#: ``type Book @model(name: "library.Book")`` binds an object type to a Django model.
|
|
21
|
+
MODEL = GraphQLDirective(
|
|
22
|
+
name="model",
|
|
23
|
+
locations=[DirectiveLocation.OBJECT],
|
|
24
|
+
args={"name": GraphQLArgument(GraphQLNonNull(GraphQLString))},
|
|
25
|
+
description="Bind this object type to a Django model given as 'app_label.ModelName'.",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
#: ``books(...): [Book!]! @queryset`` resolves a list field from the model's default manager.
|
|
29
|
+
QUERYSET = GraphQLDirective(
|
|
30
|
+
name="queryset",
|
|
31
|
+
locations=[DirectiveLocation.FIELD_DEFINITION],
|
|
32
|
+
description="Resolve a list field from the bound model's queryset; arguments become filters.",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
#: ``book(id: ID!): Book @get`` resolves a single object (or null) by its arguments.
|
|
36
|
+
GET = GraphQLDirective(
|
|
37
|
+
name="get",
|
|
38
|
+
locations=[DirectiveLocation.FIELD_DEFINITION],
|
|
39
|
+
description="Resolve a single object of the bound model; arguments become lookups.",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
DIRECTIVES_SDL = """
|
|
43
|
+
directive @model(name: String!) on OBJECT
|
|
44
|
+
directive @queryset on FIELD_DEFINITION
|
|
45
|
+
directive @get on FIELD_DEFINITION
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
#: Argument names on ``@queryset`` fields that control paging/ordering instead of filtering.
|
|
49
|
+
RESERVED_ARGS = frozenset({"limit", "offset", "orderBy"})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def directive_values(directive: GraphQLDirective, node: Any) -> dict[str, Any] | None:
|
|
53
|
+
"""Return the argument values of ``directive`` on an AST ``node``, or ``None`` if absent."""
|
|
54
|
+
if node is None:
|
|
55
|
+
return None
|
|
56
|
+
return get_directive_values(directive, node)
|
django_graphql/naming.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Name conversion between GraphQL (camelCase) and Django (snake_case)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_BOUNDARY = re.compile(r"(?<=[a-z0-9])([A-Z])")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def to_snake(name: str) -> str:
|
|
11
|
+
"""Convert ``camelCase`` to ``snake_case``; ``__`` lookup separators are preserved.
|
|
12
|
+
|
|
13
|
+
>>> to_snake("publishedYear")
|
|
14
|
+
'published_year'
|
|
15
|
+
>>> to_snake("title__icontains")
|
|
16
|
+
'title__icontains'
|
|
17
|
+
"""
|
|
18
|
+
return _BOUNDARY.sub(r"_\1", name).lower()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Selection-aware queryset optimisation.
|
|
2
|
+
|
|
3
|
+
Given the GraphQL selection for a list or single-object field, add
|
|
4
|
+
``select_related`` for forward foreign keys / one-to-ones and
|
|
5
|
+
``prefetch_related`` for reverse and many-to-many relations, so that a
|
|
6
|
+
typical one-level-nested query runs in a constant number of SQL queries.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterable, Iterator
|
|
12
|
+
|
|
13
|
+
from django.db import models
|
|
14
|
+
from django.db.models import Field, ForeignObjectRel
|
|
15
|
+
from graphql import FieldNode, FragmentDefinitionNode, FragmentSpreadNode, InlineFragmentNode
|
|
16
|
+
|
|
17
|
+
from .naming import to_snake
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _iter_fields(
|
|
21
|
+
selections: Iterable[object],
|
|
22
|
+
fragments: dict[str, FragmentDefinitionNode],
|
|
23
|
+
) -> Iterator[FieldNode]:
|
|
24
|
+
"""Yield the field nodes of a selection set, flattening fragments."""
|
|
25
|
+
for selection in selections:
|
|
26
|
+
if isinstance(selection, FieldNode):
|
|
27
|
+
yield selection
|
|
28
|
+
elif isinstance(selection, InlineFragmentNode):
|
|
29
|
+
yield from _iter_fields(selection.selection_set.selections, fragments)
|
|
30
|
+
elif isinstance(selection, FragmentSpreadNode):
|
|
31
|
+
fragment = fragments.get(selection.name.value)
|
|
32
|
+
if fragment is not None:
|
|
33
|
+
yield from _iter_fields(fragment.selection_set.selections, fragments)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def relation_fields(model: type[models.Model]) -> dict[str, Field | ForeignObjectRel]:
|
|
37
|
+
"""Map attribute names (forward field names and reverse accessors) to relation fields."""
|
|
38
|
+
result: dict[str, Field | ForeignObjectRel] = {}
|
|
39
|
+
for field in model._meta.get_fields():
|
|
40
|
+
if not field.is_relation:
|
|
41
|
+
continue
|
|
42
|
+
if field.auto_created and not field.concrete:
|
|
43
|
+
accessor = field.get_accessor_name()
|
|
44
|
+
if accessor:
|
|
45
|
+
result[accessor] = field
|
|
46
|
+
else:
|
|
47
|
+
result[field.name] = field
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def relations_for_selection(
|
|
52
|
+
model: type[models.Model],
|
|
53
|
+
field_nodes: Iterable[FieldNode],
|
|
54
|
+
fragments: dict[str, FragmentDefinitionNode] | None = None,
|
|
55
|
+
) -> tuple[list[str], list[str]]:
|
|
56
|
+
"""Return ``(select_related, prefetch_related)`` names for the given selection.
|
|
57
|
+
|
|
58
|
+
Only sub-selections that themselves have a selection set (i.e. object
|
|
59
|
+
fields) are considered; scalars never trigger joins.
|
|
60
|
+
"""
|
|
61
|
+
fragments = fragments or {}
|
|
62
|
+
relations = relation_fields(model)
|
|
63
|
+
select: list[str] = []
|
|
64
|
+
prefetch: list[str] = []
|
|
65
|
+
for node in field_nodes:
|
|
66
|
+
if node.selection_set is None:
|
|
67
|
+
continue
|
|
68
|
+
for child in _iter_fields(node.selection_set.selections, fragments):
|
|
69
|
+
if child.selection_set is None:
|
|
70
|
+
continue
|
|
71
|
+
graphql_name = child.name.value
|
|
72
|
+
name = graphql_name if graphql_name in relations else to_snake(graphql_name)
|
|
73
|
+
field = relations.get(name)
|
|
74
|
+
if field is None:
|
|
75
|
+
continue
|
|
76
|
+
single = field.many_to_one or field.one_to_one
|
|
77
|
+
target = select if single else prefetch
|
|
78
|
+
if name not in target:
|
|
79
|
+
target.append(name)
|
|
80
|
+
return select, prefetch
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def optimize(
|
|
84
|
+
queryset: models.QuerySet,
|
|
85
|
+
field_nodes: Iterable[FieldNode],
|
|
86
|
+
fragments: dict[str, FragmentDefinitionNode] | None = None,
|
|
87
|
+
) -> models.QuerySet:
|
|
88
|
+
"""Apply ``select_related``/``prefetch_related`` to ``queryset`` for the selection."""
|
|
89
|
+
select, prefetch = relations_for_selection(queryset.model, list(field_nodes), fragments)
|
|
90
|
+
if select:
|
|
91
|
+
queryset = queryset.select_related(*select)
|
|
92
|
+
if prefetch:
|
|
93
|
+
queryset = queryset.prefetch_related(*prefetch)
|
|
94
|
+
return queryset
|
django_graphql/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Resolvers generated for ``@queryset``/``@get`` fields and the default field resolver."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from django.db import models
|
|
10
|
+
from graphql import GraphQLError, GraphQLResolveInfo
|
|
11
|
+
|
|
12
|
+
from .directives import RESERVED_ARGS
|
|
13
|
+
from .naming import to_snake
|
|
14
|
+
from .optimizer import optimize
|
|
15
|
+
|
|
16
|
+
Resolver = Callable[..., Any]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def materialize(value: Any) -> Any:
|
|
20
|
+
"""Evaluate managers and querysets into lists; return anything else unchanged.
|
|
21
|
+
|
|
22
|
+
graphql-core treats objects with ``__aiter__`` (which Django querysets
|
|
23
|
+
have) as async iterables, so lists are handed to the executor instead.
|
|
24
|
+
"""
|
|
25
|
+
if isinstance(value, models.Manager):
|
|
26
|
+
value = value.all()
|
|
27
|
+
if isinstance(value, models.QuerySet):
|
|
28
|
+
return list(value)
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def wrap_resolver(func: Resolver) -> Resolver:
|
|
33
|
+
"""Wrap a user resolver so returned querysets/managers are materialized."""
|
|
34
|
+
|
|
35
|
+
@functools.wraps(func)
|
|
36
|
+
def resolve(source: Any, info: GraphQLResolveInfo, **kwargs: Any) -> Any:
|
|
37
|
+
return materialize(func(source, info, **kwargs))
|
|
38
|
+
|
|
39
|
+
return resolve
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def default_field_resolver(source: Any, info: GraphQLResolveInfo, **kwargs: Any) -> Any:
|
|
43
|
+
"""Resolve a field from ``source`` by attribute or mapping key.
|
|
44
|
+
|
|
45
|
+
Tries the GraphQL name first, then its snake_case form. Related managers
|
|
46
|
+
(reverse foreign keys, many-to-many) are evaluated via ``manager.all()``
|
|
47
|
+
so prefetched results are reused; plain callables are called with the
|
|
48
|
+
field arguments.
|
|
49
|
+
"""
|
|
50
|
+
name = info.field_name
|
|
51
|
+
value: Any = None
|
|
52
|
+
if isinstance(source, dict):
|
|
53
|
+
value = source.get(name, source.get(to_snake(name)))
|
|
54
|
+
else:
|
|
55
|
+
for candidate in (name, to_snake(name)):
|
|
56
|
+
if hasattr(source, candidate):
|
|
57
|
+
value = getattr(source, candidate)
|
|
58
|
+
break
|
|
59
|
+
if callable(value) and not isinstance(value, models.Manager):
|
|
60
|
+
value = value(**kwargs)
|
|
61
|
+
return materialize(value)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _filters(kwargs: dict[str, Any], reserved: frozenset[str]) -> dict[str, Any]:
|
|
65
|
+
"""Turn GraphQL arguments into ORM lookups, dropping reserved and null ones."""
|
|
66
|
+
return {to_snake(k): v for k, v in kwargs.items() if k not in reserved and v is not None}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _base_queryset(model: type[models.Model], info: GraphQLResolveInfo) -> models.QuerySet:
|
|
70
|
+
queryset = model._default_manager.all()
|
|
71
|
+
return optimize(queryset, info.field_nodes, info.fragments)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def make_queryset_resolver(model: type[models.Model], max_limit: int | None) -> Resolver:
|
|
75
|
+
"""Build a resolver returning filtered, ordered and sliced ``model`` instances.
|
|
76
|
+
|
|
77
|
+
Arguments ``limit``, ``offset`` and ``orderBy`` control paging and ordering;
|
|
78
|
+
every other non-null argument is passed to ``QuerySet.filter`` after
|
|
79
|
+
camelCase-to-snake_case conversion (so ``title__icontains`` and
|
|
80
|
+
``publishedYear__gte`` both work). ``limit`` is capped at ``max_limit``.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def resolve(source: Any, info: GraphQLResolveInfo, **kwargs: Any) -> list[models.Model]:
|
|
84
|
+
queryset = _base_queryset(model, info).filter(**_filters(kwargs, RESERVED_ARGS))
|
|
85
|
+
order_by = kwargs.get("orderBy")
|
|
86
|
+
if order_by:
|
|
87
|
+
queryset = queryset.order_by(*(to_snake(o) for o in order_by))
|
|
88
|
+
offset = kwargs.get("offset") or 0
|
|
89
|
+
limit = kwargs.get("limit")
|
|
90
|
+
if offset < 0 or (limit is not None and limit < 0):
|
|
91
|
+
raise GraphQLError("'limit' and 'offset' must be non-negative.")
|
|
92
|
+
if max_limit is not None:
|
|
93
|
+
limit = max_limit if limit is None else min(limit, max_limit)
|
|
94
|
+
if offset or limit is not None:
|
|
95
|
+
queryset = queryset[offset : None if limit is None else offset + limit]
|
|
96
|
+
return list(queryset)
|
|
97
|
+
|
|
98
|
+
return resolve
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def make_get_resolver(model: type[models.Model]) -> Resolver:
|
|
102
|
+
"""Build a resolver returning the single ``model`` instance matching the arguments.
|
|
103
|
+
|
|
104
|
+
Returns ``None`` when nothing matches and raises a GraphQL error when the
|
|
105
|
+
lookup is ambiguous.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def resolve(source: Any, info: GraphQLResolveInfo, **kwargs: Any) -> models.Model | None:
|
|
109
|
+
try:
|
|
110
|
+
return _base_queryset(model, info).get(**_filters(kwargs, frozenset()))
|
|
111
|
+
except model.DoesNotExist:
|
|
112
|
+
return None
|
|
113
|
+
except model.MultipleObjectsReturned as exc:
|
|
114
|
+
raise GraphQLError(f"More than one {model.__name__} matches {kwargs!r}.") from exc
|
|
115
|
+
|
|
116
|
+
return resolve
|
django_graphql/schema.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""The :class:`Schema` object: SDL in, executable Django-backed GraphQL schema out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from typing import Any, Literal, TypeVar
|
|
7
|
+
|
|
8
|
+
from django.apps import apps
|
|
9
|
+
from django.db import models
|
|
10
|
+
from graphql import (
|
|
11
|
+
ExecutionResult,
|
|
12
|
+
GraphQLList,
|
|
13
|
+
GraphQLObjectType,
|
|
14
|
+
GraphQLSchema,
|
|
15
|
+
build_schema,
|
|
16
|
+
get_nullable_type,
|
|
17
|
+
graphql_sync,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from .checks import SchemaIssue, check_schema
|
|
21
|
+
from .directives import DIRECTIVES_SDL, GET, MODEL, QUERYSET, directive_values
|
|
22
|
+
from .resolvers import (
|
|
23
|
+
default_field_resolver,
|
|
24
|
+
make_get_resolver,
|
|
25
|
+
make_queryset_resolver,
|
|
26
|
+
wrap_resolver,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
30
|
+
BindingKind = Literal["queryset", "get"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SchemaError(Exception):
|
|
34
|
+
"""Raised when the SDL cannot be bound to Django models."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Schema:
|
|
38
|
+
"""An executable GraphQL schema built from SDL and bound to Django models.
|
|
39
|
+
|
|
40
|
+
Object types annotated with ``@model(name: "app_label.Model")`` are bound
|
|
41
|
+
to that model; fields annotated with ``@queryset`` (list of a bound type)
|
|
42
|
+
or ``@get`` (single bound type) get generated resolvers. All other fields
|
|
43
|
+
use :func:`~django_graphql.resolvers.default_field_resolver` unless a
|
|
44
|
+
custom resolver is registered with :meth:`resolver`.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
sdl: The schema in GraphQL SDL. The ``@model``, ``@queryset`` and
|
|
48
|
+
``@get`` directive definitions are added automatically.
|
|
49
|
+
resolvers: Optional mapping of ``"Type.field"`` to resolver callables,
|
|
50
|
+
equivalent to decorating each with :meth:`resolver`.
|
|
51
|
+
max_limit: Upper bound applied to every ``@queryset`` field's
|
|
52
|
+
``limit`` (also used when no ``limit`` is given). ``None`` disables it.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
SchemaError: on unknown models, misplaced directives or unknown
|
|
56
|
+
resolver paths.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
sdl: str,
|
|
62
|
+
*,
|
|
63
|
+
resolvers: Mapping[str, Callable[..., Any]] | None = None,
|
|
64
|
+
max_limit: int | None = 100,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.sdl = sdl
|
|
67
|
+
self.max_limit = max_limit
|
|
68
|
+
self.graphql_schema: GraphQLSchema = build_schema(DIRECTIVES_SDL + sdl)
|
|
69
|
+
#: GraphQL object type name -> bound Django model.
|
|
70
|
+
self.models: dict[str, type[models.Model]] = {}
|
|
71
|
+
#: ``"Type.field"`` -> (model, "queryset" | "get") for generated resolvers.
|
|
72
|
+
self.bound_fields: dict[str, tuple[type[models.Model], BindingKind]] = {}
|
|
73
|
+
#: ``"Type.field"`` -> user-registered resolver.
|
|
74
|
+
self.custom_resolvers: dict[str, Callable[..., Any]] = {}
|
|
75
|
+
self._bind_models()
|
|
76
|
+
self._bind_fields()
|
|
77
|
+
for path, func in (resolvers or {}).items():
|
|
78
|
+
self.resolver(path)(func)
|
|
79
|
+
|
|
80
|
+
# -- building -------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def _object_types(self) -> list[GraphQLObjectType]:
|
|
83
|
+
return [
|
|
84
|
+
t
|
|
85
|
+
for name, t in self.graphql_schema.type_map.items()
|
|
86
|
+
if isinstance(t, GraphQLObjectType) and not name.startswith("__")
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
def _bind_models(self) -> None:
|
|
90
|
+
for gql_type in self._object_types():
|
|
91
|
+
values = directive_values(MODEL, gql_type.ast_node)
|
|
92
|
+
if values is None:
|
|
93
|
+
continue
|
|
94
|
+
label = values["name"]
|
|
95
|
+
try:
|
|
96
|
+
self.models[gql_type.name] = apps.get_model(label)
|
|
97
|
+
except (LookupError, ValueError) as exc:
|
|
98
|
+
raise SchemaError(f"{gql_type.name}: unknown Django model '{label}'.") from exc
|
|
99
|
+
|
|
100
|
+
def _bind_fields(self) -> None:
|
|
101
|
+
for gql_type in self._object_types():
|
|
102
|
+
for field_name, field in gql_type.fields.items():
|
|
103
|
+
path = f"{gql_type.name}.{field_name}"
|
|
104
|
+
field.resolve = default_field_resolver
|
|
105
|
+
is_queryset = directive_values(QUERYSET, field.ast_node) is not None
|
|
106
|
+
is_get = directive_values(GET, field.ast_node) is not None
|
|
107
|
+
if not (is_queryset or is_get):
|
|
108
|
+
continue
|
|
109
|
+
if is_queryset and is_get:
|
|
110
|
+
raise SchemaError(f"{path}: @queryset and @get are mutually exclusive.")
|
|
111
|
+
kind: BindingKind = "queryset" if is_queryset else "get"
|
|
112
|
+
model = self._model_for_return_type(path, field.type, many=is_queryset)
|
|
113
|
+
self.bound_fields[path] = (model, kind)
|
|
114
|
+
field.resolve = (
|
|
115
|
+
make_queryset_resolver(model, self.max_limit)
|
|
116
|
+
if is_queryset
|
|
117
|
+
else make_get_resolver(model)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def _model_for_return_type(self, path: str, gql_type: Any, *, many: bool) -> type[models.Model]:
|
|
121
|
+
nullable = get_nullable_type(gql_type)
|
|
122
|
+
directive = "@queryset" if many else "@get"
|
|
123
|
+
if many:
|
|
124
|
+
if not isinstance(nullable, GraphQLList):
|
|
125
|
+
raise SchemaError(f"{path}: {directive} requires a list return type.")
|
|
126
|
+
nullable = get_nullable_type(nullable.of_type)
|
|
127
|
+
elif isinstance(nullable, GraphQLList):
|
|
128
|
+
raise SchemaError(f"{path}: {directive} requires a non-list return type.")
|
|
129
|
+
name = getattr(nullable, "name", None)
|
|
130
|
+
if name not in self.models:
|
|
131
|
+
raise SchemaError(f"{path}: {directive} must return a type annotated with @model.")
|
|
132
|
+
return self.models[name]
|
|
133
|
+
|
|
134
|
+
# -- public API -------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def resolver(self, path: str) -> Callable[[F], F]:
|
|
137
|
+
"""Decorator registering a custom resolver for ``"Type.field"``.
|
|
138
|
+
|
|
139
|
+
The resolver receives ``(source, info, **arguments)``; ``info.context``
|
|
140
|
+
is the Django ``HttpRequest`` when executed through :class:`GraphQLView`.
|
|
141
|
+
It replaces any generated resolver for that field. Returned querysets
|
|
142
|
+
and managers are evaluated to lists. The original function is returned.
|
|
143
|
+
"""
|
|
144
|
+
type_name, _, field_name = path.partition(".")
|
|
145
|
+
gql_type = self.graphql_schema.get_type(type_name)
|
|
146
|
+
if not isinstance(gql_type, GraphQLObjectType) or field_name not in gql_type.fields:
|
|
147
|
+
raise SchemaError(f"Cannot register resolver: no field '{path}' in schema.")
|
|
148
|
+
field = gql_type.fields[field_name]
|
|
149
|
+
|
|
150
|
+
def decorator(func: F) -> F:
|
|
151
|
+
field.resolve = wrap_resolver(func)
|
|
152
|
+
self.custom_resolvers[path] = func
|
|
153
|
+
self.bound_fields.pop(path, None)
|
|
154
|
+
return func
|
|
155
|
+
|
|
156
|
+
return decorator
|
|
157
|
+
|
|
158
|
+
def execute(
|
|
159
|
+
self,
|
|
160
|
+
query: str,
|
|
161
|
+
variables: Mapping[str, Any] | None = None,
|
|
162
|
+
*,
|
|
163
|
+
operation_name: str | None = None,
|
|
164
|
+
context: Any = None,
|
|
165
|
+
root: Any = None,
|
|
166
|
+
) -> ExecutionResult:
|
|
167
|
+
"""Parse, validate and execute ``query`` synchronously."""
|
|
168
|
+
return graphql_sync(
|
|
169
|
+
self.graphql_schema,
|
|
170
|
+
query,
|
|
171
|
+
root_value=root,
|
|
172
|
+
context_value=context,
|
|
173
|
+
variable_values=dict(variables) if variables is not None else None,
|
|
174
|
+
operation_name=operation_name,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def check(self) -> list[SchemaIssue]:
|
|
178
|
+
"""Report SDL fields and filter arguments that do not exist on bound models."""
|
|
179
|
+
return check_schema(self)
|
|
180
|
+
|
|
181
|
+
def as_view(self, **initkwargs: Any) -> Callable[..., Any]:
|
|
182
|
+
"""Return a CSRF-exempt Django view serving this schema (see :class:`GraphQLView`)."""
|
|
183
|
+
from .views import GraphQLView
|
|
184
|
+
|
|
185
|
+
return GraphQLView.as_view(schema=self, **initkwargs)
|
|
186
|
+
|
django_graphql/views.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""A plain Django class-based view that serves a :class:`~django_graphql.Schema` over HTTP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from django.http import HttpRequest, HttpResponse, JsonResponse
|
|
9
|
+
from django.utils.decorators import method_decorator
|
|
10
|
+
from django.views import View
|
|
11
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
12
|
+
from graphql import (
|
|
13
|
+
GraphQLError,
|
|
14
|
+
OperationType,
|
|
15
|
+
execute,
|
|
16
|
+
get_operation_ast,
|
|
17
|
+
parse,
|
|
18
|
+
validate,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .schema import Schema
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BadRequest(Exception):
|
|
26
|
+
"""Raised for malformed GraphQL-over-HTTP requests (answered with HTTP 400)."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@method_decorator(csrf_exempt, name="dispatch")
|
|
30
|
+
class GraphQLView(View):
|
|
31
|
+
"""Serve GraphQL over HTTP following the common GraphQL-over-HTTP conventions.
|
|
32
|
+
|
|
33
|
+
* ``POST`` with ``application/json`` body ``{"query", "variables", "operationName"}``
|
|
34
|
+
or form-encoded fields of the same names.
|
|
35
|
+
* ``GET`` with ``?query=...&variables=<json>&operationName=...``; only
|
|
36
|
+
query operations are allowed over GET (mutations get HTTP 405).
|
|
37
|
+
|
|
38
|
+
Responses are ``{"data": ..., "errors": [...]}`` JSON. Requests that fail
|
|
39
|
+
before execution (bad JSON, syntax or validation errors) return HTTP 400;
|
|
40
|
+
execution errors return HTTP 200 alongside partial data, or HTTP 400 when
|
|
41
|
+
the error nulled the entire ``data``. The Django
|
|
42
|
+
``HttpRequest`` is passed as ``info.context``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
schema: Schema | None = None
|
|
46
|
+
http_method_names = ["get", "post"]
|
|
47
|
+
|
|
48
|
+
def get(self, request: HttpRequest) -> HttpResponse:
|
|
49
|
+
params = {k: request.GET.get(k) for k in ("query", "variables", "operationName")}
|
|
50
|
+
return self._respond(request, params, allow_mutation=False)
|
|
51
|
+
|
|
52
|
+
def post(self, request: HttpRequest) -> HttpResponse:
|
|
53
|
+
if request.content_type == "application/json":
|
|
54
|
+
try:
|
|
55
|
+
body = json.loads(request.body or b"{}")
|
|
56
|
+
except json.JSONDecodeError:
|
|
57
|
+
return self._error("Request body is not valid JSON.", 400)
|
|
58
|
+
if not isinstance(body, dict):
|
|
59
|
+
return self._error("Request body must be a JSON object.", 400)
|
|
60
|
+
params: dict[str, Any] = body
|
|
61
|
+
else:
|
|
62
|
+
params = {k: request.POST.get(k) for k in ("query", "variables", "operationName")}
|
|
63
|
+
return self._respond(request, params, allow_mutation=True)
|
|
64
|
+
|
|
65
|
+
# -- helpers ----------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _error(message: str, status: int) -> JsonResponse:
|
|
69
|
+
return JsonResponse({"errors": [{"message": message}]}, status=status)
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _parse_variables(raw: Any) -> dict[str, Any] | None:
|
|
73
|
+
if raw in (None, ""):
|
|
74
|
+
return None
|
|
75
|
+
if isinstance(raw, str):
|
|
76
|
+
try:
|
|
77
|
+
raw = json.loads(raw)
|
|
78
|
+
except json.JSONDecodeError as exc:
|
|
79
|
+
raise BadRequest("'variables' is not valid JSON.") from exc
|
|
80
|
+
if not isinstance(raw, dict):
|
|
81
|
+
raise BadRequest("'variables' must be a JSON object.")
|
|
82
|
+
return raw
|
|
83
|
+
|
|
84
|
+
def _respond(self, request: HttpRequest, params: dict[str, Any], *, allow_mutation: bool) -> HttpResponse:
|
|
85
|
+
if self.schema is None:
|
|
86
|
+
raise TypeError("GraphQLView requires a 'schema' (use schema.as_view()).")
|
|
87
|
+
query = params.get("query")
|
|
88
|
+
if not query or not isinstance(query, str):
|
|
89
|
+
return self._error("Must provide a 'query' string.", 400)
|
|
90
|
+
try:
|
|
91
|
+
variables = self._parse_variables(params.get("variables"))
|
|
92
|
+
except BadRequest as exc:
|
|
93
|
+
return self._error(str(exc), 400)
|
|
94
|
+
operation_name = params.get("operationName") or None
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
document = parse(query)
|
|
98
|
+
except GraphQLError as exc:
|
|
99
|
+
return JsonResponse({"errors": [exc.formatted]}, status=400)
|
|
100
|
+
schema = self.schema.graphql_schema
|
|
101
|
+
validation_errors = validate(schema, document)
|
|
102
|
+
if validation_errors:
|
|
103
|
+
return JsonResponse({"errors": [e.formatted for e in validation_errors]}, status=400)
|
|
104
|
+
|
|
105
|
+
operation = get_operation_ast(document, operation_name)
|
|
106
|
+
if not allow_mutation and operation is not None and operation.operation != OperationType.QUERY:
|
|
107
|
+
response = self._error(f"Can only perform a {operation.operation.value} over POST.", 405)
|
|
108
|
+
response["Allow"] = "POST"
|
|
109
|
+
return response
|
|
110
|
+
|
|
111
|
+
result = execute(
|
|
112
|
+
schema,
|
|
113
|
+
document,
|
|
114
|
+
context_value=request,
|
|
115
|
+
variable_values=variables,
|
|
116
|
+
operation_name=operation_name,
|
|
117
|
+
)
|
|
118
|
+
payload: dict[str, Any] = {"data": result.data}
|
|
119
|
+
if result.errors:
|
|
120
|
+
payload["errors"] = [e.formatted for e in result.errors]
|
|
121
|
+
return JsonResponse(payload, status=200 if result.data is not None else 400)
|
|
@@ -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,14 @@
|
|
|
1
|
+
django_graphql/__init__.py,sha256=12eyG9TMJfvM6NbnDbmpbs3Wsa5pe7W74VR3aQFJ6_E,526
|
|
2
|
+
django_graphql/checks.py,sha256=OOJsQEl6dW_zzdVvKRsz_5OVaKkUlJkD2qTyntkOs2A,3608
|
|
3
|
+
django_graphql/directives.py,sha256=QNhSV7ddLwNTJlA1Il-hHw0NgQf67bd-1QLtL5rsEuE,1872
|
|
4
|
+
django_graphql/naming.py,sha256=fhiz15Ai37_Uwkz1ZSj16Kj5pZc3T3VZETbLHjTrDb4,464
|
|
5
|
+
django_graphql/optimizer.py,sha256=7L_neo-mvobAfENhoTv5uymH_pA2b6TzRP4ZrB6w9PM,3609
|
|
6
|
+
django_graphql/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
django_graphql/resolvers.py,sha256=rn8G_MWVZdrXjM3ycyiy37aB94ZwlfSz2YrMw6ihXIg,4481
|
|
8
|
+
django_graphql/schema.py,sha256=qAsNuXHFXemHzr9Qio6EGzVJOuBOW0wPfPk9mX8WXWU,7499
|
|
9
|
+
django_graphql/views.py,sha256=boWC-MpHh62zDuslU8fbj0az1P-iDpUwrjfBxfNqTog,4873
|
|
10
|
+
django_graphql-0.1.0.dist-info/licenses/LICENSE,sha256=pAfYREEW9GAy7cnK20OXjDn7ofJahYny9GCuIZQTDAA,1061
|
|
11
|
+
django_graphql-0.1.0.dist-info/METADATA,sha256=4NJo-dsJRIzQTJh1BzqSWlPIQ78YWt32hmVo0gebCvM,9448
|
|
12
|
+
django_graphql-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
django_graphql-0.1.0.dist-info/top_level.txt,sha256=DEt-rZk23o8WotPE9wEQozOI-BiOrjSQRlDAPZlhLGo,15
|
|
14
|
+
django_graphql-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
django_graphql
|