Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e7b7f55
fix(mssql): pass the datepart of DATEADD and its relatives as a keyword
EliSauder Nov 12, 2026
52b528b
feat(mssql): type AT TIME ZONE
EliSauder Nov 12, 2026
7aaa35c
feat(mssql): seed common built-in functions
EliSauder Nov 12, 2026
d6fb19e
fix(mssql): type mixed arithmetic by data type precedence
EliSauder Nov 12, 2026
94962e8
fix(mssql): type ISNULL as nullable only when both arguments are
EliSauder Nov 12, 2026
f6e56e3
fix(mssql): type set operation columns from every branch
EliSauder Nov 12, 2026
f424edb
fix(mssql): count int and bit as decimals when combining types
EliSauder Nov 12, 2026
64e9b6a
fix(mssql): type CASE, COALESCE, GREATEST, LEAST and CHOOSE by preced…
EliSauder Nov 12, 2026
25b6753
fix(mssql): seed ROUND with its function argument
EliSauder Nov 12, 2026
f66c551
fix(mssql): type DATEADD and DATETRUNC over a string date
EliSauder Nov 12, 2026
a0842fc
feat(mssql): type the methods of xml, geography, geometry and hierarc…
EliSauder Nov 12, 2026
f04eaba
fix(core): type a placeholder among alternatives as the others combine
EliSauder Nov 12, 2026
34bc380
feat(mssql): seed more built-in functions
EliSauder Nov 12, 2026
13138f4
feat(mssql): type LEFT, RIGHT and TRIM(chars FROM string)
EliSauder Nov 12, 2026
95835ee
fix(mssql): take REPLICATE's and CHARINDEX's integer arguments as int
EliSauder Nov 12, 2026
5ba6c85
fix(core): type a set operation column of an argument-free type as it…
EliSauder Nov 13, 2026
64e0b37
refactor(core): select engine-owned type-combination policies explicitly
EliSauder Nov 13, 2026
002e9f6
docs: explain dialect typing gaps and MSSQL conversion boundaries
EliSauder Nov 13, 2026
f669ca0
refactor(core): inject engine-owned type combiners into analysis
EliSauder Nov 13, 2026
91aab3b
docs: describe engine-supplied type combination and cache wiring
EliSauder Nov 13, 2026
41a7691
refactor(mssql): keep ISNULL type selection in the engine policy
EliSauder Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/guides/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,64 @@ Install `sqlc-gen-json` to avoid test failure.
go build -o ~/go/bin/sqlc-gen-json ./cmd/sqlc-gen-json
```

## Dialect type-combination policies

The analysis core owns traversal, column names, catalog resolution and
nullability propagation. Database-specific common-type algorithms belong to
the engine: SQL Server's precedence, length and decimal rules live in
`internal/engine/mssql/typepolicy`, independent of its parser.

`core.TypeCombiner` receives an operation context and the entire ordered list
of candidate types, retaining nil entries for untyped values. It returns a
type and an explicit `Handled` flag; declining a context preserves the old
inference fallback, while an error is propagated. Engines without a combiner
keep their existing behavior. `Prepare` accepts the combiner through an
optional analyzer option, inherited by nested queries.

When a converter lowers a function to a shared expression, it can retain the
source function name rather than encoding a result-type instruction in the
AST. `TypeCombinationRequest.FunctionName` passes that qualified identity to
the engine. For example, SQL Server's policy, not shared analysis, selects
ISNULL's first typed argument; ordinary COALESCE still combines its candidates.
The shared first-typed-value helper is only the compatibility fallback for
engines without a policy or for contexts a policy declines.

The compiler's existing engine assembly selects an optional combiner factory.
It calls that factory after `core.NewCached` returns, on both fresh and
restored catalogs, and passes the instance to analysis. No engine names,
imports or strategy registry are needed in shared core analysis or seeding.
Combiners must allow concurrent analysis and must not mutate candidate types.

`set_operations` in `dialect.json` is opaque JSON to shared seeding. Its schema
and validation belong to the engine implementation; SQL Server's precedence
and decimal settings are not a universal coercion language. Only the data is
cached; runtime behavior is constructed afresh. Malformed configuration fails
in the engine factory, and configuration without a provider is a setup error.

To support another dialect, implement its combiner and wire its factory at
the compiler's engine assembly, without editing shared policy dispatch or
validation. Add end-to-end cases rather than copying SQL Server's settings.
Some engines additionally need literal/domain metadata or recursive structured
types; extend the inputs for actual implementations rather than forcing those
semantics through pairwise precedence. Set operations and `CASE`-like
expressions need not share an algorithm. The current cross-engine gaps and
database references are in
[`analyze`](../howto/analyze.md#common-type-inference).

SQL Server's converter also performs syntax/semantic lowering before seeded
function lookup. `convertMethodCall` handles receivers, static `type::method`
syntax and XML `value`'s type argument; `convertAlternatives` produces the
shared AST nodes that combine candidates and propagate nullability. Ordinary
function calls retain `OVER` rather than being rewritten as alternatives.
JSONL signatures already describe the lowered calls' argument and return
types, but cannot currently request these AST transformations. The method
recognition map must stay synchronized with the leading-dot signatures.
The datepart recognition map distinguishes bare keyword arguments from column
references; `ident_args` in the dialect settings applies to *type* arguments,
not function arguments. Moving these maps into seed metadata would require
new converter metadata and a way to consume it before analysis, not merely
additional function signatures.

## Running Tests

```
Expand Down
39 changes: 39 additions & 0 deletions docs/howto/analyze.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,45 @@ engine's own analysis path, so the two can report a type differently — most
visibly, `analyze` reports type names as the catalog stores them, in lower
case.

## Common-type inference

The shared core does not give every database the same coercion rules. SQL
Server supplies its own type combiner for set operations and
alternative-valued expressions such as `CASE`, `COALESCE`, `GREATEST`, `LEAST`
and `CHOOSE`. `ISNULL` instead keeps its first typed argument's type; both
arguments must be nullable for its result to be nullable.

Dialects without a combiner keep the existing core fallback: a set-operation
column takes its type from the left branch, and an alternative-valued
expression takes the first typed candidate. **This is incomplete static
inference, not those databases' common-type semantics.** It does not change
their normal `generate` paths.

The database rules differ in ways that need separate policies:

| Dialect | Database behavior and current core gap |
| --- | --- |
| SQL Server | Uses type precedence and combines decimal scale/integer digits with a precision limit of 38. The enabled policy models these rules, but is not a general implicit-cast validator. |
| PostgreSQL | Selects common types using categories, implicit casts, preferred types, unknown literals and domains. Modifiers survive only when the candidates have the same type and modifier: `numeric(5,2) UNION numeric(8,4)` has unconstrained `numeric`, not SQL Server-style decimal widening. |
| SQLite | Values are dynamically typed; compound-view/subquery affinity can come from any constituent `SELECT`. Declared decimal precision and string length do not impose SQL Server-style limits. |
| MySQL | Aggregates types and lengths across set-operation branches; alternative expressions also need signed/unsigned, numeric, binary/string, temporal and NULL-literal rules. |
| DuckDB | Uses combination casting for set operations, including casts not allowed by ordinary implicit casting. Integer widths and nested types need more than a precedence list. |
| ClickHouse | Computes compatible supertypes, including `Nullable`, signed/unsigned widths and nested types; decimal precision can reach 76. Some combinations have no compatible supertype. |
| GoogleSQL (Spanner) | Intersects supertype sets, distinguishes exact from inexact numeric inputs, and has separate literal and structured-type rules. Its type inventory is not identical to BigQuery's. |

These are reviewed database semantics, not a claim that sqlc has implemented
or live-tested the missing policies. See the upstream references:
[SQL Server](https://learn.microsoft.com/en-us/sql/t-sql/data-types/precision-scale-and-length-transact-sql),
[PostgreSQL common types](https://www.postgresql.org/docs/18/typeconv-union-case.html)
and [modifiers](https://github.com/postgres/postgres/blob/REL_18_STABLE/src/backend/parser/parse_coerce.c),
[SQLite](https://www.sqlite.org/datatype3.html#column_affinity_for_compound_views),
[MySQL set operations](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/set-operations.html)
and [alternative expressions](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/flow-control-functions.html),
[DuckDB](https://duckdb.org/docs/2.0/sql/data_types/typecasting),
[ClickHouse UNION](https://clickhouse.com/docs/reference/statements/select/union)
and [decimals](https://clickhouse.com/docs/reference/data-types/decimal),
and [Spanner](https://docs.cloud.google.com/spanner/docs/reference/standard-sql/conversion_rules).

## Usage

```sh
Expand Down
17 changes: 17 additions & 0 deletions internal/compiler/compile.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,23 @@ func (c *Compiler) parseCatalogCore(files []schemaFile, merr *multierr.Error) er
// Whatever apply reported is already in merr, which the caller returns.
c.coreCatalog = cat

// Runtime behavior is wired after both seed and restore paths. Installing
// it through a seed option would miss cache hits.
if c.newCoreTypeCombiner != nil {
c.coreTypeCombiner, err = c.newCoreTypeCombiner(cat)
if err != nil {
return err
}
} else {
data, err := cat.TypeCombinationData()
if err != nil {
return err
}
if len(data) > 0 {
return fmt.Errorf("type-combination data requires an engine-supplied combiner")
}
}

// Codegen consumes the catalog through the Result, in the legacy shape.
legacy, err := coreResultCatalog(cat)
if err != nil {
Expand Down
8 changes: 6 additions & 2 deletions internal/compiler/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/sqlc-dev/sqlc/internal/engine/duckdb"
"github.com/sqlc-dev/sqlc/internal/engine/googlesql"
"github.com/sqlc-dev/sqlc/internal/engine/mssql"
mssqltypes "github.com/sqlc-dev/sqlc/internal/engine/mssql/typepolicy"
"github.com/sqlc-dev/sqlc/internal/engine/postgresql"
pganalyze "github.com/sqlc-dev/sqlc/internal/engine/postgresql/analyzer"
"github.com/sqlc-dev/sqlc/internal/engine/sqlite"
Expand All @@ -37,8 +38,10 @@ type Compiler struct {
// and coreDialect seeds the catalog it does so with. The catalog itself is
// not built until the schema is loaded, because the two are cached
// together.
coreAnalysis bool
coreDialect core.Option
coreAnalysis bool
coreDialect core.Option
coreTypeCombiner core.TypeCombiner
newCoreTypeCombiner func(*core.Catalog) (core.TypeCombiner, error)

// newParser builds a parser for the configured engine, and is set for
// every engine either path supports. The core path analyzes statements
Expand Down Expand Up @@ -153,6 +156,7 @@ func (c *Compiler) initCore() error {
c.newParser = func() Parser { return mssql.NewParser() }
c.selector = newDefaultSelector()
dialect = mssql.Dialect()
c.newCoreTypeCombiner = mssqltypes.New
case config.EngineDuckDB:
c.newParser = func() Parser { return duckdb.NewParser() }
c.selector = newDefaultSelector()
Expand Down
2 changes: 1 addition & 1 deletion internal/compiler/parse_core.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func (c *Compiler) parseQueryCore(raw *ast.RawStmt, src string, pre *preprocess.
var params []Parameter
switch raw.Stmt.(type) {
case *ast.SelectStmt, *ast.InsertStmt, *ast.UpdateStmt, *ast.DeleteStmt:
res, err := coreanalyzer.Prepare(c.coreCatalog, raw)
res, err := coreanalyzer.Prepare(c.coreCatalog, raw, coreanalyzer.WithTypeCombiner(c.coreTypeCombiner))
if err != nil {
return nil, err
}
Expand Down
42 changes: 30 additions & 12 deletions internal/core/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import (
"github.com/sqlc-dev/sqlc/internal/sql/ast"
)

func Prepare(cat *core.Catalog, stmt ast.Node) (core.PrepareResult, error) {
type Option func(*analyzer)

func WithTypeCombiner(combiner core.TypeCombiner) Option {
return func(a *analyzer) { a.combiner = combiner }
}

func Prepare(cat *core.Catalog, stmt ast.Node, options ...Option) (core.PrepareResult, error) {
if rs, ok := stmt.(*ast.RawStmt); ok {
stmt = rs.Stmt
}
Expand All @@ -16,6 +22,9 @@ func Prepare(cat *core.Catalog, stmt ast.Node) (core.PrepareResult, error) {
params: map[int]core.Parameter{},
stars: &[]core.StarExpansion{},
}
for _, option := range options {
option(a)
}
switch s := stmt.(type) {
case *ast.SelectStmt:
if err := a.analyzeSelect(s); err != nil {
Expand Down Expand Up @@ -44,11 +53,12 @@ func Prepare(cat *core.Catalog, stmt ast.Node) (core.PrepareResult, error) {
}

type analyzer struct {
cat *core.Catalog
scope *scope
columns []core.Column
params map[int]core.Parameter
command core.Command
cat *core.Catalog
combiner core.TypeCombiner
scope *scope
columns []core.Column
params map[int]core.Parameter
command core.Command

// outer is the scope of the query this one is nested in, which a
// correlated subquery refers to.
Expand Down Expand Up @@ -83,11 +93,12 @@ func (a *analyzer) recordStar(s core.StarExpansion) {
// query's scope, so a correlated reference resolves.
func (a *analyzer) subquery(s *ast.SelectStmt) (*analyzer, error) {
sub := &analyzer{
cat: a.cat,
params: a.params,
outer: a.scope,
ctes: a.ctes,
stars: a.stars,
cat: a.cat,
combiner: a.combiner,
params: a.params,
outer: a.scope,
ctes: a.ctes,
stars: a.stars,
}
if err := sub.analyzeSelect(s); err != nil {
return nil, err
Expand Down Expand Up @@ -277,11 +288,18 @@ func (a *analyzer) analyzeSetOperation(s *ast.SelectStmt) error {
if err != nil {
return err
}
if _, err := a.subquery(s.Rarg); err != nil {
right, err := a.subquery(s.Rarg)
if err != nil {
return err
}
a.columns = left.columns
a.scope = left.scope
if a.combiner != nil {
a.columns, err = a.combineColumns(s.Op, left.columns, right.columns)
if err != nil {
return err
}
}
return nil
}

Expand Down
84 changes: 60 additions & 24 deletions internal/core/analyzer/expr.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ func (a *analyzer) typeExpr(n ast.Node) (exprType, error) {
return a.typeCoalesce(e)

case *ast.MinMaxExpr:
return a.typeFirstOf(listItems(e.Args), false)
context := core.CombineGreatest
if e.Op != 0 {
context = core.CombineLeast
}
return a.typeFirstOf(context, listItems(e.Args), false)

case *ast.A_ArrayExpr:
return a.typeArrayExpr(e)
Expand Down Expand Up @@ -462,6 +466,13 @@ func (a *analyzer) typeCase(e *ast.CaseExpr) (exprType, error) {
continue
}
// "CASE x WHEN y" compares y against x; "CASE WHEN y" is a predicate.
// A placeholder for x takes the type of what it is compared with.
if pr, ok := e.Arg.(*ast.ParamRef); ok && argT.typeOID == 0 && argT.expr == nil {
if argT, err = a.typeExpr(when.Expr); err != nil {
return exprType{}, err
}
a.inferParam(pr, argT)
}
if e.Arg != nil {
if err := a.typeOperands(when.Expr, argT); err != nil {
return exprType{}, err
Expand All @@ -474,59 +485,84 @@ func (a *analyzer) typeCase(e *ast.CaseExpr) (exprType, error) {
if e.Defresult != nil {
results = append(results, e.Defresult)
}
t, err := a.typeFirstOf(results, e.Defresult == nil)
t, err := a.typeFirstOf(core.CombineCase, results, e.Defresult == nil)
if err != nil {
return exprType{}, err
}
return t, nil
}

// typeCoalesce types COALESCE, which is its first typed argument's type and
// is null only when every argument is.
// typeCoalesce types COALESCE-like expressions, which are null only when
// every argument is. Function-specific type selection belongs to the dialect.
func (a *analyzer) typeCoalesce(e *ast.CoalesceExpr) (exprType, error) {
var out exprType
found := false
types := make([]exprType, 0, len(listItems(e.Args)))
nullable := true
for _, n := range listItems(e.Args) {
t, err := a.typeExpr(n)
if err != nil {
return exprType{}, err
}
if !found && (t.typeOID != 0 || t.expr != nil) {
// The result is an expression's, not the column's it came from.
out = exprType{typeOID: t.typeOID, expr: t.expr}
found = true
}
types = append(types, t)
nullable = nullable && t.nullable
}
out.nullable = nullable
return out, nil
req := core.TypeCombinationRequest{Context: core.CombineCoalesce}
if e.Func != nil {
if e.Func.Catalog != "" {
req.FunctionName = append(req.FunctionName, e.Func.Catalog, e.Func.Schema)
} else if e.Func.Schema != "" {
req.FunctionName = append(req.FunctionName, e.Func.Schema)
}
req.FunctionName = append(req.FunctionName, e.Func.Name)
}
t, err := a.commonType(req, types)
if err != nil {
return exprType{}, err
}
// A placeholder there is one that may be NULL.
a.inferAlternatives(listItems(e.Args), t, true)
// The result is an expression's, not the column's it came from.
return exprType{typeOID: t.typeOID, expr: t.expr, nullable: nullable}, nil
}

// typeFirstOf types a set of alternative results, taking the first one that has
// a type. The result is nullable when any alternative is, or when the caller
// says the set is not exhaustive.
func (a *analyzer) typeFirstOf(nodes []ast.Node, nullable bool) (exprType, error) {
var out exprType
found := false
// typeFirstOf types a set of alternative results, as the type they combine
// to, which without a dialect's rules is the first typed one's. The result
// is nullable when any alternative is, or when the caller says the set is
// not exhaustive.
func (a *analyzer) typeFirstOf(context core.TypeCombinationContext, nodes []ast.Node, nullable bool) (exprType, error) {
types := make([]exprType, 0, len(nodes))
for _, n := range nodes {
t, err := a.typeExpr(n)
if err != nil {
return exprType{}, err
}
if !found && (t.typeOID != 0 || t.expr != nil) {
out = t
found = true
}
types = append(types, t)
nullable = nullable || t.nullable
}
out, err := a.commonType(core.TypeCombinationRequest{Context: context}, types)
if err != nil {
return exprType{}, err
}
a.inferAlternatives(nodes, out, false)
out.nullable = nullable
return out, nil
}

// inferAlternatives types a placeholder among alternatives as the type the
// others combine to, as GREATEST(1, @n) takes @n as an integer.
func (a *analyzer) inferAlternatives(nodes []ast.Node, t exprType, nullable bool) {
if t.typeOID == 0 && t.expr == nil {
return
}
for _, n := range nodes {
if p, ok := n.(*ast.ParamRef); ok {
a.inferParam(p, exprType{typeOID: t.typeOID, expr: t.expr, nullable: nullable})
}
}
}

// typeArrayExpr types ARRAY[...], whose type is an array of its elements'.
func (a *analyzer) typeArrayExpr(e *ast.A_ArrayExpr) (exprType, error) {
elemT, err := a.typeFirstOf(listItems(e.Elements), false)
elemT, err := a.typeFirstOf(core.CombineArray, listItems(e.Elements), false)
if err != nil {
return exprType{}, err
}
Expand Down
Loading
Loading