Post

Running GROQ on Postgres without writing a query compiler

Full GROQ semantics from day one, with the queries a Studio actually issues staying cheap: parse with groq-js, narrow with SQL, evaluate in JavaScript.

A vertical gradient from deep navy to pale mint.

GROQ is a big language. It has projections, joins, functions, slices, ordering, conditionals and a type system that does not map cleanly onto SQL's. Implementing it as a GROQ-to-SQL compiler is the obvious idea and the wrong first move: every gap in such a compiler is a production query that returns wrong results, silently.

So this platform does something less clever and much safer.

  1. Parse the query with groq-js — the same parser Sanity's own tooling uses — to get an AST.
  2. Narrow. A planner walks the filter and lifts out the predicates Postgres can evaluate cheaply: _type equality and in, _id equality and in, defined(field), plain field equality, slug.current equality, and the draft/published discrimination the perspective implies. It also lifts order and a trailing slice when doing so cannot change the result.
  3. Evaluate. Those predicates become a JSONB query against the documents table, and the rows that come back are streamed into groq-js, which computes the whole expression.
  4. Dereference in batches. -> resolves through a per-request DataLoader, so a projection over N documents costs one extra query rather than N.

The trade is explicit: correctness is total from the first day, and performance is incremental — every predicate added to the planner makes a class of query cheaper without changing what any query returns.

What "narrowing cannot change results" means

It is the condition on step 2, and it is stricter than it sounds. A planner may only push a predicate down when the rows it excludes could not have contributed to the answer. Lifting order is safe; lifting a [0] is safe; lifting the filter of a query whose projection dereferences into other documents is safe for the candidate set but does not narrow what the dereference may touch — which is why a dereferencing query's cache tags widen to the whole dataset rather than to the documents it named.

sql
-- what a planner-narrowed lookup by slug actually runs
select id, data from documents
where project_id = $1 and dataset = $2
  and is_draft = false
  and data->>'_type' = 'post'
  and data->'slug'->>'current' = $3

There is an expression index on that last term, because it is the single most common frontend lookup there is.

The search case

Sanity v6 defaults to a search strategy that issues text::match with wildcards, phrases and negation, and searches Portable Text. Postgres already has the right primitive: each document row carries a search_text tsvector generated from its extracted plain text, with Portable Text blocks flattened. The planner pushes text::match down to a tsquery, and groq-js remains responsible for the final semantics — the same division of labour as everywhere else.