From e7b7f555c9cdfcca95d2a892dfe11b737524ab4c Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:43:06 -0800 Subject: [PATCH 01/21] fix(mssql): pass the datepart of DATEADD and its relatives as a keyword 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> --- .../testdata/analyze_datepart/mssql/exec.json | 5 +++ .../testdata/analyze_datepart/mssql/query.sql | 8 +++++ .../analyze_datepart/mssql/schema.sql | 7 ++++ .../analyze_datepart/mssql/stdout.json | 35 +++++++++++++++++++ internal/engine/mssql/convert.go | 31 +++++++++++++++- 5 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 internal/endtoend/testdata/analyze_datepart/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_datepart/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_datepart/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_datepart/mssql/stdout.json diff --git a/internal/endtoend/testdata/analyze_datepart/mssql/exec.json b/internal/endtoend/testdata/analyze_datepart/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_datepart/mssql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "mssql", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_datepart/mssql/query.sql b/internal/endtoend/testdata/analyze_datepart/mssql/query.sql new file mode 100644 index 0000000000..fe22c15579 --- /dev/null +++ b/internal/endtoend/testdata/analyze_datepart/mssql/query.sql @@ -0,0 +1,8 @@ +-- name: Recent :many +SELECT id, + DATEADD(day, @days, created) AS due, + DATEDIFF(minute, created, SYSUTCDATETIME()) AS age, + DATEPART(month, created) AS month_of, + DATENAME(weekday, created) AS weekday_of +FROM authors +WHERE created > DATEADD(hour, -1, SYSUTCDATETIME()); diff --git a/internal/endtoend/testdata/analyze_datepart/mssql/schema.sql b/internal/endtoend/testdata/analyze_datepart/mssql/schema.sql new file mode 100644 index 0000000000..3629635d2e --- /dev/null +++ b/internal/endtoend/testdata/analyze_datepart/mssql/schema.sql @@ -0,0 +1,7 @@ +CREATE TABLE authors ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + name NVARCHAR(100) NOT NULL, + bio NVARCHAR(MAX), + royalties DECIMAL(10,2) NOT NULL, + created DATETIME2 NOT NULL +); diff --git a/internal/endtoend/testdata/analyze_datepart/mssql/stdout.json b/internal/endtoend/testdata/analyze_datepart/mssql/stdout.json new file mode 100644 index 0000000000..eadcde7994 --- /dev/null +++ b/internal/endtoend/testdata/analyze_datepart/mssql/stdout.json @@ -0,0 +1,35 @@ +[ + { + "name": "Recent", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "authors" + }, + { + "name": "due" + }, + { + "name": "age" + }, + { + "name": "month_of" + }, + { + "name": "weekday_of" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "days" + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index bdef335bc6..c743d01989 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -734,7 +734,15 @@ func (c *cc) convertFunctionCall(n *tsql.FunctionCall) *ast.FuncCall { AggDistinct: n.UniqueRowFilter == "Distinct", } - for _, param := range n.Parameters { + for i, param := range n.Parameters { + // The datepart DATEADD and its relatives take first is a keyword, + // not a column: it is passed on as the string it names. + if i == 0 && datepartFunctions[identifierValue(n.FunctionName)] { + if part := datepart(param); part != "" { + fc.Args = &ast.List{Items: []ast.Node{&ast.A_Const{Val: &ast.String{Str: part}, Location: c.loc(param)}}} + continue + } + } // COUNT(*) and friends carry the star as a wildcard column reference. if col, ok := param.(*tsql.ColumnReferenceExpression); ok && col.ColumnType == "Wildcard" && col.MultiPartIdentifier == nil { fc.AggStar = true @@ -762,6 +770,27 @@ func (c *cc) convertFunctionCall(n *tsql.FunctionCall) *ast.FuncCall { return fc } +// datepartFunctions are the functions whose first argument is a datepart. +var datepartFunctions = map[string]bool{ + "dateadd": true, + "datediff": true, + "datediff_big": true, + "datename": true, + "datepart": true, + "datetrunc": true, + "date_bucket": true, +} + +// datepart returns the datepart keyword an argument spells, as in +// DATEADD(day, ...), or "" when it is not a bare name. +func datepart(n tsql.ScalarExpression) string { + col, ok := n.(*tsql.ColumnReferenceExpression) + if !ok || col.MultiPartIdentifier == nil || len(col.MultiPartIdentifier.Identifiers) != 1 { + return "" + } + return identifierValue(col.MultiPartIdentifier.Identifiers[0]) +} + // convertOutputClause converts an OUTPUT clause to a returning list. The // INSERTED and DELETED pseudo-table qualifiers are stripped so the columns // resolve against the statement's target table. From 52b528bcf1bbef01c1466eec45c1ed64c3d3fe55 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:49:34 -0800 Subject: [PATCH 02/21] feat(mssql): type AT TIME ZONE 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> --- .../analyze_at_time_zone/mssql/exec.json | 5 ++ .../analyze_at_time_zone/mssql/query.sql | 6 ++ .../analyze_at_time_zone/mssql/schema.sql | 7 +++ .../analyze_at_time_zone/mssql/stdout.json | 55 +++++++++++++++++++ internal/engine/mssql/convert.go | 15 +++++ internal/engine/mssql/dialect/functions.jsonl | 4 ++ 6 files changed, 92 insertions(+) create mode 100644 internal/endtoend/testdata/analyze_at_time_zone/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_at_time_zone/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_at_time_zone/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_at_time_zone/mssql/stdout.json diff --git a/internal/endtoend/testdata/analyze_at_time_zone/mssql/exec.json b/internal/endtoend/testdata/analyze_at_time_zone/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_at_time_zone/mssql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "mssql", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_at_time_zone/mssql/query.sql b/internal/endtoend/testdata/analyze_at_time_zone/mssql/query.sql new file mode 100644 index 0000000000..60bb2bd927 --- /dev/null +++ b/internal/endtoend/testdata/analyze_at_time_zone/mssql/query.sql @@ -0,0 +1,6 @@ +-- name: CreatedIn :many +SELECT id, + created AT TIME ZONE 'UTC' AS created_utc, + GETDATE() AT TIME ZONE 'UTC' AS now_utc +FROM authors +WHERE created AT TIME ZONE @tz > SYSDATETIMEOFFSET(); diff --git a/internal/endtoend/testdata/analyze_at_time_zone/mssql/schema.sql b/internal/endtoend/testdata/analyze_at_time_zone/mssql/schema.sql new file mode 100644 index 0000000000..3629635d2e --- /dev/null +++ b/internal/endtoend/testdata/analyze_at_time_zone/mssql/schema.sql @@ -0,0 +1,7 @@ +CREATE TABLE authors ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + name NVARCHAR(100) NOT NULL, + bio NVARCHAR(MAX), + royalties DECIMAL(10,2) NOT NULL, + created DATETIME2 NOT NULL +); diff --git a/internal/endtoend/testdata/analyze_at_time_zone/mssql/stdout.json b/internal/endtoend/testdata/analyze_at_time_zone/mssql/stdout.json new file mode 100644 index 0000000000..be5e5a7601 --- /dev/null +++ b/internal/endtoend/testdata/analyze_at_time_zone/mssql/stdout.json @@ -0,0 +1,55 @@ +[ + { + "name": "CreatedIn", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "authors" + }, + { + "name": "created_utc", + "type": { + "name": "datetimeoffset", + "nullable": true, + "args": [ + { + "int": 7 + } + ] + } + }, + { + "name": "now_utc", + "type": { + "name": "datetimeoffset", + "nullable": true, + "args": [ + { + "int": 3 + } + ] + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "tz", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 4000 + } + ] + } + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index c743d01989..8a0bfee99b 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -644,6 +644,21 @@ func (c *cc) convertScalarExpression(expr tsql.ScalarExpression) ast.Node { Subselect: c.convertQueryExpression(e.QueryExpression), Location: c.loc(e), } + case *tsql.AtTimeZoneCall: + // x AT TIME ZONE tz is typed as the call the dialect seeds under a + // name no function can have. + return &ast.FuncCall{ + Funcname: &ast.List{ + Items: []ast.Node{&ast.String{Str: "at time zone"}}, + }, + Args: &ast.List{ + Items: []ast.Node{ + c.convertScalarExpression(e.DateValue), + c.convertScalarExpression(e.TimeZone), + }, + }, + Location: c.loc(e), + } case *tsql.ParameterlessCall: return &ast.FuncCall{ Funcname: &ast.List{ diff --git a/internal/engine/mssql/dialect/functions.jsonl b/internal/engine/mssql/dialect/functions.jsonl index e0cd45408c..e0bfd9cfd2 100644 --- a/internal/engine/mssql/dialect/functions.jsonl +++ b/internal/engine/mssql/dialect/functions.jsonl @@ -74,3 +74,7 @@ {"name": "rtrim", "args": [{"type": "nvarchar"}], "returns": "nvarchar"} {"name": "trim", "args": [{"type": "varchar"}], "returns": "varchar"} {"name": "trim", "args": [{"type": "nvarchar"}], "returns": "nvarchar"} +{"name": "at time zone", "args": [{"type": "datetimeoffset"}, {"type": "nvarchar(4000)"}], "returns": "datetimeoffset(7)", "nullable": true} +{"name": "at time zone", "args": [{"type": "datetime2"}, {"type": "nvarchar(4000)"}], "returns": "datetimeoffset(7)", "nullable": true} +{"name": "at time zone", "args": [{"type": "datetime"}, {"type": "nvarchar(4000)"}], "returns": "datetimeoffset(3)", "nullable": true} +{"name": "at time zone", "args": [{"type": "smalldatetime"}, {"type": "nvarchar(4000)"}], "returns": "datetimeoffset(0)", "nullable": true} From 7aaa35cd8d07d70431dc8b3ea01b3d5276305788 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:47:49 -0800 Subject: [PATCH 03/21] feat(mssql): seed common built-in functions 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> --- .../testdata/analyze_datepart/mssql/query.sql | 4 +- .../analyze_datepart/mssql/stdout.json | 39 +- .../analyze_mssql_functions/mssql/exec.json | 5 + .../analyze_mssql_functions/mssql/query.sql | 71 +++ .../analyze_mssql_functions/mssql/schema.sql | 17 + .../analyze_mssql_functions/mssql/stdout.json | 495 ++++++++++++++++++ internal/engine/mssql/dialect/functions.jsonl | 172 ++++++ 7 files changed, 796 insertions(+), 7 deletions(-) create mode 100644 internal/endtoend/testdata/analyze_mssql_functions/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_mssql_functions/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_mssql_functions/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_mssql_functions/mssql/stdout.json diff --git a/internal/endtoend/testdata/analyze_datepart/mssql/query.sql b/internal/endtoend/testdata/analyze_datepart/mssql/query.sql index fe22c15579..0c79e36fa1 100644 --- a/internal/endtoend/testdata/analyze_datepart/mssql/query.sql +++ b/internal/endtoend/testdata/analyze_datepart/mssql/query.sql @@ -1,8 +1,8 @@ -- name: Recent :many SELECT id, - DATEADD(day, @days, created) AS due, + DATEADD(day, 1, created) AS due, DATEDIFF(minute, created, SYSUTCDATETIME()) AS age, DATEPART(month, created) AS month_of, DATENAME(weekday, created) AS weekday_of FROM authors -WHERE created > DATEADD(hour, -1, SYSUTCDATETIME()); +WHERE created > DATEADD(day, @days, SYSUTCDATETIME()); diff --git a/internal/endtoend/testdata/analyze_datepart/mssql/stdout.json b/internal/endtoend/testdata/analyze_datepart/mssql/stdout.json index eadcde7994..aa394695d9 100644 --- a/internal/endtoend/testdata/analyze_datepart/mssql/stdout.json +++ b/internal/endtoend/testdata/analyze_datepart/mssql/stdout.json @@ -11,23 +11,52 @@ "table": "authors" }, { - "name": "due" + "name": "due", + "type": { + "name": "datetime2", + "nullable": true, + "args": [ + { + "int": 7 + } + ] + } }, { - "name": "age" + "name": "age", + "type": { + "name": "int", + "nullable": true + } }, { - "name": "month_of" + "name": "month_of", + "type": { + "name": "int", + "nullable": true + } }, { - "name": "weekday_of" + "name": "weekday_of", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 30 + } + ] + } } ], "params": [ { "number": 1, "column": { - "name": "days" + "name": "days", + "type": { + "name": "int" + } } } ] diff --git a/internal/endtoend/testdata/analyze_mssql_functions/mssql/exec.json b/internal/endtoend/testdata/analyze_mssql_functions/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_mssql_functions/mssql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "mssql", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_mssql_functions/mssql/query.sql b/internal/endtoend/testdata/analyze_mssql_functions/mssql/query.sql new file mode 100644 index 0000000000..e8468281a8 --- /dev/null +++ b/internal/endtoend/testdata/analyze_mssql_functions/mssql/query.sql @@ -0,0 +1,71 @@ +-- name: Ranking :many +SELECT + id, + LAG(price) OVER (ORDER BY id) AS previous_price, + LEAD(title, 1, 'none') OVER (ORDER BY id) AS next_title, + FIRST_VALUE(subtitle) OVER (ORDER BY id) AS first_subtitle, + LAST_VALUE(qty) OVER (ORDER BY id) AS last_qty +FROM books; + +-- name: Nulls :many +SELECT + ISNULL(rating, 0) AS rating, + ISNULL(discount, 0) AS discount, + NULLIF(qty, 0) AS qty, + CHOOSE(1, title, subtitle) AS chosen, + GREATEST(price, discount) AS greatest_price, + LEAST(title, subtitle) AS least_title +FROM books; + +-- name: Strings :many +SELECT + REPLACE(title, 'a', 'b') AS replaced, + REPLACE(note, 'a', 'b') AS replaced_n, + REVERSE(title) AS reversed, + CHARINDEX('a', title) AS position, + PATINDEX('%a%', note) AS pattern_position, + FORMAT(price, 'N2') AS formatted, + QUOTENAME(name) AS quoted, + STR(weight, 10, 2) AS str_weight +FROM books; + +-- name: Aggregates :one +SELECT + STRING_AGG(title, ',') AS titles, + STRING_AGG(note, ',') AS notes, + STDEV(qty) AS qty_stdev +FROM books; + +-- name: Math :many +SELECT + ABS(qty) AS abs_qty, + ABS(price) AS abs_price, + ABS(cost) AS abs_cost, + CEILING(weight) AS ceiling_weight, + FLOOR(rating) AS floor_rating, + ROUND(discount, 1) AS rounded, + POWER(weight, 2) AS squared, + SQRT(qty) AS root, + SIGN(discount) AS sign_discount +FROM books; + +-- name: Dates :many +SELECT + YEAR(published) AS year, + MONTH(updated) AS month, + DAY(published) AS day, + EOMONTH(published) AS month_end, + DATEFROMPARTS(rating, 1, 1) AS start_date, + DATETIME2FROMPARTS(rating, 1, 1, 0, 0, 0, 0, 3) AS start_time, + SWITCHOFFSET(offset_at, '+01:00') AS switched +FROM books; + +-- name: Json :many +SELECT + ISJSON(doc) AS is_json, + JSON_VALUE(doc, '$.a') AS json_value, + JSON_QUERY(doc, '$.a') AS json_query, + JSON_MODIFY(doc, '$.a', 1) AS json_modified, + HASHBYTES('SHA2_256', title) AS hash, + CHECKSUM(title) AS checksum +FROM books; diff --git a/internal/endtoend/testdata/analyze_mssql_functions/mssql/schema.sql b/internal/endtoend/testdata/analyze_mssql_functions/mssql/schema.sql new file mode 100644 index 0000000000..c41fbe240e --- /dev/null +++ b/internal/endtoend/testdata/analyze_mssql_functions/mssql/schema.sql @@ -0,0 +1,17 @@ +CREATE TABLE books ( + id INT IDENTITY(1,1) PRIMARY KEY, + title VARCHAR(100) NOT NULL, + subtitle VARCHAR(100), + name NVARCHAR(100) NOT NULL, + note NVARCHAR(200), + price DECIMAL(10,2) NOT NULL, + discount DECIMAL(5,2), + qty INT NOT NULL, + rating INT, + weight FLOAT, + cost MONEY, + published DATE, + updated DATETIME2(3), + offset_at DATETIMEOFFSET, + doc NVARCHAR(MAX) +); diff --git a/internal/endtoend/testdata/analyze_mssql_functions/mssql/stdout.json b/internal/endtoend/testdata/analyze_mssql_functions/mssql/stdout.json new file mode 100644 index 0000000000..3983024aad --- /dev/null +++ b/internal/endtoend/testdata/analyze_mssql_functions/mssql/stdout.json @@ -0,0 +1,495 @@ +[ + { + "name": "Ranking", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "int" + }, + "table": "books" + }, + { + "name": "previous_price", + "type": { + "name": "decimal", + "nullable": true, + "args": [ + { + "int": 10 + }, + { + "int": 2 + } + ] + } + }, + { + "name": "next_title", + "type": { + "name": "varchar", + "nullable": true, + "args": [ + { + "int": 100 + } + ] + } + }, + { + "name": "first_subtitle", + "type": { + "name": "varchar", + "nullable": true, + "args": [ + { + "int": 100 + } + ] + } + }, + { + "name": "last_qty", + "type": { + "name": "int", + "nullable": true + } + } + ], + "params": [] + }, + { + "name": "Nulls", + "cmd": ":many", + "columns": [ + { + "name": "rating", + "type": { + "name": "int" + } + }, + { + "name": "discount", + "type": { + "name": "decimal", + "args": [ + { + "int": 5 + }, + { + "int": 2 + } + ] + } + }, + { + "name": "qty", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "chosen", + "type": { + "name": "varchar", + "nullable": true, + "args": [ + { + "int": 100 + } + ] + } + }, + { + "name": "greatest_price", + "type": { + "name": "decimal", + "nullable": true, + "args": [ + { + "int": 10 + }, + { + "int": 2 + } + ] + } + }, + { + "name": "least_title", + "type": { + "name": "varchar", + "nullable": true, + "args": [ + { + "int": 100 + } + ] + } + } + ], + "params": [] + }, + { + "name": "Strings", + "cmd": ":many", + "columns": [ + { + "name": "replaced", + "type": { + "name": "varchar", + "nullable": true, + "args": [ + { + "int": 8000 + } + ] + } + }, + { + "name": "replaced_n", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 4000 + } + ] + } + }, + { + "name": "reversed", + "type": { + "name": "varchar", + "nullable": true, + "args": [ + { + "int": 100 + } + ] + } + }, + { + "name": "position", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "pattern_position", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "formatted", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 4000 + } + ] + } + }, + { + "name": "quoted", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 258 + } + ] + } + }, + { + "name": "str_weight", + "type": { + "name": "varchar", + "nullable": true, + "args": [ + { + "int": 10 + } + ] + } + } + ], + "params": [] + }, + { + "name": "Aggregates", + "cmd": ":one", + "columns": [ + { + "name": "titles", + "type": { + "name": "varchar", + "nullable": true, + "args": [ + { + "int": 8000 + } + ] + } + }, + { + "name": "notes", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 4000 + } + ] + } + }, + { + "name": "qty_stdev", + "type": { + "name": "float", + "nullable": true + } + } + ], + "params": [] + }, + { + "name": "Math", + "cmd": ":many", + "columns": [ + { + "name": "abs_qty", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "abs_price", + "type": { + "name": "decimal", + "nullable": true, + "args": [ + { + "int": 10 + }, + { + "int": 2 + } + ] + } + }, + { + "name": "abs_cost", + "type": { + "name": "money", + "nullable": true + } + }, + { + "name": "ceiling_weight", + "type": { + "name": "float", + "nullable": true + } + }, + { + "name": "floor_rating", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "rounded", + "type": { + "name": "decimal", + "nullable": true, + "args": [ + { + "int": 5 + }, + { + "int": 2 + } + ] + } + }, + { + "name": "squared", + "type": { + "name": "float", + "nullable": true + } + }, + { + "name": "root", + "type": { + "name": "float", + "nullable": true + } + }, + { + "name": "sign_discount", + "type": { + "name": "decimal", + "nullable": true, + "args": [ + { + "int": 5 + }, + { + "int": 2 + } + ] + } + } + ], + "params": [] + }, + { + "name": "Dates", + "cmd": ":many", + "columns": [ + { + "name": "year", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "month", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "day", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "month_end", + "type": { + "name": "date", + "nullable": true + } + }, + { + "name": "start_date", + "type": { + "name": "date", + "nullable": true + } + }, + { + "name": "start_time", + "type": { + "name": "datetime2", + "nullable": true, + "args": [ + { + "int": 3 + } + ] + } + }, + { + "name": "switched", + "type": { + "name": "datetimeoffset", + "nullable": true, + "args": [ + { + "int": 7 + } + ] + } + } + ], + "params": [] + }, + { + "name": "Json", + "cmd": ":many", + "columns": [ + { + "name": "is_json", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "json_value", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 4000 + } + ] + } + }, + { + "name": "json_query", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "ident": "max" + } + ] + } + }, + { + "name": "json_modified", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "ident": "max" + } + ] + } + }, + { + "name": "hash", + "type": { + "name": "varbinary", + "nullable": true, + "args": [ + { + "int": 8000 + } + ] + } + }, + { + "name": "checksum", + "type": { + "name": "int", + "nullable": true + } + } + ], + "params": [] + } +] diff --git a/internal/engine/mssql/dialect/functions.jsonl b/internal/engine/mssql/dialect/functions.jsonl index e0bfd9cfd2..4173228ca5 100644 --- a/internal/engine/mssql/dialect/functions.jsonl +++ b/internal/engine/mssql/dialect/functions.jsonl @@ -50,6 +50,28 @@ {"name": "max", "kind": "a", "args": [{"type": "nvarchar"}], "returns": "nvarchar", "nullable": true} {"name": "max", "kind": "a", "args": [{"type": "char"}], "returns": "char", "nullable": true} {"name": "max", "kind": "a", "args": [{"type": "nchar"}], "returns": "nchar", "nullable": true} +{"name": "string_agg", "kind": "a", "args": [{"type": "varchar"}, {"type": "varchar"}], "returns": "varchar(8000)", "nullable": true} +{"name": "string_agg", "kind": "a", "args": [{"type": "char"}, {"type": "varchar"}], "returns": "varchar(8000)", "nullable": true} +{"name": "string_agg", "kind": "a", "args": [{"type": "nvarchar"}, {"type": "varchar"}], "returns": "nvarchar(4000)", "nullable": true} +{"name": "string_agg", "kind": "a", "args": [{"type": "nchar"}, {"type": "varchar"}], "returns": "nvarchar(4000)", "nullable": true} +{"name": "stdev", "kind": "a", "args": [{"type": "any"}], "returns": "float", "nullable": true} +{"name": "stdevp", "kind": "a", "args": [{"type": "any"}], "returns": "float", "nullable": true} +{"name": "var", "kind": "a", "args": [{"type": "any"}], "returns": "float", "nullable": true} +{"name": "varp", "kind": "a", "args": [{"type": "any"}], "returns": "float", "nullable": true} +{"name": "row_number", "kind": "w", "returns": "bigint"} +{"name": "rank", "kind": "w", "returns": "bigint"} +{"name": "dense_rank", "kind": "w", "returns": "bigint"} +{"name": "ntile", "kind": "w", "args": [{"type": "bigint"}], "returns": "bigint"} +{"name": "percent_rank", "kind": "w", "returns": "float", "nullable": true} +{"name": "cume_dist", "kind": "w", "returns": "float", "nullable": true} +{"name": "lag", "kind": "w", "args": [{"type": "any"}], "returns": "$1", "nullable": true} +{"name": "lag", "kind": "w", "args": [{"type": "any"}, {"type": "bigint"}], "returns": "$1", "nullable": true} +{"name": "lag", "kind": "w", "args": [{"type": "any"}, {"type": "bigint"}, {"type": "any"}], "returns": "$1", "nullable": true} +{"name": "lead", "kind": "w", "args": [{"type": "any"}], "returns": "$1", "nullable": true} +{"name": "lead", "kind": "w", "args": [{"type": "any"}, {"type": "bigint"}], "returns": "$1", "nullable": true} +{"name": "lead", "kind": "w", "args": [{"type": "any"}, {"type": "bigint"}, {"type": "any"}], "returns": "$1", "nullable": true} +{"name": "first_value", "kind": "w", "args": [{"type": "any"}], "returns": "$1", "nullable": true} +{"name": "last_value", "kind": "w", "args": [{"type": "any"}], "returns": "$1", "nullable": true} {"name": "getdate", "returns": "datetime"} {"name": "getutcdate", "returns": "datetime"} {"name": "sysdatetime", "returns": "datetime2"} @@ -74,6 +96,156 @@ {"name": "rtrim", "args": [{"type": "nvarchar"}], "returns": "nvarchar"} {"name": "trim", "args": [{"type": "varchar"}], "returns": "varchar"} {"name": "trim", "args": [{"type": "nvarchar"}], "returns": "nvarchar"} +{"name": "isnull", "args": [{"type": "any"}, {"type": "any"}], "returns": "$1"} +{"name": "nullif", "args": [{"type": "any"}, {"type": "any"}], "returns": "$1", "nullable": true} +{"name": "choose", "args": [{"type": "int"}, {"type": "any"}], "returns": "$2", "nullable": true} +{"name": "greatest", "args": [{"type": "any"}, {"type": "any"}], "returns": "$1", "nullable": true} +{"name": "least", "args": [{"type": "any"}, {"type": "any"}], "returns": "$1", "nullable": true} +{"name": "concat", "args": [{"type": "any"}, {"type": "any"}], "returns": "varchar"} +{"name": "concat", "args": [{"type": "nvarchar"}, {"type": "any"}], "returns": "nvarchar"} +{"name": "concat", "args": [{"type": "any"}, {"type": "nvarchar"}], "returns": "nvarchar"} +{"name": "concat_ws", "args": [{"type": "any"}, {"type": "any"}, {"type": "any"}], "returns": "varchar"} +{"name": "concat_ws", "args": [{"type": "nvarchar"}, {"type": "any"}, {"type": "any"}], "returns": "nvarchar"} +{"name": "concat_ws", "args": [{"type": "any"}, {"type": "nvarchar"}, {"type": "any"}], "returns": "nvarchar"} +{"name": "concat_ws", "args": [{"type": "any"}, {"type": "any"}, {"type": "nvarchar"}], "returns": "nvarchar"} +{"name": "substring", "args": [{"type": "varchar"}, {"type": "bigint"}, {"type": "bigint"}], "returns": "varchar", "nullable": true} +{"name": "substring", "args": [{"type": "char"}, {"type": "bigint"}, {"type": "bigint"}], "returns": "varchar", "nullable": true} +{"name": "substring", "args": [{"type": "nvarchar"}, {"type": "bigint"}, {"type": "bigint"}], "returns": "nvarchar", "nullable": true} +{"name": "substring", "args": [{"type": "nchar"}, {"type": "bigint"}, {"type": "bigint"}], "returns": "nvarchar", "nullable": true} +{"name": "substring", "args": [{"type": "varbinary"}, {"type": "bigint"}, {"type": "bigint"}], "returns": "varbinary", "nullable": true} +{"name": "substring", "args": [{"type": "binary"}, {"type": "bigint"}, {"type": "bigint"}], "returns": "varbinary", "nullable": true} +{"name": "left", "args": [{"type": "varchar"}, {"type": "bigint"}], "returns": "varchar", "nullable": true} +{"name": "left", "args": [{"type": "char"}, {"type": "bigint"}], "returns": "varchar", "nullable": true} +{"name": "left", "args": [{"type": "nvarchar"}, {"type": "bigint"}], "returns": "nvarchar", "nullable": true} +{"name": "left", "args": [{"type": "nchar"}, {"type": "bigint"}], "returns": "nvarchar", "nullable": true} +{"name": "right", "args": [{"type": "varchar"}, {"type": "bigint"}], "returns": "varchar", "nullable": true} +{"name": "right", "args": [{"type": "char"}, {"type": "bigint"}], "returns": "varchar", "nullable": true} +{"name": "right", "args": [{"type": "nvarchar"}, {"type": "bigint"}], "returns": "nvarchar", "nullable": true} +{"name": "right", "args": [{"type": "nchar"}, {"type": "bigint"}], "returns": "nvarchar", "nullable": true} +{"name": "replace", "args": [{"type": "varchar"}, {"type": "any"}, {"type": "any"}], "returns": "varchar(8000)", "nullable": true} +{"name": "replace", "args": [{"type": "char"}, {"type": "any"}, {"type": "any"}], "returns": "varchar(8000)", "nullable": true} +{"name": "replace", "args": [{"type": "nvarchar"}, {"type": "any"}, {"type": "any"}], "returns": "nvarchar(4000)", "nullable": true} +{"name": "replace", "args": [{"type": "nchar"}, {"type": "any"}, {"type": "any"}], "returns": "nvarchar(4000)", "nullable": true} +{"name": "translate", "args": [{"type": "varchar"}, {"type": "any"}, {"type": "any"}], "returns": "varchar(8000)", "nullable": true} +{"name": "translate", "args": [{"type": "nvarchar"}, {"type": "any"}, {"type": "any"}], "returns": "nvarchar(4000)", "nullable": true} +{"name": "replicate", "args": [{"type": "varchar"}, {"type": "bigint"}], "returns": "varchar", "nullable": true} +{"name": "replicate", "args": [{"type": "char"}, {"type": "bigint"}], "returns": "varchar", "nullable": true} +{"name": "replicate", "args": [{"type": "nvarchar"}, {"type": "bigint"}], "returns": "nvarchar", "nullable": true} +{"name": "replicate", "args": [{"type": "nchar"}, {"type": "bigint"}], "returns": "nvarchar", "nullable": true} +{"name": "reverse", "args": [{"type": "varchar"}], "returns": "$1", "nullable": true} +{"name": "reverse", "args": [{"type": "char"}], "returns": "varchar", "nullable": true} +{"name": "reverse", "args": [{"type": "nvarchar"}], "returns": "$1", "nullable": true} +{"name": "reverse", "args": [{"type": "nchar"}], "returns": "nvarchar", "nullable": true} +{"name": "stuff", "args": [{"type": "varchar"}, {"type": "bigint"}, {"type": "bigint"}, {"type": "any"}], "returns": "varchar", "nullable": true} +{"name": "stuff", "args": [{"type": "char"}, {"type": "bigint"}, {"type": "bigint"}, {"type": "any"}], "returns": "varchar", "nullable": true} +{"name": "stuff", "args": [{"type": "nvarchar"}, {"type": "bigint"}, {"type": "bigint"}, {"type": "any"}], "returns": "nvarchar", "nullable": true} +{"name": "stuff", "args": [{"type": "nchar"}, {"type": "bigint"}, {"type": "bigint"}, {"type": "any"}], "returns": "nvarchar", "nullable": true} +{"name": "space", "args": [{"type": "int"}], "returns": "varchar(8000)", "nullable": true} +{"name": "charindex", "args": [{"type": "any"}, {"type": "any"}], "returns": "int", "nullable": true} +{"name": "charindex", "args": [{"type": "any"}, {"type": "any"}, {"type": "bigint"}], "returns": "int", "nullable": true} +{"name": "patindex", "args": [{"type": "any"}, {"type": "any"}], "returns": "int", "nullable": true} +{"name": "ascii", "args": [{"type": "varchar"}], "returns": "int", "nullable": true} +{"name": "unicode", "args": [{"type": "nvarchar"}], "returns": "int", "nullable": true} +{"name": "format", "args": [{"type": "any"}, {"type": "nvarchar"}], "returns": "nvarchar(4000)", "nullable": true} +{"name": "format", "args": [{"type": "any"}, {"type": "nvarchar"}, {"type": "nvarchar"}], "returns": "nvarchar(4000)", "nullable": true} +{"name": "quotename", "args": [{"type": "nvarchar"}], "returns": "nvarchar(258)", "nullable": true} +{"name": "quotename", "args": [{"type": "nvarchar"}, {"type": "nvarchar"}], "returns": "nvarchar(258)", "nullable": true} +{"name": "str", "args": [{"type": "float"}], "returns": "varchar(10)", "nullable": true} +{"name": "str", "args": [{"type": "float"}, {"type": "int"}], "returns": "varchar($2)", "nullable": true} +{"name": "str", "args": [{"type": "float"}, {"type": "int"}, {"type": "int"}], "returns": "varchar($2)", "nullable": true} +{"name": "abs", "args": [{"type": "bigint"}], "returns": "bigint", "nullable": true} +{"name": "abs", "args": [{"type": "int"}], "returns": "int", "nullable": true} +{"name": "abs", "args": [{"type": "smallint"}], "returns": "int", "nullable": true} +{"name": "abs", "args": [{"type": "tinyint"}], "returns": "int", "nullable": true} +{"name": "abs", "args": [{"type": "decimal"}], "returns": "$1", "nullable": true} +{"name": "abs", "args": [{"type": "money"}], "returns": "money", "nullable": true} +{"name": "abs", "args": [{"type": "smallmoney"}], "returns": "money", "nullable": true} +{"name": "abs", "args": [{"type": "float"}], "returns": "float", "nullable": true} +{"name": "abs", "args": [{"type": "real"}], "returns": "float", "nullable": true} +{"name": "sign", "args": [{"type": "bigint"}], "returns": "bigint", "nullable": true} +{"name": "sign", "args": [{"type": "int"}], "returns": "int", "nullable": true} +{"name": "sign", "args": [{"type": "smallint"}], "returns": "int", "nullable": true} +{"name": "sign", "args": [{"type": "tinyint"}], "returns": "int", "nullable": true} +{"name": "sign", "args": [{"type": "decimal"}], "returns": "$1", "nullable": true} +{"name": "sign", "args": [{"type": "money"}], "returns": "money", "nullable": true} +{"name": "sign", "args": [{"type": "smallmoney"}], "returns": "money", "nullable": true} +{"name": "sign", "args": [{"type": "float"}], "returns": "float", "nullable": true} +{"name": "sign", "args": [{"type": "real"}], "returns": "float", "nullable": true} +{"name": "ceiling", "args": [{"type": "bigint"}], "returns": "bigint", "nullable": true} +{"name": "ceiling", "args": [{"type": "int"}], "returns": "int", "nullable": true} +{"name": "ceiling", "args": [{"type": "smallint"}], "returns": "int", "nullable": true} +{"name": "ceiling", "args": [{"type": "tinyint"}], "returns": "int", "nullable": true} +{"name": "ceiling", "args": [{"type": "decimal"}], "returns": "decimal", "nullable": true} +{"name": "ceiling", "args": [{"type": "money"}], "returns": "money", "nullable": true} +{"name": "ceiling", "args": [{"type": "smallmoney"}], "returns": "money", "nullable": true} +{"name": "ceiling", "args": [{"type": "float"}], "returns": "float", "nullable": true} +{"name": "ceiling", "args": [{"type": "real"}], "returns": "float", "nullable": true} +{"name": "floor", "args": [{"type": "bigint"}], "returns": "bigint", "nullable": true} +{"name": "floor", "args": [{"type": "int"}], "returns": "int", "nullable": true} +{"name": "floor", "args": [{"type": "smallint"}], "returns": "int", "nullable": true} +{"name": "floor", "args": [{"type": "tinyint"}], "returns": "int", "nullable": true} +{"name": "floor", "args": [{"type": "decimal"}], "returns": "decimal", "nullable": true} +{"name": "floor", "args": [{"type": "money"}], "returns": "money", "nullable": true} +{"name": "floor", "args": [{"type": "smallmoney"}], "returns": "money", "nullable": true} +{"name": "floor", "args": [{"type": "float"}], "returns": "float", "nullable": true} +{"name": "floor", "args": [{"type": "real"}], "returns": "float", "nullable": true} +{"name": "round", "args": [{"type": "bigint"}, {"type": "int"}], "returns": "bigint", "nullable": true} +{"name": "round", "args": [{"type": "int"}, {"type": "int"}], "returns": "int", "nullable": true} +{"name": "round", "args": [{"type": "smallint"}, {"type": "int"}], "returns": "int", "nullable": true} +{"name": "round", "args": [{"type": "tinyint"}, {"type": "int"}], "returns": "int", "nullable": true} +{"name": "round", "args": [{"type": "decimal"}, {"type": "int"}], "returns": "$1", "nullable": true} +{"name": "round", "args": [{"type": "money"}, {"type": "int"}], "returns": "money", "nullable": true} +{"name": "round", "args": [{"type": "smallmoney"}, {"type": "int"}], "returns": "money", "nullable": true} +{"name": "round", "args": [{"type": "float"}, {"type": "int"}], "returns": "float", "nullable": true} +{"name": "round", "args": [{"type": "real"}, {"type": "int"}], "returns": "float", "nullable": true} +{"name": "power", "args": [{"type": "bigint"}, {"type": "any"}], "returns": "bigint", "nullable": true} +{"name": "power", "args": [{"type": "int"}, {"type": "any"}], "returns": "int", "nullable": true} +{"name": "power", "args": [{"type": "smallint"}, {"type": "any"}], "returns": "int", "nullable": true} +{"name": "power", "args": [{"type": "tinyint"}, {"type": "any"}], "returns": "int", "nullable": true} +{"name": "power", "args": [{"type": "decimal"}, {"type": "any"}], "returns": "decimal", "nullable": true} +{"name": "power", "args": [{"type": "money"}, {"type": "any"}], "returns": "money", "nullable": true} +{"name": "power", "args": [{"type": "smallmoney"}, {"type": "any"}], "returns": "money", "nullable": true} +{"name": "power", "args": [{"type": "float"}, {"type": "any"}], "returns": "float", "nullable": true} +{"name": "power", "args": [{"type": "real"}, {"type": "any"}], "returns": "float", "nullable": true} +{"name": "sqrt", "args": [{"type": "float"}], "returns": "float", "nullable": true} +{"name": "square", "args": [{"type": "float"}], "returns": "float", "nullable": true} +{"name": "exp", "args": [{"type": "float"}], "returns": "float", "nullable": true} +{"name": "log", "args": [{"type": "float"}], "returns": "float", "nullable": true} +{"name": "log", "args": [{"type": "float"}, {"type": "float"}], "returns": "float", "nullable": true} +{"name": "log10", "args": [{"type": "float"}], "returns": "float", "nullable": true} +{"name": "pi", "returns": "float"} +{"name": "rand", "returns": "float", "nullable": true} +{"name": "rand", "args": [{"type": "int"}], "returns": "float", "nullable": true} +{"name": "year", "args": [{"type": "any"}], "returns": "int", "nullable": true} +{"name": "month", "args": [{"type": "any"}], "returns": "int", "nullable": true} +{"name": "day", "args": [{"type": "any"}], "returns": "int", "nullable": true} +{"name": "isdate", "args": [{"type": "any"}], "returns": "int", "nullable": true} +{"name": "eomonth", "args": [{"type": "any"}], "returns": "date", "nullable": true} +{"name": "eomonth", "args": [{"type": "any"}, {"type": "int"}], "returns": "date", "nullable": true} +{"name": "datefromparts", "args": [{"type": "int"}, {"type": "int"}, {"type": "int"}], "returns": "date", "nullable": true} +{"name": "datetimefromparts", "args": [{"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}], "returns": "datetime", "nullable": true} +{"name": "datetime2fromparts", "args": [{"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}], "returns": "datetime2($8)", "nullable": true} +{"name": "datetimeoffsetfromparts", "args": [{"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}], "returns": "datetimeoffset($10)", "nullable": true} +{"name": "timefromparts", "args": [{"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}, {"type": "int"}], "returns": "time($5)", "nullable": true} +{"name": "switchoffset", "args": [{"type": "datetimeoffset"}, {"type": "any"}], "returns": "$1", "nullable": true} +{"name": "todatetimeoffset", "args": [{"type": "any"}, {"type": "any"}], "returns": "datetimeoffset", "nullable": true} +{"name": "dateadd", "args": [{"type": "varchar"}, {"type": "int"}, {"type": "any"}], "returns": "$3", "nullable": true} +{"name": "datediff", "args": [{"type": "varchar"}, {"type": "any"}, {"type": "any"}], "returns": "int", "nullable": true} +{"name": "datediff_big", "args": [{"type": "varchar"}, {"type": "any"}, {"type": "any"}], "returns": "bigint", "nullable": true} +{"name": "datepart", "args": [{"type": "varchar"}, {"type": "any"}], "returns": "int", "nullable": true} +{"name": "datename", "args": [{"type": "varchar"}, {"type": "any"}], "returns": "nvarchar(30)", "nullable": true} +{"name": "datetrunc", "args": [{"type": "varchar"}, {"type": "any"}], "returns": "$2", "nullable": true} +{"name": "isjson", "args": [{"type": "any"}], "returns": "int", "nullable": true} +{"name": "isjson", "args": [{"type": "any"}, {"type": "varchar"}], "returns": "int", "nullable": true} +{"name": "json_value", "args": [{"type": "any"}, {"type": "nvarchar"}], "returns": "nvarchar(4000)", "nullable": true} +{"name": "json_query", "args": [{"type": "any"}], "returns": "nvarchar(max)", "nullable": true} +{"name": "json_query", "args": [{"type": "any"}, {"type": "nvarchar"}], "returns": "nvarchar(max)", "nullable": true} +{"name": "json_modify", "args": [{"type": "any"}, {"type": "nvarchar"}, {"type": "any"}], "returns": "nvarchar(max)", "nullable": true} +{"name": "json_object", "returns": "nvarchar(max)", "nullable": true} +{"name": "json_array", "returns": "nvarchar(max)", "nullable": true} +{"name": "hashbytes", "args": [{"type": "varchar"}, {"type": "any"}], "returns": "varbinary(8000)", "nullable": true} +{"name": "checksum", "args": [{"type": "any"}], "returns": "int", "nullable": true} +{"name": "binary_checksum", "args": [{"type": "any"}], "returns": "int", "nullable": true} {"name": "at time zone", "args": [{"type": "datetimeoffset"}, {"type": "nvarchar(4000)"}], "returns": "datetimeoffset(7)", "nullable": true} {"name": "at time zone", "args": [{"type": "datetime2"}, {"type": "nvarchar(4000)"}], "returns": "datetimeoffset(7)", "nullable": true} {"name": "at time zone", "args": [{"type": "datetime"}, {"type": "nvarchar(4000)"}], "returns": "datetimeoffset(3)", "nullable": true} From d6fb19e9188f718b6ba436b870d62c29ea546d53 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:49:14 -0800 Subject: [PATCH 04/21] fix(mssql): type mixed arithmetic by data type precedence 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> --- .../analyze_mssql_arithmetic/mssql/exec.json | 5 + .../analyze_mssql_arithmetic/mssql/query.sql | 23 + .../analyze_mssql_arithmetic/mssql/schema.sql | 16 + .../mssql/stdout.json | 128 ++++++ internal/engine/mssql/dialect/operators.jsonl | 432 ++++++++++++++++++ 5 files changed, 604 insertions(+) create mode 100644 internal/endtoend/testdata/analyze_mssql_arithmetic/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_mssql_arithmetic/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_mssql_arithmetic/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_mssql_arithmetic/mssql/stdout.json create mode 100644 internal/engine/mssql/dialect/operators.jsonl diff --git a/internal/endtoend/testdata/analyze_mssql_arithmetic/mssql/exec.json b/internal/endtoend/testdata/analyze_mssql_arithmetic/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_mssql_arithmetic/mssql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "mssql", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_mssql_arithmetic/mssql/query.sql b/internal/endtoend/testdata/analyze_mssql_arithmetic/mssql/query.sql new file mode 100644 index 0000000000..da4ff4cc83 --- /dev/null +++ b/internal/endtoend/testdata/analyze_mssql_arithmetic/mssql/query.sql @@ -0,0 +1,23 @@ +-- name: Numeric :many +SELECT + royalties * 2 AS doubled, + royalties / units AS per_unit, + units * weight AS weighted, + ratio + units AS ratio_units, + ratio * weight AS ratio_weight, + units + total AS units_total, + refunds - flags AS net_refunds, + fee * units AS fee_units, + small_fee + units AS small_fee_units, + small_fee + fee AS fees, + units % refunds AS remainder +FROM sales; + +-- name: Concatenation :many +SELECT + code + code AS codes, + code + title AS code_title, + ncode + title AS ncode_title, + title + name AS title_name, + code + ncode AS code_ncode +FROM sales; diff --git a/internal/endtoend/testdata/analyze_mssql_arithmetic/mssql/schema.sql b/internal/endtoend/testdata/analyze_mssql_arithmetic/mssql/schema.sql new file mode 100644 index 0000000000..891366facd --- /dev/null +++ b/internal/endtoend/testdata/analyze_mssql_arithmetic/mssql/schema.sql @@ -0,0 +1,16 @@ +CREATE TABLE sales ( + id INT IDENTITY(1,1) PRIMARY KEY, + royalties DECIMAL(10,2), + units INT, + refunds SMALLINT, + flags TINYINT, + total BIGINT, + ratio REAL, + weight FLOAT, + fee MONEY, + small_fee SMALLMONEY, + code CHAR(4), + title VARCHAR(50), + ncode NCHAR(4), + name NVARCHAR(50) +); diff --git a/internal/endtoend/testdata/analyze_mssql_arithmetic/mssql/stdout.json b/internal/endtoend/testdata/analyze_mssql_arithmetic/mssql/stdout.json new file mode 100644 index 0000000000..7ed41e25d7 --- /dev/null +++ b/internal/endtoend/testdata/analyze_mssql_arithmetic/mssql/stdout.json @@ -0,0 +1,128 @@ +[ + { + "name": "Numeric", + "cmd": ":many", + "columns": [ + { + "name": "doubled", + "type": { + "name": "decimal", + "nullable": true + } + }, + { + "name": "per_unit", + "type": { + "name": "decimal", + "nullable": true + } + }, + { + "name": "weighted", + "type": { + "name": "float", + "nullable": true + } + }, + { + "name": "ratio_units", + "type": { + "name": "real", + "nullable": true + } + }, + { + "name": "ratio_weight", + "type": { + "name": "float", + "nullable": true + } + }, + { + "name": "units_total", + "type": { + "name": "bigint", + "nullable": true + } + }, + { + "name": "net_refunds", + "type": { + "name": "smallint", + "nullable": true + } + }, + { + "name": "fee_units", + "type": { + "name": "money", + "nullable": true + } + }, + { + "name": "small_fee_units", + "type": { + "name": "smallmoney", + "nullable": true + } + }, + { + "name": "fees", + "type": { + "name": "money", + "nullable": true + } + }, + { + "name": "remainder", + "type": { + "name": "int", + "nullable": true + } + } + ], + "params": [] + }, + { + "name": "Concatenation", + "cmd": ":many", + "columns": [ + { + "name": "codes", + "type": { + "name": "char", + "nullable": true + } + }, + { + "name": "code_title", + "type": { + "name": "varchar", + "nullable": true + } + }, + { + "name": "ncode_title", + "type": { + "name": "nvarchar", + "nullable": true + } + }, + { + "name": "title_name", + "type": { + "name": "nvarchar", + "nullable": true + } + }, + { + "name": "code_ncode", + "type": { + "name": "nchar", + "nullable": true + } + } + ], + "params": [] + } +] diff --git a/internal/engine/mssql/dialect/operators.jsonl b/internal/engine/mssql/dialect/operators.jsonl new file mode 100644 index 0000000000..8d241329af --- /dev/null +++ b/internal/engine/mssql/dialect/operators.jsonl @@ -0,0 +1,432 @@ +{"name": "+", "left": "float", "right": "real", "result": "float"} +{"name": "+", "left": "float", "right": "decimal", "result": "float"} +{"name": "+", "left": "float", "right": "money", "result": "float"} +{"name": "+", "left": "float", "right": "smallmoney", "result": "float"} +{"name": "+", "left": "float", "right": "bigint", "result": "float"} +{"name": "+", "left": "float", "right": "int", "result": "float"} +{"name": "+", "left": "float", "right": "smallint", "result": "float"} +{"name": "+", "left": "float", "right": "tinyint", "result": "float"} +{"name": "+", "left": "float", "right": "bit", "result": "float"} +{"name": "+", "left": "real", "right": "float", "result": "float"} +{"name": "+", "left": "real", "right": "decimal", "result": "real"} +{"name": "+", "left": "real", "right": "money", "result": "real"} +{"name": "+", "left": "real", "right": "smallmoney", "result": "real"} +{"name": "+", "left": "real", "right": "bigint", "result": "real"} +{"name": "+", "left": "real", "right": "int", "result": "real"} +{"name": "+", "left": "real", "right": "smallint", "result": "real"} +{"name": "+", "left": "real", "right": "tinyint", "result": "real"} +{"name": "+", "left": "real", "right": "bit", "result": "real"} +{"name": "+", "left": "decimal", "right": "float", "result": "float"} +{"name": "+", "left": "decimal", "right": "real", "result": "real"} +{"name": "+", "left": "decimal", "right": "money", "result": "decimal"} +{"name": "+", "left": "decimal", "right": "smallmoney", "result": "decimal"} +{"name": "+", "left": "decimal", "right": "bigint", "result": "decimal"} +{"name": "+", "left": "decimal", "right": "int", "result": "decimal"} +{"name": "+", "left": "decimal", "right": "smallint", "result": "decimal"} +{"name": "+", "left": "decimal", "right": "tinyint", "result": "decimal"} +{"name": "+", "left": "decimal", "right": "bit", "result": "decimal"} +{"name": "+", "left": "money", "right": "float", "result": "float"} +{"name": "+", "left": "money", "right": "real", "result": "real"} +{"name": "+", "left": "money", "right": "decimal", "result": "decimal"} +{"name": "+", "left": "money", "right": "smallmoney", "result": "money"} +{"name": "+", "left": "money", "right": "bigint", "result": "money"} +{"name": "+", "left": "money", "right": "int", "result": "money"} +{"name": "+", "left": "money", "right": "smallint", "result": "money"} +{"name": "+", "left": "money", "right": "tinyint", "result": "money"} +{"name": "+", "left": "money", "right": "bit", "result": "money"} +{"name": "+", "left": "smallmoney", "right": "float", "result": "float"} +{"name": "+", "left": "smallmoney", "right": "real", "result": "real"} +{"name": "+", "left": "smallmoney", "right": "decimal", "result": "decimal"} +{"name": "+", "left": "smallmoney", "right": "money", "result": "money"} +{"name": "+", "left": "smallmoney", "right": "bigint", "result": "smallmoney"} +{"name": "+", "left": "smallmoney", "right": "int", "result": "smallmoney"} +{"name": "+", "left": "smallmoney", "right": "smallint", "result": "smallmoney"} +{"name": "+", "left": "smallmoney", "right": "tinyint", "result": "smallmoney"} +{"name": "+", "left": "smallmoney", "right": "bit", "result": "smallmoney"} +{"name": "+", "left": "bigint", "right": "float", "result": "float"} +{"name": "+", "left": "bigint", "right": "real", "result": "real"} +{"name": "+", "left": "bigint", "right": "decimal", "result": "decimal"} +{"name": "+", "left": "bigint", "right": "money", "result": "money"} +{"name": "+", "left": "bigint", "right": "smallmoney", "result": "smallmoney"} +{"name": "+", "left": "bigint", "right": "int", "result": "bigint"} +{"name": "+", "left": "bigint", "right": "smallint", "result": "bigint"} +{"name": "+", "left": "bigint", "right": "tinyint", "result": "bigint"} +{"name": "+", "left": "bigint", "right": "bit", "result": "bigint"} +{"name": "+", "left": "int", "right": "float", "result": "float"} +{"name": "+", "left": "int", "right": "real", "result": "real"} +{"name": "+", "left": "int", "right": "decimal", "result": "decimal"} +{"name": "+", "left": "int", "right": "money", "result": "money"} +{"name": "+", "left": "int", "right": "smallmoney", "result": "smallmoney"} +{"name": "+", "left": "int", "right": "bigint", "result": "bigint"} +{"name": "+", "left": "int", "right": "smallint", "result": "int"} +{"name": "+", "left": "int", "right": "tinyint", "result": "int"} +{"name": "+", "left": "int", "right": "bit", "result": "int"} +{"name": "+", "left": "smallint", "right": "float", "result": "float"} +{"name": "+", "left": "smallint", "right": "real", "result": "real"} +{"name": "+", "left": "smallint", "right": "decimal", "result": "decimal"} +{"name": "+", "left": "smallint", "right": "money", "result": "money"} +{"name": "+", "left": "smallint", "right": "smallmoney", "result": "smallmoney"} +{"name": "+", "left": "smallint", "right": "bigint", "result": "bigint"} +{"name": "+", "left": "smallint", "right": "int", "result": "int"} +{"name": "+", "left": "smallint", "right": "tinyint", "result": "smallint"} +{"name": "+", "left": "smallint", "right": "bit", "result": "smallint"} +{"name": "+", "left": "tinyint", "right": "float", "result": "float"} +{"name": "+", "left": "tinyint", "right": "real", "result": "real"} +{"name": "+", "left": "tinyint", "right": "decimal", "result": "decimal"} +{"name": "+", "left": "tinyint", "right": "money", "result": "money"} +{"name": "+", "left": "tinyint", "right": "smallmoney", "result": "smallmoney"} +{"name": "+", "left": "tinyint", "right": "bigint", "result": "bigint"} +{"name": "+", "left": "tinyint", "right": "int", "result": "int"} +{"name": "+", "left": "tinyint", "right": "smallint", "result": "smallint"} +{"name": "+", "left": "tinyint", "right": "bit", "result": "tinyint"} +{"name": "+", "left": "bit", "right": "float", "result": "float"} +{"name": "+", "left": "bit", "right": "real", "result": "real"} +{"name": "+", "left": "bit", "right": "decimal", "result": "decimal"} +{"name": "+", "left": "bit", "right": "money", "result": "money"} +{"name": "+", "left": "bit", "right": "smallmoney", "result": "smallmoney"} +{"name": "+", "left": "bit", "right": "bigint", "result": "bigint"} +{"name": "+", "left": "bit", "right": "int", "result": "int"} +{"name": "+", "left": "bit", "right": "smallint", "result": "smallint"} +{"name": "+", "left": "bit", "right": "tinyint", "result": "tinyint"} +{"name": "-", "left": "float", "right": "real", "result": "float"} +{"name": "-", "left": "float", "right": "decimal", "result": "float"} +{"name": "-", "left": "float", "right": "money", "result": "float"} +{"name": "-", "left": "float", "right": "smallmoney", "result": "float"} +{"name": "-", "left": "float", "right": "bigint", "result": "float"} +{"name": "-", "left": "float", "right": "int", "result": "float"} +{"name": "-", "left": "float", "right": "smallint", "result": "float"} +{"name": "-", "left": "float", "right": "tinyint", "result": "float"} +{"name": "-", "left": "float", "right": "bit", "result": "float"} +{"name": "-", "left": "real", "right": "float", "result": "float"} +{"name": "-", "left": "real", "right": "decimal", "result": "real"} +{"name": "-", "left": "real", "right": "money", "result": "real"} +{"name": "-", "left": "real", "right": "smallmoney", "result": "real"} +{"name": "-", "left": "real", "right": "bigint", "result": "real"} +{"name": "-", "left": "real", "right": "int", "result": "real"} +{"name": "-", "left": "real", "right": "smallint", "result": "real"} +{"name": "-", "left": "real", "right": "tinyint", "result": "real"} +{"name": "-", "left": "real", "right": "bit", "result": "real"} +{"name": "-", "left": "decimal", "right": "float", "result": "float"} +{"name": "-", "left": "decimal", "right": "real", "result": "real"} +{"name": "-", "left": "decimal", "right": "money", "result": "decimal"} +{"name": "-", "left": "decimal", "right": "smallmoney", "result": "decimal"} +{"name": "-", "left": "decimal", "right": "bigint", "result": "decimal"} +{"name": "-", "left": "decimal", "right": "int", "result": "decimal"} +{"name": "-", "left": "decimal", "right": "smallint", "result": "decimal"} +{"name": "-", "left": "decimal", "right": "tinyint", "result": "decimal"} +{"name": "-", "left": "decimal", "right": "bit", "result": "decimal"} +{"name": "-", "left": "money", "right": "float", "result": "float"} +{"name": "-", "left": "money", "right": "real", "result": "real"} +{"name": "-", "left": "money", "right": "decimal", "result": "decimal"} +{"name": "-", "left": "money", "right": "smallmoney", "result": "money"} +{"name": "-", "left": "money", "right": "bigint", "result": "money"} +{"name": "-", "left": "money", "right": "int", "result": "money"} +{"name": "-", "left": "money", "right": "smallint", "result": "money"} +{"name": "-", "left": "money", "right": "tinyint", "result": "money"} +{"name": "-", "left": "money", "right": "bit", "result": "money"} +{"name": "-", "left": "smallmoney", "right": "float", "result": "float"} +{"name": "-", "left": "smallmoney", "right": "real", "result": "real"} +{"name": "-", "left": "smallmoney", "right": "decimal", "result": "decimal"} +{"name": "-", "left": "smallmoney", "right": "money", "result": "money"} +{"name": "-", "left": "smallmoney", "right": "bigint", "result": "smallmoney"} +{"name": "-", "left": "smallmoney", "right": "int", "result": "smallmoney"} +{"name": "-", "left": "smallmoney", "right": "smallint", "result": "smallmoney"} +{"name": "-", "left": "smallmoney", "right": "tinyint", "result": "smallmoney"} +{"name": "-", "left": "smallmoney", "right": "bit", "result": "smallmoney"} +{"name": "-", "left": "bigint", "right": "float", "result": "float"} +{"name": "-", "left": "bigint", "right": "real", "result": "real"} +{"name": "-", "left": "bigint", "right": "decimal", "result": "decimal"} +{"name": "-", "left": "bigint", "right": "money", "result": "money"} +{"name": "-", "left": "bigint", "right": "smallmoney", "result": "smallmoney"} +{"name": "-", "left": "bigint", "right": "int", "result": "bigint"} +{"name": "-", "left": "bigint", "right": "smallint", "result": "bigint"} +{"name": "-", "left": "bigint", "right": "tinyint", "result": "bigint"} +{"name": "-", "left": "bigint", "right": "bit", "result": "bigint"} +{"name": "-", "left": "int", "right": "float", "result": "float"} +{"name": "-", "left": "int", "right": "real", "result": "real"} +{"name": "-", "left": "int", "right": "decimal", "result": "decimal"} +{"name": "-", "left": "int", "right": "money", "result": "money"} +{"name": "-", "left": "int", "right": "smallmoney", "result": "smallmoney"} +{"name": "-", "left": "int", "right": "bigint", "result": "bigint"} +{"name": "-", "left": "int", "right": "smallint", "result": "int"} +{"name": "-", "left": "int", "right": "tinyint", "result": "int"} +{"name": "-", "left": "int", "right": "bit", "result": "int"} +{"name": "-", "left": "smallint", "right": "float", "result": "float"} +{"name": "-", "left": "smallint", "right": "real", "result": "real"} +{"name": "-", "left": "smallint", "right": "decimal", "result": "decimal"} +{"name": "-", "left": "smallint", "right": "money", "result": "money"} +{"name": "-", "left": "smallint", "right": "smallmoney", "result": "smallmoney"} +{"name": "-", "left": "smallint", "right": "bigint", "result": "bigint"} +{"name": "-", "left": "smallint", "right": "int", "result": "int"} +{"name": "-", "left": "smallint", "right": "tinyint", "result": "smallint"} +{"name": "-", "left": "smallint", "right": "bit", "result": "smallint"} +{"name": "-", "left": "tinyint", "right": "float", "result": "float"} +{"name": "-", "left": "tinyint", "right": "real", "result": "real"} +{"name": "-", "left": "tinyint", "right": "decimal", "result": "decimal"} +{"name": "-", "left": "tinyint", "right": "money", "result": "money"} +{"name": "-", "left": "tinyint", "right": "smallmoney", "result": "smallmoney"} +{"name": "-", "left": "tinyint", "right": "bigint", "result": "bigint"} +{"name": "-", "left": "tinyint", "right": "int", "result": "int"} +{"name": "-", "left": "tinyint", "right": "smallint", "result": "smallint"} +{"name": "-", "left": "tinyint", "right": "bit", "result": "tinyint"} +{"name": "-", "left": "bit", "right": "float", "result": "float"} +{"name": "-", "left": "bit", "right": "real", "result": "real"} +{"name": "-", "left": "bit", "right": "decimal", "result": "decimal"} +{"name": "-", "left": "bit", "right": "money", "result": "money"} +{"name": "-", "left": "bit", "right": "smallmoney", "result": "smallmoney"} +{"name": "-", "left": "bit", "right": "bigint", "result": "bigint"} +{"name": "-", "left": "bit", "right": "int", "result": "int"} +{"name": "-", "left": "bit", "right": "smallint", "result": "smallint"} +{"name": "-", "left": "bit", "right": "tinyint", "result": "tinyint"} +{"name": "*", "left": "float", "right": "real", "result": "float"} +{"name": "*", "left": "float", "right": "decimal", "result": "float"} +{"name": "*", "left": "float", "right": "money", "result": "float"} +{"name": "*", "left": "float", "right": "smallmoney", "result": "float"} +{"name": "*", "left": "float", "right": "bigint", "result": "float"} +{"name": "*", "left": "float", "right": "int", "result": "float"} +{"name": "*", "left": "float", "right": "smallint", "result": "float"} +{"name": "*", "left": "float", "right": "tinyint", "result": "float"} +{"name": "*", "left": "float", "right": "bit", "result": "float"} +{"name": "*", "left": "real", "right": "float", "result": "float"} +{"name": "*", "left": "real", "right": "decimal", "result": "real"} +{"name": "*", "left": "real", "right": "money", "result": "real"} +{"name": "*", "left": "real", "right": "smallmoney", "result": "real"} +{"name": "*", "left": "real", "right": "bigint", "result": "real"} +{"name": "*", "left": "real", "right": "int", "result": "real"} +{"name": "*", "left": "real", "right": "smallint", "result": "real"} +{"name": "*", "left": "real", "right": "tinyint", "result": "real"} +{"name": "*", "left": "real", "right": "bit", "result": "real"} +{"name": "*", "left": "decimal", "right": "float", "result": "float"} +{"name": "*", "left": "decimal", "right": "real", "result": "real"} +{"name": "*", "left": "decimal", "right": "money", "result": "decimal"} +{"name": "*", "left": "decimal", "right": "smallmoney", "result": "decimal"} +{"name": "*", "left": "decimal", "right": "bigint", "result": "decimal"} +{"name": "*", "left": "decimal", "right": "int", "result": "decimal"} +{"name": "*", "left": "decimal", "right": "smallint", "result": "decimal"} +{"name": "*", "left": "decimal", "right": "tinyint", "result": "decimal"} +{"name": "*", "left": "decimal", "right": "bit", "result": "decimal"} +{"name": "*", "left": "money", "right": "float", "result": "float"} +{"name": "*", "left": "money", "right": "real", "result": "real"} +{"name": "*", "left": "money", "right": "decimal", "result": "decimal"} +{"name": "*", "left": "money", "right": "smallmoney", "result": "money"} +{"name": "*", "left": "money", "right": "bigint", "result": "money"} +{"name": "*", "left": "money", "right": "int", "result": "money"} +{"name": "*", "left": "money", "right": "smallint", "result": "money"} +{"name": "*", "left": "money", "right": "tinyint", "result": "money"} +{"name": "*", "left": "money", "right": "bit", "result": "money"} +{"name": "*", "left": "smallmoney", "right": "float", "result": "float"} +{"name": "*", "left": "smallmoney", "right": "real", "result": "real"} +{"name": "*", "left": "smallmoney", "right": "decimal", "result": "decimal"} +{"name": "*", "left": "smallmoney", "right": "money", "result": "money"} +{"name": "*", "left": "smallmoney", "right": "bigint", "result": "smallmoney"} +{"name": "*", "left": "smallmoney", "right": "int", "result": "smallmoney"} +{"name": "*", "left": "smallmoney", "right": "smallint", "result": "smallmoney"} +{"name": "*", "left": "smallmoney", "right": "tinyint", "result": "smallmoney"} +{"name": "*", "left": "smallmoney", "right": "bit", "result": "smallmoney"} +{"name": "*", "left": "bigint", "right": "float", "result": "float"} +{"name": "*", "left": "bigint", "right": "real", "result": "real"} +{"name": "*", "left": "bigint", "right": "decimal", "result": "decimal"} +{"name": "*", "left": "bigint", "right": "money", "result": "money"} +{"name": "*", "left": "bigint", "right": "smallmoney", "result": "smallmoney"} +{"name": "*", "left": "bigint", "right": "int", "result": "bigint"} +{"name": "*", "left": "bigint", "right": "smallint", "result": "bigint"} +{"name": "*", "left": "bigint", "right": "tinyint", "result": "bigint"} +{"name": "*", "left": "bigint", "right": "bit", "result": "bigint"} +{"name": "*", "left": "int", "right": "float", "result": "float"} +{"name": "*", "left": "int", "right": "real", "result": "real"} +{"name": "*", "left": "int", "right": "decimal", "result": "decimal"} +{"name": "*", "left": "int", "right": "money", "result": "money"} +{"name": "*", "left": "int", "right": "smallmoney", "result": "smallmoney"} +{"name": "*", "left": "int", "right": "bigint", "result": "bigint"} +{"name": "*", "left": "int", "right": "smallint", "result": "int"} +{"name": "*", "left": "int", "right": "tinyint", "result": "int"} +{"name": "*", "left": "int", "right": "bit", "result": "int"} +{"name": "*", "left": "smallint", "right": "float", "result": "float"} +{"name": "*", "left": "smallint", "right": "real", "result": "real"} +{"name": "*", "left": "smallint", "right": "decimal", "result": "decimal"} +{"name": "*", "left": "smallint", "right": "money", "result": "money"} +{"name": "*", "left": "smallint", "right": "smallmoney", "result": "smallmoney"} +{"name": "*", "left": "smallint", "right": "bigint", "result": "bigint"} +{"name": "*", "left": "smallint", "right": "int", "result": "int"} +{"name": "*", "left": "smallint", "right": "tinyint", "result": "smallint"} +{"name": "*", "left": "smallint", "right": "bit", "result": "smallint"} +{"name": "*", "left": "tinyint", "right": "float", "result": "float"} +{"name": "*", "left": "tinyint", "right": "real", "result": "real"} +{"name": "*", "left": "tinyint", "right": "decimal", "result": "decimal"} +{"name": "*", "left": "tinyint", "right": "money", "result": "money"} +{"name": "*", "left": "tinyint", "right": "smallmoney", "result": "smallmoney"} +{"name": "*", "left": "tinyint", "right": "bigint", "result": "bigint"} +{"name": "*", "left": "tinyint", "right": "int", "result": "int"} +{"name": "*", "left": "tinyint", "right": "smallint", "result": "smallint"} +{"name": "*", "left": "tinyint", "right": "bit", "result": "tinyint"} +{"name": "*", "left": "bit", "right": "float", "result": "float"} +{"name": "*", "left": "bit", "right": "real", "result": "real"} +{"name": "*", "left": "bit", "right": "decimal", "result": "decimal"} +{"name": "*", "left": "bit", "right": "money", "result": "money"} +{"name": "*", "left": "bit", "right": "smallmoney", "result": "smallmoney"} +{"name": "*", "left": "bit", "right": "bigint", "result": "bigint"} +{"name": "*", "left": "bit", "right": "int", "result": "int"} +{"name": "*", "left": "bit", "right": "smallint", "result": "smallint"} +{"name": "*", "left": "bit", "right": "tinyint", "result": "tinyint"} +{"name": "/", "left": "float", "right": "real", "result": "float"} +{"name": "/", "left": "float", "right": "decimal", "result": "float"} +{"name": "/", "left": "float", "right": "money", "result": "float"} +{"name": "/", "left": "float", "right": "smallmoney", "result": "float"} +{"name": "/", "left": "float", "right": "bigint", "result": "float"} +{"name": "/", "left": "float", "right": "int", "result": "float"} +{"name": "/", "left": "float", "right": "smallint", "result": "float"} +{"name": "/", "left": "float", "right": "tinyint", "result": "float"} +{"name": "/", "left": "float", "right": "bit", "result": "float"} +{"name": "/", "left": "real", "right": "float", "result": "float"} +{"name": "/", "left": "real", "right": "decimal", "result": "real"} +{"name": "/", "left": "real", "right": "money", "result": "real"} +{"name": "/", "left": "real", "right": "smallmoney", "result": "real"} +{"name": "/", "left": "real", "right": "bigint", "result": "real"} +{"name": "/", "left": "real", "right": "int", "result": "real"} +{"name": "/", "left": "real", "right": "smallint", "result": "real"} +{"name": "/", "left": "real", "right": "tinyint", "result": "real"} +{"name": "/", "left": "real", "right": "bit", "result": "real"} +{"name": "/", "left": "decimal", "right": "float", "result": "float"} +{"name": "/", "left": "decimal", "right": "real", "result": "real"} +{"name": "/", "left": "decimal", "right": "money", "result": "decimal"} +{"name": "/", "left": "decimal", "right": "smallmoney", "result": "decimal"} +{"name": "/", "left": "decimal", "right": "bigint", "result": "decimal"} +{"name": "/", "left": "decimal", "right": "int", "result": "decimal"} +{"name": "/", "left": "decimal", "right": "smallint", "result": "decimal"} +{"name": "/", "left": "decimal", "right": "tinyint", "result": "decimal"} +{"name": "/", "left": "decimal", "right": "bit", "result": "decimal"} +{"name": "/", "left": "money", "right": "float", "result": "float"} +{"name": "/", "left": "money", "right": "real", "result": "real"} +{"name": "/", "left": "money", "right": "decimal", "result": "decimal"} +{"name": "/", "left": "money", "right": "smallmoney", "result": "money"} +{"name": "/", "left": "money", "right": "bigint", "result": "money"} +{"name": "/", "left": "money", "right": "int", "result": "money"} +{"name": "/", "left": "money", "right": "smallint", "result": "money"} +{"name": "/", "left": "money", "right": "tinyint", "result": "money"} +{"name": "/", "left": "money", "right": "bit", "result": "money"} +{"name": "/", "left": "smallmoney", "right": "float", "result": "float"} +{"name": "/", "left": "smallmoney", "right": "real", "result": "real"} +{"name": "/", "left": "smallmoney", "right": "decimal", "result": "decimal"} +{"name": "/", "left": "smallmoney", "right": "money", "result": "money"} +{"name": "/", "left": "smallmoney", "right": "bigint", "result": "smallmoney"} +{"name": "/", "left": "smallmoney", "right": "int", "result": "smallmoney"} +{"name": "/", "left": "smallmoney", "right": "smallint", "result": "smallmoney"} +{"name": "/", "left": "smallmoney", "right": "tinyint", "result": "smallmoney"} +{"name": "/", "left": "smallmoney", "right": "bit", "result": "smallmoney"} +{"name": "/", "left": "bigint", "right": "float", "result": "float"} +{"name": "/", "left": "bigint", "right": "real", "result": "real"} +{"name": "/", "left": "bigint", "right": "decimal", "result": "decimal"} +{"name": "/", "left": "bigint", "right": "money", "result": "money"} +{"name": "/", "left": "bigint", "right": "smallmoney", "result": "smallmoney"} +{"name": "/", "left": "bigint", "right": "int", "result": "bigint"} +{"name": "/", "left": "bigint", "right": "smallint", "result": "bigint"} +{"name": "/", "left": "bigint", "right": "tinyint", "result": "bigint"} +{"name": "/", "left": "bigint", "right": "bit", "result": "bigint"} +{"name": "/", "left": "int", "right": "float", "result": "float"} +{"name": "/", "left": "int", "right": "real", "result": "real"} +{"name": "/", "left": "int", "right": "decimal", "result": "decimal"} +{"name": "/", "left": "int", "right": "money", "result": "money"} +{"name": "/", "left": "int", "right": "smallmoney", "result": "smallmoney"} +{"name": "/", "left": "int", "right": "bigint", "result": "bigint"} +{"name": "/", "left": "int", "right": "smallint", "result": "int"} +{"name": "/", "left": "int", "right": "tinyint", "result": "int"} +{"name": "/", "left": "int", "right": "bit", "result": "int"} +{"name": "/", "left": "smallint", "right": "float", "result": "float"} +{"name": "/", "left": "smallint", "right": "real", "result": "real"} +{"name": "/", "left": "smallint", "right": "decimal", "result": "decimal"} +{"name": "/", "left": "smallint", "right": "money", "result": "money"} +{"name": "/", "left": "smallint", "right": "smallmoney", "result": "smallmoney"} +{"name": "/", "left": "smallint", "right": "bigint", "result": "bigint"} +{"name": "/", "left": "smallint", "right": "int", "result": "int"} +{"name": "/", "left": "smallint", "right": "tinyint", "result": "smallint"} +{"name": "/", "left": "smallint", "right": "bit", "result": "smallint"} +{"name": "/", "left": "tinyint", "right": "float", "result": "float"} +{"name": "/", "left": "tinyint", "right": "real", "result": "real"} +{"name": "/", "left": "tinyint", "right": "decimal", "result": "decimal"} +{"name": "/", "left": "tinyint", "right": "money", "result": "money"} +{"name": "/", "left": "tinyint", "right": "smallmoney", "result": "smallmoney"} +{"name": "/", "left": "tinyint", "right": "bigint", "result": "bigint"} +{"name": "/", "left": "tinyint", "right": "int", "result": "int"} +{"name": "/", "left": "tinyint", "right": "smallint", "result": "smallint"} +{"name": "/", "left": "tinyint", "right": "bit", "result": "tinyint"} +{"name": "/", "left": "bit", "right": "float", "result": "float"} +{"name": "/", "left": "bit", "right": "real", "result": "real"} +{"name": "/", "left": "bit", "right": "decimal", "result": "decimal"} +{"name": "/", "left": "bit", "right": "money", "result": "money"} +{"name": "/", "left": "bit", "right": "smallmoney", "result": "smallmoney"} +{"name": "/", "left": "bit", "right": "bigint", "result": "bigint"} +{"name": "/", "left": "bit", "right": "int", "result": "int"} +{"name": "/", "left": "bit", "right": "smallint", "result": "smallint"} +{"name": "/", "left": "bit", "right": "tinyint", "result": "tinyint"} +{"name": "%", "left": "decimal", "right": "money", "result": "decimal"} +{"name": "%", "left": "decimal", "right": "smallmoney", "result": "decimal"} +{"name": "%", "left": "decimal", "right": "bigint", "result": "decimal"} +{"name": "%", "left": "decimal", "right": "int", "result": "decimal"} +{"name": "%", "left": "decimal", "right": "smallint", "result": "decimal"} +{"name": "%", "left": "decimal", "right": "tinyint", "result": "decimal"} +{"name": "%", "left": "decimal", "right": "bit", "result": "decimal"} +{"name": "%", "left": "money", "right": "decimal", "result": "decimal"} +{"name": "%", "left": "money", "right": "smallmoney", "result": "money"} +{"name": "%", "left": "money", "right": "bigint", "result": "money"} +{"name": "%", "left": "money", "right": "int", "result": "money"} +{"name": "%", "left": "money", "right": "smallint", "result": "money"} +{"name": "%", "left": "money", "right": "tinyint", "result": "money"} +{"name": "%", "left": "money", "right": "bit", "result": "money"} +{"name": "%", "left": "smallmoney", "right": "decimal", "result": "decimal"} +{"name": "%", "left": "smallmoney", "right": "money", "result": "money"} +{"name": "%", "left": "smallmoney", "right": "bigint", "result": "smallmoney"} +{"name": "%", "left": "smallmoney", "right": "int", "result": "smallmoney"} +{"name": "%", "left": "smallmoney", "right": "smallint", "result": "smallmoney"} +{"name": "%", "left": "smallmoney", "right": "tinyint", "result": "smallmoney"} +{"name": "%", "left": "smallmoney", "right": "bit", "result": "smallmoney"} +{"name": "%", "left": "bigint", "right": "decimal", "result": "decimal"} +{"name": "%", "left": "bigint", "right": "money", "result": "money"} +{"name": "%", "left": "bigint", "right": "smallmoney", "result": "smallmoney"} +{"name": "%", "left": "bigint", "right": "int", "result": "bigint"} +{"name": "%", "left": "bigint", "right": "smallint", "result": "bigint"} +{"name": "%", "left": "bigint", "right": "tinyint", "result": "bigint"} +{"name": "%", "left": "bigint", "right": "bit", "result": "bigint"} +{"name": "%", "left": "int", "right": "decimal", "result": "decimal"} +{"name": "%", "left": "int", "right": "money", "result": "money"} +{"name": "%", "left": "int", "right": "smallmoney", "result": "smallmoney"} +{"name": "%", "left": "int", "right": "bigint", "result": "bigint"} +{"name": "%", "left": "int", "right": "smallint", "result": "int"} +{"name": "%", "left": "int", "right": "tinyint", "result": "int"} +{"name": "%", "left": "int", "right": "bit", "result": "int"} +{"name": "%", "left": "smallint", "right": "decimal", "result": "decimal"} +{"name": "%", "left": "smallint", "right": "money", "result": "money"} +{"name": "%", "left": "smallint", "right": "smallmoney", "result": "smallmoney"} +{"name": "%", "left": "smallint", "right": "bigint", "result": "bigint"} +{"name": "%", "left": "smallint", "right": "int", "result": "int"} +{"name": "%", "left": "smallint", "right": "tinyint", "result": "smallint"} +{"name": "%", "left": "smallint", "right": "bit", "result": "smallint"} +{"name": "%", "left": "tinyint", "right": "decimal", "result": "decimal"} +{"name": "%", "left": "tinyint", "right": "money", "result": "money"} +{"name": "%", "left": "tinyint", "right": "smallmoney", "result": "smallmoney"} +{"name": "%", "left": "tinyint", "right": "bigint", "result": "bigint"} +{"name": "%", "left": "tinyint", "right": "int", "result": "int"} +{"name": "%", "left": "tinyint", "right": "smallint", "result": "smallint"} +{"name": "%", "left": "tinyint", "right": "bit", "result": "tinyint"} +{"name": "%", "left": "bit", "right": "decimal", "result": "decimal"} +{"name": "%", "left": "bit", "right": "money", "result": "money"} +{"name": "%", "left": "bit", "right": "smallmoney", "result": "smallmoney"} +{"name": "%", "left": "bit", "right": "bigint", "result": "bigint"} +{"name": "%", "left": "bit", "right": "int", "result": "int"} +{"name": "%", "left": "bit", "right": "smallint", "result": "smallint"} +{"name": "%", "left": "bit", "right": "tinyint", "result": "tinyint"} +{"name": "+", "left": "char", "right": "char", "result": "char"} +{"name": "+", "left": "char", "right": "varchar", "result": "varchar"} +{"name": "+", "left": "char", "right": "nchar", "result": "nchar"} +{"name": "+", "left": "char", "right": "nvarchar", "result": "nvarchar"} +{"name": "+", "left": "varchar", "right": "char", "result": "varchar"} +{"name": "+", "left": "varchar", "right": "varchar", "result": "varchar"} +{"name": "+", "left": "varchar", "right": "nchar", "result": "nvarchar"} +{"name": "+", "left": "varchar", "right": "nvarchar", "result": "nvarchar"} +{"name": "+", "left": "nchar", "right": "char", "result": "nchar"} +{"name": "+", "left": "nchar", "right": "varchar", "result": "nvarchar"} +{"name": "+", "left": "nchar", "right": "nchar", "result": "nchar"} +{"name": "+", "left": "nchar", "right": "nvarchar", "result": "nvarchar"} +{"name": "+", "left": "nvarchar", "right": "char", "result": "nvarchar"} +{"name": "+", "left": "nvarchar", "right": "varchar", "result": "nvarchar"} +{"name": "+", "left": "nvarchar", "right": "nchar", "result": "nvarchar"} +{"name": "+", "left": "nvarchar", "right": "nvarchar", "result": "nvarchar"} From 94962e827fb2fa10e9e0b15c9a4148092858db06 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:38:47 -0800 Subject: [PATCH 05/21] fix(mssql): type ISNULL as nullable only when both arguments are 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> --- .../testdata/analyze_isnull/mssql/exec.json | 5 ++ .../testdata/analyze_isnull/mssql/query.sql | 7 ++ .../testdata/analyze_isnull/mssql/schema.sql | 6 ++ .../testdata/analyze_isnull/mssql/stdout.json | 68 +++++++++++++++++++ internal/engine/mssql/convert.go | 11 +++ internal/engine/mssql/dialect/functions.jsonl | 1 - 6 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 internal/endtoend/testdata/analyze_isnull/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_isnull/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_isnull/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_isnull/mssql/stdout.json diff --git a/internal/endtoend/testdata/analyze_isnull/mssql/exec.json b/internal/endtoend/testdata/analyze_isnull/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_isnull/mssql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "mssql", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_isnull/mssql/query.sql b/internal/endtoend/testdata/analyze_isnull/mssql/query.sql new file mode 100644 index 0000000000..561b7bece7 --- /dev/null +++ b/internal/endtoend/testdata/analyze_isnull/mssql/query.sql @@ -0,0 +1,7 @@ +-- name: Defaults :many +SELECT id, + ISNULL(note, raw) AS note_or_raw, + ISNULL(note, note) AS still_nullable, + ISNULL(amount, 0) AS amount +FROM readings +WHERE ISNULL(note, raw) = @value; diff --git a/internal/endtoend/testdata/analyze_isnull/mssql/schema.sql b/internal/endtoend/testdata/analyze_isnull/mssql/schema.sql new file mode 100644 index 0000000000..ac4875448d --- /dev/null +++ b/internal/endtoend/testdata/analyze_isnull/mssql/schema.sql @@ -0,0 +1,6 @@ +CREATE TABLE readings ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + raw VARCHAR(10) NOT NULL, + note VARCHAR(10), + amount DECIMAL(10,2) +); diff --git a/internal/endtoend/testdata/analyze_isnull/mssql/stdout.json b/internal/endtoend/testdata/analyze_isnull/mssql/stdout.json new file mode 100644 index 0000000000..60250d2562 --- /dev/null +++ b/internal/endtoend/testdata/analyze_isnull/mssql/stdout.json @@ -0,0 +1,68 @@ +[ + { + "name": "Defaults", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "readings" + }, + { + "name": "note_or_raw", + "type": { + "name": "varchar", + "args": [ + { + "int": 10 + } + ] + } + }, + { + "name": "still_nullable", + "type": { + "name": "varchar", + "nullable": true, + "args": [ + { + "int": 10 + } + ] + } + }, + { + "name": "amount", + "type": { + "name": "decimal", + "args": [ + { + "int": 10 + }, + { + "int": 2 + } + ] + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "value", + "type": { + "name": "varchar", + "args": [ + { + "int": 10 + } + ] + } + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index 8a0bfee99b..a4c6e4c806 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -547,6 +547,17 @@ func (c *cc) convertScalarExpression(expr tsql.ScalarExpression) ast.Node { case *tsql.ParenthesisExpression: return c.convertScalarExpression(e.Expression) case *tsql.FunctionCall: + // ISNULL(a, b) is typed as COALESCE is: the first argument's type, + // NULL only when both are. + if identifierValue(e.FunctionName) == "isnull" && len(e.Parameters) == 2 && e.OverClause == nil { + return &ast.CoalesceExpr{ + Args: &ast.List{Items: []ast.Node{ + c.convertScalarExpression(e.Parameters[0]), + c.convertScalarExpression(e.Parameters[1]), + }}, + Location: c.loc(e), + } + } return c.convertFunctionCall(e) case *tsql.CastCall: return &ast.TypeCast{ diff --git a/internal/engine/mssql/dialect/functions.jsonl b/internal/engine/mssql/dialect/functions.jsonl index 4173228ca5..586b114678 100644 --- a/internal/engine/mssql/dialect/functions.jsonl +++ b/internal/engine/mssql/dialect/functions.jsonl @@ -96,7 +96,6 @@ {"name": "rtrim", "args": [{"type": "nvarchar"}], "returns": "nvarchar"} {"name": "trim", "args": [{"type": "varchar"}], "returns": "varchar"} {"name": "trim", "args": [{"type": "nvarchar"}], "returns": "nvarchar"} -{"name": "isnull", "args": [{"type": "any"}, {"type": "any"}], "returns": "$1"} {"name": "nullif", "args": [{"type": "any"}, {"type": "any"}], "returns": "$1", "nullable": true} {"name": "choose", "args": [{"type": "int"}, {"type": "any"}], "returns": "$2", "nullable": true} {"name": "greatest", "args": [{"type": "any"}, {"type": "any"}], "returns": "$1", "nullable": true} From f6e56e3db4fdb23322ea509e1e76501e169fbd4c Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:00:13 -0800 Subject: [PATCH 06/21] fix(mssql): type set operation columns from every branch 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> --- internal/core/analyzer/analyzer.go | 6 +- internal/core/analyzer/setop.go | 139 ++++++++++++++++ internal/core/dialect.go | 41 +++++ internal/core/seed/seed.go | 13 ++ .../analyze_set_operations/mssql/exec.json | 5 + .../analyze_set_operations/mssql/query.sql | 22 +++ .../analyze_set_operations/mssql/schema.sql | 16 ++ .../analyze_set_operations/mssql/stdout.json | 149 ++++++++++++++++++ internal/engine/mssql/dialect/dialect.json | 19 +++ 9 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 internal/core/analyzer/setop.go create mode 100644 internal/endtoend/testdata/analyze_set_operations/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_set_operations/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_set_operations/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_set_operations/mssql/stdout.json diff --git a/internal/core/analyzer/analyzer.go b/internal/core/analyzer/analyzer.go index 2bc94d7338..dc039689aa 100644 --- a/internal/core/analyzer/analyzer.go +++ b/internal/core/analyzer/analyzer.go @@ -277,11 +277,15 @@ 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 rules := a.cat.SetOperations(); rules != nil { + a.columns = a.combineColumns(rules, s.Op, left.columns, right.columns) + } return nil } diff --git a/internal/core/analyzer/setop.go b/internal/core/analyzer/setop.go new file mode 100644 index 0000000000..a77b6b2944 --- /dev/null +++ b/internal/core/analyzer/setop.go @@ -0,0 +1,139 @@ +package analyzer + +import ( + "slices" + + "github.com/sqlc-dev/sqlc/internal/core" + "github.com/sqlc-dev/sqlc/internal/sql/ast" +) + +// combineColumns types each column of a set operation from both of its +// branches by the dialect's rules. A column keeps the first branch's name +// and is read from no one table. It may be NULL in a UNION when either +// branch's may, in an INTERSECT when both may, and in an EXCEPT when the +// first branch's may, since every row it returns is one of those. +func (a *analyzer) combineColumns(rules *core.SetOperationRules, op ast.SetOperation, left, right []core.Column) []core.Column { + out := make([]core.Column, len(left)) + for i, l := range left { + if i >= len(right) { + out[i] = l + continue + } + r := right[i] + col := core.Column{Name: l.Name, NotNull: l.NotNull && r.NotNull} + switch op { + case ast.Intersect: + col.NotNull = l.NotNull || r.NotNull + case ast.Except: + col.NotNull = l.NotNull + } + t := a.combineTypes(rules, l, r) + t.nullable = !col.NotNull + col.TypeOID = t.typeOID + col.DataType, col.IsArray = a.typeNameOf(t) + col.Type = a.typeExprOf(t) + out[i] = col + } + return out +} + +// combineTypes is the type a column takes from two branches: the one of +// higher precedence, varying when either branch varies, with the length or +// precision that holds both. +func (a *analyzer) combineTypes(rules *core.SetOperationRules, l, r core.Column) exprType { + lt, rt := a.exprOf(columnExprType(l)), a.exprOf(columnExprType(r)) + switch { + case lt == nil: + return columnExprType(r) + case rt == nil: + return columnExprType(l) + } + li, ri := slices.Index(rules.Precedence, lt.Name), slices.Index(rules.Precedence, rt.Name) + if li < 0 || ri < 0 { + return columnExprType(l) + } + winner, loser := lt, rt + if ri < li { + winner, loser = rt, lt + } + out := winner.WithNullable(false) + if varying, ok := rules.Varying[out.Name]; ok && slices.Contains(mapValues(rules.Varying), loser.Name) { + out.Name = varying + } + switch { + case out.Name == rules.Decimal && rules.Decimal != "": + combineDecimal(rules, out, loser) + case len(out.Args) == 1 && len(loser.Args) == 1 && a.sameCategory(lt.Name, rt.Name): + out.Args[0] = widerArg(out.Args[0], loser.Args[0]) + } + return a.lookupType(out) +} + +func mapValues(m map[string]string) []string { + out := make([]string, 0, len(m)) + for _, v := range m { + out = append(out, v) + } + return out +} + +// widerArg is the larger of two lengths or precisions, MAX above any. +func widerArg(a, b core.TypeArg) core.TypeArg { + switch { + case a.Ident != nil: + return a + case b.Ident != nil: + return b + case a.Int != nil && b.Int != nil && *b.Int > *a.Int: + return b + } + return a +} + +// combineDecimal gives the exact numeric out the precision and scale that +// hold both it and the other branch's type: the larger scale, and the +// larger count of integer digits, with scale giving way past the maximum +// precision. A family with no decimal form leaves out as it is. +func combineDecimal(rules *core.SetOperationRules, out, other *core.TypeExpr) { + p1, s1, ok1 := decimalForm(rules, out) + p2, s2, ok2 := decimalForm(rules, other) + if !ok1 || !ok2 { + return + } + scale := max(s1, s2) + precision := max(p1-s1, p2-s2) + scale + if limit := rules.DecimalMaxPrecision; limit > 0 && precision > limit { + scale = max(scale-(precision-limit), 0) + precision = limit + } + out.Args = []core.TypeArg{{Int: &precision}, {Int: &scale}} +} + +func decimalForm(rules *core.SetOperationRules, t *core.TypeExpr) (int64, int64, bool) { + if t.Name == rules.Decimal && len(t.Args) == 2 && t.Args[0].Int != nil && t.Args[1].Int != nil { + return *t.Args[0].Int, *t.Args[1].Int, true + } + if form, ok := rules.AsDecimal[t.Name]; ok { + return form[0], form[1], true + } + return 0, 0, false +} + +// sameCategory reports whether two types are of one category, so that the +// length of one says something about the other: the 20 of varchar(20) +// widens an nvarchar(10), but not a datetime2(3). +func (a *analyzer) sameCategory(x, y string) bool { + category := func(name string) string { + oid, err := a.cat.TypeOID(name) + if err != nil { + return "" + } + t, err := a.cat.LookupType(a.familyOID(oid)) + if err != nil { + return "" + } + return t.Category + } + cx := category(x) + return cx != "" && cx == category(y) +} diff --git a/internal/core/dialect.go b/internal/core/dialect.go index e192c442c9..8555007d73 100644 --- a/internal/core/dialect.go +++ b/internal/core/dialect.go @@ -2,6 +2,7 @@ package core import ( "context" + "encoding/json" "fmt" "slices" "strings" @@ -77,6 +78,10 @@ const FlagPropagateNullable = "functions.propagate_nullable" // e.id. const FlagQualifyDuplicateColumns = "columns.qualify_duplicates" +// FlagSetOperations holds, as JSON, the SetOperationRules a dialect types +// the columns of a set operation by. +const FlagSetOperations = "set_operations" + // FlagDefaultSchema holds the schema the dialect puts an unqualified object // in, when that is not the catalog's own default: a type in it is reported // without its schema. @@ -196,6 +201,42 @@ func (c *Catalog) QualifiesDuplicateColumns() bool { return v == "true" } +// SetOperationRules say how a dialect types a column of UNION, INTERSECT or +// EXCEPT from its branches, as SQL Server does by data type precedence. A +// dialect without them types the column from the first branch. +type SetOperationRules struct { + // Precedence lists type families, highest first: the column takes the + // highest of its branches'. + Precedence []string `json:"precedence"` + // Varying maps a fixed-length family to its varying one, which the + // column takes when another branch varies: char and varchar make + // varchar. + Varying map[string]string `json:"varying,omitempty"` + // Decimal names the exact numeric family, whose precision and scale + // cover every branch's, up to DecimalMaxPrecision, which scale gives + // way to. AsDecimal is the precision and scale another family counts + // as. + Decimal string `json:"decimal,omitempty"` + DecimalMaxPrecision int64 `json:"decimal_max_precision,omitempty"` + AsDecimal map[string][2]int64 `json:"as_decimal,omitempty"` +} + +// SetOperations returns the dialect's set operation rules, or nil. +func (c *Catalog) SetOperations() *SetOperationRules { + if c.dialectOID == 0 { + return nil + } + v, _ := c.DialectFlag(c.dialectOID, FlagSetOperations) + if v == "" { + return nil + } + var rules SetOperationRules + if err := json.Unmarshal([]byte(v), &rules); err != nil { + return nil + } + return &rules +} + // DefaultNamespaces lists the namespaces a type is reported from without // qualification: the catalog's default, PostgreSQL's system catalog, and // the dialect's own default schema when it names one. diff --git a/internal/core/seed/seed.go b/internal/core/seed/seed.go index 064afedae8..93ba30e10e 100644 --- a/internal/core/seed/seed.go +++ b/internal/core/seed/seed.go @@ -84,6 +84,10 @@ type Settings struct { // ClickHouse names the second id of a join e.id. QualifyDuplicateColumns bool `json:"qualify_duplicate_columns,omitempty"` + // SetOperations types a column of a set operation from every branch, + // rather than from the first. + SetOperations *core.SetOperationRules `json:"set_operations,omitempty"` + // Comparison operators are registered as (T, T) -> Bool for every type in // ComparisonCategories. Comparison []string `json:"comparison,omitempty"` @@ -660,6 +664,15 @@ func (b *builder) consts() error { return err } } + if b.settings.SetOperations != nil { + rules, err := json.Marshal(b.settings.SetOperations) + if err != nil { + return err + } + if err := b.cat.SetDialectFlag(b.dialectOID, core.FlagSetOperations, string(rules)); err != nil { + return err + } + } // A schema declares types the seed knows nothing about — enums, domains, // arrays, a SQLite column typed whatever the author felt like. Recording // the comparison operators lets the catalog give those types the same ones. diff --git a/internal/endtoend/testdata/analyze_set_operations/mssql/exec.json b/internal/endtoend/testdata/analyze_set_operations/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_set_operations/mssql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "mssql", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_set_operations/mssql/query.sql b/internal/endtoend/testdata/analyze_set_operations/mssql/query.sql new file mode 100644 index 0000000000..24ebd88bd9 --- /dev/null +++ b/internal/endtoend/testdata/analyze_set_operations/mssql/query.sql @@ -0,0 +1,22 @@ +-- name: Everyone :many +SELECT id, name, code, score, joined FROM people WHERE name <> @skip +UNION ALL +SELECT id, title, code, budget, founded FROM orgs; + +-- name: Names :many +SELECT name FROM people +UNION +SELECT nickname FROM people +UNION +SELECT NULL; + +-- name: SharedNicknames :many +SELECT nickname, id FROM people +INTERSECT +SELECT title, id FROM orgs; + +-- name: OnlyPeople :many +SELECT id, score FROM people +EXCEPT +SELECT id, budget FROM orgs; + diff --git a/internal/endtoend/testdata/analyze_set_operations/mssql/schema.sql b/internal/endtoend/testdata/analyze_set_operations/mssql/schema.sql new file mode 100644 index 0000000000..a1732f75f5 --- /dev/null +++ b/internal/endtoend/testdata/analyze_set_operations/mssql/schema.sql @@ -0,0 +1,16 @@ +CREATE TABLE people ( + id INT IDENTITY(1,1) PRIMARY KEY, + name VARCHAR(50) NOT NULL, + nickname NVARCHAR(20), + code CHAR(3) NOT NULL, + score DECIMAL(10,2) NOT NULL, + joined DATETIME2(3) NOT NULL +); + +CREATE TABLE orgs ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + title NVARCHAR(100) NOT NULL, + code VARCHAR(5), + budget MONEY NOT NULL, + founded DATE NOT NULL +); diff --git a/internal/endtoend/testdata/analyze_set_operations/mssql/stdout.json b/internal/endtoend/testdata/analyze_set_operations/mssql/stdout.json new file mode 100644 index 0000000000..062e10c474 --- /dev/null +++ b/internal/endtoend/testdata/analyze_set_operations/mssql/stdout.json @@ -0,0 +1,149 @@ +[ + { + "name": "Everyone", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + } + }, + { + "name": "name", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 100 + } + ] + } + }, + { + "name": "code", + "type": { + "name": "varchar", + "nullable": true, + "args": [ + { + "int": 5 + } + ] + } + }, + { + "name": "score", + "type": { + "name": "decimal", + "args": [ + { + "int": 19 + }, + { + "int": 4 + } + ] + } + }, + { + "name": "joined", + "type": { + "name": "datetime2", + "args": [ + { + "int": 3 + } + ] + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "skip", + "type": { + "name": "varchar", + "args": [ + { + "int": 50 + } + ] + }, + "table": "people" + } + } + ] + }, + { + "name": "Names", + "cmd": ":many", + "columns": [ + { + "name": "name", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 50 + } + ] + } + } + ], + "params": [] + }, + { + "name": "SharedNicknames", + "cmd": ":many", + "columns": [ + { + "name": "nickname", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 100 + } + ] + } + }, + { + "name": "id", + "type": { + "name": "bigint" + } + } + ], + "params": [] + }, + { + "name": "OnlyPeople", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + } + }, + { + "name": "score", + "type": { + "name": "decimal", + "args": [ + { + "int": 19 + }, + { + "int": 4 + } + ] + } + } + ], + "params": [] + } +] diff --git a/internal/engine/mssql/dialect/dialect.json b/internal/engine/mssql/dialect/dialect.json index 30ea408744..ab9eccb266 100644 --- a/internal/engine/mssql/dialect/dialect.json +++ b/internal/engine/mssql/dialect/dialect.json @@ -8,6 +8,25 @@ "bool": "bit" }, "bool": "bit", + "set_operations": { + "precedence": [ + "sql_variant", "xml", "datetimeoffset", "datetime2", "datetime", + "smalldatetime", "date", "time", "float", "real", "decimal", "money", + "smallmoney", "bigint", "int", "smallint", "tinyint", "bit", "ntext", + "text", "image", "rowversion", "uniqueidentifier", "nvarchar", "nchar", + "varchar", "char", "varbinary", "binary" + ], + "varying": {"char": "varchar", "nchar": "nvarchar", "binary": "varbinary"}, + "decimal": "decimal", + "decimal_max_precision": 38, + "as_decimal": { + "tinyint": [3, 0], + "smallint": [5, 0], + "bigint": [19, 0], + "money": [19, 4], + "smallmoney": [10, 4] + } + }, "comparison": ["=", "<>", "!=", "<", "<=", ">", ">="], "comparison_categories": "NBSD", "arithmetic": ["+", "-", "*", "/", "%"], From f424edbbfd3c9acac86cbca38554066e34f64f81 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:44:31 -0800 Subject: [PATCH 07/21] fix(mssql): count int and bit as decimals when combining types 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> --- .../analyze_set_operations/mssql/query.sql | 7 +++++++ .../analyze_set_operations/mssql/schema.sql | 6 ++++++ .../analyze_set_operations/mssql/stdout.json | 21 +++++++++++++++++++ internal/engine/mssql/dialect/dialect.json | 2 ++ 4 files changed, 36 insertions(+) diff --git a/internal/endtoend/testdata/analyze_set_operations/mssql/query.sql b/internal/endtoend/testdata/analyze_set_operations/mssql/query.sql index 24ebd88bd9..f52cdad03c 100644 --- a/internal/endtoend/testdata/analyze_set_operations/mssql/query.sql +++ b/internal/endtoend/testdata/analyze_set_operations/mssql/query.sql @@ -20,3 +20,10 @@ SELECT id, score FROM people EXCEPT SELECT id, budget FROM orgs; + +-- name: Amounts :many +SELECT qty AS v FROM ledger +UNION ALL +SELECT amount FROM ledger +UNION ALL +SELECT flag FROM ledger; diff --git a/internal/endtoend/testdata/analyze_set_operations/mssql/schema.sql b/internal/endtoend/testdata/analyze_set_operations/mssql/schema.sql index a1732f75f5..d5863672a6 100644 --- a/internal/endtoend/testdata/analyze_set_operations/mssql/schema.sql +++ b/internal/endtoend/testdata/analyze_set_operations/mssql/schema.sql @@ -14,3 +14,9 @@ CREATE TABLE orgs ( budget MONEY NOT NULL, founded DATE NOT NULL ); + +CREATE TABLE ledger ( + qty INT NOT NULL, + flag BIT NOT NULL, + amount DECIMAL(5,2) NOT NULL +); diff --git a/internal/endtoend/testdata/analyze_set_operations/mssql/stdout.json b/internal/endtoend/testdata/analyze_set_operations/mssql/stdout.json index 062e10c474..c7cd695f18 100644 --- a/internal/endtoend/testdata/analyze_set_operations/mssql/stdout.json +++ b/internal/endtoend/testdata/analyze_set_operations/mssql/stdout.json @@ -145,5 +145,26 @@ } ], "params": [] + }, + { + "name": "Amounts", + "cmd": ":many", + "columns": [ + { + "name": "v", + "type": { + "name": "decimal", + "args": [ + { + "int": 12 + }, + { + "int": 2 + } + ] + } + } + ], + "params": [] } ] diff --git a/internal/engine/mssql/dialect/dialect.json b/internal/engine/mssql/dialect/dialect.json index ab9eccb266..bd05f86922 100644 --- a/internal/engine/mssql/dialect/dialect.json +++ b/internal/engine/mssql/dialect/dialect.json @@ -20,8 +20,10 @@ "decimal": "decimal", "decimal_max_precision": 38, "as_decimal": { + "bit": [1, 0], "tinyint": [3, 0], "smallint": [5, 0], + "int": [10, 0], "bigint": [19, 0], "money": [19, 4], "smallmoney": [10, 4] From 64e9b6a857c9be61918f8e908156640f8e6f3b02 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:49:21 -0800 Subject: [PATCH 08/21] fix(mssql): type CASE, COALESCE, GREATEST, LEAST and CHOOSE by precedence 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> --- internal/core/analyzer/expr.go | 50 ++++--- internal/core/analyzer/setop.go | 38 +++++- .../analyze_combined_types/mssql/exec.json | 5 + .../analyze_combined_types/mssql/query.sql | 10 ++ .../analyze_combined_types/mssql/schema.sql | 1 + .../analyze_combined_types/mssql/stdout.json | 122 ++++++++++++++++++ internal/engine/mssql/convert.go | 47 +++++-- internal/engine/mssql/dialect/functions.jsonl | 3 - internal/sql/ast/coalesce_expr.go | 3 + 9 files changed, 241 insertions(+), 38 deletions(-) create mode 100644 internal/endtoend/testdata/analyze_combined_types/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_combined_types/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_combined_types/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_combined_types/mssql/stdout.json diff --git a/internal/core/analyzer/expr.go b/internal/core/analyzer/expr.go index 62a69b273d..a41f1d8272 100644 --- a/internal/core/analyzer/expr.go +++ b/internal/core/analyzer/expr.go @@ -462,6 +462,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 @@ -481,45 +488,48 @@ func (a *analyzer) typeCase(e *ast.CaseExpr) (exprType, error) { 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, which is null only when every argument is, +// and of the type its arguments combine to, or its first typed argument's +// for SQL Server's ISNULL. 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 + t := a.commonType(types) + if e.FirstArgType { + for _, first := range types { + if first.typeOID != 0 || first.expr != nil { + t = first + break + } + } + } + // 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. +// 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(nodes []ast.Node, nullable bool) (exprType, error) { - var out exprType - found := false + 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 := a.commonType(types) out.nullable = nullable return out, nil } diff --git a/internal/core/analyzer/setop.go b/internal/core/analyzer/setop.go index a77b6b2944..04e8060414 100644 --- a/internal/core/analyzer/setop.go +++ b/internal/core/analyzer/setop.go @@ -27,7 +27,7 @@ func (a *analyzer) combineColumns(rules *core.SetOperationRules, op ast.SetOpera case ast.Except: col.NotNull = l.NotNull } - t := a.combineTypes(rules, l, r) + t := a.combineTypes(rules, columnExprType(l), columnExprType(r)) t.nullable = !col.NotNull col.TypeOID = t.typeOID col.DataType, col.IsArray = a.typeNameOf(t) @@ -40,17 +40,17 @@ func (a *analyzer) combineColumns(rules *core.SetOperationRules, op ast.SetOpera // combineTypes is the type a column takes from two branches: the one of // higher precedence, varying when either branch varies, with the length or // precision that holds both. -func (a *analyzer) combineTypes(rules *core.SetOperationRules, l, r core.Column) exprType { - lt, rt := a.exprOf(columnExprType(l)), a.exprOf(columnExprType(r)) +func (a *analyzer) combineTypes(rules *core.SetOperationRules, l, r exprType) exprType { + lt, rt := a.exprOf(l), a.exprOf(r) switch { case lt == nil: - return columnExprType(r) + return r case rt == nil: - return columnExprType(l) + return l } li, ri := slices.Index(rules.Precedence, lt.Name), slices.Index(rules.Precedence, rt.Name) if li < 0 || ri < 0 { - return columnExprType(l) + return l } winner, loser := lt, rt if ri < li { @@ -69,6 +69,32 @@ func (a *analyzer) combineTypes(rules *core.SetOperationRules, l, r core.Column) return a.lookupType(out) } +// commonType is the type a dialect gives an expression that yields one of +// several values, as CASE, COALESCE and GREATEST do, by the rules it +// combines a set operation's branches by; a dialect without them takes the +// first typed value's. The result is no one column's. +func (a *analyzer) commonType(types []exprType) exprType { + var out exprType + rules := a.cat.SetOperations() + for i, t := range types { + if t.typeOID == 0 && t.expr == nil { + continue + } + switch { + case out.typeOID == 0 && out.expr == nil: + out = t + case rules == nil: + return out + default: + out = a.combineTypes(rules, out, t) + } + if i > 0 { + out.sourceClassOID, out.sourceAttributeOID, out.sourceTableAlias = 0, 0, "" + } + } + return out +} + func mapValues(m map[string]string) []string { out := make([]string, 0, len(m)) for _, v := range m { diff --git a/internal/endtoend/testdata/analyze_combined_types/mssql/exec.json b/internal/endtoend/testdata/analyze_combined_types/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_combined_types/mssql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "mssql", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_combined_types/mssql/query.sql b/internal/endtoend/testdata/analyze_combined_types/mssql/query.sql new file mode 100644 index 0000000000..09290a7263 --- /dev/null +++ b/internal/endtoend/testdata/analyze_combined_types/mssql/query.sql @@ -0,0 +1,10 @@ +-- name: Combined :many +SELECT GREATEST(i, d52) AS greatest_of, + LEAST(d52, i, si) AS least_of, + CHOOSE(@pick, i, d102) AS chosen, + CASE WHEN i > 0 THEN si ELSE d102 END AS case_of, + IIF(i > 0, v20, n10) AS iif_of, + COALESCE(m, d102) AS coalesce_of, + ISNULL(i, d52) AS isnull_int, + ISNULL(n10, c3) AS isnull_text +FROM t; diff --git a/internal/endtoend/testdata/analyze_combined_types/mssql/schema.sql b/internal/endtoend/testdata/analyze_combined_types/mssql/schema.sql new file mode 100644 index 0000000000..df06bb301b --- /dev/null +++ b/internal/endtoend/testdata/analyze_combined_types/mssql/schema.sql @@ -0,0 +1 @@ +CREATE TABLE t (i INT NOT NULL, d52 DECIMAL(5,2) NOT NULL, d102 DECIMAL(10,2), v20 VARCHAR(20) NOT NULL, n10 NVARCHAR(10), c3 CHAR(3), si SMALLINT NOT NULL, m MONEY); diff --git a/internal/endtoend/testdata/analyze_combined_types/mssql/stdout.json b/internal/endtoend/testdata/analyze_combined_types/mssql/stdout.json new file mode 100644 index 0000000000..36ac64c6cf --- /dev/null +++ b/internal/endtoend/testdata/analyze_combined_types/mssql/stdout.json @@ -0,0 +1,122 @@ +[ + { + "name": "Combined", + "cmd": ":many", + "columns": [ + { + "name": "greatest_of", + "type": { + "name": "decimal", + "args": [ + { + "int": 12 + }, + { + "int": 2 + } + ] + } + }, + { + "name": "least_of", + "type": { + "name": "decimal", + "args": [ + { + "int": 12 + }, + { + "int": 2 + } + ] + } + }, + { + "name": "chosen", + "type": { + "name": "decimal", + "nullable": true, + "args": [ + { + "int": 12 + }, + { + "int": 2 + } + ] + } + }, + { + "name": "case_of", + "type": { + "name": "decimal", + "nullable": true, + "args": [ + { + "int": 10 + }, + { + "int": 2 + } + ] + } + }, + { + "name": "iif_of", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 20 + } + ] + } + }, + { + "name": "coalesce_of", + "type": { + "name": "decimal", + "nullable": true, + "args": [ + { + "int": 19 + }, + { + "int": 4 + } + ] + } + }, + { + "name": "isnull_int", + "type": { + "name": "int" + } + }, + { + "name": "isnull_text", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 10 + } + ] + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "pick", + "type": { + "name": "int" + } + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index a4c6e4c806..c10c64c656 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -547,15 +547,9 @@ func (c *cc) convertScalarExpression(expr tsql.ScalarExpression) ast.Node { case *tsql.ParenthesisExpression: return c.convertScalarExpression(e.Expression) case *tsql.FunctionCall: - // ISNULL(a, b) is typed as COALESCE is: the first argument's type, - // NULL only when both are. - if identifierValue(e.FunctionName) == "isnull" && len(e.Parameters) == 2 && e.OverClause == nil { - return &ast.CoalesceExpr{ - Args: &ast.List{Items: []ast.Node{ - c.convertScalarExpression(e.Parameters[0]), - c.convertScalarExpression(e.Parameters[1]), - }}, - Location: c.loc(e), + if e.OverClause == nil { + if out := c.convertAlternatives(e); out != nil { + return out } } return c.convertFunctionCall(e) @@ -682,6 +676,41 @@ func (c *cc) convertScalarExpression(expr tsql.ScalarExpression) ast.Node { } } +// convertAlternatives converts the functions that yield one of their +// arguments, which SQL Server types as the type their candidates combine +// to, as it does CASE: GREATEST and LEAST, and CHOOSE, which is the CASE +// over its index it is defined as. ISNULL, NULL only when both arguments +// are, as COALESCE is, keeps its first argument's type. +func (c *cc) convertAlternatives(e *tsql.FunctionCall) ast.Node { + args := &ast.List{} + for _, p := range e.Parameters { + args.Items = append(args.Items, c.convertScalarExpression(p)) + } + switch identifierValue(e.FunctionName) { + case "isnull": + if len(args.Items) == 2 { + return &ast.CoalesceExpr{Args: args, FirstArgType: true, Location: c.loc(e)} + } + case "greatest": + return &ast.MinMaxExpr{Op: 0, Args: args, Location: c.loc(e)} + case "least": + return &ast.MinMaxExpr{Op: 1, Args: args, Location: c.loc(e)} + case "choose": + if len(args.Items) < 2 { + return nil + } + choose := &ast.CaseExpr{Arg: args.Items[0], Args: &ast.List{}, Location: c.loc(e)} + for i, item := range args.Items[1:] { + choose.Args.Items = append(choose.Args.Items, &ast.CaseWhen{ + Expr: &ast.A_Const{Val: &ast.Integer{Ival: int64(i + 1)}}, + Result: item, + }) + } + return choose + } + return nil +} + func binaryOperator(binaryExpressionType string) string { switch binaryExpressionType { case "Add": diff --git a/internal/engine/mssql/dialect/functions.jsonl b/internal/engine/mssql/dialect/functions.jsonl index 586b114678..b5effbe525 100644 --- a/internal/engine/mssql/dialect/functions.jsonl +++ b/internal/engine/mssql/dialect/functions.jsonl @@ -97,9 +97,6 @@ {"name": "trim", "args": [{"type": "varchar"}], "returns": "varchar"} {"name": "trim", "args": [{"type": "nvarchar"}], "returns": "nvarchar"} {"name": "nullif", "args": [{"type": "any"}, {"type": "any"}], "returns": "$1", "nullable": true} -{"name": "choose", "args": [{"type": "int"}, {"type": "any"}], "returns": "$2", "nullable": true} -{"name": "greatest", "args": [{"type": "any"}, {"type": "any"}], "returns": "$1", "nullable": true} -{"name": "least", "args": [{"type": "any"}, {"type": "any"}], "returns": "$1", "nullable": true} {"name": "concat", "args": [{"type": "any"}, {"type": "any"}], "returns": "varchar"} {"name": "concat", "args": [{"type": "nvarchar"}, {"type": "any"}], "returns": "nvarchar"} {"name": "concat", "args": [{"type": "any"}, {"type": "nvarchar"}], "returns": "nvarchar"} diff --git a/internal/sql/ast/coalesce_expr.go b/internal/sql/ast/coalesce_expr.go index f51c2c5c8e..36c1defa3c 100644 --- a/internal/sql/ast/coalesce_expr.go +++ b/internal/sql/ast/coalesce_expr.go @@ -10,6 +10,9 @@ type CoalesceExpr struct { Coalescecollid Oid `json:"coalescecollid"` Args *List `json:"args,omitempty"` Location int `json:"location"` + // FirstArgType marks SQL Server's ISNULL, which takes its first + // argument's type rather than the type its arguments combine to. + FirstArgType bool `json:"first_arg_type,omitempty"` } func (n *CoalesceExpr) Pos() int { From 25b6753383a7a991e99a2a4d0fb0db3557864dcd Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:50:44 -0800 Subject: [PATCH 09/21] fix(mssql): seed ROUND with its function argument 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> --- .../testdata/analyze_round/mssql/exec.json | 5 ++ .../testdata/analyze_round/mssql/query.sql | 7 +++ .../testdata/analyze_round/mssql/schema.sql | 6 ++ .../testdata/analyze_round/mssql/stdout.json | 60 +++++++++++++++++++ internal/engine/mssql/dialect/functions.jsonl | 9 +++ 5 files changed, 87 insertions(+) create mode 100644 internal/endtoend/testdata/analyze_round/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_round/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_round/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_round/mssql/stdout.json diff --git a/internal/endtoend/testdata/analyze_round/mssql/exec.json b/internal/endtoend/testdata/analyze_round/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_round/mssql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "mssql", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_round/mssql/query.sql b/internal/endtoend/testdata/analyze_round/mssql/query.sql new file mode 100644 index 0000000000..9c348e718f --- /dev/null +++ b/internal/endtoend/testdata/analyze_round/mssql/query.sql @@ -0,0 +1,7 @@ +-- name: Rounded :many +SELECT ROUND(price, 1) AS rounded, + ROUND(price, 1, 1) AS truncated, + ROUND(ratio, 2, 1) AS ratio_truncated, + ROUND(qty, -1, 1) AS qty_truncated, + ROUND(fee, 2, 1) AS fee_truncated +FROM prices; diff --git a/internal/endtoend/testdata/analyze_round/mssql/schema.sql b/internal/endtoend/testdata/analyze_round/mssql/schema.sql new file mode 100644 index 0000000000..f759dbea38 --- /dev/null +++ b/internal/endtoend/testdata/analyze_round/mssql/schema.sql @@ -0,0 +1,6 @@ +CREATE TABLE prices ( + price DECIMAL(10,2), + ratio FLOAT, + qty INT, + fee MONEY +); diff --git a/internal/endtoend/testdata/analyze_round/mssql/stdout.json b/internal/endtoend/testdata/analyze_round/mssql/stdout.json new file mode 100644 index 0000000000..871c0961ce --- /dev/null +++ b/internal/endtoend/testdata/analyze_round/mssql/stdout.json @@ -0,0 +1,60 @@ +[ + { + "name": "Rounded", + "cmd": ":many", + "columns": [ + { + "name": "rounded", + "type": { + "name": "decimal", + "nullable": true, + "args": [ + { + "int": 10 + }, + { + "int": 2 + } + ] + } + }, + { + "name": "truncated", + "type": { + "name": "decimal", + "nullable": true, + "args": [ + { + "int": 10 + }, + { + "int": 2 + } + ] + } + }, + { + "name": "ratio_truncated", + "type": { + "name": "float", + "nullable": true + } + }, + { + "name": "qty_truncated", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "fee_truncated", + "type": { + "name": "money", + "nullable": true + } + } + ], + "params": [] + } +] diff --git a/internal/engine/mssql/dialect/functions.jsonl b/internal/engine/mssql/dialect/functions.jsonl index b5effbe525..9975119abb 100644 --- a/internal/engine/mssql/dialect/functions.jsonl +++ b/internal/engine/mssql/dialect/functions.jsonl @@ -194,6 +194,15 @@ {"name": "round", "args": [{"type": "smallmoney"}, {"type": "int"}], "returns": "money", "nullable": true} {"name": "round", "args": [{"type": "float"}, {"type": "int"}], "returns": "float", "nullable": true} {"name": "round", "args": [{"type": "real"}, {"type": "int"}], "returns": "float", "nullable": true} +{"name": "round", "args": [{"type": "bigint"}, {"type": "int"}, {"type": "int"}], "returns": "bigint", "nullable": true} +{"name": "round", "args": [{"type": "int"}, {"type": "int"}, {"type": "int"}], "returns": "int", "nullable": true} +{"name": "round", "args": [{"type": "smallint"}, {"type": "int"}, {"type": "int"}], "returns": "int", "nullable": true} +{"name": "round", "args": [{"type": "tinyint"}, {"type": "int"}, {"type": "int"}], "returns": "int", "nullable": true} +{"name": "round", "args": [{"type": "decimal"}, {"type": "int"}, {"type": "int"}], "returns": "$1", "nullable": true} +{"name": "round", "args": [{"type": "money"}, {"type": "int"}, {"type": "int"}], "returns": "money", "nullable": true} +{"name": "round", "args": [{"type": "smallmoney"}, {"type": "int"}, {"type": "int"}], "returns": "money", "nullable": true} +{"name": "round", "args": [{"type": "float"}, {"type": "int"}, {"type": "int"}], "returns": "float", "nullable": true} +{"name": "round", "args": [{"type": "real"}, {"type": "int"}, {"type": "int"}], "returns": "float", "nullable": true} {"name": "power", "args": [{"type": "bigint"}, {"type": "any"}], "returns": "bigint", "nullable": true} {"name": "power", "args": [{"type": "int"}, {"type": "any"}], "returns": "int", "nullable": true} {"name": "power", "args": [{"type": "smallint"}, {"type": "any"}], "returns": "int", "nullable": true} From f66c55192d88191a48a71b5d6619c6cd68663db4 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:52:00 -0800 Subject: [PATCH 10/21] fix(mssql): type DATEADD and DATETRUNC over a string date 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> --- .../testdata/analyze_datepart/mssql/query.sql | 5 +++ .../analyze_datepart/mssql/stdout.json | 33 +++++++++++++++++++ internal/engine/mssql/dialect/functions.jsonl | 4 +++ 3 files changed, 42 insertions(+) diff --git a/internal/endtoend/testdata/analyze_datepart/mssql/query.sql b/internal/endtoend/testdata/analyze_datepart/mssql/query.sql index 0c79e36fa1..f1ae69121d 100644 --- a/internal/endtoend/testdata/analyze_datepart/mssql/query.sql +++ b/internal/endtoend/testdata/analyze_datepart/mssql/query.sql @@ -6,3 +6,8 @@ SELECT id, DATENAME(weekday, created) AS weekday_of FROM authors WHERE created > DATEADD(day, @days, SYSUTCDATETIME()); + +-- name: FromStrings :one +SELECT DATEADD(day, 1, '2026-01-01') AS next_day, + DATEADD(day, 1, N'2026-01-01') AS next_day_n, + DATETRUNC(month, '2026-01-15') AS month_start; diff --git a/internal/endtoend/testdata/analyze_datepart/mssql/stdout.json b/internal/endtoend/testdata/analyze_datepart/mssql/stdout.json index aa394695d9..6e1436e784 100644 --- a/internal/endtoend/testdata/analyze_datepart/mssql/stdout.json +++ b/internal/endtoend/testdata/analyze_datepart/mssql/stdout.json @@ -60,5 +60,38 @@ } } ] + }, + { + "name": "FromStrings", + "cmd": ":one", + "columns": [ + { + "name": "next_day", + "type": { + "name": "datetime", + "nullable": true + } + }, + { + "name": "next_day_n", + "type": { + "name": "datetime", + "nullable": true + } + }, + { + "name": "month_start", + "type": { + "name": "datetime2", + "nullable": true, + "args": [ + { + "int": 7 + } + ] + } + } + ], + "params": [] } ] diff --git a/internal/engine/mssql/dialect/functions.jsonl b/internal/engine/mssql/dialect/functions.jsonl index 9975119abb..614d680e7a 100644 --- a/internal/engine/mssql/dialect/functions.jsonl +++ b/internal/engine/mssql/dialect/functions.jsonl @@ -235,11 +235,15 @@ {"name": "switchoffset", "args": [{"type": "datetimeoffset"}, {"type": "any"}], "returns": "$1", "nullable": true} {"name": "todatetimeoffset", "args": [{"type": "any"}, {"type": "any"}], "returns": "datetimeoffset", "nullable": true} {"name": "dateadd", "args": [{"type": "varchar"}, {"type": "int"}, {"type": "any"}], "returns": "$3", "nullable": true} +{"name": "dateadd", "args": [{"type": "varchar"}, {"type": "int"}, {"type": "varchar"}], "returns": "datetime", "nullable": true} +{"name": "dateadd", "args": [{"type": "varchar"}, {"type": "int"}, {"type": "nvarchar"}], "returns": "datetime", "nullable": true} {"name": "datediff", "args": [{"type": "varchar"}, {"type": "any"}, {"type": "any"}], "returns": "int", "nullable": true} {"name": "datediff_big", "args": [{"type": "varchar"}, {"type": "any"}, {"type": "any"}], "returns": "bigint", "nullable": true} {"name": "datepart", "args": [{"type": "varchar"}, {"type": "any"}], "returns": "int", "nullable": true} {"name": "datename", "args": [{"type": "varchar"}, {"type": "any"}], "returns": "nvarchar(30)", "nullable": true} {"name": "datetrunc", "args": [{"type": "varchar"}, {"type": "any"}], "returns": "$2", "nullable": true} +{"name": "datetrunc", "args": [{"type": "varchar"}, {"type": "varchar"}], "returns": "datetime2(7)", "nullable": true} +{"name": "datetrunc", "args": [{"type": "varchar"}, {"type": "nvarchar"}], "returns": "datetime2(7)", "nullable": true} {"name": "isjson", "args": [{"type": "any"}], "returns": "int", "nullable": true} {"name": "isjson", "args": [{"type": "any"}, {"type": "varchar"}], "returns": "int", "nullable": true} {"name": "json_value", "args": [{"type": "any"}, {"type": "nvarchar"}], "returns": "nvarchar(4000)", "nullable": true} From a0842fc65804ba3f8aa66e4eb60257f927a0967b Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:23:31 -0800 Subject: [PATCH 11/21] feat(mssql): type the methods of xml, geography, geometry and hierarchyid 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> --- .../analyze_type_methods/mssql/exec.json | 5 + .../analyze_type_methods/mssql/query.sql | 10 + .../analyze_type_methods/mssql/schema.sql | 1 + .../analyze_type_methods/mssql/stdout.json | 196 ++++++++++++++++++ internal/engine/mssql/convert.go | 65 ++++++ internal/engine/mssql/dialect/functions.jsonl | 50 +++++ 6 files changed, 327 insertions(+) create mode 100644 internal/endtoend/testdata/analyze_type_methods/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_type_methods/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_type_methods/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_type_methods/mssql/stdout.json diff --git a/internal/endtoend/testdata/analyze_type_methods/mssql/exec.json b/internal/endtoend/testdata/analyze_type_methods/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_type_methods/mssql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "mssql", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_type_methods/mssql/query.sql b/internal/endtoend/testdata/analyze_type_methods/mssql/query.sql new file mode 100644 index 0000000000..0dd53b3f26 --- /dev/null +++ b/internal/endtoend/testdata/analyze_type_methods/mssql/query.sql @@ -0,0 +1,10 @@ +-- name: Methods :one +SELECT doc.value('(/a/@n)[1]', 'int') AS v, doc.value('(/a)[1]', 'nvarchar(20)') AS s, + doc.exist('/a') AS e, geo.STDistance(geo) AS dist, + geo.STAsText() AS txt, shape.ToString() AS shape_text, node.ToString() AS path, + node.GetLevel() AS lvl, node.GetAncestor(@up) AS anc, + hierarchyid::GetRoot() AS root, geography::Point(@lat, @long, 4326) AS pt +FROM m WHERE id = @id; + +-- name: Nearby :many +SELECT id FROM m WHERE geo.STDistance(geography::Point(@lat, @long, 4326)) < @meters AND node.IsDescendantOf(@ancestor) = 1; diff --git a/internal/endtoend/testdata/analyze_type_methods/mssql/schema.sql b/internal/endtoend/testdata/analyze_type_methods/mssql/schema.sql new file mode 100644 index 0000000000..88b8cb4b7e --- /dev/null +++ b/internal/endtoend/testdata/analyze_type_methods/mssql/schema.sql @@ -0,0 +1 @@ +CREATE TABLE m (id INT NOT NULL, doc XML NOT NULL, geo GEOGRAPHY NOT NULL, shape GEOMETRY NULL, node HIERARCHYID NOT NULL); diff --git a/internal/endtoend/testdata/analyze_type_methods/mssql/stdout.json b/internal/endtoend/testdata/analyze_type_methods/mssql/stdout.json new file mode 100644 index 0000000000..c587ba6fbb --- /dev/null +++ b/internal/endtoend/testdata/analyze_type_methods/mssql/stdout.json @@ -0,0 +1,196 @@ +[ + { + "name": "Methods", + "cmd": ":one", + "columns": [ + { + "name": "v", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "s", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 20 + } + ] + } + }, + { + "name": "e", + "type": { + "name": "bit", + "nullable": true + } + }, + { + "name": "dist", + "type": { + "name": "float", + "nullable": true + } + }, + { + "name": "txt", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "ident": "max" + } + ] + } + }, + { + "name": "shape_text", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "ident": "max" + } + ] + } + }, + { + "name": "path", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 4000 + } + ] + } + }, + { + "name": "lvl", + "type": { + "name": "smallint", + "nullable": true + } + }, + { + "name": "anc", + "type": { + "name": "hierarchyid", + "nullable": true + } + }, + { + "name": "root", + "type": { + "name": "hierarchyid", + "nullable": true + } + }, + { + "name": "pt", + "type": { + "name": "geography", + "nullable": true + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "up", + "type": { + "name": "int" + } + } + }, + { + "number": 2, + "column": { + "name": "lat", + "type": { + "name": "float" + } + } + }, + { + "number": 3, + "column": { + "name": "long", + "type": { + "name": "float" + } + } + }, + { + "number": 4, + "column": { + "name": "id", + "type": { + "name": "int" + }, + "table": "m" + } + } + ] + }, + { + "name": "Nearby", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "int" + }, + "table": "m" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "lat", + "type": { + "name": "float" + } + } + }, + { + "number": 2, + "column": { + "name": "long", + "type": { + "name": "float" + } + } + }, + { + "number": 3, + "column": { + "name": "meters", + "type": { + "name": "float", + "nullable": true + } + } + }, + { + "number": 4, + "column": { + "name": "ancestor", + "type": { + "name": "hierarchyid" + } + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index c10c64c656..df7f1a4a99 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -547,6 +547,9 @@ func (c *cc) convertScalarExpression(expr tsql.ScalarExpression) ast.Node { case *tsql.ParenthesisExpression: return c.convertScalarExpression(e.Expression) case *tsql.FunctionCall: + if out := c.convertMethodCall(e); out != nil { + return out + } if e.OverClause == nil { if out := c.convertAlternatives(e); out != nil { return out @@ -780,6 +783,68 @@ type namedParam struct { name string } +// typeMethods are the methods of xml, geography, geometry and hierarchyid +// a query calls on a value, as doc.exist('/a') or node.GetLevel() do. A +// call to one is seeded as ".name" taking the value first, which tells it +// from a function in a schema, as dbo.f(), the parser sees alike. +var typeMethods = map[string]bool{ + "exist": true, + "getlevel": true, "tostring": true, "getancestor": true, "getdescendant": true, + "isdescendantof": true, "getreparentedvalue": true, + "stastext": true, "stasbinary": true, "starea": true, "stlength": true, + "stdistance": true, "stintersects": true, "stcontains": true, "stwithin": true, + "stequals": true, "stbuffer": true, "stunion": true, "stintersection": true, + "stnumpoints": true, "stgeometrytype": true, "stdimension": true, + "stisvalid": true, "stpointn": true, +} + +// convertMethodCall converts a call of a method on a value, or of a static +// one on a type, as geography::Point(...) is, seeded as "geography::point". +// xml's value(path, 'type') returns the type it names, or NULL when the +// path finds nothing. +func (c *cc) convertMethodCall(n *tsql.FunctionCall) ast.Node { + method := identifierValue(n.FunctionName) + var receiver ast.Node + switch t := n.CallTarget.(type) { + case *tsql.UserDefinedTypeCallTarget: + if t.SchemaObjectName == nil || t.SchemaObjectName.BaseIdentifier == nil { + return nil + } + fc := c.convertFunctionCall(n) + fc.Funcname = &ast.List{Items: []ast.Node{&ast.String{Str: identifierValue(t.SchemaObjectName.BaseIdentifier) + "::" + method}}} + return fc + case *tsql.MultiPartIdentifierCallTarget: + if t.MultiPartIdentifier == nil { + return nil + } + receiver = c.convertColumnReference(&tsql.ColumnReferenceExpression{MultiPartIdentifier: t.MultiPartIdentifier}) + case *tsql.ExpressionCallTarget: + receiver = c.convertScalarExpression(t.Expression) + default: + return nil + } + if method == "value" && len(n.Parameters) == 2 { + if lit, ok := n.Parameters[1].(*tsql.StringLiteral); ok { + return &ast.TypeCast{ + Arg: receiver, + TypeName: &ast.TypeName{Spelling: "Nullable(" + strings.ToLower(lit.Value) + ")"}, + Location: c.loc(n), + } + } + } + if !typeMethods[method] { + return nil + } + fc := c.convertFunctionCall(n) + fc.Funcname = &ast.List{Items: []ast.Node{&ast.String{Str: "." + method}}} + args := []ast.Node{receiver} + if fc.Args != nil { + args = append(args, fc.Args.Items...) + } + fc.Args = &ast.List{Items: args} + return fc +} + func (c *cc) convertFunctionCall(n *tsql.FunctionCall) *ast.FuncCall { fc := &ast.FuncCall{ Funcname: &ast.List{ diff --git a/internal/engine/mssql/dialect/functions.jsonl b/internal/engine/mssql/dialect/functions.jsonl index 614d680e7a..ec800de080 100644 --- a/internal/engine/mssql/dialect/functions.jsonl +++ b/internal/engine/mssql/dialect/functions.jsonl @@ -259,3 +259,53 @@ {"name": "at time zone", "args": [{"type": "datetime2"}, {"type": "nvarchar(4000)"}], "returns": "datetimeoffset(7)", "nullable": true} {"name": "at time zone", "args": [{"type": "datetime"}, {"type": "nvarchar(4000)"}], "returns": "datetimeoffset(3)", "nullable": true} {"name": "at time zone", "args": [{"type": "smalldatetime"}, {"type": "nvarchar(4000)"}], "returns": "datetimeoffset(0)", "nullable": true} +{"name": ".exist", "args": [{"type": "xml"}, {"type": "nvarchar(4000)"}], "returns": "bit", "nullable": true} +{"name": ".getlevel", "args": [{"type": "hierarchyid"}], "returns": "smallint", "nullable": true} +{"name": ".tostring", "args": [{"type": "hierarchyid"}], "returns": "nvarchar(4000)", "nullable": true} +{"name": ".getancestor", "args": [{"type": "hierarchyid"}, {"type": "int"}], "returns": "hierarchyid", "nullable": true} +{"name": ".getdescendant", "args": [{"type": "hierarchyid"}, {"type": "hierarchyid"}, {"type": "hierarchyid"}], "returns": "hierarchyid", "nullable": true} +{"name": ".isdescendantof", "args": [{"type": "hierarchyid"}, {"type": "hierarchyid"}], "returns": "bit", "nullable": true} +{"name": ".getreparentedvalue", "args": [{"type": "hierarchyid"}, {"type": "hierarchyid"}, {"type": "hierarchyid"}], "returns": "hierarchyid", "nullable": true} +{"name": "hierarchyid::getroot", "args": [], "returns": "hierarchyid", "nullable": true} +{"name": "hierarchyid::parse", "args": [{"type": "nvarchar(4000)"}], "returns": "hierarchyid", "nullable": true} +{"name": ".tostring", "args": [{"type": "geography"}], "returns": "nvarchar(max)", "nullable": true} +{"name": ".stastext", "args": [{"type": "geography"}], "returns": "nvarchar(max)", "nullable": true} +{"name": ".stasbinary", "args": [{"type": "geography"}], "returns": "varbinary(max)", "nullable": true} +{"name": ".starea", "args": [{"type": "geography"}], "returns": "float", "nullable": true} +{"name": ".stlength", "args": [{"type": "geography"}], "returns": "float", "nullable": true} +{"name": ".stdistance", "args": [{"type": "geography"}, {"type": "geography"}], "returns": "float", "nullable": true} +{"name": ".stintersects", "args": [{"type": "geography"}, {"type": "geography"}], "returns": "bit", "nullable": true} +{"name": ".stcontains", "args": [{"type": "geography"}, {"type": "geography"}], "returns": "bit", "nullable": true} +{"name": ".stwithin", "args": [{"type": "geography"}, {"type": "geography"}], "returns": "bit", "nullable": true} +{"name": ".stequals", "args": [{"type": "geography"}, {"type": "geography"}], "returns": "bit", "nullable": true} +{"name": ".stisvalid", "args": [{"type": "geography"}], "returns": "bit", "nullable": true} +{"name": ".stbuffer", "args": [{"type": "geography"}, {"type": "float"}], "returns": "geography", "nullable": true} +{"name": ".stunion", "args": [{"type": "geography"}, {"type": "geography"}], "returns": "geography", "nullable": true} +{"name": ".stintersection", "args": [{"type": "geography"}, {"type": "geography"}], "returns": "geography", "nullable": true} +{"name": ".stnumpoints", "args": [{"type": "geography"}], "returns": "int", "nullable": true} +{"name": ".stdimension", "args": [{"type": "geography"}], "returns": "int", "nullable": true} +{"name": ".stgeometrytype", "args": [{"type": "geography"}], "returns": "nvarchar(4000)", "nullable": true} +{"name": ".stpointn", "args": [{"type": "geography"}, {"type": "int"}], "returns": "geography", "nullable": true} +{"name": "geography::stgeomfromtext", "args": [{"type": "nvarchar(max)"}, {"type": "int"}], "returns": "geography", "nullable": true} +{"name": "geography::stpointfromtext", "args": [{"type": "nvarchar(max)"}, {"type": "int"}], "returns": "geography", "nullable": true} +{"name": ".tostring", "args": [{"type": "geometry"}], "returns": "nvarchar(max)", "nullable": true} +{"name": ".stastext", "args": [{"type": "geometry"}], "returns": "nvarchar(max)", "nullable": true} +{"name": ".stasbinary", "args": [{"type": "geometry"}], "returns": "varbinary(max)", "nullable": true} +{"name": ".starea", "args": [{"type": "geometry"}], "returns": "float", "nullable": true} +{"name": ".stlength", "args": [{"type": "geometry"}], "returns": "float", "nullable": true} +{"name": ".stdistance", "args": [{"type": "geometry"}, {"type": "geometry"}], "returns": "float", "nullable": true} +{"name": ".stintersects", "args": [{"type": "geometry"}, {"type": "geometry"}], "returns": "bit", "nullable": true} +{"name": ".stcontains", "args": [{"type": "geometry"}, {"type": "geometry"}], "returns": "bit", "nullable": true} +{"name": ".stwithin", "args": [{"type": "geometry"}, {"type": "geometry"}], "returns": "bit", "nullable": true} +{"name": ".stequals", "args": [{"type": "geometry"}, {"type": "geometry"}], "returns": "bit", "nullable": true} +{"name": ".stisvalid", "args": [{"type": "geometry"}], "returns": "bit", "nullable": true} +{"name": ".stbuffer", "args": [{"type": "geometry"}, {"type": "float"}], "returns": "geometry", "nullable": true} +{"name": ".stunion", "args": [{"type": "geometry"}, {"type": "geometry"}], "returns": "geometry", "nullable": true} +{"name": ".stintersection", "args": [{"type": "geometry"}, {"type": "geometry"}], "returns": "geometry", "nullable": true} +{"name": ".stnumpoints", "args": [{"type": "geometry"}], "returns": "int", "nullable": true} +{"name": ".stdimension", "args": [{"type": "geometry"}], "returns": "int", "nullable": true} +{"name": ".stgeometrytype", "args": [{"type": "geometry"}], "returns": "nvarchar(4000)", "nullable": true} +{"name": ".stpointn", "args": [{"type": "geometry"}, {"type": "int"}], "returns": "geometry", "nullable": true} +{"name": "geometry::stgeomfromtext", "args": [{"type": "nvarchar(max)"}, {"type": "int"}], "returns": "geometry", "nullable": true} +{"name": "geometry::stpointfromtext", "args": [{"type": "nvarchar(max)"}, {"type": "int"}], "returns": "geometry", "nullable": true} +{"name": "geography::point", "args": [{"type": "float"}, {"type": "float"}, {"type": "int"}], "returns": "geography", "nullable": true} From f04eabadb8709512114fd2ff884cf6285c24578f Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:27:01 -0800 Subject: [PATCH 12/21] fix(core): type a placeholder among alternatives as the others combine 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> --- internal/core/analyzer/expr.go | 16 +++ .../mssql/exec.json | 5 + .../mssql/query.sql | 4 + .../mssql/schema.sql | 1 + .../mssql/stdout.json | 122 ++++++++++++++++++ 5 files changed, 148 insertions(+) create mode 100644 internal/endtoend/testdata/analyze_alternative_params/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_alternative_params/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_alternative_params/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_alternative_params/mssql/stdout.json diff --git a/internal/core/analyzer/expr.go b/internal/core/analyzer/expr.go index a41f1d8272..10d747bd80 100644 --- a/internal/core/analyzer/expr.go +++ b/internal/core/analyzer/expr.go @@ -511,6 +511,8 @@ func (a *analyzer) typeCoalesce(e *ast.CoalesceExpr) (exprType, error) { } } } + // 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 } @@ -530,10 +532,24 @@ func (a *analyzer) typeFirstOf(nodes []ast.Node, nullable bool) (exprType, error nullable = nullable || t.nullable } out := a.commonType(types) + 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) diff --git a/internal/endtoend/testdata/analyze_alternative_params/mssql/exec.json b/internal/endtoend/testdata/analyze_alternative_params/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_alternative_params/mssql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "mssql", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_alternative_params/mssql/query.sql b/internal/endtoend/testdata/analyze_alternative_params/mssql/query.sql new file mode 100644 index 0000000000..21aae2f85a --- /dev/null +++ b/internal/endtoend/testdata/analyze_alternative_params/mssql/query.sql @@ -0,0 +1,4 @@ +-- name: Alternatives :many +SELECT GREATEST(1, @n) AS g, LEAST(n, @cap) AS l, COALESCE(@x, name) AS c, + CASE WHEN id > 0 THEN @label ELSE name END AS k, IIF(id > 0, @alt, at) AS i +FROM t; diff --git a/internal/endtoend/testdata/analyze_alternative_params/mssql/schema.sql b/internal/endtoend/testdata/analyze_alternative_params/mssql/schema.sql new file mode 100644 index 0000000000..53c28cb58a --- /dev/null +++ b/internal/endtoend/testdata/analyze_alternative_params/mssql/schema.sql @@ -0,0 +1 @@ +CREATE TABLE t (id INT NOT NULL, name NVARCHAR(50) NULL, at DATETIME2(3) NOT NULL, n BIGINT NOT NULL); diff --git a/internal/endtoend/testdata/analyze_alternative_params/mssql/stdout.json b/internal/endtoend/testdata/analyze_alternative_params/mssql/stdout.json new file mode 100644 index 0000000000..23fa44d170 --- /dev/null +++ b/internal/endtoend/testdata/analyze_alternative_params/mssql/stdout.json @@ -0,0 +1,122 @@ +[ + { + "name": "Alternatives", + "cmd": ":many", + "columns": [ + { + "name": "g", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "l", + "type": { + "name": "bigint", + "nullable": true + }, + "table": "t" + }, + { + "name": "c", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 50 + } + ] + } + }, + { + "name": "k", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 50 + } + ] + } + }, + { + "name": "i", + "type": { + "name": "datetime2", + "nullable": true, + "args": [ + { + "int": 3 + } + ] + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "n", + "type": { + "name": "int" + } + } + }, + { + "number": 2, + "column": { + "name": "cap", + "type": { + "name": "bigint" + } + } + }, + { + "number": 3, + "column": { + "name": "x", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 50 + } + ] + } + } + }, + { + "number": 4, + "column": { + "name": "label", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 50 + } + ] + } + } + }, + { + "number": 5, + "column": { + "name": "alt", + "type": { + "name": "datetime2", + "args": [ + { + "int": 3 + } + ] + } + } + } + ] + } +] From 34bc380b0881554b9b417d4c7184d6df12529c7f Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:30:57 -0800 Subject: [PATCH 13/21] feat(mssql): seed more built-in functions 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> --- .../analyze_more_functions/mssql/exec.json | 5 + .../analyze_more_functions/mssql/query.sql | 22 ++ .../analyze_more_functions/mssql/schema.sql | 1 + .../analyze_more_functions/mssql/stdout.json | 367 ++++++++++++++++++ internal/engine/mssql/dialect/functions.jsonl | 73 ++++ 5 files changed, 468 insertions(+) create mode 100644 internal/endtoend/testdata/analyze_more_functions/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_more_functions/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_more_functions/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_more_functions/mssql/stdout.json diff --git a/internal/endtoend/testdata/analyze_more_functions/mssql/exec.json b/internal/endtoend/testdata/analyze_more_functions/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_more_functions/mssql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "mssql", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_more_functions/mssql/query.sql b/internal/endtoend/testdata/analyze_more_functions/mssql/query.sql new file mode 100644 index 0000000000..f9db529a01 --- /dev/null +++ b/internal/endtoend/testdata/analyze_more_functions/mssql/query.sql @@ -0,0 +1,22 @@ +-- name: Grouping :many +SELECT g, GROUPING(g) AS gr, GROUPING_ID(g, id) AS gid, APPROX_COUNT_DISTINCT(id) AS approx, CHECKSUM_AGG(id) AS ca +FROM s GROUP BY ROLLUP(g, id); + +-- name: Percentiles :many +SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY v) OVER (PARTITION BY g) AS med FROM s; + +-- name: Metadata :one +SELECT DB_NAME() AS d, SCHEMA_NAME() AS sn, OBJECT_ID(N'dbo.s') AS o, OBJECT_NAME(@oid) AS obj, + IDENT_CURRENT(N's') AS ic, ROWCOUNT_BIG() AS rc, ORIGINAL_LOGIN() AS ol, DB_ID() AS dbid, + ERROR_MESSAGE() AS em, ERROR_NUMBER() AS en, XACT_STATE() AS xs, SESSION_CONTEXT(N'k') AS sc; + +-- name: Strings :many +SELECT STRING_ESCAPE(g, 'json') AS se, SOUNDEX(g) AS sx, DIFFERENCE(g, @other) AS df, + LTRIM(g, N'x') AS lt, RTRIM(g, N'x') AS rt, COMPRESS(g) AS c, REGEXP_REPLACE(g, @pattern, N'b') AS rr, + REGEXP_SUBSTR(g, N'a') AS rs, REGEXP_COUNT(g, N'a') AS rc +FROM s; + +-- name: Bits :many +SELECT BIT_COUNT(id) AS bc, LEFT_SHIFT(id, 1) AS ls, GET_BIT(id, 1) AS gb, SET_BIT(id, @bit) AS sb, + DATE_BUCKET(week, 2, CAST(SYSDATETIME() AS date)) AS db +FROM s; diff --git a/internal/endtoend/testdata/analyze_more_functions/mssql/schema.sql b/internal/endtoend/testdata/analyze_more_functions/mssql/schema.sql new file mode 100644 index 0000000000..dc9cce113a --- /dev/null +++ b/internal/endtoend/testdata/analyze_more_functions/mssql/schema.sql @@ -0,0 +1 @@ +CREATE TABLE s (id INT NOT NULL, g NVARCHAR(10) NOT NULL, v DECIMAL(10,2) NOT NULL, f FLOAT NULL); diff --git a/internal/endtoend/testdata/analyze_more_functions/mssql/stdout.json b/internal/endtoend/testdata/analyze_more_functions/mssql/stdout.json new file mode 100644 index 0000000000..ff93fb5e24 --- /dev/null +++ b/internal/endtoend/testdata/analyze_more_functions/mssql/stdout.json @@ -0,0 +1,367 @@ +[ + { + "name": "Grouping", + "cmd": ":many", + "columns": [ + { + "name": "g", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 10 + } + ] + }, + "table": "s" + }, + { + "name": "gr", + "type": { + "name": "tinyint" + } + }, + { + "name": "gid", + "type": { + "name": "int" + } + }, + { + "name": "approx", + "type": { + "name": "bigint", + "nullable": true + } + }, + { + "name": "ca", + "type": { + "name": "int", + "nullable": true + } + } + ], + "params": [] + }, + { + "name": "Percentiles", + "cmd": ":many", + "columns": [ + { + "name": "med", + "type": { + "name": "float", + "nullable": true + } + } + ], + "params": [] + }, + { + "name": "Metadata", + "cmd": ":one", + "columns": [ + { + "name": "d", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 128 + } + ] + } + }, + { + "name": "sn", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 128 + } + ] + } + }, + { + "name": "o", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "obj", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 128 + } + ] + } + }, + { + "name": "ic", + "type": { + "name": "decimal", + "nullable": true, + "args": [ + { + "int": 38 + }, + { + "int": 0 + } + ] + } + }, + { + "name": "rc", + "type": { + "name": "bigint" + } + }, + { + "name": "ol", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 4000 + } + ] + } + }, + { + "name": "dbid", + "type": { + "name": "smallint", + "nullable": true + } + }, + { + "name": "em", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 4000 + } + ] + } + }, + { + "name": "en", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "xs", + "type": { + "name": "smallint", + "nullable": true + } + }, + { + "name": "sc", + "type": { + "name": "sql_variant", + "nullable": true + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "oid", + "type": { + "name": "int" + } + } + } + ] + }, + { + "name": "Strings", + "cmd": ":many", + "columns": [ + { + "name": "se", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "ident": "max" + } + ] + } + }, + { + "name": "sx", + "type": { + "name": "varchar", + "nullable": true, + "args": [ + { + "int": 5 + } + ] + } + }, + { + "name": "df", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "lt", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 10 + } + ] + } + }, + { + "name": "rt", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 10 + } + ] + } + }, + { + "name": "c", + "type": { + "name": "varbinary", + "nullable": true, + "args": [ + { + "ident": "max" + } + ] + } + }, + { + "name": "rr", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 4000 + } + ] + } + }, + { + "name": "rs", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 10 + } + ] + } + }, + { + "name": "rc", + "type": { + "name": "int", + "nullable": true + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "other", + "type": { + "name": "varchar" + } + } + }, + { + "number": 2, + "column": { + "name": "pattern" + } + } + ] + }, + { + "name": "Bits", + "cmd": ":many", + "columns": [ + { + "name": "bc", + "type": { + "name": "bigint", + "nullable": true + } + }, + { + "name": "ls", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "gb", + "type": { + "name": "bit", + "nullable": true + } + }, + { + "name": "sb", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "db", + "type": { + "name": "date", + "nullable": true + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "bit", + "type": { + "name": "int" + } + } + } + ] + } +] diff --git a/internal/engine/mssql/dialect/functions.jsonl b/internal/engine/mssql/dialect/functions.jsonl index ec800de080..ab14ba0aac 100644 --- a/internal/engine/mssql/dialect/functions.jsonl +++ b/internal/engine/mssql/dialect/functions.jsonl @@ -309,3 +309,76 @@ {"name": "geometry::stgeomfromtext", "args": [{"type": "nvarchar(max)"}, {"type": "int"}], "returns": "geometry", "nullable": true} {"name": "geometry::stpointfromtext", "args": [{"type": "nvarchar(max)"}, {"type": "int"}], "returns": "geometry", "nullable": true} {"name": "geography::point", "args": [{"type": "float"}, {"type": "float"}, {"type": "int"}], "returns": "geography", "nullable": true} +{"name": "grouping", "kind": "a", "args": [{"type": "any"}], "returns": "tinyint", "never_null": true} +{"name": "grouping_id", "kind": "a", "args": [{"type": "any"}, {"type": "any", "mode": "v"}], "returns": "int", "never_null": true} +{"name": "approx_count_distinct", "kind": "a", "args": [{"type": "any"}], "returns": "bigint", "nullable": true} +{"name": "checksum_agg", "kind": "a", "args": [{"type": "int"}], "returns": "int", "nullable": true} +{"name": "percentile_cont", "kind": "w", "args": [{"type": "float"}], "returns": "float", "nullable": true} +{"name": "ident_current", "args": [{"type": "nvarchar"}], "returns": "decimal(38,0)", "nullable": true} +{"name": "ident_seed", "args": [{"type": "nvarchar"}], "returns": "decimal(38,0)", "nullable": true} +{"name": "ident_incr", "args": [{"type": "nvarchar"}], "returns": "decimal(38,0)", "nullable": true} +{"name": "rowcount_big", "returns": "bigint", "never_null": true} +{"name": "db_name", "returns": "nvarchar(128)", "nullable": true} +{"name": "suser_sname", "returns": "nvarchar(128)", "nullable": true} +{"name": "schema_name", "returns": "nvarchar(128)", "nullable": true} +{"name": "user_name", "returns": "nvarchar(128)", "nullable": true} +{"name": "db_name", "args": [{"type": "int"}], "returns": "nvarchar(128)", "nullable": true} +{"name": "suser_sname", "args": [{"type": "varbinary"}], "returns": "nvarchar(128)", "nullable": true} +{"name": "schema_name", "args": [{"type": "int"}], "returns": "nvarchar(128)", "nullable": true} +{"name": "user_name", "args": [{"type": "int"}], "returns": "nvarchar(128)", "nullable": true} +{"name": "host_name", "returns": "nvarchar(128)", "nullable": true} +{"name": "app_name", "returns": "nvarchar(128)", "nullable": true} +{"name": "suser_name", "returns": "nvarchar(128)", "nullable": true} +{"name": "current_timezone", "returns": "nvarchar(128)", "nullable": true} +{"name": "current_timezone_id", "returns": "nvarchar(128)", "nullable": true} +{"name": "error_procedure", "returns": "nvarchar(128)", "nullable": true} +{"name": "original_login", "returns": "nvarchar(4000)", "nullable": true} +{"name": "object_id", "args": [{"type": "nvarchar"}], "returns": "int", "nullable": true} +{"name": "object_id", "args": [{"type": "nvarchar"}, {"type": "nvarchar"}], "returns": "int", "nullable": true} +{"name": "object_name", "args": [{"type": "int"}], "returns": "nvarchar(128)", "nullable": true} +{"name": "object_name", "args": [{"type": "int"}, {"type": "int"}], "returns": "nvarchar(128)", "nullable": true} +{"name": "object_schema_name", "args": [{"type": "int"}], "returns": "nvarchar(128)", "nullable": true} +{"name": "object_schema_name", "args": [{"type": "int"}, {"type": "int"}], "returns": "nvarchar(128)", "nullable": true} +{"name": "object_definition", "args": [{"type": "int"}], "returns": "nvarchar(max)", "nullable": true} +{"name": "db_id", "returns": "smallint", "nullable": true} +{"name": "db_id", "args": [{"type": "nvarchar"}], "returns": "smallint", "nullable": true} +{"name": "user_id", "returns": "smallint", "nullable": true} +{"name": "user_id", "args": [{"type": "nvarchar"}], "returns": "smallint", "nullable": true} +{"name": "suser_id", "returns": "int", "nullable": true} +{"name": "suser_id", "args": [{"type": "nvarchar"}], "returns": "int", "nullable": true} +{"name": "schema_id", "returns": "int", "nullable": true} +{"name": "schema_id", "args": [{"type": "nvarchar"}], "returns": "int", "nullable": true} +{"name": "suser_sid", "returns": "varbinary(85)", "nullable": true} +{"name": "col_length", "args": [{"type": "nvarchar"}, {"type": "nvarchar"}], "returns": "smallint", "nullable": true} +{"name": "col_name", "args": [{"type": "int"}, {"type": "int"}], "returns": "nvarchar(128)", "nullable": true} +{"name": "type_name", "args": [{"type": "int"}], "returns": "nvarchar(128)", "nullable": true} +{"name": "error_number", "returns": "int", "nullable": true} +{"name": "error_severity", "returns": "int", "nullable": true} +{"name": "error_state", "returns": "int", "nullable": true} +{"name": "error_line", "returns": "int", "nullable": true} +{"name": "error_message", "returns": "nvarchar(4000)", "nullable": true} +{"name": "xact_state", "returns": "smallint", "nullable": true} +{"name": "session_context", "args": [{"type": "nvarchar"}], "returns": "sql_variant", "nullable": true} +{"name": "context_info", "returns": "varbinary(128)", "nullable": true} +{"name": "sql_variant_property", "args": [{"type": "any"}, {"type": "varchar"}], "returns": "sql_variant", "nullable": true} +{"name": "string_escape", "args": [{"type": "nvarchar"}, {"type": "varchar"}], "returns": "nvarchar(max)", "nullable": true} +{"name": "soundex", "args": [{"type": "varchar"}], "returns": "varchar(5)", "nullable": true} +{"name": "difference", "args": [{"type": "varchar"}, {"type": "varchar"}], "returns": "int", "nullable": true} +{"name": "formatmessage", "args": [{"type": "any"}, {"type": "any", "mode": "v"}], "returns": "nvarchar(4000)", "nullable": true} +{"name": "ltrim", "args": [{"type": "any"}, {"type": "any"}], "returns": "$1", "nullable": true} +{"name": "rtrim", "args": [{"type": "any"}, {"type": "any"}], "returns": "$1", "nullable": true} +{"name": "compress", "args": [{"type": "any"}], "returns": "varbinary(max)", "nullable": true} +{"name": "decompress", "args": [{"type": "any"}], "returns": "varbinary(max)", "nullable": true} +{"name": "bit_count", "args": [{"type": "any"}], "returns": "bigint", "nullable": true} +{"name": "left_shift", "args": [{"type": "any"}, {"type": "int"}], "returns": "$1", "nullable": true} +{"name": "right_shift", "args": [{"type": "any"}, {"type": "int"}], "returns": "$1", "nullable": true} +{"name": "get_bit", "args": [{"type": "any"}, {"type": "int"}], "returns": "bit", "nullable": true} +{"name": "set_bit", "args": [{"type": "any"}, {"type": "int"}], "returns": "$1", "nullable": true} +{"name": "set_bit", "args": [{"type": "any"}, {"type": "int"}, {"type": "int"}], "returns": "$1", "nullable": true} +{"name": "date_bucket", "args": [{"type": "varchar"}, {"type": "int"}, {"type": "any"}], "returns": "$3", "nullable": true} +{"name": "date_bucket", "args": [{"type": "varchar"}, {"type": "int"}, {"type": "any"}, {"type": "any"}], "returns": "$3", "nullable": true} +{"name": "regexp_replace", "args": [{"type": "any"}, {"type": "any"}, {"type": "any", "mode": "v"}], "returns": "nvarchar(4000)", "nullable": true} +{"name": "regexp_substr", "args": [{"type": "any"}, {"type": "any"}, {"type": "any", "mode": "v"}], "returns": "$1", "nullable": true} +{"name": "regexp_instr", "args": [{"type": "any"}, {"type": "any"}, {"type": "any", "mode": "v"}], "returns": "int", "nullable": true} +{"name": "regexp_count", "args": [{"type": "any"}, {"type": "any"}, {"type": "any", "mode": "v"}], "returns": "int", "nullable": true} +{"name": "vector_distance", "args": [{"type": "varchar"}, {"type": "vector"}, {"type": "vector"}], "returns": "float", "nullable": true} From 13138f46ff8dfbb6401a56840d6b9a3a1380795a Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:38:38 -0800 Subject: [PATCH 14/21] feat(mssql): type LEFT, RIGHT and TRIM(chars FROM string) 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> --- .../analyze_left_right_trim/mssql/exec.json | 5 ++ .../analyze_left_right_trim/mssql/query.sql | 2 + .../analyze_left_right_trim/mssql/schema.sql | 1 + .../analyze_left_right_trim/mssql/stdout.json | 55 +++++++++++++++++++ internal/engine/mssql/convert.go | 18 ++++++ internal/engine/mssql/dialect/functions.jsonl | 17 +++--- 6 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 internal/endtoend/testdata/analyze_left_right_trim/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_left_right_trim/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_left_right_trim/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_left_right_trim/mssql/stdout.json diff --git a/internal/endtoend/testdata/analyze_left_right_trim/mssql/exec.json b/internal/endtoend/testdata/analyze_left_right_trim/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_left_right_trim/mssql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "mssql", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_left_right_trim/mssql/query.sql b/internal/endtoend/testdata/analyze_left_right_trim/mssql/query.sql new file mode 100644 index 0000000000..6ce39b24e8 --- /dev/null +++ b/internal/endtoend/testdata/analyze_left_right_trim/mssql/query.sql @@ -0,0 +1,2 @@ +-- name: Trimmed :many +SELECT LEFT(a, 3) AS lf, RIGHT(c, @n) AS rt, TRIM(N'x' FROM a) AS tr, TRIM(LEADING 'x' FROM c) AS tl FROM p; diff --git a/internal/endtoend/testdata/analyze_left_right_trim/mssql/schema.sql b/internal/endtoend/testdata/analyze_left_right_trim/mssql/schema.sql new file mode 100644 index 0000000000..2008fa5e89 --- /dev/null +++ b/internal/endtoend/testdata/analyze_left_right_trim/mssql/schema.sql @@ -0,0 +1 @@ +CREATE TABLE p (a NVARCHAR(10) NOT NULL, b NVARCHAR(20) NOT NULL, c VARCHAR(5) NOT NULL, d DECIMAL(10,2) NOT NULL, e DECIMAL(5,3) NOT NULL, i INT NOT NULL); diff --git a/internal/endtoend/testdata/analyze_left_right_trim/mssql/stdout.json b/internal/endtoend/testdata/analyze_left_right_trim/mssql/stdout.json new file mode 100644 index 0000000000..57527e3607 --- /dev/null +++ b/internal/endtoend/testdata/analyze_left_right_trim/mssql/stdout.json @@ -0,0 +1,55 @@ +[ + { + "name": "Trimmed", + "cmd": ":many", + "columns": [ + { + "name": "lf", + "type": { + "name": "nvarchar", + "nullable": true + } + }, + { + "name": "rt", + "type": { + "name": "varchar", + "nullable": true + } + }, + { + "name": "tr", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 10 + } + ] + } + }, + { + "name": "tl", + "type": { + "name": "varchar", + "args": [ + { + "int": 5 + } + ] + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "n", + "type": { + "name": "int" + } + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index df7f1a4a99..b1092011f2 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -667,6 +667,10 @@ func (c *cc) convertScalarExpression(expr tsql.ScalarExpression) ast.Node { }, Location: c.loc(e), } + case *tsql.LeftFunctionCall: + return c.namedCall("left", e.Parameters, e) + case *tsql.RightFunctionCall: + return c.namedCall("right", e.Parameters, e) case *tsql.ParameterlessCall: return &ast.FuncCall{ Funcname: &ast.List{ @@ -1333,6 +1337,20 @@ func (c *cc) convertAlterTableAddTableElementStatement(n *tsql.AlterTableAddTabl return stmt } +// namedCall is a call of name with args, for a function the parser gives a +// node of its own, as LEFT and RIGHT, which are keywords too. +func (c *cc) namedCall(name string, args []tsql.ScalarExpression, n tsql.Node) *ast.FuncCall { + call := &ast.FuncCall{ + Funcname: &ast.List{Items: []ast.Node{&ast.String{Str: name}}}, + Args: &ast.List{}, + Location: c.loc(n), + } + for _, arg := range args { + call.Args.Items = append(call.Args.Items, c.convertScalarExpression(arg)) + } + return call +} + func (c *cc) convertAlterTableDropTableElementStatement(n *tsql.AlterTableDropTableElementStatement) ast.Node { if n.SchemaObjectName == nil { return todo(n) diff --git a/internal/engine/mssql/dialect/functions.jsonl b/internal/engine/mssql/dialect/functions.jsonl index ab14ba0aac..ab5e9e8765 100644 --- a/internal/engine/mssql/dialect/functions.jsonl +++ b/internal/engine/mssql/dialect/functions.jsonl @@ -110,14 +110,14 @@ {"name": "substring", "args": [{"type": "nchar"}, {"type": "bigint"}, {"type": "bigint"}], "returns": "nvarchar", "nullable": true} {"name": "substring", "args": [{"type": "varbinary"}, {"type": "bigint"}, {"type": "bigint"}], "returns": "varbinary", "nullable": true} {"name": "substring", "args": [{"type": "binary"}, {"type": "bigint"}, {"type": "bigint"}], "returns": "varbinary", "nullable": true} -{"name": "left", "args": [{"type": "varchar"}, {"type": "bigint"}], "returns": "varchar", "nullable": true} -{"name": "left", "args": [{"type": "char"}, {"type": "bigint"}], "returns": "varchar", "nullable": true} -{"name": "left", "args": [{"type": "nvarchar"}, {"type": "bigint"}], "returns": "nvarchar", "nullable": true} -{"name": "left", "args": [{"type": "nchar"}, {"type": "bigint"}], "returns": "nvarchar", "nullable": true} -{"name": "right", "args": [{"type": "varchar"}, {"type": "bigint"}], "returns": "varchar", "nullable": true} -{"name": "right", "args": [{"type": "char"}, {"type": "bigint"}], "returns": "varchar", "nullable": true} -{"name": "right", "args": [{"type": "nvarchar"}, {"type": "bigint"}], "returns": "nvarchar", "nullable": true} -{"name": "right", "args": [{"type": "nchar"}, {"type": "bigint"}], "returns": "nvarchar", "nullable": true} +{"name": "left", "args": [{"type": "varchar"}, {"type": "int"}], "returns": "varchar", "nullable": true} +{"name": "left", "args": [{"type": "char"}, {"type": "int"}], "returns": "varchar", "nullable": true} +{"name": "left", "args": [{"type": "nvarchar"}, {"type": "int"}], "returns": "nvarchar", "nullable": true} +{"name": "left", "args": [{"type": "nchar"}, {"type": "int"}], "returns": "nvarchar", "nullable": true} +{"name": "right", "args": [{"type": "varchar"}, {"type": "int"}], "returns": "varchar", "nullable": true} +{"name": "right", "args": [{"type": "char"}, {"type": "int"}], "returns": "varchar", "nullable": true} +{"name": "right", "args": [{"type": "nvarchar"}, {"type": "int"}], "returns": "nvarchar", "nullable": true} +{"name": "right", "args": [{"type": "nchar"}, {"type": "int"}], "returns": "nvarchar", "nullable": true} {"name": "replace", "args": [{"type": "varchar"}, {"type": "any"}, {"type": "any"}], "returns": "varchar(8000)", "nullable": true} {"name": "replace", "args": [{"type": "char"}, {"type": "any"}, {"type": "any"}], "returns": "varchar(8000)", "nullable": true} {"name": "replace", "args": [{"type": "nvarchar"}, {"type": "any"}, {"type": "any"}], "returns": "nvarchar(4000)", "nullable": true} @@ -382,3 +382,4 @@ {"name": "regexp_instr", "args": [{"type": "any"}, {"type": "any"}, {"type": "any", "mode": "v"}], "returns": "int", "nullable": true} {"name": "regexp_count", "args": [{"type": "any"}, {"type": "any"}, {"type": "any", "mode": "v"}], "returns": "int", "nullable": true} {"name": "vector_distance", "args": [{"type": "varchar"}, {"type": "vector"}, {"type": "vector"}], "returns": "float", "nullable": true} +{"name": "trim", "args": [{"type": "any"}, {"type": "any"}], "returns": "$2"} From 95835ee18bcb5b09eb18cecb6a768c4d86c41e8a Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:39:32 -0800 Subject: [PATCH 15/21] fix(mssql): take REPLICATE's and CHARINDEX's integer arguments as int 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> --- .../analyze_left_right_trim/mssql/query.sql | 3 ++ .../analyze_left_right_trim/mssql/stdout.json | 40 +++++++++++++++++++ internal/engine/mssql/dialect/functions.jsonl | 10 ++--- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/internal/endtoend/testdata/analyze_left_right_trim/mssql/query.sql b/internal/endtoend/testdata/analyze_left_right_trim/mssql/query.sql index 6ce39b24e8..9eb8705b85 100644 --- a/internal/endtoend/testdata/analyze_left_right_trim/mssql/query.sql +++ b/internal/endtoend/testdata/analyze_left_right_trim/mssql/query.sql @@ -1,2 +1,5 @@ -- name: Trimmed :many SELECT LEFT(a, 3) AS lf, RIGHT(c, @n) AS rt, TRIM(N'x' FROM a) AS tr, TRIM(LEADING 'x' FROM c) AS tl FROM p; + +-- name: Repeated :many +SELECT REPLICATE(a, @r) AS r, CHARINDEX(N'x', a, @ci) AS ci FROM p; diff --git a/internal/endtoend/testdata/analyze_left_right_trim/mssql/stdout.json b/internal/endtoend/testdata/analyze_left_right_trim/mssql/stdout.json index 57527e3607..4743bd3f92 100644 --- a/internal/endtoend/testdata/analyze_left_right_trim/mssql/stdout.json +++ b/internal/endtoend/testdata/analyze_left_right_trim/mssql/stdout.json @@ -51,5 +51,45 @@ } } ] + }, + { + "name": "Repeated", + "cmd": ":many", + "columns": [ + { + "name": "r", + "type": { + "name": "nvarchar", + "nullable": true + } + }, + { + "name": "ci", + "type": { + "name": "int", + "nullable": true + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "r", + "type": { + "name": "int" + } + } + }, + { + "number": 2, + "column": { + "name": "ci", + "type": { + "name": "int" + } + } + } + ] } ] diff --git a/internal/engine/mssql/dialect/functions.jsonl b/internal/engine/mssql/dialect/functions.jsonl index ab5e9e8765..f3c47c5418 100644 --- a/internal/engine/mssql/dialect/functions.jsonl +++ b/internal/engine/mssql/dialect/functions.jsonl @@ -124,10 +124,10 @@ {"name": "replace", "args": [{"type": "nchar"}, {"type": "any"}, {"type": "any"}], "returns": "nvarchar(4000)", "nullable": true} {"name": "translate", "args": [{"type": "varchar"}, {"type": "any"}, {"type": "any"}], "returns": "varchar(8000)", "nullable": true} {"name": "translate", "args": [{"type": "nvarchar"}, {"type": "any"}, {"type": "any"}], "returns": "nvarchar(4000)", "nullable": true} -{"name": "replicate", "args": [{"type": "varchar"}, {"type": "bigint"}], "returns": "varchar", "nullable": true} -{"name": "replicate", "args": [{"type": "char"}, {"type": "bigint"}], "returns": "varchar", "nullable": true} -{"name": "replicate", "args": [{"type": "nvarchar"}, {"type": "bigint"}], "returns": "nvarchar", "nullable": true} -{"name": "replicate", "args": [{"type": "nchar"}, {"type": "bigint"}], "returns": "nvarchar", "nullable": true} +{"name": "replicate", "args": [{"type": "varchar"}, {"type": "int"}], "returns": "varchar", "nullable": true} +{"name": "replicate", "args": [{"type": "char"}, {"type": "int"}], "returns": "varchar", "nullable": true} +{"name": "replicate", "args": [{"type": "nvarchar"}, {"type": "int"}], "returns": "nvarchar", "nullable": true} +{"name": "replicate", "args": [{"type": "nchar"}, {"type": "int"}], "returns": "nvarchar", "nullable": true} {"name": "reverse", "args": [{"type": "varchar"}], "returns": "$1", "nullable": true} {"name": "reverse", "args": [{"type": "char"}], "returns": "varchar", "nullable": true} {"name": "reverse", "args": [{"type": "nvarchar"}], "returns": "$1", "nullable": true} @@ -138,7 +138,7 @@ {"name": "stuff", "args": [{"type": "nchar"}, {"type": "bigint"}, {"type": "bigint"}, {"type": "any"}], "returns": "nvarchar", "nullable": true} {"name": "space", "args": [{"type": "int"}], "returns": "varchar(8000)", "nullable": true} {"name": "charindex", "args": [{"type": "any"}, {"type": "any"}], "returns": "int", "nullable": true} -{"name": "charindex", "args": [{"type": "any"}, {"type": "any"}, {"type": "bigint"}], "returns": "int", "nullable": true} +{"name": "charindex", "args": [{"type": "any"}, {"type": "any"}, {"type": "int"}], "returns": "int", "nullable": true} {"name": "patindex", "args": [{"type": "any"}, {"type": "any"}], "returns": "int", "nullable": true} {"name": "ascii", "args": [{"type": "varchar"}], "returns": "int", "nullable": true} {"name": "unicode", "args": [{"type": "nvarchar"}], "returns": "int", "nullable": true} From 5ba6c853b196af0430719d65b8fe5b661bda417f Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:52:47 -0800 Subject: [PATCH 16/21] fix(core): type a set operation column of an argument-free type as its 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> --- internal/core/analyzer/setop.go | 7 +++++++ .../testdata/analyze_set_operations/mssql/query.sql | 3 +++ .../analyze_set_operations/mssql/stdout.json | 13 +++++++++++++ 3 files changed, 23 insertions(+) diff --git a/internal/core/analyzer/setop.go b/internal/core/analyzer/setop.go index 04e8060414..52b83ee802 100644 --- a/internal/core/analyzer/setop.go +++ b/internal/core/analyzer/setop.go @@ -66,6 +66,13 @@ func (a *analyzer) combineTypes(rules *core.SetOperationRules, l, r exprType) ex case len(out.Args) == 1 && len(loser.Args) == 1 && a.sameCategory(lt.Name, rt.Name): out.Args[0] = widerArg(out.Args[0], loser.Args[0]) } + // A type with no arguments is its family. Looked up as a declaration + // it would take the dialect's defaults, as varchar is varchar(1). + if len(out.Args) == 0 { + if oid, err := a.cat.TypeOID(out.Name); err == nil { + return exprType{typeOID: oid} + } + } return a.lookupType(out) } diff --git a/internal/endtoend/testdata/analyze_set_operations/mssql/query.sql b/internal/endtoend/testdata/analyze_set_operations/mssql/query.sql index f52cdad03c..344b803714 100644 --- a/internal/endtoend/testdata/analyze_set_operations/mssql/query.sql +++ b/internal/endtoend/testdata/analyze_set_operations/mssql/query.sql @@ -27,3 +27,6 @@ UNION ALL SELECT amount FROM ledger UNION ALL SELECT flag FROM ledger; + +-- name: LiteralUnion :many +SELECT 'active' AS status UNION ALL SELECT 'inactive'; diff --git a/internal/endtoend/testdata/analyze_set_operations/mssql/stdout.json b/internal/endtoend/testdata/analyze_set_operations/mssql/stdout.json index c7cd695f18..97efa9f313 100644 --- a/internal/endtoend/testdata/analyze_set_operations/mssql/stdout.json +++ b/internal/endtoend/testdata/analyze_set_operations/mssql/stdout.json @@ -166,5 +166,18 @@ } ], "params": [] + }, + { + "name": "LiteralUnion", + "cmd": ":many", + "columns": [ + { + "name": "status", + "type": { + "name": "varchar" + } + } + ], + "params": [] } ] From 64e0b37d72ddeeda2594476556539899f30ace02 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:18:01 -0800 Subject: [PATCH 17/21] refactor(core): select engine-owned type-combination policies explicitly 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> --- internal/core/analyzer/analyzer.go | 5 +- internal/core/analyzer/expr.go | 12 +- internal/core/analyzer/setop.go | 123 ++++++-------------- internal/core/dialect.go | 11 +- internal/core/seed/seed.go | 3 + internal/core/seed/seed_test.go | 35 ++++++ internal/engine/mssql/dialect/dialect.json | 1 + internal/engine/mssql/typepolicy/combine.go | 80 +++++++++++++ 8 files changed, 178 insertions(+), 92 deletions(-) create mode 100644 internal/core/seed/seed_test.go create mode 100644 internal/engine/mssql/typepolicy/combine.go diff --git a/internal/core/analyzer/analyzer.go b/internal/core/analyzer/analyzer.go index dc039689aa..dff30966fd 100644 --- a/internal/core/analyzer/analyzer.go +++ b/internal/core/analyzer/analyzer.go @@ -284,7 +284,10 @@ func (a *analyzer) analyzeSetOperation(s *ast.SelectStmt) error { a.columns = left.columns a.scope = left.scope if rules := a.cat.SetOperations(); rules != nil { - a.columns = a.combineColumns(rules, s.Op, left.columns, right.columns) + a.columns, err = a.combineColumns(rules, s.Op, left.columns, right.columns) + if err != nil { + return err + } } return nil } diff --git a/internal/core/analyzer/expr.go b/internal/core/analyzer/expr.go index 10d747bd80..1667955b23 100644 --- a/internal/core/analyzer/expr.go +++ b/internal/core/analyzer/expr.go @@ -502,7 +502,12 @@ func (a *analyzer) typeCoalesce(e *ast.CoalesceExpr) (exprType, error) { types = append(types, t) nullable = nullable && t.nullable } - t := a.commonType(types) + t, err := a.commonType(types) + if err != nil { + return exprType{}, err + } + // Only ISNULL's converter sets FirstArgType. Ordinary COALESCE keeps + // the common type selected above, rather than its first argument's. if e.FirstArgType { for _, first := range types { if first.typeOID != 0 || first.expr != nil { @@ -531,7 +536,10 @@ func (a *analyzer) typeFirstOf(nodes []ast.Node, nullable bool) (exprType, error types = append(types, t) nullable = nullable || t.nullable } - out := a.commonType(types) + out, err := a.commonType(types) + if err != nil { + return exprType{}, err + } a.inferAlternatives(nodes, out, false) out.nullable = nullable return out, nil diff --git a/internal/core/analyzer/setop.go b/internal/core/analyzer/setop.go index 52b83ee802..1bf4e870ad 100644 --- a/internal/core/analyzer/setop.go +++ b/internal/core/analyzer/setop.go @@ -1,9 +1,10 @@ package analyzer import ( - "slices" + "fmt" "github.com/sqlc-dev/sqlc/internal/core" + mssqltypes "github.com/sqlc-dev/sqlc/internal/engine/mssql/typepolicy" "github.com/sqlc-dev/sqlc/internal/sql/ast" ) @@ -12,7 +13,7 @@ import ( // and is read from no one table. It may be NULL in a UNION when either // branch's may, in an INTERSECT when both may, and in an EXCEPT when the // first branch's may, since every row it returns is one of those. -func (a *analyzer) combineColumns(rules *core.SetOperationRules, op ast.SetOperation, left, right []core.Column) []core.Column { +func (a *analyzer) combineColumns(rules *core.SetOperationRules, op ast.SetOperation, left, right []core.Column) ([]core.Column, error) { out := make([]core.Column, len(left)) for i, l := range left { if i >= len(right) { @@ -27,60 +28,56 @@ func (a *analyzer) combineColumns(rules *core.SetOperationRules, op ast.SetOpera case ast.Except: col.NotNull = l.NotNull } - t := a.combineTypes(rules, columnExprType(l), columnExprType(r)) + t, err := a.combineTypes(rules, columnExprType(l), columnExprType(r)) + if err != nil { + return nil, err + } t.nullable = !col.NotNull col.TypeOID = t.typeOID col.DataType, col.IsArray = a.typeNameOf(t) col.Type = a.typeExprOf(t) out[i] = col } - return out + return out, nil } -// combineTypes is the type a column takes from two branches: the one of -// higher precedence, varying when either branch varies, with the length or -// precision that holds both. -func (a *analyzer) combineTypes(rules *core.SetOperationRules, l, r exprType) exprType { +// combineTypes delegates type selection and modifiers to the selected policy, +// then resolves its result against the catalog. No policy is enabled merely +// by listing a dialect's types. +func (a *analyzer) combineTypes(rules *core.SetOperationRules, l, r exprType) (exprType, error) { lt, rt := a.exprOf(l), a.exprOf(r) switch { case lt == nil: - return r + return r, nil case rt == nil: - return l - } - li, ri := slices.Index(rules.Precedence, lt.Name), slices.Index(rules.Precedence, rt.Name) - if li < 0 || ri < 0 { - return l + return l, nil } - winner, loser := lt, rt - if ri < li { - winner, loser = rt, lt + var out *core.TypeExpr + var ok bool + switch rules.Strategy { + case "mssql": + out, ok = mssqltypes.Combine(rules, lt, rt, a.sameCategory(lt.Name, rt.Name)) + default: + return exprType{}, fmt.Errorf("unsupported type-combination strategy %q", rules.Strategy) } - out := winner.WithNullable(false) - if varying, ok := rules.Varying[out.Name]; ok && slices.Contains(mapValues(rules.Varying), loser.Name) { - out.Name = varying - } - switch { - case out.Name == rules.Decimal && rules.Decimal != "": - combineDecimal(rules, out, loser) - case len(out.Args) == 1 && len(loser.Args) == 1 && a.sameCategory(lt.Name, rt.Name): - out.Args[0] = widerArg(out.Args[0], loser.Args[0]) + if !ok { + return l, nil } // A type with no arguments is its family. Looked up as a declaration // it would take the dialect's defaults, as varchar is varchar(1). if len(out.Args) == 0 { if oid, err := a.cat.TypeOID(out.Name); err == nil { - return exprType{typeOID: oid} + return exprType{typeOID: oid}, nil } } - return a.lookupType(out) + return a.lookupType(out), nil } -// commonType is the type a dialect gives an expression that yields one of -// several values, as CASE, COALESCE and GREATEST do, by the rules it -// combines a set operation's branches by; a dialect without them takes the -// first typed value's. The result is no one column's. -func (a *analyzer) commonType(types []exprType) exprType { +// commonType uses the enabled policy for alternative-valued expressions. +// Currently only MSSQL combines these with its set-operation rules. Other +// dialects retain the first typed value; this is a compatibility fallback, +// not a claim that their databases use the same rules. +func (a *analyzer) commonType(types []exprType) (exprType, error) { var out exprType rules := a.cat.SetOperations() for i, t := range types { @@ -91,65 +88,19 @@ func (a *analyzer) commonType(types []exprType) exprType { case out.typeOID == 0 && out.expr == nil: out = t case rules == nil: - return out + return out, nil default: - out = a.combineTypes(rules, out, t) + var err error + out, err = a.combineTypes(rules, out, t) + if err != nil { + return exprType{}, err + } } if i > 0 { out.sourceClassOID, out.sourceAttributeOID, out.sourceTableAlias = 0, 0, "" } } - return out -} - -func mapValues(m map[string]string) []string { - out := make([]string, 0, len(m)) - for _, v := range m { - out = append(out, v) - } - return out -} - -// widerArg is the larger of two lengths or precisions, MAX above any. -func widerArg(a, b core.TypeArg) core.TypeArg { - switch { - case a.Ident != nil: - return a - case b.Ident != nil: - return b - case a.Int != nil && b.Int != nil && *b.Int > *a.Int: - return b - } - return a -} - -// combineDecimal gives the exact numeric out the precision and scale that -// hold both it and the other branch's type: the larger scale, and the -// larger count of integer digits, with scale giving way past the maximum -// precision. A family with no decimal form leaves out as it is. -func combineDecimal(rules *core.SetOperationRules, out, other *core.TypeExpr) { - p1, s1, ok1 := decimalForm(rules, out) - p2, s2, ok2 := decimalForm(rules, other) - if !ok1 || !ok2 { - return - } - scale := max(s1, s2) - precision := max(p1-s1, p2-s2) + scale - if limit := rules.DecimalMaxPrecision; limit > 0 && precision > limit { - scale = max(scale-(precision-limit), 0) - precision = limit - } - out.Args = []core.TypeArg{{Int: &precision}, {Int: &scale}} -} - -func decimalForm(rules *core.SetOperationRules, t *core.TypeExpr) (int64, int64, bool) { - if t.Name == rules.Decimal && len(t.Args) == 2 && t.Args[0].Int != nil && t.Args[1].Int != nil { - return *t.Args[0].Int, *t.Args[1].Int, true - } - if form, ok := rules.AsDecimal[t.Name]; ok { - return form[0], form[1], true - } - return 0, 0, false + return out, nil } // sameCategory reports whether two types are of one category, so that the diff --git a/internal/core/dialect.go b/internal/core/dialect.go index 8555007d73..78ae79192e 100644 --- a/internal/core/dialect.go +++ b/internal/core/dialect.go @@ -201,10 +201,15 @@ func (c *Catalog) QualifiesDuplicateColumns() bool { return v == "true" } -// SetOperationRules say how a dialect types a column of UNION, INTERSECT or -// EXCEPT from its branches, as SQL Server does by data type precedence. A -// dialect without them types the column from the first branch. +// SetOperationRules select a dialect's type-combination policy. A dialect +// without one keeps the analyzer's first-branch/first-typed-value behavior; +// that fallback is not a model of the database's coercion rules. type SetOperationRules struct { + // Strategy selects an implemented policy, currently "mssql". Other + // engines need their own policy, not just another precedence list. + Strategy string `json:"strategy"` + + // The remaining settings parameterize the MSSQL policy. // Precedence lists type families, highest first: the column takes the // highest of its branches'. Precedence []string `json:"precedence"` diff --git a/internal/core/seed/seed.go b/internal/core/seed/seed.go index 93ba30e10e..e544384b0d 100644 --- a/internal/core/seed/seed.go +++ b/internal/core/seed/seed.go @@ -370,6 +370,9 @@ func loadSettings(fsys fs.FS) (Settings, error) { if settings.Dialect == "" { return Settings{}, fmt.Errorf("seed: %s: dialect has no name", SettingsFile) } + if rules := settings.SetOperations; rules != nil && rules.Strategy != "mssql" { + return Settings{}, fmt.Errorf("seed: %s: unsupported type-combination strategy %q", SettingsFile, rules.Strategy) + } settings.fsys = fsys return settings, nil } diff --git a/internal/core/seed/seed_test.go b/internal/core/seed/seed_test.go new file mode 100644 index 0000000000..eb17b1dc8f --- /dev/null +++ b/internal/core/seed/seed_test.go @@ -0,0 +1,35 @@ +package seed + +import ( + "strings" + "testing" + "testing/fstest" +) + +// Dialect seeds are embedded, so the CLI cannot supply invalid policy metadata. +func TestTypeCombinationStrategy(t *testing.T) { + tests := []struct { + name string + settings string + wantErr string + }{ + {"disabled", `{"dialect":"postgresql"}`, ""}, + {"mssql", `{"dialect":"mssql","set_operations":{"strategy":"mssql"}}`, ""}, + {"missing", `{"dialect":"mssql","set_operations":{}}`, `unsupported type-combination strategy ""`}, + {"unknown", `{"dialect":"mssql","set_operations":{"strategy":"unknown"}}`, `unsupported type-combination strategy "unknown"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := loadSettings(fstest.MapFS{ + SettingsFile: &fstest.MapFile{Data: []byte(tt.settings)}, + }) + if tt.wantErr == "" { + if err != nil { + t.Fatal(err) + } + } else if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("got %v, want error containing %q", err, tt.wantErr) + } + }) + } +} diff --git a/internal/engine/mssql/dialect/dialect.json b/internal/engine/mssql/dialect/dialect.json index bd05f86922..78223ad721 100644 --- a/internal/engine/mssql/dialect/dialect.json +++ b/internal/engine/mssql/dialect/dialect.json @@ -9,6 +9,7 @@ }, "bool": "bit", "set_operations": { + "strategy": "mssql", "precedence": [ "sql_variant", "xml", "datetimeoffset", "datetime2", "datetime", "smalldatetime", "date", "time", "float", "real", "decimal", "money", diff --git a/internal/engine/mssql/typepolicy/combine.go b/internal/engine/mssql/typepolicy/combine.go new file mode 100644 index 0000000000..cb0b6ca022 --- /dev/null +++ b/internal/engine/mssql/typepolicy/combine.go @@ -0,0 +1,80 @@ +// Package typepolicy implements SQL Server's type-combination rules without +// depending on its parser or the shared analyzer. +package typepolicy + +import ( + "slices" + + "github.com/sqlc-dev/sqlc/internal/core" +) + +// Combine applies SQL Server's precedence and set-operation type modifiers. +// sameCategory says whether lengths/precisions of the two families are +// comparable. An unlisted family leaves type selection to the caller. +func Combine(rules *core.SetOperationRules, left, right *core.TypeExpr, sameCategory bool) (*core.TypeExpr, bool) { + li, ri := slices.Index(rules.Precedence, left.Name), slices.Index(rules.Precedence, right.Name) + if li < 0 || ri < 0 { + return nil, false + } + winner, loser := left, right + if ri < li { + winner, loser = right, left + } + out := winner.WithNullable(false) + if varying, ok := rules.Varying[out.Name]; ok { + for _, name := range rules.Varying { + if loser.Name == name { + out.Name = varying + break + } + } + } + switch { + case out.Name == rules.Decimal && rules.Decimal != "": + combineDecimal(rules, out, loser) + case len(out.Args) == 1 && len(loser.Args) == 1 && sameCategory: + out.Args[0] = widerArg(out.Args[0], loser.Args[0]) + } + return out, true +} + +// widerArg is the larger of two lengths or precisions, MAX above any. +func widerArg(a, b core.TypeArg) core.TypeArg { + switch { + case a.Ident != nil: + return a + case b.Ident != nil: + return b + case a.Int != nil && b.Int != nil && *b.Int > *a.Int: + return b + } + return a +} + +// combineDecimal applies SQL Server's UNION/INTERSECT/EXCEPT formula: +// retain the larger scale and integer-digit count, reducing scale at the +// precision limit. This is not a cross-engine decimal-coercion rule. +func combineDecimal(rules *core.SetOperationRules, out, other *core.TypeExpr) { + p1, s1, ok1 := decimalForm(rules, out) + p2, s2, ok2 := decimalForm(rules, other) + if !ok1 || !ok2 { + return + } + scale := max(s1, s2) + precision := max(p1-s1, p2-s2) + scale + if limit := rules.DecimalMaxPrecision; limit > 0 && precision > limit { + scale = max(scale-(precision-limit), 0) + precision = limit + } + out.Args = []core.TypeArg{{Int: &precision}, {Int: &scale}} +} + +func decimalForm(rules *core.SetOperationRules, t *core.TypeExpr) (int64, int64, bool) { + if t.Name == rules.Decimal && len(t.Args) == 2 && t.Args[0].Int != nil && t.Args[1].Int != nil { + return *t.Args[0].Int, *t.Args[1].Int, true + } + if form, ok := rules.AsDecimal[t.Name]; ok { + return form[0], form[1], true + } + return 0, 0, false +} From 002e9f690c2a805ab8d48214ef2ae32676e10f14 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:18:01 -0800 Subject: [PATCH 18/21] docs: explain dialect typing gaps and MSSQL conversion boundaries 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> --- docs/guides/development.md | 39 ++++++++++++++++++++++++++++++++ docs/howto/analyze.md | 39 ++++++++++++++++++++++++++++++++ internal/engine/mssql/convert.go | 20 +++++++++++----- 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/docs/guides/development.md b/docs/guides/development.md index ae5ece2fa6..f4a5d52692 100644 --- a/docs/guides/development.md +++ b/docs/guides/development.md @@ -14,6 +14,45 @@ 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. + +`set_operations.strategy` in a dialect's `dialect.json` explicitly selects an +implemented policy (currently only `"mssql"`). The remaining fields configure +that SQL Server policy; they are not a universal coercion language. Seeds +reject missing or unsupported strategies rather than accidentally applying +SQL Server's formula to another engine. The selector is serialized in the +catalog, so cache restores use the same policy as freshly seeded catalogs. +Omitting `set_operations` preserves the old inference fallback. + +To support another dialect, add its engine-owned policy and explicit +dispatch/seed validation, then opt in its dialect settings and add end-to-end +cases. Do not copy SQL Server's settings into other seeds. Some engines need +the entire candidate list, cast context, domain information or recursive +structured types; extend the policy inputs/orchestration for those semantics +rather than forcing them through pairwise precedence. Set operations and +`CASE`-like expressions need not share one 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 ``` diff --git a/docs/howto/analyze.md b/docs/howto/analyze.md index 09784d2217..a1709947ed 100644 --- a/docs/howto/analyze.md +++ b/docs/howto/analyze.md @@ -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 explicitly enables its type-combination policy 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. + +Other dialects currently 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 diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index b1092011f2..f5d7fea6ac 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -547,6 +547,10 @@ func (c *cc) convertScalarExpression(expr tsql.ScalarExpression) ast.Node { case *tsql.ParenthesisExpression: return c.convertScalarExpression(e.Expression) case *tsql.FunctionCall: + // Methods need receiver/type syntax lowered before signature lookup; + // alternatives need candidate-dependent typing and nullability. + // Keep window calls on the ordinary path so an alternative rewrite + // cannot discard their OVER clause. if out := c.convertMethodCall(e); out != nil { return out } @@ -684,10 +688,10 @@ func (c *cc) convertScalarExpression(expr tsql.ScalarExpression) ast.Node { } // convertAlternatives converts the functions that yield one of their -// arguments, which SQL Server types as the type their candidates combine -// to, as it does CASE: GREATEST and LEAST, and CHOOSE, which is the CASE -// over its index it is defined as. ISNULL, NULL only when both arguments -// are, as COALESCE is, keeps its first argument's type. +// arguments: GREATEST/LEAST combine their candidate types, and CHOOSE becomes +// the CASE over its index. ISNULL keeps its first typed argument's type and +// is NULL only when both arguments are. Fixed JSONL function signatures do +// not describe these AST rewrites or the candidates' combined nullability. func (c *cc) convertAlternatives(e *tsql.FunctionCall) ast.Node { args := &ast.List{} for _, p := range e.Parameters { @@ -790,7 +794,9 @@ type namedParam struct { // typeMethods are the methods of xml, geography, geometry and hierarchyid // a query calls on a value, as doc.exist('/a') or node.GetLevel() do. A // call to one is seeded as ".name" taking the value first, which tells it -// from a function in a schema, as dbo.f(), the parser sees alike. +// from a function in a schema, as dbo.f(), the parser sees alike. This map +// recognizes receiver syntax before catalog lookup and must stay in sync +// with the leading-dot JSONL signatures. var typeMethods = map[string]bool{ "exist": true, "getlevel": true, "tostring": true, "getancestor": true, "getdescendant": true, @@ -894,7 +900,9 @@ func (c *cc) convertFunctionCall(n *tsql.FunctionCall) *ast.FuncCall { return fc } -// datepartFunctions are the functions whose first argument is a datepart. +// datepartFunctions take a bare keyword which the parser sees as a column. +// JSONL signatures describe its string type, not this syntax; dialect +// ident_args describes type arguments, not function arguments. var datepartFunctions = map[string]bool{ "dateadd": true, "datediff": true, From f669ca0ccb3d7844dd667c72eff89584470b0a9a Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:56:02 -0800 Subject: [PATCH 19/21] refactor(core): inject engine-owned type combiners into analysis 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> --- internal/compiler/compile.go | 17 +++ internal/compiler/engine.go | 8 +- internal/compiler/parse_core.go | 2 +- internal/core/analyzer/analyzer.go | 37 ++++-- internal/core/analyzer/expr.go | 35 +++-- internal/core/analyzer/setop.go | 122 ++++++++---------- internal/core/analyzer/typecombiner_test.go | 59 +++++++++ internal/core/dialect.go | 54 +++----- internal/core/seed/seed.go | 17 +-- internal/core/seed/seed_test.go | 35 ----- internal/core/typecombiner.go | 36 ++++++ .../analyze_combined_types/mssql/query.sql | 12 ++ .../analyze_combined_types/mssql/stdout.json | 42 ++++++ internal/engine/mssql/dialect/dialect.json | 1 - internal/engine/mssql/typepolicy/combine.go | 98 ++++++++++++-- .../engine/mssql/typepolicy/combine_test.go | 34 +++++ 16 files changed, 415 insertions(+), 194 deletions(-) create mode 100644 internal/core/analyzer/typecombiner_test.go delete mode 100644 internal/core/seed/seed_test.go create mode 100644 internal/core/typecombiner.go create mode 100644 internal/engine/mssql/typepolicy/combine_test.go diff --git a/internal/compiler/compile.go b/internal/compiler/compile.go index b6f703d1cb..241c0f093a 100644 --- a/internal/compiler/compile.go +++ b/internal/compiler/compile.go @@ -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 { diff --git a/internal/compiler/engine.go b/internal/compiler/engine.go index 959207b12f..f8d774f872 100644 --- a/internal/compiler/engine.go +++ b/internal/compiler/engine.go @@ -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" @@ -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 @@ -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() diff --git a/internal/compiler/parse_core.go b/internal/compiler/parse_core.go index 2e1dfc7042..3b6ddbba2b 100644 --- a/internal/compiler/parse_core.go +++ b/internal/compiler/parse_core.go @@ -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 } diff --git a/internal/core/analyzer/analyzer.go b/internal/core/analyzer/analyzer.go index dff30966fd..f3d2d84ad1 100644 --- a/internal/core/analyzer/analyzer.go +++ b/internal/core/analyzer/analyzer.go @@ -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 } @@ -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 { @@ -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. @@ -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 @@ -283,8 +294,8 @@ func (a *analyzer) analyzeSetOperation(s *ast.SelectStmt) error { } a.columns = left.columns a.scope = left.scope - if rules := a.cat.SetOperations(); rules != nil { - a.columns, err = a.combineColumns(rules, s.Op, left.columns, right.columns) + if a.combiner != nil { + a.columns, err = a.combineColumns(s.Op, left.columns, right.columns) if err != nil { return err } diff --git a/internal/core/analyzer/expr.go b/internal/core/analyzer/expr.go index 1667955b23..1434ea78b3 100644 --- a/internal/core/analyzer/expr.go +++ b/internal/core/analyzer/expr.go @@ -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) @@ -481,7 +485,7 @@ 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 } @@ -489,8 +493,7 @@ func (a *analyzer) typeCase(e *ast.CaseExpr) (exprType, error) { } // typeCoalesce types COALESCE, which is null only when every argument is, -// and of the type its arguments combine to, or its first typed argument's -// for SQL Server's ISNULL. +// and uses a common type unless the AST requests the first typed argument. func (a *analyzer) typeCoalesce(e *ast.CoalesceExpr) (exprType, error) { types := make([]exprType, 0, len(listItems(e.Args))) nullable := true @@ -502,18 +505,14 @@ func (a *analyzer) typeCoalesce(e *ast.CoalesceExpr) (exprType, error) { types = append(types, t) nullable = nullable && t.nullable } - t, err := a.commonType(types) - if err != nil { - return exprType{}, err - } - // Only ISNULL's converter sets FirstArgType. Ordinary COALESCE keeps - // the common type selected above, rather than its first argument's. + var t exprType if e.FirstArgType { - for _, first := range types { - if first.typeOID != 0 || first.expr != nil { - t = first - break - } + t = firstTyped(types) + } else { + var err error + t, err = a.commonType(core.CombineCoalesce, types) + if err != nil { + return exprType{}, err } } // A placeholder there is one that may be NULL. @@ -526,7 +525,7 @@ func (a *analyzer) typeCoalesce(e *ast.CoalesceExpr) (exprType, error) { // 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(nodes []ast.Node, nullable bool) (exprType, error) { +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) @@ -536,7 +535,7 @@ func (a *analyzer) typeFirstOf(nodes []ast.Node, nullable bool) (exprType, error types = append(types, t) nullable = nullable || t.nullable } - out, err := a.commonType(types) + out, err := a.commonType(context, types) if err != nil { return exprType{}, err } @@ -560,7 +559,7 @@ func (a *analyzer) inferAlternatives(nodes []ast.Node, t exprType, nullable bool // 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 } diff --git a/internal/core/analyzer/setop.go b/internal/core/analyzer/setop.go index 1bf4e870ad..ca32f53d1d 100644 --- a/internal/core/analyzer/setop.go +++ b/internal/core/analyzer/setop.go @@ -1,10 +1,7 @@ package analyzer import ( - "fmt" - "github.com/sqlc-dev/sqlc/internal/core" - mssqltypes "github.com/sqlc-dev/sqlc/internal/engine/mssql/typepolicy" "github.com/sqlc-dev/sqlc/internal/sql/ast" ) @@ -13,7 +10,14 @@ import ( // and is read from no one table. It may be NULL in a UNION when either // branch's may, in an INTERSECT when both may, and in an EXCEPT when the // first branch's may, since every row it returns is one of those. -func (a *analyzer) combineColumns(rules *core.SetOperationRules, op ast.SetOperation, left, right []core.Column) ([]core.Column, error) { +func (a *analyzer) combineColumns(op ast.SetOperation, left, right []core.Column) ([]core.Column, error) { + context := core.CombineUnion + switch op { + case ast.Intersect: + context = core.CombineIntersect + case ast.Except: + context = core.CombineExcept + } out := make([]core.Column, len(left)) for i, l := range left { if i >= len(right) { @@ -28,10 +32,14 @@ func (a *analyzer) combineColumns(rules *core.SetOperationRules, op ast.SetOpera case ast.Except: col.NotNull = l.NotNull } - t, err := a.combineTypes(rules, columnExprType(l), columnExprType(r)) + t, handled, err := a.combineTypes(context, []exprType{columnExprType(l), columnExprType(r)}) if err != nil { return nil, err } + if !handled { + out[i] = l + continue + } t.nullable = !col.NotNull col.TypeOID = t.typeOID col.DataType, col.IsArray = a.typeNameOf(t) @@ -41,83 +49,65 @@ func (a *analyzer) combineColumns(rules *core.SetOperationRules, op ast.SetOpera return out, nil } -// combineTypes delegates type selection and modifiers to the selected policy, -// then resolves its result against the catalog. No policy is enabled merely -// by listing a dialect's types. -func (a *analyzer) combineTypes(rules *core.SetOperationRules, l, r exprType) (exprType, error) { - lt, rt := a.exprOf(l), a.exprOf(r) - switch { - case lt == nil: - return r, nil - case rt == nil: - return l, nil +// combineTypes delegates selection and modifiers to engine-supplied behavior. +// The whole list is passed without discarding untyped candidate positions. +func (a *analyzer) combineTypes(context core.TypeCombinationContext, types []exprType) (exprType, bool, error) { + if a.combiner == nil { + return exprType{}, false, nil } - var out *core.TypeExpr - var ok bool - switch rules.Strategy { - case "mssql": - out, ok = mssqltypes.Combine(rules, lt, rt, a.sameCategory(lt.Name, rt.Name)) - default: - return exprType{}, fmt.Errorf("unsupported type-combination strategy %q", rules.Strategy) + candidates := make([]*core.TypeExpr, len(types)) + typed := 0 + for i, t := range types { + candidates[i] = a.exprOf(t) + if candidates[i] != nil { + typed++ + } + } + result, err := a.combiner.Combine(core.TypeCombinationRequest{Context: context, Candidates: candidates}) + if err != nil { + return exprType{}, false, err + } + if !result.Handled { + return exprType{}, false, nil } - if !ok { - return l, nil + out := result.Type + if out == nil { + return exprType{}, true, nil + } + // Preserve the existing column provenance when only the first candidate + // is typed and the combiner leaves its type unchanged. + if typed == 1 && candidates[0] != nil && out.Key() == candidates[0].Key() { + return types[0], true, nil } // A type with no arguments is its family. Looked up as a declaration // it would take the dialect's defaults, as varchar is varchar(1). if len(out.Args) == 0 { if oid, err := a.cat.TypeOID(out.Name); err == nil { - return exprType{typeOID: oid}, nil + return exprType{typeOID: oid}, true, nil } } - return a.lookupType(out), nil + return a.lookupType(out), true, nil +} + +// commonType retains first-typed-value inference when no combiner handles +// the context. This compatibility fallback is not database coercion semantics. +func (a *analyzer) commonType(context core.TypeCombinationContext, types []exprType) (exprType, error) { + out, handled, err := a.combineTypes(context, types) + if err != nil || handled { + return out, err + } + return firstTyped(types), nil } -// commonType uses the enabled policy for alternative-valued expressions. -// Currently only MSSQL combines these with its set-operation rules. Other -// dialects retain the first typed value; this is a compatibility fallback, -// not a claim that their databases use the same rules. -func (a *analyzer) commonType(types []exprType) (exprType, error) { - var out exprType - rules := a.cat.SetOperations() +func firstTyped(types []exprType) exprType { for i, t := range types { if t.typeOID == 0 && t.expr == nil { continue } - switch { - case out.typeOID == 0 && out.expr == nil: - out = t - case rules == nil: - return out, nil - default: - var err error - out, err = a.combineTypes(rules, out, t) - if err != nil { - return exprType{}, err - } - } if i > 0 { - out.sourceClassOID, out.sourceAttributeOID, out.sourceTableAlias = 0, 0, "" - } - } - return out, nil -} - -// sameCategory reports whether two types are of one category, so that the -// length of one says something about the other: the 20 of varchar(20) -// widens an nvarchar(10), but not a datetime2(3). -func (a *analyzer) sameCategory(x, y string) bool { - category := func(name string) string { - oid, err := a.cat.TypeOID(name) - if err != nil { - return "" - } - t, err := a.cat.LookupType(a.familyOID(oid)) - if err != nil { - return "" + t.sourceClassOID, t.sourceAttributeOID, t.sourceTableAlias = 0, 0, "" } - return t.Category + return t } - cx := category(x) - return cx != "" && cx == category(y) + return exprType{} } diff --git a/internal/core/analyzer/typecombiner_test.go b/internal/core/analyzer/typecombiner_test.go new file mode 100644 index 0000000000..81d93c783e --- /dev/null +++ b/internal/core/analyzer/typecombiner_test.go @@ -0,0 +1,59 @@ +package analyzer + +import ( + "errors" + "testing" + + "github.com/sqlc-dev/sqlc/internal/core" +) + +type combinerFunc func(core.TypeCombinationRequest) (core.TypeCombinationResult, error) + +func (f combinerFunc) Combine(req core.TypeCombinationRequest) (core.TypeCombinationResult, error) { + return f(req) +} + +// A deliberately declining/failing engine cannot be selected through the CLI. +func TestTypeCombinerContract(t *testing.T) { + failure := errors.New("policy failure") + for _, name := range []string{"absent", "declined", "untyped result", "error"} { + t.Run(name, func(t *testing.T) { + types := []exprType{{}, {expr: core.ParseTypeExpr("integer")}, {expr: core.ParseTypeExpr("text")}} + a := &analyzer{} + if name != "absent" { + a.combiner = combinerFunc(func(req core.TypeCombinationRequest) (core.TypeCombinationResult, error) { + if req.Context != core.CombineCase || len(req.Candidates) != 3 || + req.Candidates[0] != nil || req.Candidates[1].Name != "integer" || + req.Candidates[2].Name != "text" { + t.Fatalf("candidate context/order not preserved: %+v", req) + } + switch name { + case "error": + return core.TypeCombinationResult{}, failure + case "untyped result": + return core.TypeCombinationResult{Handled: true}, nil + default: + return core.TypeCombinationResult{}, nil + } + }) + } + got, err := a.commonType(core.CombineCase, types) + if name == "error" { + if !errors.Is(err, failure) { + t.Fatalf("got %v, want policy failure", err) + } + return + } + if err != nil { + t.Fatal(err) + } + if name == "untyped result" { + if got.typeOID != 0 || got.expr != nil { + t.Fatalf("handled untyped result fell back to %+v", got) + } + } else if got.expr == nil || got.expr.Name != "integer" { + t.Fatalf("first-typed fallback changed: %+v", got) + } + }) + } +} diff --git a/internal/core/dialect.go b/internal/core/dialect.go index 78ae79192e..062a84778d 100644 --- a/internal/core/dialect.go +++ b/internal/core/dialect.go @@ -2,7 +2,9 @@ package core import ( "context" + "database/sql" "encoding/json" + "errors" "fmt" "slices" "strings" @@ -78,8 +80,8 @@ const FlagPropagateNullable = "functions.propagate_nullable" // e.id. const FlagQualifyDuplicateColumns = "columns.qualify_duplicates" -// FlagSetOperations holds, as JSON, the SetOperationRules a dialect types -// the columns of a set operation by. +// FlagSetOperations holds opaque JSON interpreted by the dialect's type +// combiner. Shared catalog/seeding code does not define its schema. const FlagSetOperations = "set_operations" // FlagDefaultSchema holds the schema the dialect puts an unqualified object @@ -201,45 +203,23 @@ func (c *Catalog) QualifiesDuplicateColumns() bool { return v == "true" } -// SetOperationRules select a dialect's type-combination policy. A dialect -// without one keeps the analyzer's first-branch/first-typed-value behavior; -// that fallback is not a model of the database's coercion rules. -type SetOperationRules struct { - // Strategy selects an implemented policy, currently "mssql". Other - // engines need their own policy, not just another precedence list. - Strategy string `json:"strategy"` - - // The remaining settings parameterize the MSSQL policy. - // Precedence lists type families, highest first: the column takes the - // highest of its branches'. - Precedence []string `json:"precedence"` - // Varying maps a fixed-length family to its varying one, which the - // column takes when another branch varies: char and varchar make - // varchar. - Varying map[string]string `json:"varying,omitempty"` - // Decimal names the exact numeric family, whose precision and scale - // cover every branch's, up to DecimalMaxPrecision, which scale gives - // way to. AsDecimal is the precision and scale another family counts - // as. - Decimal string `json:"decimal,omitempty"` - DecimalMaxPrecision int64 `json:"decimal_max_precision,omitempty"` - AsDecimal map[string][2]int64 `json:"as_decimal,omitempty"` -} - -// SetOperations returns the dialect's set operation rules, or nil. -func (c *Catalog) SetOperations() *SetOperationRules { +// TypeCombinationData returns the dialect-owned configuration, or nil when +// the dialect has none. It is persisted with the catalog, not runtime behavior. +func (c *Catalog) TypeCombinationData() (json.RawMessage, error) { if c.dialectOID == 0 { - return nil + return nil, nil } - v, _ := c.DialectFlag(c.dialectOID, FlagSetOperations) - if v == "" { - return nil + v, err := c.q.DialectFlag(context.Background(), catalogdb.DialectFlagParams{ + DialectOid: c.dialectOID, + Key: FlagSetOperations, + }) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil } - var rules SetOperationRules - if err := json.Unmarshal([]byte(v), &rules); err != nil { - return nil + if err != nil { + return nil, fmt.Errorf("read type-combination data: %w", err) } - return &rules + return json.RawMessage(v), nil } // DefaultNamespaces lists the namespaces a type is reported from without diff --git a/internal/core/seed/seed.go b/internal/core/seed/seed.go index e544384b0d..ef076e6920 100644 --- a/internal/core/seed/seed.go +++ b/internal/core/seed/seed.go @@ -84,9 +84,9 @@ type Settings struct { // ClickHouse names the second id of a join e.id. QualifyDuplicateColumns bool `json:"qualify_duplicate_columns,omitempty"` - // SetOperations types a column of a set operation from every branch, - // rather than from the first. - SetOperations *core.SetOperationRules `json:"set_operations,omitempty"` + // SetOperations is opaque configuration for an engine-owned type + // combiner. Only that engine interprets and validates its contents. + SetOperations json.RawMessage `json:"set_operations,omitempty"` // Comparison operators are registered as (T, T) -> Bool for every type in // ComparisonCategories. @@ -370,9 +370,6 @@ func loadSettings(fsys fs.FS) (Settings, error) { if settings.Dialect == "" { return Settings{}, fmt.Errorf("seed: %s: dialect has no name", SettingsFile) } - if rules := settings.SetOperations; rules != nil && rules.Strategy != "mssql" { - return Settings{}, fmt.Errorf("seed: %s: unsupported type-combination strategy %q", SettingsFile, rules.Strategy) - } settings.fsys = fsys return settings, nil } @@ -667,12 +664,8 @@ func (b *builder) consts() error { return err } } - if b.settings.SetOperations != nil { - rules, err := json.Marshal(b.settings.SetOperations) - if err != nil { - return err - } - if err := b.cat.SetDialectFlag(b.dialectOID, core.FlagSetOperations, string(rules)); err != nil { + if len(b.settings.SetOperations) > 0 { + if err := b.cat.SetDialectFlag(b.dialectOID, core.FlagSetOperations, string(b.settings.SetOperations)); err != nil { return err } } diff --git a/internal/core/seed/seed_test.go b/internal/core/seed/seed_test.go deleted file mode 100644 index eb17b1dc8f..0000000000 --- a/internal/core/seed/seed_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package seed - -import ( - "strings" - "testing" - "testing/fstest" -) - -// Dialect seeds are embedded, so the CLI cannot supply invalid policy metadata. -func TestTypeCombinationStrategy(t *testing.T) { - tests := []struct { - name string - settings string - wantErr string - }{ - {"disabled", `{"dialect":"postgresql"}`, ""}, - {"mssql", `{"dialect":"mssql","set_operations":{"strategy":"mssql"}}`, ""}, - {"missing", `{"dialect":"mssql","set_operations":{}}`, `unsupported type-combination strategy ""`}, - {"unknown", `{"dialect":"mssql","set_operations":{"strategy":"unknown"}}`, `unsupported type-combination strategy "unknown"`}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := loadSettings(fstest.MapFS{ - SettingsFile: &fstest.MapFile{Data: []byte(tt.settings)}, - }) - if tt.wantErr == "" { - if err != nil { - t.Fatal(err) - } - } else if err == nil || !strings.Contains(err.Error(), tt.wantErr) { - t.Fatalf("got %v, want error containing %q", err, tt.wantErr) - } - }) - } -} diff --git a/internal/core/typecombiner.go b/internal/core/typecombiner.go new file mode 100644 index 0000000000..b5453ff12e --- /dev/null +++ b/internal/core/typecombiner.go @@ -0,0 +1,36 @@ +package core + +// TypeCombinationContext distinguishes constructs whose coercion rules may +// differ even within one dialect. +type TypeCombinationContext string + +const ( + CombineUnion TypeCombinationContext = "union" + CombineIntersect TypeCombinationContext = "intersect" + CombineExcept TypeCombinationContext = "except" + CombineCase TypeCombinationContext = "case" + CombineCoalesce TypeCombinationContext = "coalesce" + CombineGreatest TypeCombinationContext = "greatest" + CombineLeast TypeCombinationContext = "least" + CombineArray TypeCombinationContext = "array" +) + +type TypeCombinationRequest struct { + Context TypeCombinationContext + // Candidates are in expression order, with nil for an untyped value. + // Top-level result nullability is handled by the analyzer. + Candidates []*TypeExpr +} + +type TypeCombinationResult struct { + // Handled distinguishes an untyped result from an unsupported context. + // When false, the analyzer retains its existing inference behavior. + Handled bool + Type *TypeExpr +} + +// TypeCombiner is supplied by a dialect, not selected by shared analysis. +// Implementations must support concurrent calls and not mutate their inputs. +type TypeCombiner interface { + Combine(TypeCombinationRequest) (TypeCombinationResult, error) +} diff --git a/internal/endtoend/testdata/analyze_combined_types/mssql/query.sql b/internal/endtoend/testdata/analyze_combined_types/mssql/query.sql index 09290a7263..aead99fc6d 100644 --- a/internal/endtoend/testdata/analyze_combined_types/mssql/query.sql +++ b/internal/endtoend/testdata/analyze_combined_types/mssql/query.sql @@ -8,3 +8,15 @@ SELECT GREATEST(i, d52) AS greatest_of, ISNULL(i, d52) AS isnull_int, ISNULL(n10, c3) AS isnull_text FROM t; + +-- name: NestedCombined :many +SELECT n.value +FROM (SELECT GREATEST(i, d52) AS value FROM t) AS n; + +-- name: CTECombined :many +WITH combined AS ( + SELECT d52 AS value FROM t + UNION ALL + SELECT i FROM t +) +SELECT value FROM combined; diff --git a/internal/endtoend/testdata/analyze_combined_types/mssql/stdout.json b/internal/endtoend/testdata/analyze_combined_types/mssql/stdout.json index 36ac64c6cf..f14688123c 100644 --- a/internal/endtoend/testdata/analyze_combined_types/mssql/stdout.json +++ b/internal/endtoend/testdata/analyze_combined_types/mssql/stdout.json @@ -118,5 +118,47 @@ } } ] + }, + { + "name": "NestedCombined", + "cmd": ":many", + "columns": [ + { + "name": "value", + "type": { + "name": "decimal", + "args": [ + { + "int": 12 + }, + { + "int": 2 + } + ] + } + } + ], + "params": [] + }, + { + "name": "CTECombined", + "cmd": ":many", + "columns": [ + { + "name": "value", + "type": { + "name": "decimal", + "args": [ + { + "int": 12 + }, + { + "int": 2 + } + ] + } + } + ], + "params": [] } ] diff --git a/internal/engine/mssql/dialect/dialect.json b/internal/engine/mssql/dialect/dialect.json index 78223ad721..bd05f86922 100644 --- a/internal/engine/mssql/dialect/dialect.json +++ b/internal/engine/mssql/dialect/dialect.json @@ -9,7 +9,6 @@ }, "bool": "bit", "set_operations": { - "strategy": "mssql", "precedence": [ "sql_variant", "xml", "datetimeoffset", "datetime2", "datetime", "smalldatetime", "date", "time", "float", "real", "decimal", "money", diff --git a/internal/engine/mssql/typepolicy/combine.go b/internal/engine/mssql/typepolicy/combine.go index cb0b6ca022..9a48575053 100644 --- a/internal/engine/mssql/typepolicy/combine.go +++ b/internal/engine/mssql/typepolicy/combine.go @@ -3,18 +3,81 @@ package typepolicy import ( + "bytes" + "encoding/json" + "fmt" "slices" "github.com/sqlc-dev/sqlc/internal/core" ) -// Combine applies SQL Server's precedence and set-operation type modifiers. -// sameCategory says whether lengths/precisions of the two families are -// comparable. An unlisted family leaves type selection to the caller. -func Combine(rules *core.SetOperationRules, left, right *core.TypeExpr, sameCategory bool) (*core.TypeExpr, bool) { +type rules struct { + Precedence []string `json:"precedence"` + Varying map[string]string `json:"varying,omitempty"` + Decimal string `json:"decimal,omitempty"` + DecimalMaxPrecision int64 `json:"decimal_max_precision,omitempty"` + AsDecimal map[string][2]int64 `json:"as_decimal,omitempty"` +} + +type combiner struct { + cat *core.Catalog + rules rules +} + +// New binds SQL Server's policy to a freshly seeded or restored catalog. +func New(cat *core.Catalog) (core.TypeCombiner, error) { + data, err := cat.TypeCombinationData() + if err != nil { + return nil, err + } + rules, err := decodeRules(data) + if err != nil { + return nil, fmt.Errorf("mssql: type-combination rules: %w", err) + } + return &combiner{cat: cat, rules: rules}, nil +} + +func decodeRules(data []byte) (rules, error) { + var out rules + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&out); err != nil { + return rules{}, err + } + if len(out.Precedence) == 0 { + return rules{}, fmt.Errorf("precedence must not be empty") + } + return out, nil +} + +func (p *combiner) Combine(req core.TypeCombinationRequest) (core.TypeCombinationResult, error) { + switch req.Context { + case core.CombineUnion, core.CombineIntersect, core.CombineExcept, + core.CombineCase, core.CombineCoalesce, core.CombineGreatest, core.CombineLeast: + default: + return core.TypeCombinationResult{}, nil + } + var out *core.TypeExpr + for _, candidate := range req.Candidates { + if candidate == nil { + continue + } + if out == nil { + out = candidate + } else { + out = p.combine(out, candidate) + } + } + return core.TypeCombinationResult{Handled: true, Type: out.WithNullable(false)}, nil +} + +// combine applies SQL Server's precedence and set-operation type modifiers. +// An unlisted family retains the left type, as the existing inference did. +func (p *combiner) combine(left, right *core.TypeExpr) *core.TypeExpr { + rules := &p.rules li, ri := slices.Index(rules.Precedence, left.Name), slices.Index(rules.Precedence, right.Name) if li < 0 || ri < 0 { - return nil, false + return left } winner, loser := left, right if ri < li { @@ -32,10 +95,27 @@ func Combine(rules *core.SetOperationRules, left, right *core.TypeExpr, sameCate switch { case out.Name == rules.Decimal && rules.Decimal != "": combineDecimal(rules, out, loser) - case len(out.Args) == 1 && len(loser.Args) == 1 && sameCategory: + case len(out.Args) == 1 && len(loser.Args) == 1 && p.sameCategory(left.Name, right.Name): out.Args[0] = widerArg(out.Args[0], loser.Args[0]) } - return out, true + return out +} + +func (p *combiner) sameCategory(x, y string) bool { + category := func(name string) string { + oid, err := p.cat.TypeOID(name) + if err != nil { + return "" + } + chain := p.cat.ResolutionChain(oid) + t, err := p.cat.LookupType(chain[len(chain)-1]) + if err != nil { + return "" + } + return t.Category + } + cx := category(x) + return cx != "" && cx == category(y) } // widerArg is the larger of two lengths or precisions, MAX above any. @@ -54,7 +134,7 @@ func widerArg(a, b core.TypeArg) core.TypeArg { // combineDecimal applies SQL Server's UNION/INTERSECT/EXCEPT formula: // retain the larger scale and integer-digit count, reducing scale at the // precision limit. This is not a cross-engine decimal-coercion rule. -func combineDecimal(rules *core.SetOperationRules, out, other *core.TypeExpr) { +func combineDecimal(rules *rules, out, other *core.TypeExpr) { p1, s1, ok1 := decimalForm(rules, out) p2, s2, ok2 := decimalForm(rules, other) if !ok1 || !ok2 { @@ -69,7 +149,7 @@ func combineDecimal(rules *core.SetOperationRules, out, other *core.TypeExpr) { out.Args = []core.TypeArg{{Int: &precision}, {Int: &scale}} } -func decimalForm(rules *core.SetOperationRules, t *core.TypeExpr) (int64, int64, bool) { +func decimalForm(rules *rules, t *core.TypeExpr) (int64, int64, bool) { if t.Name == rules.Decimal && len(t.Args) == 2 && t.Args[0].Int != nil && t.Args[1].Int != nil { return *t.Args[0].Int, *t.Args[1].Int, true } diff --git a/internal/engine/mssql/typepolicy/combine_test.go b/internal/engine/mssql/typepolicy/combine_test.go new file mode 100644 index 0000000000..9820315b9f --- /dev/null +++ b/internal/engine/mssql/typepolicy/combine_test.go @@ -0,0 +1,34 @@ +package typepolicy + +import ( + "strings" + "testing" +) + +// Embedded dialect metadata cannot be replaced through the CLI. +func TestDecodeRules(t *testing.T) { + tests := []struct { + name string + data string + want string + }{ + {"missing", "", "EOF"}, + {"empty", "{}", "precedence must not be empty"}, + {"null", "null", "precedence must not be empty"}, + {"unknown field", `{"strategy":"mssql","precedence":["int"]}`, `unknown field "strategy"`}, + {"invalid shape", `{"precedence":"int"}`, "cannot unmarshal"}, + {"valid", `{"precedence":["int"]}`, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := decodeRules([]byte(tt.data)) + if tt.want == "" { + if err != nil { + t.Fatal(err) + } + } else if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("got %v, want error containing %q", err, tt.want) + } + }) + } +} From 91aab3b0e1e939f995a429a9ef8750a5c996c565 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:56:02 -0800 Subject: [PATCH 20/21] docs: describe engine-supplied type combination and cache wiring 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> --- docs/guides/development.md | 43 ++++++++++++++++++++++++-------------- docs/howto/analyze.md | 4 ++-- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/docs/guides/development.md b/docs/guides/development.md index f4a5d52692..110ca080fb 100644 --- a/docs/guides/development.md +++ b/docs/guides/development.md @@ -21,22 +21,33 @@ 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. -`set_operations.strategy` in a dialect's `dialect.json` explicitly selects an -implemented policy (currently only `"mssql"`). The remaining fields configure -that SQL Server policy; they are not a universal coercion language. Seeds -reject missing or unsupported strategies rather than accidentally applying -SQL Server's formula to another engine. The selector is serialized in the -catalog, so cache restores use the same policy as freshly seeded catalogs. -Omitting `set_operations` preserves the old inference fallback. - -To support another dialect, add its engine-owned policy and explicit -dispatch/seed validation, then opt in its dialect settings and add end-to-end -cases. Do not copy SQL Server's settings into other seeds. Some engines need -the entire candidate list, cast context, domain information or recursive -structured types; extend the policy inputs/orchestration for those semantics -rather than forcing them through pairwise precedence. Set operations and -`CASE`-like expressions need not share one algorithm. The current cross-engine -gaps and database references are in +`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. + +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 diff --git a/docs/howto/analyze.md b/docs/howto/analyze.md index a1709947ed..e1c629fb04 100644 --- a/docs/howto/analyze.md +++ b/docs/howto/analyze.md @@ -22,12 +22,12 @@ case. ## Common-type inference The shared core does not give every database the same coercion rules. SQL -Server explicitly enables its type-combination policy for set operations and +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. -Other dialects currently keep the existing core fallback: a set-operation +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 From 41a769124ac553146eaa9d9c0a632e9a160fac9a Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:11:43 -0800 Subject: [PATCH 21/21] refactor(mssql): keep ISNULL type selection in the engine policy 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> --- docs/guides/development.md | 8 ++ internal/core/analyzer/expr.go | 25 ++--- internal/core/analyzer/setop.go | 11 ++- internal/core/analyzer/typecombiner_test.go | 8 +- internal/core/typecombiner.go | 4 + .../testdata/analyze_isnull/mssql/query.sql | 8 ++ .../testdata/analyze_isnull/mssql/stdout.json | 94 +++++++++++++++++++ internal/engine/mssql/convert.go | 2 +- internal/engine/mssql/parse_test.go | 29 ++++++ internal/engine/mssql/typepolicy/combine.go | 13 +++ internal/sql/ast/coalesce_expr.go | 13 ++- internal/sql/astutils/rewrite.go | 1 + internal/sql/astutils/walk.go | 3 + 13 files changed, 196 insertions(+), 23 deletions(-) create mode 100644 internal/engine/mssql/parse_test.go diff --git a/docs/guides/development.md b/docs/guides/development.md index 110ca080fb..96757389df 100644 --- a/docs/guides/development.md +++ b/docs/guides/development.md @@ -28,6 +28,14 @@ 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, diff --git a/internal/core/analyzer/expr.go b/internal/core/analyzer/expr.go index 1434ea78b3..c50032e602 100644 --- a/internal/core/analyzer/expr.go +++ b/internal/core/analyzer/expr.go @@ -492,8 +492,8 @@ func (a *analyzer) typeCase(e *ast.CaseExpr) (exprType, error) { return t, nil } -// typeCoalesce types COALESCE, which is null only when every argument is, -// and uses a common type unless the AST requests the first typed argument. +// 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) { types := make([]exprType, 0, len(listItems(e.Args))) nullable := true @@ -505,15 +505,18 @@ func (a *analyzer) typeCoalesce(e *ast.CoalesceExpr) (exprType, error) { types = append(types, t) nullable = nullable && t.nullable } - var t exprType - if e.FirstArgType { - t = firstTyped(types) - } else { - var err error - t, err = a.commonType(core.CombineCoalesce, types) - if err != nil { - return exprType{}, err + 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) @@ -535,7 +538,7 @@ func (a *analyzer) typeFirstOf(context core.TypeCombinationContext, nodes []ast. types = append(types, t) nullable = nullable || t.nullable } - out, err := a.commonType(context, types) + out, err := a.commonType(core.TypeCombinationRequest{Context: context}, types) if err != nil { return exprType{}, err } diff --git a/internal/core/analyzer/setop.go b/internal/core/analyzer/setop.go index ca32f53d1d..e9665c9919 100644 --- a/internal/core/analyzer/setop.go +++ b/internal/core/analyzer/setop.go @@ -32,7 +32,7 @@ func (a *analyzer) combineColumns(op ast.SetOperation, left, right []core.Column case ast.Except: col.NotNull = l.NotNull } - t, handled, err := a.combineTypes(context, []exprType{columnExprType(l), columnExprType(r)}) + t, handled, err := a.combineTypes(core.TypeCombinationRequest{Context: context}, []exprType{columnExprType(l), columnExprType(r)}) if err != nil { return nil, err } @@ -51,7 +51,7 @@ func (a *analyzer) combineColumns(op ast.SetOperation, left, right []core.Column // combineTypes delegates selection and modifiers to engine-supplied behavior. // The whole list is passed without discarding untyped candidate positions. -func (a *analyzer) combineTypes(context core.TypeCombinationContext, types []exprType) (exprType, bool, error) { +func (a *analyzer) combineTypes(req core.TypeCombinationRequest, types []exprType) (exprType, bool, error) { if a.combiner == nil { return exprType{}, false, nil } @@ -63,7 +63,8 @@ func (a *analyzer) combineTypes(context core.TypeCombinationContext, types []exp typed++ } } - result, err := a.combiner.Combine(core.TypeCombinationRequest{Context: context, Candidates: candidates}) + req.Candidates = candidates + result, err := a.combiner.Combine(req) if err != nil { return exprType{}, false, err } @@ -91,8 +92,8 @@ func (a *analyzer) combineTypes(context core.TypeCombinationContext, types []exp // commonType retains first-typed-value inference when no combiner handles // the context. This compatibility fallback is not database coercion semantics. -func (a *analyzer) commonType(context core.TypeCombinationContext, types []exprType) (exprType, error) { - out, handled, err := a.combineTypes(context, types) +func (a *analyzer) commonType(req core.TypeCombinationRequest, types []exprType) (exprType, error) { + out, handled, err := a.combineTypes(req, types) if err != nil || handled { return out, err } diff --git a/internal/core/analyzer/typecombiner_test.go b/internal/core/analyzer/typecombiner_test.go index 81d93c783e..654c1d1e15 100644 --- a/internal/core/analyzer/typecombiner_test.go +++ b/internal/core/analyzer/typecombiner_test.go @@ -22,7 +22,9 @@ func TestTypeCombinerContract(t *testing.T) { a := &analyzer{} if name != "absent" { a.combiner = combinerFunc(func(req core.TypeCombinationRequest) (core.TypeCombinationResult, error) { - if req.Context != core.CombineCase || len(req.Candidates) != 3 || + if req.Context != core.CombineCoalesce || len(req.FunctionName) != 2 || + req.FunctionName[0] != "schema" || req.FunctionName[1] != "fn" || + len(req.Candidates) != 3 || req.Candidates[0] != nil || req.Candidates[1].Name != "integer" || req.Candidates[2].Name != "text" { t.Fatalf("candidate context/order not preserved: %+v", req) @@ -37,7 +39,9 @@ func TestTypeCombinerContract(t *testing.T) { } }) } - got, err := a.commonType(core.CombineCase, types) + got, err := a.commonType(core.TypeCombinationRequest{ + Context: core.CombineCoalesce, FunctionName: []string{"schema", "fn"}, + }, types) if name == "error" { if !errors.Is(err, failure) { t.Fatalf("got %v, want policy failure", err) diff --git a/internal/core/typecombiner.go b/internal/core/typecombiner.go index b5453ff12e..e4fa8d39e2 100644 --- a/internal/core/typecombiner.go +++ b/internal/core/typecombiner.go @@ -17,6 +17,10 @@ const ( type TypeCombinationRequest struct { Context TypeCombinationContext + // FunctionName preserves a lowered function's identity as qualified name + // components, not a result-type instruction. Nil means the ordinary + // construct identified by Context. + FunctionName []string // Candidates are in expression order, with nil for an untyped value. // Top-level result nullability is handled by the analyzer. Candidates []*TypeExpr diff --git a/internal/endtoend/testdata/analyze_isnull/mssql/query.sql b/internal/endtoend/testdata/analyze_isnull/mssql/query.sql index 561b7bece7..8694f979cf 100644 --- a/internal/endtoend/testdata/analyze_isnull/mssql/query.sql +++ b/internal/endtoend/testdata/analyze_isnull/mssql/query.sql @@ -5,3 +5,11 @@ SELECT id, ISNULL(amount, 0) AS amount FROM readings WHERE ISNULL(note, raw) = @value; + +-- name: FirstArgumentTypes :many +SELECT ISNULL(raw, CAST(note AS NVARCHAR(40))) AS first_text, + COALESCE(raw, CAST(note AS NVARCHAR(40))) AS combined_text, + ISNULL(NULL, raw) AS literal_null, + ISNULL(@fallback, raw) AS placeholder_first, + ISNULL(note, @replacement) AS placeholder_second +FROM readings; diff --git a/internal/endtoend/testdata/analyze_isnull/mssql/stdout.json b/internal/endtoend/testdata/analyze_isnull/mssql/stdout.json index 60250d2562..b063e27452 100644 --- a/internal/endtoend/testdata/analyze_isnull/mssql/stdout.json +++ b/internal/endtoend/testdata/analyze_isnull/mssql/stdout.json @@ -64,5 +64,99 @@ } } ] + }, + { + "name": "FirstArgumentTypes", + "cmd": ":many", + "columns": [ + { + "name": "first_text", + "type": { + "name": "varchar", + "args": [ + { + "int": 10 + } + ] + } + }, + { + "name": "combined_text", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 40 + } + ] + } + }, + { + "name": "literal_null", + "type": { + "name": "varchar", + "args": [ + { + "int": 10 + } + ] + } + }, + { + "name": "placeholder_first", + "type": { + "name": "varchar", + "args": [ + { + "int": 10 + } + ] + } + }, + { + "name": "placeholder_second", + "type": { + "name": "varchar", + "nullable": true, + "args": [ + { + "int": 10 + } + ] + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "fallback", + "type": { + "name": "varchar", + "nullable": true, + "args": [ + { + "int": 10 + } + ] + } + } + }, + { + "number": 2, + "column": { + "name": "replacement", + "type": { + "name": "varchar", + "nullable": true, + "args": [ + { + "int": 10 + } + ] + } + } + } + ] } ] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index f5d7fea6ac..d9a5b95941 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -700,7 +700,7 @@ func (c *cc) convertAlternatives(e *tsql.FunctionCall) ast.Node { switch identifierValue(e.FunctionName) { case "isnull": if len(args.Items) == 2 { - return &ast.CoalesceExpr{Args: args, FirstArgType: true, Location: c.loc(e)} + return &ast.CoalesceExpr{Args: args, Func: &ast.FuncName{Name: "isnull"}, Location: c.loc(e)} } case "greatest": return &ast.MinMaxExpr{Op: 0, Args: args, Location: c.loc(e)} diff --git a/internal/engine/mssql/parse_test.go b/internal/engine/mssql/parse_test.go new file mode 100644 index 0000000000..a9ae707c14 --- /dev/null +++ b/internal/engine/mssql/parse_test.go @@ -0,0 +1,29 @@ +package mssql + +import ( + "strings" + "testing" + + "github.com/sqlc-dev/sqlc/internal/sql/ast" +) + +// The fmt CLI does not support MSSQL, so exercise its AST printer directly. +func TestLoweredFunctionFormatting(t *testing.T) { + parser := NewParser() + for _, name := range []string{"isnull", "coalesce"} { + t.Run(name, func(t *testing.T) { + stmts, err := parser.Parse(strings.NewReader("SELECT " + name + "(NULL, 1);")) + if err != nil { + t.Fatal(err) + } + // This literal-only expression needs no dialect formatting hooks. + formatted := ast.Format(stmts[0].Raw, nil) + if !strings.Contains(formatted, name+"(") { + t.Fatalf("function identity changed: %s", formatted) + } + if _, err := parser.Parse(strings.NewReader(formatted)); err != nil { + t.Fatalf("formatted SQL cannot be parsed: %v", err) + } + }) + } +} diff --git a/internal/engine/mssql/typepolicy/combine.go b/internal/engine/mssql/typepolicy/combine.go index 9a48575053..f727707a11 100644 --- a/internal/engine/mssql/typepolicy/combine.go +++ b/internal/engine/mssql/typepolicy/combine.go @@ -51,6 +51,19 @@ func decodeRules(data []byte) (rules, error) { } func (p *combiner) Combine(req core.TypeCombinationRequest) (core.TypeCombinationResult, error) { + if req.Context == core.CombineCoalesce && len(req.FunctionName) > 0 { + if len(req.FunctionName) != 1 || req.FunctionName[0] != "isnull" { + return core.TypeCombinationResult{}, nil + } + // ISNULL retains the first typed argument, including when a literal + // NULL or an unconstrained placeholder comes before it. + for _, candidate := range req.Candidates { + if candidate != nil { + return core.TypeCombinationResult{Handled: true, Type: candidate.WithNullable(false)}, nil + } + } + return core.TypeCombinationResult{Handled: true}, nil + } switch req.Context { case core.CombineUnion, core.CombineIntersect, core.CombineExcept, core.CombineCase, core.CombineCoalesce, core.CombineGreatest, core.CombineLeast: diff --git a/internal/sql/ast/coalesce_expr.go b/internal/sql/ast/coalesce_expr.go index 36c1defa3c..18224834a4 100644 --- a/internal/sql/ast/coalesce_expr.go +++ b/internal/sql/ast/coalesce_expr.go @@ -10,9 +10,9 @@ type CoalesceExpr struct { Coalescecollid Oid `json:"coalescecollid"` Args *List `json:"args,omitempty"` Location int `json:"location"` - // FirstArgType marks SQL Server's ISNULL, which takes its first - // argument's type rather than the type its arguments combine to. - FirstArgType bool `json:"first_arg_type,omitempty"` + // Func preserves the source identity of a function lowered to these + // semantics. Its dialect decides any function-specific type rules. + Func *FuncName `json:"func,omitempty"` } func (n *CoalesceExpr) Pos() int { @@ -25,7 +25,12 @@ func (n *CoalesceExpr) Format(buf *TrackedBuffer, d format.Dialect) { } // Lower case, like every other function name: the printer upper-cases // keywords, and function names are identifiers, which fold lower. - buf.WriteString("coalesce(") + if n.Func != nil { + buf.astFormat(n.Func, d) + } else { + buf.WriteString("coalesce") + } + buf.WriteString("(") buf.astFormat(n.Args, d) buf.WriteString(")") } diff --git a/internal/sql/astutils/rewrite.go b/internal/sql/astutils/rewrite.go index 25b274a16c..73b2641fe6 100644 --- a/internal/sql/astutils/rewrite.go +++ b/internal/sql/astutils/rewrite.go @@ -449,6 +449,7 @@ func (a *application) apply(parent ast.Node, name string, iter *iterator, n ast. a.apply(n, "Relation", nil, n.Relation) case *ast.CoalesceExpr: + a.apply(n, "Func", nil, n.Func) a.apply(n, "Xpr", nil, n.Xpr) a.apply(n, "Args", nil, n.Args) diff --git a/internal/sql/astutils/walk.go b/internal/sql/astutils/walk.go index 6d5e80bdc3..c2535f8397 100644 --- a/internal/sql/astutils/walk.go +++ b/internal/sql/astutils/walk.go @@ -571,6 +571,9 @@ func Walk(f Visitor, node ast.Node) { } case *ast.CoalesceExpr: + if n.Func != nil { + Walk(f, n.Func) + } if n.Xpr != nil { Walk(f, n.Xpr) }