Skip to content
Talk to our solutions team

GraphQL

data.svc generates a GraphQL SDL from the same entity definitions that drive REST, and resolves it through the same execution engine. There is no separate GraphQL schema file, no resolver code, and no second execution path: every top-level field turns into one engine Query, Create, Update or Delete call, so access rules, row-level security, validations, compliance and audit behave exactly as they do on REST.

For an entity named order, the generated SDL carries three query fields, three mutation fields, and three input types.

SDL elementNameSignature
Get by idorderorder(id: ID!): Order
ListorderListorderList(filter: OrderFilter, limit: Int, offset: Int, orderBy: String): [Order!]!
CountorderCountorderCount(filter: OrderFilter): Int!
CreatecreateOrdercreateOrder(input: CreateOrderInput!): Order!
UpdateupdateOrderupdateOrder(id: ID!, input: UpdateOrderInput!): Order!
DeletedeleteOrderdeleteOrder(id: ID!): Boolean!
Object typeOrderone field per declared field, plus one per relation
Create inputCreateOrderInputwritable fields, non-null preserved
Update inputUpdateOrderInputwritable fields, all optional
Filter inputOrderFilterevery field, all optional

Type names are the PascalCase form of the entity name — the name is split on _ and each part is capitalised. Query field names are not PascalCased: they use the entity name verbatim.

The SDL declares three custom scalars — DateTime, JSON and Decimal. Decimal is declared but never assigned to a field.

Field typeGraphQL type
string, text, fileString
int, bigintInt
float, decimal, moneyFloat
boolBoolean
datetime, date, timeDateTime
uuid, ulidID
json, array, bytesJSON
enum, stateString
custom types, anything elseString

A ! is appended only when the field is required: true and nullable is not set. Setting nullable: true on a required field removes the non-null marker.

Each entry under relations: emits one field on the parent type, typed by cardinality.

type:Emitted field
onetomany<name>: [RelatedType!]
manytomany<name>: [RelatedType!]
onetoone<name>: RelatedType
manytoone<name>: RelatedType

At execution, every sub-selection that has its own selection set is collected by name and passed to the engine as an eager-load include. The engine loads the relation, then the response is trimmed to exactly the fields you selected.

Only the top-level field’s immediate sub-selections become includes. Deeper nesting is projected out of whatever the engine already returned, so a third level resolves only when the engine’s include machinery already loaded it.

These field names are excluded from both Create<T>Input and Update<T>Input:

id · created_at · updated_at · created_by · updated_by · deleted_at · deleted_by · version · tenant_id

Any field marked computed: is excluded as well. In Update<T>Input every remaining field has its non-null marker stripped — all update fields are optional. <T>Filter is different: it contains every field including the excluded ones, all optional.

This definition:

entities:
- name: widget
fields:
- name: id
type: ulid
defaultvalue: ulid()
- name: title
type: string
- name: qty
type: int

generates this SDL:

scalar DateTime
scalar JSON
scalar Decimal
type Widget {
id: ID
title: String
qty: Int
createdby: String
createdon: DateTime
updatedby: String
updatedon: DateTime
deletedby: String
deletedon: DateTime
}
input CreateWidgetInput {
title: String
qty: Int
createdby: String
createdon: DateTime
deletedby: String
deletedon: DateTime
}
input UpdateWidgetInput {
title: String
qty: Int
createdby: String
createdon: DateTime
deletedby: String
deletedon: DateTime
}
input WidgetFilter {
id: ID
title: String
qty: Int
createdby: String
createdon: DateTime
updatedby: String
updatedon: DateTime
deletedby: String
deletedon: DateTime
}
type Query {
widget(id: ID!): Widget
widgetList(filter: WidgetFilter, limit: Int, offset: Int, orderBy: String): [Widget!]!
widgetCount(filter: WidgetFilter): Int!
}
type Mutation {
createWidget(input: CreateWidgetInput!): Widget!
updateWidget(id: ID!, input: UpdateWidgetInput!): Widget!
deleteWidget(id: ID!): Boolean!
}

The six audit columns are not in the definition. They are added to every entity before the SDL is generated, which is why they appear on the type, in the filter, and — for the four that are not computed — in the create and update inputs.

Listing with projection and ordering:

query {
widgetList(limit: 10, orderBy: "qty DESC") {
id
title
}
}

Fetching one row:

{
widget(id: "w1") {
id
qty
}
}

Creating a row — id is absent from the input because it is an auto field, and the engine’s defaults fill it:

mutation {
createWidget(input: { title: "Created", qty: 1 }) {
id
title
}
}

The response keys results under the field name, or under the alias when one is given — data.createWidget, not data.widget.

ArgumentTypeBehaviour
filter<T>FilterEquality only. Each supplied key becomes one field = value condition, ANDed.
limitIntMaximum rows.
offsetIntRows to skip.
orderByStringA single string of the form "field ASC" or "field DESC".
OperationEngine callNotes
<entity>Query, limit: 1Filters on the column literally named id. Returns null when no row matches.
<entity>ListQueryIncludes from sub-selections, then projection.
<entity>CountQueryCounts rows in memory — see below.
create<Entity>CreateRETURNING *, projected to the selection set.
update<Entity>UpdateFilters on id, RETURNING *.
delete<Entity>DeleteFilters on id.

Three behaviours differ from what the SDL suggests:

  • <entity>Count fetches rows and counts them in Go. There is no COUNT(*) pushdown. The engine caps an uncapped query at 10 000 rows, so any count above 10 000 is reported as 10 000.
  • delete<Entity> always returns true when the engine returns no error, regardless of how many rows matched. Deleting a nonexistent id reports success.
  • Create and update echo your input when the mutation’s RETURNING produced no rows. You can receive values that were never persisted, and server-generated defaults are absent in that case.
FeatureState
Introspection (__schema, __type)Validates, then fails to resolve. Use GET /data/schema/graphql.
SubscriptionsNo type Subscription is generated and no transport serves one.
Fragments and inline fragmentsParse and validate, then contribute no data and raise no error.
Directives (@skip, @include)Never evaluated.
Interfaces, unions, custom directivesNever generated.
Multiple operations per requestOnly the operation selected by operationName runs.
A hosted playground or GraphiQL pageNot served.
GET /data/graphqlRejected with 405; the endpoint is POST-only.

Aliases and variables do work. Variables are merged with the operation’s declared defaults before arguments are resolved, and both top-level and nested fields honour an alias.

There are no GraphQL subscriptions. Schema generation emits only type Query and type Mutation; the resolver has no subscription operation kind; no WebSocket or SSE route serves one. Design material and generated event contracts that mention subscribing to entity events describe a transport that is not served. There is no push surface to fall back to either: row history is stored, not streamed, and you read it by polling an entity’s ?asof= and ?version= reads.

Introspection does not work, so the SDL is served as a plain document:

Terminal window
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/data/schema/graphql

That returns text/plain; charset=utf-8. GET /data/schema/entities lists entity names with field and relation counts only; GET /data/schema/entities/<entity> is the field-by-field JSON view, returning name, table_name, primary_key, materialization, fields[] and relations[]. GET /data/schema/jsonschema.json and GET /data/schema/zod.ts return generated bindings.

A schema change becomes visible to GraphQL only once the tenant’s engine is rebuilt; the generated SDL is cached per tenant and datastore. DELETE /data/cache drops the tenant’s cached schemas, pools and engines so the next request rebuilds.

MethodPathAuth
POST/data/graphqlBearer token
POST/data/anon/graphqlAnonymous; access rules and RLS decide what resolves

Both serve the same generated SDL from the same resolvers — only the resolved principal differs. Append ?datastore=<name> to address a datastore other than the default.

The request body is the standard envelope:

{ "query": "{ widgetList { id title } }", "operationName": "", "variables": {} }

When operationName is empty the first operation in the document runs.

For the shared request conventions — tenant headers, bearer format, the error envelope, and the common query parameters — see the Data API HTTP reference.

GraphQL errors do not change the HTTP status.

ConditionStatusBody
Unknown-field or engine error200{"data": {...}, "errors": [...]} with the failing field null
Parse or validation error200{"errors": [...]} — no data key at all; nothing was dispatched
Non-POST method405method not allowed, text/plain
Body is not valid JSON400invalid JSON body, text/plain
No tenant on the request403Platform error envelope
Engine or SDL could not be built500Platform error envelope

An access denial, a validation failure and a constraint violation all arrive as HTTP 200 with an entry in errors[] scoped to that field’s path. Inspect the body, not the status.