Skip to content

Add testcheck to verify analyze cases against a real database, starting with ClickHouse - #4603

Open
kyleconroy wants to merge 9 commits into
mainfrom
claude/clickhouse-testgen-module-rjzyuf
Open

Add testcheck to verify analyze cases against a real database, starting with ClickHouse#4603
kyleconroy wants to merge 9 commits into
mainfrom
claude/clickhouse-testgen-module-rjzyuf

Conversation

@kyleconroy

@kyleconroy kyleconroy commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds internal/testcheck, a nested, dependency-free Go module that verifies the analyze cases under internal/endtoend/testdata against a real database, and then fixes everything it found wrong in sqlc's ClickHouse analysis. All six ClickHouse analyze cases now match what ClickHouse itself reports, byte for byte.

testcheck generates nothing: each engine package finds every analyze_*/<engine> case, loads its 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 case's committed output.json.

cd internal/testcheck
go run ./cmd/testcheck install clickhouse   # download the pinned clickhouse binary once
go run ./cmd/testcheck check                # or: go test ./...

ClickHouse package

  • Needs no server. Each case runs in an ephemeral clickhouse local process. The pinned release (25.8.2.29) is downloaded per platform into the user cache directory and verified against a SHA-512 table; CLICKHOUSE overrides the lookup.
  • Column types come from the executed query's result header. Provenance comes from EXPLAIN QUERY TREE, followed through subqueries, CTEs and unions. Parameters, which ClickHouse never sees, are substituted with ordinal-carrying sentinel constants that the query tree describes by the operand they are compared with; INSERT ... VALUES parameters map onto DESCRIBE TABLE.

sqlc analyze output

sqlc analyze prints each column's type as a call expression instead of data_type, not_null and is_array: a name applied to args, each carrying an optional label and exactly one of type, int, bool or string, with nullable set at whatever depth it applies. docs/howto/analyze.md is updated.

{"name": "map", "args": [
  {"type": {"name": "string"}},
  {"type": {"name": "uint8", "nullable": true}}]}

Every analyze case's expected output is renamed from stdout.txt to output.json, which the harness now reads first, and regenerated. New ClickHouse cases analyze_types, analyze_expressions, analyze_subqueries and analyze_exec come with a fixture.sql next to the schema, and analyze_basic and analyze_params gain fixtures.

What testcheck found, and the fixes

  • Types lost their arguments and nesting. The ClickHouse converter now keeps a column's full spelling in the type name's Spelling, the schema stores it as the attribute's declared type, and the analysis core writes a TypeExpr for every column and parameter from it. Array(Nullable(String)), Array(Array(UInt8)), LowCardinality(Nullable(String)), Map(String, Nullable(UInt8)), Tuple(lat Float64, lon Float64), Enum8('active' = 1, ...), Decimal(10, 2), DateTime64(3, 'UTC') and FixedString(4) all survive.
  • Expression columns had no type. A ClickHouse function seed of 581 signatures replaces the single count() entry, with "$n" naming the type of the nth argument and never_null for results that stay non-null. The dialect declares that functions propagate nullability, that comparisons yield UInt8 while true is Bool, that LIMIT counts are UInt64, and that an unconstrained placeholder is Nothing. The analyzer scores overloads instead of taking the first of the right arity, and COALESCE is now null only when every argument is.
  • Parameters went missing. ClickHouse joins the preprocessed engines, so sqlc.arg() and sqlc.narg() work with ? binding; the analyzer types ORDER BY, LIMIT and OFFSET, follows IN (SELECT ...) into the subquery, and names a placeholder compared with a function call after the function. The converter's positions are now zero-based, which is also what made star expansion work.
  • Scalar subqueries were ?column? typed bool. The converter treats a subquery in value position as a value rather than EXISTS, and captures aliases on every expression node kind.

Beyond the four: unaliased expression columns get ClickHouse's own names (sum(amount), plus(id, 1)), WITH elements are converted as the parser produces them so CTEs resolve, coalesce/ifNull convert to COALESCE, and a dialect that names the second id of a join e.id says so in its seed. The CTE and SELECT * join queries that had to be left out earlier are back in the subqueries case.

Tests

go test ./... at the root passes apart from the database-backed schema tests that need MySQL. The ClickHouse parse golden changed by one position and one column name, both correct. The testcheck module is not wired into CI.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y6XkyWnx7iJFEb8q3AnYps

Add a nested, dependency-free Go module at
internal/engine/clickhouse/testgen that records what ClickHouse itself
reports about a schema, fixture and sqlc query file, in the same JSON
shape as `sqlc analyze`, so the two can be diffed.

`testgen install` downloads the pinned clickhouse release for the
running platform into the user cache directory. `testgen analyze` runs
each query in its own `clickhouse local` process: result column types
and nullability come from the executed query's result header,
provenance from EXPLAIN QUERY TREE (followed through subqueries, CTEs
and unions), and parameters from ordinal-carrying sentinel constants
substituted for ?, sqlc.arg() and sqlc.narg(), or from DESCRIBE TABLE
for INSERT ... VALUES.

Golden tests under testdata/ cover the type lowering, expressions,
subqueries and exec statements. The analyze_params case reproduces the
existing sqlc analyze golden byte for byte.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6XkyWnx7iJFEb8q3AnYps
Replace the data_type and is_array fields with one type expression per
column: a lowercased name applied to arguments that are numbers, quoted
strings, identifiers, other calls, or any of those with a label. Nullable,
Array and LowCardinality are ordinary names in that grammar, so nested
types such as Array(Nullable(String)), Map(String, Nullable(UInt8)) and
Tuple(lat Float64, lon Float64) survive intact. An outer Nullable is
lifted into the column's not_null flag; deeper ones stay in the
expression. Resolving the names is left to the reader of the output.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6XkyWnx7iJFEb8q3AnYps
A column's type is now a JSON expression: a lowercased name applied to
arguments, each carrying an optional label and exactly one of type, int
or string. The shape maps one to one onto a protobuf message with a
oneof for the argument value. There is no separate nullability flag any
more: a nullable column is one whose type is nullable(...), which keeps
Nullable at every depth where ClickHouse put it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6XkyWnx7iJFEb8q3AnYps
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6XkyWnx7iJFEb8q3AnYps
Nullable(T) is now T with nullable set, at whatever depth ClickHouse
wrote it, instead of a call named nullable. Every engine has nullability
and every consumer needs it, so an attribute on the type node spares
readers from treating one name as special.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6XkyWnx7iJFEb8q3AnYps
releaseAsset now looks a build up in a table of version, platform, file
name and SHA-512, and Install hashes every byte off the wire, including
the tail of a tarball past the binary, and discards a download whose
digest does not match. A version missing from the table cannot be
installed. The tarball digests are the ones ClickHouse publishes in its
.sha512 sidecar files; the macOS binaries have no published digest, so
theirs were computed from the downloads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6XkyWnx7iJFEb8q3AnYps
… cases

testcheck replaces testgen. It generates nothing: each engine package
finds the analyze cases under internal/endtoend/testdata, loads a case's
schema, fixture and queries into a real database, and compares what the
database reports with the output.json the case committed, byte for byte.
The ClickHouse package is the testgen code moved over; other engines get
their own package alongside it.

sqlc analyze now prints each column's type as a call expression instead
of data_type, not_null and is_array, so its output and the database's
answer share one format. The compiler's flat column description maps
onto it as the data type wrapped in one array node per dimension with
the column's nullability on the outermost node. Every analyze case's
expected output is renamed from stdout.txt to output.json, which the
end-to-end harness now reads first, and regenerated.

The cases from testgen's testdata become analyze_types,
analyze_expressions, analyze_subqueries and analyze_exec under the
ClickHouse dialect, with fixture.sql next to the schema, and the existing
analyze_basic and analyze_params ClickHouse cases gain fixtures. Two
queries from the subqueries case, a CTE and a SELECT * over a join, are
left out because sqlc cannot analyze them yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6XkyWnx7iJFEb8q3AnYps
@kyleconroy kyleconroy changed the title Add ClickHouse testgen module for recording ground-truth query analysis Add testcheck to verify analyze cases against a real database, starting with ClickHouse Sep 2, 2026
testcheck showed sqlc disagreeing with ClickHouse on every analyze case
that went beyond plain column references. This makes the six ClickHouse
cases match the database byte for byte, and moves the testcheck command
under cmd/testcheck.

Types are carried as expressions. The ClickHouse converter keeps a
column's full spelling in the type name's Spelling, the schema stores it
as the attribute's declared type, and the analysis core writes each
column and parameter a TypeExpr from it, so Array(Nullable(String)),
Map(String, Nullable(UInt8)), Tuple(lat Float64, lon Float64) and
Decimal(10, 2) survive intact. The analyze command prints that
expression when the core produced one.

Functions are typed. A ClickHouse function seed of 581 signatures
replaces the single count() entry, with "$n" naming the type of the nth
argument and never_null marking results that stay non-null. The dialect
declares that functions propagate nullability, that comparisons yield
UInt8 while true is Bool, that LIMIT counts are UInt64, and that an
unconstrained placeholder is Nothing. The analyzer scores overloads,
types ORDER BY, LIMIT and OFFSET, follows IN (SELECT ...) into the
subquery, makes COALESCE null only when every argument is, and names a
placeholder compared with a function call after the function.

The converter gives unaliased expression columns ClickHouse's own names,
such as sum(amount) and plus(id, 1), captures aliases on every node
kind, converts a scalar subquery as a value rather than EXISTS, converts
coalesce and ifNull to COALESCE, handles WITH elements as the parser
produces them, and counts positions from zero so star expansion and
parameter renumbering line up. ClickHouse joins the preprocessed
engines, so sqlc.arg() and sqlc.narg() work with ? binding. A dialect
that names the second id of a join e.id says so in its seed and the
analyzer qualifies such columns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6XkyWnx7iJFEb8q3AnYps
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants