core: one type row per expression, canonicalized per dialect, across every engine - #4618
Merged
Merged
Conversation
…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
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The analysis core's catalog held one
sql_typerow per flat name, so a type argument, a struct field, an enum label, a domain's base, a MySQLUNSIGNEDor a second array dimension had nowhere to go, andsqlc analyzereportednumeric(10,2)asnumeric,int[][]as one dimension, and a ClickHouseCAST(x AS Nullable(String))asnullable. Three of those were regressions against the legacy compiler on the core path: codegen lostunsigned,lengthandarray_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_typeholds 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))), withexpras the canonical spelling and identity, andfamily_oid,element_oid,base_oidandcanonical_oidpointing at what resolution falls back to. Arguments, fields and labels live in a newsql_type_argtable.format_typenames (integer,character varying(255),timestamp with time zone), MySQL spellsBOOLEANastinyint(1), ClickHouse storesDecimal32(4)asDecimal(9, 4), DuckDB dropsvarchar(10)'s length, SQL Server keepsfloat(24)asreal.Array(Nullable(String))).myschema.mood), the wayformat_typeprints a type off the search path; an instance's key is built from qualified names, soarray(myschema.mood)andarray(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.jsoncarriesrewrites(pattern, template, optional bound:float($1)→realwhere$1 <= 24), the words and positions that are identifiers rather than types (max, the function ofSimpleAggregateFunction), SQLite's affinity rule, and a default schema.types.jsonlmay give a family abase, which is how MySQL's unsigned families stand on their signed ones.functions.jsonlmay spell a value-dependent result as a template ("returns": "Decimal(18, $2)"). The seed loads these intosql_type_rewrite,sql_type_affinity, dialect flags andsql_proc.return_template.The analyzer
exprTypecarries the expression beside the row, so casts, typed placeholders, constructors and function results report whole types; resolution walks the pointer chain, sonumeric(10, 2) + numeric(5, 1)resolves onnumeric. A cast is NULL when its operand is or its type says so. The legacy bridge derivesArrayDims,LengthandUnsignedfrom the expression.Engines
Each engine's converter hands the core a canonical rendering (
TypeName.Canonical, with fields labelleda: 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,Nestedcolumns and canonical enum and variant spellings. DuckDB, GoogleSQL and SQL Server carry nested types, parameters,MAX, defaults and alias types.Tests
analyze_types/<engine>case for every engine, checked by goldeneye against live MySQL, SQLite and ClickHouse (goldeneye now reads MySQL'sCOLUMN_TYPE, binds ClickHouse placeholders, and writes PostgreSQL array columns as element plus flag, which also fixes the legacy path's_textcolumns incodegen_json).--tags=examplespasses 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 generatesuint64forCAST(... AS UNSIGNED), which is the correct answer.timestamp with time zone, a write probe that inserted an empty-named type row,CREATE TYPEdeduplication that ignored the schema sofoo.moodandmoodcollapsed, bare names binding to types in unrelated schemas,IN (SELECT ...)typing its left side twice, and operator resolution issuing one query per chain pair.$1::varchar(10)[]) or tried to create thearrayfamily; rewrites applied before alias resolution, sodec(7,2)missed MySQL's rule ondecimal; a bare SQL ServerCREATE TYPElanding inpublicwhiledbo.Coderesolved elsewhere; the legacy bridge reportingbigint unsignedas the data type instead ofbigintplus the flag; a PostgreSQL cast tointerval day to secondcarrying the raw field mask; MySQLCAST(x AS DOUBLE)carrying the parser's display width; DuckDB'smain.schema leaving a leading dot; ClickHouseLowCardinality(Nullable(T))columns reported NOT NULL; goldeneye lowercasing MySQL enum members and relabellingint2vectorandoidvectorcolumns as arrays. Theanalyze_typescases cover each.Output changes
sqlc analyzeoutput changes for every dialect: canonical names, arguments, nested arrays, a fifth argument kindident, 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