Skip to content

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.

make reset
make test  # pytest runs with --no-migrations

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_field has something to find — posts(filter: { search: "django" }) answers totalCount: 20 rather 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 Account and Invoice targets so the typed GenericForeignKey union 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:

  1. Open http://127.0.0.1:8000/admin/ and log in as demo / demo12345.
  2. 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:

{
  posts(filter: { search: "author 3" }) {
    totalCount
    results(limit: 5) { id title }
  }
  authors {
    results(page: 1) {
      name
      postCount
      posts(filter: { title: { icontains: "Post 1" } }) {
        totalCount
        results(limit: 3, ordering: "-id") {
          title
          status
          category { name }
        }
      }
    }
  }
}

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:

{
  attachments {
    totalCount
    results {
      caption
      target {
        __typename
        ... on AccountType { label balance }
        ... on InvoiceType { number amount }
      }
    }
  }
}

Try the directives too

Every directive from all_directives is registered. For example:

{
  posts {
    results(limit: 3) {
      title @uppercase
      slug: title @slugify
      teaser: body @truncate(length: 40)
    }
  }
}

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:

{ postsFlat(ordering: "-viewCount", limit: 1) { id viewCount } }

It answers "9007199254740993". The Decimal side needs no login:

{ attachments { results { target { ... on AccountType { balance } } } } }

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:

  1. Open http://127.0.0.1:8000/graphql/client/. WS mode is pre-selected and already points at /ws/graphql/; press Connect.
  2. 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 is postSubscription. Widen the selection set if you want more than the id:

    subscription {
      postSubscription(action: ALL_ACTIONS) {
        id
        title
        status
      }
    }
    

    The rename needs introspection, and this playground sets ALLOW_INTROSPECTION = True so it happens. On a project that leaves the flag off, the document still says yourSubscription and pressing ▶ answers Cannot query field 'yourSubscription' on type 'Subscription' until you type your own field name.

  3. 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 next event 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 set payload_mode = "full", so the whole serialized instance is deliverable — not just the id.

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 to noteSubscription(action: ALL_ACTIONS) { id title } from the same browser session. It is gated by AuthenticatedFieldsMiddleware, and subscription_scope server-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:

make test          # runs the complete playground suite

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