Metadata-Version: 2.4
Name: django-graphql
Version: 0.1.0
Summary: SDL-first GraphQL for Django: bind schema types to models with directives and get N+1-aware resolvers.
Author: nehz
License-Expression: MIT
Keywords: django,graphql,sdl,schema-first,api
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Framework :: Django :: 5.2
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django>=4.2
Requires-Dist: graphql-core<3.4,>=3.2
Provides-Extra: test
Requires-Dist: pytest>=7; extra == "test"
Requires-Dist: pytest-django>=4.5; extra == "test"
Dynamic: license-file

# django-graphql

**SDL-first GraphQL for Django.** Write your API contract in plain GraphQL SDL, bind
object types to Django models with a `@model` directive, and let `@queryset` / `@get`
generate resolvers that filter, page and join efficiently. No `DjangoObjectType`
classes, no Python mirror of your schema: the `.graphql` text *is* the schema.

Because the SDL and the models are separate sources of truth, django-graphql ships a
**drift checker** that reports any SDL field or filter argument your models no longer
back, and can plug it into `manage.py check`.

## Features

- **Schema-first**: build an executable schema from an SDL string with `Schema(sdl)`.
- **Model binding**: `type Book @model(name: "library.Book")` maps a type to a model;
  fields resolve by attribute, with automatic `camelCase` to `snake_case` fallback
  (`publishedYear` reads `published_year`). Properties and methods work too.
- **Generated resolvers**:
  - `@queryset` on a list field: every argument becomes an ORM lookup
    (`title__icontains`, `publishedYear__gte`, `author__name`). `limit`, `offset` and
    `orderBy` are reserved for paging and ordering. Null arguments are ignored.
  - `@get` on a single-object field: arguments become a `.get()` lookup. Returns
    `null` when nothing matches.
- **N+1 avoidance**: the selection set is inspected, then forward FKs and one-to-ones
  are `select_related` while reverse FKs and many-to-many are `prefetch_related`
  (one level deep, fragments included).
- **Safe paging**: `max_limit` caps every `@queryset` result (default 100).
- **Custom resolvers**: register them with `@schema.resolver("Type.field")`.
- **Drift checks**: `schema.check()` returns a list of `SchemaIssue`s, and
  `register_django_check(schema)` reports them through Django's system checks.
- **Plain Django view**: GET and POST, JSON or form-encoded, CSRF-exempt.
  Mutations over GET are refused, and `info.context` is the `HttpRequest`.

## Install

```bash
pip install django-graphql
```

Requires Python 3.10+, Django 4.2+ and graphql-core 3.2/3.3. Adding the package to
`INSTALLED_APPS` is not required.

## Quickstart

```python
# library/models.py
from django.db import models

class Author(models.Model):
    name = models.CharField(max_length=100)

class Book(models.Model):
    title = models.CharField(max_length=200)
    published_year = models.IntegerField()
    author = models.ForeignKey(Author, related_name="books", on_delete=models.CASCADE)
```

```python
# library/schema.py
from django_graphql import Schema, register_django_check

schema = Schema("""
type Author @model(name: "library.Author") {
  id: ID!
  name: String!
  books: [Book!]!
}

type Book @model(name: "library.Book") {
  id: ID!
  title: String!
  publishedYear: Int!
  author: Author!
}

type Query {
  books(title__icontains: String, publishedYear__gte: Int,
        limit: Int, offset: Int, orderBy: [String!]): [Book!]! @queryset
  book(id: ID!): Book @get
  me: String
}

type Mutation {
  renameAuthor(id: ID!, name: String!): Author
}
""", max_limit=50)

@schema.resolver("Query.me")
def resolve_me(root, info):
    return info.context.user.get_username()   # info.context is the HttpRequest

@schema.resolver("Mutation.renameAuthor")
def rename_author(root, info, id, name):
    from library.models import Author
    author = Author.objects.get(pk=id)
    author.name = name
    author.save()
    return author

register_django_check(schema)   # `manage.py check` now reports SDL/model drift
```

```python
# urls.py
from django.urls import path
from library.schema import schema

urlpatterns = [path("graphql/", schema.as_view())]
```

```graphql
{
  books(publishedYear__gte: 1970, orderBy: ["-publishedYear"], limit: 10) {
    title
    author { name }      # select_related("author"): one SQL query total
  }
}
```

You can also execute without HTTP:

```python
result = schema.execute("query($id: ID!) { book(id: $id) { title } }", {"id": "1"})
result.data, result.errors
```

## API overview

All names below are importable from `django_graphql`.

### SDL directives (definitions are injected automatically)

| Directive | Location | Meaning |
|---|---|---|
| `@model(name: String!)` | object type | Bind the type to the model `"app_label.ModelName"`. |
| `@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). |
| `@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. |

### `Schema(sdl, *, resolvers=None, max_limit=100)`

Builds the executable schema. Raises `SchemaError` for an unknown model, a misplaced
directive (`@queryset` on a non-list, `@get` on a list, either on a non-`@model`
type, or both on one field) or an unknown resolver path.

- `resolvers`: optional `{"Type.field": callable}` mapping, the same as applying `resolver()` to each.
- `max_limit`: cap (and default) for `limit` on every `@queryset` field. `None` means no cap.

Attributes:

- `sdl: str`: the SDL you passed in.
- `max_limit: int | None`
- `graphql_schema: graphql.GraphQLSchema`: the underlying graphql-core schema.
- `models: dict[str, type[Model]]`: type name to bound model.
- `bound_fields: dict[str, tuple[type[Model], "queryset" | "get"]]`: `"Type.field"` to its generated binding.
- `custom_resolvers: dict[str, Callable]`: `"Type.field"` to the registered resolver.

Methods:

- `resolver(path: str)`: decorator registering `func(source, info, **args)` for
  `"Type.field"`. It replaces a generated resolver, evaluates returned querysets and
  managers to lists, and returns the original function.
- `execute(query, variables=None, *, operation_name=None, context=None, root=None) -> graphql.ExecutionResult`:
  runs the query synchronously.
- `check() -> list[SchemaIssue]`: reports drift (see below).
- `as_view(**initkwargs)`: a CSRF-exempt Django view (`GraphQLView`) for this schema.

### `SchemaError(Exception)`

Raised when the SDL cannot be bound to Django models.

### `SchemaIssue(path: str, message: str)`

A frozen dataclass. `path` is `"Type.field"` or `"Type.field(arg)"`, and
`str(issue)` gives `"path: message"`. `schema.check()` reports:

- a field on a `@model` type that is not a model field, reverse accessor, property
  or method (camelCase is also tried as snake_case). Fields with a custom resolver
  are skipped.
- a `@queryset` / `@get` argument whose first lookup segment (`publisher` in
  `publisher__name`) is not a model field or `pk`.

### `register_django_check(schema, *, check_id="django_graphql.E001")`

Registers a system check (tag `django_graphql`) that turns each `SchemaIssue` into a
`checks.Error` with `obj=issue.path`.

### `GraphQLView`

A Django `View` with a class attribute `schema`. Usually you create it with
`schema.as_view()`, or `GraphQLView.as_view(schema=schema)`.

- `POST` accepts `application/json` `{"query", "variables", "operationName"}` or
  form fields with the same names (`variables` as a JSON string).
- `GET` accepts `?query=&variables=&operationName=`, for queries only. A mutation gets
  `405` with `Allow: POST`.
- The response is `{"data": ..., "errors": [...]}`. A malformed request, a syntax
  error or a validation error returns `400`. An execution error returns `200` with
  partial data, or `400` if `data` is entirely `null`.
- `info.context` is the `HttpRequest`.

### `default_field_resolver(source, info, **args)`

The resolver used for every field without a generated or custom resolver. It reads
`source.<name>` or `source.<snake_name>` (or the dict keys of the same names),
calls callables with the field arguments, and evaluates managers and querysets to
lists.

## Development

```bash
python3 -m venv .venv
.venv/bin/pip install -e ".[test]"
.venv/bin/python -m pytest
```

The tests configure Django in `tests/conftest.py` (in-memory SQLite, no settings module).

## License

MIT
