gopgql
One annotated GraphQL SDL document → PostgreSQL 19 SQL/PGQ. Each
tab below is a complete, editable scenario: put a
schema and a query in on the left,
press Generate, and read the generated database
schema and the compiled query on the right. It runs entirely in your
browser as compiled Go (WebAssembly) — the real
sdl+generator+migrate+compiler
packages, no server and no JavaScript re-implementation. Nothing is
hardcoded: every output is generated from your input.
gopgql once more, which
regroups them — deduplicating parents across the one-to-many fan-out —
into the nested GraphQL response the panel leads
with. That is the whole round trip: a GraphQL query in, SQL out, rows
back, a GraphQL response out. The rows themselves stay one click away
underneath it.
INSERT statements its
example schema needs, in an editable Data pane that
runs just before the query. Change them — INSERT,
UPDATE, DELETE — press Run, and the response
reflects what you did. Writes are plain SQL rather than GraphQL
mutations because a SQL/PGQ property graph is a read-only view:
gopgql compiles queries against it and never writes
through it.
loading WebAssembly…
Nesting extends one MATCH chain rather than spawning a
second query, so a three-hop traversal is still a single
GRAPH_TABLE. Argument values travel as bind parameters
($1, …) and are never interpolated, and every pair of
vertex positions that could bind the same row is guarded with
<> — PostgreSQL does not enforce edge isomorphism.
These INSERTs fill the generated tables with the follow chain
Alice → Bob → Carol → Dave → Erin.
Edit it — INSERT, UPDATE
or DELETE — and press Run again to see the query answer
differently. A property graph is a read-only view, so gopgql compiles
queries and not mutations; changing data is plain SQL, and it runs against
the same in-memory database.
Executes the schema, the data SQL and the query above against a real PostgreSQL 19 compiled to WebAssembly, in this tab. Nothing is fetched until you press it: the first press downloads a ~15 MB PostgreSQL build (about 4.7 MB over the wire) and takes a few seconds to start. The database is in memory only and is discarded afterwards.
SQL/PGQ has no variable-length paths, so gopgql refuses a selection
nested past MaxDepth instead of silently truncating the
pattern. The rejection is a typed
*compiler.DepthExceededError raised at compile time, so
no SQL exists to send. The ceiling is per-Compiler
configuration (compiler.WithMaxDepth), not a constant —
it defaults to 3. Raise it below to
compile this four-hop query, or lower it until a shorter one is
refused.
The same rows as the Traversal tab, which is the schema this tab shares. The
chain is five people long, so a four-hop query still finds a path whose
vertices are all distinct. Edit it — INSERT,
UPDATE or DELETE — and press Run again to
see the query answer differently. A property graph is a read-only view, so
gopgql compiles queries and not mutations; changing data is plain SQL, and
it runs against the same in-memory database.
Unavailable while the query is refused — the rejection happens at compile time, so there is no SQL to send. Raise the ceiling until a query compiles and this becomes available. The first press downloads a ~15 MB PostgreSQL build (about 4.7 MB over the wire); nothing is fetched before that.
@column(name:) renames the physical column — and with it
the property the graph exposes, which is why the compiled query below
projects name while the GraphQL field is still
title. @column(type:) overrides the default
scalar mapping, @unique makes the database itself reject
a duplicate, and @index adds a secondary index with an
optional access method — bare, the name is derived from the table and
column.
Written against the physical column names, so the field
title is inserted as name — the same rename
the compiled query projects. Edit it — INSERT,
UPDATE or DELETE — and press Run again to
see the query answer differently. A property graph is a read-only view, so
gopgql compiles queries and not mutations; changing data is plain SQL, and
it runs against the same in-memory database.
Executes the schema, the data SQL and the query above against a real PostgreSQL 19 compiled to WebAssembly, in this tab — the result column is the renamed physical column. The first press downloads a ~15 MB PostgreSQL build (about 4.7 MB over the wire); nothing is fetched before that.
@default(value:) and @check(expr:) reach
the database as written — they are raw SQL, and the escape
hatch is deliberate: whoever writes the SDL already owns the schema.
So a check expression names left_at, the physical
column, not leftAt, the GraphQL field. Every check is
emitted under a name gopgql derives itself
(employees_role_check,
employees_check_1), because a later delta has to drop
it by name and an anonymous constraint would first have to be looked
up in a live database.
@key(fields:) is a natural key alongside
the surrogate id, never in place of it: the table keeps
id uuid PRIMARY KEY, gains
CONSTRAINT employees_key UNIQUE (tenant, email), and
the key's columns are listed in the property graph's
KEY (...) clause — which is what lets the query below
select an employee by tenant and email
while edges still reference the id.
Two employees of one tenant and the edge between them. The first row is the
one the query selects by its natural key. Edit it —
INSERT, UPDATE or DELETE — and
press Run again to see the query answer differently. A property graph is a
read-only view, so gopgql compiles queries and not mutations; changing data
is plain SQL, and it runs against the same in-memory database.
Executes the schema, the data SQL and the query above against a real
PostgreSQL 19 compiled to WebAssembly, in this tab — the
defaults and CHECK constraints are enforced by the
database itself. The first press downloads a
~15 MB PostgreSQL build (about
4.7 MB over the wire); nothing is fetched before that.
@renamedFrom(name:) is a hint, never an inference. A
differ sees one column disappear and another appear; it cannot tell
a rename from a genuine drop-and-add, and guessing wrong destroys
the rows one way or loses them the other. The hint carries the
previous GraphQL name, from which gopgql derives the
physical names to look for in the folded prior state — so the delta
below moves the column instead of dropping one and adding
another. Delete the @renamedFrom and watch it become
DROP COLUMN + ADD COLUMN. A hint matching
nothing in the prior state emits nothing at all, which is what lets
the same SDL keep generating cleanly once the rename has landed.
Selecting two relationships at one level would need comma-separated
path patterns in a single MATCH — PG19 parses those but
will not execute them. gopgql splits instead: the chain up to the
branching level stays one GRAPH_TABLE, each branch
becomes its own, and the outer query LEFT JOINs them on
the projected ids. Guards that would have spanned the split move to
the ON clause, so a branch still cannot walk back to a
vertex an ancestor bound.
The same rows as the Traversal tab. Dave’s follow back to Alice is
what gives her the incoming edge this query’s followedBy
branch reads. Edit it — INSERT, UPDATE or
DELETE — and press Run again to see the query answer
differently. A property graph is a read-only view, so gopgql compiles
queries and not mutations; changing data is plain SQL, and it runs against
the same in-memory database.
Executes the joined GRAPH_TABLE calls above against
a real PostgreSQL 19 compiled to WebAssembly, in this tab.
The first press downloads a
~15 MB PostgreSQL build (about
4.7 MB over the wire); nothing is fetched before that.
A GraphQL interface makes several tables one queryable position. An
interface carrying @node(label:) becomes a label its
implementors' tables all expose — (v0 IS actor); an
interface without it is matched by alternation over their own labels
— (v0 IS bot|person). Edit the query to
{ profiles { name } } to see the second form.
Both implementors of Actor are filled, and both edge tables
carry a row, so matching the shared label returns one from each. Edit it
— INSERT, UPDATE or DELETE
— and press Run again to see the query answer differently. A property
graph is a read-only view, so gopgql compiles queries and not mutations;
changing data is plain SQL, and it runs against the same in-memory database.
Executes the schema, the data SQL and the query above against a real PostgreSQL 19 compiled to WebAssembly, in this tab — the rows come from both tables the interface spans. The first press downloads a ~15 MB PostgreSQL build (about 4.7 MB over the wire); nothing is fetched before that.
The goose migrations a first generation emits from a schema. No
migration mixes table DDL with property-graph DDL, so there are two,
numbered in the order they are applied — and each carries a
-- +goose Down section that is its exact inverse.
Revise that schema and gopgql folds those migrations back into a
model, diffs it, and emits the next generation: the property graph
taken down, then ALTER TABLE, then the graph rebuilt over
the tables of its own generation — PostgreSQL will not alter a column a
live graph exposes. Consecutively numbered, so one
goose up applies them in that order. The revision below is
diffed against the schema in the scenario above, so editing either one
regenerates it.
Folding migrations back into a model is sound only while nobody
alters the database out of band. Nothing else in gopgql can notice
when someone has: the generator, the differ and the compiler all
reason from the SDL, so they would go on agreeing with each other
while the database quietly diverged.
gopgql conform --sdl … --dsn … is the check on that
assumption — it reads the property graph back out of a live database
and reports the differences as typed findings.
It compares the property graph, and only that:
which elements exist, the labels they carry, and the properties they
expose — the whole of what pg_propgraph_element,
pg_propgraph_label and pg_propgraph_property
record. That is why the mapping is shown here on its own. The
DEFAULT, the two CHECK constraints and the
natural key's UNIQUE that the Constraints tab
generates from this very schema are nowhere in it, and are not
compared: an empty report means the graph mapping matches the SDL,
not that the tables underneath it do. The exit status carries the
verdict — 0 conforms, 2 drifted, and
1 for a check that never ran (unparseable schema,
unreachable database, missing graph), because those call for
completely different next moves.
gopgql conform, kept to show the report's structure and
all five finding kinds at once. It is the one output on this page
that is not generated from your input. The graph mapping above it
is generated, here and now, by the same Go the check
compares against.
gopgql has two ways to turn a match into a nested GraphQL response.
Go-side projects one flat column per selected field
and regroups the rows in Go. SQL-side asks
PostgreSQL to assemble the response with
json_build_object and json_agg, and
returns it as a single response column. The strategy is
a compiler option (compiler.WithShaping), not an
execution-time switch, because the two emit different SQL.
The MATCH pattern, its predicates and its bind
parameters are identical under both — only the projection around
them changes, which is why which rows match is not part of what the
two strategies have to be proven to agree about. Where the query
branches, the flat statement LEFT JOINs the branches
and a parent with m and n children yields
m×n rows; the SQL-side statement aggregates each branch to
an array before the join, so that cross-product never forms.
shape.Encode, which is encoding/json over
the response value. It is not a comparison of the bytes
PostgreSQL sent: json_build_object spaces its colons and
emits keys in argument order, which encoding/json does
neither of. Both paths are decoded into the same Go value and
re-encoded by that one encoder, which is why the bytes agree. The
same comparison runs in CI against a real
postgres:19beta2 in the test/parity suite;
the two strategies are measured against each other by
test/bench.
These INSERTs branch on purpose: Alice follows two people
and is followed by two others. That is the case the two strategies
disagree about the shape of — Go-side asks for
2×2 = 4 flat rows, SQL-side for one — and
agree about the response to. Edit it and press Run again.
Runs the schema, the data SQL, and then both compiled statements against one real PostgreSQL 19 compiled to WebAssembly, in this tab. One database, so a difference between the responses could only come from the strategies. Nothing is fetched until you press it: the first press downloads a ~15 MB PostgreSQL build (about 4.7 MB over the wire) and takes a few seconds to start. The database is in memory only and is discarded afterwards.