GraphQL
A single shared GraphQL endpoint serves queries and mutations across every model, translated internally into the same CRUD operations REST uses – same access control, same row-level security.
Models marked "internal": true are not reachable from a root field over HTTP, exactly like the REST routes (a NOT_FOUND error). A relationship field can still join one: selecting user_id { id email } on a public model works when users is internal, under the joined model’s field-level read rules. Python code calling kirak.graphql() reaches every model, as it does with kirak.fetch().
POST /graphql
{ "query": "{ posts(status: \"published\", limit: 10) { id title created_at } }"}Response Envelope
Section titled “Response Envelope”GraphQL uses the same response envelope as REST. It is not
spec-compliant GraphQL-over-HTTP: there is no top-level errors[].
Success:
{ "statusCode": 200, "status": "success", "message": "GraphQL executed successfully", "data": { "posts": [ { "id": 1, "title": "Hello" } ] }}Error (rendered with the failing operation’s own status code):
{ "statusCode": 403, "status": "error", "error": "PERMISSION_DENIED", "message": "Access denied for 'secrets'", "data": null}Any error in any root field fails the whole request – there is no partial
data with a per-field error entry. An unknown model is NOT_FOUND (404),
matching REST. An unknown mutation verb is VALIDATION_ERROR (400). A
syntactically malformed query is also VALIDATION_ERROR (400)
(Invalid GraphQL syntax: ...). Any other unhandled error surfaces as
INTERNAL_ERROR (500).
GraphQL fires the same per-model CRUD hooks as REST:
- Mutations –
before_<op>andafter_<op>fire on the model for each mutation root field.createPostsfiresbefore_create/after_createon"posts". - Simple queries –
before_fetch/after_fetchfor plain field queries;before_search/after_searchwhen asearch:argument is used.
In addition, two request-level hooks wrap the entire GraphQL operation and are
registered on the "graphql" model name:
@kirak.on("graphql").hook("before_graphql")async def inspect_query(payload): # payload = {"query": "...", "variables": {...}} return payload
@kirak.on("graphql").hook("after_graphql")async def post_process(result): # result = full response envelope return resultbefore_graphql fires before query translation. after_graphql fires after all
root fields execute (and, for mutations, after the transaction commits).
Relationship join queries and aggregate queries (<model>_aggregate) are
GraphQL-only paths with no REST equivalent; they fire before_graphql/after_graphql
but not per-model CRUD hooks.
Multi-root mutations are atomic
Section titled “Multi-root mutations are atomic”A mutation with more than one root field runs all root fields inside one database transaction, serially. If any field fails, the entire mutation rolls back – earlier fields are not committed. GraphQL queries (reads) do not open a transaction.
Filtering
Section titled “Filtering”A plain argument value is equality. Use an object argument for other operators:
{ posts(status: "published", price: { gt: 100, lte: 500 }) { id title }}| Operator key | SQL |
|---|---|
| (plain value) | = |
eq |
= |
neq |
!= |
gt / gte |
> / >= |
lt / lte |
< / <= |
like / ilike |
LIKE / ILIKE (PostgreSQL native; MySQL falls back to LOWER(...) LIKE LOWER(...)) |
in / nin |
IN (...) / NOT IN (...) |
is_null |
IS NULL / IS NOT NULL depending on boolean value |
This is a different convention from REST’s field__operator query-string keys – GraphQL always nests the operator inside an object argument.
Dates are ISO-8601 strings, as in REST: created_at: { gte: "2026-09-01" }. A string that is not a date on a date or datetime field is a 400 VALIDATION_ERROR.
Plain field-filter queries (no search: argument, see below) delegate internally to the same _fetch function REST’s /fetch route uses, so the RLS rules that apply are the model’s fetch rules specifically. A query using search: delegates to _search instead and is scoped by the model’s search rules. For a model whose access block defines identical rules for both fetch and search (the common case – Kirak Studio generates them together), this distinction is invisible; a model with genuinely different rules per operation will see different results depending on whether search: was used.
Free-text search
Section titled “Free-text search”Add a search: argument to route a query through the model’s searchable fields ("searchable": true in models.json) instead of exact/operator filtering:
{ posts(search: "kirak") { id title }}This is the GraphQL equivalent of REST’s /search route and its search_term: a LIKE-across-searchable-fields match, OR’d together. search: can be combined with regular field filters in the same query – both apply. A model with no field marked searchable returns a permission error if search: is used against it, the same as REST’s /search route would.
Pagination & Sorting
Section titled “Pagination & Sorting”{ posts(limit: 10, offset: 20, orderBy: "-created_at") { id title }}limit, offset, orderBy (or order_by), and search are reserved argument names on every model field – they are never treated as filters even if a model happens to have a field with the same name.
A query with no limit: argument is capped at 10 rows by default (or the model’s max_limit if configured in models.json, whichever is smaller), the same default REST’s /fetch and /search routes use – it is not unbounded.
Relationships
Section titled “Relationships”A field can resolve a joined model if its schema entry declares foreign_key: true plus a relationship object (see Defining Models):
{ "schema": { "user_id": { "type": "integer", "foreign_key": true, "relationship": { "model": "users", "foreign_key": "id" } } }}With that in place, selecting the field with a sub-selection produces a LEFT JOIN:
{ posts { id title user_id { id email } }}Field-level read permissions on the joined model still apply to its nested selection.
Aggregates
Section titled “Aggregates”Every model also exposes a <model>_aggregate root field:
{ orders_aggregate(status: "completed", groupBy: "user_id") { count sum { total } avg { total } min { total } max { total } }}counttakes no sub-selection.sum/avg/min/maxtake a sub-selection of the numeric fields to aggregate.groupBy(orgroup_by) groups by one or more fields; without it, the whole filtered set is aggregated into one row.limit,offset,orderBy(ororder_by), and the same filter-operator syntax as regular queries are all supported.- Access is checked against the model’s
searchrules (notfetch), and their row conditions scope the rows aggregated. - Each aggregate comes back as
<function>_<field>, e.g.sum { total }->sum_total, next to thegroupByfields.
Mutations
Section titled “Mutations”Mutation field names are {operation}{Model}, where {operation} is create/update/upsert/delete/destroy/restore and {Model} is the model name with each _-separated part capitalized: order_items -> createOrderItems, blogPost -> createBlogPost. If two model names give the same {Model} (blog_post and blogPost), the snake_case one gets it:
mutation { createPosts(input: { title: "Hello", user_id: 1 }) { id title }
updatePosts(where: { id: 42 }, input: { title: "Updated" }) { id title }
deletePosts(ids: [1, 2, 3]) { id }}| Operation | Args | Notes |
|---|---|---|
create<Model> |
input |
Single record; the return selection is fetched by id after insert. |
update<Model> |
input, where |
where uses the same filter syntax as queries. |
upsert<Model> |
input |
Conflict detected on unique fields, same as REST. |
delete<Model> |
ids or where |
Soft delete. |
destroy<Model> |
ids or where |
Hard delete, irreversible. |
restore<Model> |
input, where |
Un-soft-delete. input must contain at least one restore field (deleted_at, status, etc.), same requirement as the REST restore endpoint. |
Requesting fields back from a mutation ({ id title }) triggers a follow-up fetch by id and is subject to the same field-level read permissions as a query.
Variables, Directives, Fragments, and Aliases
Section titled “Variables, Directives, Fragments, and Aliases”Standard GraphQL-over-HTTP conveniences are supported:
{ "query": "query($status: String, $withBody: Boolean) { published: posts(status: $status) { id title body @include(if: $withBody) ...meta } } fragment meta on Post { created_at updated_at }", "variables": { "status": "published", "withBody": true }}- Variables – declared in the operation signature, referenced with
$name, passed via the top-levelvariablesobject. - Directives –
@skip(if: Boolean)and@include(if: Boolean)are evaluated per-field. - Fragments – both named (
...fragmentName+fragment fragmentName on Type { ... }) and inline (... on Type { ... }) are resolved. - Aliases –
published: posts(...)renames the response key, as shown above. __typename– returns the model name as a string literal wherever selected.
Introspection is always disabled
Section titled “Introspection is always disabled”__schema and __type unconditionally raise PermissionDenied("GraphQL introspection is disabled") – this is not configurable per-request. If you need a schema for tooling, generate it from your models.json directly rather than querying /graphql.