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.
@@ -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")
@@ -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)
@@ -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
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
@@ -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
+
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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