GraphQL
https://<ref>.api.snoutdata.com/graphql/v1 answers GraphQL over your own tables, views and
functions. There is no schema to write: it is read from the database, and it follows the database
when you change it. It is part of the data API, so it is on the plans the data API is
on, and it runs as the caller's role like every other read and write, so row-level security
decides what a query sees, exactly as it does for REST.
curl -X POST "https://<ref>.api.snoutdata.com/graphql/v1" \
-H "apikey: <your anon key>" \
-H "Authorization: Bearer <your anon key>" \
-H "Content-Type: application/json" \
-d '{"query":"{ todosCollection(first: 10) { edges { node { id title } } } }"}'
It is answered inside your database by pg_graphql, which on SnoutData Cloud is our own
implementation of the same extension, snout_graphql
(Apache-2.0), so a query or a generated client written for another hosted Postgres works unchanged.
From JavaScript, @snoutdata/client has no GraphQL helper of its own: POST to the same URL with
your usual GraphQL client, and put the signed-in user's access token in Authorization so their
policies apply.
Names
Each table in the schemas the API serves (public by default) becomes a type. Names keep their
database spelling unless you ask for GraphQL's usual casing, which is one comment on the schema:
comment on schema public is '@graphql({"inflect_names": true})';
With it, blog_post becomes the type BlogPost, its collection blogPostCollection, and a
column created_at the field createdAt. The examples on this page assume it.
Reading
Every table has a collection on Query, paged the Relay way:
{
blogPostCollection(
first: 20
filter: { published: { eq: true }, title: { ilike: "%postgres%" } }
orderBy: [{ createdAt: DescNullsLast }]
) {
edges {
cursor
node { id title createdAt }
}
pageInfo { hasNextPage endCursor }
}
}
- Paging:
firstwithafter, orlastwithbefore, take a cursor from a previous page;offsetis there too. A page holds at most 30 rows unless you change it (max_rows, below). - Filters:
eq,neq,lt,lte,gt,gte,in,is(NULLorNOT_NULL), and for textlike,ilike,startsWith,regexandiregex; array columns havecontains,containedByandoverlaps. Combine them withand,orandnot. - Order:
AscNullsFirst,AscNullsLast,DescNullsFirst,DescNullsLast, per column, in the order you list them. - One row: a table with a primary key has
blogPostByPk(id: 1), and each of its rows has anodeIdthatnode(nodeId: ...)fetches from anywhere.
Foreign keys become fields both ways: a comment has its blogPost, and a post has its
commentCollection, which takes the same arguments as any collection.
{
blogPostCollection(first: 5) {
edges {
node {
title
author { name }
commentCollection(first: 3, orderBy: [{ createdAt: DescNullsLast }]) {
edges { node { body } }
}
}
}
}
}
Writing
Each table has three mutations. Every one returns the rows it touched, and none touches more rows
than atMost allows (default 1 for update and delete), so a filter that matched more than you
meant fails instead of rewriting the table.
mutation {
insertIntoBlogPostCollection(objects: [{ title: "Hello", published: false }]) {
affectedCount
records { id title }
}
}
mutation {
updateBlogPostCollection(set: { published: true }, filter: { id: { eq: 7 } }, atMost: 1) {
records { id published }
}
}
mutation {
deleteFromBlogPostCollection(filter: { id: { eq: 7 } }) {
affectedCount
}
}
A request is one transaction: if anything in it fails, nothing it wrote is kept, and the response
carries the error with data set to null.
Functions
A function in a served schema becomes a field: a stable or immutable one on Query, a
volatile one on Mutation. A function whose one argument is a table's row becomes a computed
field on that table's type:
create function public.full_name(rec public.person)
returns text stable language sql
as $$ select rec.first_name || ' ' || rec.last_name $$;
{ personCollection { edges { node { fullName } } } }
Shaping the schema with comments
Everything else is a comment on the object, @graphql(...) with JSON inside:
| On | Write | What it does |
|---|---|---|
| a schema | {"inflect_names": true} | GraphQL casing, as above |
| a schema | {"max_rows": 100} | The largest page, for every table in it (default 30) |
| a schema | {"introspection": true} | Answers introspection queries (off by default, see below) |
| a table or view | {"name": "Post"} | Renames its type |
| a table or view | {"totalCount": {"enabled": true}} | Adds totalCount to its collection |
| a table or view | {"aggregate": {"enabled": true}} | Adds aggregate { count sum avg min max } |
| a table or view | {"max_rows": 500} | Its own largest page |
| a view | {"primary_key_columns": ["id"]} | Gives a view a key, so it gets a type |
| a view | {"foreign_keys": [...]} | Relationships to or from a view |
| a column, function or foreign key | {"name": "..."} | Renames the field |
| an enum | {"mappings": {"in_progress": "IN_PROGRESS"}} | Renames its values |
comment on table public.blog_post is '@graphql({"totalCount": {"enabled": true}})';
A comment that is not valid JSON inside @graphql(...) makes every request fail with the JSON
error, so the next query after the mistake tells you.
Introspection is off until you switch it on
GraphiQL, Apollo's tooling and code generators all read the schema by introspection, and a project answers introspection only for schemas whose comment says so:
comment on schema public is '@graphql({"inflect_names": true, "introspection": true})';
Off by default because introspection describes every table the caller can see, which is more than most applications want to hand an anonymous visitor. Row-level security still applies either way: introspection shows the shape of what a role may see, never the rows.
Limits
- A selection nests at most 32 levels deep, and fragments may not refer to themselves.
- A page holds at most
max_rowsrows (30 unless a comment raises it). atMostbounds every update and delete (1 unless you pass more).
Changes take effect on the next request
A new table, column, function, grant or comment is in the schema on the next request, with nothing to reload. Creating temporary tables, or refreshing a materialized view, does not make the next request slower: only a change the schema can show makes it read the database's catalogue again.
Also read
- REST and GraphQL, for switching the data API on and the policies that govern both.
- Authentication, which issues the tokens those policies read.
- The project API, for the two keys and every path prefix.