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.

Generate is instant, pure and offline. Each query tab also has a Run in PostgreSQL button, which executes the generated schema, the data and the compiled query against a real PostgreSQL 19 with SQL/PGQ, compiled to WebAssembly and running in this tab — nothing is sent anywhere. It downloads a ~15 MB build the first time you press it and never before, and the database is in memory only, so reloading the page starts clean. The flat rows that come back go through 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. Each runnable tab starts with the 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.

Generate

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.

Run in PostgreSQL

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.

Generate

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.

Run in PostgreSQL

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.

Generate

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.

Run in PostgreSQL

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.

Generate

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.

Run in PostgreSQL

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.

Generate

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.

Generate

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.

Run in PostgreSQL

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.

Generate

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.

Run in PostgreSQL

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.

Generate

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.

Generate

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.

There is no database in a browser, so the report below is a fixture — a recorded run of 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.

Run executes both statements against one database, and shapes each result into a response. What it then compares is the two canonical encodingsshape.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.
Generate

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.

Run both strategies

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.