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 — installs the local library (editable) + daphne
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 — creates the venv, installs django-graphex[subscriptions] from the parent checkout (editable) plus daphne |
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.
WebSocket origin policy¶
The shipped ALLOWED_HOSTS is exactly 127.0.0.1, localhost and
testserver. 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 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. 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
mutations, noteSubscription) require a logged-in user:
- 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.
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.
get_queryset is field-level, and the playground shows both sides of
that. 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 } } } } } |
DRAFT, ARCHIVED, PUBLISHED — the auto-expanded relation reads the prefetch cache, so PostType.get_queryset never runs |
The third row is not a bug; it is the boundary the scoping hatch exists to close, and the second row is that hatch mounted. Declaring it costs the relation its nested filter paths — see the table under Filtering.
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/Update/Delete; 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 — one column, two documented ways in |
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, so 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); 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) |
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 } }→Relation-spanning ordering is not permittedposts { results(ordering: "nonexistent") { title } }→Invalid ordering field
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 { hasNextPage endCursor }
}
}
}
authors {
totalCount
results(page: 1) { name }
}
comments {
totalCount
results(first: 3) { text }
pageInfo { hasNextPage endCursor }
}
}
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 the Did you mean …? tails below are
present; flip that flag off and the tails are stripped while the messages
themselves stay.
| 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 } } |
Field 'bio' is not defined by type 'AuthorFilterInput'. Did you mean 'id'? |
{ 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 } } |
Field 'internalNote' is not defined by type 'CommentCreateGenericType_p84059c'. — 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 |
One surface in this playground does not honour the same projection
CommentSubscription is a hand-written Subscription bound to
Meta.model = Comment, so it compiles its event type and its
CommentSubscriptionFilterInput from the model — internalNote is
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 leaves it open on purpose and pins it with a test
that goes red the day it is closed.
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"}}}}
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 |
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 } } }):The client streams the
nextevent with the new post instantly. 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 in all four spellings, 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