goldeneye
goldeneye generates the dialect seeds under internal/engine/<engine>/dialect
from a live database, and verifies the committed ones against it. A dialect
is the JSONL that gives an engine its type system and standard library —
types.jsonl, functions.jsonl, operators.jsonl, relations.jsonl and
the extensions/ bundles — read by internal/core/seed. Each engine package
here asks the database what it knows, writes the answer in that shape, and
the tests compare it with what is committed, byte for byte. A difference
means the committed dialect has drifted from the database.
It is a nested Go module, so its only dependencies beyond the standard library are the database clients — PostgreSQL's, MySQL's and SQL Server's drivers, and Spanner's gRPC client with what it stands on — and it never shares code with the analysis that reads the files: the files are the contract. Run it from this directory:
go run ./cmd/goldeneye install clickhouse # download the pinned clickhouse binary once
go run ./cmd/goldeneye install duckdb # download the current DuckDB 2.0 preview build once
go run ./cmd/goldeneye install sqlite # build the pinned sqlite3 shells once; needs a C compiler
go run ./cmd/goldeneye check # check every engine whose database is available
go run ./cmd/goldeneye check postgresql # check one engine
go run ./cmd/goldeneye check spanner # SPANNER_SERVER_URI=localhost:15000, a Spanner Omni container
go run ./cmd/goldeneye generate [engine] # rewrite the generated files from the database
go test ./... # the same checks as tests; engines without a database skip
generate and check say which engines they skipped for lack of a
database; naming an engine makes its database required.
What is generated, and what is not
A generator owns only the files it produces; dialect.json is always written
by hand, and so are the lists an engine cannot describe. Both commands leave
the hand-written files alone, and the checks do not look at them.
postgresqlreads a live server named byPOSTGRESQL_SERVER_URI:functions.jsonlispg_catalog's functions,relations.jsonlthe tables and views ofpg_catalogandinformation_schema, and each contrib extension gets a directory underextensions/holding the types and functionsCREATE EXTENSIONadds, so the server needs contrib installed. A function that one of those extensions puts inpg_catalog, asadminpackdoes, belongs to the extension's directory rather than the catalog's list. The server has to be the major release pinned inpostgresql.Major, since every release adds to the catalogs; the top-leveltypes.jsonlandoperators.jsonlare hand-written.mysqlreads a live server named byMYSQL_SERVER_URI, a go-sql-driver DSN such asroot:mysecretpassword@tcp(127.0.0.1:3306)/mysql, and writes intointernal/engine/dolphin/dialect, since sqlc's MySQL engine is named after its parser. MySQL keeps no catalog of its types, functions or operators — the help tables describe a built-in function no further than its name — sotypes.jsonl,functions.jsonlandoperators.jsonlare hand-written. What it does describe is its data dictionary:relations.jsonlis every view ofinformation_schema, read frominformation_schemaitself, with their names in lower case, since MySQL matches them in any case and sqlc's parser lowercases every identifier. A column's type is spelled the way a declaration does,bigint unsignedincluded, whichtypes.jsonllists as an alias ofbigint. The other schemasmysqld --initializecreates —mysql,performance_schema,sys— are tables rather than views, and every table a dialect seeds is one the analysis core hands codegen as a model, so they are left out until codegen knows a system schema when it sees one. The server has to be the major release pinned inmysql.Major, since every release adds toinformation_schema.duckdbreads the DuckDB CLI named byDUCKDB, or the oneinstallput in the user cache directory, orduckdbonPATH:types.jsonl,functions.jsonlandoperators.jsonlcome fromduckdb_types()andduckdb_functions(). The CLI has to be a DuckDB 2.0 build, the release darkwing is pinned against, which has no release to download yet: until 2.0 is out,installdownloads the current build of DuckDB's v2.0 preview channel,duckdb.DefaultVersion, a rolling tarball per platform underartifacts.duckdb.orgwith no per-build download and no checksum to pin, so what a run logs is the version the CLI reports, andduckdb.GeneratedFromrecords the build the committed dialect came from. A check against a later build reports what the later build added; regenerate, and updateGeneratedFrom, to move the dialect along. Once 2.0 is released, the installer should pin the release and its checksums the way the clickhouse one does.clickhouseneeds no server:types.jsonlcomes fromsystem.data_type_familiesof an ephemeralclickhouse localprocess, every family that is not an alias becoming a type carrying the spellings that alias it, with a category decided by its name. The binary is downloaded once per pinned release byinstallinto the user cache directory, or supplied through theCLICKHOUSEenvironment variable; the pinned release and the SHA-512 of each platform's download live inclickhouse/install.go, and a download that does not match is discarded. ClickHouse describes its functions no further than their names, sofunctions.jsonlis hand-written.mssqlreads a live server named byMSSQL_SERVER_URI, in any form the go-mssqldb driver accepts, such assqlserver://sa:password@127.0.0.1:1433?encrypt=disable. SQL Server keeps no catalog of its intrinsic functions or its operators — GETDATE and LEN are not objects — sotypes.jsonlandfunctions.jsonlare hand-written. What it does describe is its catalog:relations.jsonlis every view of thesysandINFORMATION_SCHEMAschemas, listed from a database of its own, since the views a query sees are the ones a user database has andmasterlists internal views no query can name; each view's columns are whatsys.dm_exec_describe_first_result_setsays aSELECT *from it returns, the type spelled the way a declaration spells it —nvarchar(128),decimal(10,2),varbinary(max)— and the nullability the server computes. Names are written in lower case, since SQL Server matches them in any case under its default collations and sqlc's parser lowercases every identifier. Both schemas hold nothing but views, so no table is seeded that codegen would take for a model. The server has to be the major release pinned inmssql.Major, since every release adds to the catalog views.spannerreads a live Spanner Omni server — the downloadable Spanner, run from its container image — named bySPANNER_SERVER_URI, the gRPC endpoint such aslocalhost:15000, reached without TLS or credentials as Omni is, and writes intointernal/engine/googlesql/dialect, since sqlc's engine is named after the language Spanner speaks. Spanner keeps no catalog of its types, functions or operators, sotypes.jsonl,functions.jsonlandoperators.jsonlare hand-written. What it does describe is its information schema:relations.jsonlis every view ofINFORMATION_SCHEMAandSPANNER_SYS, read fromINFORMATION_SCHEMAitself in a database created for the purpose in the instance Omni's single server provides,projects/default/instances/default. Names are kept as the catalog spells them, in upper case: Spanner matches a name in any case, but sqlc's GoogleSQL engine matches one as it is spelled, so a query reaches these views by their upper-case names until the engine folds case; a column's type is spelled in lower case the way the seed spells one, anARRAY<T>asTwith the array flag, aSTRUCT<a T>asstruct(a: t)and aPROTO<p.M>asproto('p.M'), since a seed writes a type's arguments in parentheses. The container image is pinned in the gen workflow anddocker-compose.yml.sqliteneeds no server either:functions.jsonlcomes frompragma_function_listof asqlite3shell run against an in-memory database. Which functions a SQLite has is decided when it is compiled, soinstalldownloads the pinned release's amalgamation, checked against the SHA3-256 the download page lists, and compiles the shell from it with the compilerCCnames, orcc— once with the options sqlite.org's own configure turns on by default, which givesfunctions.jsonl, and once more per option insqlite/install.go's extension list, each of which gets a directory underextensions/holding the functions its build adds over the default one, the way each PostgreSQL contrib extension holds whatCREATE EXTENSIONadds; a schema that saysCREATE VIRTUAL TABLE ... USING fts5loads the option's directory, through themodulesmap in the hand-writtendialect.json. SQLite describes its functions as far as their names, their kinds and the number of arguments each overload takes, and no further — it types values, not functions — so what each returns and what its arguments hold is read from the amalgamation: every function is registered with the C functions that implement it, and those set their result throughsqlite3_result_*and read their arguments throughsqlite3_value_*. A function a shell reports that the source does not register fails the run rather than being guessed at. Whether an aggregate returns NULL over no rows is found by running it over none; the scalar functions that return NULL for arguments that are not are a short list insqlite/signatures.go, since a SQLite function returns NULL as often by setting no result as by saying so. The pinned release is the one the main module's driver embeds. SQLite has no catalog of types or operators, sotypes.jsonlandoperators.jsonlare hand-written. A function that returns an integer for an integer and a real for a real —abs,ceil,floor,trunc,sum— is written once overany, returning the real that a text or blob argument gets, and once more per spellingtypes.jsonlgives integer and real, returning that type, the way PostgreSQL's catalog has asumper numeric type; the overload overanycomes first, since the legacy compiler resolves by arity alone and takes it.installbuilds one more shell, for the analysis check below; nothing is generated from it.
Layout
dialect/— the record types the files are made of, mirrored frominternal/core/seed, and the helpers that write a generated set of files into an engine directory or diff it against what is committed.endtoend/— finds the analyze cases, splits their query files, and compares an engine's answer with a case's committed output.analysis/— the shape of that answer: the JSONsqlc analyzeprints.postgresql/,mysql/,mssql/,spanner/,duckdb/,clickhouse/,sqlite/— one package per engine, each exposingLocate,VersionandGenerate,Analyzewhere the engine has an analysis check, and tests that run the checks.cmd/goldeneye/— the command.
Analysis checks
check also verifies the analyze_* cases under internal/endtoend/testdata
against what the database itself reports. A case is an
analyze_<name>/<engine> directory whose exec.json runs the analyze
command; endtoend/ finds them. The engine package loads the case's
schema.sql and optional fixture.sql into the database, runs query.sql
there, prints what the database reports in the JSON shape sqlc analyze
prints, and compares it with the committed stdout.json byte for byte. A
difference means sqlc's analysis disagrees with the database. A case that
asks for --ast is skipped, since only sqlc can print that.
-
clickhouseruns each case in an ephemeralclickhouse localprocess. Column types come from the executed query's result header, provenance fromEXPLAIN QUERY TREE, and parameters from sentinel constants substituted for?,sqlc.arg()andsqlc.narg(), since ClickHouse itself never sees a placeholder;INSERT ... VALUESparameters map ontoDESCRIBE TABLE. -
sqliteruns each case through one more shellinstallbuilds, underanalysis/: every extension option at once, so that any case's schema loads, andSQLITE_ENABLE_COLUMN_METADATA, which lets the shell's.stats stmtsay which table column each result column of a statement is read from. That column's declared type is the result column's, and its NOT NULL decides nullability, the rowid counting as NOT NULL. SQLite types values rather than expressions, so a column the library has no origin for — an aggregate, an arithmetic result — is typed by the storage class of the value it returns, which is why a case wants afixture.sql: the query is run over the fixture, with each parameter bound to a value of the column it stands in for, and again over no rows, and a column is nullable when either run returns a NULL for it — an aggregate over nothing, the far side of an outer join. The library reports nothing about a parameter but its number, so parameters are found in the bytecodeEXPLAINprints, the way ClickHouse's are found in its query tree: each is followed from the register itsVariableloads, through copies and the expressions it is an argument of, to the first opcode that uses it against something the catalog can name — the other operand of a comparison, the row a seek lands on, the position in the record anInsertwrites, the column of an IN list's ephemeral table it comes back out of. One that reaches nothing nameable is described by what the program requires of it, when it requires anything:MustBeIntmakes LIMIT's an integer.sqlc.arg(name)becomes?N, numbered as sqlc numbers them, so a repeated name is one parameter. Two things the check reports that sqlc does not: a bare column selected alongside an aggregate is NULL over no rows, and so nullable, and a comparison such asx IS NULLis an integer, since that is what SQLite returns. -
mysqlruns each case in a database of its own on the server named byMYSQL_SERVER_URI, and asks the server three things about a query. What a driver sees: the query is run, with every parameter a user variable set to NULL, and each result column's name, type and nullability are read from the result set's metadata as go-sql-driver reports them. What the resolver made of it: the optimizer trace prints each query block back after name resolution and before optimisation, with every column qualified, every alias kept and everySELECT *expanded, which says which table a result column is read from and what a parameter is compared with or assigned to. And for a statement the trace does not expand — anINSERT ... VALUES, a single-tableUPDATEorDELETE— the noteEXPLAINleaves, which prints the statement the same way; aSELECTcannot be read from the note, since it is printed after optimisation, and a lookup on a unique key against an empty table has folded toNULL = (@x)there. Views and derived tables are kept as the query wrote them rather than merged, so that a column read through one is reported as its column and aninformation_schemaview is not resolved away into the dictionary tables behind it. MySQL itself reports nothing about a parameter but its position, so a parameter is described by its partner: a column's type and nullability come frominformation_schema, a column of a derived table or CTE from what its block projects, and an expression's from running it, over the tables it reads, as a query of its own; aLIMITorOFFSETcount is abigint unsigned. Two things the driver keeps to itself: the table a result column comes from, which is why the trace is read for it, and the length beside the one wire type every size ofTEXTandBLOBis sent as, which is why a column read from a table, directly or through a derived table, is spelled the way the table declares it. -
duckdbruns each case through the CLI, which loads the schema and fixture into an in-memory database of their own, one process per question, and is asked four things about each query. What its parameters are: the query is prepared and explained as JSON with a string sentinel bound to each parameter,EXECUTE q('goldeneye_1', ...), and the unoptimized logical plan the CLI prints underexplain_output = 'all'shows each asCAST('goldeneye_k' AS T),Tbeing the type the binder gave the parameter, or bare when that type isVARCHAR; a sentinel the binder converts on the spot, as anINSERT'sVALUESare, is bound to NULL instead. What its result columns are:DESCRIBE, with each parameter replaced by a NULL of its type, names and types them; DuckDB describes no DML, so aRETURNINGcolumn is the target table's column it names. Which table a result column is read from and which column a parameter stands in for: DuckDB prints a plan with every column by its bare name and every aliased expression by its alias, so these are read from the query text, the select list's items, a star expanded to its table's columns, and the operand beside each parameter, resolved against theFROMclause and the catalog,duckdb_columns(), from which a column read from a table takes its declared type and nullability; a parameter the query casts takes the cast's type as DuckDB spells it, and one inside a subquery, whose tables the statement's scope does not name, takes the binder's. And whether an expression can be NULL, which DuckDB does not track: the query is run, with each parameter bound to a value of its type, over the fixture and over no rows, and a column is nullable when either run returns a NULL for it. DuckDB spells an enum column by its labels whether the schema named the type or not, so labels that are those of an enum the schema created name that type, and a spellingtypes.jsonllists as an alias —json, which DuckDB's own catalog lists as a spelling ofvarchar— is reported by the dialect's name for it. -
mssqldescribes each case in a database of its own on the server named byMSSQL_SERVER_URI, without running anything: the schema is loaded one statement at a time, since aCREATE TYPEhas to be its own batch before a table can use the type, and the server is asked three things about each query. What a driver would see:sys.dm_exec_describe_first_result_setdescribes each result column — its name, its type spelled the way a declaration spells it, whether it can be NULL, and which table column it is read from. What each parameter would be:sp_describe_undeclared_parameterssays what type the server would give each parameter the query leaves undeclared, which is the type of aCAST(@p AS T); it describes a parameter only when it is used once, so each appearance of a repeated one becomes a variable of its own. And what each parameter stands in for: the estimated showplan, compiled withSET SHOWPLAN_XML ONand the parameters declared as those types, prints every column as a reference naming its table and every variable as@variable, and a parameter's partner is the column on the other side of theCompareit is an operand of, the column anAssignsets to it, or the column of a seek whose range expression it is, with a named expression such asExpr1002followed to its definition; a parameter under aCONVERTthe query wrote has no partner, since the cast says what it is. A parameter with a partner is described as that column, from the catalog of the case's database. Three things the describing function keeps to itself: ajsonorvectorcolumn is described by thenvarchar(max)it is sent to a driver as, so a column read from a table is typed fromsys.columnsinstead; a type the schema created is reported beside the system type it stands on, and the dialect reports it by its own name; and a spellingtypes.jsonllists as an alias —numeric,timestamp— is reported by the dialect's name for it,decimal,rowversion. One thing it says that sqlc does not: a computed column — a cast, an arithmetic — is nullable whatever its arguments, since a conversion that fails underANSI_WARNINGS OFFyields NULL, where sqlc follows the arguments, and a computed column names the column it is computed from, as the one an update through the result set would write, where sqlc gives an expression no table. The cases compute over nullable columns, and a computed column is reported without a table. -
spannercreates a database of each case's own from its schema in the instance named bySPANNER_SERVER_URI, writes its fixture there in a read-write transaction, and compiles each query inPLANmode, which runs nothing: a DML statement is compiled in a read-write transaction that is rolled back. The server reports the name and type of each result column and the type of each parameter the query leaves undeclared, the way the wire spells them —STRING,ARRAY<INT64>— without the length a declaration gives aSTRING(10)or whether a column can be NULL, and the query plan, which says where each comes from: the children ofSerialize Resultafter the relation it serializes are the result columns, aScanof a table defines a variable per column it reads, and a comparison is aFunctionwhose description reads($col = @param). A result column the plan reads from a table column, directly or through the variables a join or a batch passes it through, is described from the case'sINFORMATION_SCHEMA, with its declared length and nullability; one the plan reads as a parameter is the column the parameter is compared with, which is why the optimizer substituted it. A parameter compared with a table column is described as that column. A DML plan lists the values it writes before the columns it returns — the table's key columns, then the columns anUPDATEsets or anINSERTinserts, read from the statement — and a parameter written to a column is described as it, while aTHEN RETURNcolumn is the table's column of that name. Two things Spanner does not say: whether an expression can be NULL, which is reported as it is not, and anything about aSTRUCTreturned as a column, which Spanner rejects.
The other engines have no analysis check yet.