(an oliphaunt, for the elephant that is PostgreSQL's grammar)
oliphant is a hand-written, pure Go (no cgo, no wasm) implementation of pganalyze/pg_query_go, in the same family as:
- sqlc-dev/zetajones — GoogleSQL (BigQuery)
- sqlc-dev/meyer — SQLite
- sqlc-dev/teesql — T-SQL (SQL Server)
- sqlc-dev/doubleclick — ClickHouse
- sqlc-dev/marino — MySQL (TiDB fork, goyacc)
Its immediate purpose is to replace the pganalyze/pg_query_go/v6 +
wasilibs/go-pgquery build-tag pair in
sqlc/internal/engine/postgresql
— the last sqlc engine still backed by a foreign parser (C via cgo, wasm via
wazero everywhere else). PostgreSQL is the only dialect where sqlc pays a cgo
tax, a wasm tax on Windows, and a multi-minute first-build tax (pg_query_go
compiles ~100 vendored C files, gram.c alone is 2.7 MB).
The API does not change. oliphant is a drop-in replacement for
pg_query_go: same exported functions, same generated protobuf types, same
JSON output, same error type, same fingerprints. Consumers change an import
path and delete a build constraint. sqlc's 92 KB convert.go — written
against pg_query's protobuf node shapes — must work unmodified after the
import swap.
The reference is pganalyze/libpg_query,
which vendors ~220,000 lines of extracted PostgreSQL C (the real
gram.c/scan.c generated from gram.y/scan.l, plus node infrastructure)
behind a small hand-written API layer, with 11 patches applied to upstream
PostgreSQL before extraction.
Initial pin: libpg_query 17-6.2.2 (PostgreSQL 17.7) — the exact tag
pg_query_go v6.2.2 ships and the version sqlc builds against at the time.
(The pin advanced on 2026-08-17: the current pin is libpg_query 18.0.0
(PostgreSQL 18.4), ParseResult.Version == 180004, via the § Regeneration
workflow. No pg_query_go release ships libpg_query 18 yet, so the oracle
builds pg_query_go's wrapper against the 18.0.0 sources locally — see
oracle/update-pg-query-go.sh.) The plan treats "advance the pin" as a
first-class workflow (§ Regeneration) rather than targeting a moving version.
Two consequences of the patches matter and are easy to miss:
- oliphant ports patched PostgreSQL, not vanilla:
$1parameter references are legal in more grammar positions (patch 01, what makes normalized queries re-parseable),param_junkis removed from the scanner (09), the lexer tracks token end positions (03) and comments as tokens (04), andLIMIT_OPTION_DEFAULTis reordered for proto3 zero-value semantics (05). - The oracle for every golden output is the pinned libpg_query build, never upstream PostgreSQL.
Adopted unchanged from zetajones/meyer/doubleclick:
- Hand-written recursive descent. No parser generators, no grammar files
feeding tools. One
parse*method per production, each carrying an attribution comment naming thegram.yrule it implements (simple_select:,a_expr:,opt_sort_clause:, …). The pinnedgram.yandscan.l(post-patch) are vendored underinternal/reference/for documentation only. - Corpus-driven development loop. Vendored corpus in consolidated
.testfiles (cases separated by==, input/expectation by--), per-filemetadata.jsonsidecars withtodotracking,cmd/next-testto pick the next failing case,go test ./parser -check-parseto harvest newly passing cases, and the hard rule that expected outputs are never edited by hand — they come from the oracle or not at all. - Eager lexer. Whole-input tokenization up front; tokens carry byte
offsets. (Here the token stream is itself public API — see
Scan.) - Fail-fast, single-error reporting, exactly like the reference (bison aborts at first syntax error).
- CI: GitHub Actions,
go build ./...+go test -race ./..., no cgo anywhere in the module.
The siblings expose parser.Parse(ctx, io.Reader) ([]ast.Stmt, error).
oliphant instead reproduces pg_query_go's root package verbatim:
package oliphant // module github.com/sqlc-dev/oliphant
func Parse(input string) (*ParseResult, error)
func ParseToJSON(input string) (string, error)
func Scan(input string) (*ScanResult, error)
func Deparse(tree *ParseResult) (string, error)
func Normalize(input string) (string, error)
func NormalizeUtility(input string) (string, error)
func Fingerprint(input string) (string, error)
func FingerprintToUInt64(input string) (uint64, error)
func HashXXH3_64(input []byte, seed uint64) uint64
func SplitWithScanner(input string, trimSpace bool) ([]string, error)
func SplitWithParser(input string, trimSpace bool) ([]string, error)
func IsUtilityStmt(input string) ([]bool, error)
func ParsePlPgSqlToJSON(input string) (string, error)
func Summary(input string, truncateLimit int) (*SummaryResult, error)plus the 28 Make* AST constructors from makefuncs.go (ported verbatim —
they are already pure Go) and the parser subpackage's Error type:
type Error struct {
Message string
Funcname string
Filename string
Lineno int
Cursorpos int
Context string
}The root package is named oliphant; consumers alias it at the import site,
which is what pg_query_go users already do, so migration is exactly one line
and no call site changes:
-import pg_query "github.com/pganalyze/pg_query_go/v6"
+import pg_query "github.com/sqlc-dev/oliphant"The parser subpackage's byte-level entry points (ParseToProtobuf,
DeparseFromProtobuf, ScanToProtobuf, …) are kept for compatibility, as
thin proto.Marshal/Unmarshal wrappers — they exist upstream only because
cgo speaks bytes; here they are conveniences.
The siblings' "zero dependencies, ever" rule bends once: pg_query_go's API
returns *ParseResult and friends generated by protoc-gen-go, and
google.golang.org/protobuf is load-bearing for API parity (users call
proto.Marshal on parse results today). go.mod therefore carries exactly
one runtime dependency: google.golang.org/protobuf. Nothing else, ever —
XXH3 is implemented in-house (§ Fingerprinting) rather than imported.
Layout implication: the generated types cannot live in the root package (the internal parser must build them, and root calls the parser — that would be an import cycle). So:
ast/holdspg_query.pb.go, regenerated from the pinnedpg_query.proto(276 messages, 73 enums;json_nameannotations and field numbers unchanged, so wire format and protobuf-JSON are identical to upstream's).- The root package exposes generated type and const aliases
(
type ParseResult = ast.ParseResult, one line per exported type/enum value) sopg_query.SelectStmt,pg_query.JoinType_JOIN_INNER, etc. all resolve exactly as they do in pg_query_go (with the import aliased topg_query). The alias file is emitted bycmd/generate— never hand-maintained.
The grammar actions build these protobuf structs directly. libpg_query's whole outfuncs/readfuncs layer (C parse nodes → protobuf) exists because the C parser builds C structs; a Go parser building the Go structs natively deletes that entire translation layer.
This is the largest grammar in the family — bigger than GoogleSQL, far bigger than SQLite. What recursive descent must consciously replicate:
- The precedence ladder —
%left/%right/%nonassocdeclarations ingram.y, implemented as one precedence-climbing expression loop (doubleclick-style Pratt), with PostgreSQL's quirks: unary minus binding,AT TIME ZONE,COLLATE,AT LOCAL, postfixIS NULL/ISNULL,BETWEEN/IN/LIKE/ILIKE/SIMILARvia theNOT_LAmechanism, qualified operators (OPERATOR(schema.+)), and the multi-character operator rules that live half in the scanner. base_yylexlookahead filtering — PostgreSQL keeps its grammar LALR(1) by merging token pairs in a filter between lexer and parser (parser.c):NOT→NOT_LAbeforeBETWEEN/IN/LIKE/...,NULLS→NULLS_LAbeforeFIRST/LAST,WITH→WITH_LAbeforeTIME/ORDINALITY,WITHOUT→WITHOUT_LAbeforeTIME,FORMAT→FORMAT_LAbeforeJSON. oliphant reproduces this as a filter layer between lexer and parser so the parser sees exactly the reference token stream.- 494 keywords in four reserved-ness categories (unreserved, column-name,
type-function-name, reserved) plus the orthogonal
bare_labelattribute (PG 14+SELECT expr aliaswithoutAS). As in meyer, every identifier-position consumption routes through a small set of helpers encoding exactly these sets; the tables are generated from the pinnedkwlist.h, andScan'sKeywordKindoutput cross-checks them on the whole corpus. - The ~3,000 lines of support functions at the bottom of
gram.y—insertSelectOptions,makeOrderedSetArgs,doNegate,SplitColQualList,SystemTypeName,makeIntConst, precision/typmod packing forINTERVAL/NUMERIC,check_func_name, … These build the exact node shapes and must be ported faithfully; each gets a Go counterpart with the same name and an attribution comment. - Location fidelity. Every node's
locationfield (plus PG 17'slist_start/list_end,rexpr_list_start/rexpr_list_end) must byte-match the reference — sqlc slices source text with these, and the tree-diff oracle catches every deviation. The grammar is precise about which token a node's location points at (e.g. anA_Expr's location is the operator's, aTypeCast's is the expression's start); these choices fall out of@Nreferences ingram.yactions and are ported rule by rule. - Literal semantics: integer literals that overflow
int32becomeFloatnodes (process_integer_literal), numeric literals keep their source spelling, string continuation across newlines ('a'\n'b'),standard_conforming_stringson — all exactly as the pinned build behaves.
Flex's 12 exclusive start conditions become an explicit state machine:
extended (E'') and standard strings, dollar-quoted strings ($tag$...$tag$),
quoted identifiers, Unicode escapes (U&'...' / U&"..." + UESCAPE),
bit/hex strings, nested /* */ comments, and PostgreSQL's operator munching
(longest-match with the "trailing +/- only after non-SQL operator chars"
rule, != → <> normalization). Numeric literals include PG 16+
underscore separators and hex/octal/binary integers. The patches apply:
token end offsets are tracked (patch 03) and param_junk does not exist
(09).
The scanner has its own byte-exact oracle: Scan() returns
ScanResult{Tokens: []*ScanToken{Start, End, Token, KeywordKind}}, and the
reference emits the same protobuf. Token-stream equality over the entire
corpus is the acceptance gate for the lexer milestone, before any parsing
work starts. SplitWithScanner (a ~220-line lexer-level statement splitter)
ships in the same milestone.
meyer's oracle could only say accept/reject + error message. libpg_query dumps everything, so conformance here is the strongest in the family — for every corpus statement:
- Parse: the protobuf tree equals the reference tree exactly
(
proto.Equal, diffs rendered via protobuf-JSON). - ParseToJSON: byte-identical output. Upstream's JSON does not come
from protojson — it is a hand-rolled C emitter (
pg_query_outfuncs_json.c) with its own conventions (fields in struct order under the C type'sjson_name, zero/NULL fields omitted, specific float and string escaping). oliphant generates an equivalent emitter from the same metadata rather than bending protojson into shape. - Errors: same
Error{Message, Filename, Funcname, Cursorpos}—syntax error at or near "x"with the same cursor position, scanner errors attributed toscan.l/scanner_yyerror, grammar-action errors (ereportcalls ingram.y, e.g. "improper qualified name") ported with their exact strings.Linenois excluded from conformance (upstream's own tests zero it — it is a C source line number; oliphant emits the reference's value where stable, and nothing depends on it). - Version:
ParseResult.Versionreports the pinnedPG_VERSION_NUM(170007).
Fingerprinting is in scope, for three reasons:
HashXXH3_64is exported API, so oliphant needs a pure-Go XXH3-64 implementation regardless (implemented ininternal/xxh3, ~500 lines, verified against upstream's published test vectors and the three hard-coded vectors in pg_query_go's tests). The marginal cost of fingerprinting proper is only the tree walk.- The walk is generated, not written: libpg_query produces its 15k-line
fingerprint_defs.cfromsrcdata/struct_defs.json; oliphant'scmd/generateemits the equivalent Go from the same vendored metadata. The hand-written parts are small and well-understood: version-3 seed, depth cutoff at 100, empty-subtree elision (hash-state snapshot/restore), sort-and-dedup of list elements forfromClause/targetList/cols/rexpr/valuesLists/args(with the pointer-keyed cache that makes the 1.1 MB stress query tractable), the skip-list (locations, prepared statement names, cursor names,NOTIFYpayloads, …), pre-PG15 legacy field names (str), theRangeVaralias/schema jumbling rules, andAEXPR_OP_ANY/AEXPR_INfolding toAEXPR_OP. - The corpus is ready-made: ~96 query/hash pairs in libpg_query's
fingerprint_tests.c, 78 in pg_query_go'stestdata/fingerprint.json(including the 1.1 MB insert), plus differential fuzzing against the oracle for free.
Fingerprints are stable across libpg_query majors by design (that is the point of the legacy-name shims), so this work is not redone per PG version.
The corpus question has a better answer for PostgreSQL than for any sibling: libpg_query already vendors the full PostgreSQL regression suite, and the oracle can classify and golden-ify every statement mechanically.
Inventory (all at the pinned tag, sizes measured):
| Source | Contents |
|---|---|
libpg_query/test/sql/postgres_regress/ |
233 files, ~118,400 lines — PostgreSQL's own src/test/regress/sql/, the single densest grammar-coverage corpus that exists; includes deliberately-invalid statements (negative cases with exact error goldens) |
libpg_query/test/sql/plpgsql_regress/ |
13 files, ~3,500 lines (PL/pgSQL milestone) |
libpg_query/test/sql/deparse/ + deparse-depesz/ |
13 + 150 files — deparser roundtrip corpora |
| libpg_query inline test tables | 72 parse, 62 normalize, ~96 fingerprint, 419 deparse, plus scan/split/normalize_utility/summary cases — transcribed by the corpus tool, never by hand |
| pg_query_go tests | testdata/fingerprint.json (78 cases), parse_test.go/normalize_test.go/split_test.go tables — the drop-in-compatibility smoke set |
sqlc internal/endtoend/testdata/ |
1,049 PostgreSQL .sql files / ~929 named queries — exactly the inputs oliphant must keep accepting for sqlc; imported as a corpus tier and later exercised for real by sqlc's own TestReplay during integration |
PostgreSQL tarball contrib/*/sql/, src/pl/*/sql/ |
thousands more statements, extracted by the same tool from the pinned tarball (stretch tier — added when regress conformance nears 100%) |
| Differential fuzzing | cmd/difftest: mutation fuzzing with the live oracle in-process — every corpus statement is a seed; the invariant is full tree/error equality, not "doesn't panic". This turns ~120k corpus lines into effectively unlimited cases and is the answer to "is the corpus large enough?" |
Pipeline (cmd/regenerate): a separate Go module under oracle/ (so the
main module never depends on cgo) links pg_query_go v6.2.2, splits each
corpus file with the oracle's own splitter, runs every statement through
parse/scan/normalize/fingerprint/deparse, and writes consolidated .test
files: SQL, then expected protobuf-JSON tree (or ERROR: message +
cursor position), in the family's ==/-- format with metadata.json todo
sidecars. psql backslash commands and COPY ... FROM stdin payloads in the
regression files are filtered at extraction. Goldens are committed;
conformance CI is pure Go with no oracle present. A scheduled CI job re-runs
regeneration against the pinned oracle to prove goldens are reproducible.
oliphant/
├── go.mod # github.com/sqlc-dev/oliphant; dep: google.golang.org/protobuf
├── LICENSE # MIT (oliphant's own code)
├── LICENSE.POSTGRESQL # PostgreSQL License (ported grammar/scanner logic)
├── LICENSE.LIBPG_QUERY # BSD-3 (pganalyze; ported glue/deparser/fingerprint logic)
├── PLAN.md # this file
├── CLAUDE.md # dev loop: next-test → implement → -check-parse
├── oliphant.go # public API (package oliphant) — mirrors pg_query_go
├── makefuncs.go # ported verbatim from pg_query_go
├── aliases.go # generated type/const aliases into ast/
├── ast/
│ └── pg_query.pb.go # generated from pinned pg_query.proto
├── parser/ # public subpackage: Error + protobuf-bytes entry points
├── internal/
│ ├── lexer/ # scan.l port; keyword tables (generated)
│ ├── parse/ # the recursive-descent grammar, one file per gram.y region
│ │ ├── parse_select.go # select_stmt, set ops, CTEs, VALUES, locking
│ │ ├── parse_expr.go # a_expr/b_expr/c_expr precedence climbing, func_expr
│ │ ├── parse_dml.go # insert/update/delete/merge/copy
│ │ ├── parse_ddl_*.go # create table/alter table/index/…, ~150 stmt types
│ │ ├── parse_utility.go # set/show/vacuum/explain/grant/…
│ │ └── gram_support.go # ports of gram.y's static helper functions
│ ├── emit/ # generated JSON emitter (ParseToJSON parity)
│ ├── fingerprint/ # generated walk + hand-written special cases
│ ├── xxh3/ # in-house XXH3-64
│ ├── normalize/ # pg_query_normalize.c port (constant → $n)
│ ├── deparse/ # postgres_deparse.c port (12k lines C → Go)
│ ├── plpgsql/ # pl_gram.y port + JSON output (last milestone)
│ ├── summary/ # summary API port
│ └── reference/ # vendored gram.y, scan.l, kwlist.h (docs only)
├── srcdata/ # vendored libpg_query srcdata/*.json (generator input)
├── oracle/ # SEPARATE go module; links pg_query_go v6 (cgo)
│ └── ... # oracle binary used by cmd/regenerate & cmd/difftest
├── parser/testdata/ # consolidated corpus + goldens + metadata sidecars
└── cmd/
├── generate/ # emits ast aliases, JSON emitter, fingerprint walk,
│ # keyword tables from srcdata + proto (replaces the
│ # Ruby generators; this is the PG-upgrade story)
├── regenerate/ # corpus extraction + golden generation via oracle/
├── next-test/ # pick next todo case
├── debug-parse/ # parse argv SQL, print tree/JSON/error
└── difftest/ # mutation differential fuzzing vs live oracle
Ordered so that every milestone lands with its own oracle-backed acceptance gate; the corpus harness exists before the first line of the lexer.
- Scaffolding + codegen. ✅ Landed 2026-08-15. Module, licenses, CI;
vendor
srcdata/,pg_query.proto, reference grammar files at the pin;cmd/generateproducingast/pg_query.pb.go, root aliases, keyword tables;oliphant.goAPI stubs returning not-implemented;makefuncs.goand theparser.Errortype ported. Gate: pg_query_go'sparse_test.goexpected-tree literals compile unchanged against oliphant's types — met (parse_test.go, import swap only). - Corpus + oracle. ✅ Landed 2026-08-15.
oracle/module wrapping pg_query_go v6.2.2;cmd/regenerateextracting the corpus tiers into.testgoldens (trees, errors, scan tokens, normalize, fingerprint, deparse, splits);internal/testfilereader; harness running everything astodo. Gate: goldens reproducible byte-for-byte across two regeneration runs — met, and re-verified weekly by theregenerateCI workflow. See "As-built notes" below for measured counts and deferrals. - Lexer. ✅ Landed 2026-08-15.
scan.lport +base_yylexfilter layer;Scan,SplitWithScanner,HashXXH3_64(+internal/xxh3) ship. Gate: token streams byte-identical to the oracle across the entire corpus, including scanner error messages and cursor positions — met: all 43,373 scan cases and all 8 split_scanner cases pass; xxh3 is verified against 126 oracle-generated vectors covering every length class and seed path. See "As-built notes (milestone 3)". - Expressions + SELECT. ✅ Landed 2026-08-15.
a_expr/b_expr/c_exprprecedence machinery, constants and typecasts,func_expr(includingjson_*,xml*, aggregateFILTER/WITHIN GROUP, window functions),SELECTend-to-end: target list,FROM(joins,LATERAL, table functions,TABLESAMPLE,XMLTABLE,JSON_TABLE), grouping sets, set operations, CTEs (MATERIALIZED,SEARCH/CYCLE),VALUES, locking clauses. The largest single chunk of work.ParseToJSONships here too (the generated-equivalent JSON emitter plusParse/ParseToProtobuf). See "As-built notes (milestone 4)". - DML. ✅ Landed 2026-08-16.
INSERT(ON CONFLICT,OVERRIDING),UPDATE,DELETE,MERGE,RETURNING,COPY,PREPARE/EXECUTE, cursors. See "As-built notes (milestone 5)". - DDL, part 1. ✅ Landed 2026-08-16.
CREATE/ALTER TABLE(the second-biggest grammar region: constraints, partitioning, identity, storage options),CREATE INDEX, views, sequences, schemas. See "As-built notes (milestones 6–7)". - DDL, part 2 + utility. ✅ Landed 2026-08-16. The long tail of ~150
statement types: functions/procedures, triggers, policies, roles/grants,
types/domains, FDWs, publications/subscriptions,
EXPLAIN/VACUUM/SET/SHOW, event triggers, …SplitWithParserandIsUtilityStmtship here. Gate for 4–7: 100% of the regress corpus — trees, JSON bytes, error messages, cursor positions — met: the parse suite's todo list is empty (42,970 cases across every tier), as are scan and both split suites. - Normalize + Fingerprint. ✅ Landed 2026-08-16. Port
pg_query_normalize.c(constant locations →$n,NormalizeUtility); fingerprint walk + the hand-written special cases. Gate: all normalize/fingerprint goldens, including the 1.1 MB stress case — met: 43,385 fingerprint, 43,254 normalize, and 25 normalize_utility todos graduated. See "As-built notes (milestones 8–9)". - Deparse. ✅ Landed 2026-08-16. Port
postgres_deparse.c(12,107 lines at the pin, mechanical node-by-node) targeting the protobuf structs directly. Gate: byte-equality with the oracle's deparse output on the corpus — met: all 43,352 deparse todos graduated (which subsumes the reference's parse → deparse → reparse roundtrip criterion, since the goldens are the oracle's own deparse output). See "As-built notes (milestones 8–9)". - Summary. ✅ Landed 2026-08-17. Port the summary API (classification + smart truncation). Gate: the deferred summary corpus extracted and green — met: all 43,992 summary and 43,345 summary_truncate cases pass (the regress tiers untruncated and at limit 100, plus libpg_query's summary test-call inputs at their upstream limits). See "As-built notes (milestones 10–11)".
- PL/pgSQL. ✅ Landed 2026-08-17. Port
pl_gram.y+pl_compsubset + the JSON serializer behindParsePlPgSqlToJSON. Gate: the deferredplpgsql_regress/+plpgsql_samplescorpus green — met: all 690 plpgsql cases pass, and the plpgsql_regress files also feed the standard SQL suites (all passing on arrival). Every corpus suite's todo list is empty: 2,067 files / 308,561 cases across eleven suites. See "As-built notes (milestones 10–11)". - Hardening.
cmd/difftestmutation fuzzing vs the live oracle as a scheduled CI job;go test -raceover the parallel corpus run; benchmarks vs pg_query_go (cgo) and wasilibs (wasm) — expect wins from no cgo crossings and true in-process parallelism; memory profiling on the stress queries. Benchmarks + memory profiling landed 2026-08-17; see "As-built notes (milestone 12, benchmarks + profiling)". Fuzzing,-race, and the wasilibs comparison remain. - sqlc integration (in the sqlc repo). Replace
parse_default.go/parse_wasi.gowith one unconditional file; swap the import path inconvert.goet al.; dropwasilibs/go-pgquery,tetratelabs/wazero, and the cgo requirement; sqlc's endtoend suite is the acceptance gate. Windows andCGO_ENABLED=0become first-class.
Measured at the pin, where the plan's estimates differ:
- The corpus as regenerated: 1,525
.testfiles / 217,229 cases (~80 MB) across eight suites (parse,scan,normalize,normalize_utility,fingerprint,deparse,split_scanner,split_parser). postgres_regress/has 224 files at17-6.2.2(not 233);kwlist.hyields 491 keywords (not 494); the proto has 273 messages and 71 enums (not 276/73). The inline tables carry 36 parse, 12 scan, 25 normalize, 25 normalize_utility, 78 fingerprint, 418 deparse, and 8 split inputs;deparse-depesz/is 150.psqlfiles under*.d/subdirectories.- The
.testformat needed one addition over the family convention: input or expectation lines that collide with the==/--markers are escaped with a leading|(SQL comment banners of exactly--are everywhere in the regress files).internal/testfileround-trips this bijectively. cmd/regeneratepreserves passing status across runs: a case identical in name, input, and expectation to one that had graduated out oftodostays passing, so a pin advance puts exactly the diff back on the todo list.cmd/generate -protorequiresprotoc+protoc-gen-goon PATH; the pure-Go-aliases/-keywordsmodes are what CI verifies.- Deferred, deliberately: the sqlc endtoend tier (needs per-directory
engine classification in the sqlc repo; import it alongside milestone 4),
plpgsql_regress/(milestone 11), and summary golden extraction (milestone 10). The stretch tarball tier remains stretch. (The plpgsql and summary tiers landed with milestones 10–11: the corpus is now 2,067 files / 308,561 cases across eleven suites; only the sqlc endtoend and stretch tiers remain out.)
- The scanner (
internal/lexer) is pull-based (Scanner.Next), one token per call likecore_yylex;Scan/SplitWithScannerdrain it eagerly, but milestone 4's parser must pull lazily so grammar errors can win over scanner errors that lie further right, as they do under bison. - Flex's longest-match/rule-order discipline is reproduced structurally
except for the numeric-literal rules, whose fourteen overlapping patterns
(
decinteger…real_junk, including flex backtracking inside the*_junkrules) are resolved by computing every candidate match length and picking the winner (internal/lexer/numbers.go). - Two pinned-oracle behaviors worth knowing: token End offsets come from
the patch-03
yyllocendfor multi-rule tokens (pg_query_scan.cuses it for SCONST/USCONST/BCONST/XCONST/IDENT/ UIDENT/C_COMMENT andyylloc + yylengfor the rest — with the eager scanner both reduce to "position after the token"), and a comment between abase_yylexmerge pair blocks the merge (the lookahead is a rawcore_yylexcall, so pg_query_go v6.2.2 rejectsSELECT 1 WHERE 1 NOT /* c */ IN (2)where vanilla PostgreSQL does not; the filter reproduces this, pinned by a unit test). - Scanner error cursor positions are character-based
(
pg_mbstrlen_with_lensemantics, per-lead-byte stride, not rune count); the "at or near" text runs from the error location to the end of the current match (flex's hold-char NUL), or to end of input in<<EOF>>rules. internal/xxh3implements onlyXXH3_64bits_withSeed(scalar paths), the sole entry point the API needs.
- The JSON emitter (
internal/emit) is a protobuf-descriptor-driven walker rather than generated per-node code: the pinned proto was itself generated from the C struct metadata, so field declaration order,json_name, and field kinds already encode everythingpg_query_outfuncs_json.cdoes. The hand-written special cases (A_Const, the five value nodes,Listvariants,_outTokenescaping, two always-emitted empty-string fields) mirror the reference file. Byte-parity is proven corpus-wide byinternal/emit.TestGoldenRoundTrip, which protojson-decodes all 42,970 tree goldens and re-emits them byte-identically. Generated per-node emitters remain an option for milestone 12 if profiling wants them. - The parser (
internal/parse) reproduces bison's conflict resolution by precedence climbing: left-assoc operators parse their right operand one level up,%nonassoclevels error when chained within one climb, and the%precannotations are reproduced at their call sites. Two behaviors worth calling out:a_expr subquery_Op sub_typeenters the loop at the operator token's own precedence but reduces at%prec Op(so1 = 2 = ANY(...)fails at the second=while= ANY(...) + 1binds the+outside), and qual-requiring JOINs absorb further joins into their right operand (bison shifts because the rule cannot reduce until its join_qual) while CROSS/NATURAL joins stay left-associative. - LALR keeps sub-select and parenthesized-expression paths alive
simultaneously; the recursive-descent port uses bounded backtracking
(token-position mark/reset) in exactly four places:
(-headed c_expr / in_expr / table_ref (select_with_parens trials, pre-gated by a skip-the-parens lookahead for a SELECT/VALUES/TABLE/WITH head), and the OVERLAY/SUBSTRING/JSON_OBJECT special-vs-generic argument forms. - The
NOT_LA/WITH_LA/FORMAT_LAmerges arrive pre-applied from the milestone-3 filter; grammar errors report through the same scanner_yyerror path the C stack uses (base_yyerror→parser_yyerror→scanner_yyerror), so syntax errors carryscan.lerror data and the merged tokens report only their first word, exactly as parser.c's hold-char poke arranges. - Corpus effect: 16,300+ parse cases came off the todo list. Every remaining parse todo either starts with a statement type from milestones 5-7 or embeds one (DML inside CTEs); the only pure-SELECT stragglers are negative cases whose error texts come from those same unimplemented productions.
cmd/difftestcurrently ships the interim-summary/-showfailure classifier used to drive the milestone; the mutation fuzzer replaces it in milestone 12.
- The five statements that carry
opt_with_clause(SELECT, INSERT, UPDATE, DELETE, MERGE) share one entry point: the WITH clause is parsed once and the following head token picks the statement, reproducing how the LALR tables keep all five alternatives alive through the with_clause.PreparableStmt(CTE bodies,COPY (query),PREPARE ... AS) now accepts the full set. - The grammar's unreserved-keyword ambiguities are each resolved by the same
single token of lookahead bison uses: FETCH/MOVE direction keywords vs. a
cursor named
next/prior/forward/…,DEALLOCATE PREPARE xvs. a plan namedprepare,WHERE CURRENT OFvs. a column namedcurrent(OF decides),insert_rest's'(' column listvs. a parenthesized SelectStmt (skip-the-parens select-head gate, as in table_ref), andrelation_expr_opt_aliasrefusing bareSETas an alias (the production outranks SET's%nonassocprecedence). - COPY's two action ereports ("STDIN/STDOUT not allowed with PROGRAM",
"WHERE clause not allowed with COPY TO") reproduce bison's
empty-production location rule:
@8falls back through empty copy_delimiter/opt_using to the file-name token's location. CREATE TABLE ... AS EXECUTEis deferred to milestone 6 with the rest of the CREATE TABLE machinery (OptTemp,create_as_target,opt_with_data); plainEXECUTE name [(params)]ships now.- Corpus effect: 7,337 parse todos and 73 deparse todos (error-path cases,
where the golden is the parse error itself) graduated. Every remaining
parse todo starts with — or embeds — a statement type from milestones 6-7,
and the
-summaryclassifier shows no tree or error mismatches against any implemented statement.
- Statement dispatch is a family of lookahead routers
(
internal/parse/ddl_dispatch.go,ddl_alter_generic.go): each CREATE/ALTER/DROP object-kind arm parses the object reference once, then the following token picks the production, mirroring how the LALR tables keep RenameStmt/AlterObjectSchemaStmt/AlterOwnerStmt/ AlterObjectDependsStmt alive alongside each object's own ALTER statement. A sharedparseAlterGenericTailimplements the four generic tails with a per-kind support mask, soALTER LANGUAGE x SET SCHEMAstill fails at the token bison would reject. - The CreateStmt/CreateAsStmt ambiguity (
CREATE TABLE t (…)element list vs.create_as_targetcolumn list) is the one new bounded-backtracking site: the CTAS reading is tried first and commits only when itsASarrives, else the position resets and the element list parses. - C's zero-valued enums surface as their proto-shifted spellings, which the
goldens made explicit: every
AlterTableCmd/RenameStmtcarriesbehavior: DROP_RESTRICT,RenameStmt.relationTypedefaults toOBJECT_ACCESS_METHOD, andAlterPublicationStmt.actiontoAP_AddObjects. Two NIL-vs-empty-list shapes matter: the zero-argument(*)aggregate stores a null list element inDefineStmt.args, and an emptyBEGIN ATOMICbody stores[NIL], not[[]]. - Support-function ereports carry their C call site's funcname
(
processCASbits,SplitColQualList,makeOrderedSetArgs,preprocess_pubobj_list, …), andConstraintAttributeSpec's own location is always -1 (PG'sYYLLOC_DEFAULTgives empty productions -1 and the left recursion propagates it), soprocessCASbitserrors have no cursor position. - The
base_yylex-merged*_LAtokens keep their first word's keyword classification in the lexer but are never identifiers in the grammar; the identifier-class helpers reject them (found vianulls firstinindex_elem, whereNULLS_LAmust not parse as an opclass name). makeRangeVarFromAnyNamebuilds viamakeNode, soinhstays false — unlikemakeRangeVar's true — which the CompositeTypeStmt goldens pin.cmd/difftodo(debug aid) prints input/want/got for a file's remaining parse todos; it drove the long-tail mismatch hunt to zero.- Corpus effect: all 19,787 remaining parse todos graduated, plus the 8
split_parsercases. The remaining todo suites (deparse, fingerprint, normalize, normalize_utility) belong to milestones 8–9.
- The fingerprint walk (
internal/fingerprint) is descriptor-driven rather than generated per-node code, the same call the JSON emitter made in milestone 4: upstream'spg_query_fingerprint_defs.cis generated from the same struct metadata the pinned proto came from, so the descriptor already carries the field set, kinds, and (after an explicit sort) the alphabetical field order. The hand-written parts mirror the upstream generator's tables: skip-nodes and skip-fields, the six sorted-dedup list field names with the listsort cache (the exponential-blowup guard the 1.1 MB insert needs),AEXPR_OP_ANY/AEXPR_INfolding,RangeVardigit-stripping and temp-table elision, TypeCast-of-constant elision, theResTarget.name-under-SELECT-targetList rule, and the pre-PG15 legacy value-node field names. The C's per-field XXH3 state snapshot/restore reduces to "elide the field name if the subtree appended zero bytes", so tokens stream into a byte buffer andinternal/xxh3stays one-shot. - Normalize's tree walk is
const_record_walkerbacked by a port of PG 17'sraw_expression_tree_walker; node types that walker does not know stop the subtree walk exactly like the swallowedelogupstream. Two bug-compatibility details mattered: constant lengths come from the milestone-3 lexer (theU&''trailing-whitespace trim is a no-op because patch-03 token ends already exclude the UESCAPE lookahead), and the location sort is a port ofpg_qsortitself — the C sort is unstable, and for duplicate locations (aMultiAssignRefsource walked once per target column) the parameter number a constant gets depends on which duplicate the Bentley–McIlroy partition leaves first (SET (c,b,a) = ($1, b+$4, DEFAULT) WHERE c = $7). - The deparser's part/group/nesting machinery (built for the pretty-print
mode pg_query_go never exposes) still shapes plain output — parts join
with single spaces except after
(or before a part starting with)or;, part groups fold their major keyword into their first part, and the merge pass runs against the default 80-column limit — so it is ported exactly, while the pretty-print and comment paths themselves are omitted as unreachable. Deparse errors surface via panic/recover standing in for the Cereportlongjmp; no corpus input triggers one (the goldens' ERROR cases are all parse-stage errors). - proto3 cannot represent C's NULL-vs-empty-string distinction; where the
deparser branches on it, call sites decided the port (e.g.
deparseOptBooleanOrString's NULL guard is dead upstream — every caller passesstrVal— soSET x TO ''must fall through to print''). - Corpus effect: milestone 8 graduated all 43,385 fingerprint, 43,254
normalize, and 25 normalize_utility todos; milestone 9 graduated all
43,352 deparse todos. Every suite's todo list is now empty; the remaining
unimplemented entry points are
Summary(milestone 10) andParsePlPgSqlToJSON(milestone 11).
- The deferred corpus tiers landed first, as their own commit:
summary(regress + plpgsql_regress + libpg_query's summary test-call inputs, untruncated),summary_truncate(the same regress statements at limit 100 — the limit rides along as a-- truncate_limit: Ndirective line in the case input, stripped by both sides — plus the upstream truncate tests at their exact limits and every summary input at limit 50), andplpgsql(plpgsql_regress/+plpgsql_samples.sql, oneParsePlPgSqlToJSONgolden per statement). libpg_query's summary tests are call sites, nottests[]tables, socmd/regenerategrew a call extractor (summary("...", 0, limit)with C line splices removed); inputs are transcribed mechanically, expectations always from the oracle. The plpgsql_regress files also feed the five standard SQL suites — all of those passed the existing implementation on arrival. - Summary (
internal/summary) is three ports over one sharedraw_expression_tree_walker(nowinternal/rawwalk, PostgreSQL 17's nodeFuncs.c gated bypg_query_raw_tree_walker_supports): the table/alias/CTE/function/filter-column walk with its quirks preserved (WHERE clauses walked twice, so functions there are recorded once per pass; the filter-column pass aborts at sub-SELECTs;MERGE ... USINGsources are never tables), the statement-type walk (insertion-ordered set), and deparse-driven truncation. Truncation ordering needed the exactpg_qsortalgorithm again —list_sortmaps topg_qsortat the pin, and equal (depth, length) pairs are ordered by the partition scheme, not the comparator. List nodes count as depth levels, matchingWALKon a List field. The"…"dummy-node replacements and the multibyte fallback chop (pg_mbcharcliplenover UTF-8) are byte-exact ports. - PL/pgSQL (
internal/plpgsql) ports what libpg_query builds, not vanilla PostgreSQL:extract_source.rbmocksparse_datatype(type text kept verbatim; only RECORD/REFCURSOR/CURSOR/TEXT recognized, by length-limited case-insensitive prefix), stubs the%TYPE/%ROWTYPElookups to NULL (sofoo%rowtypefalls through to the verbatim-text path), replacesmake_return_stmt(no return-type checks; RETURN never captures a varno), and failsfunction_parse_error_transposeunconditionally — so compile errors carrycompilation of PL/pgSQL function "f" near line Ncontext and no cursor, with filenames as the vendored build's__FILE__(pl_gram.y,src_pl_plpgsql_src_pl_scanner.c, ...). - The pl scanner layers on the milestone-3 lexer: the core scanner runs
with the PL reserved keyword list, modeled by re-classifying the SQL
lexer's output (SQL keywords become plain identifiers unless
PL-reserved; the
n'...'NCHAR special falls back to IDENT"n"), plus the pushback stack,A.B.Cdatum composition, and the statement-start/unreserved-keyword dance.pl_gram.ybecame recursive descent with statement linenos computed at bison's reduce points —plpgsql_latest_linenois stateful and feeds the error context, so call order is part of conformance. check_sql_exprneeded the core grammar's PL/pgSQL raw-parse modes:parse.ParseWithModeaddsRAW_PARSE_PLPGSQL_EXPR(thePLpgSQL_Exprproduction — everything that can follow SELECT, minus SELECT) andASSIGN1..3(PLAssignStmt). Embedded-SQL errors keep the core message/filename/funcname but lose their cursor (plpgsql_sql_error_callbackflushes the external position).cmd/generate -plpgsqlemitsinternal/plpgsql/tables.gofrom the newly vendoredpl_reserved_kwlist.h/pl_unreserved_kwlist.h/plerrcodes.h(condition names with their duplicate counts — duplicate names produce condition chains of that length).- Corpus effect: all 43,992 summary, 43,345 summary_truncate, and 690
plpgsql todos graduated; every suite's todo list is empty (2,067 files /
308,561 cases). pg_query_go's
summary_test.gois ported verbatim andTestParsePlPgSQLnow runs green; no entry point returns not-implemented anymore.
- The root
benchmark_test.gomirrors pg_query_go v6.2.2's benchmark file name-for-name (same queries, same globals trick), with additions: JSON / Scan variants and*Stressbenchmarks over the corpus's largest input, the 1.1 MB multi-VALUES INSERT (fingerprint suite, case 073).oracle/benchmark_test.gois the cgo twin — identical names and inputs against the pinned pg_query_go — so a run of each diffs directly:benchstat cgo.txt pure.txt(after normalizing thepkg:line, since the two files live in different modules). - Measured (4 vCPU Xeon 2.8 GHz, go1.24.7): raw parse is 3–10% faster
than cgo single-threaded and 29% faster on the stress query;
Scanis at parity. Small-query parallel parse runs 6–26% behind cgo on this box: the Go side pays GC for every AST node while libpg_query's arena allocations are invisible to Go accounting, and the cgo crossing itself parallelizes fine. Normalize is 1.6–3.6× slower, fingerprint 4.3–6.3×, ParseToJSON ~5× — all three are protobuf-reflection-driven walks, so the standing option of generated per-node emitters is where that time would come back if those entry points ever matter to a consumer. (cgo's 1 alloc/op in these tables is just the Go-side result copy; C-side allocations are not observable, so allocs/op is only meaningful within the pure-Go column.) - Profiling (alloc_space on the stress parse) found two fixable hotspots,
both landed with the benchmarks. The parser's token buffer regrew via
append doubling — 72% of all bytes allocated — and is now pre-sized to
len(input)/3(the stress case's ~3 bytes/token is the dense extreme), cutting stress parse from 92 MB to 39 MB and 185 ms to 146 ms per op. The fingerprint walk re-sorted each node's field descriptors on every visit; the per-type order is static and now memoized (−28–31% time, −25% allocs). - What remains is the tree itself: ~2 allocations per AST node (the node
struct plus its
ast.Nodeoneof wrapper) and the final protobuf marshal — 20–138 allocs for the upstream benchmark queries. That is the cost of the pg_query_go-compatible protobuf AST, not overhead to engineer away. - Still open from the milestone: difftest mutation fuzzing as a scheduled
CI job,
go test -raceover the parallel corpus run, and the wasilibs (wasm) comparison.
Everything derived is derived by committed tooling from the pin:
cmd/generate (from srcdata/ + pg_query.proto) and cmd/regenerate
(from the corpus + oracle). Advancing to a new libpg_query tag is a
defined workflow, not a rewrite:
- Bump the pin; re-vendor
srcdata/, proto, reference files, corpus. - Re-run
cmd/generate(new nodes/fields appear inast/, aliases, JSON emitter, fingerprint walk, keyword tables). - Re-run
cmd/regenerateagainst the new oracle; every diff is either a new grammar feature (goes on the todo list, driven down bynext-test) or a changed node shape (mechanical). - Grammar diffs are read from
git diffof the vendoredgram.y/scan.l— upstream's parser changes per major are small relative to the whole.
Module versioning follows pg_query_go's convention once stable: a PostgreSQL major bump is an oliphant major bump.
- No semantic analysis — no catalog lookups, no type checking, no name resolution. Errors PostgreSQL raises after raw parsing are out of scope, exactly as in libpg_query (whose analysis-phase entry points are mocked).
- Not a formatter. Deparse matches the reference deparser's output byte-for-byte; it makes no beautification promises beyond it.
- No error recovery / multi-error reporting — first error aborts, like bison and every sibling.
- No options plumbing upstream doesn't expose in Go: the
*_optsC variants and deparse pretty-printing are not part of pg_query_go's Go API and are not part of oliphant's v1 (revisit if pg_query_go adds them). - No new dialect. oliphant parses exactly what the pinned libpg_query accepts — including its patches — and nothing else.
- Grammar scale. This is the family's biggest port (estimate 35–50k lines of hand-written Go vs ~25–29k for zetajones/teesql). Mitigation: the corpus loop makes progress strictly incremental and measurable; the regress suite's file-per-feature layout gives natural work units; nothing blocks on completeness (every unimplemented production fails as a todo, not a crash).
- Error-message/cursor parity with bison. A recursive-descent parser does not naturally fail on the same token bison does in every case. The regress corpus's negative cases + difftest give exhaustive coverage; where RD and LALR genuinely diverge, the parser tracks the furthest-consumed token to reproduce bison's report point. meyer proved this bar reachable; zetajones hit byte-parity including positions.
- JSON byte-parity. Field ordering and omission rules are generated from the same metadata upstream uses, and diffed byte-wise over the whole corpus — divergence is caught the day it appears, not at release.
- Fingerprint stability. Version-3 fingerprints are frozen by test vectors; the generated walk plus special cases are verified against both upstream corpora and difftest.
- Upstream drift. libpg_query majors track PostgreSQL majors on a
~yearly cadence; § Regeneration bounds the upgrade cost. The pin is
explicit everywhere (goldens record the tag;
Versionfield asserts it). - License. PostgreSQL License for ported grammar/scanner/deparser logic and BSD-3 for libpg_query-derived glue are both permissive and compatible with MIT for oliphant's own code; all three notices ship in the repo root, and ported files carry per-file attribution headers (the sibling convention).