Skip to content

feat(mssql): add engine-owned expression type policies - #4689

Open
EliSauder wants to merge 21 commits into
sqlc-dev:mainfrom
EliSauder:mssql/expression-typing
Open

EliSauder wants to merge 21 commits into
sqlc-dev:mainfrom
EliSauder:mssql/expression-typing

Conversation

@EliSauder

@EliSauder EliSauder commented Nov 13, 2026 •

Copy link
Copy Markdown

Summary

Improve SQL Server expression inference while keeping its coercion rules in the engine rather than the shared analyzer.

  • Infer mixed arithmetic, set-operation columns, CASE/COALESCE/GREATEST/LEAST/CHOOSE/IIF, and parameters among alternatives using SQL Server precedence and modifier rules.
  • Correct ISNULL's first-argument type selection and nullability.
  • Expand built-in function metadata, including datepart keywords, AT TIME ZONE, ROUND, DATEADD/DATETRUNC over strings, LEFT/RIGHT/TRIM, REPLICATE/CHARINDEX, and XML/spatial/hierarchyid methods.

Engine-owned policy and compatibility

The shared core exposes an optional TypeCombiner contract with construct context, ordered candidate types, and optional source function identity. SQL Server owns the implementation, configuration decoding, and validation in its typepolicy package. Shared seeding retains opaque engine configuration rather than selecting a named SQL Server strategy.

The compiler constructs the runtime policy after either catalog seeding or cache restoration, and nested analyzers inherit it. Engines without a combiner retain their existing first-branch/first-typed-candidate behavior; a policy may also decline a context and use that fallback. No new coercion policy is enabled for PostgreSQL, MySQL, SQLite, DuckDB, ClickHouse, or GoogleSQL.

ISNULL's source identity is preserved in the AST and forwarded generically. Only the SQL Server policy interprets that identity; shared AST/analyzer code has no first-argument behavioral flag. Walking, rewriting, and formatting preserve the function name.

Development and analysis documentation describe the extension contract, cache wiring, engine boundaries, and differences between database type-combination rules.

Related independent drafts: #4687 (core query support) and #4688 (domain metadata). Adjacent converter, dialect, and compiler edits may need conflict resolution when these are integrated; none requires another to land first.

Tests and validation

Schema/query fixtures with committed CLI output cover type combinations, modifiers, inferred parameters, nullability, nested queries, and ISNULL versus COALESCE.

The few direct tests are limited to behavior unavailable through the CLI: deliberately failing/declining providers, invalid embedded engine metadata, and the SQL Server AST printer because the fmt CLI does not support this engine. Each test documents that reason.

Validation completed during preparation on this branch's unchanged code tree:

  • Complete TestReplay/base/ corpus.
  • Touched-package and targeted end-to-end tests under the race detector, vet, build, and diff checks.
  • Cold and restored catalogs: ISNULL retains varchar(10) while COALESCE widens to nvarchar(40); the restored policy also produces the expected decimal modifiers.
  • Selected experimental-core replays matched the pre-refactor baseline for all 71 recorded test nodes, including identical differences for five existing MySQL/SQLite failures.

The existing experimental-core failures are case_value_param/mysql, coalesce_as/mysql, coalesce_as/sqlite, select_union/mysql, and select_union/sqlite; this PR does not claim the full opt-in core corpus passes. SQL Server behavior was checked against SQL Server 2025 during development. The later policy-ownership refactors did not rerun live-database validation, and this draft submission did not rerun the full database/examples suite. Upstream CI has not yet been evaluated.

Fork review and detailed history: EliSauder#4.

AI assistance

Prepared with GitHub Copilot assistance. Opened as a draft for author and maintainer review.

2026-11-13 - Draft reviewed and updated by @EliSauder

EliSauder and others added 21 commits November 12, 2026 18:34
DATEADD(day, ...), DATEDIFF, DATEPART, DATENAME, DATETRUNC and
DATE_BUCKET take a datepart keyword first, which teesql reports as a
column reference, so the analyzer failed with unknown column "day".
The converter now passes it as the string it names.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
teesql parses x AT TIME ZONE tz, but the converter dropped it, leaving
the column untyped. It is now the call of a seeded pseudo-function, so
the result is the nullable datetimeoffset SQL Server describes, with
datetime's precision of 3, and a time zone placeholder is nvarchar(4000).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Window, string, math, date and JSON functions such as ROW_NUMBER, LAG,
ISNULL, REPLACE, ROUND, EOMONTH and JSON_VALUE were unknown to the
analyzer and came out untyped. Seed them with the result types and
nullability SQL Server reports for them, so queries using them get real
column types.

DATEADD, DATEDIFF, DATEPART and DATENAME are now typed, so the
analyze_datepart case is regenerated, its placeholder moved to WHERE
where SQL Server can describe the query to check it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Only same-type arithmetic was seeded, so an expression mixing types such
as decimal * int fell back to a cast and came out as bigint. SQL Server
gives such an expression the operand type with the higher data type
precedence, and + on strings concatenates, giving a Unicode result when
either side is Unicode and a variable-length one when either side is.
Seed those operator pairs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ISNULL was seeded as a function returning its first argument's type,
never NULL, which is wrong when both arguments can be NULL. It is now
converted to the COALESCE node it behaves as: the first argument's type,
NULL only when every argument is.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A UNION, INTERSECT or EXCEPT column was typed from the first branch, as
it is named, but SQL Server types it from every branch: the type of
highest precedence, varying when either branch varies, with the length,
fractional precision or decimal precision and scale that hold both, and
reads it from no one table. It is nullable in a UNION when either branch
is, in an INTERSECT when both are, and in an EXCEPT when the first is.

A dialect may now give these rules as data, in set_operations; SQL
Server does. Without them a column is typed from the first branch, as
before.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A set operation combining an int or bit with a decimal kept the decimal's
precision, which is what SQL Server reports for a NULL constant of those
types, but not for a column: an int column counts as decimal(10, 0) and
a bit as decimal(1, 0), so int UNION ALL decimal(5,2) is decimal(12,2).

Addresses review comment r4557025343 on #1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ence

These yield one of several values, and were typed as the first typed one,
so CASE WHEN on_sale THEN 0 ELSE price END was an int and generated an
int32 the decimal price could not scan into. SQL Server types them as it
does a set operation's columns: the candidates' types combined by data
type precedence. They now are, by the dialect's set_operations rules;
a dialect without them types them as before.

GREATEST and LEAST convert to the min/max node and CHOOSE to the CASE
over its index it is defined as, whose placeholder now takes the type of
what it is compared with. ISNULL keeps its first argument's type, as
SQL Server gives it.

Addresses review comment r4557025398 on #1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ROUND(value, length, function) truncates rather than rounds when its
third argument is not zero, but only the two-argument form was seeded,
so a three-argument call matched no overload by its arguments and took
the first, bigint. Each overload now has a three-argument form returning
what its two-argument form does.

Addresses review comment r4557025437 on #1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
DATEADD and DATETRUNC return the type of the date they are given, which
a string literal is not: SQL Server reads one as datetime for DATEADD
and datetime2(7) for DATETRUNC. The seed returned the argument's own
type, varchar. String overloads now return what the server does, while
a typed date keeps its type.

Addresses review comment r4557025498 on #1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…hyid

A method called on a value, as doc.exist('/a') or node.GetLevel() is,
was taken for a function of that name, so its result went untyped. A
method is now seeded as ".name" taking the value first, and a static
one, as geography::Point(...), as "geography::point"; xml's value(path,
'type') returns the type it names. Each may return NULL.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A placeholder passed to COALESCE, GREATEST, LEAST or IIF, or returned by
a CASE branch, went untyped. It now takes the type the other
alternatives combine to; one in COALESCE may be NULL, since that is what
passing it there is for.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
GROUPING, GROUPING_ID, APPROX_COUNT_DISTINCT, CHECKSUM_AGG and
PERCENTILE_CONT; the metadata functions, as DB_NAME, OBJECT_ID and
IDENT_CURRENT; the ERROR_* functions, XACT_STATE and SESSION_CONTEXT;
STRING_ESCAPE, SOUNDEX, DIFFERENCE, FORMATMESSAGE, COMPRESS and
DECOMPRESS; the bit functions; DATE_BUCKET; the REGEXP_* functions;
VECTOR_DISTANCE; and LTRIM and RTRIM given the characters to trim.
Each was untyped. Their types are those SQL Server reports.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
LEFT and RIGHT, which the parser gives nodes of their own, were not
converted, so their results went untyped, and TRIM(chars FROM string)
took the type of the characters. LEFT and RIGHT are now called, taking
their length as the int SQL Server does, and TRIM given the characters
returns the string's type.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A placeholder for the count REPLICATE repeats a string, or the position
CHARINDEX starts at, was a bigint, where SQL Server takes an int.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…s family

A column of UNION, INTERSECT or EXCEPT whose combined type has no
arguments, as a string literal's varchar or decimal arithmetic's
decimal, was looked up as a declared type, which takes the dialect's
defaults: varchar(1) and decimal(18,0). It is now its family.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Keep SQL Server precedence and modifier rules in its typepolicy package, validate the serialized selector, and preserve the existing fallback for all other dialects. Cover invalid embedded seed metadata separately because it cannot be supplied through the CLI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Compare all seven seeded engines' common-type semantics and explain why SQL Server settings must not be copied to them. Clarify converter dispatch and the metadata needed to replace its recognition maps.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove SQL Server dispatch and typed policy settings from shared analysis and seeding. Bind the optional engine factory after catalog creation or restore, inherit it in nested queries, and retain existing fallback inference when no combiner handles a context.

Cover nested SQL Server combination end to end and inaccessible policy-contract/configuration failures with focused tests. Preserve single-candidate column provenance and all existing engine goldens.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace named-strategy documentation with the optional generic interface, engine-owned metadata validation and runtime construction after both catalog paths. Document backward-compatible inference for engines without a combiner.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the shared FirstArgType instruction with source function identity passed through the generic combination request. Keep first-typed inference in shared code only as the compatibility fallback. Preserve ISNULL identity in AST traversal and printing.

Cover first-argument selection versus COALESCE widening, literal NULLs and placeholders with pre-change end-to-end goldens. Exercise formatting directly because the fmt CLI does not support MSSQL.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@EliSauder

Copy link
Copy Markdown
Author

I have been using sqlc with mssql as a trial and ran in to feature gaps so I figured I would spend the time and tokens to suggest some changes.

Handles type resolution and promotion. The added TypeCombiner interfaces allows the engine/dialect to define how types are promoted since the underlying engines handle promotions differently and I wanted to avoid making core depend on mssql behavior.

@EliSauder
EliSauder marked this pull request as ready for review November 13, 2026 18:58
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.

1 participant