Skip to content

core: one type row per expression, canonicalized per dialect, across every engine - #4618

Merged
kyleconroy merged 16 commits into
mainfrom
claude/kind-dijkstra-88hp13
Sep 11, 2026
Merged

kyleconroy merged 16 commits into
mainfrom
claude/kind-dijkstra-88hp13

Conversation

@kyleconroy

@kyleconroy kyleconroy commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

The analysis core's catalog held one sql_type row per flat name, so a type argument, a struct field, an enum label, a domain's base, a MySQL UNSIGNED or a second array dimension had nowhere to go, and sqlc analyze reported numeric(10,2) as numeric, int[][] as one dimension, and a ClickHouse CAST(x AS Nullable(String)) as nullable. Three of those were regressions against the legacy compiler on the core path: codegen lost unsigned, length and array_dims.

This branch starts with a design note (internal/core/types.md) that evaluates the current representation, reviews how each engine's own catalog represents a type (PostgreSQL, MySQL, SQLite, ClickHouse and DuckDB queried live; SQL Server and GoogleSQL from their catalogs and parsers), and then implements the design engine by engine.

The catalog

  • sql_type holds one row per type expression: a family row for a name the dialect or the schema declares (numeric, array, mood) and an instance row for a family applied to arguments (numeric(10, 2), array(array(integer))), with expr as the canonical spelling and identity, and family_oid, element_oid, base_oid and canonical_oid pointing at what resolution falls back to. Arguments, fields and labels live in a new sql_type_arg table.
  • An alias spelling is a row pointing at its canonical type instead of a mesh of implicit casts. SQLite's aliases are types of their own standing on a base, so a column reports the spelling it was declared with while comparing by affinity.
  • Interning canonicalizes, and every engine reports the form its own catalog stores: PostgreSQL uses format_type names (integer, character varying(255), timestamp with time zone), MySQL spells BOOLEAN as tinyint(1), ClickHouse stores Decimal32(4) as Decimal(9, 4), DuckDB drops varchar(10)'s length, SQL Server keeps float(24) as real.
  • Nullability is never part of a row: it stays on the attribute, the analysis and the reported column, with inner nullability a flag on the argument position (Array(Nullable(String))).
  • A bare type name resolves in the default namespaces only, and a type elsewhere is reported qualified (myschema.mood), the way format_type prints a type off the search path; an instance's key is built from qualified names, so array(myschema.mood) and array(mood) are two rows.

What a dialect says, as data

Nothing about a dialect is Go code, so a new engine adds one by writing files. dialect.json carries rewrites (pattern, template, optional bound: float($1)real where $1 <= 24), the words and positions that are identifiers rather than types (max, the function of SimpleAggregateFunction), SQLite's affinity rule, and a default schema. types.jsonl may give a family a base, which is how MySQL's unsigned families stand on their signed ones. functions.jsonl may spell a value-dependent result as a template ("returns": "Decimal(18, $2)"). The seed loads these into sql_type_rewrite, sql_type_affinity, dialect flags and sql_proc.return_template.

The analyzer

exprType carries the expression beside the row, so casts, typed placeholders, constructors and function results report whole types; resolution walks the pointer chain, so numeric(10, 2) + numeric(5, 1) resolves on numeric. A cast is NULL when its operand is or its type says so. The legacy bridge derives ArrayDims, Length and Unsigned from the expression.

Engines

Each engine's converter hands the core a canonical rendering (TypeName.Canonical, with fields labelled a: integer), the author's spelling, or a name with modifiers. PostgreSQL carries typmods, interval fields, dimensions, domains, composites, ranges, enum labels and namespaces. MySQL carries unsigned families, typmods, enum and set members, and names a cast as MySQL types it. ClickHouse handles casts, {name:Type} placeholders, Nested columns and canonical enum and variant spellings. DuckDB, GoogleSQL and SQL Server carry nested types, parameters, MAX, defaults and alias types.

Tests

  • An analyze_types/<engine> case for every engine, checked by goldeneye against live MySQL, SQLite and ClickHouse (goldeneye now reads MySQL's COLUMN_TYPE, binds ClickHouse placeholders, and writes PostgreSQL array columns as element plus flag, which also fixes the legacy path's _text columns in codegen_json).
  • The full suite with --tags=examples passes against live PostgreSQL and MySQL. The opt-in core context (SQLC_TEST_CORE=1) goes from 347 failing cases to 292; the one case that newly differs from its legacy golden, func_call_cast/mysql, now generates uint64 for CAST(... AS UNSIGNED), which is the correct answer.
  • A first adversarial review of the branch found and this branch fixes: a label heuristic that mis-read a struct field typed timestamp with time zone, a write probe that inserted an empty-named type row, CREATE TYPE deduplication that ignored the schema so foo.mood and mood collapsed, bare names binding to types in unrelated schemas, IN (SELECT ...) typing its left side twice, and operator resolution issuing one query per chain pair.
  • A second review, fanned out over the analyzer, the seed and converters, goldeneye, and the core's type and rewrite code, found and the last commit fixes: an instance key that dropped an argument's namespace; a read-only lookup that failed outright when a nested instance was not a row ($1::varchar(10)[]) or tried to create the array family; rewrites applied before alias resolution, so dec(7,2) missed MySQL's rule on decimal; a bare SQL Server CREATE TYPE landing in public while dbo.Code resolved elsewhere; the legacy bridge reporting bigint unsigned as the data type instead of bigint plus the flag; a PostgreSQL cast to interval day to second carrying the raw field mask; MySQL CAST(x AS DOUBLE) carrying the parser's display width; DuckDB's main. schema leaving a leading dot; ClickHouse LowCardinality(Nullable(T)) columns reported NOT NULL; goldeneye lowercasing MySQL enum members and relabelling int2vector and oidvector columns as arrays. The analyze_types cases cover each.

Output changes

sqlc analyze output changes for every dialect: canonical names, arguments, nested arrays, a fifth argument kind ident, and qualified names for types outside the default namespaces. The how-to is updated. The new analyzer has not shipped, so nothing released changes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Qryf7a1doPFuCz2eGT3bCr

…sions in the catalog

Every engine's type system was run through sqlc analyze and compared with
what the engine itself reports. The catalog holds one row per flat name, so
a type argument, a struct field, an enum label, a domain's base, a range's
subtype, a MySQL UNSIGNED and a second array dimension all have nowhere to
go, and a ClickHouse cast to Nullable(String) reports "nullable".

The note proposes one row per type expression: family rows the dialect and
the schema declare, instance rows for a family applied to arguments, the
arguments in sql_type_arg, and family, element, base and canonical pointers
denormalized so that a bare name and a structured expression both resolve
to a row in one lookup. Interning stays at schema load, since the cached
catalog is read-only; the analyzer carries the expression and looks up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qryf7a1doPFuCz2eGT3bCr
…n the way in

PostgreSQL, MySQL, SQLite, ClickHouse and DuckDB were asked live what they
record about a type, how they spell a column's type back, what a result's
type is and how they describe a function; SQL Server and GoogleSQL from
their documented catalogs and the parsers sqlc uses for them.

No engine keeps arguments inside its type table: PostgreSQL and SQL Server
put them on the use site, the rest in the spelling. Every engine with a
catalog canonicalizes a declared type and reports the canonical form, and
MySQL, ClickHouse and DuckDB compute full result types in the binder that
no catalog holds. The design now canonicalizes at interning through
dialect data and an engine hook, reports the canonical expression with the
declared spelling kept on the attribute, nests array dimensions the way
codegen needs them, and leaves result-type arguments to a per-dialect hook
over the catalog's family-level answer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qryf7a1doPFuCz2eGT3bCr
Settles the open question: a PostgreSQL type is reported as format_type
spells it, read into the same expression every dialect reports, with the
typmod lifted out of a multi-word name or from before an array suffix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qryf7a1doPFuCz2eGT3bCr
…arrying the expression

sql_type now holds a row per type expression rather than per name: a
family row for a name the dialect or the schema declares, an instance row
for a family applied to arguments, with the arguments in sql_type_arg and
family_oid, element_oid, base_oid and canonical_oid pointing at what
resolution falls back to. An alias spelling is a row pointing at the type
it names, or at a base type it stands on for SQLite, instead of a mesh of
implicit casts. Interning canonicalizes and reads array suffixes into
instances of the array family, so the "name[]" convention is gone.

The analyzer carries the expression beside the row, so a cast, a
constructed array, a placeholder or a function result reports the whole
type, and operator and function resolution walk the pointer chain, so
numeric(10, 2) + numeric(5, 1) resolves on numeric. The legacy bridge
derives array dimensions, length and unsignedness from the expression.

PostgreSQL's seed names its types as format_type does, integer rather than
int4, and codegen accepts those spellings. The ClickHouse converter marks
a column nullable only for a Nullable at the top of its type.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qryf7a1doPFuCz2eGT3bCr
…namespaces in the core

The parser now hands the core a column's type modifiers, so numeric(10,2),
character varying(255), timestamp(3) and bit(8) reach the catalog, with an
interval's field mask decoded into the words format_type prints. Domains
stand on their base with their NOT NULL, composites carry their fields,
ranges their subtype and enums their labels, and a schema-qualified type
lives in its namespace. A canonicalizer registered per dialect rewrites
what only code can, starting with pg_type's _int4 spelling of an array,
and is found by name so a catalog restored from the cache has it too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qryf7a1doPFuCz2eGT3bCr
…s MySQL reports for casts

A column's UNSIGNED reaches the core as part of its family, which the seed
now lists for every numeric type rather than as an alias of the signed one,
so codegen picks uint64 again on the core path. The converter carries the
width of tinyint(1) and bit(n), a fractional-seconds precision and the
members of an enum or set, and names a cast's type as MySQL types its
result: varchar for CAST AS CHAR rather than the parser's var_string. A
canonicalizer fills in decimal(10,0), folds float(p) and spells boolean as
tinyint(1), as MySQL does.

goldeneye reads a table column's whole type from COLUMN_TYPE, in the
relations seed and in the analyze check, and compares an expression by
family alone, since the wire carries no more. A cast is NULL when its
operand is, in every dialect.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qryf7a1doPFuCz2eGT3bCr
A SQLite dialect setting makes each alias in types.jsonl a type of its own
standing on the type it aliases, so a column declared VARCHAR(255) reports
that spelling, as SQLite does, while comparing as text. A spelling neither
seeded nor aliased — FOO BAR(3) — gets the affinity SQLite's rule gives
it as its base, through a per-dialect hook the catalog asks when a schema
declares a family it does not know.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qryf7a1doPFuCz2eGT3bCr
…onical forms and Nested columns

A cast and a {name:Type} placeholder hand the core their whole spelling,
so CAST(x AS Nullable(String)) reports a nullable string and a placeholder
reports the type it declares, named as the query named it. A per-dialect
canonicalizer stores what ClickHouse stores: Decimal32(4) as Decimal(9, 4),
Enum('a', 'b') as Enum8('a' = 1, 'b' = 2), a Variant's members sorted, and
the function an AggregateFunction names as a word. A result-type rule
types toDecimal64(x, 4) as Decimal(18, 4) from the literal. A Nested
column becomes the array columns ClickHouse stores it as, reachable as
n.a, and goldeneye's rewriter binds {name:Type} like any placeholder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qryf7a1doPFuCz2eGT3bCr
… alias types in the core

DuckDB hands the core a type's whole spelling, so a struct's fields, a
map's key and value, a union's members, a fixed-size array's length and a
list of lists all arrive, with a varchar's length dropped and a bare
decimal filled in as decimal(18,3), as DuckDB stores them. GoogleSQL spells
ARRAY<T>, STRUCT<...>, RANGE<T> and a parameter list in the same call
form, and reads MAX as the word it is. SQL Server's converter carries a
type's parameters and MAX, qualifies a user-defined type by its schema,
reports CREATE TYPE ... FROM as the domain it is, and canonicalizes as
sys.types does: float(24) is real, sysname is nvarchar(128), and a length
or precision left out takes its default.

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

A column declared myschema.mood resolves in that namespace rather than by
its bare name, and a type outside the default namespaces is reported as
myschema.mood, the way format_type prints a type off the search path. A
dialect names its own default schema in dialect.json, so SQL Server's dbo
and DuckDB's main report unqualified.

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

The per-dialect Go hooks are gone. dialect.json now carries rewrites — an
ordered list of pattern, template and bound, such as float($1) to real
where $1 <= 24 or Decimal32($1) to Decimal(9, $1) — which the seed loads
into sql_type_rewrite and the catalog applies before interning; the words
and argument positions that are identifiers rather than types, kept as
dialect flags; and SQLite's affinity rule, loaded into sql_type_affinity.
A result that depends on an argument's value is a return template in
functions.jsonl, kept on sql_proc and filled in from the call's literals.
ClickHouse numbers an Enum's members and sorts a Variant's in its
converter, where spelling a type belongs, and goldeneye writes an array
column of the PostgreSQL system catalogs as its element with the array
flag rather than pg_type's _text, which the legacy path now sees too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qryf7a1doPFuCz2eGT3bCr
…ces, lookups

A type's canonical rendering now travels in its own field of ast.TypeName,
so DuckDB, GoogleSQL and ClickHouse hand the core struct(a: integer) and
Enum8('a' = 1) while the formatter keeps printing the author's spelling;
ParseTypeExpr reads a label before a colon, and before a space only when
one word follows, so a field typed timestamp with time zone keeps its
type. A lookup that may not write no longer probes by inserting an
empty-named row. A bare type name resolves in the default namespaces
only, and CREATE TYPE deduplicates within the namespace it names, so
foo.mood and mood are two types. IN with a subquery types its left side
once, and an operator's overloads are read once per resolution.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qryf7a1doPFuCz2eGT3bCr
…tes after aliases

An instance row's key is built from namespace-qualified names, so
array(myschema.mood) and array(mood) are two rows, and a lookup that may
not write still canonicalizes an expression whose nested instance is not
a row, so $1::varchar(10)[] reports array(character varying(10)). The
array family is seeded for every dialect rather than created on a
read-only lookup. A rewrite is tried with the alias resolved, so dec(7,2)
meets a rule on decimal. A bare CREATE TYPE lands in the dialect's
default schema, so SQL Server's Code and dbo.Code are one row.

A family in types.jsonl may name a base, which is how MySQL's unsigned
families stand on their signed ones; the legacy bridge strips " unsigned"
from the data type and sets the flag. Relations() carries one array
dimension for an array column.

Converters: a PostgreSQL cast to interval day to second decodes the field
mask as a column does; a MySQL cast to float or double carries no
modifier and a decimal cast's scale left out is 0; DuckDB's main schema
no longer leaves a leading dot; ClickHouse's LowCardinality(Nullable(T))
is a nullable column, read as a nullable lowcardinality(T) by the
converter and by goldeneye alike.

goldeneye keeps the case of a MySQL enum's members and unescapes them,
and relabels only the _-prefixed PostgreSQL array types, so int2vector
and oidvector stay their own; the relation seeds and the goldens they
feed are regenerated. The analyze_types cases cover each fix.

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

The seed now spells an array column as its element with the array flag,
and int2vector and oidvector columns as their own types, which the
external JSON plugin reports the same way the built-in one does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qryf7a1doPFuCz2eGT3bCr
@kyleconroy
kyleconroy merged commit 0cd1040 into main Sep 11, 2026
12 checks passed
@kyleconroy
kyleconroy deleted the claude/kind-dijkstra-88hp13 branch September 11, 2026 19:44
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