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, base64 file uploads, 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 |
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 - 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.
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 / CommentMutation (DjangoModelMutation) → postCreate/Update/Delete, commentCreate/Update/Delete; NoteModelType.MutationFields() → 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 | UploadDocument accepts a Base64FileInput; MAX_UPLOAD_SIZE (5 MB) and MAX_REQUEST_BODY_SIZE (20 MB) guard it in config/settings.py |
Mutations |
| Permissions | NoteModelType.permission_classes = [IsAuthenticatedOrReadOnly]; a custom IsOwnerOrReadOnly(BasePermission); 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 filters); 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:
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. -
Enter a subscription and press the run (▶) button:
-
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, filters: { post: 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, plus schema/permission and client smoke tests:
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