Playground Project¶
The repository ships a small, fully runnable Django project at
examples/playground/
that exercises every major django-graphex feature end-to-end: queries with all
three paginators, filtering (including a custom @filter_field), N+1-safe
nested lists, the full query-optimization surface, a typed
GenericForeignKey union, directives, CRUD mutations with permissions,
descriptor-API hand-written mutations, file uploads on both paths (base64
in the JSON body and a multipart part), the
projection security boundary on all
three axes, the relation scoping hatch in both directions, query depth/cost
limits, and native subscriptions over WebSocket and SSE.
Playground vs. the tutorial
The Sample Application page is a standalone,
illustrative tutorial — its models exist only to demonstrate framework
concepts and are not backed by runnable code. The playground is the
opposite: a real project you clone, seed and run, installing the
library from the repo checkout (editable, via [tool.uv.sources]) — no
PyPI release needed. Use the tutorial to learn the concepts and the
playground to try them live.
Quick start¶
You need uv and Python 3.12+.
git clone https://github.com/eamigo86/django-graphex.git
cd django-graphex/examples/playground
make install # uv sync --group test — local library (editable) + daphne + test tools
make migrate # generate + apply migrations (SQLite)
make seed # load demo data (see below)
make run # daphne ASGI server (HTTP + WebSocket) on 127.0.0.1:8000
Open GraphiQL at http://127.0.0.1:8000/graphql/. The seed creates the
superuser demo / demo12345.
All the Makefile targets:
| Target | What it does |
|---|---|
make install |
uv sync --group test — creates the venv, installs django-graphex[subscriptions] from the parent checkout (editable), daphne and the pytest tools |
make migrate |
makemigrations blog + migrate (SQLite) |
make seed |
Loads the demo data via python manage.py seed |
make run |
Starts daphne (ASGI: HTTP and WebSocket) at http://127.0.0.1:8000/ |
make superuser |
createsuperuser — create your own admin user |
make shell |
Django shell |
make test |
Runs the end-to-end test suite (WS + SSE subscription round-trips, schema and client smoke tests) |
make collectstatic |
Collects static files into STATIC_ROOT |
make reset |
Drops db.sqlite3, re-migrates, re-seeds |
make clean |
Removes the database and __pycache__ caches |
Resetting the playground¶
make reset is the recovery path when generated migrations no longer match the
local SQLite database. It deletes db.sqlite3 and only migration files matching
blog/migrations/0*.py, preserves blog/migrations/__init__.py, removes
migration bytecode, then regenerates, migrates and seeds.
The --no-migrations test option is deliberate: playground tests exercise the
current models and must not depend on whatever generated migration happened to
exist in a developer checkout.
Not production-ready¶
The playground settings are a development fixture. manage.py check --deploy
reports seven warnings on them: DEBUG = True, a short hard-coded
SECRET_KEY, no HSTS, no SSL redirect, insecure session and CSRF cookies, and
no clickjacking middleware. Copy the patterns (the DJANGO_GRAPHEX block, the
schema wiring), not the file, into a real project and set those deliberately.
WebSocket origin policy¶
The shipped ALLOWED_HOSTS is exactly 127.0.0.1, localhost and
testserver; the last one exists only because Django's test client and
Channels' test communicator use that host, so drop it from a copy that is not
run by this test suite. AllowedHostsOriginValidator applies that list to WebSocket
handshakes, so a browser origin such as evil.example is rejected while the
documented localhost and Django test-client origins connect.
If you expose the playground through another development hostname, add that
exact host. Do not replace the list with "*": the socket uses session cookies,
so an allow-all Origin policy would enable cross-site WebSocket requests.
What the seed creates¶
python manage.py seed (idempotent — it clears the demo tables first) loads:
- 15 authors x 12 posts each (180 posts), statuses cycling
DRAFT/PUBLISHED/ARCHIVED, each post in one of the categories Tech / Life / News and tagged with one of django / graphql / python. Each body names its own tag ("Body of post 9 by author 13. Topic: django.") so the custom@filter_fieldhas something to find —posts(filter: { search: "django" })answerstotalCount: 20rather than an empty page that reads like a broken example - 3 comments per post (540 comments)
- 12 private notes owned by the demo user
- 2 accounts + 2 invoices + 4 attachments — the attachments alternate
between
AccountandInvoicetargets so the typedGenericForeignKeyunion query returns both member types - The superuser
demo/demo12345(also linked to the first author)
The counts are deliberate: 12 posts per author is more than the page size
of 10, so — when logged in — nested posts lists span real multiple pages
and the DB-side window-pagination path (ROW_NUMBER() OVER (PARTITION BY ...))
is genuinely exercised: try results(offset: 10) on a nested list. Logged out,
PostType.get_queryset shows only the 4 published posts per author, which fit
on one page. Scale the dataset without editing code:
uv run python manage.py seed --authors 25 --posts 15 # explicit counts
uv run python manage.py seed --scale 2 # doubles authors + posts
Endpoints¶
| Route | View | Notes |
|---|---|---|
/graphql/ |
GraphQLView |
Queries + mutations over HTTP, with GraphiQL |
/graphql/secure/ |
AuthenticatedGraphQLView |
Same schema behind view-level auth — rejects anonymous requests with HTTP 403 before any query runs |
/graphql/stream |
subscription_sse_view |
Native SSE subscription transport (text/event-stream) |
/ws/graphql/ |
subscription_ws_consumer |
Native graphql-transport-ws WebSocket (routed in config/asgi.py) |
/graphql/client/ |
SubscriptionClientView |
Self-contained browser client to try subscriptions live |
/admin/ |
Django admin | Log in here to authenticate your GraphiQL session |
Authenticating (private fields)¶
Auth is Django session-based. Private fields (me, myNotes, the note
and comment writes, noteSubscription) and the private mutations postUpdate,
postDelete and documentUpdate require a logged-in user. Exactly these
writes are deliberately open to anonymous callers, so you can compare a gated
write with an ungated one:
| Open mutation | Why it is open |
|---|---|
postCreate |
Drives the public postSubscription demo without a session |
postWithCommentsCreate (no comments) |
Behaves like postCreate; with comments the child's IsOwnerOrReadOnly gate applies |
createCategory |
Hand-written mutation demo (duplicate names return ok: false) |
documentCreate |
The multipart curl demo needs no session |
uploadDocument |
The base64 upload demo |
registerUser |
Account registration is public by nature |
The destructive or modifying siblings are mounted under PrivateMutation
(private_mutation= on DjangoGraphQLSchema), so an anonymous call answers
Authentication required.. To log in:
- Open http://127.0.0.1:8000/admin/ and log in as
demo/demo12345. - Return to GraphiQL — it shares the session cookie.
Log out of /admin to test the anonymous (public) behaviour again. An account
made with registerUser is not staff and cannot log in anywhere here: the
admin login accepts staff only and the playground ships no other login view.
The ids in the examples (id: 1, author: 1) assume a fresh database from
make reset; re-running make seed on existing rows creates new ids, so look
one up first.
Anonymous users see fewer posts — on purpose
PostType.get_queryset scopes the base queryset per request: anonymous
users only see PUBLISHED posts, authenticated users see everything.
Compare posts { totalCount } before and after logging in — it is the
get_queryset hook working on every top-level field
type.
The hook also scopes automatic FK and reverse-FK output reads. Anonymously:
| Query | Statuses returned |
|---|---|
{ posts { results { status } } } |
PUBLISHED only |
{ categories { name posts { title status } } } |
PUBLISHED only — CategoryType.posts is a mounted DjangoFilterListField, so the scope runs |
{ authors { results { posts { results { status } } } } } |
PUBLISHED only — the automatic reverse-FK container applies PostType.get_queryset |
The mounted field in the second row changes the wire shape to a plain list; it is no longer needed merely to hide drafts. Declaring it costs the relation its nested filter paths — see the table under Filtering.
The Playground's PostType declares a direct author FK-ID filter, not
author__name traversal. That source-owned ID lookup retains its current
behavior; nested forward-FK filters in applications that declare them now
apply the destination type's request scope. See
forward-FK filter scope.
Schema tour¶
Everything lives in blog/schema.py
(with settings in config/settings.py). What each part demonstrates:
| Feature | Where in the playground | Docs |
|---|---|---|
| All three paginators | PostListType — LimitOffsetGraphqlPagination(default_limit=10, ordering="-id"); AuthorListType — PageGraphqlPagination(page_size=10); CommentListType — CursorGraphqlPagination(ordering="-created") (exposes pageInfo) |
Pagination |
| Filtering | Meta.filter_fields on every object type, queried through the nested filter: argument |
Filtering |
Custom @filter_field |
PostType.search — @filter_field(GraphQLString) full-text search over title and body |
Filtering |
| Nested lists (N+1-safe) | authors → posts → comments, each independently paginated and filterable; Post.tags (M2M) nests automatically |
Nested Lists |
| Query optimization | AuthorType.post_count = AnnotatedField(GraphQLInt, Count("posts")) (annotation injected only when selected); the AuthorType.optimize_posts per-field hook; DB-side window pagination of nested pages — all OPTIMIZE_* settings are ON by default |
Query Optimization |
| Typed GFK union | AttachmentTargetUnion (DjangoUnionType, Meta.types = (AccountType, InvoiceType)) mapped via AttachmentType.Meta.unions = {"target": AttachmentTargetUnion} |
Types |
| Directives | DjangoGraphQLSchema(..., directives=all_directives) + GraphQLDirectiveMiddleware in DJANGO_GRAPHEX["MIDDLEWARE"] |
Directives |
| Model CRUD mutations | PostMutation (DjangoModelMutation) → postCreate (open) and postUpdate/Delete (mounted under the private_mutation root, so login is required); CommentModelType.MutationFields() and NoteModelType.MutationFields() (DjangoModelType) → commentCreate/Update/Delete and noteCreate/Update/Delete |
Mutations |
| Nested writes | PostWithCommentsMutation — Meta.nested_fields = {"comments": Comment} creates a Post plus its comments in one atomic operation |
Mutations |
| Descriptor-API mutations | CreateCategory / UploadDocument — hand-written Mutation classes with class Arguments (Field(CategoryInput, required=True), CharField(required=True)) and payload fields declared with BooleanField() / CharField() / Field(CategoryType) |
Mutations |
| File uploads, base64 | UploadDocument accepts a Base64FileInput; MAX_UPLOAD_SIZE (5 MB) and MAX_REQUEST_BODY_SIZE (20 MB) guard it in config/settings.py |
Mutations |
| File uploads, multipart | DocumentMutation (DjangoModelMutation, model_operations = ("create", "update")) → documentCreate / documentUpdate; a part named attachedFile or attached_file lands on the same Document.attached_file column. The configured 5 MB MAX_UPLOAD_SIZE now applies to every received multipart file, including unmatched parts. |
Mutations |
| Projection = security boundary | AuthorType hides bio, UserType hides password, CommentType / CommentModelType hide internal_note on read and write — each unreadable, unorderable and unfilterable through its type |
Types |
| Relation scoping hatch | to-MANY: CategoryType.posts = DjangoFilterListField(PostType); to-ONE: AuthorType.user = Field(UserType) + resolve_user. Beside them AuthorType.posts stays the auto-expanded shape, while automatic reverse M2M TagType.posts applies PostType.get_queryset to its members and count. The pair shows what the hatch buys and costs |
Types |
| Permissions | NoteModelType.permission_classes = [IsAuthenticatedOrReadOnly]; a custom IsOwnerOrReadOnly(BasePermission) assigned on CommentModelType, which is also the gate the nested postWithCommentsCreate runs for each inline comment; per-request scoping via filter_queryset (myNotes returns only your notes) |
Permissions |
| Public/private schema split | DjangoGraphQLSchema(query=..., private_query=..., subscription=..., private_subscription=...) + AuthenticatedFieldsMiddleware protects the private roots at resolve time |
Security |
| Subscriptions | Public PostSubscription / CommentSubscription (payload_mode = "full", per-subscriber filter; PostSubscription.subscription_scope keeps drafts away from anonymous subscribers, since subscriptions do not run PostType.get_queryset); private NoteModelType.SubscriptionField() with subscription_scope (server-forced "only my notes") and subscription_index_fields = ("owner",) |
Subscriptions |
| Query limits | Global MAX_QUERY_DEPTH = 6 in settings; per-type PostType.Meta.max_depth = 4 and complexity = 2 (most-restrictive wins). Cost analysis is off by default: config/settings.py carries a commented, measured MAX_QUERY_COST = 10_000_000 (a budget of 200 would refuse even a one-row list query) |
Query Limits |
Safe ordering, live
The ordering allowlist is active on all paginated fields. Try these in GraphiQL to see the anti-oracle guard reject them:
posts { results(ordering: "author__user__password") { title } }→Invalid ordering field: 'author__user__password'. Relation-spanning ordering is not permitted.posts { results(ordering: "nonexistent") { title } }→Invalid ordering field: 'nonexistent'.
Starter queries¶
Paste these into GraphiQL at /graphql/ right after make seed — they all
work anonymously.
Limit/offset (posts), page (authors) and cursor (comments) — plus
nested M2M tags and cursor-paginated nested comments in one request:
{
posts(filter: { status: { exact: PUBLISHED } }) {
totalCount
results(limit: 5, ordering: "-id") {
id
title
status
author { name }
tags { totalCount results { name } }
comments {
totalCount
results(first: 2) { authorName text }
pageInfo(first: 2) { hasNextPage endCursor }
}
}
}
authors {
totalCount
results(page: 1) { name }
}
comments {
totalCount
results(first: 3) { text }
pageInfo(first: 3) { hasNextPage endCursor }
}
}
To continue either cursor list, send its endCursor as cursor to both
results and pageInfo, with the same first value on each. Otherwise
cursor metadata can advance beyond rows the results did not show.
search is the custom @filter_field (title or body); postCount
is an AnnotatedField whose Count() is added to the SQL only because
it is selected; the nested posts page is sliced DB-side with a window
function:
Each Attachment.target is a GenericForeignKey exposed as a
DjangoUnionType — select per-member fields with inline fragments. The
seed mixes Account and Invoice targets so both branches return data:
Try the directives too
Every directive from all_directives is registered. For example:
The projection boundary, live¶
AuthorType projects bio away. The rule says a hidden column is not
readable, not orderable and not filterable
(the canonical statement) — here
are all three axes, anonymously, with the answers the playground returns. It
sets ALLOW_INTROSPECTION = True, so schema-derived Did you mean …? hints
may appear. Flip that flag off and the hints are stripped while the rejection
and any trailing Found detail remain. Input-field wording varies by
graphql-core generation.
| Query | Answer |
|---|---|
{ authors { results { bio } } } |
Cannot query field 'bio' on type 'AuthorType'. Did you mean 'id'? |
{ authors { results(ordering: "bio") { name } } } |
Invalid ordering field: 'bio'. |
{ authors(filter: { bio: { icontains: "x" } }) { totalCount } } |
Expected value of type 'AuthorFilterInput' not to include unknown field 'bio'. Did you mean 'id'? Found: { bio: { icontains: "x" } }. |
{ authors { results(ordering: "name", page: 1) { name } } } |
the control — still sorts, Author 0, Author 1, Author 10, … |
The same rule is told on two columns where it matters more than on a bio:
| Query | Answer |
|---|---|
{ authors { results { user { password } } } } |
Cannot query field 'password' on type 'UserType'. — without the exclusion Author.user answers the hash to every authenticated caller. Anonymous ones are stopped one layer earlier by resolve_user (see the hatch table below), so this column has two independent walls |
{ comments { results(first: 1) { internalNote } } } |
Cannot query field 'internalNote' on type 'CommentType'. |
mutation { commentCreate(newComment: { post: 1, authorName: "a", text: "t", internalNote: "x" }) { ok } } |
Expected value of type 'CommentCreateGenericType_p84059c' not to include unknown field 'internalNote', found: { post: 1, authorName: "a", text: "t", internalNote: "x" }. — the same projection on the write input |
The to-ONE hatch closes the ordering axis on the key behind it:
| Query | Answer |
|---|---|
{ authors { results { name user { username } } } } |
user: null for an anonymous caller — resolve_user scopes it |
{ authors { results(ordering: "userId") { name } } } |
Invalid ordering field: 'user_id'. — the term is normalized to the column before it is judged, so the message names user_id, not what you sent |
The model-bound subscription needs its own projection
CommentSubscription is a hand-written Subscription bound to
Meta.model = Comment, so it would compile its event type and its
CommentSubscriptionFilterInput from the model — internalNote would
be selectable and equality-filterable there while CommentType hides it.
That is the third of the
open boundaries the rule states, and
the remedy is one line: repeat exclude_fields in the subscription's own
Meta. The playground does exactly that, so internalNote is absent from
the subscription's event type and filter input, and a test pins both the
closure and the library gap it compensates for.
Uploading a file¶
documentCreate is a DjangoModelMutation over a model with a FileField, so
a multipart part named after the field is merged into the payload with no extra
configuration. GraphiQL cannot send multipart, so this one is a curl demo —
run it against make run:
curl -s http://127.0.0.1:8000/graphql/ \
-H 'X-Requested-With: XMLHttpRequest' \
-F 'query=mutation { documentCreate(newDocument: { name: "Notes" }) { ok document { id name attachedFile } } }' \
-F 'attachedFile=@notes.txt'
{"data":{"documentCreate":{"ok":true,"document":{"id":"1","name":"Notes","attachedFile":"documents/notes.txt"}}}}
The file column is an allow-list (.txt, .pdf, .png, .jpg, .jpeg,
.gif), applied by both upload paths: an .html or .svg upload answers
ok: false. While DEBUG is on, uploaded files are served as downloads
(Content-Disposition: attachment, nosniff, Content-Security-Policy:
sandbox); in production serve user uploads from a separate domain or CDN, never
from the application's own origin.
Three variations worth running, because each one teaches a rule:
| Change | What happens |
|---|---|
rename the part to attached_file |
same landing column — the part name is matched against both the camelCase alias the SDL publishes and the model attribute. The stored path picks up a uniqueness suffix (documents/notes_ciyKdvA.txt) only because the first upload already took documents/notes.txt |
misspell the part (attachedFyle) |
ok: true with attachedFile: "" — a part matching no exposed input field is ignored, so a typo looks exactly like success |
drop -H 'X-Requested-With: …' |
HTTP 403, This content type requires the X-Requested-With header. …, before the body is read |
BigInt and Decimal over the wire¶
Both scalars travel as strings, so a value past 2**53 or a money amount never
passes through a JSON float. Log in first (the BigInt post is a seeded draft)
and run:
It answers "9007199254740993". The Decimal side needs no login:
answers balance: "1200.50" for the first account. A test pins both documents
against the project README.md.
Two settings that ship on, and are invisible until they bite¶
config/settings.py deliberately does not set either of these — both ship
enabled, and pinning a key to its own default only hides that you depend on it.
The file names and explains them in comments instead, so a reader copying it
meets both walls at their desk:
| Setting | Default | What you meet |
|---|---|---|
REQUIRE_CSRF_HEADER |
True |
the 403 in the upload table above. application/json clients — GraphiQL included — never see it |
MAX_SUBSCRIPTIONS_PER_CONNECTION |
50 |
the 51st concurrent subscribe on one WebSocket is answered with an error frame naming the limit; the socket and every subscription already running on it keep going |
Try subscriptions live¶
The playground serves the v2 native subscription engine behind both
standards-based transports: graphql-transport-ws on /ws/graphql/ and SSE
(graphql-sse / text/event-stream) on /graphql/stream. The easiest way
to watch them work is the built-in browser client:
- Open http://127.0.0.1:8000/graphql/client/. WS mode is
pre-selected and already points at
/ws/graphql/; press Connect. -
Press the run (▶) button. The editor is already holding a runnable document — the client ships
yourSubscription(action: ALL_ACTIONS) { id }and its introspection renames the placeholder to the first field this schema's subscription root advertises, which here ispostSubscription. Widen the selection set if you want more than the id:The rename needs introspection, and this playground sets
ALLOW_INTROSPECTION = Trueso it happens. On a project that leaves the flag off, the document still saysyourSubscriptionand pressing ▶ answersCannot query field 'yourSubscription' on type 'Subscription'until you type your own field name. -
Trigger a change from GraphiQL in another tab (any existing author id works — grab one with
{ authors { results(page: 1) { id name } } }):mutation { postCreate(newPost: { title: "Live update", author: 1, status: PUBLISHED }) { ok post { id title } } }The client streams the
nextevent with the new post instantly. The explicit published status is required for an anonymous subscriber; omitting it creates a draft that this stream intentionally hides. Both playground subscriptions setpayload_mode = "full", so the whole serialized instance is deliverable — not just theid.
To try the SSE transport instead, switch the client's toggle to SSE
and set the SSE endpoint to http://127.0.0.1:8000/graphql/stream (the
playground mounts the SSE view on that route) before connecting.
More things to try:
- Per-subscriber filters — subscribe with
commentSubscription(action: ALL_ACTIONS, filter: { post: { exact: 1 } }) { id text }and only that post's comments are delivered. - Private subscription — log in at
/admin/first, then subscribe tonoteSubscription(action: ALL_ACTIONS) { id title }from the same browser session. It is gated byAuthenticatedFieldsMiddleware, andsubscription_scopeserver-forces "only my notes" — another user's note changes never reach you.
Run the test suite¶
The playground ships genuine end-to-end tests that drive the same consumer,
SSE view and schema the server runs — a WS round-trip (subscribe → ORM
Post.objects.create() → next frame received) and the equivalent SSE
round-trip, the multipart upload with both spellings of the part name, the projection boundary
on every axis it can reach, both arms of the relation hatch, and the two
settings that ship on:
Several of them assert the verbatim answer strings this page and the
project's own README.md quote, so a message that changes turns the suite red
instead of leaving the documentation quietly wrong.
Where to go next¶
- Sample Application — the illustrative tutorial companion
- Query Recipes and Mutation Recipes
- Subscriptions — the full transport and engine guide
- Query Optimization — what the optimizer does on every playground query