From 1b00606c01223d42fb0abb3e39a6146f35bf56a4 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:38:59 -0800 Subject: [PATCH 01/43] fix(mssql): make the optional side of outer joins nullable The core analyzer bound every relation in a join with the nullability its columns were declared with, so LEFT, RIGHT and FULL joins reported the optional side as NOT NULL. A new dialect setting, outer_join_nullable, makes those columns nullable. SQL Server turns it on; ClickHouse, which fills unmatched rows with defaults, keeps the old behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/core/analyzer/scope.go | 31 +++- internal/core/dialect.go | 15 ++ internal/core/seed/seed.go | 9 ++ .../testdata/mssql_outer_join/mssql/exec.json | 1 + .../testdata/mssql_outer_join/mssql/go/db.go | 31 ++++ .../mssql_outer_join/mssql/go/models.go | 16 ++ .../mssql_outer_join/mssql/go/query.sql.go | 147 ++++++++++++++++++ .../testdata/mssql_outer_join/mssql/query.sql | 11 ++ .../mssql_outer_join/mssql/schema.sql | 10 ++ .../testdata/mssql_outer_join/mssql/sqlc.yaml | 10 ++ internal/engine/mssql/dialect/dialect.json | 1 + 11 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 internal/endtoend/testdata/mssql_outer_join/mssql/exec.json create mode 100644 internal/endtoend/testdata/mssql_outer_join/mssql/go/db.go create mode 100644 internal/endtoend/testdata/mssql_outer_join/mssql/go/models.go create mode 100644 internal/endtoend/testdata/mssql_outer_join/mssql/go/query.sql.go create mode 100644 internal/endtoend/testdata/mssql_outer_join/mssql/query.sql create mode 100644 internal/endtoend/testdata/mssql_outer_join/mssql/schema.sql create mode 100644 internal/endtoend/testdata/mssql_outer_join/mssql/sqlc.yaml diff --git a/internal/core/analyzer/scope.go b/internal/core/analyzer/scope.go index 218a8e0b11..50545466e5 100644 --- a/internal/core/analyzer/scope.go +++ b/internal/core/analyzer/scope.go @@ -52,10 +52,25 @@ func (a *analyzer) appendFromItem(sc *scope, item ast.Node) error { sc.rels = append(sc.rels, rel) return nil case *ast.JoinExpr: + start := len(sc.rels) if err := a.appendFromItem(sc, v.Larg); err != nil { return err } - return a.appendFromItem(sc, v.Rarg) + mid := len(sc.rels) + if err := a.appendFromItem(sc, v.Rarg); err != nil { + return err + } + if a.cat.OuterJoinsNullable() { + switch v.Jointype { + case ast.JoinTypeLeft: + nullableRels(sc.rels[mid:]) + case ast.JoinTypeRight: + nullableRels(sc.rels[start:mid]) + case ast.JoinTypeFull: + nullableRels(sc.rels[start:]) + } + } + return nil case *ast.RangeFunction: rel, err := a.bindRangeFunction(v) if err != nil { @@ -83,6 +98,20 @@ func (a *analyzer) appendFromItem(sc *scope, item ast.Node) error { } } +// nullableRels makes every column of rels nullable, as the optional side of +// an outer join is. The columns are copied: a relation's column list is the +// catalog's own. +func nullableRels(rels []scopeRel) { + for i := range rels { + cols := make([]core.ClassColumn, len(rels[i].cols)) + for j, c := range rels[i].cols { + c.NotNull = false + cols[j] = c + } + rels[i].cols = cols + } +} + // bindRangeFunction binds a function called in FROM. A set-returning function // stands in for a relation of a single column named after it. func (a *analyzer) bindRangeFunction(rf *ast.RangeFunction) (scopeRel, error) { diff --git a/internal/core/dialect.go b/internal/core/dialect.go index e192c442c9..e9723c0fb1 100644 --- a/internal/core/dialect.go +++ b/internal/core/dialect.go @@ -77,6 +77,11 @@ const FlagPropagateNullable = "functions.propagate_nullable" // e.id. const FlagQualifyDuplicateColumns = "columns.qualify_duplicates" +// FlagOuterJoinNullable is set for a dialect whose outer joins fill the +// columns of an unmatched side with NULL, as SQL Server's do. ClickHouse's +// fill them with the type's default instead. +const FlagOuterJoinNullable = "joins.outer_nullable" + // 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,16 @@ func (c *Catalog) QualifiesDuplicateColumns() bool { return v == "true" } +// OuterJoinsNullable reports whether the columns of an outer join's +// optional side are nullable. +func (c *Catalog) OuterJoinsNullable() bool { + if c.dialectOID == 0 { + return false + } + v, _ := c.DialectFlag(c.dialectOID, FlagOuterJoinNullable) + return v == "true" +} + // 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..daedab961c 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"` + // OuterJoinNullable makes the columns of an outer join's optional side + // nullable, as SQL Server fills an unmatched row with NULL. + OuterJoinNullable bool `json:"outer_join_nullable,omitempty"` + // Comparison operators are registered as (T, T) -> Bool for every type in // ComparisonCategories. Comparison []string `json:"comparison,omitempty"` @@ -660,6 +664,11 @@ func (b *builder) consts() error { return err } } + if b.settings.OuterJoinNullable { + if err := b.cat.SetDialectFlag(b.dialectOID, core.FlagOuterJoinNullable, "true"); 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/mssql_outer_join/mssql/exec.json b/internal/endtoend/testdata/mssql_outer_join/mssql/exec.json new file mode 100644 index 0000000000..f4e21983b6 --- /dev/null +++ b/internal/endtoend/testdata/mssql_outer_join/mssql/exec.json @@ -0,0 +1 @@ +{"contexts": ["base"]} diff --git a/internal/endtoend/testdata/mssql_outer_join/mssql/go/db.go b/internal/endtoend/testdata/mssql_outer_join/mssql/go/db.go new file mode 100644 index 0000000000..32faa9f2b8 --- /dev/null +++ b/internal/endtoend/testdata/mssql_outer_join/mssql/go/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/endtoend/testdata/mssql_outer_join/mssql/go/models.go b/internal/endtoend/testdata/mssql_outer_join/mssql/go/models.go new file mode 100644 index 0000000000..927a05454c --- /dev/null +++ b/internal/endtoend/testdata/mssql_outer_join/mssql/go/models.go @@ -0,0 +1,16 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +type Author struct { + ID int64 + Name string +} + +type Book struct { + ID int64 + AuthorID int64 + Title string +} diff --git a/internal/endtoend/testdata/mssql_outer_join/mssql/go/query.sql.go b/internal/endtoend/testdata/mssql_outer_join/mssql/go/query.sql.go new file mode 100644 index 0000000000..cb22db28e4 --- /dev/null +++ b/internal/endtoend/testdata/mssql_outer_join/mssql/go/query.sql.go @@ -0,0 +1,147 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package querytest + +import ( + "context" + "database/sql" +) + +const fullJoin = `-- name: FullJoin :many +SELECT a.name, b.title FROM authors a FULL OUTER JOIN books b ON b.author_id = a.id; +` + +type FullJoinRow struct { + Name sql.NullString + Title sql.NullString +} + +func (q *Queries) FullJoin(ctx context.Context) ([]FullJoinRow, error) { + rows, err := q.db.QueryContext(ctx, fullJoin) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FullJoinRow + for rows.Next() { + var i FullJoinRow + if err := rows.Scan(&i.Name, &i.Title); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const leftJoin = `-- name: LeftJoin :many +SELECT a.id, a.name, b.title FROM authors a LEFT JOIN books b ON b.author_id = a.id; +` + +type LeftJoinRow struct { + ID int64 + Name string + Title sql.NullString +} + +func (q *Queries) LeftJoin(ctx context.Context) ([]LeftJoinRow, error) { + rows, err := q.db.QueryContext(ctx, leftJoin) + if err != nil { + return nil, err + } + defer rows.Close() + var items []LeftJoinRow + for rows.Next() { + var i LeftJoinRow + if err := rows.Scan(&i.ID, &i.Name, &i.Title); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const leftJoinStar = `-- name: LeftJoinStar :many +SELECT a.name, b.id, b.author_id, b.title FROM authors a LEFT JOIN books b ON b.author_id = a.id; +` + +type LeftJoinStarRow struct { + Name string + ID sql.NullInt64 + AuthorID sql.NullInt64 + Title sql.NullString +} + +func (q *Queries) LeftJoinStar(ctx context.Context) ([]LeftJoinStarRow, error) { + rows, err := q.db.QueryContext(ctx, leftJoinStar) + if err != nil { + return nil, err + } + defer rows.Close() + var items []LeftJoinStarRow + for rows.Next() { + var i LeftJoinStarRow + if err := rows.Scan( + &i.Name, + &i.ID, + &i.AuthorID, + &i.Title, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const rightJoin = `-- name: RightJoin :many +SELECT a.name, b.title FROM authors a RIGHT JOIN books b ON b.author_id = a.id; +` + +type RightJoinRow struct { + Name sql.NullString + Title string +} + +func (q *Queries) RightJoin(ctx context.Context) ([]RightJoinRow, error) { + rows, err := q.db.QueryContext(ctx, rightJoin) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RightJoinRow + for rows.Next() { + var i RightJoinRow + if err := rows.Scan(&i.Name, &i.Title); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/endtoend/testdata/mssql_outer_join/mssql/query.sql b/internal/endtoend/testdata/mssql_outer_join/mssql/query.sql new file mode 100644 index 0000000000..c9696e994a --- /dev/null +++ b/internal/endtoend/testdata/mssql_outer_join/mssql/query.sql @@ -0,0 +1,11 @@ +-- name: LeftJoin :many +SELECT a.id, a.name, b.title FROM authors a LEFT JOIN books b ON b.author_id = a.id; + +-- name: RightJoin :many +SELECT a.name, b.title FROM authors a RIGHT JOIN books b ON b.author_id = a.id; + +-- name: FullJoin :many +SELECT a.name, b.title FROM authors a FULL OUTER JOIN books b ON b.author_id = a.id; + +-- name: LeftJoinStar :many +SELECT a.name, b.* FROM authors a LEFT JOIN books b ON b.author_id = a.id; diff --git a/internal/endtoend/testdata/mssql_outer_join/mssql/schema.sql b/internal/endtoend/testdata/mssql_outer_join/mssql/schema.sql new file mode 100644 index 0000000000..754abf3290 --- /dev/null +++ b/internal/endtoend/testdata/mssql_outer_join/mssql/schema.sql @@ -0,0 +1,10 @@ +CREATE TABLE authors ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + name NVARCHAR(100) NOT NULL +); + +CREATE TABLE books ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + author_id BIGINT NOT NULL, + title NVARCHAR(200) NOT NULL +); diff --git a/internal/endtoend/testdata/mssql_outer_join/mssql/sqlc.yaml b/internal/endtoend/testdata/mssql_outer_join/mssql/sqlc.yaml new file mode 100644 index 0000000000..94b0dc8896 --- /dev/null +++ b/internal/endtoend/testdata/mssql_outer_join/mssql/sqlc.yaml @@ -0,0 +1,10 @@ +version: "2" +sql: + - engine: "mssql" + schema: "schema.sql" + queries: "query.sql" + gen: + go: + package: "querytest" + out: "go" + sql_package: "database/sql" diff --git a/internal/engine/mssql/dialect/dialect.json b/internal/engine/mssql/dialect/dialect.json index 30ea408744..41f5e92253 100644 --- a/internal/engine/mssql/dialect/dialect.json +++ b/internal/engine/mssql/dialect/dialect.json @@ -8,6 +8,7 @@ "bool": "bit" }, "bool": "bit", + "outer_join_nullable": true, "comparison": ["=", "<>", "!=", "<", "<=", ">", ">="], "comparison_categories": "NBSD", "arithmetic": ["+", "-", "*", "/", "%"], From cc09068001233f46b280d23b9017c5a0758fae10 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:40:07 -0800 Subject: [PATCH 02/43] feat(mssql): support CROSS APPLY and OUTER APPLY APPLY reached the analyzer as an unconverted node and failed with "unsupported FROM item". Convert it to the join it is: CROSS APPLY to an inner join and OUTER APPLY to a left join. The analyzer already binds each FROM item against the ones before it, so the applied subquery's references to the left side resolve. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../testdata/analyze_apply/mssql/exec.json | 5 + .../testdata/analyze_apply/mssql/query.sql | 10 ++ .../testdata/analyze_apply/mssql/schema.sql | 10 ++ .../testdata/analyze_apply/mssql/stdout.json | 97 +++++++++++++++++++ internal/engine/mssql/convert.go | 20 ++-- 5 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 internal/endtoend/testdata/analyze_apply/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_apply/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_apply/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_apply/mssql/stdout.json diff --git a/internal/endtoend/testdata/analyze_apply/mssql/exec.json b/internal/endtoend/testdata/analyze_apply/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_apply/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_apply/mssql/query.sql b/internal/endtoend/testdata/analyze_apply/mssql/query.sql new file mode 100644 index 0000000000..5362c35b24 --- /dev/null +++ b/internal/endtoend/testdata/analyze_apply/mssql/query.sql @@ -0,0 +1,10 @@ +-- name: LatestBook :many +SELECT a.id, a.name, b.title +FROM authors a +CROSS APPLY (SELECT TOP 1 title FROM books WHERE author_id = a.id ORDER BY id DESC) b; + +-- name: TopBooks :many +SELECT a.name, b.title +FROM authors a +OUTER APPLY (SELECT TOP (@n) title FROM books WHERE author_id = a.id ORDER BY id) b +WHERE a.name = @name; diff --git a/internal/endtoend/testdata/analyze_apply/mssql/schema.sql b/internal/endtoend/testdata/analyze_apply/mssql/schema.sql new file mode 100644 index 0000000000..2a49202aaa --- /dev/null +++ b/internal/endtoend/testdata/analyze_apply/mssql/schema.sql @@ -0,0 +1,10 @@ +CREATE TABLE authors ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + name NVARCHAR(100) NOT NULL +); + +CREATE TABLE books ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + author_id BIGINT NOT NULL, + title NVARCHAR(200) NOT NULL +); diff --git a/internal/endtoend/testdata/analyze_apply/mssql/stdout.json b/internal/endtoend/testdata/analyze_apply/mssql/stdout.json new file mode 100644 index 0000000000..6ba43fceb9 --- /dev/null +++ b/internal/endtoend/testdata/analyze_apply/mssql/stdout.json @@ -0,0 +1,97 @@ +[ + { + "name": "LatestBook", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "authors" + }, + { + "name": "name", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 100 + } + ] + }, + "table": "authors" + }, + { + "name": "title", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 200 + } + ] + }, + "table": "books" + } + ], + "params": [] + }, + { + "name": "TopBooks", + "cmd": ":many", + "columns": [ + { + "name": "name", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 100 + } + ] + }, + "table": "authors" + }, + { + "name": "title", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 200 + } + ] + }, + "table": "books" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "n", + "type": { + "name": "int" + } + } + }, + { + "number": 2, + "column": { + "name": "name", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 100 + } + ] + }, + "table": "authors" + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index bdef335bc6..1fe2cad0d9 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -341,14 +341,22 @@ func (c *cc) convertTableReference(ref tsql.TableReference) ast.Node { } return join case *tsql.UnqualifiedJoin: - if t.UnqualifiedJoinType != "CrossJoin" { - return todo(ref) + // APPLY is a lateral join: its right side may refer to the left, + // which the analyzer allows for every FROM item bound after another. + // OUTER APPLY keeps unmatched left rows, as a left join does. + join := &ast.JoinExpr{ + Larg: c.convertTableReference(t.FirstTableReference), + Rarg: c.convertTableReference(t.SecondTableReference), } - return &ast.JoinExpr{ - Jointype: ast.JoinTypeInner, - Larg: c.convertTableReference(t.FirstTableReference), - Rarg: c.convertTableReference(t.SecondTableReference), + switch t.UnqualifiedJoinType { + case "CrossJoin", "CrossApply": + join.Jointype = ast.JoinTypeInner + case "OuterApply": + join.Jointype = ast.JoinTypeLeft + default: + return todo(ref) } + return join default: return todo(ref) } From 42ea003edb6a6bfa71a993ba7e77a8f7a14cec99 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:41:52 -0800 Subject: [PATCH 03/43] fix(mssql): type TOP and OFFSET/FETCH parameters as bigint A placeholder counting rows fell back to the core's default int. SQL Server describes the parameter of TOP (@n), OFFSET @n ROWS and FETCH NEXT @n ROWS as bigint, so the dialect now says so. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../testdata/analyze_apply/mssql/stdout.json | 2 +- .../testdata/analyze_paging/mssql/exec.json | 5 ++ .../testdata/analyze_paging/mssql/query.sql | 5 ++ .../testdata/analyze_paging/mssql/schema.sql | 7 ++ .../testdata/analyze_paging/mssql/stdout.json | 83 +++++++++++++++++++ internal/engine/mssql/dialect/dialect.json | 1 + 6 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 internal/endtoend/testdata/analyze_paging/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_paging/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_paging/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_paging/mssql/stdout.json diff --git a/internal/endtoend/testdata/analyze_apply/mssql/stdout.json b/internal/endtoend/testdata/analyze_apply/mssql/stdout.json index 6ba43fceb9..927f596197 100644 --- a/internal/endtoend/testdata/analyze_apply/mssql/stdout.json +++ b/internal/endtoend/testdata/analyze_apply/mssql/stdout.json @@ -73,7 +73,7 @@ "column": { "name": "n", "type": { - "name": "int" + "name": "bigint" } } }, diff --git a/internal/endtoend/testdata/analyze_paging/mssql/exec.json b/internal/endtoend/testdata/analyze_paging/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_paging/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_paging/mssql/query.sql b/internal/endtoend/testdata/analyze_paging/mssql/query.sql new file mode 100644 index 0000000000..cb73b7f734 --- /dev/null +++ b/internal/endtoend/testdata/analyze_paging/mssql/query.sql @@ -0,0 +1,5 @@ +-- name: TopAuthors :many +SELECT TOP (@n) id, name FROM authors ORDER BY id; + +-- name: PageAuthors :many +SELECT id, name FROM authors ORDER BY id OFFSET @skip ROWS FETCH NEXT @take ROWS ONLY; diff --git a/internal/endtoend/testdata/analyze_paging/mssql/schema.sql b/internal/endtoend/testdata/analyze_paging/mssql/schema.sql new file mode 100644 index 0000000000..3629635d2e --- /dev/null +++ b/internal/endtoend/testdata/analyze_paging/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_paging/mssql/stdout.json b/internal/endtoend/testdata/analyze_paging/mssql/stdout.json new file mode 100644 index 0000000000..5fc257bcbb --- /dev/null +++ b/internal/endtoend/testdata/analyze_paging/mssql/stdout.json @@ -0,0 +1,83 @@ +[ + { + "name": "TopAuthors", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "authors" + }, + { + "name": "name", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 100 + } + ] + }, + "table": "authors" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "n", + "type": { + "name": "bigint" + } + } + } + ] + }, + { + "name": "PageAuthors", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "authors" + }, + { + "name": "name", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 100 + } + ] + }, + "table": "authors" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "skip", + "type": { + "name": "bigint" + } + } + }, + { + "number": 2, + "column": { + "name": "take", + "type": { + "name": "bigint" + } + } + } + ] + } +] diff --git a/internal/engine/mssql/dialect/dialect.json b/internal/engine/mssql/dialect/dialect.json index 41f5e92253..701b03c3c4 100644 --- a/internal/engine/mssql/dialect/dialect.json +++ b/internal/engine/mssql/dialect/dialect.json @@ -8,6 +8,7 @@ "bool": "bit" }, "bool": "bit", + "limit": "bigint", "outer_join_nullable": true, "comparison": ["=", "<>", "!=", "<", "<=", ">", ">="], "comparison_categories": "NBSD", From 3b901d64ce198054583e4669d9b3155f8b0a812a Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:44:50 -0800 Subject: [PATCH 04/43] feat(mssql): support recursive common table expressions A CTE that read itself failed: the analyzer bound a CTE only once its whole query was analyzed, so the self-reference named an unknown relation. When a WITH clause is recursive, the anchor member is now bound first and the full query analyzed against it. SQL Server describes every column of a recursive CTE as nullable and read from no table, and so does sqlc. T-SQL has no RECURSIVE keyword, so the converter marks every WITH clause recursive; only a CTE that names itself is treated as one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/core/analyzer/analyzer.go | 38 ++++++++- .../analyze_recursive_cte/mssql/exec.json | 5 ++ .../analyze_recursive_cte/mssql/query.sql | 18 +++++ .../analyze_recursive_cte/mssql/schema.sql | 5 ++ .../analyze_recursive_cte/mssql/stdout.json | 77 +++++++++++++++++++ internal/engine/mssql/convert.go | 7 +- 6 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 internal/endtoend/testdata/analyze_recursive_cte/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_recursive_cte/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_recursive_cte/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_recursive_cte/mssql/stdout.json diff --git a/internal/core/analyzer/analyzer.go b/internal/core/analyzer/analyzer.go index 2bc94d7338..70fa3c81bc 100644 --- a/internal/core/analyzer/analyzer.go +++ b/internal/core/analyzer/analyzer.go @@ -5,6 +5,7 @@ import ( "github.com/sqlc-dev/sqlc/internal/core" "github.com/sqlc-dev/sqlc/internal/sql/ast" + "github.com/sqlc-dev/sqlc/internal/sql/astutils" ) func Prepare(cat *core.Catalog, stmt ast.Node) (core.PrepareResult, error) { @@ -300,20 +301,53 @@ func (a *analyzer) bindCTEs(with *ast.WithClause) error { if !ok { continue } + if a.ctes == nil { + a.ctes = map[string]scopeRel{} + } + // A recursive CTE refers to itself in the branches after its first, + // which gives the columns, so the first is bound before the rest. + recursive := with.Recursive && sel.Larg != nil && refersTo(sel.Rarg, *cte.Ctename) + if recursive { + anchor := sel + for anchor.Larg != nil { + anchor = anchor.Larg + } + cols, err := a.subqueryColumns(anchor) + if err != nil { + return fmt.Errorf("with %s: %w", *cte.Ctename, err) + } + rel := derivedRel(*cte.Ctename, cols) + renameColumns(&rel, cte.Aliascolnames) + a.ctes[*cte.Ctename] = rel + } cols, err := a.subqueryColumns(sel) if err != nil { return fmt.Errorf("with %s: %w", *cte.Ctename, err) } rel := derivedRel(*cte.Ctename, cols) renameColumns(&rel, cte.Aliascolnames) - if a.ctes == nil { - a.ctes = map[string]scopeRel{} + // What a recursive CTE yields is the union of every round of it, so + // no column stands for a single table column and any may be NULL. + if recursive { + for i := range rel.cols { + rel.cols[i].AttOID = 0 + rel.cols[i].NotNull = false + } } a.ctes[*cte.Ctename] = rel } return nil } +// refersTo reports whether a query reads the relation named name. +func refersTo(n ast.Node, name string) bool { + found := astutils.Search(n, func(n ast.Node) bool { + rv, ok := n.(*ast.RangeVar) + return ok && rv.Relname != nil && *rv.Relname == name && (rv.Schemaname == nil || *rv.Schemaname == "") + }) + return len(found.Items) > 0 +} + // bindAliases records the output names the target list assigns, so a later // clause can refer to a result column by name. func (a *analyzer) bindAliases(targets *ast.List) { diff --git a/internal/endtoend/testdata/analyze_recursive_cte/mssql/exec.json b/internal/endtoend/testdata/analyze_recursive_cte/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_recursive_cte/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_recursive_cte/mssql/query.sql b/internal/endtoend/testdata/analyze_recursive_cte/mssql/query.sql new file mode 100644 index 0000000000..bf18662350 --- /dev/null +++ b/internal/endtoend/testdata/analyze_recursive_cte/mssql/query.sql @@ -0,0 +1,18 @@ +-- name: Subtree :many +WITH tree AS ( + SELECT id, parent_id, name, 0 AS depth FROM nodes WHERE id = @root + UNION ALL + SELECT n.id, n.parent_id, n.name, t.depth + 1 + FROM nodes n + JOIN tree t ON n.parent_id = t.id +) +SELECT id, parent_id, name, depth FROM tree +OPTION (MAXRECURSION 100); + +-- name: Numbers :many +WITH seq(i) AS ( + SELECT 1 + UNION ALL + SELECT i + 1 FROM seq WHERE i < @max +) +SELECT i FROM seq; diff --git a/internal/endtoend/testdata/analyze_recursive_cte/mssql/schema.sql b/internal/endtoend/testdata/analyze_recursive_cte/mssql/schema.sql new file mode 100644 index 0000000000..89c256a5ef --- /dev/null +++ b/internal/endtoend/testdata/analyze_recursive_cte/mssql/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE nodes ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + parent_id BIGINT, + name NVARCHAR(100) NOT NULL +); diff --git a/internal/endtoend/testdata/analyze_recursive_cte/mssql/stdout.json b/internal/endtoend/testdata/analyze_recursive_cte/mssql/stdout.json new file mode 100644 index 0000000000..f864eb2874 --- /dev/null +++ b/internal/endtoend/testdata/analyze_recursive_cte/mssql/stdout.json @@ -0,0 +1,77 @@ +[ + { + "name": "Subtree", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint", + "nullable": true + } + }, + { + "name": "parent_id", + "type": { + "name": "bigint", + "nullable": true + } + }, + { + "name": "name", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 100 + } + ] + } + }, + { + "name": "depth", + "type": { + "name": "int", + "nullable": true + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "root", + "type": { + "name": "bigint" + }, + "table": "nodes" + } + } + ] + }, + { + "name": "Numbers", + "cmd": ":many", + "columns": [ + { + "name": "i", + "type": { + "name": "int", + "nullable": true + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "max", + "type": { + "name": "int" + } + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index 1fe2cad0d9..e7715e135d 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -107,9 +107,12 @@ func (c *cc) convertWithClause(n *tsql.WithCtesAndXmlNamespaces) *ast.WithClause if n == nil || len(n.CommonTableExpressions) == 0 { return nil } + // T-SQL has no RECURSIVE keyword: a common table expression that names + // itself is recursive. with := &ast.WithClause{ - Ctes: &ast.List{}, - Location: c.loc(n), + Ctes: &ast.List{}, + Recursive: true, + Location: c.loc(n), } for _, cte := range n.CommonTableExpressions { name := identifierValue(cte.ExpressionName) From b4fd639c65ea50c0861df108b20248906ab6fe7d Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:47:37 -0800 Subject: [PATCH 05/43] feat(mssql): support OPENJSON, STRING_SPLIT and GENERATE_SERIES in FROM The table-valued built-ins reached the analyzer unconverted and failed with "unsupported FROM item". The converter now reports each as a range function with a column definition list: OPENJSON's key, value and type, or the columns its WITH clause declares; STRING_SPLIT's value, plus its ordinal when enable_ordinal is passed; GENERATE_SERIES's value. The core analyzer reads a range function's column definition list, where a definition with no type is the function's own result, and a seeded "$n" result over an untyped placeholder takes the parameter's declared type rather than leaking the pseudo-type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/core/analyzer/expr.go | 5 + internal/core/analyzer/scope.go | 25 +++ .../analyze_table_functions/mssql/exec.json | 5 + .../analyze_table_functions/mssql/query.sql | 19 +++ .../analyze_table_functions/mssql/schema.sql | 5 + .../analyze_table_functions/mssql/stdout.json | 145 ++++++++++++++++++ .../mssql_string_split_param/mssql/exec.json | 5 + .../mssql_string_split_param/mssql/query.sql | 6 + .../mssql_string_split_param/mssql/schema.sql | 5 + .../mssql/stdout.json | 61 ++++++++ internal/engine/mssql/convert.go | 77 ++++++++++ internal/engine/mssql/dialect/functions.jsonl | 12 ++ 12 files changed, 370 insertions(+) create mode 100644 internal/endtoend/testdata/analyze_table_functions/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_table_functions/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_table_functions/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_table_functions/mssql/stdout.json create mode 100644 internal/endtoend/testdata/mssql_string_split_param/mssql/exec.json create mode 100644 internal/endtoend/testdata/mssql_string_split_param/mssql/query.sql create mode 100644 internal/endtoend/testdata/mssql_string_split_param/mssql/schema.sql create mode 100644 internal/endtoend/testdata/mssql_string_split_param/mssql/stdout.json diff --git a/internal/core/analyzer/expr.go b/internal/core/analyzer/expr.go index 62a69b273d..ec7120b96c 100644 --- a/internal/core/analyzer/expr.go +++ b/internal/core/analyzer/expr.go @@ -984,6 +984,11 @@ func (a *analyzer) returnType(p core.ProcOverload, argTypes []exprType) exprType } if n, ok := argIndex(name); ok { if n < len(argTypes) { + // A placeholder passed there takes the parameter's type, and so + // does the result. + if argTypes[n].typeOID == 0 && argTypes[n].expr == nil && n < len(p.ArgTypes) && !a.isPolymorphicOID(p.ArgTypes[n]) { + return exprType{typeOID: p.ArgTypes[n]} + } return exprType{typeOID: argTypes[n].typeOID, expr: argTypes[n].expr} } return exprType{} diff --git a/internal/core/analyzer/scope.go b/internal/core/analyzer/scope.go index 50545466e5..ada3da93eb 100644 --- a/internal/core/analyzer/scope.go +++ b/internal/core/analyzer/scope.go @@ -151,9 +151,34 @@ func (a *analyzer) bindRangeFunction(rf *ast.RangeFunction) (scopeRel, error) { col.TypeOID, col.NotNull = p.ReturnTypeOID, !p.ReturnNullable } rel.cols = []core.ClassColumn{col} + if rf.Coldeflist != nil { + rel.cols = a.coldefColumns(rf.Coldeflist, col) + } return rel, nil } +// coldefColumns are the columns a function's column definition list +// declares, for a function returning rows of more than one column. A +// definition with no type is the function's own result. +func (a *analyzer) coldefColumns(defs *ast.List, result core.ClassColumn) []core.ClassColumn { + var cols []core.ClassColumn + for _, item := range listItems(defs) { + def, ok := item.(*ast.ColumnDef) + if !ok { + continue + } + if def.TypeName == nil { + col := result + col.Name = def.Colname + cols = append(cols, col) + continue + } + t := a.lookupType(core.TypeExprOfTypeName(def.TypeName)) + cols = append(cols, core.ClassColumn{Name: def.Colname, TypeOID: t.typeOID, Type: t.expr, NotNull: def.IsNotNull}) + } + return cols +} + func findFuncCall(l *ast.List) *ast.FuncCall { for _, item := range listItems(l) { switch v := item.(type) { diff --git a/internal/endtoend/testdata/analyze_table_functions/mssql/exec.json b/internal/endtoend/testdata/analyze_table_functions/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_table_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_table_functions/mssql/query.sql b/internal/endtoend/testdata/analyze_table_functions/mssql/query.sql new file mode 100644 index 0000000000..677428a56e --- /dev/null +++ b/internal/endtoend/testdata/analyze_table_functions/mssql/query.sql @@ -0,0 +1,19 @@ +-- name: Elements :many +SELECT d.id, j.[key], j.value, j.type +FROM documents d +CROSS APPLY OPENJSON(d.body) j +WHERE d.id = @id; + +-- name: Items :many +SELECT d.id, i.name, i.qty, i.extra +FROM documents d +CROSS APPLY OPENJSON(d.body, '$.items') + WITH (name NVARCHAR(50) '$.name', qty INT '$.qty', extra NVARCHAR(MAX) '$.extra' AS JSON) i; + +-- name: Words :many +SELECT d.id, w.value, w.ordinal +FROM documents d +CROSS APPLY STRING_SPLIT(d.title, N' ', 1) w; + +-- name: Series :many +SELECT value FROM GENERATE_SERIES(1, 10); diff --git a/internal/endtoend/testdata/analyze_table_functions/mssql/schema.sql b/internal/endtoend/testdata/analyze_table_functions/mssql/schema.sql new file mode 100644 index 0000000000..f70422d2a7 --- /dev/null +++ b/internal/endtoend/testdata/analyze_table_functions/mssql/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE documents ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + title NVARCHAR(100) NOT NULL, + body NVARCHAR(MAX) +); diff --git a/internal/endtoend/testdata/analyze_table_functions/mssql/stdout.json b/internal/endtoend/testdata/analyze_table_functions/mssql/stdout.json new file mode 100644 index 0000000000..529f8710d7 --- /dev/null +++ b/internal/endtoend/testdata/analyze_table_functions/mssql/stdout.json @@ -0,0 +1,145 @@ +[ + { + "name": "Elements", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "documents" + }, + { + "name": "key", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 4000 + } + ] + } + }, + { + "name": "value", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "ident": "max" + } + ] + } + }, + { + "name": "type", + "type": { + "name": "tinyint" + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "documents" + } + } + ] + }, + { + "name": "Items", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "documents" + }, + { + "name": "name", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 50 + } + ] + } + }, + { + "name": "qty", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "extra", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "ident": "max" + } + ] + } + } + ], + "params": [] + }, + { + "name": "Words", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "documents" + }, + { + "name": "value", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 100 + } + ] + } + }, + { + "name": "ordinal", + "type": { + "name": "bigint" + } + } + ], + "params": [] + }, + { + "name": "Series", + "cmd": ":many", + "columns": [ + { + "name": "value", + "type": { + "name": "int" + } + } + ], + "params": [] + } +] diff --git a/internal/endtoend/testdata/mssql_string_split_param/mssql/exec.json b/internal/endtoend/testdata/mssql_string_split_param/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/mssql_string_split_param/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/mssql_string_split_param/mssql/query.sql b/internal/endtoend/testdata/mssql_string_split_param/mssql/query.sql new file mode 100644 index 0000000000..16c0c69a27 --- /dev/null +++ b/internal/endtoend/testdata/mssql_string_split_param/mssql/query.sql @@ -0,0 +1,6 @@ +-- name: ByIDs :many +SELECT id, title FROM documents +WHERE id IN (SELECT CAST(value AS BIGINT) FROM STRING_SPLIT(@ids, ',')); + +-- name: Tokens :many +SELECT value FROM STRING_SPLIT(@list, ','); diff --git a/internal/endtoend/testdata/mssql_string_split_param/mssql/schema.sql b/internal/endtoend/testdata/mssql_string_split_param/mssql/schema.sql new file mode 100644 index 0000000000..f70422d2a7 --- /dev/null +++ b/internal/endtoend/testdata/mssql_string_split_param/mssql/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE documents ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + title NVARCHAR(100) NOT NULL, + body NVARCHAR(MAX) +); diff --git a/internal/endtoend/testdata/mssql_string_split_param/mssql/stdout.json b/internal/endtoend/testdata/mssql_string_split_param/mssql/stdout.json new file mode 100644 index 0000000000..430beca540 --- /dev/null +++ b/internal/endtoend/testdata/mssql_string_split_param/mssql/stdout.json @@ -0,0 +1,61 @@ +[ + { + "name": "ByIDs", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "documents" + }, + { + "name": "title", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 100 + } + ] + }, + "table": "documents" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "ids", + "type": { + "name": "varchar" + } + } + } + ] + }, + { + "name": "Tokens", + "cmd": ":many", + "columns": [ + { + "name": "value", + "type": { + "name": "varchar" + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "list", + "type": { + "name": "varchar" + } + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index e7715e135d..452152b96b 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -360,11 +360,88 @@ func (c *cc) convertTableReference(ref tsql.TableReference) ast.Node { return todo(ref) } return join + case *tsql.OpenJsonTableReference: + return c.convertOpenJson(t) + case *tsql.GlobalFunctionTableReference: + return c.convertGlobalFunctionTableReference(t) default: return todo(ref) } } +// rangeFunction is a call in FROM to a function returning rows of the +// columns defs declares. +func (c *cc) rangeFunction(n tsql.Node, name string, args []tsql.ScalarExpression, alias *tsql.Identifier, defs []ast.Node) *ast.RangeFunction { + call := &ast.FuncCall{ + Funcname: &ast.List{Items: []ast.Node{&ast.String{Str: name}}}, + Location: c.loc(n), + } + for _, arg := range args { + if call.Args == nil { + call.Args = &ast.List{} + } + call.Args.Items = append(call.Args.Items, c.convertScalarExpression(arg)) + } + rf := &ast.RangeFunction{ + Functions: &ast.List{Items: []ast.Node{&ast.List{Items: []ast.Node{call}}}}, + Coldeflist: &ast.List{Items: defs}, + } + if alias != nil { + name := identifierValue(alias) + rf.Alias = &ast.Alias{Aliasname: &name} + } + return rf +} + +// convertOpenJson converts OPENJSON, whose rows are the key, value and type +// of each JSON element, or the columns its WITH clause declares. +func (c *cc) convertOpenJson(n *tsql.OpenJsonTableReference) ast.Node { + args := []tsql.ScalarExpression{n.Variable} + if n.RowPattern != nil { + args = append(args, n.RowPattern) + } + var defs []ast.Node + if len(n.SchemaDeclarationItems) == 0 { + defs = []ast.Node{ + &ast.ColumnDef{Colname: "key", TypeName: typeName("nvarchar", 4000), IsNotNull: true}, + &ast.ColumnDef{Colname: "value", TypeName: &ast.TypeName{Name: "nvarchar", Typmods: appendTypmod(nil, &ast.String{Str: "max"})}}, + &ast.ColumnDef{Colname: "type", TypeName: &ast.TypeName{Name: "tinyint"}, IsNotNull: true}, + } + } + for _, item := range n.SchemaDeclarationItems { + if item.ColumnDefinition == nil { + continue + } + defs = append(defs, &ast.ColumnDef{ + Colname: identifierValue(item.ColumnDefinition.ColumnIdentifier), + TypeName: dataTypeName(item.ColumnDefinition.DataType), + }) + } + return c.rangeFunction(n, "openjson", args, n.Alias, defs) +} + +// convertGlobalFunctionTableReference converts STRING_SPLIT and +// GENERATE_SERIES. Each returns a value of its seeded result type, and +// STRING_SPLIT with enable_ordinal set adds the ordinal of each. +func (c *cc) convertGlobalFunctionTableReference(n *tsql.GlobalFunctionTableReference) ast.Node { + name := identifierValue(n.Name) + defs := []ast.Node{&ast.ColumnDef{Colname: "value"}} + switch name { + case "string_split": + if len(n.Parameters) == 3 { + defs = append(defs, &ast.ColumnDef{Colname: "ordinal", TypeName: &ast.TypeName{Name: "bigint"}, IsNotNull: true}) + } + case "generate_series": + default: + return todo(n) + } + return c.rangeFunction(n, name, n.Parameters, n.Alias, defs) +} + +func typeName(name string, length int64) *ast.TypeName { + return &ast.TypeName{Name: name, Typmods: appendTypmod(nil, &ast.A_Const{Val: &ast.Integer{Ival: length}})} +} + func (c *cc) convertBooleanExpression(expr tsql.BooleanExpression) ast.Node { if expr == nil { return nil diff --git a/internal/engine/mssql/dialect/functions.jsonl b/internal/engine/mssql/dialect/functions.jsonl index e0cd45408c..891d141f36 100644 --- a/internal/engine/mssql/dialect/functions.jsonl +++ b/internal/engine/mssql/dialect/functions.jsonl @@ -74,3 +74,15 @@ {"name": "rtrim", "args": [{"type": "nvarchar"}], "returns": "nvarchar"} {"name": "trim", "args": [{"type": "varchar"}], "returns": "varchar"} {"name": "trim", "args": [{"type": "nvarchar"}], "returns": "nvarchar"} +{"name": "openjson", "args": [{"type": "nvarchar"}], "returns": "nvarchar", "nullable": true} +{"name": "openjson", "args": [{"type": "nvarchar"}, {"type": "nvarchar"}], "returns": "nvarchar", "nullable": true} +{"name": "string_split", "args": [{"type": "nvarchar"}, {"type": "nvarchar"}], "returns": "$1"} +{"name": "string_split", "args": [{"type": "varchar"}, {"type": "varchar"}], "returns": "$1"} +{"name": "string_split", "args": [{"type": "nvarchar"}, {"type": "nvarchar"}, {"type": "int"}], "returns": "$1"} +{"name": "string_split", "args": [{"type": "varchar"}, {"type": "varchar"}, {"type": "int"}], "returns": "$1"} +{"name": "generate_series", "args": [{"type": "int"}, {"type": "int"}], "returns": "$1"} +{"name": "generate_series", "args": [{"type": "bigint"}, {"type": "bigint"}], "returns": "$1"} +{"name": "generate_series", "args": [{"type": "decimal"}, {"type": "decimal"}], "returns": "$1"} +{"name": "generate_series", "args": [{"type": "int"}, {"type": "int"}, {"type": "int"}], "returns": "$1"} +{"name": "generate_series", "args": [{"type": "bigint"}, {"type": "bigint"}, {"type": "bigint"}], "returns": "$1"} +{"name": "generate_series", "args": [{"type": "decimal"}, {"type": "decimal"}, {"type": "decimal"}], "returns": "$1"} From 94a9d7a617328d7b4e2ae16265140889009ca754 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:46:25 -0800 Subject: [PATCH 06/43] feat(mssql): support sqlc.arg, sqlc.narg and sqlc.slice SQL Server had no preprocessor dialect, so sqlc macros reached the T-SQL parser as function calls and produced no parameter. Add one that rewrites them to native @name placeholders, numbers bare @name alongside them and folds name case as SQL Server does, leaving @@ system functions alone. Named-arg queries cannot expand a slice into positional ?s, so expand it into the @p names go-mssqldb gives unnamed arguments and pass the other parameters through sql.Named. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reference/macros.md | 3 + internal/codegen/golang/imports.go | 12 +- internal/codegen/golang/query.go | 20 +++ .../golang/templates/stdlib/queryCode.tmpl | 30 +++- .../mssql_sqlc_macros/mssql/exec.json | 1 + .../testdata/mssql_sqlc_macros/mssql/go/db.go | 31 ++++ .../mssql_sqlc_macros/mssql/go/models.go | 21 +++ .../mssql_sqlc_macros/mssql/go/query.sql.go | 150 ++++++++++++++++++ .../mssql_sqlc_macros/mssql/query.sql | 14 ++ .../mssql_sqlc_macros/mssql/schema.sql | 11 ++ .../mssql_sqlc_macros/mssql/sqlc.yaml | 10 ++ internal/sql/preprocess/CLAUDE.md | 1 + internal/sql/preprocess/dialect.go | 9 ++ internal/sql/preprocess/preprocess.go | 20 +++ internal/sql/preprocess/scan.go | 3 +- .../preprocess/testdata/mssql/arg/input.sql | 1 + .../preprocess/testdata/mssql/arg/output.sql | 1 + .../testdata/mssql/arg/side_table.json | 24 +++ .../mssql/at_sign_keeps_spelling/input.sql | 1 + .../mssql/at_sign_keeps_spelling/output.sql | 1 + .../at_sign_keeps_spelling/side_table.json | 30 ++++ .../preprocess/testdata/mssql/embed/input.sql | 1 + .../testdata/mssql/embed/output.sql | 1 + .../testdata/mssql/embed/side_table.json | 24 +++ .../mssql/skip_nested_block_comment/input.sql | 1 + .../skip_nested_block_comment/output.sql | 1 + .../skip_nested_block_comment/side_table.json | 16 ++ .../preprocess/testdata/mssql/slice/input.sql | 1 + .../testdata/mssql/slice/output.sql | 1 + .../testdata/mssql/slice/side_table.json | 24 +++ .../input.sql | 1 + .../output.sql | 1 + .../side_table.json | 28 ++++ 33 files changed, 490 insertions(+), 4 deletions(-) create mode 100644 internal/endtoend/testdata/mssql_sqlc_macros/mssql/exec.json create mode 100644 internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/db.go create mode 100644 internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/models.go create mode 100644 internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/query.sql.go create mode 100644 internal/endtoend/testdata/mssql_sqlc_macros/mssql/query.sql create mode 100644 internal/endtoend/testdata/mssql_sqlc_macros/mssql/schema.sql create mode 100644 internal/endtoend/testdata/mssql_sqlc_macros/mssql/sqlc.yaml create mode 100644 internal/sql/preprocess/testdata/mssql/arg/input.sql create mode 100644 internal/sql/preprocess/testdata/mssql/arg/output.sql create mode 100644 internal/sql/preprocess/testdata/mssql/arg/side_table.json create mode 100644 internal/sql/preprocess/testdata/mssql/at_sign_keeps_spelling/input.sql create mode 100644 internal/sql/preprocess/testdata/mssql/at_sign_keeps_spelling/output.sql create mode 100644 internal/sql/preprocess/testdata/mssql/at_sign_keeps_spelling/side_table.json create mode 100644 internal/sql/preprocess/testdata/mssql/embed/input.sql create mode 100644 internal/sql/preprocess/testdata/mssql/embed/output.sql create mode 100644 internal/sql/preprocess/testdata/mssql/embed/side_table.json create mode 100644 internal/sql/preprocess/testdata/mssql/skip_nested_block_comment/input.sql create mode 100644 internal/sql/preprocess/testdata/mssql/skip_nested_block_comment/output.sql create mode 100644 internal/sql/preprocess/testdata/mssql/skip_nested_block_comment/side_table.json create mode 100644 internal/sql/preprocess/testdata/mssql/slice/input.sql create mode 100644 internal/sql/preprocess/testdata/mssql/slice/output.sql create mode 100644 internal/sql/preprocess/testdata/mssql/slice/side_table.json create mode 100644 internal/sql/preprocess/testdata/mssql/system_functions_are_not_parameters/input.sql create mode 100644 internal/sql/preprocess/testdata/mssql/system_functions_are_not_parameters/output.sql create mode 100644 internal/sql/preprocess/testdata/mssql/system_functions_are_not_parameters/side_table.json diff --git a/docs/reference/macros.md b/docs/reference/macros.md index 54d44ed5c9..fb8ac2ebe8 100644 --- a/docs/reference/macros.md +++ b/docs/reference/macros.md @@ -115,5 +115,8 @@ WHERE age IN (/*SLICE:ages*/?) Since the `/*SLICE:ages*/` placeholder is dynamically replaced on a per-query basis, this macro can't be used with prepared statements. +On SQL Server the placeholder is `/*SLICE:ages*/@ages`, and each value is bound +to its own `@p` parameter, the name go-mssqldb gives an unnamed argument. + See a full example in [Passing a slice as a parameter to a query](../howto/select.md#mysql-and-sqlite). diff --git a/internal/codegen/golang/imports.go b/internal/codegen/golang/imports.go index e1370e48eb..62b59ba7c1 100644 --- a/internal/codegen/golang/imports.go +++ b/internal/codegen/golang/imports.go @@ -439,9 +439,16 @@ func (i *importer) queryImports(filename string) fileImports { return false } - // A query bound by name passes sql.Named arguments. + // A query bound by name passes sql.Named arguments, except for its + // slices, whose values it binds to numbered placeholders. for _, q := range gq { - if q.Arg.NamedArgs && !q.Arg.isEmpty() { + if !q.Arg.NamedArgs || q.Arg.isEmpty() { + continue + } + if q.Arg.HasSqlcSlices() { + std["strconv"] = struct{}{} + } + if q.Arg.hasNamedArg() { std["database/sql"] = struct{}{} } } @@ -454,6 +461,7 @@ func (i *importer) queryImports(filename string) fileImports { if sqlcSliceScan() && !sqlpkg.IsPGX() { std["strings"] = struct{}{} } + if sliceScan() && usesPqArrays(i.Engine, sqlpkg) { pkg[ImportSpec{Path: "github.com/lib/pq"}] = struct{}{} } diff --git a/internal/codegen/golang/query.go b/internal/codegen/golang/query.go index 6bd9b70e25..363ae6a0b0 100644 --- a/internal/codegen/golang/query.go +++ b/internal/codegen/golang/query.go @@ -157,6 +157,12 @@ func (v QueryValue) namedArg(name, expr string) string { return "sql.Named(" + strconv.Quote(name) + ", " + expr + ")" } +// NamedArgForField is the argument expr passed for a field of a query bound +// by name. +func (v QueryValue) NamedArgForField(f Field) string { + return v.namedArg(f.ParamName, v.VariableForField(f)) +} + func (v QueryValue) Params() string { if v.isEmpty() { return "" @@ -224,6 +230,20 @@ func (v QueryValue) HasSqlcSlices() bool { return false } +// hasNamedArg reports whether any parameter is passed as sql.Named, which a +// sqlc.slice() never is. +func (v QueryValue) hasNamedArg() bool { + if v.Struct == nil { + return !v.HasSqlcSlices() + } + for _, f := range v.Struct.Fields { + if !f.HasSqlcSlice() { + return true + } + } + return false +} + func (v QueryValue) Scan() string { var out []string if v.Struct == nil { diff --git a/internal/codegen/golang/templates/stdlib/queryCode.tmpl b/internal/codegen/golang/templates/stdlib/queryCode.tmpl index 8fd67f1567..f0fe0189ef 100644 --- a/internal/codegen/golang/templates/stdlib/queryCode.tmpl +++ b/internal/codegen/golang/templates/stdlib/queryCode.tmpl @@ -131,7 +131,20 @@ func (q *Queries) {{.MethodName}}(ctx context.Context, {{ dbarg }} {{.Arg.Pair}} {{- if .Arg.Struct }} {{- $arg := .Arg }} {{- range .Arg.Struct.Fields }} - {{- if .HasSqlcSlice }} + {{- if and .HasSqlcSlice $arg.NamedArgs }} + {{- /* The driver names an unnamed argument @p, its + 1-based position in queryParams. */}} + if len({{$arg.VariableForField .}}) > 0 { + var placeholders []string + for _, v := range {{$arg.VariableForField .}} { + queryParams = append(queryParams, v) + placeholders = append(placeholders, "@p"+strconv.Itoa(len(queryParams))) + } + query = strings.Replace(query, "/*SLICE:{{.Column.Name}}*/@{{.Column.Name}}", strings.Join(placeholders, ","), 1) + } else { + query = strings.Replace(query, "/*SLICE:{{.Column.Name}}*/@{{.Column.Name}}", "NULL", 1) + } + {{- else if .HasSqlcSlice }} if len({{$arg.VariableForField .}}) > 0 { for _, v := range {{$arg.VariableForField .}} { queryParams = append(queryParams, v) @@ -140,6 +153,8 @@ func (q *Queries) {{.MethodName}}(ctx context.Context, {{ dbarg }} {{.Arg.Pair}} } else { query = strings.Replace(query, "/*SLICE:{{.Column.Name}}*/?", "NULL", 1) } + {{- else if $arg.NamedArgs }} + queryParams = append(queryParams, {{$arg.NamedArgForField .}}) {{- else }} queryParams = append(queryParams, {{$arg.VariableForField .}}) {{- end }} @@ -149,6 +164,18 @@ func (q *Queries) {{.MethodName}}(ctx context.Context, {{ dbarg }} {{.Arg.Pair}} in a struct), because .Arg.HasSqlcSlices further up above was true, this section is 100% a slice (impossible to get here otherwise). */}} + {{- if .Arg.NamedArgs }} + if len({{.Arg.Name}}) > 0 { + var placeholders []string + for _, v := range {{.Arg.Name}} { + queryParams = append(queryParams, v) + placeholders = append(placeholders, "@p"+strconv.Itoa(len(queryParams))) + } + query = strings.Replace(query, "/*SLICE:{{.Arg.Column.Name}}*/@{{.Arg.Column.Name}}", strings.Join(placeholders, ","), 1) + } else { + query = strings.Replace(query, "/*SLICE:{{.Arg.Column.Name}}*/@{{.Arg.Column.Name}}", "NULL", 1) + } + {{- else }} if len({{.Arg.Name}}) > 0 { for _, v := range {{.Arg.Name}} { queryParams = append(queryParams, v) @@ -157,6 +184,7 @@ func (q *Queries) {{.MethodName}}(ctx context.Context, {{ dbarg }} {{.Arg.Pair}} } else { query = strings.Replace(query, "/*SLICE:{{.Arg.Column.Name}}*/?", "NULL", 1) } + {{- end }} {{- end }} {{- if emitPreparedQueries }} {{ queryRetval . }} {{ queryMethod . }}(ctx, nil, query, queryParams...) diff --git a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/exec.json b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/exec.json new file mode 100644 index 0000000000..f4e21983b6 --- /dev/null +++ b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/exec.json @@ -0,0 +1 @@ +{"contexts": ["base"]} diff --git a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/db.go b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/db.go new file mode 100644 index 0000000000..32faa9f2b8 --- /dev/null +++ b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/models.go b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/models.go new file mode 100644 index 0000000000..4073af03ce --- /dev/null +++ b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/models.go @@ -0,0 +1,21 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "database/sql" +) + +type Author struct { + ID int64 + Name string + Bio sql.NullString +} + +type Book struct { + ID int64 + AuthorID int64 + Title string +} diff --git a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/query.sql.go b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/query.sql.go new file mode 100644 index 0000000000..6ca10472c2 --- /dev/null +++ b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/query.sql.go @@ -0,0 +1,150 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package querytest + +import ( + "context" + "database/sql" + "strconv" + "strings" +) + +const createAuthor = `-- name: CreateAuthor :exec +INSERT INTO authors (id, name, bio) VALUES (@id, @name, @bio); +` + +type CreateAuthorParams struct { + ID int64 + Name string + Bio sql.NullString +} + +func (q *Queries) CreateAuthor(ctx context.Context, arg CreateAuthorParams) error { + _, err := q.db.ExecContext(ctx, createAuthor, sql.Named("id", arg.ID), sql.Named("name", arg.Name), sql.Named("bio", arg.Bio)) + return err +} + +const listAuthorsByIDs = `-- name: ListAuthorsByIDs :many +SELECT id, name FROM authors WHERE id IN (/*SLICE:ids*/@ids) ORDER BY id; +` + +type ListAuthorsByIDsRow struct { + ID int64 + Name string +} + +func (q *Queries) ListAuthorsByIDs(ctx context.Context, ids []int64) ([]ListAuthorsByIDsRow, error) { + query := listAuthorsByIDs + var queryParams []any + if len(ids) > 0 { + var placeholders []string + for _, v := range ids { + queryParams = append(queryParams, v) + placeholders = append(placeholders, "@p"+strconv.Itoa(len(queryParams))) + } + query = strings.Replace(query, "/*SLICE:ids*/@ids", strings.Join(placeholders, ","), 1) + } else { + query = strings.Replace(query, "/*SLICE:ids*/@ids", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAuthorsByIDsRow + for rows.Next() { + var i ListAuthorsByIDsRow + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAuthorsByIDsAndName = `-- name: ListAuthorsByIDsAndName :many +SELECT id, name FROM authors WHERE name <> @name AND id IN (/*SLICE:ids*/@ids) AND bio IS NULL ORDER BY id; +` + +type ListAuthorsByIDsAndNameParams struct { + Name string + Ids []int64 +} + +type ListAuthorsByIDsAndNameRow struct { + ID int64 + Name string +} + +func (q *Queries) ListAuthorsByIDsAndName(ctx context.Context, arg ListAuthorsByIDsAndNameParams) ([]ListAuthorsByIDsAndNameRow, error) { + query := listAuthorsByIDsAndName + var queryParams []any + queryParams = append(queryParams, sql.Named("name", arg.Name)) + if len(arg.Ids) > 0 { + var placeholders []string + for _, v := range arg.Ids { + queryParams = append(queryParams, v) + placeholders = append(placeholders, "@p"+strconv.Itoa(len(queryParams))) + } + query = strings.Replace(query, "/*SLICE:ids*/@ids", strings.Join(placeholders, ","), 1) + } else { + query = strings.Replace(query, "/*SLICE:ids*/@ids", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAuthorsByIDsAndNameRow + for rows.Next() { + var i ListAuthorsByIDsAndNameRow + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const rowCount = `-- name: RowCount :one +SELECT CAST(@@ROWCOUNT AS INT) AS row_count; +` + +func (q *Queries) RowCount(ctx context.Context) (int32, error) { + row := q.db.QueryRowContext(ctx, rowCount) + var row_count int32 + err := row.Scan(&row_count) + return row_count, err +} + +const updateBio = `-- name: UpdateBio :execrows +UPDATE authors SET bio = @bio WHERE id = @id; +` + +type UpdateBioParams struct { + Bio sql.NullString + ID int64 +} + +func (q *Queries) UpdateBio(ctx context.Context, arg UpdateBioParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateBio, sql.Named("bio", arg.Bio), sql.Named("id", arg.ID)) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/query.sql b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/query.sql new file mode 100644 index 0000000000..5de1c42a5d --- /dev/null +++ b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/query.sql @@ -0,0 +1,14 @@ +-- name: CreateAuthor :exec +INSERT INTO authors (id, name, bio) VALUES (sqlc.arg(id), sqlc.arg(name), sqlc.narg(bio)); + +-- name: UpdateBio :execrows +UPDATE authors SET bio = sqlc.narg(bio) WHERE id = @id; + +-- name: ListAuthorsByIDs :many +SELECT id, name FROM authors WHERE id IN (sqlc.slice(ids)) ORDER BY id; + +-- name: ListAuthorsByIDsAndName :many +SELECT id, name FROM authors WHERE name <> @name AND id IN (sqlc.slice(ids)) AND bio IS NULL ORDER BY id; + +-- name: RowCount :one +SELECT CAST(@@ROWCOUNT AS INT) AS row_count; diff --git a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/schema.sql b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/schema.sql new file mode 100644 index 0000000000..95887bf36e --- /dev/null +++ b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/schema.sql @@ -0,0 +1,11 @@ +CREATE TABLE authors ( + id BIGINT PRIMARY KEY, + name NVARCHAR(50) NOT NULL, + bio NVARCHAR(MAX) +); + +CREATE TABLE books ( + id BIGINT PRIMARY KEY, + author_id BIGINT NOT NULL, + title NVARCHAR(100) NOT NULL +); diff --git a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/sqlc.yaml b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/sqlc.yaml new file mode 100644 index 0000000000..94b0dc8896 --- /dev/null +++ b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/sqlc.yaml @@ -0,0 +1,10 @@ +version: "2" +sql: + - engine: "mssql" + schema: "schema.sql" + queries: "query.sql" + gen: + go: + package: "querytest" + out: "go" + sql_package: "database/sql" diff --git a/internal/sql/preprocess/CLAUDE.md b/internal/sql/preprocess/CLAUDE.md index a7f4d9aafe..2f8a48f67f 100644 --- a/internal/sql/preprocess/CLAUDE.md +++ b/internal/sql/preprocess/CLAUDE.md @@ -20,6 +20,7 @@ Native placeholders by dialect: | postgresql | `$1` | sqlc syntax | | mysql | `?` | user variable, left alone | | sqlite | `?1` | sqlc syntax | +| mssql | `@name` | native, kept as written; names compare without regard to case | GoogleSQL and ClickHouse are deliberately absent. They handle their own parameter syntax, so `File` returns their source unchanged and sqlc syntax is diff --git a/internal/sql/preprocess/dialect.go b/internal/sql/preprocess/dialect.go index 4d8d4ae85b..4098512086 100644 --- a/internal/sql/preprocess/dialect.go +++ b/internal/sql/preprocess/dialect.go @@ -14,6 +14,8 @@ const ( StyleQuestion // StyleOrdinal numbers parameters as ?1, ?2, ... (SQLite) StyleOrdinal + // StyleAt names parameters as @name (SQL Server) + StyleAt ) // Dialect describes the lexical rules the preprocessor needs to walk a query @@ -88,6 +90,13 @@ var dialects = map[config.Engine]Dialect{ Backtick: true, Backslash: true, }, + // SQL Server's @name is already a named parameter, so sqlc.arg(name) + // becomes one. Its comments nest and its identifiers keep their case. + config.EngineMSSQL: { + Style: StyleAt, + AtSign: true, + NestedBlockComment: true, + }, // ClickHouse binds with an unnumbered ?, like MySQL, and its identifiers // keep their case. config.EngineClickHouse: { diff --git a/internal/sql/preprocess/preprocess.go b/internal/sql/preprocess/preprocess.go index ad25bc9b2f..ff9c92880a 100644 --- a/internal/sql/preprocess/preprocess.go +++ b/internal/sql/preprocess/preprocess.go @@ -347,11 +347,22 @@ func number(d Dialect, occs []occurrence) *named.ParamSet { numbs[occ.number] = true } ps := named.NewParamSet(numbs, d.hasNamedSupport()) + // SQL Server compares parameter names without regard to case, so @Name + // and @name are one parameter, named by its first spelling. + spelling := map[string]string{} for i := range occs { occ := &occs[i] if occ.kind == kindEmbed || occ.kind == kindNative { continue } + if d.Style == StyleAt { + key := strings.ToLower(occ.name) + if first, ok := spelling[key]; ok { + occ.name = first + } else { + spelling[key] = occ.name + } + } occ.number = ps.Add(param(occ)) } return ps @@ -389,6 +400,15 @@ func (d Dialect) replacement(occ *occurrence) string { return fmt.Sprintf("/*SLICE:%s*/?", occ.name) } return fmt.Sprintf("?%d", occ.number) + case StyleAt: + if occ.kind == kindSlice { + return fmt.Sprintf("/*SLICE:%s*/@%s", occ.name, occ.name) + } + // A bare @name is already native; keep the spelling the user wrote. + if strings.HasPrefix(occ.ident, "@") { + return occ.ident + } + return "@" + occ.name default: if occ.kind == kindSlice { return fmt.Sprintf("/*SLICE:%s*/?", occ.name) diff --git a/internal/sql/preprocess/scan.go b/internal/sql/preprocess/scan.go index b2b4b1f534..a6245e5704 100644 --- a/internal/sql/preprocess/scan.go +++ b/internal/sql/preprocess/scan.go @@ -33,7 +33,8 @@ func (l *lexer) scan(start, end int) []occurrence { } i = j - case c == '@' && l.d.AtSign && isIdentStart(l.at(i+1)): + // @@name is a system function, such as SQL Server's @@ROWCOUNT. + case c == '@' && l.d.AtSign && isIdentStart(l.at(i+1)) && l.at(i-1) != '@': j := l.identEnd(i + 1) out = append(out, occurrence{ kind: kindArg, diff --git a/internal/sql/preprocess/testdata/mssql/arg/input.sql b/internal/sql/preprocess/testdata/mssql/arg/input.sql new file mode 100644 index 0000000000..c689beee0c --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/arg/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = sqlc.arg(x) AND b = sqlc.narg(y); diff --git a/internal/sql/preprocess/testdata/mssql/arg/output.sql b/internal/sql/preprocess/testdata/mssql/arg/output.sql new file mode 100644 index 0000000000..9dd09b1637 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/arg/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = @x AND b = @y; diff --git a/internal/sql/preprocess/testdata/mssql/arg/side_table.json b/internal/sql/preprocess/testdata/mssql/arg/side_table.json new file mode 100644 index 0000000000..a67d864ad8 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/arg/side_table.json @@ -0,0 +1,24 @@ +[ + { + "start": 0, + "end": 45, + "dollar": true, + "params": [ + { + "number": 1, + "location": 31, + "origin": 31, + "name": "x", + "named": true + }, + { + "number": 2, + "location": 42, + "origin": 51, + "name": "y", + "named": true, + "nullable": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/mssql/at_sign_keeps_spelling/input.sql b/internal/sql/preprocess/testdata/mssql/at_sign_keeps_spelling/input.sql new file mode 100644 index 0000000000..f67f702a35 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/at_sign_keeps_spelling/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = @Name AND b = sqlc.arg(name) AND c = @NAME; diff --git a/internal/sql/preprocess/testdata/mssql/at_sign_keeps_spelling/output.sql b/internal/sql/preprocess/testdata/mssql/at_sign_keeps_spelling/output.sql new file mode 100644 index 0000000000..145c90ded9 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/at_sign_keeps_spelling/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = @Name AND b = @Name AND c = @NAME; diff --git a/internal/sql/preprocess/testdata/mssql/at_sign_keeps_spelling/side_table.json b/internal/sql/preprocess/testdata/mssql/at_sign_keeps_spelling/side_table.json new file mode 100644 index 0000000000..438b1d69e2 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/at_sign_keeps_spelling/side_table.json @@ -0,0 +1,30 @@ +[ + { + "start": 0, + "end": 65, + "dollar": true, + "params": [ + { + "number": 1, + "location": 31, + "origin": 31, + "name": "Name", + "named": true + }, + { + "number": 1, + "location": 45, + "origin": 45, + "name": "Name", + "named": true + }, + { + "number": 1, + "location": 59, + "origin": 68, + "name": "Name", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/mssql/embed/input.sql b/internal/sql/preprocess/testdata/mssql/embed/input.sql new file mode 100644 index 0000000000..34ec985f7f --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/embed/input.sql @@ -0,0 +1 @@ +SELECT sqlc.embed(users) FROM users WHERE id = @id; diff --git a/internal/sql/preprocess/testdata/mssql/embed/output.sql b/internal/sql/preprocess/testdata/mssql/embed/output.sql new file mode 100644 index 0000000000..7493847494 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/embed/output.sql @@ -0,0 +1 @@ +SELECT users.* FROM users WHERE id = @id; diff --git a/internal/sql/preprocess/testdata/mssql/embed/side_table.json b/internal/sql/preprocess/testdata/mssql/embed/side_table.json new file mode 100644 index 0000000000..fdd0a53517 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/embed/side_table.json @@ -0,0 +1,24 @@ +[ + { + "start": 0, + "end": 41, + "dollar": true, + "params": [ + { + "number": 1, + "location": 37, + "origin": 47, + "name": "id", + "named": true + } + ], + "embeds": [ + { + "table": "users", + "location": 7, + "origin": 7, + "orig": "sqlc.embed(users)" + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/mssql/skip_nested_block_comment/input.sql b/internal/sql/preprocess/testdata/mssql/skip_nested_block_comment/input.sql new file mode 100644 index 0000000000..cd8a15e7bf --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/skip_nested_block_comment/input.sql @@ -0,0 +1 @@ +SELECT id FROM users /* outer /* sqlc.arg(a) */ @b */ WHERE id = @id; diff --git a/internal/sql/preprocess/testdata/mssql/skip_nested_block_comment/output.sql b/internal/sql/preprocess/testdata/mssql/skip_nested_block_comment/output.sql new file mode 100644 index 0000000000..cd8a15e7bf --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/skip_nested_block_comment/output.sql @@ -0,0 +1 @@ +SELECT id FROM users /* outer /* sqlc.arg(a) */ @b */ WHERE id = @id; diff --git a/internal/sql/preprocess/testdata/mssql/skip_nested_block_comment/side_table.json b/internal/sql/preprocess/testdata/mssql/skip_nested_block_comment/side_table.json new file mode 100644 index 0000000000..94445664da --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/skip_nested_block_comment/side_table.json @@ -0,0 +1,16 @@ +[ + { + "start": 0, + "end": 69, + "dollar": true, + "params": [ + { + "number": 1, + "location": 65, + "origin": 65, + "name": "id", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/mssql/slice/input.sql b/internal/sql/preprocess/testdata/mssql/slice/input.sql new file mode 100644 index 0000000000..6a76a052a4 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/slice/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE id IN (sqlc.slice(ids)) AND name = @name; diff --git a/internal/sql/preprocess/testdata/mssql/slice/output.sql b/internal/sql/preprocess/testdata/mssql/slice/output.sql new file mode 100644 index 0000000000..2816aaef80 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/slice/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE id IN (/*SLICE:ids*/@ids) AND name = @name; diff --git a/internal/sql/preprocess/testdata/mssql/slice/side_table.json b/internal/sql/preprocess/testdata/mssql/slice/side_table.json new file mode 100644 index 0000000000..32867f1747 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/slice/side_table.json @@ -0,0 +1,24 @@ +[ + { + "start": 0, + "end": 70, + "dollar": true, + "params": [ + { + "number": 1, + "location": 47, + "origin": 34, + "name": "ids", + "named": true, + "slice": true + }, + { + "number": 2, + "location": 64, + "origin": 62, + "name": "name", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/mssql/system_functions_are_not_parameters/input.sql b/internal/sql/preprocess/testdata/mssql/system_functions_are_not_parameters/input.sql new file mode 100644 index 0000000000..89714db7ab --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/system_functions_are_not_parameters/input.sql @@ -0,0 +1 @@ +UPDATE users SET name = @name WHERE id = @id; SELECT @@ROWCOUNT; diff --git a/internal/sql/preprocess/testdata/mssql/system_functions_are_not_parameters/output.sql b/internal/sql/preprocess/testdata/mssql/system_functions_are_not_parameters/output.sql new file mode 100644 index 0000000000..89714db7ab --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/system_functions_are_not_parameters/output.sql @@ -0,0 +1 @@ +UPDATE users SET name = @name WHERE id = @id; SELECT @@ROWCOUNT; diff --git a/internal/sql/preprocess/testdata/mssql/system_functions_are_not_parameters/side_table.json b/internal/sql/preprocess/testdata/mssql/system_functions_are_not_parameters/side_table.json new file mode 100644 index 0000000000..ad18705e8f --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/system_functions_are_not_parameters/side_table.json @@ -0,0 +1,28 @@ +[ + { + "start": 0, + "end": 45, + "dollar": true, + "params": [ + { + "number": 1, + "location": 24, + "origin": 24, + "name": "name", + "named": true + }, + { + "number": 2, + "location": 41, + "origin": 41, + "name": "id", + "named": true + } + ] + }, + { + "start": 45, + "end": 64, + "dollar": true + } +] From 948ff95d34965f02b46e13ef4f957bc8e20ba92d Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:48:11 -0800 Subject: [PATCH 07/43] feat(mssql): support sqlc.embed The core analyzer path expanded a rewritten sqlc.embed(t) star into the table's columns but never folded them back into one embedded column, so SQL Server queries got flat rows instead of the table's struct. Fold each embed star's columns into a column naming its source table, which also resolves an embed of an alias to the table behind it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/compiler/parse_core.go | 40 +++++++++++++++++ .../mssql_sqlc_macros/mssql/go/query.sql.go | 44 +++++++++++++++++++ .../mssql_sqlc_macros/mssql/query.sql | 6 +++ 3 files changed, 90 insertions(+) diff --git a/internal/compiler/parse_core.go b/internal/compiler/parse_core.go index 2e1dfc7042..49840002cc 100644 --- a/internal/compiler/parse_core.go +++ b/internal/compiler/parse_core.go @@ -64,6 +64,7 @@ func (c *Compiler) parseQueryCore(raw *ast.RawStmt, src string, pre *preprocess. for _, col := range res.Columns { cols = append(cols, coreColumn(col)) } + cols = embedColumns(raw, cols, res.Stars, pre.Embeds) placeholders, err := placeholderNames(raw) if err != nil { return nil, err @@ -105,6 +106,45 @@ func (c *Compiler) parseQueryCore(raw *ast.RawStmt, src string, pre *preprocess. }, nil } +// embedColumns folds the columns each sqlc.embed() star expanded to into +// the one column codegen embeds the table's struct for. The table is the +// columns' source, so an embed of an alias names the table behind it. +func embedColumns(raw *ast.RawStmt, cols []*Column, stars []core.StarExpansion, embeds preprocess.EmbedSet) []*Column { + sel, ok := raw.Stmt.(*ast.SelectStmt) + if !ok || len(embeds) == 0 || sel.TargetList == nil { + return cols + } + width := map[int]int{} + for _, star := range stars { + width[star.Location] = len(star.Columns) + } + var out []*Column + i := 0 + for _, item := range sel.TargetList.Items { + res, ok := item.(*ast.ResTarget) + if !ok { + return cols + } + ref, ok := res.Val.(*ast.ColumnRef) + if !ok || !hasStarRef(ref) { + out = append(out, cols[i]) + i++ + continue + } + n, ok := width[res.Location] + if !ok || i+n > len(cols) { + return cols + } + if embed, ok := embeds.Find(ref.Location, res.Location); ok && n > 0 && cols[i].Table != nil { + out = append(out, &Column{Name: embed.Table.Name, EmbedTable: cols[i].Table}) + } else { + out = append(out, cols[i:i+n]...) + } + i += n + } + return out +} + func coreColumn(c core.Column) *Column { col := &Column{ Name: c.Name, diff --git a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/query.sql.go b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/query.sql.go index 6ca10472c2..b93d8cbec6 100644 --- a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/query.sql.go +++ b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/query.sql.go @@ -12,6 +12,50 @@ import ( "strings" ) +const authorBooks = `-- name: AuthorBooks :many +SELECT a.id, a.name, a.bio, books.id, books.author_id, books.title, books.title AS book_title +FROM authors a JOIN books ON books.author_id = a.id +WHERE a.id = @author_id +ORDER BY books.id; +` + +type AuthorBooksRow struct { + Author Author + Book Book + BookTitle string +} + +func (q *Queries) AuthorBooks(ctx context.Context, authorID int64) ([]AuthorBooksRow, error) { + rows, err := q.db.QueryContext(ctx, authorBooks, sql.Named("author_id", authorID)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AuthorBooksRow + for rows.Next() { + var i AuthorBooksRow + if err := rows.Scan( + &i.Author.ID, + &i.Author.Name, + &i.Author.Bio, + &i.Book.ID, + &i.Book.AuthorID, + &i.Book.Title, + &i.BookTitle, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const createAuthor = `-- name: CreateAuthor :exec INSERT INTO authors (id, name, bio) VALUES (@id, @name, @bio); ` diff --git a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/query.sql b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/query.sql index 5de1c42a5d..be9e4fc7a2 100644 --- a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/query.sql +++ b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/query.sql @@ -12,3 +12,9 @@ SELECT id, name FROM authors WHERE name <> @name AND id IN (sqlc.slice(ids)) AND -- name: RowCount :one SELECT CAST(@@ROWCOUNT AS INT) AS row_count; + +-- name: AuthorBooks :many +SELECT sqlc.embed(a), sqlc.embed(books), books.title AS book_title +FROM authors a JOIN books ON books.author_id = a.id +WHERE a.id = sqlc.arg(author_id) +ORDER BY books.id; From e46519f9044cbc889778f72254baf720632a2655 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:51:51 -0800 Subject: [PATCH 08/43] fix(core): let set operation branches see the enclosing query A subquery's analyzer took the scope of the query it is nested in as its outer scope. A set operation binds no scope of its own, so the branches of a UNION nested in another query got none, and a correlated reference in them, as in CROSS APPLY (SELECT ... WHERE x = a.id UNION ALL ...), failed as an unknown column. They now see the scope the set operation itself sees. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/core/analyzer/analyzer.go | 8 ++++++- .../mssql_correlated_union/mssql/exec.json | 5 +++++ .../mssql_correlated_union/mssql/query.sql | 8 +++++++ .../mssql_correlated_union/mssql/schema.sql | 10 +++++++++ .../mssql_correlated_union/mssql/stdout.json | 22 +++++++++++++++++++ 5 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 internal/endtoend/testdata/mssql_correlated_union/mssql/exec.json create mode 100644 internal/endtoend/testdata/mssql_correlated_union/mssql/query.sql create mode 100644 internal/endtoend/testdata/mssql_correlated_union/mssql/schema.sql create mode 100644 internal/endtoend/testdata/mssql_correlated_union/mssql/stdout.json diff --git a/internal/core/analyzer/analyzer.go b/internal/core/analyzer/analyzer.go index 70fa3c81bc..52c56c9520 100644 --- a/internal/core/analyzer/analyzer.go +++ b/internal/core/analyzer/analyzer.go @@ -83,10 +83,16 @@ func (a *analyzer) recordStar(s core.StarExpansion) { // placeholder inside the subquery is reported with the rest, and it sees this // query's scope, so a correlated reference resolves. func (a *analyzer) subquery(s *ast.SelectStmt) (*analyzer, error) { + // A set operation binds no scope of its own, so its branches see the + // query it is nested in. + outer := a.scope + if outer == nil { + outer = a.outer + } sub := &analyzer{ cat: a.cat, params: a.params, - outer: a.scope, + outer: outer, ctes: a.ctes, stars: a.stars, } diff --git a/internal/endtoend/testdata/mssql_correlated_union/mssql/exec.json b/internal/endtoend/testdata/mssql_correlated_union/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/mssql_correlated_union/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/mssql_correlated_union/mssql/query.sql b/internal/endtoend/testdata/mssql_correlated_union/mssql/query.sql new file mode 100644 index 0000000000..d5d6716d35 --- /dev/null +++ b/internal/endtoend/testdata/mssql_correlated_union/mssql/query.sql @@ -0,0 +1,8 @@ +-- name: Related :many +SELECT a.id, r.n +FROM authors a +CROSS APPLY ( + SELECT 1 AS n FROM books WHERE author_id = a.id + UNION ALL + SELECT 2 FROM authors WHERE id = a.id +) r; diff --git a/internal/endtoend/testdata/mssql_correlated_union/mssql/schema.sql b/internal/endtoend/testdata/mssql_correlated_union/mssql/schema.sql new file mode 100644 index 0000000000..2a49202aaa --- /dev/null +++ b/internal/endtoend/testdata/mssql_correlated_union/mssql/schema.sql @@ -0,0 +1,10 @@ +CREATE TABLE authors ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + name NVARCHAR(100) NOT NULL +); + +CREATE TABLE books ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + author_id BIGINT NOT NULL, + title NVARCHAR(200) NOT NULL +); diff --git a/internal/endtoend/testdata/mssql_correlated_union/mssql/stdout.json b/internal/endtoend/testdata/mssql_correlated_union/mssql/stdout.json new file mode 100644 index 0000000000..c0c2357a52 --- /dev/null +++ b/internal/endtoend/testdata/mssql_correlated_union/mssql/stdout.json @@ -0,0 +1,22 @@ +[ + { + "name": "Related", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "authors" + }, + { + "name": "n", + "type": { + "name": "int" + } + } + ], + "params": [] + } +] From d130dd5657600af2ec4a858c3ef1a781e406d4f1 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:51:51 -0800 Subject: [PATCH 09/43] feat(mssql): support VALUES as a derived table (VALUES ...) AS v(a, b) in FROM reached the analyzer unconverted. It is now the subquery it stands for, each row a SELECT combined with UNION ALL, and its columns are named by the alias list. A VALUES table can be joined, applied, and refer to the relations before it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../analyze_values_table/mssql/exec.json | 5 ++ .../analyze_values_table/mssql/query.sql | 16 +++++ .../analyze_values_table/mssql/schema.sql | 7 ++ .../analyze_values_table/mssql/stdout.json | 68 +++++++++++++++++++ internal/engine/mssql/convert.go | 35 ++++++++++ 5 files changed, 131 insertions(+) create mode 100644 internal/endtoend/testdata/analyze_values_table/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_values_table/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_values_table/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_values_table/mssql/stdout.json diff --git a/internal/endtoend/testdata/analyze_values_table/mssql/exec.json b/internal/endtoend/testdata/analyze_values_table/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_values_table/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_values_table/mssql/query.sql b/internal/endtoend/testdata/analyze_values_table/mssql/query.sql new file mode 100644 index 0000000000..a2224287b0 --- /dev/null +++ b/internal/endtoend/testdata/analyze_values_table/mssql/query.sql @@ -0,0 +1,16 @@ +-- name: Weights :many +SELECT a.id, a.name, v.weight +FROM authors a +JOIN (VALUES (1, 10), (2, 20)) AS v(id, weight) ON v.id = a.id; + +-- name: Correlated :many +SELECT a.id, v.n +FROM authors a +CROSS APPLY (VALUES (1), (2)) v(n) +WHERE v.n < a.id; + +-- name: Lateral :many +SELECT a.id +FROM authors a +CROSS APPLY (VALUES (a.id), (a.id + 1)) v(n) +WHERE v.n > 1; diff --git a/internal/endtoend/testdata/analyze_values_table/mssql/schema.sql b/internal/endtoend/testdata/analyze_values_table/mssql/schema.sql new file mode 100644 index 0000000000..3629635d2e --- /dev/null +++ b/internal/endtoend/testdata/analyze_values_table/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_values_table/mssql/stdout.json b/internal/endtoend/testdata/analyze_values_table/mssql/stdout.json new file mode 100644 index 0000000000..ed0b4e767a --- /dev/null +++ b/internal/endtoend/testdata/analyze_values_table/mssql/stdout.json @@ -0,0 +1,68 @@ +[ + { + "name": "Weights", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "authors" + }, + { + "name": "name", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 100 + } + ] + }, + "table": "authors" + }, + { + "name": "weight", + "type": { + "name": "int" + } + } + ], + "params": [] + }, + { + "name": "Correlated", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "authors" + }, + { + "name": "n", + "type": { + "name": "int" + } + } + ], + "params": [] + }, + { + "name": "Lateral", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "authors" + } + ], + "params": [] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index 452152b96b..f10b53e69e 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -324,6 +324,8 @@ func (c *cc) convertTableReference(ref tsql.TableReference) ast.Node { sub.Alias = &ast.Alias{Aliasname: &alias} } return sub + case *tsql.InlineDerivedTable: + return c.convertInlineDerivedTable(t) case *tsql.QualifiedJoin: join := &ast.JoinExpr{ Larg: c.convertTableReference(t.FirstTableReference), @@ -369,6 +371,39 @@ func (c *cc) convertTableReference(ref tsql.TableReference) ast.Node { } } +// convertInlineDerivedTable converts (VALUES ...) AS v(a, b) to the +// subquery it stands for, its rows selected one after another with UNION +// ALL, so the relation's columns are typed from its first row. +func (c *cc) convertInlineDerivedTable(n *tsql.InlineDerivedTable) ast.Node { + var query *ast.SelectStmt + for _, row := range n.RowValues { + sel := &ast.SelectStmt{TargetList: &ast.List{}} + for _, val := range row.ColumnValues { + sel.TargetList.Items = append(sel.TargetList.Items, &ast.ResTarget{ + Val: c.convertScalarExpression(val), + Location: c.loc(val), + }) + } + if query == nil { + query = sel + continue + } + query = &ast.SelectStmt{Op: ast.Union, All: true, Larg: query, Rarg: sel} + } + if query == nil { + return todo(n) + } + sub := &ast.RangeSubselect{Subquery: query} + if n.Alias != nil { + alias := identifierValue(n.Alias) + sub.Alias = &ast.Alias{Aliasname: &alias, Colnames: &ast.List{}} + for _, col := range n.Columns { + sub.Alias.Colnames.Items = append(sub.Alias.Colnames.Items, NewIdentifier(col.Value)) + } + } + return sub +} + // rangeFunction is a call in FROM to a function returning rows of the // columns defs declares. func (c *cc) rangeFunction(n tsql.Node, name string, args []tsql.ScalarExpression, alias *tsql.Identifier, defs []ast.Node) *ast.RangeFunction { From 6bdd726fb76737a25adb910442f9336b893d77dc Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:31:44 -0800 Subject: [PATCH 10/43] fix(mssql): resolve INSERTED and DELETED in OUTPUT against the target The converter stripped the INSERTED and DELETED qualifiers from an OUTPUT clause so the columns resolved against the target table. With a FROM clause joining other tables, the bare names were ambiguous, so DELETE b OUTPUT DELETED.id FROM books b JOIN authors a ... failed. And a star lost its qualifier, so OUTPUT INSERTED.* was rewritten into broken SQL: the expansion replaced only the star's first character and named columns without the prefix SQL Server requires. A dialect may now name, in returning_aliases, the pseudo-relations its RETURNING list qualifies the target's columns with. The analyzer reads them as the target while it types that list, and the converter keeps the qualifiers, so INSERTED.* expands to inserted.id, inserted.name, ... Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/core/analyzer/analyzer.go | 4 + internal/core/analyzer/dml.go | 17 ++- internal/core/analyzer/expr.go | 3 + internal/core/analyzer/projection.go | 3 + internal/core/dialect.go | 18 +++ internal/core/seed/seed.go | 10 ++ .../mssql_output_clause/mssql/exec.json | 1 + .../mssql_output_clause/mssql/go/db.go | 31 +++++ .../mssql_output_clause/mssql/go/models.go | 22 ++++ .../mssql_output_clause/mssql/go/query.sql.go | 123 ++++++++++++++++++ .../mssql_output_clause/mssql/query.sql | 16 +++ .../mssql_output_clause/mssql/schema.sql | 2 + .../mssql_output_clause/mssql/sqlc.yaml | 10 ++ internal/engine/mssql/convert.go | 24 +--- internal/engine/mssql/dialect/dialect.json | 1 + 15 files changed, 261 insertions(+), 24 deletions(-) create mode 100644 internal/endtoend/testdata/mssql_output_clause/mssql/exec.json create mode 100644 internal/endtoend/testdata/mssql_output_clause/mssql/go/db.go create mode 100644 internal/endtoend/testdata/mssql_output_clause/mssql/go/models.go create mode 100644 internal/endtoend/testdata/mssql_output_clause/mssql/go/query.sql.go create mode 100644 internal/endtoend/testdata/mssql_output_clause/mssql/query.sql create mode 100644 internal/endtoend/testdata/mssql_output_clause/mssql/schema.sql create mode 100644 internal/endtoend/testdata/mssql_output_clause/mssql/sqlc.yaml diff --git a/internal/core/analyzer/analyzer.go b/internal/core/analyzer/analyzer.go index 52c56c9520..356d7c9eea 100644 --- a/internal/core/analyzer/analyzer.go +++ b/internal/core/analyzer/analyzer.go @@ -66,6 +66,10 @@ type analyzer struct { // resolving guards against an alias that refers to itself. resolving map[string]bool + // returning maps the names a RETURNING list may give the statement's + // target to the target's own, while that list is analyzed. + returning map[string]string + // stars are the expansions every star in the statement asked for, shared // with the analyzers of the queries nested in it so one statement reports // all of them. diff --git a/internal/core/analyzer/dml.go b/internal/core/analyzer/dml.go index 597723cedd..8b7554b638 100644 --- a/internal/core/analyzer/dml.go +++ b/internal/core/analyzer/dml.go @@ -24,7 +24,7 @@ func (a *analyzer) analyzeInsert(s *ast.InsertStmt) error { if err := a.bindInsertValues(s.SelectStmt, rel, targets); err != nil { return err } - return a.projectReturning(s.ReturningList) + return a.projectReturning(s.ReturningList, rel) } func (a *analyzer) analyzeUpdate(s *ast.UpdateStmt) error { @@ -57,7 +57,7 @@ func (a *analyzer) analyzeUpdate(s *ast.UpdateStmt) error { return fmt.Errorf("where: %w", err) } } - return a.projectReturning(s.ReturningList) + return a.projectReturning(s.ReturningList, target) } func (a *analyzer) analyzeDelete(s *ast.DeleteStmt) error { @@ -75,7 +75,7 @@ func (a *analyzer) analyzeDelete(s *ast.DeleteStmt) error { return fmt.Errorf("where: %w", err) } } - return a.projectReturning(s.ReturningList) + return a.projectReturning(s.ReturningList, sc.rels[0]) } // relationScope builds the scope a DML statement operates on: the relations it @@ -177,7 +177,16 @@ func (a *analyzer) bindValue(rel scopeRel, target *core.ClassColumn, v ast.Node) return err } -func (a *analyzer) projectReturning(l *ast.List) error { +func (a *analyzer) projectReturning(l *ast.List, target scopeRel) error { + // A dialect's names for the target, such as SQL Server's INSERTED and + // DELETED, refer to it within the list. + if aliases := a.cat.ReturningAliases(); len(aliases) > 0 { + a.returning = map[string]string{} + for _, name := range aliases { + a.returning[name] = target.alias + } + defer func() { a.returning = nil }() + } for _, item := range listItems(l) { rt, ok := item.(*ast.ResTarget) if !ok { diff --git a/internal/core/analyzer/expr.go b/internal/core/analyzer/expr.go index ec7120b96c..14b23bcfc1 100644 --- a/internal/core/analyzer/expr.go +++ b/internal/core/analyzer/expr.go @@ -160,6 +160,9 @@ func (a *analyzer) typeColumnRef(c *ast.ColumnRef) (exprType, error) { relation = parts[0] column = parts[1] } + if target, ok := a.returning[relation]; ok { + relation = target + } rel, col, ok, err := a.resolveColumn(relation, column) if err != nil { return exprType{}, err diff --git a/internal/core/analyzer/projection.go b/internal/core/analyzer/projection.go index 17fc3cbf47..a4ac3d6be0 100644 --- a/internal/core/analyzer/projection.go +++ b/internal/core/analyzer/projection.go @@ -129,6 +129,9 @@ func (a *analyzer) emitStar(rt *ast.ResTarget, fields []string) { if len(fields) > 1 { relName = fields[0] } + if target, ok := a.returning[relName]; ok { + relName = target + } // The star is reported along with the columns it covers, so the query text // can be rewritten to name them. star := core.StarExpansion{Location: rt.Location, Fields: fields} diff --git a/internal/core/dialect.go b/internal/core/dialect.go index e9723c0fb1..3f8133a825 100644 --- a/internal/core/dialect.go +++ b/internal/core/dialect.go @@ -82,6 +82,10 @@ const FlagQualifyDuplicateColumns = "columns.qualify_duplicates" // fill them with the type's default instead. const FlagOuterJoinNullable = "joins.outer_nullable" +// FlagReturningAliases holds, comma separated, the names a RETURNING list +// may use for the statement's target. +const FlagReturningAliases = "returning.aliases" + // 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. @@ -211,6 +215,20 @@ func (c *Catalog) OuterJoinsNullable() bool { return v == "true" } +// ReturningAliases lists the names a RETURNING list may qualify a column of +// the statement's target with, as SQL Server's OUTPUT clause reads +// INSERTED.id and DELETED.id. +func (c *Catalog) ReturningAliases() []string { + if c.dialectOID == 0 { + return nil + } + v, _ := c.DialectFlag(c.dialectOID, FlagReturningAliases) + if v == "" { + return nil + } + return strings.Split(v, ",") +} + // 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 daedab961c..cfbedf69fa 100644 --- a/internal/core/seed/seed.go +++ b/internal/core/seed/seed.go @@ -88,6 +88,11 @@ type Settings struct { // nullable, as SQL Server fills an unmatched row with NULL. OuterJoinNullable bool `json:"outer_join_nullable,omitempty"` + // ReturningAliases are the names a RETURNING list may qualify a column + // of the statement's target with, as SQL Server's OUTPUT clause has + // INSERTED and DELETED. + ReturningAliases []string `json:"returning_aliases,omitempty"` + // Comparison operators are registered as (T, T) -> Bool for every type in // ComparisonCategories. Comparison []string `json:"comparison,omitempty"` @@ -669,6 +674,11 @@ func (b *builder) consts() error { return err } } + if len(b.settings.ReturningAliases) > 0 { + if err := b.cat.SetDialectFlag(b.dialectOID, core.FlagReturningAliases, strings.Join(b.settings.ReturningAliases, ",")); 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/mssql_output_clause/mssql/exec.json b/internal/endtoend/testdata/mssql_output_clause/mssql/exec.json new file mode 100644 index 0000000000..f4e21983b6 --- /dev/null +++ b/internal/endtoend/testdata/mssql_output_clause/mssql/exec.json @@ -0,0 +1 @@ +{"contexts": ["base"]} diff --git a/internal/endtoend/testdata/mssql_output_clause/mssql/go/db.go b/internal/endtoend/testdata/mssql_output_clause/mssql/go/db.go new file mode 100644 index 0000000000..32faa9f2b8 --- /dev/null +++ b/internal/endtoend/testdata/mssql_output_clause/mssql/go/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/endtoend/testdata/mssql_output_clause/mssql/go/models.go b/internal/endtoend/testdata/mssql_output_clause/mssql/go/models.go new file mode 100644 index 0000000000..730bdef708 --- /dev/null +++ b/internal/endtoend/testdata/mssql_output_clause/mssql/go/models.go @@ -0,0 +1,22 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "database/sql" +) + +type Author struct { + ID int64 + Name string + Bio sql.NullString +} + +type Book struct { + ID int64 + AuthorID int64 + Title string + Price sql.NullFloat64 +} diff --git a/internal/endtoend/testdata/mssql_output_clause/mssql/go/query.sql.go b/internal/endtoend/testdata/mssql_output_clause/mssql/go/query.sql.go new file mode 100644 index 0000000000..76833127fa --- /dev/null +++ b/internal/endtoend/testdata/mssql_output_clause/mssql/go/query.sql.go @@ -0,0 +1,123 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package querytest + +import ( + "context" + "database/sql" +) + +const createBook = `-- name: CreateBook :one +INSERT INTO books (id, author_id, title) OUTPUT inserted.id, inserted.author_id, inserted.title, inserted.price VALUES (@id, @author_id, @title); +` + +type CreateBookParams struct { + ID int64 + AuthorID int64 + Title string +} + +func (q *Queries) CreateBook(ctx context.Context, arg CreateBookParams) (Book, error) { + row := q.db.QueryRowContext(ctx, createBook, sql.Named("id", arg.ID), sql.Named("author_id", arg.AuthorID), sql.Named("title", arg.Title)) + var i Book + err := row.Scan( + &i.ID, + &i.AuthorID, + &i.Title, + &i.Price, + ) + return i, err +} + +const deleteBook = `-- name: DeleteBook :one +DELETE FROM books OUTPUT deleted.id, deleted.author_id, deleted.title, deleted.price WHERE id = @id; +` + +func (q *Queries) DeleteBook(ctx context.Context, id int64) (Book, error) { + row := q.db.QueryRowContext(ctx, deleteBook, sql.Named("id", id)) + var i Book + err := row.Scan( + &i.ID, + &i.AuthorID, + &i.Title, + &i.Price, + ) + return i, err +} + +const deleteByAuthor = `-- name: DeleteByAuthor :many +DELETE b OUTPUT DELETED.id, DELETED.title +FROM books b JOIN authors a ON a.id = b.author_id +WHERE a.name = @name; +` + +type DeleteByAuthorRow struct { + ID int64 + Title string +} + +func (q *Queries) DeleteByAuthor(ctx context.Context, name string) ([]DeleteByAuthorRow, error) { + rows, err := q.db.QueryContext(ctx, deleteByAuthor, sql.Named("name", name)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DeleteByAuthorRow + for rows.Next() { + var i DeleteByAuthorRow + if err := rows.Scan(&i.ID, &i.Title); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const retitleByAuthor = `-- name: RetitleByAuthor :many +UPDATE b SET title = @title +OUTPUT INSERTED.id, DELETED.title AS old_title +FROM books b JOIN authors a ON a.id = b.author_id +WHERE a.name = @name; +` + +type RetitleByAuthorParams struct { + Title string + Name string +} + +type RetitleByAuthorRow struct { + ID int64 + OldTitle string +} + +func (q *Queries) RetitleByAuthor(ctx context.Context, arg RetitleByAuthorParams) ([]RetitleByAuthorRow, error) { + rows, err := q.db.QueryContext(ctx, retitleByAuthor, sql.Named("title", arg.Title), sql.Named("name", arg.Name)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RetitleByAuthorRow + for rows.Next() { + var i RetitleByAuthorRow + if err := rows.Scan(&i.ID, &i.OldTitle); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/endtoend/testdata/mssql_output_clause/mssql/query.sql b/internal/endtoend/testdata/mssql_output_clause/mssql/query.sql new file mode 100644 index 0000000000..c08f806278 --- /dev/null +++ b/internal/endtoend/testdata/mssql_output_clause/mssql/query.sql @@ -0,0 +1,16 @@ +-- name: CreateBook :one +INSERT INTO books (id, author_id, title) OUTPUT INSERTED.* VALUES (@id, @author_id, @title); + +-- name: RetitleByAuthor :many +UPDATE b SET title = @title +OUTPUT INSERTED.id, DELETED.title AS old_title +FROM books b JOIN authors a ON a.id = b.author_id +WHERE a.name = @name; + +-- name: DeleteByAuthor :many +DELETE b OUTPUT DELETED.id, DELETED.title +FROM books b JOIN authors a ON a.id = b.author_id +WHERE a.name = @name; + +-- name: DeleteBook :one +DELETE FROM books OUTPUT DELETED.* WHERE id = @id; diff --git a/internal/endtoend/testdata/mssql_output_clause/mssql/schema.sql b/internal/endtoend/testdata/mssql_output_clause/mssql/schema.sql new file mode 100644 index 0000000000..8b4a7c39cb --- /dev/null +++ b/internal/endtoend/testdata/mssql_output_clause/mssql/schema.sql @@ -0,0 +1,2 @@ +CREATE TABLE authors (id BIGINT IDENTITY(1,1) PRIMARY KEY, name NVARCHAR(100) NOT NULL, bio NVARCHAR(MAX)); +CREATE TABLE books (id BIGINT PRIMARY KEY, author_id BIGINT NOT NULL, title NVARCHAR(200) NOT NULL, price FLOAT); diff --git a/internal/endtoend/testdata/mssql_output_clause/mssql/sqlc.yaml b/internal/endtoend/testdata/mssql_output_clause/mssql/sqlc.yaml new file mode 100644 index 0000000000..94b0dc8896 --- /dev/null +++ b/internal/endtoend/testdata/mssql_output_clause/mssql/sqlc.yaml @@ -0,0 +1,10 @@ +version: "2" +sql: + - engine: "mssql" + schema: "schema.sql" + queries: "query.sql" + gen: + go: + package: "querytest" + out: "go" + sql_package: "database/sql" diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index f10b53e69e..228bda9780 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -886,36 +886,20 @@ func (c *cc) convertFunctionCall(n *tsql.FunctionCall) *ast.FuncCall { } // 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. +// dialect names INSERTED and DELETED as the target's aliases there, so the +// qualifiers are kept, and a star they qualify expands to columns they +// qualify too. func (c *cc) convertOutputClause(n *tsql.OutputClause) *ast.List { if n == nil || len(n.SelectColumns) == 0 { return nil } list := &ast.List{} for _, elem := range n.SelectColumns { - target := c.convertSelectElement(elem) - if res, ok := target.(*ast.ResTarget); ok { - stripOutputQualifier(res.Val) - } - list.Items = append(list.Items, target) + list.Items = append(list.Items, c.convertSelectElement(elem)) } return list } -func stripOutputQualifier(node ast.Node) { - ref, ok := node.(*ast.ColumnRef) - if !ok || ref.Fields == nil || len(ref.Fields.Items) < 2 { - return - } - if first, ok := ref.Fields.Items[0].(*ast.String); ok { - switch first.Str { - case "inserted", "deleted": - ref.Fields.Items = ref.Fields.Items[1:] - } - } -} - func (c *cc) convertInsertStatement(n *tsql.InsertStatement) ast.Node { if n.InsertSpecification == nil { return todo(n) diff --git a/internal/engine/mssql/dialect/dialect.json b/internal/engine/mssql/dialect/dialect.json index 701b03c3c4..1519273f43 100644 --- a/internal/engine/mssql/dialect/dialect.json +++ b/internal/engine/mssql/dialect/dialect.json @@ -10,6 +10,7 @@ "bool": "bit", "limit": "bigint", "outer_join_nullable": true, + "returning_aliases": ["inserted", "deleted"], "comparison": ["=", "<>", "!=", "<", "<=", ">", ">="], "comparison_categories": "NBSD", "arithmetic": ["+", "-", "*", "/", "%"], From 108d8a1bff4523544aa19158f79c80f481781af6 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:34:03 -0800 Subject: [PATCH 11/43] fix(mssql): type TRY_CAST and TRY_CONVERT as nullable TRY_CAST and TRY_CONVERT yield NULL for a value they cannot convert, but were converted as plain casts, which are NOT NULL over a NOT NULL operand. Their target type is now marked nullable, as SQL Server describes it. A placeholder cast this way is still typed NOT NULL by the cast, as with CAST. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../analyze_try_conversions/mssql/exec.json | 5 ++ .../analyze_try_conversions/mssql/query.sql | 7 +++ .../analyze_try_conversions/mssql/schema.sql | 6 ++ .../analyze_try_conversions/mssql/stdout.json | 60 +++++++++++++++++++ internal/engine/mssql/convert.go | 30 ++++++---- 5 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 internal/endtoend/testdata/analyze_try_conversions/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_try_conversions/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_try_conversions/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_try_conversions/mssql/stdout.json diff --git a/internal/endtoend/testdata/analyze_try_conversions/mssql/exec.json b/internal/endtoend/testdata/analyze_try_conversions/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_try_conversions/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_try_conversions/mssql/query.sql b/internal/endtoend/testdata/analyze_try_conversions/mssql/query.sql new file mode 100644 index 0000000000..adf4881cde --- /dev/null +++ b/internal/endtoend/testdata/analyze_try_conversions/mssql/query.sql @@ -0,0 +1,7 @@ +-- name: Converted :many +SELECT id, + TRY_CAST(raw AS INT) AS as_int, + TRY_CONVERT(DECIMAL(5,1), raw) AS as_decimal, + TRY_CAST(note AS NVARCHAR(MAX)) AS as_text +FROM readings +WHERE TRY_CAST(@min AS INT) > 0; diff --git a/internal/endtoend/testdata/analyze_try_conversions/mssql/schema.sql b/internal/endtoend/testdata/analyze_try_conversions/mssql/schema.sql new file mode 100644 index 0000000000..ac4875448d --- /dev/null +++ b/internal/endtoend/testdata/analyze_try_conversions/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_try_conversions/mssql/stdout.json b/internal/endtoend/testdata/analyze_try_conversions/mssql/stdout.json new file mode 100644 index 0000000000..6839d20eef --- /dev/null +++ b/internal/endtoend/testdata/analyze_try_conversions/mssql/stdout.json @@ -0,0 +1,60 @@ +[ + { + "name": "Converted", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "readings" + }, + { + "name": "as_int", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "as_decimal", + "type": { + "name": "decimal", + "nullable": true, + "args": [ + { + "int": 5 + }, + { + "int": 1 + } + ] + } + }, + { + "name": "as_text", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "ident": "max" + } + ] + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "min", + "type": { + "name": "int" + } + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index 228bda9780..ce2459368b 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -6,6 +6,7 @@ import ( tsql "github.com/sqlc-dev/teesql/ast" + "github.com/sqlc-dev/sqlc/internal/core" "github.com/sqlc-dev/sqlc/internal/sql/ast" ) @@ -678,11 +679,7 @@ func (c *cc) convertScalarExpression(expr tsql.ScalarExpression) ast.Node { Location: c.loc(e), } case *tsql.TryCastCall: - return &ast.TypeCast{ - Arg: c.convertScalarExpression(e.Parameter), - TypeName: dataTypeName(e.DataType), - Location: c.loc(e), - } + return c.tryCast(e.Parameter, e.DataType, e) case *tsql.ConvertCall: return &ast.TypeCast{ Arg: c.convertScalarExpression(e.Parameter), @@ -690,11 +687,7 @@ func (c *cc) convertScalarExpression(expr tsql.ScalarExpression) ast.Node { Location: c.loc(e), } case *tsql.TryConvertCall: - return &ast.TypeCast{ - Arg: c.convertScalarExpression(e.Parameter), - TypeName: dataTypeName(e.DataType), - Location: c.loc(e), - } + return c.tryCast(e.Parameter, e.DataType, e) case *tsql.CoalesceExpression: coalesce := &ast.CoalesceExpr{ Args: &ast.List{}, @@ -779,6 +772,23 @@ func (c *cc) convertScalarExpression(expr tsql.ScalarExpression) ast.Node { } } +// tryCast converts TRY_CAST and TRY_CONVERT, which yield NULL for a value +// they cannot convert, so their result is nullable whatever the operand is. +// A placeholder cast this way is typed by the cast as it is by CAST. +func (c *cc) tryCast(arg tsql.ScalarExpression, dataType tsql.DataTypeReference, n tsql.Node) ast.Node { + tn := dataTypeName(dataType) + if _, ok := arg.(*tsql.VariableReference); !ok { + if t := core.TypeExprOfTypeName(tn); t != nil { + tn = &ast.TypeName{Spelling: "Nullable(" + t.String() + ")"} + } + } + return &ast.TypeCast{ + Arg: c.convertScalarExpression(arg), + TypeName: tn, + Location: c.loc(n), + } +} + func binaryOperator(binaryExpressionType string) string { switch binaryExpressionType { case "Add": From dd318d5bc3bab42d9154b41fd78f7c91e80e5c76 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:35:34 -0800 Subject: [PATCH 12/43] feat(mssql): type PARSE and TRY_PARSE PARSE and TRY_PARSE were left unconverted, so their result was untyped and a placeholder in them was lost. They are now the conversions they are: PARSE as CAST, TRY_PARSE as the nullable TRY_CAST. What they parse is a string, so a placeholder there is the nvarchar(4000) SQL Server takes it as, and a culture placeholder is nvarchar. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../analyze_try_conversions/mssql/query.sql | 7 +++ .../analyze_try_conversions/mssql/stdout.json | 56 +++++++++++++++++++ internal/engine/mssql/convert.go | 51 ++++++++++++++--- internal/engine/mssql/dialect/functions.jsonl | 1 + 4 files changed, 108 insertions(+), 7 deletions(-) diff --git a/internal/endtoend/testdata/analyze_try_conversions/mssql/query.sql b/internal/endtoend/testdata/analyze_try_conversions/mssql/query.sql index adf4881cde..320b95474b 100644 --- a/internal/endtoend/testdata/analyze_try_conversions/mssql/query.sql +++ b/internal/endtoend/testdata/analyze_try_conversions/mssql/query.sql @@ -5,3 +5,10 @@ SELECT id, TRY_CAST(note AS NVARCHAR(MAX)) AS as_text FROM readings WHERE TRY_CAST(@min AS INT) > 0; + +-- name: Parsed :many +SELECT id, + TRY_PARSE(raw AS DECIMAL(10,2)) AS try_parsed, + TRY_PARSE(note AS DATETIME2 USING 'en-US') AS try_parsed_date +FROM readings +WHERE PARSE(@limit AS INT) > 0; diff --git a/internal/endtoend/testdata/analyze_try_conversions/mssql/stdout.json b/internal/endtoend/testdata/analyze_try_conversions/mssql/stdout.json index 6839d20eef..7af70da068 100644 --- a/internal/endtoend/testdata/analyze_try_conversions/mssql/stdout.json +++ b/internal/endtoend/testdata/analyze_try_conversions/mssql/stdout.json @@ -56,5 +56,61 @@ } } ] + }, + { + "name": "Parsed", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "readings" + }, + { + "name": "try_parsed", + "type": { + "name": "decimal", + "nullable": true, + "args": [ + { + "int": 10 + }, + { + "int": 2 + } + ] + } + }, + { + "name": "try_parsed_date", + "type": { + "name": "datetime2", + "nullable": true, + "args": [ + { + "int": 7 + } + ] + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "limit", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 4000 + } + ] + } + } + } + ] } ] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index ce2459368b..48a59bbcb3 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -680,6 +680,10 @@ func (c *cc) convertScalarExpression(expr tsql.ScalarExpression) ast.Node { } case *tsql.TryCastCall: return c.tryCast(e.Parameter, e.DataType, e) + case *tsql.ParseCall: + return c.parseCall(e.StringValue, e.Culture, e.DataType, false, e) + case *tsql.TryParseCall: + return c.parseCall(e.StringValue, e.Culture, e.DataType, true, e) case *tsql.ConvertCall: return &ast.TypeCast{ Arg: c.convertScalarExpression(e.Parameter), @@ -776,19 +780,52 @@ func (c *cc) convertScalarExpression(expr tsql.ScalarExpression) ast.Node { // they cannot convert, so their result is nullable whatever the operand is. // A placeholder cast this way is typed by the cast as it is by CAST. func (c *cc) tryCast(arg tsql.ScalarExpression, dataType tsql.DataTypeReference, n tsql.Node) ast.Node { - tn := dataTypeName(dataType) - if _, ok := arg.(*tsql.VariableReference); !ok { - if t := core.TypeExprOfTypeName(tn); t != nil { - tn = &ast.TypeName{Spelling: "Nullable(" + t.String() + ")"} - } - } return &ast.TypeCast{ Arg: c.convertScalarExpression(arg), - TypeName: tn, + TypeName: tryTypeName(arg, dataType), Location: c.loc(n), } } +// tryTypeName is the type a TRY_ conversion of arg yields: nullable, unless +// arg is a placeholder, which the conversion types as CAST does. +func tryTypeName(arg tsql.ScalarExpression, dataType tsql.DataTypeReference) *ast.TypeName { + if _, ok := arg.(*tsql.VariableReference); ok { + return dataTypeName(dataType) + } + return nullableTypeName(dataTypeName(dataType)) +} + +func nullableTypeName(tn *ast.TypeName) *ast.TypeName { + if t := core.TypeExprOfTypeName(tn); t != nil { + return &ast.TypeName{Spelling: "Nullable(" + t.String() + ")"} + } + return tn +} + +// parseCall converts PARSE and TRY_PARSE, which convert a string as CAST +// and TRY_CAST do. What they convert is a string, so a placeholder there is +// the nvarchar(4000) SQL Server takes it as. A culture is passed through a +// call, so a placeholder given for it is still a parameter of the query. +func (c *cc) parseCall(value, culture tsql.ScalarExpression, dataType tsql.DataTypeReference, try bool, n tsql.Node) ast.Node { + tn := dataTypeName(dataType) + if try { + tn = nullableTypeName(tn) + } + arg := c.convertScalarExpression(value) + if _, ok := value.(*tsql.VariableReference); ok { + arg = &ast.TypeCast{Arg: arg, TypeName: typeName("nvarchar", 4000), Location: c.loc(value)} + } + if culture != nil { + arg = &ast.FuncCall{ + Funcname: &ast.List{Items: []ast.Node{&ast.String{Str: "parse"}}}, + Args: &ast.List{Items: []ast.Node{arg, c.convertScalarExpression(culture)}}, + Location: c.loc(n), + } + } + return &ast.TypeCast{Arg: arg, TypeName: tn, Location: c.loc(n)} +} + 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 891d141f36..948a5a63b7 100644 --- a/internal/engine/mssql/dialect/functions.jsonl +++ b/internal/engine/mssql/dialect/functions.jsonl @@ -86,3 +86,4 @@ {"name": "generate_series", "args": [{"type": "int"}, {"type": "int"}, {"type": "int"}], "returns": "$1"} {"name": "generate_series", "args": [{"type": "bigint"}, {"type": "bigint"}, {"type": "bigint"}], "returns": "$1"} {"name": "generate_series", "args": [{"type": "decimal"}, {"type": "decimal"}, {"type": "decimal"}], "returns": "$1"} +{"name": "parse", "args": [{"type": "nvarchar"}, {"type": "nvarchar"}], "returns": "nvarchar"} From 142a7132228cd7b88daa34b9b9f954b47105780a Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:36:30 -0800 Subject: [PATCH 13/43] feat(mssql): type @@ROWCOUNT and the other system variables @@ROWCOUNT, @@IDENTITY, @@TRANCOUNT and their relatives were left unconverted, so a column reading one was untyped. Each is now the call of a function seeded under its name, with the type and nullability SQL Server describes it with. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../analyze_global_variables/mssql/exec.json | 5 + .../analyze_global_variables/mssql/query.sql | 13 ++ .../analyze_global_variables/mssql/schema.sql | 6 + .../mssql/stdout.json | 123 ++++++++++++++++++ internal/engine/mssql/convert.go | 9 ++ internal/engine/mssql/dialect/functions.jsonl | 19 +++ 6 files changed, 175 insertions(+) create mode 100644 internal/endtoend/testdata/analyze_global_variables/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_global_variables/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_global_variables/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_global_variables/mssql/stdout.json diff --git a/internal/endtoend/testdata/analyze_global_variables/mssql/exec.json b/internal/endtoend/testdata/analyze_global_variables/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_global_variables/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_global_variables/mssql/query.sql b/internal/endtoend/testdata/analyze_global_variables/mssql/query.sql new file mode 100644 index 0000000000..d4f08649f4 --- /dev/null +++ b/internal/endtoend/testdata/analyze_global_variables/mssql/query.sql @@ -0,0 +1,13 @@ +-- name: SessionInfo :one +SELECT @@ROWCOUNT AS row_count, + @@IDENTITY AS last_identity, + @@SPID AS session_id, + @@VERSION AS version, + @@TRANCOUNT AS tran_count, + @@ERROR AS error, + @@SERVERNAME AS server_name, + @@DATEFIRST AS date_first, + @@DBTS AS dbts; + +-- name: TouchInTransaction :exec +UPDATE readings SET note = @note WHERE id = @id AND @@TRANCOUNT > 0; diff --git a/internal/endtoend/testdata/analyze_global_variables/mssql/schema.sql b/internal/endtoend/testdata/analyze_global_variables/mssql/schema.sql new file mode 100644 index 0000000000..ac4875448d --- /dev/null +++ b/internal/endtoend/testdata/analyze_global_variables/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_global_variables/mssql/stdout.json b/internal/endtoend/testdata/analyze_global_variables/mssql/stdout.json new file mode 100644 index 0000000000..3c1996768c --- /dev/null +++ b/internal/endtoend/testdata/analyze_global_variables/mssql/stdout.json @@ -0,0 +1,123 @@ +[ + { + "name": "SessionInfo", + "cmd": ":one", + "columns": [ + { + "name": "row_count", + "type": { + "name": "int" + } + }, + { + "name": "last_identity", + "type": { + "name": "decimal", + "nullable": true, + "args": [ + { + "int": 38 + }, + { + "int": 0 + } + ] + } + }, + { + "name": "session_id", + "type": { + "name": "smallint" + } + }, + { + "name": "version", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 300 + } + ] + } + }, + { + "name": "tran_count", + "type": { + "name": "int" + } + }, + { + "name": "error", + "type": { + "name": "int" + } + }, + { + "name": "server_name", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 128 + } + ] + } + }, + { + "name": "date_first", + "type": { + "name": "tinyint", + "nullable": true + } + }, + { + "name": "dbts", + "type": { + "name": "varbinary", + "args": [ + { + "int": 8 + } + ] + } + } + ], + "params": [] + }, + { + "name": "TouchInTransaction", + "cmd": ":exec", + "columns": [], + "params": [ + { + "number": 1, + "column": { + "name": "note", + "type": { + "name": "varchar", + "nullable": true, + "args": [ + { + "int": 10 + } + ] + }, + "table": "readings" + } + }, + { + "number": 2, + "column": { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "readings" + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index 48a59bbcb3..8283d5bf2e 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -764,6 +764,15 @@ func (c *cc) convertScalarExpression(expr tsql.ScalarExpression) ast.Node { Subselect: c.convertQueryExpression(e.QueryExpression), Location: c.loc(e), } + case *tsql.GlobalVariableExpression: + // @@ROWCOUNT and its relatives are typed as the functions the + // dialect seeds under their names. + return &ast.FuncCall{ + Funcname: &ast.List{ + Items: []ast.Node{&ast.String{Str: identifier(e.Name)}}, + }, + 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 948a5a63b7..e39e27fe03 100644 --- a/internal/engine/mssql/dialect/functions.jsonl +++ b/internal/engine/mssql/dialect/functions.jsonl @@ -87,3 +87,22 @@ {"name": "generate_series", "args": [{"type": "bigint"}, {"type": "bigint"}, {"type": "bigint"}], "returns": "$1"} {"name": "generate_series", "args": [{"type": "decimal"}, {"type": "decimal"}, {"type": "decimal"}], "returns": "$1"} {"name": "parse", "args": [{"type": "nvarchar"}, {"type": "nvarchar"}], "returns": "nvarchar"} +{"name": "@@rowcount", "returns": "int"} +{"name": "@@identity", "returns": "numeric(38, 0)", "nullable": true} +{"name": "@@spid", "returns": "smallint"} +{"name": "@@version", "returns": "nvarchar(300)", "nullable": true} +{"name": "@@trancount", "returns": "int"} +{"name": "@@error", "returns": "int"} +{"name": "@@fetch_status", "returns": "int"} +{"name": "@@servername", "returns": "nvarchar(128)", "nullable": true} +{"name": "@@servicename", "returns": "nvarchar(128)", "nullable": true} +{"name": "@@language", "returns": "nvarchar(128)", "nullable": true} +{"name": "@@datefirst", "returns": "tinyint", "nullable": true} +{"name": "@@max_precision", "returns": "tinyint"} +{"name": "@@nestlevel", "returns": "int"} +{"name": "@@procid", "returns": "int"} +{"name": "@@dbts", "returns": "varbinary(8)"} +{"name": "@@lock_timeout", "returns": "int"} +{"name": "@@options", "returns": "int"} +{"name": "@@textsize", "returns": "int"} +{"name": "@@cursor_rows", "returns": "int"} From 6476de912c414cc71193d567650490eb440f4dcd Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:35:16 -0800 Subject: [PATCH 14/43] feat(mssql): support PIVOT and UNPIVOT in FROM Both reached the analyzer as an unconverted node. Their columns are the source's columns less the rotated ones, plus the ones the rotation makes, which only the analyzer knows, so the converter passes them on in a new RangePivot node that the analyzer binds against its source. A star over a PIVOT expands to columns named after its values, as 2023, so the expansion quotes names that are no regular identifier. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/compiler/expand.go | 5 + internal/core/analyzer/scope.go | 68 ++++++ .../testdata/analyze_pivot/mssql/exec.json | 5 + .../testdata/analyze_pivot/mssql/query.sql | 17 ++ .../testdata/analyze_pivot/mssql/schema.sql | 15 ++ .../testdata/analyze_pivot/mssql/stdout.json | 215 ++++++++++++++++++ internal/engine/mssql/convert.go | 66 ++++++ internal/sql/ast/range_pivot.go | 56 +++++ internal/sql/astutils/rewrite.go | 6 + internal/sql/astutils/walk.go | 14 ++ 10 files changed, 467 insertions(+) create mode 100644 internal/endtoend/testdata/analyze_pivot/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_pivot/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_pivot/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_pivot/mssql/stdout.json create mode 100644 internal/sql/ast/range_pivot.go diff --git a/internal/compiler/expand.go b/internal/compiler/expand.go index 8c8a490b6d..990fc8068b 100644 --- a/internal/compiler/expand.go +++ b/internal/compiler/expand.go @@ -66,6 +66,11 @@ func (c *Compiler) quoteIdent(ident string) string { return c.quote(ident) } } + // A column PIVOT names after a value, as [2023], is no regular + // identifier. + if c.conf.Engine == config.EngineMSSQL && !validPostgresIdent.MatchString(strings.ToLower(ident)) { + return c.quote(ident) + } return ident } diff --git a/internal/core/analyzer/scope.go b/internal/core/analyzer/scope.go index ada3da93eb..7bc1424280 100644 --- a/internal/core/analyzer/scope.go +++ b/internal/core/analyzer/scope.go @@ -85,6 +85,13 @@ func (a *analyzer) appendFromItem(sc *scope, item ast.Node) error { } sc.rels = append(sc.rels, rel) return nil + case *ast.RangePivot: + rel, err := a.bindRangePivot(v) + if err != nil { + return err + } + sc.rels = append(sc.rels, rel) + return nil case *ast.List: // Some engines report a comma-separated FROM as a nested list. for _, item := range listItems(v) { @@ -193,6 +200,67 @@ func findFuncCall(l *ast.List) *ast.FuncCall { return nil } +// bindRangePivot binds PIVOT and UNPIVOT. The columns of the source that +// are not rotated pass through, followed by those the rotation makes, typed +// against the source. +func (a *analyzer) bindRangePivot(p *ast.RangePivot) (scopeRel, error) { + src := &scope{} + if err := a.appendFromItem(src, p.Source); err != nil { + return scopeRel{}, err + } + defer a.binding(src)() + rotated := map[string]bool{} + var made []core.ClassColumn + if p.Unpivot { + // The value column has the type the rotated columns share, and is + // nullable when any of them is. + value := core.ClassColumn{Name: p.ValueColumn, NotNull: true} + for i, item := range listItems(p.InList) { + t, err := a.typeExpr(&ast.ColumnRef{Fields: &ast.List{Items: []ast.Node{item}}}) + if err != nil { + return scopeRel{}, err + } + if i == 0 { + value.TypeOID, value.Type = t.typeOID, t.expr + } + value.NotNull = value.NotNull && !t.nullable + rotated[item.(*ast.String).Str] = true + } + name := a.lookupType(core.TypeExprOfTypeName(p.ForType)) + made = []core.ClassColumn{value, {Name: p.ForColumn, TypeOID: name.typeOID, Type: name.expr}} + } else { + // Each value in the list names a column of the aggregate over the + // rows with that value, null when there are none. + t, err := a.typeExpr(p.Aggregate) + if err != nil { + return scopeRel{}, err + } + rotated[p.ForColumn] = true + for _, arg := range listItems(p.Aggregate.Args) { + if cr, ok := arg.(*ast.ColumnRef); ok { + fields := flattenFields(cr.Fields) + rotated[fields[len(fields)-1]] = true + } + } + for _, item := range listItems(p.InList) { + made = append(made, core.ClassColumn{Name: item.(*ast.String).Str, TypeOID: t.typeOID, Type: t.expr}) + } + } + var rel scopeRel + if p.Alias != nil && p.Alias.Aliasname != nil { + rel.alias = *p.Alias.Aliasname + } + for _, r := range src.rels { + for _, c := range r.cols { + if !rotated[c.Name] { + rel.cols = append(rel.cols, c) + } + } + } + rel.cols = append(rel.cols, made...) + return rel, nil +} + // bindRangeSubselect binds a subquery used as a relation in FROM. func (a *analyzer) bindRangeSubselect(rs *ast.RangeSubselect) (scopeRel, error) { sel, ok := rs.Subquery.(*ast.SelectStmt) diff --git a/internal/endtoend/testdata/analyze_pivot/mssql/exec.json b/internal/endtoend/testdata/analyze_pivot/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_pivot/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_pivot/mssql/query.sql b/internal/endtoend/testdata/analyze_pivot/mssql/query.sql new file mode 100644 index 0000000000..1ab8ee5bc5 --- /dev/null +++ b/internal/endtoend/testdata/analyze_pivot/mssql/query.sql @@ -0,0 +1,17 @@ +-- name: PivotDerived :many +SELECT pv.* FROM (SELECT author_id, year, amount FROM sales WHERE author_id > @min_author) s +PIVOT (SUM(amount) FOR year IN ([2023], [2024])) pv; + +-- name: PivotTable :many +SELECT * FROM sales PIVOT (COUNT(amount) FOR year IN ([2023], [2024])) AS pv; + +-- name: PivotColumns :many +SELECT pv.author_id, pv.[2023] FROM sales PIVOT (MAX(amount) FOR year IN ([2023])) pv +WHERE pv.id = @id; + +-- name: Unpivot :many +SELECT u.id, u.metric, u.value FROM stats s +UNPIVOT (value FOR metric IN (q1, q2, q3)) u; + +-- name: UnpivotStar :many +SELECT * FROM stats UNPIVOT (value FOR metric IN (q1)) AS u; diff --git a/internal/endtoend/testdata/analyze_pivot/mssql/schema.sql b/internal/endtoend/testdata/analyze_pivot/mssql/schema.sql new file mode 100644 index 0000000000..a32279545a --- /dev/null +++ b/internal/endtoend/testdata/analyze_pivot/mssql/schema.sql @@ -0,0 +1,15 @@ +CREATE TABLE sales ( + id INT NOT NULL PRIMARY KEY, + author_id INT NOT NULL, + year INT NOT NULL, + amount INT NOT NULL, + note NVARCHAR(50) NULL +); + +CREATE TABLE stats ( + id INT NOT NULL PRIMARY KEY, + name VARCHAR(20) NULL, + q1 INT NOT NULL, + q2 INT NULL, + q3 INT NULL +); diff --git a/internal/endtoend/testdata/analyze_pivot/mssql/stdout.json b/internal/endtoend/testdata/analyze_pivot/mssql/stdout.json new file mode 100644 index 0000000000..9c91bb88c8 --- /dev/null +++ b/internal/endtoend/testdata/analyze_pivot/mssql/stdout.json @@ -0,0 +1,215 @@ +[ + { + "name": "PivotDerived", + "cmd": ":many", + "columns": [ + { + "name": "author_id", + "type": { + "name": "int" + }, + "table": "sales" + }, + { + "name": "2023", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "2024", + "type": { + "name": "int", + "nullable": true + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "min_author", + "type": { + "name": "int" + }, + "table": "sales" + } + } + ] + }, + { + "name": "PivotTable", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "int" + }, + "table": "sales" + }, + { + "name": "author_id", + "type": { + "name": "int" + }, + "table": "sales" + }, + { + "name": "note", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 50 + } + ] + }, + "table": "sales" + }, + { + "name": "2023", + "type": { + "name": "int", + "nullable": true + } + }, + { + "name": "2024", + "type": { + "name": "int", + "nullable": true + } + } + ], + "params": [] + }, + { + "name": "PivotColumns", + "cmd": ":many", + "columns": [ + { + "name": "author_id", + "type": { + "name": "int" + }, + "table": "sales" + }, + { + "name": "2023", + "type": { + "name": "int", + "nullable": true + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "id", + "type": { + "name": "int" + }, + "table": "sales" + } + } + ] + }, + { + "name": "Unpivot", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "int" + }, + "table": "stats" + }, + { + "name": "metric", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 128 + } + ] + } + }, + { + "name": "value", + "type": { + "name": "int", + "nullable": true + } + } + ], + "params": [] + }, + { + "name": "UnpivotStar", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "int" + }, + "table": "stats" + }, + { + "name": "name", + "type": { + "name": "varchar", + "nullable": true, + "args": [ + { + "int": 20 + } + ] + }, + "table": "stats" + }, + { + "name": "q2", + "type": { + "name": "int", + "nullable": true + }, + "table": "stats" + }, + { + "name": "q3", + "type": { + "name": "int", + "nullable": true + }, + "table": "stats" + }, + { + "name": "value", + "type": { + "name": "int" + } + }, + { + "name": "metric", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 128 + } + ] + } + } + ], + "params": [] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index 8283d5bf2e..78cb3aaa7a 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -367,11 +367,77 @@ func (c *cc) convertTableReference(ref tsql.TableReference) ast.Node { return c.convertOpenJson(t) case *tsql.GlobalFunctionTableReference: return c.convertGlobalFunctionTableReference(t) + case *tsql.PivotedTableReference: + return c.convertPivot(t) + case *tsql.UnpivotedTableReference: + return c.convertUnpivot(t) default: return todo(ref) } } +// convertPivot converts PIVOT. Its columns depend on those of its source, +// which only the analyzer knows, so it is passed on rather than rewritten +// as the grouped query it stands for. +func (c *cc) convertPivot(n *tsql.PivotedTableReference) ast.Node { + if n.AggregateFunctionIdentifier == nil || len(n.AggregateFunctionIdentifier.Identifiers) == 0 || n.PivotColumn == nil { + return todo(n) + } + ids := n.AggregateFunctionIdentifier.Identifiers + agg := &ast.FuncCall{ + Funcname: &ast.List{Items: []ast.Node{&ast.String{Str: identifierValue(ids[len(ids)-1])}}}, + Args: &ast.List{}, + Location: c.loc(n), + } + for _, col := range n.ValueColumns { + agg.Args.Items = append(agg.Args.Items, c.convertColumnReference(col)) + } + p := &ast.RangePivot{ + Source: c.convertTableReference(n.TableReference), + Aggregate: agg, + ForColumn: columnName(n.PivotColumn), + InList: &ast.List{}, + } + for _, id := range n.InColumns { + p.InList.Items = append(p.InList.Items, NewIdentifier(id.Value)) + } + if n.Alias != nil { + alias := identifierValue(n.Alias) + p.Alias = &ast.Alias{Aliasname: &alias} + } + return p +} + +// convertUnpivot converts UNPIVOT, which, as PIVOT, keeps the columns of +// its source the analyzer knows. +func (c *cc) convertUnpivot(n *tsql.UnpivotedTableReference) ast.Node { + p := &ast.RangePivot{ + Source: c.convertTableReference(n.TableReference), + Unpivot: true, + ValueColumn: identifierValue(n.ValueColumn), + ForColumn: identifierValue(n.PivotColumn), + ForType: typeName("nvarchar", 128), + InList: &ast.List{}, + } + for _, col := range n.InColumns { + p.InList.Items = append(p.InList.Items, &ast.String{Str: columnName(col)}) + } + if n.Alias != nil { + alias := identifierValue(n.Alias) + p.Alias = &ast.Alias{Aliasname: &alias} + } + return p +} + +// columnName is the name a column reference ends in. +func columnName(n *tsql.ColumnReferenceExpression) string { + if n == nil || n.MultiPartIdentifier == nil || len(n.MultiPartIdentifier.Identifiers) == 0 { + return "" + } + ids := n.MultiPartIdentifier.Identifiers + return identifierValue(ids[len(ids)-1]) +} + // convertInlineDerivedTable converts (VALUES ...) AS v(a, b) to the // subquery it stands for, its rows selected one after another with UNION // ALL, so the relation's columns are typed from its first row. diff --git a/internal/sql/ast/range_pivot.go b/internal/sql/ast/range_pivot.go new file mode 100644 index 0000000000..f3d7b7a761 --- /dev/null +++ b/internal/sql/ast/range_pivot.go @@ -0,0 +1,56 @@ +package ast + +import "github.com/sqlc-dev/sqlc/internal/sql/format" + +// RangePivot is a FROM item that rotates the rows of another, as SQL +// Server's PIVOT and UNPIVOT do. PIVOT turns the values of ForColumn named +// in InList into columns of Aggregate; UNPIVOT turns the columns in InList +// into rows of ValueColumn, named in ForColumn of type ForType. +type RangePivot struct { + Tag NodeTag[RangePivot] `json:"tag"` + + Source Node `json:"source,omitempty"` + Unpivot bool `json:"unpivot"` + Aggregate *FuncCall `json:"aggregate,omitempty"` + ValueColumn string `json:"value_column"` + ForColumn string `json:"for_column"` + ForType *TypeName `json:"for_type,omitempty"` + InList *List `json:"in_list,omitempty"` + Alias *Alias `json:"alias,omitempty"` +} + +func (n *RangePivot) Pos() int { + return 0 +} + +func (n *RangePivot) Format(buf *TrackedBuffer, d format.Dialect) { + if n == nil { + return + } + buf.astFormat(n.Source, d) + if n.Unpivot { + buf.WriteString(" UNPIVOT (") + buf.WriteString(d.QuoteIdent(n.ValueColumn)) + } else { + buf.WriteString(" PIVOT (") + buf.astFormat(n.Aggregate, d) + } + buf.WriteString(" FOR ") + buf.WriteString(d.QuoteIdent(n.ForColumn)) + buf.WriteString(" IN (") + if n.InList != nil { + for i, item := range n.InList.Items { + if i > 0 { + buf.WriteString(", ") + } + if s, ok := item.(*String); ok { + buf.WriteString(d.QuoteIdent(s.Str)) + } + } + } + buf.WriteString("))") + if n.Alias != nil { + buf.WriteString(" AS ") + buf.astFormat(n.Alias, d) + } +} diff --git a/internal/sql/astutils/rewrite.go b/internal/sql/astutils/rewrite.go index 25b274a16c..d178de1893 100644 --- a/internal/sql/astutils/rewrite.go +++ b/internal/sql/astutils/rewrite.go @@ -967,6 +967,12 @@ func (a *application) apply(parent ast.Node, name string, iter *iterator, n ast. a.apply(n, "Alias", nil, n.Alias) a.apply(n, "Coldeflist", nil, n.Coldeflist) + case *ast.RangePivot: + a.apply(n, "Source", nil, n.Source) + a.apply(n, "Aggregate", nil, n.Aggregate) + a.apply(n, "InList", nil, n.InList) + a.apply(n, "Alias", nil, n.Alias) + case *ast.RangeSubselect: a.apply(n, "Subquery", nil, n.Subquery) a.apply(n, "Alias", nil, n.Alias) diff --git a/internal/sql/astutils/walk.go b/internal/sql/astutils/walk.go index 6d5e80bdc3..dcd5119df5 100644 --- a/internal/sql/astutils/walk.go +++ b/internal/sql/astutils/walk.go @@ -1615,6 +1615,20 @@ func Walk(f Visitor, node ast.Node) { Walk(f, n.Coldeflist) } + case *ast.RangePivot: + if n.Source != nil { + Walk(f, n.Source) + } + if n.Aggregate != nil { + Walk(f, n.Aggregate) + } + if n.InList != nil { + Walk(f, n.InList) + } + if n.Alias != nil { + Walk(f, n.Alias) + } + case *ast.RangeSubselect: if n.Subquery != nil { Walk(f, n.Subquery) From 4ac96f6bf96cab2d54e06f0d4df2b054fb7acb88 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:37:53 -0800 Subject: [PATCH 15/43] fix(mssql): quote expanded identifiers with brackets A star expanded into a column named by a reserved word, or by something that is not an identifier at all, such as PIVOT's [2023], was quoted with double quotes, which SQL Server reads as an identifier only while QUOTED_IDENTIFIER is on. SQL Server's own brackets quote it whatever the setting, with a closing bracket doubled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/compiler/expand.go | 3 + .../mssql_star_quoting/mssql/exec.json | 1 + .../mssql_star_quoting/mssql/go/db.go | 31 ++++++++ .../mssql_star_quoting/mssql/go/models.go | 21 ++++++ .../mssql_star_quoting/mssql/go/query.sql.go | 71 +++++++++++++++++++ .../mssql_star_quoting/mssql/query.sql | 5 ++ .../mssql_star_quoting/mssql/schema.sql | 11 +++ .../mssql_star_quoting/mssql/sqlc.yaml | 10 +++ 8 files changed, 153 insertions(+) create mode 100644 internal/endtoend/testdata/mssql_star_quoting/mssql/exec.json create mode 100644 internal/endtoend/testdata/mssql_star_quoting/mssql/go/db.go create mode 100644 internal/endtoend/testdata/mssql_star_quoting/mssql/go/models.go create mode 100644 internal/endtoend/testdata/mssql_star_quoting/mssql/go/query.sql.go create mode 100644 internal/endtoend/testdata/mssql_star_quoting/mssql/query.sql create mode 100644 internal/endtoend/testdata/mssql_star_quoting/mssql/schema.sql create mode 100644 internal/endtoend/testdata/mssql_star_quoting/mssql/sqlc.yaml diff --git a/internal/compiler/expand.go b/internal/compiler/expand.go index 990fc8068b..b7e5d12605 100644 --- a/internal/compiler/expand.go +++ b/internal/compiler/expand.go @@ -78,6 +78,9 @@ func (c *Compiler) quote(x string) string { switch c.conf.Engine { case config.EngineMySQL: return "`" + x + "`" + case config.EngineMSSQL: + // Brackets quote whatever QUOTED_IDENTIFIER is set to. + return "[" + strings.ReplaceAll(x, "]", "]]") + "]" default: return "\"" + x + "\"" } diff --git a/internal/endtoend/testdata/mssql_star_quoting/mssql/exec.json b/internal/endtoend/testdata/mssql_star_quoting/mssql/exec.json new file mode 100644 index 0000000000..f4e21983b6 --- /dev/null +++ b/internal/endtoend/testdata/mssql_star_quoting/mssql/exec.json @@ -0,0 +1 @@ +{"contexts": ["base"]} diff --git a/internal/endtoend/testdata/mssql_star_quoting/mssql/go/db.go b/internal/endtoend/testdata/mssql_star_quoting/mssql/go/db.go new file mode 100644 index 0000000000..32faa9f2b8 --- /dev/null +++ b/internal/endtoend/testdata/mssql_star_quoting/mssql/go/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/endtoend/testdata/mssql_star_quoting/mssql/go/models.go b/internal/endtoend/testdata/mssql_star_quoting/mssql/go/models.go new file mode 100644 index 0000000000..a5ecb804af --- /dev/null +++ b/internal/endtoend/testdata/mssql_star_quoting/mssql/go/models.go @@ -0,0 +1,21 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "database/sql" +) + +type Order struct { + ID int32 + Select string + WeirdName sql.NullInt32 +} + +type Sale struct { + AuthorID int32 + Year int32 + Amount int32 +} diff --git a/internal/endtoend/testdata/mssql_star_quoting/mssql/go/query.sql.go b/internal/endtoend/testdata/mssql_star_quoting/mssql/go/query.sql.go new file mode 100644 index 0000000000..f620a15220 --- /dev/null +++ b/internal/endtoend/testdata/mssql_star_quoting/mssql/go/query.sql.go @@ -0,0 +1,71 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package querytest + +import ( + "context" + "database/sql" +) + +const listOrders = `-- name: ListOrders :many +SELECT o.id, o.[select], o.[weird]]name] FROM [order] o; +` + +func (q *Queries) ListOrders(ctx context.Context) ([]Order, error) { + rows, err := q.db.QueryContext(ctx, listOrders) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Order + for rows.Next() { + var i Order + if err := rows.Scan(&i.ID, &i.Select, &i.WeirdName); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const salesByYear = `-- name: SalesByYear :many +SELECT pv.author_id, pv.[2023], pv.[2024] FROM sales PIVOT (SUM(amount) FOR year IN ([2023], [2024])) pv; +` + +type SalesByYearRow struct { + AuthorID int32 + _2023 sql.NullInt32 + _2024 sql.NullInt32 +} + +func (q *Queries) SalesByYear(ctx context.Context) ([]SalesByYearRow, error) { + rows, err := q.db.QueryContext(ctx, salesByYear) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SalesByYearRow + for rows.Next() { + var i SalesByYearRow + if err := rows.Scan(&i.AuthorID, &i._2023, &i._2024); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/endtoend/testdata/mssql_star_quoting/mssql/query.sql b/internal/endtoend/testdata/mssql_star_quoting/mssql/query.sql new file mode 100644 index 0000000000..dc4f48900c --- /dev/null +++ b/internal/endtoend/testdata/mssql_star_quoting/mssql/query.sql @@ -0,0 +1,5 @@ +-- name: ListOrders :many +SELECT o.* FROM [order] o; + +-- name: SalesByYear :many +SELECT pv.* FROM sales PIVOT (SUM(amount) FOR year IN ([2023], [2024])) pv; diff --git a/internal/endtoend/testdata/mssql_star_quoting/mssql/schema.sql b/internal/endtoend/testdata/mssql_star_quoting/mssql/schema.sql new file mode 100644 index 0000000000..67eb0ffc8a --- /dev/null +++ b/internal/endtoend/testdata/mssql_star_quoting/mssql/schema.sql @@ -0,0 +1,11 @@ +CREATE TABLE [order] ( + id INT PRIMARY KEY, + [select] NVARCHAR(10) NOT NULL, + [weird]]name] INT +); + +CREATE TABLE sales ( + author_id INT NOT NULL, + year INT NOT NULL, + amount INT NOT NULL +); diff --git a/internal/endtoend/testdata/mssql_star_quoting/mssql/sqlc.yaml b/internal/endtoend/testdata/mssql_star_quoting/mssql/sqlc.yaml new file mode 100644 index 0000000000..94b0dc8896 --- /dev/null +++ b/internal/endtoend/testdata/mssql_star_quoting/mssql/sqlc.yaml @@ -0,0 +1,10 @@ +version: "2" +sql: + - engine: "mssql" + schema: "schema.sql" + queries: "query.sql" + gen: + go: + package: "querytest" + out: "go" + sql_package: "database/sql" From 033df822cea393060620ce511c0ed28ad722b9fb Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:44:31 -0800 Subject: [PATCH 16/43] feat(mssql): type FOR JSON and FOR XML results A query ending in FOR JSON or FOR XML returns one document column, but was reported as returning its select list. It is now that column: nvarchar(max) for JSON, ntext for XML, and xml with TYPE, nullable and named as SQL Server names it; as a subquery, FOR XML without TYPE is nvarchar(max). The query itself is kept as a subquery of the column, so its placeholders and stars are still analyzed. A query with no FROM, like that column's, binds no relations, so what is nested in it now sees the enclosing query, as a set operation's branches already did, and a correlated FOR JSON subquery resolves. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/core/analyzer/analyzer.go | 6 +- .../testdata/analyze_for_json/mssql/exec.json | 5 + .../testdata/analyze_for_json/mssql/query.sql | 13 +++ .../analyze_for_json/mssql/schema.sql | 10 ++ .../analyze_for_json/mssql/stdout.json | 107 ++++++++++++++++++ internal/engine/mssql/convert.go | 65 +++++++++++ 6 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 internal/endtoend/testdata/analyze_for_json/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_for_json/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_for_json/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_for_json/mssql/stdout.json diff --git a/internal/core/analyzer/analyzer.go b/internal/core/analyzer/analyzer.go index 356d7c9eea..8f301f32ea 100644 --- a/internal/core/analyzer/analyzer.go +++ b/internal/core/analyzer/analyzer.go @@ -87,10 +87,10 @@ func (a *analyzer) recordStar(s core.StarExpansion) { // placeholder inside the subquery is reported with the rest, and it sees this // query's scope, so a correlated reference resolves. func (a *analyzer) subquery(s *ast.SelectStmt) (*analyzer, error) { - // A set operation binds no scope of its own, so its branches see the - // query it is nested in. + // A set operation, or a query with no FROM, binds no relations of its + // own, so what is nested in it sees the query it is nested in. outer := a.scope - if outer == nil { + if outer == nil || len(outer.rels) == 0 { outer = a.outer } sub := &analyzer{ diff --git a/internal/endtoend/testdata/analyze_for_json/mssql/exec.json b/internal/endtoend/testdata/analyze_for_json/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_for_json/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_for_json/mssql/query.sql b/internal/endtoend/testdata/analyze_for_json/mssql/query.sql new file mode 100644 index 0000000000..e1cb5c0698 --- /dev/null +++ b/internal/endtoend/testdata/analyze_for_json/mssql/query.sql @@ -0,0 +1,13 @@ +-- name: AuthorsJSON :many +SELECT id, name FROM authors WHERE id > @min_id FOR JSON PATH; + +-- name: AuthorsXML :many +SELECT id, name FROM authors FOR XML RAW; + +-- name: AuthorsWithBooks :many +SELECT a.id, + (SELECT b.id, b.title FROM books b WHERE b.author_id = a.id FOR JSON PATH) AS books, + (SELECT b.title + N',' FROM books b WHERE b.author_id = a.id FOR XML PATH('')) AS titles, + (SELECT b.id, b.title FROM books b WHERE b.author_id = a.id FOR XML PATH, TYPE) AS books_xml +FROM authors a +WHERE a.name = @name; diff --git a/internal/endtoend/testdata/analyze_for_json/mssql/schema.sql b/internal/endtoend/testdata/analyze_for_json/mssql/schema.sql new file mode 100644 index 0000000000..2a49202aaa --- /dev/null +++ b/internal/endtoend/testdata/analyze_for_json/mssql/schema.sql @@ -0,0 +1,10 @@ +CREATE TABLE authors ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + name NVARCHAR(100) NOT NULL +); + +CREATE TABLE books ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + author_id BIGINT NOT NULL, + title NVARCHAR(200) NOT NULL +); diff --git a/internal/endtoend/testdata/analyze_for_json/mssql/stdout.json b/internal/endtoend/testdata/analyze_for_json/mssql/stdout.json new file mode 100644 index 0000000000..ec333cc25c --- /dev/null +++ b/internal/endtoend/testdata/analyze_for_json/mssql/stdout.json @@ -0,0 +1,107 @@ +[ + { + "name": "AuthorsJSON", + "cmd": ":many", + "columns": [ + { + "name": "json_f52e2b61-18a1-11d1-b105-00805f49916b", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "ident": "max" + } + ] + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "min_id", + "type": { + "name": "bigint" + }, + "table": "authors" + } + } + ] + }, + { + "name": "AuthorsXML", + "cmd": ":many", + "columns": [ + { + "name": "xml_f52e2b61-18a1-11d1-b105-00805f49916b", + "type": { + "name": "ntext", + "nullable": true + } + } + ], + "params": [] + }, + { + "name": "AuthorsWithBooks", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "bigint" + }, + "table": "authors" + }, + { + "name": "books", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "ident": "max" + } + ] + } + }, + { + "name": "titles", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "ident": "max" + } + ] + } + }, + { + "name": "books_xml", + "type": { + "name": "xml", + "nullable": true + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "name", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 100 + } + ] + }, + "table": "authors" + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index 78cb3aaa7a..9a40577568 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -98,6 +98,17 @@ func (c *cc) convertSelectStatement(n *tsql.SelectStatement) ast.Node { if !ok { return stmt } + // A query's own FOR JSON or FOR XML result is a column with the name + // SQL Server gives it, and FOR XML is ntext there. + if qs, ok := n.QueryExpression.(*tsql.QuerySpecification); ok { + if tn := forClauseType(qs.ForClause, true); tn != nil { + res := sel.TargetList.Items[0].(*ast.ResTarget) + res.Val.(*ast.TypeCast).TypeName = nullableTypeName(tn) + if name := forClauseColumn(qs.ForClause, tn); name != "" { + res.Name = &name + } + } + } if with := c.convertWithClause(n.WithCtesAndXmlNamespaces); with != nil { sel.WithClause = with } @@ -237,9 +248,63 @@ func (c *cc) convertQuerySpecification(n *tsql.QuerySpecification) *ast.SelectSt stmt.DistinctClause = &ast.List{} } + if tn := forClauseType(n.ForClause, false); tn != nil { + return forClauseResult(stmt, tn, c.loc(n)) + } return stmt } +// forClauseResult is a query with FOR JSON or FOR XML as what it returns: a +// single nullable column of the document its rows make. The query itself is +// kept as a subquery, so its placeholders and stars are still analyzed. +func forClauseResult(query *ast.SelectStmt, tn *ast.TypeName, loc int) *ast.SelectStmt { + return &ast.SelectStmt{TargetList: &ast.List{Items: []ast.Node{&ast.ResTarget{ + Val: &ast.TypeCast{ + Arg: &ast.SubLink{SubLinkType: ast.EXPR_SUBLINK, Subselect: query, Location: loc}, + TypeName: nullableTypeName(tn), + Location: loc, + }, + Location: loc, + }}}} +} + +// forClauseType is the type of the document FOR JSON or FOR XML makes, or +// nil for any other FOR clause. FOR XML without TYPE is ntext as a query's +// result but nvarchar(max) as a subquery's. +func forClauseType(fc tsql.ForClause, topLevel bool) *ast.TypeName { + maxText := &ast.TypeName{Name: "nvarchar", Typmods: appendTypmod(nil, &ast.String{Str: "max"})} + switch f := fc.(type) { + case *tsql.JsonForClause: + return maxText + case *tsql.XmlForClause: + for _, o := range f.Options { + if o.OptionKind == "Type" { + return &ast.TypeName{Name: "xml"} + } + } + if topLevel { + return &ast.TypeName{Name: "ntext"} + } + return maxText + } + return nil +} + +// forClauseColumn is the name SQL Server gives the column FOR JSON or FOR +// XML returns, which XML with TYPE leaves unnamed. +func forClauseColumn(fc tsql.ForClause, tn *ast.TypeName) string { + switch { + case tn.Name == "xml": + return "" + case fc != nil: + if _, ok := fc.(*tsql.JsonForClause); ok { + return identifier("JSON_F52E2B61-18A1-11d1-B105-00805F49916B") + } + return identifier("XML_F52E2B61-18A1-11d1-B105-00805F49916B") + } + return "" +} + func (c *cc) convertSelectElement(elem tsql.SelectElement) ast.Node { switch e := elem.(type) { case *tsql.SelectScalarExpression: From b6aef71c57e470733fdae807329185c7d60cc17d Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:24:14 -0800 Subject: [PATCH 17/43] fix(golang): expand every use of a named slice A query that uses one sqlc.slice() twice binds a single parameter, but the generated code for a query bound by name replaced only the first /*SLICE:x*/@x marker, leaving the others unbound. Every marker is now replaced with the same placeholders, which the driver binds by position however many times they appear. Addresses review comment r4557024903 on #1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../golang/templates/stdlib/queryCode.tmpl | 8 +-- .../mssql_sqlc_macros/mssql/go/query.sql.go | 52 +++++++++++++++++-- .../mssql_sqlc_macros/mssql/query.sql | 3 ++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/internal/codegen/golang/templates/stdlib/queryCode.tmpl b/internal/codegen/golang/templates/stdlib/queryCode.tmpl index f0fe0189ef..827fc8c951 100644 --- a/internal/codegen/golang/templates/stdlib/queryCode.tmpl +++ b/internal/codegen/golang/templates/stdlib/queryCode.tmpl @@ -140,9 +140,9 @@ func (q *Queries) {{.MethodName}}(ctx context.Context, {{ dbarg }} {{.Arg.Pair}} queryParams = append(queryParams, v) placeholders = append(placeholders, "@p"+strconv.Itoa(len(queryParams))) } - query = strings.Replace(query, "/*SLICE:{{.Column.Name}}*/@{{.Column.Name}}", strings.Join(placeholders, ","), 1) + query = strings.ReplaceAll(query, "/*SLICE:{{.Column.Name}}*/@{{.Column.Name}}", strings.Join(placeholders, ",")) } else { - query = strings.Replace(query, "/*SLICE:{{.Column.Name}}*/@{{.Column.Name}}", "NULL", 1) + query = strings.ReplaceAll(query, "/*SLICE:{{.Column.Name}}*/@{{.Column.Name}}", "NULL") } {{- else if .HasSqlcSlice }} if len({{$arg.VariableForField .}}) > 0 { @@ -171,9 +171,9 @@ func (q *Queries) {{.MethodName}}(ctx context.Context, {{ dbarg }} {{.Arg.Pair}} queryParams = append(queryParams, v) placeholders = append(placeholders, "@p"+strconv.Itoa(len(queryParams))) } - query = strings.Replace(query, "/*SLICE:{{.Arg.Column.Name}}*/@{{.Arg.Column.Name}}", strings.Join(placeholders, ","), 1) + query = strings.ReplaceAll(query, "/*SLICE:{{.Arg.Column.Name}}*/@{{.Arg.Column.Name}}", strings.Join(placeholders, ",")) } else { - query = strings.Replace(query, "/*SLICE:{{.Arg.Column.Name}}*/@{{.Arg.Column.Name}}", "NULL", 1) + query = strings.ReplaceAll(query, "/*SLICE:{{.Arg.Column.Name}}*/@{{.Arg.Column.Name}}", "NULL") } {{- else }} if len({{.Arg.Name}}) > 0 { diff --git a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/query.sql.go b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/query.sql.go index b93d8cbec6..d6a425f9f4 100644 --- a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/query.sql.go +++ b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/query.sql.go @@ -89,9 +89,9 @@ func (q *Queries) ListAuthorsByIDs(ctx context.Context, ids []int64) ([]ListAuth queryParams = append(queryParams, v) placeholders = append(placeholders, "@p"+strconv.Itoa(len(queryParams))) } - query = strings.Replace(query, "/*SLICE:ids*/@ids", strings.Join(placeholders, ","), 1) + query = strings.ReplaceAll(query, "/*SLICE:ids*/@ids", strings.Join(placeholders, ",")) } else { - query = strings.Replace(query, "/*SLICE:ids*/@ids", "NULL", 1) + query = strings.ReplaceAll(query, "/*SLICE:ids*/@ids", "NULL") } rows, err := q.db.QueryContext(ctx, query, queryParams...) if err != nil { @@ -139,9 +139,9 @@ func (q *Queries) ListAuthorsByIDsAndName(ctx context.Context, arg ListAuthorsBy queryParams = append(queryParams, v) placeholders = append(placeholders, "@p"+strconv.Itoa(len(queryParams))) } - query = strings.Replace(query, "/*SLICE:ids*/@ids", strings.Join(placeholders, ","), 1) + query = strings.ReplaceAll(query, "/*SLICE:ids*/@ids", strings.Join(placeholders, ",")) } else { - query = strings.Replace(query, "/*SLICE:ids*/@ids", "NULL", 1) + query = strings.ReplaceAll(query, "/*SLICE:ids*/@ids", "NULL") } rows, err := q.db.QueryContext(ctx, query, queryParams...) if err != nil { @@ -165,6 +165,50 @@ func (q *Queries) ListAuthorsByIDsAndName(ctx context.Context, arg ListAuthorsBy return items, nil } +const listAuthorsByIDsTwice = `-- name: ListAuthorsByIDsTwice :many +SELECT id, name FROM authors WHERE id IN (/*SLICE:ids*/@ids) OR id + 100 IN (/*SLICE:ids*/@ids); +` + +type ListAuthorsByIDsTwiceRow struct { + ID int64 + Name string +} + +func (q *Queries) ListAuthorsByIDsTwice(ctx context.Context, ids []int64) ([]ListAuthorsByIDsTwiceRow, error) { + query := listAuthorsByIDsTwice + var queryParams []any + if len(ids) > 0 { + var placeholders []string + for _, v := range ids { + queryParams = append(queryParams, v) + placeholders = append(placeholders, "@p"+strconv.Itoa(len(queryParams))) + } + query = strings.ReplaceAll(query, "/*SLICE:ids*/@ids", strings.Join(placeholders, ",")) + } else { + query = strings.ReplaceAll(query, "/*SLICE:ids*/@ids", "NULL") + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAuthorsByIDsTwiceRow + for rows.Next() { + var i ListAuthorsByIDsTwiceRow + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const rowCount = `-- name: RowCount :one SELECT CAST(@@ROWCOUNT AS INT) AS row_count; ` diff --git a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/query.sql b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/query.sql index be9e4fc7a2..37529badea 100644 --- a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/query.sql +++ b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/query.sql @@ -18,3 +18,6 @@ SELECT sqlc.embed(a), sqlc.embed(books), books.title AS book_title FROM authors a JOIN books ON books.author_id = a.id WHERE a.id = sqlc.arg(author_id) ORDER BY books.id; + +-- name: ListAuthorsByIDsTwice :many +SELECT id, name FROM authors WHERE id IN (sqlc.slice(ids)) OR id + 100 IN (sqlc.slice(ids)); From e85a2ac44a68a52a85d646339fc4fe20c917666d Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:25:26 -0800 Subject: [PATCH 18/43] fix(mssql): expand a star qualified with double quotes Star expansion measures the reference it replaces by the quoting the dialect writes, which for SQL Server became brackets. A qualifier the user wrote in double quotes, which SQL Server also reads as an identifier, no longer matched, so "o".* was replaced only in part and left broken SQL. A double-quoted qualifier is now measured as written. Addresses review comment r4557024959 on #1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/compiler/expand.go | 4 +++ .../mssql_star_quoting/mssql/go/query.sql.go | 27 +++++++++++++++++++ .../mssql_star_quoting/mssql/query.sql | 3 +++ 3 files changed, 34 insertions(+) diff --git a/internal/compiler/expand.go b/internal/compiler/expand.go index b7e5d12605..64ed0693d8 100644 --- a/internal/compiler/expand.go +++ b/internal/compiler/expand.go @@ -110,6 +110,10 @@ func (c *Compiler) starOldFunc(parts []string) func(string) int { length += len(o) } else if quoted := c.quote(o); strings.HasPrefix(s[length:], quoted) { length += len(quoted) + } else if quoted := `"` + o + `"`; strings.HasPrefix(s[length:], quoted) { + // SQL Server quotes with brackets, but reads double quotes + // as an identifier too. + length += len(quoted) } else { length += len(o) } diff --git a/internal/endtoend/testdata/mssql_star_quoting/mssql/go/query.sql.go b/internal/endtoend/testdata/mssql_star_quoting/mssql/go/query.sql.go index f620a15220..f145d188af 100644 --- a/internal/endtoend/testdata/mssql_star_quoting/mssql/go/query.sql.go +++ b/internal/endtoend/testdata/mssql_star_quoting/mssql/go/query.sql.go @@ -37,6 +37,33 @@ func (q *Queries) ListOrders(ctx context.Context) ([]Order, error) { return items, nil } +const listOrdersDoubleQuoted = `-- name: ListOrdersDoubleQuoted :many +SELECT o.id, o.[select], o.[weird]]name] FROM [order] "o"; +` + +func (q *Queries) ListOrdersDoubleQuoted(ctx context.Context) ([]Order, error) { + rows, err := q.db.QueryContext(ctx, listOrdersDoubleQuoted) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Order + for rows.Next() { + var i Order + if err := rows.Scan(&i.ID, &i.Select, &i.WeirdName); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const salesByYear = `-- name: SalesByYear :many SELECT pv.author_id, pv.[2023], pv.[2024] FROM sales PIVOT (SUM(amount) FOR year IN ([2023], [2024])) pv; ` diff --git a/internal/endtoend/testdata/mssql_star_quoting/mssql/query.sql b/internal/endtoend/testdata/mssql_star_quoting/mssql/query.sql index dc4f48900c..8f19dc927f 100644 --- a/internal/endtoend/testdata/mssql_star_quoting/mssql/query.sql +++ b/internal/endtoend/testdata/mssql_star_quoting/mssql/query.sql @@ -3,3 +3,6 @@ SELECT o.* FROM [order] o; -- name: SalesByYear :many SELECT pv.* FROM sales PIVOT (SUM(amount) FOR year IN ([2023], [2024])) pv; + +-- name: ListOrdersDoubleQuoted :many +SELECT "o".* FROM [order] "o"; From 969a10e8175f42ae0472778c421472d69e37b21d Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:27:23 -0800 Subject: [PATCH 19/43] fix(mssql): embed a table only when the star is all of its columns sqlc.embed() on the core path folded a star into the table its first column came from, whatever else the star expanded to. The struct scans every column of the table in order, so an embed of a subquery that selects some of its columns, or reorders them, scanned the wrong number of values or into the wrong fields. A star is now folded only when it is exactly the table's columns in order, and is otherwise left flat. Addresses review comment r4557025012 on #1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/compiler/parse_core.go | 46 +++++++++++-- .../mssql_sqlc_macros/mssql/go/query.sql.go | 65 +++++++++++++++++++ .../mssql_sqlc_macros/mssql/query.sql | 6 ++ 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/internal/compiler/parse_core.go b/internal/compiler/parse_core.go index 49840002cc..5679f39dd6 100644 --- a/internal/compiler/parse_core.go +++ b/internal/compiler/parse_core.go @@ -64,7 +64,7 @@ func (c *Compiler) parseQueryCore(raw *ast.RawStmt, src string, pre *preprocess. for _, col := range res.Columns { cols = append(cols, coreColumn(col)) } - cols = embedColumns(raw, cols, res.Stars, pre.Embeds) + cols = embedColumns(raw, cols, res.Stars, pre.Embeds, c.coreCatalog) placeholders, err := placeholderNames(raw) if err != nil { return nil, err @@ -108,8 +108,10 @@ func (c *Compiler) parseQueryCore(raw *ast.RawStmt, src string, pre *preprocess. // embedColumns folds the columns each sqlc.embed() star expanded to into // the one column codegen embeds the table's struct for. The table is the -// columns' source, so an embed of an alias names the table behind it. -func embedColumns(raw *ast.RawStmt, cols []*Column, stars []core.StarExpansion, embeds preprocess.EmbedSet) []*Column { +// columns' source, so an embed of an alias names the table behind it. The +// struct scans every column of the table in order, so only an expansion +// that is exactly those columns is folded; any other stays flat. +func embedColumns(raw *ast.RawStmt, cols []*Column, stars []core.StarExpansion, embeds preprocess.EmbedSet, cat *core.Catalog) []*Column { sel, ok := raw.Stmt.(*ast.SelectStmt) if !ok || len(embeds) == 0 || sel.TargetList == nil { return cols @@ -135,7 +137,7 @@ func embedColumns(raw *ast.RawStmt, cols []*Column, stars []core.StarExpansion, if !ok || i+n > len(cols) { return cols } - if embed, ok := embeds.Find(ref.Location, res.Location); ok && n > 0 && cols[i].Table != nil { + if embed, ok := embeds.Find(ref.Location, res.Location); ok && n > 0 && isWholeTable(cat, cols[i:i+n]) { out = append(out, &Column{Name: embed.Table.Name, EmbedTable: cols[i].Table}) } else { out = append(out, cols[i:i+n]...) @@ -145,6 +147,42 @@ func embedColumns(raw *ast.RawStmt, cols []*Column, stars []core.StarExpansion, return out } +// isWholeTable reports whether cols are every column of one table, in the +// table's order. +func isWholeTable(cat *core.Catalog, cols []*Column) bool { + table := cols[0].Table + if table == nil { + return false + } + schema := table.Schema + if schema == "" { + schema = "public" + } + nsOID, err := cat.NamespaceOID(schema) + if err != nil { + return false + } + classOID, err := cat.ClassOID(nsOID, table.Name) + if err != nil { + return false + } + tableCols, err := cat.ClassColumns(classOID) + if err != nil { + return false + } + i := 0 + for _, tc := range tableCols { + if tc.Hidden { + continue + } + if i >= len(cols) || cols[i].Table == nil || *cols[i].Table != *table || cols[i].OriginalName != tc.Name { + return false + } + i++ + } + return i == len(cols) +} + func coreColumn(c core.Column) *Column { col := &Column{ Name: c.Name, diff --git a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/query.sql.go b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/query.sql.go index d6a425f9f4..4bad0e6fab 100644 --- a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/query.sql.go +++ b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/go/query.sql.go @@ -71,6 +71,71 @@ func (q *Queries) CreateAuthor(ctx context.Context, arg CreateAuthorParams) erro return err } +const embedNarrowSubquery = `-- name: EmbedNarrowSubquery :many +SELECT x.id, x.name FROM (SELECT id, name FROM authors) x; +` + +type EmbedNarrowSubqueryRow struct { + ID int64 + Name string +} + +func (q *Queries) EmbedNarrowSubquery(ctx context.Context) ([]EmbedNarrowSubqueryRow, error) { + rows, err := q.db.QueryContext(ctx, embedNarrowSubquery) + if err != nil { + return nil, err + } + defer rows.Close() + var items []EmbedNarrowSubqueryRow + for rows.Next() { + var i EmbedNarrowSubqueryRow + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const embedReorderedSubquery = `-- name: EmbedReorderedSubquery :many +SELECT x.name, x.id, x.bio FROM (SELECT name, id, bio FROM authors) x; +` + +type EmbedReorderedSubqueryRow struct { + Name string + ID int64 + Bio sql.NullString +} + +func (q *Queries) EmbedReorderedSubquery(ctx context.Context) ([]EmbedReorderedSubqueryRow, error) { + rows, err := q.db.QueryContext(ctx, embedReorderedSubquery) + if err != nil { + return nil, err + } + defer rows.Close() + var items []EmbedReorderedSubqueryRow + for rows.Next() { + var i EmbedReorderedSubqueryRow + if err := rows.Scan(&i.Name, &i.ID, &i.Bio); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listAuthorsByIDs = `-- name: ListAuthorsByIDs :many SELECT id, name FROM authors WHERE id IN (/*SLICE:ids*/@ids) ORDER BY id; ` diff --git a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/query.sql b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/query.sql index 37529badea..3c973ed232 100644 --- a/internal/endtoend/testdata/mssql_sqlc_macros/mssql/query.sql +++ b/internal/endtoend/testdata/mssql_sqlc_macros/mssql/query.sql @@ -21,3 +21,9 @@ ORDER BY books.id; -- name: ListAuthorsByIDsTwice :many SELECT id, name FROM authors WHERE id IN (sqlc.slice(ids)) OR id + 100 IN (sqlc.slice(ids)); + +-- name: EmbedNarrowSubquery :many +SELECT sqlc.embed(x) FROM (SELECT id, name FROM authors) x; + +-- name: EmbedReorderedSubquery :many +SELECT sqlc.embed(x) FROM (SELECT name, id, bio FROM authors) x; From 6a3e9b2f6f1746504027094d4b18ca05780564f1 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:33:39 -0800 Subject: [PATCH 20/43] fix(core): keep a WITH clause's names out of the enclosing query The analyzers of nested queries share the map of CTEs they inherit, so a WITH clause in a subquery added its names to the enclosing query's map, where a CTE could hide a table of the same name: with an inner WITH authors AS (...), a later SELECT name FROM authors in the outer query read the CTE and failed. A WITH clause now binds its names in its own copy of the map. Addresses review comment r4557025055 on #1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/core/analyzer/analyzer.go | 10 +++++--- .../analyze_cte_scope/postgresql/exec.json | 5 ++++ .../analyze_cte_scope/postgresql/query.sql | 6 +++++ .../analyze_cte_scope/postgresql/schema.sql | 1 + .../analyze_cte_scope/postgresql/stdout.json | 23 +++++++++++++++++++ 5 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 internal/endtoend/testdata/analyze_cte_scope/postgresql/exec.json create mode 100644 internal/endtoend/testdata/analyze_cte_scope/postgresql/query.sql create mode 100644 internal/endtoend/testdata/analyze_cte_scope/postgresql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_cte_scope/postgresql/stdout.json diff --git a/internal/core/analyzer/analyzer.go b/internal/core/analyzer/analyzer.go index 8f301f32ea..faa66a8da8 100644 --- a/internal/core/analyzer/analyzer.go +++ b/internal/core/analyzer/analyzer.go @@ -2,6 +2,7 @@ package analyzer import ( "fmt" + "maps" "github.com/sqlc-dev/sqlc/internal/core" "github.com/sqlc-dev/sqlc/internal/sql/ast" @@ -302,6 +303,12 @@ func (a *analyzer) bindCTEs(with *ast.WithClause) error { if with == nil { return nil } + // The analyzers of nested queries share the map they inherit, so a WITH + // clause binds its names in a copy, out of sight of the enclosing query. + a.ctes = maps.Clone(a.ctes) + if a.ctes == nil { + a.ctes = map[string]scopeRel{} + } for _, item := range listItems(with.Ctes) { cte, ok := item.(*ast.CommonTableExpr) if !ok || cte.Ctename == nil { @@ -311,9 +318,6 @@ func (a *analyzer) bindCTEs(with *ast.WithClause) error { if !ok { continue } - if a.ctes == nil { - a.ctes = map[string]scopeRel{} - } // A recursive CTE refers to itself in the branches after its first, // which gives the columns, so the first is bound before the rest. recursive := with.Recursive && sel.Larg != nil && refersTo(sel.Rarg, *cte.Ctename) diff --git a/internal/endtoend/testdata/analyze_cte_scope/postgresql/exec.json b/internal/endtoend/testdata/analyze_cte_scope/postgresql/exec.json new file mode 100644 index 0000000000..b102755fb6 --- /dev/null +++ b/internal/endtoend/testdata/analyze_cte_scope/postgresql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "postgresql", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_cte_scope/postgresql/query.sql b/internal/endtoend/testdata/analyze_cte_scope/postgresql/query.sql new file mode 100644 index 0000000000..148528dcd1 --- /dev/null +++ b/internal/endtoend/testdata/analyze_cte_scope/postgresql/query.sql @@ -0,0 +1,6 @@ +-- name: InnerWithDoesNotLeak :one +WITH x AS (SELECT 1 AS n) +SELECT + (SELECT count(*) FROM (WITH authors AS (SELECT 1 AS id) SELECT id FROM authors) s) AS inner_count, + (SELECT name FROM authors LIMIT 1) AS outer_name +FROM x; diff --git a/internal/endtoend/testdata/analyze_cte_scope/postgresql/schema.sql b/internal/endtoend/testdata/analyze_cte_scope/postgresql/schema.sql new file mode 100644 index 0000000000..b1098ab8ff --- /dev/null +++ b/internal/endtoend/testdata/analyze_cte_scope/postgresql/schema.sql @@ -0,0 +1 @@ +CREATE TABLE authors (id int PRIMARY KEY, name text NOT NULL); diff --git a/internal/endtoend/testdata/analyze_cte_scope/postgresql/stdout.json b/internal/endtoend/testdata/analyze_cte_scope/postgresql/stdout.json new file mode 100644 index 0000000000..5794ccc393 --- /dev/null +++ b/internal/endtoend/testdata/analyze_cte_scope/postgresql/stdout.json @@ -0,0 +1,23 @@ +[ + { + "name": "InnerWithDoesNotLeak", + "cmd": ":one", + "columns": [ + { + "name": "inner_count", + "type": { + "name": "bigint", + "nullable": true + } + }, + { + "name": "outer_name", + "type": { + "name": "text", + "nullable": true + } + } + ], + "params": [] + } +] From 1fe1a2d2b1a968db8dda36bf933f4a8d4bf3f02f Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:35:30 -0800 Subject: [PATCH 21/43] fix(mssql): leave bracketed identifiers alone when preprocessing The preprocessor skips the quoted regions of a query, but did not know SQL Server's bracketed identifiers, so [sqlc.arg(x)] was rewritten to [@x], an @name inside brackets was taken for a parameter, and a semicolon inside them split the statement. A dialect may now say that [...] quotes an identifier, with ]] standing for a bracket in it, and SQL Server does. Addresses review comment r4557025105 on #1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/sql/preprocess/CLAUDE.md | 6 +++--- internal/sql/preprocess/dialect.go | 5 +++++ internal/sql/preprocess/lexer.go | 5 +++++ .../mssql/skip_bracketed_identifiers/input.sql | 2 ++ .../mssql/skip_bracketed_identifiers/output.sql | 2 ++ .../skip_bracketed_identifiers/side_table.json | 16 ++++++++++++++++ 6 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 internal/sql/preprocess/testdata/mssql/skip_bracketed_identifiers/input.sql create mode 100644 internal/sql/preprocess/testdata/mssql/skip_bracketed_identifiers/output.sql create mode 100644 internal/sql/preprocess/testdata/mssql/skip_bracketed_identifiers/side_table.json diff --git a/internal/sql/preprocess/CLAUDE.md b/internal/sql/preprocess/CLAUDE.md index 2f8a48f67f..cf468c91c0 100644 --- a/internal/sql/preprocess/CLAUDE.md +++ b/internal/sql/preprocess/CLAUDE.md @@ -31,9 +31,9 @@ looks like. `lexer` is a dialect-parameterized scanner. It does not parse SQL — it knows only enough to skip the regions where sqlc syntax must not be rewritten: -comments (`--`, `/* */`, `#`), string literals, quoted identifiers, backticks -and PostgreSQL dollar-quoted strings. `Dialect` (dialect.go) is the single -place those lexical rules live. +comments (`--`, `/* */`, `#`), string literals, quoted identifiers, backticks, +SQL Server brackets and PostgreSQL dollar-quoted strings. `Dialect` +(dialect.go) is the single place those lexical rules live. `File(engine, src)` looks up the dialect; an engine with no entry gets its source back untouched. Otherwise it walks the whole file, splits it at diff --git a/internal/sql/preprocess/dialect.go b/internal/sql/preprocess/dialect.go index 4098512086..3ed78281dd 100644 --- a/internal/sql/preprocess/dialect.go +++ b/internal/sql/preprocess/dialect.go @@ -44,6 +44,10 @@ type Dialect struct { // Backtick reports whether `ident` is a quoted identifier. Backtick bool + // Bracket reports whether [ident] is a quoted identifier, with ]] + // standing for a closing bracket in it. + Bracket bool + // HashComment reports whether # starts a line comment. HashComment bool @@ -95,6 +99,7 @@ var dialects = map[config.Engine]Dialect{ config.EngineMSSQL: { Style: StyleAt, AtSign: true, + Bracket: true, NestedBlockComment: true, }, // ClickHouse binds with an unnumbered ?, like MySQL, and its identifiers diff --git a/internal/sql/preprocess/lexer.go b/internal/sql/preprocess/lexer.go index d17d80b077..7c9568c3c2 100644 --- a/internal/sql/preprocess/lexer.go +++ b/internal/sql/preprocess/lexer.go @@ -53,6 +53,11 @@ func (l *lexer) skip(i int) int { return -1 } return l.skipQuoted(i, '`', false) + case '[': + if !l.d.Bracket { + return -1 + } + return l.skipQuoted(i, ']', false) case '-': if l.at(i+1) == '-' { return l.skipLine(i) diff --git a/internal/sql/preprocess/testdata/mssql/skip_bracketed_identifiers/input.sql b/internal/sql/preprocess/testdata/mssql/skip_bracketed_identifiers/input.sql new file mode 100644 index 0000000000..cadc2e6fac --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/skip_bracketed_identifiers/input.sql @@ -0,0 +1,2 @@ +-- name: Bracketed :many +SELECT [sqlc.arg(x)], [a;b], [@id], [x]]@y] FROM t WHERE id = @id; diff --git a/internal/sql/preprocess/testdata/mssql/skip_bracketed_identifiers/output.sql b/internal/sql/preprocess/testdata/mssql/skip_bracketed_identifiers/output.sql new file mode 100644 index 0000000000..cadc2e6fac --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/skip_bracketed_identifiers/output.sql @@ -0,0 +1,2 @@ +-- name: Bracketed :many +SELECT [sqlc.arg(x)], [a;b], [@id], [x]]@y] FROM t WHERE id = @id; diff --git a/internal/sql/preprocess/testdata/mssql/skip_bracketed_identifiers/side_table.json b/internal/sql/preprocess/testdata/mssql/skip_bracketed_identifiers/side_table.json new file mode 100644 index 0000000000..b66e867f66 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/skip_bracketed_identifiers/side_table.json @@ -0,0 +1,16 @@ +[ + { + "start": 0, + "end": 91, + "dollar": true, + "params": [ + { + "number": 1, + "location": 87, + "origin": 87, + "name": "id", + "named": true + } + ] + } +] From d4a19b5532c1c907db1d8dba268ad861f8fce2be Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:37:02 -0800 Subject: [PATCH 22/43] fix(mssql): reject a macro name that is not a valid @name SQL Server binds a parameter by the name sqlc writes into the query, so sqlc.arg('user-id') became @user-id, which the server reads as @user minus id, and a name with a space did not parse; the generated Go did not compile either way. For a dialect that binds @name, a name passed to sqlc.arg, sqlc.narg or sqlc.slice must now be one an @name can spell, and is otherwise reported at the call. Addresses review comment r4557025173 on #1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/sql/preprocess/scan.go | 21 +++++++++++++++++++ .../mssql/error_invalid_param_name/input.sql | 2 ++ .../mssql/error_invalid_param_name/output.sql | 2 ++ .../error_invalid_param_name/side_table.json | 9 ++++++++ .../mssql/error_invalid_param_name/stderr.txt | 1 + 5 files changed, 35 insertions(+) create mode 100644 internal/sql/preprocess/testdata/mssql/error_invalid_param_name/input.sql create mode 100644 internal/sql/preprocess/testdata/mssql/error_invalid_param_name/output.sql create mode 100644 internal/sql/preprocess/testdata/mssql/error_invalid_param_name/side_table.json create mode 100644 internal/sql/preprocess/testdata/mssql/error_invalid_param_name/stderr.txt diff --git a/internal/sql/preprocess/scan.go b/internal/sql/preprocess/scan.go index a6245e5704..feafe91ddc 100644 --- a/internal/sql/preprocess/scan.go +++ b/internal/sql/preprocess/scan.go @@ -139,11 +139,32 @@ func (l *lexer) sqlcCall(start, afterSqlc int) (occurrence, int, bool) { } return occ, occ.end, true } + // A dialect that binds @name writes the name into the query, so it has + // to read back as one parameter: @user-id would be a subtraction. + if l.d.Style == StyleAt && !isParamName(name) { + occ.err = &sqlerr.Error{ + Message: fmt.Sprintf("parameter name %q passed to sqlc.%s is not a valid @name; use letters, digits and underscores", name, fn), + Location: start, + } + return occ, occ.end, true + } occ.name = name occ.ident = ident return occ, occ.end, true } +func isParamName(name string) bool { + if name == "" || !isIdentStart(name[0]) { + return false + } + for i := 1; i < len(name); i++ { + if !isIdentPart(name[i]) { + return false + } + } + return true +} + // argument interprets the single argument to a sqlc function. It returns the // parameter name and the text to reuse when rebuilding a reference, which keeps // any identifier quoting the user wrote. diff --git a/internal/sql/preprocess/testdata/mssql/error_invalid_param_name/input.sql b/internal/sql/preprocess/testdata/mssql/error_invalid_param_name/input.sql new file mode 100644 index 0000000000..5e5891a8b2 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/error_invalid_param_name/input.sql @@ -0,0 +1,2 @@ +-- name: Bad :many +SELECT id FROM t WHERE user_id = sqlc.arg('user-id') - 1; diff --git a/internal/sql/preprocess/testdata/mssql/error_invalid_param_name/output.sql b/internal/sql/preprocess/testdata/mssql/error_invalid_param_name/output.sql new file mode 100644 index 0000000000..5e5891a8b2 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/error_invalid_param_name/output.sql @@ -0,0 +1,2 @@ +-- name: Bad :many +SELECT id FROM t WHERE user_id = sqlc.arg('user-id') - 1; diff --git a/internal/sql/preprocess/testdata/mssql/error_invalid_param_name/side_table.json b/internal/sql/preprocess/testdata/mssql/error_invalid_param_name/side_table.json new file mode 100644 index 0000000000..e0e4768647 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/error_invalid_param_name/side_table.json @@ -0,0 +1,9 @@ +[ + { + "start": 0, + "end": 76, + "dollar": true, + "error": "parameter name \"user-id\" passed to sqlc.arg is not a valid @name; use letters, digits and underscores", + "error_at": 52 + } +] diff --git a/internal/sql/preprocess/testdata/mssql/error_invalid_param_name/stderr.txt b/internal/sql/preprocess/testdata/mssql/error_invalid_param_name/stderr.txt new file mode 100644 index 0000000000..89416c54d3 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/error_invalid_param_name/stderr.txt @@ -0,0 +1 @@ +2:34: parameter name "user-id" passed to sqlc.arg is not a valid @name; use letters, digits and underscores From d630cd1eb84d211d9ce68b9bbc849c4421912131 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:56:23 -0800 Subject: [PATCH 23/43] fix(mssql): keep the placeholders of FOR SYSTEM_TIME A table read FOR SYSTEM_TIME AS OF @t, BETWEEN, FROM ... TO or CONTAINED IN converted to the plain table, dropping the placeholders the clause holds, so the generated code did not pass them. The table reference has nowhere to hold an expression, so the times are now analyzed with the query's WHERE clause, as the datetime2 SQL Server takes them as. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../testdata/analyze_temporal/mssql/exec.json | 5 + .../testdata/analyze_temporal/mssql/query.sql | 5 + .../analyze_temporal/mssql/schema.sql | 7 + .../analyze_temporal/mssql/stdout.json | 138 ++++++++++++++++++ internal/engine/mssql/convert.go | 22 +++ 5 files changed, 177 insertions(+) create mode 100644 internal/endtoend/testdata/analyze_temporal/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_temporal/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_temporal/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_temporal/mssql/stdout.json diff --git a/internal/endtoend/testdata/analyze_temporal/mssql/exec.json b/internal/endtoend/testdata/analyze_temporal/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_temporal/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_temporal/mssql/query.sql b/internal/endtoend/testdata/analyze_temporal/mssql/query.sql new file mode 100644 index 0000000000..06320c8b79 --- /dev/null +++ b/internal/endtoend/testdata/analyze_temporal/mssql/query.sql @@ -0,0 +1,5 @@ +-- name: PriceAsOf :many +SELECT id, amount FROM prices FOR SYSTEM_TIME AS OF @at WHERE id = @id; + +-- name: PriceHistory :many +SELECT id, amount, valid_from, valid_to FROM prices FOR SYSTEM_TIME BETWEEN @from AND @to; diff --git a/internal/endtoend/testdata/analyze_temporal/mssql/schema.sql b/internal/endtoend/testdata/analyze_temporal/mssql/schema.sql new file mode 100644 index 0000000000..971a5a65cd --- /dev/null +++ b/internal/endtoend/testdata/analyze_temporal/mssql/schema.sql @@ -0,0 +1,7 @@ +CREATE TABLE prices ( + id INT PRIMARY KEY, + amount DECIMAL(10,2) NOT NULL, + valid_from DATETIME2 GENERATED ALWAYS AS ROW START NOT NULL, + valid_to DATETIME2 GENERATED ALWAYS AS ROW END NOT NULL, + PERIOD FOR SYSTEM_TIME (valid_from, valid_to) +) WITH (SYSTEM_VERSIONING = ON); diff --git a/internal/endtoend/testdata/analyze_temporal/mssql/stdout.json b/internal/endtoend/testdata/analyze_temporal/mssql/stdout.json new file mode 100644 index 0000000000..942935336f --- /dev/null +++ b/internal/endtoend/testdata/analyze_temporal/mssql/stdout.json @@ -0,0 +1,138 @@ +[ + { + "name": "PriceAsOf", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "int" + }, + "table": "prices" + }, + { + "name": "amount", + "type": { + "name": "decimal", + "args": [ + { + "int": 10 + }, + { + "int": 2 + } + ] + }, + "table": "prices" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "at", + "type": { + "name": "datetime2", + "args": [ + { + "int": 7 + } + ] + } + } + }, + { + "number": 2, + "column": { + "name": "id", + "type": { + "name": "int" + }, + "table": "prices" + } + } + ] + }, + { + "name": "PriceHistory", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "int" + }, + "table": "prices" + }, + { + "name": "amount", + "type": { + "name": "decimal", + "args": [ + { + "int": 10 + }, + { + "int": 2 + } + ] + }, + "table": "prices" + }, + { + "name": "valid_from", + "type": { + "name": "datetime2", + "args": [ + { + "int": 7 + } + ] + }, + "table": "prices" + }, + { + "name": "valid_to", + "type": { + "name": "datetime2", + "args": [ + { + "int": 7 + } + ] + }, + "table": "prices" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "from", + "type": { + "name": "datetime2", + "args": [ + { + "int": 7 + } + ] + } + } + }, + { + "number": 2, + "column": { + "name": "to", + "type": { + "name": "datetime2", + "args": [ + { + "int": 7 + } + ] + } + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index 9a40577568..f1996d1112 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -18,6 +18,10 @@ type cc struct { // toByte maps a teesql UTF-16 code-unit offset to a byte offset in the // source, so Location fields agree with StmtLocation/StmtLen. toByte func(int) int + // fromExprs are expressions a FROM item holds that the AST has no place + // for, as FOR SYSTEM_TIME AS OF @t does, analyzed with the query's WHERE + // clause so a placeholder in them is still a parameter. + fromExprs []ast.Node } // loc returns a node's start offset in bytes, for the Location fields of the @@ -201,16 +205,26 @@ func (c *cc) convertQuerySpecification(n *tsql.QuerySpecification) *ast.SelectSt } } + outerFromExprs := c.fromExprs + c.fromExprs = nil if n.FromClause != nil { stmt.FromClause = &ast.List{} for _, ref := range n.FromClause.TableReferences { stmt.FromClause.Items = append(stmt.FromClause.Items, c.convertTableReference(ref)) } } + fromExprs := c.fromExprs + c.fromExprs = outerFromExprs if n.WhereClause != nil { stmt.WhereClause = c.convertBooleanExpression(n.WhereClause.SearchCondition) } + if len(fromExprs) > 0 { + if stmt.WhereClause != nil { + fromExprs = append([]ast.Node{stmt.WhereClause}, fromExprs...) + } + stmt.WhereClause = &ast.BoolExpr{Boolop: ast.BoolExprTypeAnd, Args: &ast.List{Items: fromExprs}} + } if n.GroupByClause != nil { stmt.GroupClause = &ast.List{} @@ -380,6 +394,14 @@ func (c *cc) convertTableReference(ref tsql.TableReference) ast.Node { alias := identifierValue(t.Alias) rv.Alias = &ast.Alias{Aliasname: &alias} } + // The times of FOR SYSTEM_TIME are the datetime2 SQL Server takes. + if tc := t.TemporalClause; tc != nil { + for _, e := range []tsql.ScalarExpression{tc.StartTime, tc.EndTime} { + if e != nil { + c.fromExprs = append(c.fromExprs, &ast.TypeCast{Arg: c.convertScalarExpression(e), TypeName: typeName("datetime2", 7)}) + } + } + } return rv case *tsql.QueryDerivedTable: sub := &ast.RangeSubselect{ From c3ceb10d7da1b971222fbab6c208f71081a90aec Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:56:49 -0800 Subject: [PATCH 24/43] fix(mssql): keep the placeholders of CONTAINS and FREETEXT The full-text predicates were left unconverted, which dropped a placeholder for the search condition from the query's parameters, so the generated code did not pass it. They are now calls of the functions seeded under their names, whose search condition is the nvarchar(4000) SQL Server takes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mssql_fulltext_params/mssql/exec.json | 1 + .../mssql_fulltext_params/mssql/go/db.go | 31 ++++++++ .../mssql_fulltext_params/mssql/go/models.go | 10 +++ .../mssql/go/query.sql.go | 70 +++++++++++++++++++ .../mssql_fulltext_params/mssql/query.sql | 5 ++ .../mssql_fulltext_params/mssql/schema.sql | 1 + .../mssql_fulltext_params/mssql/sqlc.yaml | 10 +++ internal/engine/mssql/convert.go | 12 ++++ internal/engine/mssql/dialect/functions.jsonl | 4 ++ 9 files changed, 144 insertions(+) create mode 100644 internal/endtoend/testdata/mssql_fulltext_params/mssql/exec.json create mode 100644 internal/endtoend/testdata/mssql_fulltext_params/mssql/go/db.go create mode 100644 internal/endtoend/testdata/mssql_fulltext_params/mssql/go/models.go create mode 100644 internal/endtoend/testdata/mssql_fulltext_params/mssql/go/query.sql.go create mode 100644 internal/endtoend/testdata/mssql_fulltext_params/mssql/query.sql create mode 100644 internal/endtoend/testdata/mssql_fulltext_params/mssql/schema.sql create mode 100644 internal/endtoend/testdata/mssql_fulltext_params/mssql/sqlc.yaml diff --git a/internal/endtoend/testdata/mssql_fulltext_params/mssql/exec.json b/internal/endtoend/testdata/mssql_fulltext_params/mssql/exec.json new file mode 100644 index 0000000000..f4e21983b6 --- /dev/null +++ b/internal/endtoend/testdata/mssql_fulltext_params/mssql/exec.json @@ -0,0 +1 @@ +{"contexts": ["base"]} diff --git a/internal/endtoend/testdata/mssql_fulltext_params/mssql/go/db.go b/internal/endtoend/testdata/mssql_fulltext_params/mssql/go/db.go new file mode 100644 index 0000000000..32faa9f2b8 --- /dev/null +++ b/internal/endtoend/testdata/mssql_fulltext_params/mssql/go/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/endtoend/testdata/mssql_fulltext_params/mssql/go/models.go b/internal/endtoend/testdata/mssql_fulltext_params/mssql/go/models.go new file mode 100644 index 0000000000..32a015b046 --- /dev/null +++ b/internal/endtoend/testdata/mssql_fulltext_params/mssql/go/models.go @@ -0,0 +1,10 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +type Book struct { + ID int32 + Title string +} diff --git a/internal/endtoend/testdata/mssql_fulltext_params/mssql/go/query.sql.go b/internal/endtoend/testdata/mssql_fulltext_params/mssql/go/query.sql.go new file mode 100644 index 0000000000..cfee736040 --- /dev/null +++ b/internal/endtoend/testdata/mssql_fulltext_params/mssql/go/query.sql.go @@ -0,0 +1,70 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package querytest + +import ( + "context" + "database/sql" +) + +const searchBooks = `-- name: SearchBooks :many +SELECT id, title FROM books WHERE CONTAINS(title, @query); +` + +func (q *Queries) SearchBooks(ctx context.Context, query string) ([]Book, error) { + rows, err := q.db.QueryContext(ctx, searchBooks, sql.Named("query", query)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Book + for rows.Next() { + var i Book + if err := rows.Scan(&i.ID, &i.Title); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchBooksFreeText = `-- name: SearchBooksFreeText :many +SELECT id, title FROM books WHERE FREETEXT(title, @phrase, LANGUAGE 1033) AND id > @min_id; +` + +type SearchBooksFreeTextParams struct { + Phrase string + MinID int32 +} + +func (q *Queries) SearchBooksFreeText(ctx context.Context, arg SearchBooksFreeTextParams) ([]Book, error) { + rows, err := q.db.QueryContext(ctx, searchBooksFreeText, sql.Named("phrase", arg.Phrase), sql.Named("min_id", arg.MinID)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Book + for rows.Next() { + var i Book + if err := rows.Scan(&i.ID, &i.Title); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/endtoend/testdata/mssql_fulltext_params/mssql/query.sql b/internal/endtoend/testdata/mssql_fulltext_params/mssql/query.sql new file mode 100644 index 0000000000..66a83e8708 --- /dev/null +++ b/internal/endtoend/testdata/mssql_fulltext_params/mssql/query.sql @@ -0,0 +1,5 @@ +-- name: SearchBooks :many +SELECT id, title FROM books WHERE CONTAINS(title, @query); + +-- name: SearchBooksFreeText :many +SELECT id, title FROM books WHERE FREETEXT(title, @phrase, LANGUAGE 1033) AND id > @min_id; diff --git a/internal/endtoend/testdata/mssql_fulltext_params/mssql/schema.sql b/internal/endtoend/testdata/mssql_fulltext_params/mssql/schema.sql new file mode 100644 index 0000000000..baa6ed3a42 --- /dev/null +++ b/internal/endtoend/testdata/mssql_fulltext_params/mssql/schema.sql @@ -0,0 +1 @@ +CREATE TABLE books (id INT PRIMARY KEY, title NVARCHAR(200) NOT NULL); diff --git a/internal/endtoend/testdata/mssql_fulltext_params/mssql/sqlc.yaml b/internal/endtoend/testdata/mssql_fulltext_params/mssql/sqlc.yaml new file mode 100644 index 0000000000..94b0dc8896 --- /dev/null +++ b/internal/endtoend/testdata/mssql_fulltext_params/mssql/sqlc.yaml @@ -0,0 +1,10 @@ +version: "2" +sql: + - engine: "mssql" + schema: "schema.sql" + queries: "query.sql" + gen: + go: + package: "querytest" + out: "go" + sql_package: "database/sql" diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index f1996d1112..3bc8119255 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -724,6 +724,18 @@ func (c *cc) convertBooleanExpression(expr tsql.BooleanExpression) ast.Node { } case *tsql.BooleanScalarPlaceholder: return c.convertScalarExpression(e.Scalar) + case *tsql.FullTextPredicate: + // CONTAINS and FREETEXT are the predicates the dialect seeds under + // their names, over their search condition and language term. + call := &ast.FuncCall{ + Funcname: &ast.List{Items: []ast.Node{&ast.String{Str: identifier(e.FullTextFunctionType)}}}, + Args: &ast.List{Items: []ast.Node{c.convertScalarExpression(e.Value)}}, + Location: c.loc(e), + } + if e.LanguageTerm != nil { + call.Args.Items = append(call.Args.Items, c.convertScalarExpression(e.LanguageTerm)) + } + return call default: return todo(expr) } diff --git a/internal/engine/mssql/dialect/functions.jsonl b/internal/engine/mssql/dialect/functions.jsonl index e39e27fe03..99167607ec 100644 --- a/internal/engine/mssql/dialect/functions.jsonl +++ b/internal/engine/mssql/dialect/functions.jsonl @@ -106,3 +106,7 @@ {"name": "@@options", "returns": "int"} {"name": "@@textsize", "returns": "int"} {"name": "@@cursor_rows", "returns": "int"} +{"name": "contains", "args": [{"type": "nvarchar(4000)"}], "returns": "bit"} +{"name": "contains", "args": [{"type": "nvarchar(4000)"}, {"type": "any"}], "returns": "bit"} +{"name": "freetext", "args": [{"type": "nvarchar(4000)"}], "returns": "bit"} +{"name": "freetext", "args": [{"type": "nvarchar(4000)"}, {"type": "any"}], "returns": "bit"} From de920bcfcf0bc712f2e4c91bf1d24dc57b0cf252 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:02:25 -0800 Subject: [PATCH 25/43] feat(mssql): support WITH on INSERT The converter dropped the WITH clause of an INSERT, and the core analyzer bound CTEs for SELECT, UPDATE and DELETE but not INSERT, so an INSERT ... SELECT reading a CTE failed with an unknown relation. Both now carry it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/core/analyzer/dml.go | 3 + .../analyze_cte_insert/mssql/exec.json | 5 ++ .../analyze_cte_insert/mssql/query.sql | 5 ++ .../analyze_cte_insert/mssql/schema.sql | 2 + .../analyze_cte_insert/mssql/stdout.json | 67 +++++++++++++++++++ internal/engine/mssql/convert.go | 3 +- 6 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 internal/endtoend/testdata/analyze_cte_insert/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_cte_insert/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_cte_insert/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_cte_insert/mssql/stdout.json diff --git a/internal/core/analyzer/dml.go b/internal/core/analyzer/dml.go index 8b7554b638..dc16d47ceb 100644 --- a/internal/core/analyzer/dml.go +++ b/internal/core/analyzer/dml.go @@ -11,6 +11,9 @@ func (a *analyzer) analyzeInsert(s *ast.InsertStmt) error { if s.Relation == nil { return fmt.Errorf("insert: missing relation") } + if err := a.bindCTEs(s.WithClause); err != nil { + return err + } rel, err := a.bindRangeVar(s.Relation) if err != nil { return err diff --git a/internal/endtoend/testdata/analyze_cte_insert/mssql/exec.json b/internal/endtoend/testdata/analyze_cte_insert/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_cte_insert/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_cte_insert/mssql/query.sql b/internal/endtoend/testdata/analyze_cte_insert/mssql/query.sql new file mode 100644 index 0000000000..b2b0e55321 --- /dev/null +++ b/internal/endtoend/testdata/analyze_cte_insert/mssql/query.sql @@ -0,0 +1,5 @@ +-- name: CopyFromAuthor :many +WITH a AS (SELECT id FROM authors WHERE name = @name) +INSERT INTO books (id, author_id, title) +OUTPUT INSERTED.id, INSERTED.title +SELECT CAST(@book_id AS INT), a.id, CAST(@title AS NVARCHAR(200)) FROM a; diff --git a/internal/endtoend/testdata/analyze_cte_insert/mssql/schema.sql b/internal/endtoend/testdata/analyze_cte_insert/mssql/schema.sql new file mode 100644 index 0000000000..ceb008432e --- /dev/null +++ b/internal/endtoend/testdata/analyze_cte_insert/mssql/schema.sql @@ -0,0 +1,2 @@ +CREATE TABLE books (id INT NOT NULL, author_id INT NOT NULL, title NVARCHAR(200) NOT NULL); +CREATE TABLE authors (id INT NOT NULL, name NVARCHAR(100) NOT NULL); diff --git a/internal/endtoend/testdata/analyze_cte_insert/mssql/stdout.json b/internal/endtoend/testdata/analyze_cte_insert/mssql/stdout.json new file mode 100644 index 0000000000..af61a35a49 --- /dev/null +++ b/internal/endtoend/testdata/analyze_cte_insert/mssql/stdout.json @@ -0,0 +1,67 @@ +[ + { + "name": "CopyFromAuthor", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "int" + }, + "table": "books" + }, + { + "name": "title", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 200 + } + ] + }, + "table": "books" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "name", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 100 + } + ] + }, + "table": "authors" + } + }, + { + "number": 2, + "column": { + "name": "book_id", + "type": { + "name": "int" + } + } + }, + { + "number": 3, + "column": { + "name": "title", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 200 + } + ] + } + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index 3bc8119255..ee07fc9935 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -1133,7 +1133,8 @@ func (c *cc) convertInsertStatement(n *tsql.InsertStatement) ast.Node { } stmt := &ast.InsertStmt{ - Relation: c.parseRangeVar(target.SchemaObject), + Relation: c.parseRangeVar(target.SchemaObject), + WithClause: c.convertWithClause(n.WithCtesAndXmlNamespaces), } if len(spec.Columns) > 0 { From 0e560ff5a0adab0f17821c0fcf6b7c11e6fbe8d1 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:13:31 -0800 Subject: [PATCH 26/43] feat(mssql): type a call to a scalar function the schema creates CREATE FUNCTION was ignored, so a query calling the function failed. A scalar function is now recorded with its parameters and result, which SQL Server reports nullable whatever the arguments, so the converter returns Nullable(T) and the schema records a T that may be NULL. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/core/schema/schema.go | 16 ++-- .../analyze_scalar_functions/mssql/exec.json | 5 ++ .../analyze_scalar_functions/mssql/query.sql | 2 + .../analyze_scalar_functions/mssql/schema.sql | 8 ++ .../mssql/stdout.json | 82 +++++++++++++++++++ internal/engine/mssql/convert.go | 29 +++++++ 6 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 internal/endtoend/testdata/analyze_scalar_functions/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_scalar_functions/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_scalar_functions/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_scalar_functions/mssql/stdout.json diff --git a/internal/core/schema/schema.go b/internal/core/schema/schema.go index e96d8dbe2a..aeeb748a7b 100644 --- a/internal/core/schema/schema.go +++ b/internal/core/schema/schema.go @@ -491,7 +491,12 @@ func applyCreateFunction(cat *core.Catalog, stmt *ast.CreateFunctionStmt) error if stmt.Func == nil || stmt.Func.Name == "" || stmt.ReturnType == nil { return nil } - returnOID, err := cat.ResolveType(stmt.ReturnType) + // A Nullable(T) result is a T the function may return NULL for. + ret := core.TypeExprOfTypeName(stmt.ReturnType) + if ret == nil { + return fmt.Errorf("function %q: missing type name", stmt.Func.Name) + } + returnOID, err := cat.ResolveTypeExpr(ret.WithNullable(false)) if err != nil { return fmt.Errorf("function %q: %w", stmt.Func.Name, err) } @@ -518,10 +523,11 @@ func applyCreateFunction(cat *core.Catalog, stmt *ast.CreateFunctionStmt) error } } _, err = cat.CreateProc(core.ProcSpec{ - Name: stmt.Func.Name, - ReturnTypeOID: returnOID, - ReturnSet: stmt.ReturnType != nil && stmt.ReturnType.Setof, - Args: args, + Name: stmt.Func.Name, + ReturnTypeOID: returnOID, + ReturnSet: stmt.ReturnType != nil && stmt.ReturnType.Setof, + ReturnNullable: ret.Nullable, + Args: args, }) return err } diff --git a/internal/endtoend/testdata/analyze_scalar_functions/mssql/exec.json b/internal/endtoend/testdata/analyze_scalar_functions/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_scalar_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_scalar_functions/mssql/query.sql b/internal/endtoend/testdata/analyze_scalar_functions/mssql/query.sql new file mode 100644 index 0000000000..89bb6eef73 --- /dev/null +++ b/internal/endtoend/testdata/analyze_scalar_functions/mssql/query.sql @@ -0,0 +1,2 @@ +-- name: ItemTotals :many +SELECT id, dbo.f_add(id, price) AS total, dbo.f_add(@n, DEFAULT) AS d, dbo.label(@s) AS l FROM items; diff --git a/internal/endtoend/testdata/analyze_scalar_functions/mssql/schema.sql b/internal/endtoend/testdata/analyze_scalar_functions/mssql/schema.sql new file mode 100644 index 0000000000..bbb242872d --- /dev/null +++ b/internal/endtoend/testdata/analyze_scalar_functions/mssql/schema.sql @@ -0,0 +1,8 @@ +CREATE TABLE items (id INT NOT NULL, price DECIMAL(10,2) NOT NULL); +GO +CREATE FUNCTION dbo.f_add(@a INT, @b DECIMAL(10,2) = 1) RETURNS DECIMAL(12,2) +AS BEGIN RETURN @a + @b END; +GO +CREATE FUNCTION label(@s NVARCHAR(20)) RETURNS NVARCHAR(30) WITH SCHEMABINDING +AS BEGIN RETURN N'x' + @s END; +GO diff --git a/internal/endtoend/testdata/analyze_scalar_functions/mssql/stdout.json b/internal/endtoend/testdata/analyze_scalar_functions/mssql/stdout.json new file mode 100644 index 0000000000..9da8e78da8 --- /dev/null +++ b/internal/endtoend/testdata/analyze_scalar_functions/mssql/stdout.json @@ -0,0 +1,82 @@ +[ + { + "name": "ItemTotals", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "int" + }, + "table": "items" + }, + { + "name": "total", + "type": { + "name": "decimal", + "nullable": true, + "args": [ + { + "int": 12 + }, + { + "int": 2 + } + ] + } + }, + { + "name": "d", + "type": { + "name": "decimal", + "nullable": true, + "args": [ + { + "int": 12 + }, + { + "int": 2 + } + ] + } + }, + { + "name": "l", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 30 + } + ] + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "n", + "type": { + "name": "int" + } + } + }, + { + "number": 2, + "column": { + "name": "s", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 20 + } + ] + } + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index ee07fc9935..e3f6972fe3 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -68,6 +68,8 @@ func (c *cc) convert(node tsql.Node) ast.Node { return c.convertAlterTableAddTableElementStatement(n) case *tsql.AlterTableDropTableElementStatement: return c.convertAlterTableDropTableElementStatement(n) + case *tsql.CreateFunctionStatement: + return c.convertCreateFunctionStatement(n) case *tsql.CreateTypeUddtStatement: return c.convertCreateTypeUddtStatement(n) default: @@ -1513,6 +1515,33 @@ func (c *cc) convertAlterTableAddTableElementStatement(n *tsql.AlterTableAddTabl return stmt } +// convertCreateFunctionStatement records a scalar function, whose result +// SQL Server reports nullable whatever its arguments are. +func (c *cc) convertCreateFunctionStatement(n *tsql.CreateFunctionStatement) ast.Node { + ret, ok := n.ReturnType.(*tsql.ScalarFunctionReturnType) + if !ok || ret.DataType == nil || n.Name == nil || n.Name.BaseIdentifier == nil { + return todo(n) + } + tn := parseTableName(n.Name) + params := &ast.List{} + for _, p := range n.Parameters { + if p.VariableName == nil || p.DataType == nil { + continue + } + name := strings.TrimPrefix(p.VariableName.Value, "@") + param := &ast.FuncParam{Name: &name, Type: dataTypeName(p.DataType)} + if p.Value != nil { + param.DefExpr = c.convertScalarExpression(p.Value) + } + params.Items = append(params.Items, param) + } + return &ast.CreateFunctionStmt{ + Func: &ast.FuncName{Schema: tn.Schema, Name: tn.Name}, + Params: params, + ReturnType: nullableTypeName(dataTypeName(ret.DataType)), + } +} + func (c *cc) convertAlterTableDropTableElementStatement(n *tsql.AlterTableDropTableElementStatement) ast.Node { if n.SchemaObjectName == nil { return todo(n) From a90357665d835a5e11b3eda478eee96ba5f23db0 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:18:11 -0800 Subject: [PATCH 27/43] feat(mssql): select from a table-valued function the schema creates A table-valued function called in FROM was not converted, so the query failed. A function in FROM is now called, and a table-valued function returns rows of table(col type, ...): the columns a multi-statement function declares, or those an inline function's query selects, which the schema works out as it does a view's. A function returning such rows binds as a relation of those columns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/core/analyzer/scope.go | 21 +++ internal/core/schema/schema.go | 38 ++++ .../mssql/exec.json | 5 + .../mssql/query.sql | 8 + .../mssql/schema.sql | 11 ++ .../mssql/stdout.json | 171 ++++++++++++++++++ internal/engine/mssql/convert.go | 71 +++++++- 7 files changed, 317 insertions(+), 8 deletions(-) create mode 100644 internal/endtoend/testdata/analyze_table_valued_functions/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_table_valued_functions/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_table_valued_functions/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_table_valued_functions/mssql/stdout.json diff --git a/internal/core/analyzer/scope.go b/internal/core/analyzer/scope.go index 7bc1424280..b48986d383 100644 --- a/internal/core/analyzer/scope.go +++ b/internal/core/analyzer/scope.go @@ -158,12 +158,33 @@ func (a *analyzer) bindRangeFunction(rf *ast.RangeFunction) (scopeRel, error) { col.TypeOID, col.NotNull = p.ReturnTypeOID, !p.ReturnNullable } rel.cols = []core.ClassColumn{col} + if cols := a.rowColumns(overloads[0]); cols != nil { + rel.cols = cols + } if rf.Coldeflist != nil { rel.cols = a.coldefColumns(rf.Coldeflist, col) } return rel, nil } +// rowColumns are the columns of a function returning rows of +// table(col type, ...), as a SQL Server table-valued function does. +func (a *analyzer) rowColumns(p core.ProcOverload) []core.ClassColumn { + row, err := a.cat.TypeExprOf(p.ReturnTypeOID) + if err != nil || row.Name != "table" || len(row.Args) == 0 { + return nil + } + var cols []core.ClassColumn + for _, arg := range row.Args { + if arg.Label == "" || arg.Type == nil { + return nil + } + t := a.lookupType(arg.Type.WithNullable(false)) + cols = append(cols, core.ClassColumn{Name: arg.Label, TypeOID: t.typeOID, Type: t.expr, NotNull: !arg.Type.Nullable}) + } + return cols +} + // coldefColumns are the columns a function's column definition list // declares, for a function returning rows of more than one column. A // definition with no type is the function's own result. diff --git a/internal/core/schema/schema.go b/internal/core/schema/schema.go index aeeb748a7b..14708920da 100644 --- a/internal/core/schema/schema.go +++ b/internal/core/schema/schema.go @@ -413,6 +413,37 @@ func applyCreateDomain(cat *core.Catalog, stmt *ast.CreateDomainStmt) error { return err } +// functionQuery is the query a function returns the rows of, which a +// converter passes as the "query" option when the function's columns are +// whatever it selects, as a SQL Server inline table-valued function's are. +func functionQuery(stmt *ast.CreateFunctionStmt) ast.Node { + for _, item := range listItems(stmt.Options) { + if def, ok := item.(*ast.DefElem); ok && def.Defname != nil && *def.Defname == "query" { + return def.Arg + } + } + return nil +} + +// queryRow is the row type of a query's results, table(col type, ...). +func queryRow(cat *core.Catalog, query ast.Node) (*core.TypeExpr, error) { + res, err := analyzer.Prepare(cat, query) + if err != nil { + return nil, err + } + row := &core.TypeExpr{Name: "table"} + for _, col := range res.Columns { + t := col.Type + if t == nil { + if t, err = cat.TypeExprOf(col.TypeOID); err != nil { + return nil, err + } + } + row.Args = append(row.Args, core.TypeArg{Label: col.Name, Type: t.WithNullable(!col.NotNull)}) + } + return row, nil +} + // applyCompositeType records a composite type as a type whose arguments are // its fields, labelled by name. func applyCompositeType(cat *core.Catalog, stmt *ast.CompositeTypeStmt) error { @@ -496,6 +527,13 @@ func applyCreateFunction(cat *core.Catalog, stmt *ast.CreateFunctionStmt) error if ret == nil { return fmt.Errorf("function %q: missing type name", stmt.Func.Name) } + if query := functionQuery(stmt); query != nil { + row, err := queryRow(cat, query) + if err != nil { + return fmt.Errorf("function %q: %w", stmt.Func.Name, err) + } + ret = row + } returnOID, err := cat.ResolveTypeExpr(ret.WithNullable(false)) if err != nil { return fmt.Errorf("function %q: %w", stmt.Func.Name, err) diff --git a/internal/endtoend/testdata/analyze_table_valued_functions/mssql/exec.json b/internal/endtoend/testdata/analyze_table_valued_functions/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_table_valued_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_table_valued_functions/mssql/query.sql b/internal/endtoend/testdata/analyze_table_valued_functions/mssql/query.sql new file mode 100644 index 0000000000..703a1aa0a2 --- /dev/null +++ b/internal/endtoend/testdata/analyze_table_valued_functions/mssql/query.sql @@ -0,0 +1,8 @@ +-- name: ApplyBoth :many +SELECT o.id, o.name, o.price, m.id AS mid, m.label FROM dbo.items_over(@p) o CROSS APPLY dbo.ms_items(o.id) m; + +-- name: InlineStar :many +SELECT * FROM items_over(@min); + +-- name: OuterApply :many +SELECT i.id, m.label FROM items i OUTER APPLY ms_items(i.id) m WHERE i.id = @id; diff --git a/internal/endtoend/testdata/analyze_table_valued_functions/mssql/schema.sql b/internal/endtoend/testdata/analyze_table_valued_functions/mssql/schema.sql new file mode 100644 index 0000000000..9f7b6cba9d --- /dev/null +++ b/internal/endtoend/testdata/analyze_table_valued_functions/mssql/schema.sql @@ -0,0 +1,11 @@ +CREATE TABLE items (id INT NOT NULL, name NVARCHAR(20) NULL, price DECIMAL(10,2) NOT NULL); +GO +CREATE FUNCTION dbo.items_over(@min DECIMAL(10,2)) RETURNS TABLE +AS RETURN SELECT id, name, price FROM items WHERE price > @min; +GO +CREATE FUNCTION ms_items(@n INT) RETURNS @t TABLE (id INT NOT NULL, label NVARCHAR(30)) +AS BEGIN + INSERT @t SELECT id, name FROM items WHERE id > @n; + RETURN; +END; +GO diff --git a/internal/endtoend/testdata/analyze_table_valued_functions/mssql/stdout.json b/internal/endtoend/testdata/analyze_table_valued_functions/mssql/stdout.json new file mode 100644 index 0000000000..f8997abd13 --- /dev/null +++ b/internal/endtoend/testdata/analyze_table_valued_functions/mssql/stdout.json @@ -0,0 +1,171 @@ +[ + { + "name": "ApplyBoth", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "int" + } + }, + { + "name": "name", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 20 + } + ] + } + }, + { + "name": "price", + "type": { + "name": "decimal", + "args": [ + { + "int": 10 + }, + { + "int": 2 + } + ] + } + }, + { + "name": "mid", + "type": { + "name": "int" + } + }, + { + "name": "label", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 30 + } + ] + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "p", + "type": { + "name": "decimal", + "args": [ + { + "int": 10 + }, + { + "int": 2 + } + ] + } + } + } + ] + }, + { + "name": "InlineStar", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "int" + } + }, + { + "name": "name", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 20 + } + ] + } + }, + { + "name": "price", + "type": { + "name": "decimal", + "args": [ + { + "int": 10 + }, + { + "int": 2 + } + ] + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "min", + "type": { + "name": "decimal", + "args": [ + { + "int": 10 + }, + { + "int": 2 + } + ] + } + } + } + ] + }, + { + "name": "OuterApply", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "int" + }, + "table": "items" + }, + { + "name": "label", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 30 + } + ] + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "id", + "type": { + "name": "int" + }, + "table": "items" + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index e3f6972fe3..d2c971f871 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -456,6 +456,11 @@ func (c *cc) convertTableReference(ref tsql.TableReference) ast.Node { return c.convertOpenJson(t) case *tsql.GlobalFunctionTableReference: return c.convertGlobalFunctionTableReference(t) + case *tsql.SchemaObjectFunctionTableReference: + if t.SchemaObject == nil || t.SchemaObject.BaseIdentifier == nil { + return todo(t) + } + return c.rangeFunction(t, identifierValue(t.SchemaObject.BaseIdentifier), t.Parameters, t.Alias, nil) case *tsql.PivotedTableReference: return c.convertPivot(t) case *tsql.UnpivotedTableReference: @@ -561,7 +566,7 @@ func (c *cc) convertInlineDerivedTable(n *tsql.InlineDerivedTable) ast.Node { } // rangeFunction is a call in FROM to a function returning rows of the -// columns defs declares. +// columns defs declares, or of its own columns when there are none. func (c *cc) rangeFunction(n tsql.Node, name string, args []tsql.ScalarExpression, alias *tsql.Identifier, defs []ast.Node) *ast.RangeFunction { call := &ast.FuncCall{ Funcname: &ast.List{Items: []ast.Node{&ast.String{Str: name}}}, @@ -574,8 +579,10 @@ func (c *cc) rangeFunction(n tsql.Node, name string, args []tsql.ScalarExpressio call.Args.Items = append(call.Args.Items, c.convertScalarExpression(arg)) } rf := &ast.RangeFunction{ - Functions: &ast.List{Items: []ast.Node{&ast.List{Items: []ast.Node{call}}}}, - Coldeflist: &ast.List{Items: defs}, + Functions: &ast.List{Items: []ast.Node{&ast.List{Items: []ast.Node{call}}}}, + } + if len(defs) > 0 { + rf.Coldeflist = &ast.List{Items: defs} } if alias != nil { name := identifierValue(alias) @@ -1515,11 +1522,49 @@ func (c *cc) convertAlterTableAddTableElementStatement(n *tsql.AlterTableAddTabl return stmt } -// convertCreateFunctionStatement records a scalar function, whose result -// SQL Server reports nullable whatever its arguments are. +// convertCreateFunctionStatement records a function. A scalar function's +// result SQL Server reports nullable whatever its arguments are. A +// table-valued function returns rows of table(col type, ...), the columns +// it declares; an inline one's are those its query selects, which the +// schema works out from the query, passed as the "query" option. func (c *cc) convertCreateFunctionStatement(n *tsql.CreateFunctionStatement) ast.Node { - ret, ok := n.ReturnType.(*tsql.ScalarFunctionReturnType) - if !ok || ret.DataType == nil || n.Name == nil || n.Name.BaseIdentifier == nil { + if n.Name == nil || n.Name.BaseIdentifier == nil { + return todo(n) + } + var returns *ast.TypeName + var options *ast.List + switch ret := n.ReturnType.(type) { + case *tsql.ScalarFunctionReturnType: + if ret.DataType == nil { + return todo(n) + } + returns = nullableTypeName(dataTypeName(ret.DataType)) + case *tsql.TableValuedFunctionReturnType: + if ret.DeclareTableVariableBody == nil || ret.DeclareTableVariableBody.Definition == nil { + return todo(n) + } + var cols []string + for _, col := range ret.DeclareTableVariableBody.Definition.ColumnDefinitions { + def := c.convertColumnDefinition(col, nil) + t := def.TypeName + if !def.IsNotNull { + t = nullableTypeName(t) + } + spelled := core.TypeExprOfTypeName(t) + if spelled == nil { + return todo(n) + } + cols = append(cols, def.Colname+" "+typeSpelling(t, spelled)) + } + returns = &ast.TypeName{Spelling: "table(" + strings.Join(cols, ", ") + ")", Setof: true} + case *tsql.SelectFunctionReturnType: + if ret.SelectStatement == nil { + return todo(n) + } + returns = &ast.TypeName{Name: "table", Setof: true} + query := "query" + options = &ast.List{Items: []ast.Node{&ast.DefElem{Defname: &query, Arg: c.convertSelectStatement(ret.SelectStatement)}}} + default: return todo(n) } tn := parseTableName(n.Name) @@ -1538,8 +1583,18 @@ func (c *cc) convertCreateFunctionStatement(n *tsql.CreateFunctionStatement) ast return &ast.CreateFunctionStmt{ Func: &ast.FuncName{Schema: tn.Schema, Name: tn.Name}, Params: params, - ReturnType: nullableTypeName(dataTypeName(ret.DataType)), + ReturnType: returns, + Options: options, + } +} + +// typeSpelling spells a type so that it parses back as the same type, which +// for a nullable one is the Nullable(T) it was spelled as. +func typeSpelling(tn *ast.TypeName, t *core.TypeExpr) string { + if tn.Spelling != "" { + return tn.Spelling } + return t.String() } func (c *cc) convertAlterTableDropTableElementStatement(n *tsql.AlterTableDropTableElementStatement) ast.Node { From 2420922ba5d34b87b2766228b3af903f046d0672 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:25:19 -0800 Subject: [PATCH 28/43] fix(core): type a placeholder INSERT ... SELECT inserts as its column A placeholder in VALUES took the type of the column it is inserted into, but one selected by INSERT ... SELECT @a went untyped. A placeholder selected as it is now takes its column's type too. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/core/analyzer/dml.go | 13 +++ .../mssql/exec.json | 5 + .../mssql/query.sql | 5 + .../mssql/schema.sql | 1 + .../mssql/stdout.json | 97 +++++++++++++++++++ 5 files changed, 121 insertions(+) create mode 100644 internal/endtoend/testdata/analyze_insert_select_params/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_insert_select_params/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_insert_select_params/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_insert_select_params/mssql/stdout.json diff --git a/internal/core/analyzer/dml.go b/internal/core/analyzer/dml.go index dc16d47ceb..9a158f474b 100644 --- a/internal/core/analyzer/dml.go +++ b/internal/core/analyzer/dml.go @@ -145,6 +145,19 @@ func (a *analyzer) bindInsertValues(n ast.Node, rel scopeRel, targets []core.Cla // INSERT ... SELECT inserts whatever the query returns. The rows are not // the statement's result, but the query still holds placeholders. if sel.ValuesLists == nil { + // A placeholder selected as it is takes the type of the column it + // is inserted into, as one in VALUES does. + if sel.Op == ast.None { + for i, item := range listItems(sel.TargetList) { + rt, ok := item.(*ast.ResTarget) + if !ok || i >= len(targets) { + continue + } + if p, ok := rt.Val.(*ast.ParamRef); ok { + a.inferParam(p, columnType(rel, targets[i])) + } + } + } _, err := a.subqueryColumns(sel) return err } diff --git a/internal/endtoend/testdata/analyze_insert_select_params/mssql/exec.json b/internal/endtoend/testdata/analyze_insert_select_params/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_insert_select_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_insert_select_params/mssql/query.sql b/internal/endtoend/testdata/analyze_insert_select_params/mssql/query.sql new file mode 100644 index 0000000000..679a148aab --- /dev/null +++ b/internal/endtoend/testdata/analyze_insert_select_params/mssql/query.sql @@ -0,0 +1,5 @@ +-- name: InsertSelected :exec +INSERT INTO t (id, name, at, n) SELECT @a, @b, @c, @d; + +-- name: CopyRow :exec +INSERT INTO t (id, name, at, n) SELECT @a, name, at, n + @inc FROM t WHERE id = @src; diff --git a/internal/endtoend/testdata/analyze_insert_select_params/mssql/schema.sql b/internal/endtoend/testdata/analyze_insert_select_params/mssql/schema.sql new file mode 100644 index 0000000000..53c28cb58a --- /dev/null +++ b/internal/endtoend/testdata/analyze_insert_select_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_insert_select_params/mssql/stdout.json b/internal/endtoend/testdata/analyze_insert_select_params/mssql/stdout.json new file mode 100644 index 0000000000..1886cd3009 --- /dev/null +++ b/internal/endtoend/testdata/analyze_insert_select_params/mssql/stdout.json @@ -0,0 +1,97 @@ +[ + { + "name": "InsertSelected", + "cmd": ":exec", + "columns": [], + "params": [ + { + "number": 1, + "column": { + "name": "a", + "type": { + "name": "int" + }, + "table": "t" + } + }, + { + "number": 2, + "column": { + "name": "b", + "type": { + "name": "nvarchar", + "nullable": true, + "args": [ + { + "int": 50 + } + ] + }, + "table": "t" + } + }, + { + "number": 3, + "column": { + "name": "c", + "type": { + "name": "datetime2", + "args": [ + { + "int": 3 + } + ] + }, + "table": "t" + } + }, + { + "number": 4, + "column": { + "name": "d", + "type": { + "name": "bigint" + }, + "table": "t" + } + } + ] + }, + { + "name": "CopyRow", + "cmd": ":exec", + "columns": [], + "params": [ + { + "number": 1, + "column": { + "name": "a", + "type": { + "name": "int" + }, + "table": "t" + } + }, + { + "number": 2, + "column": { + "name": "inc", + "type": { + "name": "bigint" + }, + "table": "t" + } + }, + { + "number": 3, + "column": { + "name": "src", + "type": { + "name": "int" + }, + "table": "t" + } + } + ] + } +] From 2861faa7ef8d9e7277c1779d46f11afdbc195103 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:41:34 -0800 Subject: [PATCH 29/43] fix(mssql): type an N'...' literal as nvarchar A Unicode string literal was typed as a varchar, as any string literal is. N'...' is now an nvarchar as long as the string, as SQL Server types it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../analyze_national_literals/mssql/exec.json | 5 ++ .../analyze_national_literals/mssql/query.sql | 2 + .../mssql/schema.sql | 1 + .../mssql/stdout.json | 59 +++++++++++++++++++ internal/engine/mssql/convert.go | 8 ++- 5 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 internal/endtoend/testdata/analyze_national_literals/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_national_literals/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_national_literals/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_national_literals/mssql/stdout.json diff --git a/internal/endtoend/testdata/analyze_national_literals/mssql/exec.json b/internal/endtoend/testdata/analyze_national_literals/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_national_literals/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_national_literals/mssql/query.sql b/internal/endtoend/testdata/analyze_national_literals/mssql/query.sql new file mode 100644 index 0000000000..f79268096b --- /dev/null +++ b/internal/endtoend/testdata/analyze_national_literals/mssql/query.sql @@ -0,0 +1,2 @@ +-- name: Literals :many +SELECT N'hello' AS a1, N'' AS a2, 'hi' AS a3, a + N'xy' AS a4 FROM p WHERE a LIKE N'%x%' AND b = @b; diff --git a/internal/endtoend/testdata/analyze_national_literals/mssql/schema.sql b/internal/endtoend/testdata/analyze_national_literals/mssql/schema.sql new file mode 100644 index 0000000000..2008fa5e89 --- /dev/null +++ b/internal/endtoend/testdata/analyze_national_literals/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_national_literals/mssql/stdout.json b/internal/endtoend/testdata/analyze_national_literals/mssql/stdout.json new file mode 100644 index 0000000000..5d8934146c --- /dev/null +++ b/internal/endtoend/testdata/analyze_national_literals/mssql/stdout.json @@ -0,0 +1,59 @@ +[ + { + "name": "Literals", + "cmd": ":many", + "columns": [ + { + "name": "a1", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 5 + } + ] + } + }, + { + "name": "a2", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 1 + } + ] + } + }, + { + "name": "a3", + "type": { + "name": "varchar" + } + }, + { + "name": "a4", + "type": { + "name": "nvarchar" + } + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "b", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 20 + } + ] + }, + "table": "p" + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index d2c971f871..7690ab65b3 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -3,6 +3,7 @@ package mssql import ( "strconv" "strings" + "unicode/utf16" tsql "github.com/sqlc-dev/teesql/ast" @@ -800,10 +801,15 @@ func (c *cc) convertScalarExpression(expr tsql.ScalarExpression) ast.Node { Location: c.loc(e), } case *tsql.StringLiteral: - return &ast.A_Const{ + lit := &ast.A_Const{ Val: &ast.String{Str: e.Value}, Location: c.loc(e), } + // N'...' is an nvarchar as long as the string, and at least 1. + if e.IsNational { + return &ast.TypeCast{Arg: lit, TypeName: typeName("nvarchar", max(1, int64(len(utf16.Encode([]rune(e.Value)))))), Location: c.loc(e)} + } + return lit case *tsql.NullLiteral: return &ast.A_Const{ Val: &ast.Null{}, From 444e97c309ee7b8f5e105c96d473a598279e33c9 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:45:59 -0800 Subject: [PATCH 30/43] fix(core): find the family of a type given default arguments A type the catalog holds no row for, as decimal(5,1) cast to before any column has it, is looked up by its family. Looking the family up by its bare name applied the dialect's default arguments again, as SQL Server's decimal is decimal(18,0), which held no row either, so the expression went untyped and its operators resolved against nothing. The family is now found by name when its bare name is no row. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/core/types.go | 6 +++++- .../mssql/exec.json | 5 +++++ .../mssql/query.sql | 2 ++ .../mssql/schema.sql | 1 + .../mssql/stdout.json | 21 +++++++++++++++++++ 5 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 internal/endtoend/testdata/analyze_uninterned_decimal/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_uninterned_decimal/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_uninterned_decimal/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_uninterned_decimal/mssql/stdout.json diff --git a/internal/core/types.go b/internal/core/types.go index 7c482a1f2b..99c38e2cb3 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -659,7 +659,11 @@ func (c *Catalog) LookupTypeExpr(t *TypeExpr) (TypeLookup, bool) { // The family is known and the instance is not a row. familyOID, _, err := c.internType(&TypeExpr{Name: canonical.Name}, refuse, false) if err != nil { - return TypeLookup{}, false + // The bare name may take default arguments, as SQL Server's + // decimal is decimal(18,0), and so name no row either. + if familyOID, err = c.TypeOID(canonical.Name); err != nil { + return TypeLookup{}, false + } } return TypeLookup{OID: familyOID, FamilyOID: familyOID, Expr: canonical}, true } diff --git a/internal/endtoend/testdata/analyze_uninterned_decimal/mssql/exec.json b/internal/endtoend/testdata/analyze_uninterned_decimal/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_uninterned_decimal/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_uninterned_decimal/mssql/query.sql b/internal/endtoend/testdata/analyze_uninterned_decimal/mssql/query.sql new file mode 100644 index 0000000000..41eee04589 --- /dev/null +++ b/internal/endtoend/testdata/analyze_uninterned_decimal/mssql/query.sql @@ -0,0 +1,2 @@ +-- name: Negated :many +SELECT -CAST(i AS DECIMAL) AS nd, d * CAST(i AS DECIMAL(12,3)) AS product FROM p2; diff --git a/internal/endtoend/testdata/analyze_uninterned_decimal/mssql/schema.sql b/internal/endtoend/testdata/analyze_uninterned_decimal/mssql/schema.sql new file mode 100644 index 0000000000..e1944ab1c9 --- /dev/null +++ b/internal/endtoend/testdata/analyze_uninterned_decimal/mssql/schema.sql @@ -0,0 +1 @@ +CREATE TABLE p2 (d DECIMAL(10,2) NOT NULL, i INT NOT NULL, f FLOAT NOT NULL); diff --git a/internal/endtoend/testdata/analyze_uninterned_decimal/mssql/stdout.json b/internal/endtoend/testdata/analyze_uninterned_decimal/mssql/stdout.json new file mode 100644 index 0000000000..29a0af6f68 --- /dev/null +++ b/internal/endtoend/testdata/analyze_uninterned_decimal/mssql/stdout.json @@ -0,0 +1,21 @@ +[ + { + "name": "Negated", + "cmd": ":many", + "columns": [ + { + "name": "nd", + "type": { + "name": "decimal" + } + }, + { + "name": "product", + "type": { + "name": "decimal" + } + } + ], + "params": [] + } +] From 043680e89067b4bfe7ae08f5950ba9fc3ccbe148 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:47:36 -0800 Subject: [PATCH 31/43] fix(mssql): type a number with a decimal point as decimal A literal such as 1.5 was typed as a float, and one too large for an int, as 2147483648, as one too. SQL Server types each as the decimal holding its digits, 1.5 as decimal(2,1) and 2147483648 as decimal(10,0), and so now does sqlc. 1e5 is still a float. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../analyze_numeric_literals/mssql/exec.json | 5 + .../analyze_numeric_literals/mssql/query.sql | 2 + .../analyze_numeric_literals/mssql/schema.sql | 1 + .../mssql/stdout.json | 111 ++++++++++++++++++ internal/engine/mssql/convert.go | 27 ++++- 5 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 internal/endtoend/testdata/analyze_numeric_literals/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_numeric_literals/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_numeric_literals/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_numeric_literals/mssql/stdout.json diff --git a/internal/endtoend/testdata/analyze_numeric_literals/mssql/exec.json b/internal/endtoend/testdata/analyze_numeric_literals/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_numeric_literals/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_numeric_literals/mssql/query.sql b/internal/endtoend/testdata/analyze_numeric_literals/mssql/query.sql new file mode 100644 index 0000000000..1a9d4db738 --- /dev/null +++ b/internal/endtoend/testdata/analyze_numeric_literals/mssql/query.sql @@ -0,0 +1,2 @@ +-- name: Literals :many +SELECT 1.5 AS a, 0.5 AS b, 10.00 AS c, 007.10 AS d, 2147483647 AS e, 2147483648 AS f, 12345678901 AS g, -1.5 AS h, 1e5 AS i FROM p; diff --git a/internal/endtoend/testdata/analyze_numeric_literals/mssql/schema.sql b/internal/endtoend/testdata/analyze_numeric_literals/mssql/schema.sql new file mode 100644 index 0000000000..2008fa5e89 --- /dev/null +++ b/internal/endtoend/testdata/analyze_numeric_literals/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_numeric_literals/mssql/stdout.json b/internal/endtoend/testdata/analyze_numeric_literals/mssql/stdout.json new file mode 100644 index 0000000000..11c73eecae --- /dev/null +++ b/internal/endtoend/testdata/analyze_numeric_literals/mssql/stdout.json @@ -0,0 +1,111 @@ +[ + { + "name": "Literals", + "cmd": ":many", + "columns": [ + { + "name": "a", + "type": { + "name": "decimal", + "args": [ + { + "int": 2 + }, + { + "int": 1 + } + ] + } + }, + { + "name": "b", + "type": { + "name": "decimal", + "args": [ + { + "int": 1 + }, + { + "int": 1 + } + ] + } + }, + { + "name": "c", + "type": { + "name": "decimal", + "args": [ + { + "int": 4 + }, + { + "int": 2 + } + ] + } + }, + { + "name": "d", + "type": { + "name": "decimal", + "args": [ + { + "int": 3 + }, + { + "int": 2 + } + ] + } + }, + { + "name": "e", + "type": { + "name": "int" + } + }, + { + "name": "f", + "type": { + "name": "decimal", + "args": [ + { + "int": 10 + }, + { + "int": 0 + } + ] + } + }, + { + "name": "g", + "type": { + "name": "decimal", + "args": [ + { + "int": 11 + }, + { + "int": 0 + } + ] + } + }, + { + "name": "h", + "type": { + "name": "decimal" + } + }, + { + "name": "i", + "type": { + "name": "float" + } + } + ], + "params": [] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index 7690ab65b3..eb9f21cb01 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -1,6 +1,7 @@ package mssql import ( + "math" "strconv" "strings" "unicode/utf16" @@ -780,16 +781,16 @@ func (c *cc) convertScalarExpression(expr tsql.ScalarExpression) ast.Node { case *tsql.VariableReference: return c.convertVariableReference(e) case *tsql.IntegerLiteral: - ival, _ := strconv.ParseInt(e.Value, 10, 64) + ival, err := strconv.ParseInt(e.Value, 10, 64) + if err != nil || ival > math.MaxInt32 { + return c.numericLiteral(e.Value, e) + } return &ast.A_Const{ Val: &ast.Integer{Ival: ival}, Location: c.loc(e), } case *tsql.NumericLiteral: - return &ast.A_Const{ - Val: &ast.Float{Str: e.Value}, - Location: c.loc(e), - } + return c.numericLiteral(e.Value, e) case *tsql.RealLiteral: return &ast.A_Const{ Val: &ast.Float{Str: e.Value}, @@ -1594,6 +1595,22 @@ func (c *cc) convertCreateFunctionStatement(n *tsql.CreateFunctionStatement) ast } } +// numericLiteral is a number written with a decimal point, or too large +// for an int, which SQL Server types as the decimal holding its digits: +// 1.50 is a decimal(3,2), 2147483648 a decimal(10,0). +func (c *cc) numericLiteral(value string, n tsql.Node) ast.Node { + whole, frac, _ := strings.Cut(value, ".") + whole = strings.TrimLeft(whole, "0") + scale := int64(len(frac)) + precision := max(int64(len(whole))+scale, 1) + return &ast.TypeCast{ + Arg: &ast.A_Const{Val: &ast.Float{Str: value}, Location: c.loc(n)}, + TypeName: &ast.TypeName{Name: "decimal", Typmods: appendTypmod(appendTypmod(nil, + &ast.A_Const{Val: &ast.Integer{Ival: precision}}), &ast.A_Const{Val: &ast.Integer{Ival: scale}})}, + Location: c.loc(n), + } +} + // typeSpelling spells a type so that it parses back as the same type, which // for a nullable one is the Nullable(T) it was spelled as. func typeSpelling(tn *ast.TypeName, t *core.TypeExpr) string { From fe55ab36188726b4c8a6efcc915f8f958d88f7ae Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:31:00 -0800 Subject: [PATCH 32/43] fix(mssql): keep the placeholders of TABLESAMPLE TABLESAMPLE (@n PERCENT) and REPEATABLE (@seed) dropped their placeholders. They are now analyzed with the query's WHERE clause, as FOR SYSTEM_TIME's are: a percentage as a float, a count of rows and a seed as a bigint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../analyze_tablesample/mssql/exec.json | 5 ++ .../analyze_tablesample/mssql/query.sql | 5 ++ .../analyze_tablesample/mssql/schema.sql | 1 + .../analyze_tablesample/mssql/stdout.json | 69 +++++++++++++++++++ internal/engine/mssql/convert.go | 16 +++++ 5 files changed, 96 insertions(+) create mode 100644 internal/endtoend/testdata/analyze_tablesample/mssql/exec.json create mode 100644 internal/endtoend/testdata/analyze_tablesample/mssql/query.sql create mode 100644 internal/endtoend/testdata/analyze_tablesample/mssql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_tablesample/mssql/stdout.json diff --git a/internal/endtoend/testdata/analyze_tablesample/mssql/exec.json b/internal/endtoend/testdata/analyze_tablesample/mssql/exec.json new file mode 100644 index 0000000000..253b05abbf --- /dev/null +++ b/internal/endtoend/testdata/analyze_tablesample/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_tablesample/mssql/query.sql b/internal/endtoend/testdata/analyze_tablesample/mssql/query.sql new file mode 100644 index 0000000000..7c8561d078 --- /dev/null +++ b/internal/endtoend/testdata/analyze_tablesample/mssql/query.sql @@ -0,0 +1,5 @@ +-- name: Sampled :many +SELECT id FROM t TABLESAMPLE (@pct PERCENT) WHERE id > @min; + +-- name: SampledRows :many +SELECT id FROM t TABLESAMPLE SYSTEM (@n ROWS) REPEATABLE (@seed); diff --git a/internal/endtoend/testdata/analyze_tablesample/mssql/schema.sql b/internal/endtoend/testdata/analyze_tablesample/mssql/schema.sql new file mode 100644 index 0000000000..64963bfc13 --- /dev/null +++ b/internal/endtoend/testdata/analyze_tablesample/mssql/schema.sql @@ -0,0 +1 @@ +CREATE TABLE t (id INT NOT NULL, name NVARCHAR(50) NULL, at DATETIME2 NOT NULL); diff --git a/internal/endtoend/testdata/analyze_tablesample/mssql/stdout.json b/internal/endtoend/testdata/analyze_tablesample/mssql/stdout.json new file mode 100644 index 0000000000..9ea3c11619 --- /dev/null +++ b/internal/endtoend/testdata/analyze_tablesample/mssql/stdout.json @@ -0,0 +1,69 @@ +[ + { + "name": "Sampled", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "int" + }, + "table": "t" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "pct", + "type": { + "name": "float" + } + } + }, + { + "number": 2, + "column": { + "name": "min", + "type": { + "name": "int" + }, + "table": "t" + } + } + ] + }, + { + "name": "SampledRows", + "cmd": ":many", + "columns": [ + { + "name": "id", + "type": { + "name": "int" + }, + "table": "t" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "n", + "type": { + "name": "bigint" + } + } + }, + { + "number": 2, + "column": { + "name": "seed", + "type": { + "name": "bigint" + } + } + } + ] + } +] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index eb9f21cb01..b78dc961f4 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -406,6 +406,22 @@ func (c *cc) convertTableReference(ref tsql.TableReference) ast.Node { } } } + // TABLESAMPLE takes a percentage as a float and a count of rows + // or a REPEATABLE seed as a bigint. + if ts := t.TableSampleClause; ts != nil { + size := "float" + if ts.TableSampleClauseOption == "Rows" { + size = "bigint" + } + for _, e := range []struct { + expr tsql.ScalarExpression + typ string + }{{ts.SampleNumber, size}, {ts.RepeatSeed, "bigint"}} { + if e.expr != nil { + c.fromExprs = append(c.fromExprs, &ast.TypeCast{Arg: c.convertScalarExpression(e.expr), TypeName: &ast.TypeName{Name: e.typ}}) + } + } + } return rv case *tsql.QueryDerivedTable: sub := &ast.RangeSubselect{ From 896a11245f19dc0962c9419fef24ea0a9981622d Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:33:37 -0800 Subject: [PATCH 33/43] fix(core): stop pairing INSERT ... SELECT items with columns after a star A placeholder selected by INSERT ... SELECT took the type of the column at its position, but a star before it selects any number of columns, so INSERT INTO t (id, name, at, n) SELECT t2.*, @n typed @n as name. Items after a star are no longer paired. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/core/analyzer/dml.go | 8 ++++++-- .../analyze_insert_select_params/mssql/query.sql | 3 +++ .../analyze_insert_select_params/mssql/stdout.json | 13 +++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/internal/core/analyzer/dml.go b/internal/core/analyzer/dml.go index 9a158f474b..51975dd5b9 100644 --- a/internal/core/analyzer/dml.go +++ b/internal/core/analyzer/dml.go @@ -146,12 +146,16 @@ func (a *analyzer) bindInsertValues(n ast.Node, rel scopeRel, targets []core.Cla // the statement's result, but the query still holds placeholders. if sel.ValuesLists == nil { // A placeholder selected as it is takes the type of the column it - // is inserted into, as one in VALUES does. + // is inserted into, as one in VALUES does. A star selects any + // number of columns, so the items after one are not paired. if sel.Op == ast.None { for i, item := range listItems(sel.TargetList) { rt, ok := item.(*ast.ResTarget) if !ok || i >= len(targets) { - continue + break + } + if cr, ok := rt.Val.(*ast.ColumnRef); ok && isStar(flattenFields(cr.Fields)) { + break } if p, ok := rt.Val.(*ast.ParamRef); ok { a.inferParam(p, columnType(rel, targets[i])) diff --git a/internal/endtoend/testdata/analyze_insert_select_params/mssql/query.sql b/internal/endtoend/testdata/analyze_insert_select_params/mssql/query.sql index 679a148aab..402d9d8f61 100644 --- a/internal/endtoend/testdata/analyze_insert_select_params/mssql/query.sql +++ b/internal/endtoend/testdata/analyze_insert_select_params/mssql/query.sql @@ -3,3 +3,6 @@ INSERT INTO t (id, name, at, n) SELECT @a, @b, @c, @d; -- name: CopyRow :exec INSERT INTO t (id, name, at, n) SELECT @a, name, at, n + @inc FROM t WHERE id = @src; + +-- name: CopyWithStar :exec +INSERT INTO t (id, name, at, n) SELECT t2.*, @n FROM (SELECT id, name, at FROM t) t2; diff --git a/internal/endtoend/testdata/analyze_insert_select_params/mssql/stdout.json b/internal/endtoend/testdata/analyze_insert_select_params/mssql/stdout.json index 1886cd3009..46f15394ca 100644 --- a/internal/endtoend/testdata/analyze_insert_select_params/mssql/stdout.json +++ b/internal/endtoend/testdata/analyze_insert_select_params/mssql/stdout.json @@ -93,5 +93,18 @@ } } ] + }, + { + "name": "CopyWithStar", + "cmd": ":exec", + "columns": [], + "params": [ + { + "number": 1, + "column": { + "name": "n" + } + } + ] } ] From 70864b87276370f9279292dffa019b7abe4443fd Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:35:27 -0800 Subject: [PATCH 34/43] fix(mssql): leave sqlc.embed out of the @name check sqlc.embed names a table, not a parameter, so sqlc.embed("order details") was rejected as an invalid @name. Only sqlc.arg, narg and slice are checked now. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/sql/preprocess/scan.go | 5 ++-- .../mssql/embed_quoted_table/input.sql | 1 + .../mssql/embed_quoted_table/output.sql | 1 + .../mssql/embed_quoted_table/side_table.json | 24 +++++++++++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 internal/sql/preprocess/testdata/mssql/embed_quoted_table/input.sql create mode 100644 internal/sql/preprocess/testdata/mssql/embed_quoted_table/output.sql create mode 100644 internal/sql/preprocess/testdata/mssql/embed_quoted_table/side_table.json diff --git a/internal/sql/preprocess/scan.go b/internal/sql/preprocess/scan.go index feafe91ddc..59c6825f8b 100644 --- a/internal/sql/preprocess/scan.go +++ b/internal/sql/preprocess/scan.go @@ -140,8 +140,9 @@ func (l *lexer) sqlcCall(start, afterSqlc int) (occurrence, int, bool) { return occ, occ.end, true } // A dialect that binds @name writes the name into the query, so it has - // to read back as one parameter: @user-id would be a subtraction. - if l.d.Style == StyleAt && !isParamName(name) { + // to read back as one parameter: @user-id would be a subtraction. An + // embed names a table, not a parameter. + if l.d.Style == StyleAt && fn != "embed" && !isParamName(name) { occ.err = &sqlerr.Error{ Message: fmt.Sprintf("parameter name %q passed to sqlc.%s is not a valid @name; use letters, digits and underscores", name, fn), Location: start, diff --git a/internal/sql/preprocess/testdata/mssql/embed_quoted_table/input.sql b/internal/sql/preprocess/testdata/mssql/embed_quoted_table/input.sql new file mode 100644 index 0000000000..1d03b5f520 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/embed_quoted_table/input.sql @@ -0,0 +1 @@ +SELECT sqlc.embed("order details") FROM [order details] WHERE id = @id; diff --git a/internal/sql/preprocess/testdata/mssql/embed_quoted_table/output.sql b/internal/sql/preprocess/testdata/mssql/embed_quoted_table/output.sql new file mode 100644 index 0000000000..5554ca972b --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/embed_quoted_table/output.sql @@ -0,0 +1 @@ +SELECT "order details".* FROM [order details] WHERE id = @id; diff --git a/internal/sql/preprocess/testdata/mssql/embed_quoted_table/side_table.json b/internal/sql/preprocess/testdata/mssql/embed_quoted_table/side_table.json new file mode 100644 index 0000000000..4baa50e0c2 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/embed_quoted_table/side_table.json @@ -0,0 +1,24 @@ +[ + { + "start": 0, + "end": 61, + "dollar": true, + "params": [ + { + "number": 1, + "location": 57, + "origin": 67, + "name": "id", + "named": true + } + ], + "embeds": [ + { + "table": "order details", + "location": 7, + "origin": 7, + "orig": "sqlc.embed(\"order details\")" + } + ] + } +] From 916869af90a59b588b92ce371ec098298a3f7002 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:35:27 -0800 Subject: [PATCH 35/43] fix(mssql): read a placeholder's name through #, $ and @ SQL Server's names go on through #, $ and @ after the first character, but the preprocessor stopped at them, so @a and @a#b were both read as @a and given one number, and generated code bound only one of them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/sql/preprocess/scan.go | 5 ++++ .../testdata/mssql/names_with_hash/input.sql | 1 + .../testdata/mssql/names_with_hash/output.sql | 1 + .../mssql/names_with_hash/side_table.json | 30 +++++++++++++++++++ 4 files changed, 37 insertions(+) create mode 100644 internal/sql/preprocess/testdata/mssql/names_with_hash/input.sql create mode 100644 internal/sql/preprocess/testdata/mssql/names_with_hash/output.sql create mode 100644 internal/sql/preprocess/testdata/mssql/names_with_hash/side_table.json diff --git a/internal/sql/preprocess/scan.go b/internal/sql/preprocess/scan.go index 59c6825f8b..026899ed58 100644 --- a/internal/sql/preprocess/scan.go +++ b/internal/sql/preprocess/scan.go @@ -36,6 +36,11 @@ func (l *lexer) scan(start, end int) []occurrence { // @@name is a system function, such as SQL Server's @@ROWCOUNT. case c == '@' && l.d.AtSign && isIdentStart(l.at(i+1)) && l.at(i-1) != '@': j := l.identEnd(i + 1) + // A SQL Server name goes on through #, $ and @, so @a and @a#b + // are two parameters. + for l.d.Style == StyleAt && j < len(l.src) && (isIdentPart(l.src[j]) || strings.IndexByte("#$@", l.src[j]) >= 0) { + j++ + } out = append(out, occurrence{ kind: kindArg, start: i, diff --git a/internal/sql/preprocess/testdata/mssql/names_with_hash/input.sql b/internal/sql/preprocess/testdata/mssql/names_with_hash/input.sql new file mode 100644 index 0000000000..4cd1e4dc34 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/names_with_hash/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE id = @a AND parent = @a#b AND other = @a$c; diff --git a/internal/sql/preprocess/testdata/mssql/names_with_hash/output.sql b/internal/sql/preprocess/testdata/mssql/names_with_hash/output.sql new file mode 100644 index 0000000000..4cd1e4dc34 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/names_with_hash/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE id = @a AND parent = @a#b AND other = @a$c; diff --git a/internal/sql/preprocess/testdata/mssql/names_with_hash/side_table.json b/internal/sql/preprocess/testdata/mssql/names_with_hash/side_table.json new file mode 100644 index 0000000000..fe5e7ecea4 --- /dev/null +++ b/internal/sql/preprocess/testdata/mssql/names_with_hash/side_table.json @@ -0,0 +1,30 @@ +[ + { + "start": 0, + "end": 70, + "dollar": true, + "params": [ + { + "number": 1, + "location": 32, + "origin": 32, + "name": "a", + "named": true + }, + { + "number": 2, + "location": 48, + "origin": 48, + "name": "a#b", + "named": true + }, + { + "number": 3, + "location": 65, + "origin": 65, + "name": "a$c", + "named": true + } + ] + } +] From 8e0928ffafd31d765a1dedd70a781f12e6e7e4fb Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:36:44 -0800 Subject: [PATCH 36/43] fix(mssql): measure a star qualifier with escaped quotes as written A star qualified by a name that needs quoting was measured from the name as sqlc quotes it for output, so "a""b".* read as [a"b] measured short and left the star in the query, and "x]y".* measured long. Each quoted form is now built from the name itself, doubling its quotes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/compiler/expand.go | 4 +- .../mssql/exec.json | 1 + .../mssql/go/db.go | 31 +++++++ .../mssql/go/models.go | 14 +++ .../mssql/go/query.sql.go | 91 +++++++++++++++++++ .../mssql/query.sql | 8 ++ .../mssql/schema.sql | 1 + .../mssql/sqlc.yaml | 10 ++ 8 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/exec.json create mode 100644 internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/go/db.go create mode 100644 internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/go/models.go create mode 100644 internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/go/query.sql.go create mode 100644 internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/query.sql create mode 100644 internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/schema.sql create mode 100644 internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/sqlc.yaml diff --git a/internal/compiler/expand.go b/internal/compiler/expand.go index 64ed0693d8..f8073ae4ff 100644 --- a/internal/compiler/expand.go +++ b/internal/compiler/expand.go @@ -108,9 +108,9 @@ func (c *Compiler) starOldFunc(parts []string) func(string) int { } if strings.HasPrefix(s[length:], o) { length += len(o) - } else if quoted := c.quote(o); strings.HasPrefix(s[length:], quoted) { + } else if quoted := c.quote(parts[i]); strings.HasPrefix(s[length:], quoted) { length += len(quoted) - } else if quoted := `"` + o + `"`; strings.HasPrefix(s[length:], quoted) { + } else if quoted := `"` + strings.ReplaceAll(parts[i], `"`, `""`) + `"`; strings.HasPrefix(s[length:], quoted) { // SQL Server quotes with brackets, but reads double quotes // as an identifier too. length += len(quoted) diff --git a/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/exec.json b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/exec.json new file mode 100644 index 0000000000..f4e21983b6 --- /dev/null +++ b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/exec.json @@ -0,0 +1 @@ +{"contexts": ["base"]} diff --git a/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/go/db.go b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/go/db.go new file mode 100644 index 0000000000..32faa9f2b8 --- /dev/null +++ b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/go/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/go/models.go b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/go/models.go new file mode 100644 index 0000000000..4167d53357 --- /dev/null +++ b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/go/models.go @@ -0,0 +1,14 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "database/sql" +) + +type Author struct { + ID int32 + Name sql.NullString +} diff --git a/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/go/query.sql.go b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/go/query.sql.go new file mode 100644 index 0000000000..6a06f8caa8 --- /dev/null +++ b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/go/query.sql.go @@ -0,0 +1,91 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package querytest + +import ( + "context" +) + +const a = `-- name: A :many +SELECT [a"b].id, [a"b].name FROM authors AS "a""b"; +` + +func (q *Queries) A(ctx context.Context) ([]Author, error) { + rows, err := q.db.QueryContext(ctx, a) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Author + for rows.Next() { + var i Author + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const b = `-- name: B :many +SELECT [x]]y].id, [x]]y].name FROM authors AS "x]y"; +` + +func (q *Queries) B(ctx context.Context) ([]Author, error) { + rows, err := q.db.QueryContext(ctx, b) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Author + for rows.Next() { + var i Author + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const c = `-- name: C :many +SELECT [x]]y].id, [x]]y].name FROM authors AS [x]]y]; +` + +func (q *Queries) C(ctx context.Context) ([]Author, error) { + rows, err := q.db.QueryContext(ctx, c) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Author + for rows.Next() { + var i Author + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/query.sql b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/query.sql new file mode 100644 index 0000000000..37705849ae --- /dev/null +++ b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/query.sql @@ -0,0 +1,8 @@ +-- name: A :many +SELECT "a""b".* FROM authors AS "a""b"; + +-- name: B :many +SELECT "x]y".* FROM authors AS "x]y"; + +-- name: C :many +SELECT [x]]y].* FROM authors AS [x]]y]; diff --git a/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/schema.sql b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/schema.sql new file mode 100644 index 0000000000..531179cece --- /dev/null +++ b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/schema.sql @@ -0,0 +1 @@ +CREATE TABLE authors (id INT NOT NULL, name NVARCHAR(10) NULL); diff --git a/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/sqlc.yaml b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/sqlc.yaml new file mode 100644 index 0000000000..94b0dc8896 --- /dev/null +++ b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/sqlc.yaml @@ -0,0 +1,10 @@ +version: "2" +sql: + - engine: "mssql" + schema: "schema.sql" + queries: "query.sql" + gen: + go: + package: "querytest" + out: "go" + sql_package: "database/sql" From d1d16c96a9ff961846849fc2345201cfa3fc3142 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:38:34 -0800 Subject: [PATCH 37/43] fix(mssql): embed a table only when its columns are as nullable as the table's sqlc.embed(b) on the optional side of an outer join folded b's columns into the Book model, whose fields cannot hold NULL, so scanning a row with no match failed. A table is now embedded only when each column is as nullable as the table's, and otherwise its columns stay flat. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/compiler/parse_core.go | 5 +- .../mssql_embed_outer_join/mssql/exec.json | 1 + .../mssql_embed_outer_join/mssql/go/db.go | 31 +++++++++++ .../mssql_embed_outer_join/mssql/go/models.go | 20 ++++++++ .../mssql/go/query.sql.go | 51 +++++++++++++++++++ .../mssql_embed_outer_join/mssql/query.sql | 2 + .../mssql_embed_outer_join/mssql/schema.sql | 2 + .../mssql_embed_outer_join/mssql/sqlc.yaml | 10 ++++ 8 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 internal/endtoend/testdata/mssql_embed_outer_join/mssql/exec.json create mode 100644 internal/endtoend/testdata/mssql_embed_outer_join/mssql/go/db.go create mode 100644 internal/endtoend/testdata/mssql_embed_outer_join/mssql/go/models.go create mode 100644 internal/endtoend/testdata/mssql_embed_outer_join/mssql/go/query.sql.go create mode 100644 internal/endtoend/testdata/mssql_embed_outer_join/mssql/query.sql create mode 100644 internal/endtoend/testdata/mssql_embed_outer_join/mssql/schema.sql create mode 100644 internal/endtoend/testdata/mssql_embed_outer_join/mssql/sqlc.yaml diff --git a/internal/compiler/parse_core.go b/internal/compiler/parse_core.go index 5679f39dd6..2ad64cb77b 100644 --- a/internal/compiler/parse_core.go +++ b/internal/compiler/parse_core.go @@ -148,7 +148,8 @@ func embedColumns(raw *ast.RawStmt, cols []*Column, stars []core.StarExpansion, } // isWholeTable reports whether cols are every column of one table, in the -// table's order. +// table's order, each as nullable as the table's: the optional side of an +// outer join is not, and the table's model could not hold its NULLs. func isWholeTable(cat *core.Catalog, cols []*Column) bool { table := cols[0].Table if table == nil { @@ -175,7 +176,7 @@ func isWholeTable(cat *core.Catalog, cols []*Column) bool { if tc.Hidden { continue } - if i >= len(cols) || cols[i].Table == nil || *cols[i].Table != *table || cols[i].OriginalName != tc.Name { + if i >= len(cols) || cols[i].Table == nil || *cols[i].Table != *table || cols[i].OriginalName != tc.Name || cols[i].NotNull != tc.NotNull { return false } i++ diff --git a/internal/endtoend/testdata/mssql_embed_outer_join/mssql/exec.json b/internal/endtoend/testdata/mssql_embed_outer_join/mssql/exec.json new file mode 100644 index 0000000000..f4e21983b6 --- /dev/null +++ b/internal/endtoend/testdata/mssql_embed_outer_join/mssql/exec.json @@ -0,0 +1 @@ +{"contexts": ["base"]} diff --git a/internal/endtoend/testdata/mssql_embed_outer_join/mssql/go/db.go b/internal/endtoend/testdata/mssql_embed_outer_join/mssql/go/db.go new file mode 100644 index 0000000000..32faa9f2b8 --- /dev/null +++ b/internal/endtoend/testdata/mssql_embed_outer_join/mssql/go/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/endtoend/testdata/mssql_embed_outer_join/mssql/go/models.go b/internal/endtoend/testdata/mssql_embed_outer_join/mssql/go/models.go new file mode 100644 index 0000000000..33cf3d5142 --- /dev/null +++ b/internal/endtoend/testdata/mssql_embed_outer_join/mssql/go/models.go @@ -0,0 +1,20 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "database/sql" +) + +type Author struct { + ID int32 + Name string +} + +type Book struct { + ID int32 + AuthorID int32 + Title sql.NullString +} diff --git a/internal/endtoend/testdata/mssql_embed_outer_join/mssql/go/query.sql.go b/internal/endtoend/testdata/mssql_embed_outer_join/mssql/go/query.sql.go new file mode 100644 index 0000000000..2463659348 --- /dev/null +++ b/internal/endtoend/testdata/mssql_embed_outer_join/mssql/go/query.sql.go @@ -0,0 +1,51 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package querytest + +import ( + "context" + "database/sql" +) + +const authorBooks = `-- name: AuthorBooks :many +SELECT a.id, a.name, b.id, b.author_id, b.title FROM authors a LEFT JOIN books b ON b.author_id = a.id; +` + +type AuthorBooksRow struct { + Author Author + ID sql.NullInt32 + AuthorID sql.NullInt32 + Title sql.NullString +} + +func (q *Queries) AuthorBooks(ctx context.Context) ([]AuthorBooksRow, error) { + rows, err := q.db.QueryContext(ctx, authorBooks) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AuthorBooksRow + for rows.Next() { + var i AuthorBooksRow + if err := rows.Scan( + &i.Author.ID, + &i.Author.Name, + &i.ID, + &i.AuthorID, + &i.Title, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/endtoend/testdata/mssql_embed_outer_join/mssql/query.sql b/internal/endtoend/testdata/mssql_embed_outer_join/mssql/query.sql new file mode 100644 index 0000000000..bd127e839d --- /dev/null +++ b/internal/endtoend/testdata/mssql_embed_outer_join/mssql/query.sql @@ -0,0 +1,2 @@ +-- name: AuthorBooks :many +SELECT sqlc.embed(a), sqlc.embed(b) FROM authors a LEFT JOIN books b ON b.author_id = a.id; diff --git a/internal/endtoend/testdata/mssql_embed_outer_join/mssql/schema.sql b/internal/endtoend/testdata/mssql_embed_outer_join/mssql/schema.sql new file mode 100644 index 0000000000..7aaba24c5a --- /dev/null +++ b/internal/endtoend/testdata/mssql_embed_outer_join/mssql/schema.sql @@ -0,0 +1,2 @@ +CREATE TABLE authors (id INT NOT NULL, name NVARCHAR(10) NOT NULL); +CREATE TABLE books (id INT NOT NULL, author_id INT NOT NULL, title NVARCHAR(20) NULL); diff --git a/internal/endtoend/testdata/mssql_embed_outer_join/mssql/sqlc.yaml b/internal/endtoend/testdata/mssql_embed_outer_join/mssql/sqlc.yaml new file mode 100644 index 0000000000..94b0dc8896 --- /dev/null +++ b/internal/endtoend/testdata/mssql_embed_outer_join/mssql/sqlc.yaml @@ -0,0 +1,10 @@ +version: "2" +sql: + - engine: "mssql" + schema: "schema.sql" + queries: "query.sql" + gen: + go: + package: "querytest" + out: "go" + sql_package: "database/sql" From d44a09216544c7678be345b8f5c288a8101d2a98 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:39:00 -0800 Subject: [PATCH 38/43] test(mssql): clarify variable scanning Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/sql/preprocess/scan.go | 7 ++++--- .../preprocess/testdata/mssql/names_with_hash/input.sql | 2 +- .../preprocess/testdata/mssql/names_with_hash/output.sql | 2 +- .../testdata/mssql/names_with_hash/side_table.json | 9 ++++++++- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/internal/sql/preprocess/scan.go b/internal/sql/preprocess/scan.go index 026899ed58..9eede256f6 100644 --- a/internal/sql/preprocess/scan.go +++ b/internal/sql/preprocess/scan.go @@ -33,11 +33,12 @@ func (l *lexer) scan(start, end int) []occurrence { } i = j - // @@name is a system function, such as SQL Server's @@ROWCOUNT. + // A parameter starts with @ followed by an identifier. The second @ + // in a T-SQL system variable such as @@ROWCOUNT must not start one. case c == '@' && l.d.AtSign && isIdentStart(l.at(i+1)) && l.at(i-1) != '@': j := l.identEnd(i + 1) - // A SQL Server name goes on through #, $ and @, so @a and @a#b - // are two parameters. + // T-SQL parameter names may also contain #, $, and @ after the + // first identifier character. for l.d.Style == StyleAt && j < len(l.src) && (isIdentPart(l.src[j]) || strings.IndexByte("#$@", l.src[j]) >= 0) { j++ } diff --git a/internal/sql/preprocess/testdata/mssql/names_with_hash/input.sql b/internal/sql/preprocess/testdata/mssql/names_with_hash/input.sql index 4cd1e4dc34..8f68a05ce5 100644 --- a/internal/sql/preprocess/testdata/mssql/names_with_hash/input.sql +++ b/internal/sql/preprocess/testdata/mssql/names_with_hash/input.sql @@ -1 +1 @@ -SELECT id FROM users WHERE id = @a AND parent = @a#b AND other = @a$c; +SELECT id FROM users WHERE id = @a AND parent = @a#b AND other = @a$c AND related = @a@b; diff --git a/internal/sql/preprocess/testdata/mssql/names_with_hash/output.sql b/internal/sql/preprocess/testdata/mssql/names_with_hash/output.sql index 4cd1e4dc34..8f68a05ce5 100644 --- a/internal/sql/preprocess/testdata/mssql/names_with_hash/output.sql +++ b/internal/sql/preprocess/testdata/mssql/names_with_hash/output.sql @@ -1 +1 @@ -SELECT id FROM users WHERE id = @a AND parent = @a#b AND other = @a$c; +SELECT id FROM users WHERE id = @a AND parent = @a#b AND other = @a$c AND related = @a@b; diff --git a/internal/sql/preprocess/testdata/mssql/names_with_hash/side_table.json b/internal/sql/preprocess/testdata/mssql/names_with_hash/side_table.json index fe5e7ecea4..7c2e22ab83 100644 --- a/internal/sql/preprocess/testdata/mssql/names_with_hash/side_table.json +++ b/internal/sql/preprocess/testdata/mssql/names_with_hash/side_table.json @@ -1,7 +1,7 @@ [ { "start": 0, - "end": 70, + "end": 89, "dollar": true, "params": [ { @@ -24,6 +24,13 @@ "origin": 65, "name": "a$c", "named": true + }, + { + "number": 4, + "location": 84, + "origin": 84, + "name": "a@b", + "named": true } ] } From b6f2cc97e7c254f28ab88ba95be38b787ae37a87 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:39:00 -0800 Subject: [PATCH 39/43] test(compiler): cover quoted SQL Server star references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/compiler/expand.go | 2 ++ .../mssql/exec.json | 2 +- .../mssql/go/query.sql.go | 27 +++++++++++++++++++ .../mssql/query.sql | 3 +++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/internal/compiler/expand.go b/internal/compiler/expand.go index f8073ae4ff..83420c9f74 100644 --- a/internal/compiler/expand.go +++ b/internal/compiler/expand.go @@ -106,6 +106,8 @@ func (c *Compiler) starOldFunc(parts []string) func(string) int { if hasSeparator := i > 0; hasSeparator { length++ } + // Use the original identifier below: o may already be quoted by + // quoteIdent, and quoting it again would measure a different name. if strings.HasPrefix(s[length:], o) { length += len(o) } else if quoted := c.quote(parts[i]); strings.HasPrefix(s[length:], quoted) { diff --git a/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/exec.json b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/exec.json index f4e21983b6..8ac9f9122f 100644 --- a/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/exec.json +++ b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/exec.json @@ -1 +1 @@ -{"contexts": ["base"]} +{"contexts": ["base", "core"]} diff --git a/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/go/query.sql.go b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/go/query.sql.go index 6a06f8caa8..b285fd17b3 100644 --- a/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/go/query.sql.go +++ b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/go/query.sql.go @@ -89,3 +89,30 @@ func (q *Queries) C(ctx context.Context) ([]Author, error) { } return items, nil } + +const d = `-- name: D :many +SELECT [select].id, [select].name FROM authors AS "select"; +` + +func (q *Queries) D(ctx context.Context) ([]Author, error) { + rows, err := q.db.QueryContext(ctx, d) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Author + for rows.Next() { + var i Author + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/query.sql b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/query.sql index 37705849ae..9e5b6a3408 100644 --- a/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/query.sql +++ b/internal/endtoend/testdata/mssql_star_escaped_qualifier/mssql/query.sql @@ -6,3 +6,6 @@ SELECT "x]y".* FROM authors AS "x]y"; -- name: C :many SELECT [x]]y].* FROM authors AS [x]]y]; + +-- name: D :many +SELECT "select".* FROM authors AS "select"; From bbc976ec8a6ce2dd6caf9f0cdd75c9f320c3b356 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:39:00 -0800 Subject: [PATCH 40/43] test(compiler): verify core sqlc.embed output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/compiler/parse_core.go | 10 +++++----- .../testdata/mssql_embed_outer_join/mssql/exec.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/compiler/parse_core.go b/internal/compiler/parse_core.go index 2ad64cb77b..3019144521 100644 --- a/internal/compiler/parse_core.go +++ b/internal/compiler/parse_core.go @@ -106,11 +106,11 @@ func (c *Compiler) parseQueryCore(raw *ast.RawStmt, src string, pre *preprocess. }, nil } -// embedColumns folds the columns each sqlc.embed() star expanded to into -// the one column codegen embeds the table's struct for. The table is the -// columns' source, so an embed of an alias names the table behind it. The -// struct scans every column of the table in order, so only an expansion -// that is exactly those columns is folded; any other stays flat. +// embedColumns restores sqlc.embed() grouping after the preprocessor rewrites +// each macro to table.* and core analysis expands that star into columns. The +// table is the columns' source, so an embed of an alias names the table behind +// it. The struct scans every column of the table in order, so only an +// expansion that is exactly those columns is folded; any other stays flat. func embedColumns(raw *ast.RawStmt, cols []*Column, stars []core.StarExpansion, embeds preprocess.EmbedSet, cat *core.Catalog) []*Column { sel, ok := raw.Stmt.(*ast.SelectStmt) if !ok || len(embeds) == 0 || sel.TargetList == nil { diff --git a/internal/endtoend/testdata/mssql_embed_outer_join/mssql/exec.json b/internal/endtoend/testdata/mssql_embed_outer_join/mssql/exec.json index f4e21983b6..8ac9f9122f 100644 --- a/internal/endtoend/testdata/mssql_embed_outer_join/mssql/exec.json +++ b/internal/endtoend/testdata/mssql_embed_outer_join/mssql/exec.json @@ -1 +1 @@ -{"contexts": ["base"]} +{"contexts": ["base", "core"]} From d1ad51581d8bce39f1f35c0a8fe38d3074e48956 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:39:00 -0800 Subject: [PATCH 41/43] fix(core): scope recursive CTE nullability by dialect Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/core/analyzer/analyzer.go | 8 ++++--- internal/core/dialect.go | 14 +++++++++++++ internal/core/seed/seed.go | 9 ++++++++ .../postgresql/exec.json | 5 +++++ .../postgresql/query.sql | 14 +++++++++++++ .../postgresql/schema.sql | 1 + .../postgresql/stdout.json | 21 +++++++++++++++++++ .../analyze_recursive_cte/mssql/exec.json | 2 +- internal/engine/mssql/dialect/dialect.json | 1 + 9 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 internal/endtoend/testdata/analyze_cte_nested_scope/postgresql/exec.json create mode 100644 internal/endtoend/testdata/analyze_cte_nested_scope/postgresql/query.sql create mode 100644 internal/endtoend/testdata/analyze_cte_nested_scope/postgresql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_cte_nested_scope/postgresql/stdout.json diff --git a/internal/core/analyzer/analyzer.go b/internal/core/analyzer/analyzer.go index faa66a8da8..d8e94a6655 100644 --- a/internal/core/analyzer/analyzer.go +++ b/internal/core/analyzer/analyzer.go @@ -340,12 +340,14 @@ func (a *analyzer) bindCTEs(with *ast.WithClause) error { } rel := derivedRel(*cte.Ctename, cols) renameColumns(&rel, cte.Aliascolnames) - // What a recursive CTE yields is the union of every round of it, so - // no column stands for a single table column and any may be NULL. + // A recursive CTE's output no longer has one source column. SQL + // Server also reports all of its columns as nullable. if recursive { for i := range rel.cols { rel.cols[i].AttOID = 0 - rel.cols[i].NotNull = false + if a.cat.RecursiveCTEsNullable() { + rel.cols[i].NotNull = false + } } } a.ctes[*cte.Ctename] = rel diff --git a/internal/core/dialect.go b/internal/core/dialect.go index 3f8133a825..41eea18da9 100644 --- a/internal/core/dialect.go +++ b/internal/core/dialect.go @@ -82,6 +82,10 @@ const FlagQualifyDuplicateColumns = "columns.qualify_duplicates" // fill them with the type's default instead. const FlagOuterJoinNullable = "joins.outer_nullable" +// FlagRecursiveCTENullable marks a dialect whose recursive CTE columns are +// nullable regardless of the nullability of their anchor and recursive terms. +const FlagRecursiveCTENullable = "ctes.recursive_nullable" + // FlagReturningAliases holds, comma separated, the names a RETURNING list // may use for the statement's target. const FlagReturningAliases = "returning.aliases" @@ -215,6 +219,16 @@ func (c *Catalog) OuterJoinsNullable() bool { return v == "true" } +// RecursiveCTEsNullable reports whether the dialect treats every column of a +// recursive CTE as nullable, regardless of its terms' nullability. +func (c *Catalog) RecursiveCTEsNullable() bool { + if c.dialectOID == 0 { + return false + } + v, _ := c.DialectFlag(c.dialectOID, FlagRecursiveCTENullable) + return v == "true" +} + // ReturningAliases lists the names a RETURNING list may qualify a column of // the statement's target with, as SQL Server's OUTPUT clause reads // INSERTED.id and DELETED.id. diff --git a/internal/core/seed/seed.go b/internal/core/seed/seed.go index cfbedf69fa..2011128cbc 100644 --- a/internal/core/seed/seed.go +++ b/internal/core/seed/seed.go @@ -88,6 +88,10 @@ type Settings struct { // nullable, as SQL Server fills an unmatched row with NULL. OuterJoinNullable bool `json:"outer_join_nullable,omitempty"` + // RecursiveCTENullable makes all recursive CTE columns nullable, as + // SQL Server reports them regardless of their terms' nullability. + RecursiveCTENullable bool `json:"recursive_cte_nullable,omitempty"` + // ReturningAliases are the names a RETURNING list may qualify a column // of the statement's target with, as SQL Server's OUTPUT clause has // INSERTED and DELETED. @@ -674,6 +678,11 @@ func (b *builder) consts() error { return err } } + if b.settings.RecursiveCTENullable { + if err := b.cat.SetDialectFlag(b.dialectOID, core.FlagRecursiveCTENullable, "true"); err != nil { + return err + } + } if len(b.settings.ReturningAliases) > 0 { if err := b.cat.SetDialectFlag(b.dialectOID, core.FlagReturningAliases, strings.Join(b.settings.ReturningAliases, ",")); err != nil { return err diff --git a/internal/endtoend/testdata/analyze_cte_nested_scope/postgresql/exec.json b/internal/endtoend/testdata/analyze_cte_nested_scope/postgresql/exec.json new file mode 100644 index 0000000000..85c14aaea3 --- /dev/null +++ b/internal/endtoend/testdata/analyze_cte_nested_scope/postgresql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "postgresql", "--schema", "schema.sql", "query.sql"], + "contexts": ["core"] +} diff --git a/internal/endtoend/testdata/analyze_cte_nested_scope/postgresql/query.sql b/internal/endtoend/testdata/analyze_cte_nested_scope/postgresql/query.sql new file mode 100644 index 0000000000..912234d004 --- /dev/null +++ b/internal/endtoend/testdata/analyze_cte_nested_scope/postgresql/query.sql @@ -0,0 +1,14 @@ +-- name: NestedCTEScope :one +WITH items AS ( + SELECT 1::integer AS value +), +derived AS ( + SELECT value FROM ( + WITH items AS ( + SELECT 'inner'::text AS value + ) + SELECT value FROM items + ) AS nested +) +SELECT items.value AS outer_value, derived.value AS nested_value +FROM items CROSS JOIN derived; diff --git a/internal/endtoend/testdata/analyze_cte_nested_scope/postgresql/schema.sql b/internal/endtoend/testdata/analyze_cte_nested_scope/postgresql/schema.sql new file mode 100644 index 0000000000..19f5486402 --- /dev/null +++ b/internal/endtoend/testdata/analyze_cte_nested_scope/postgresql/schema.sql @@ -0,0 +1 @@ +-- This query uses only CTEs and does not require table definitions. diff --git a/internal/endtoend/testdata/analyze_cte_nested_scope/postgresql/stdout.json b/internal/endtoend/testdata/analyze_cte_nested_scope/postgresql/stdout.json new file mode 100644 index 0000000000..c3585a4d38 --- /dev/null +++ b/internal/endtoend/testdata/analyze_cte_nested_scope/postgresql/stdout.json @@ -0,0 +1,21 @@ +[ + { + "name": "NestedCTEScope", + "cmd": ":one", + "columns": [ + { + "name": "outer_value", + "type": { + "name": "integer" + } + }, + { + "name": "nested_value", + "type": { + "name": "text" + } + } + ], + "params": [] + } +] diff --git a/internal/endtoend/testdata/analyze_recursive_cte/mssql/exec.json b/internal/endtoend/testdata/analyze_recursive_cte/mssql/exec.json index 253b05abbf..7b9f8e17cc 100644 --- a/internal/endtoend/testdata/analyze_recursive_cte/mssql/exec.json +++ b/internal/endtoend/testdata/analyze_recursive_cte/mssql/exec.json @@ -1,5 +1,5 @@ { "command": "analyze", "args": ["--dialect", "mssql", "--schema", "schema.sql", "query.sql"], - "contexts": ["base"] + "contexts": ["base", "core"] } diff --git a/internal/engine/mssql/dialect/dialect.json b/internal/engine/mssql/dialect/dialect.json index 1519273f43..50042e966e 100644 --- a/internal/engine/mssql/dialect/dialect.json +++ b/internal/engine/mssql/dialect/dialect.json @@ -10,6 +10,7 @@ "bool": "bit", "limit": "bigint", "outer_join_nullable": true, + "recursive_cte_nullable": true, "returning_aliases": ["inserted", "deleted"], "comparison": ["=", "<>", "!=", "<", "<=", ">", ">="], "comparison_categories": "NBSD", From ce98a0d865ffcf3cb0b71dd7e11d24518e63859d Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:39:00 -0800 Subject: [PATCH 42/43] test(mssql): cover conversion and literal type analysis Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../analyze_national_literals/mssql/exec.json | 2 +- .../analyze_national_literals/mssql/query.sql | 2 +- .../mssql/stdout.json | 11 ++++++ .../analyze_try_conversions/mssql/exec.json | 2 +- .../analyze_try_conversions/mssql/query.sql | 1 + .../analyze_try_conversions/mssql/stdout.json | 12 +++++++ .../testdata/analyze_types/mssql/exec.json | 2 +- .../testdata/analyze_types/mssql/query.sql | 3 ++ .../testdata/analyze_types/mssql/stdout.json | 35 +++++++++++++++++++ internal/engine/mssql/convert.go | 7 ++-- 10 files changed, 70 insertions(+), 7 deletions(-) diff --git a/internal/endtoend/testdata/analyze_national_literals/mssql/exec.json b/internal/endtoend/testdata/analyze_national_literals/mssql/exec.json index 253b05abbf..7b9f8e17cc 100644 --- a/internal/endtoend/testdata/analyze_national_literals/mssql/exec.json +++ b/internal/endtoend/testdata/analyze_national_literals/mssql/exec.json @@ -1,5 +1,5 @@ { "command": "analyze", "args": ["--dialect", "mssql", "--schema", "schema.sql", "query.sql"], - "contexts": ["base"] + "contexts": ["base", "core"] } diff --git a/internal/endtoend/testdata/analyze_national_literals/mssql/query.sql b/internal/endtoend/testdata/analyze_national_literals/mssql/query.sql index f79268096b..f174d191d1 100644 --- a/internal/endtoend/testdata/analyze_national_literals/mssql/query.sql +++ b/internal/endtoend/testdata/analyze_national_literals/mssql/query.sql @@ -1,2 +1,2 @@ -- name: Literals :many -SELECT N'hello' AS a1, N'' AS a2, 'hi' AS a3, a + N'xy' AS a4 FROM p WHERE a LIKE N'%x%' AND b = @b; +SELECT N'hello' AS a1, N'' AS a2, 'hi' AS a3, a + N'xy' AS a4, N'😀' AS a5 FROM p WHERE a LIKE N'%x%' AND b = @b; diff --git a/internal/endtoend/testdata/analyze_national_literals/mssql/stdout.json b/internal/endtoend/testdata/analyze_national_literals/mssql/stdout.json index 5d8934146c..d98c204f47 100644 --- a/internal/endtoend/testdata/analyze_national_literals/mssql/stdout.json +++ b/internal/endtoend/testdata/analyze_national_literals/mssql/stdout.json @@ -36,6 +36,17 @@ "type": { "name": "nvarchar" } + }, + { + "name": "a5", + "type": { + "name": "nvarchar", + "args": [ + { + "int": 2 + } + ] + } } ], "params": [ diff --git a/internal/endtoend/testdata/analyze_try_conversions/mssql/exec.json b/internal/endtoend/testdata/analyze_try_conversions/mssql/exec.json index 253b05abbf..7b9f8e17cc 100644 --- a/internal/endtoend/testdata/analyze_try_conversions/mssql/exec.json +++ b/internal/endtoend/testdata/analyze_try_conversions/mssql/exec.json @@ -1,5 +1,5 @@ { "command": "analyze", "args": ["--dialect", "mssql", "--schema", "schema.sql", "query.sql"], - "contexts": ["base"] + "contexts": ["base", "core"] } diff --git a/internal/endtoend/testdata/analyze_try_conversions/mssql/query.sql b/internal/endtoend/testdata/analyze_try_conversions/mssql/query.sql index 320b95474b..a6125ce7cf 100644 --- a/internal/endtoend/testdata/analyze_try_conversions/mssql/query.sql +++ b/internal/endtoend/testdata/analyze_try_conversions/mssql/query.sql @@ -2,6 +2,7 @@ SELECT id, TRY_CAST(raw AS INT) AS as_int, TRY_CONVERT(DECIMAL(5,1), raw) AS as_decimal, + TRY_CONVERT(DATETIME2(3), raw, 126) AS as_datetime, TRY_CAST(note AS NVARCHAR(MAX)) AS as_text FROM readings WHERE TRY_CAST(@min AS INT) > 0; diff --git a/internal/endtoend/testdata/analyze_try_conversions/mssql/stdout.json b/internal/endtoend/testdata/analyze_try_conversions/mssql/stdout.json index 7af70da068..eab9cf4808 100644 --- a/internal/endtoend/testdata/analyze_try_conversions/mssql/stdout.json +++ b/internal/endtoend/testdata/analyze_try_conversions/mssql/stdout.json @@ -32,6 +32,18 @@ ] } }, + { + "name": "as_datetime", + "type": { + "name": "datetime2", + "nullable": true, + "args": [ + { + "int": 3 + } + ] + } + }, { "name": "as_text", "type": { diff --git a/internal/endtoend/testdata/analyze_types/mssql/exec.json b/internal/endtoend/testdata/analyze_types/mssql/exec.json index 253b05abbf..7b9f8e17cc 100644 --- a/internal/endtoend/testdata/analyze_types/mssql/exec.json +++ b/internal/endtoend/testdata/analyze_types/mssql/exec.json @@ -1,5 +1,5 @@ { "command": "analyze", "args": ["--dialect", "mssql", "--schema", "schema.sql", "query.sql"], - "contexts": ["base"] + "contexts": ["base", "core"] } diff --git a/internal/endtoend/testdata/analyze_types/mssql/query.sql b/internal/endtoend/testdata/analyze_types/mssql/query.sql index e2075d7abd..02d0efff15 100644 --- a/internal/endtoend/testdata/analyze_types/mssql/query.sql +++ b/internal/endtoend/testdata/analyze_types/mssql/query.sql @@ -22,3 +22,6 @@ WHERE CAST(@a AS DECIMAL(5,2)) > 0 -- name: Params :one SELECT id FROM things WHERE price = @price AND body = @body AND phone = @phone AND offset_at = @offset_at; + +-- name: NumericLiterals :one +SELECT 2147483648 AS over_int, 9223372036854775808 AS over_bigint; diff --git a/internal/endtoend/testdata/analyze_types/mssql/stdout.json b/internal/endtoend/testdata/analyze_types/mssql/stdout.json index 2d747d3085..8de370925e 100644 --- a/internal/endtoend/testdata/analyze_types/mssql/stdout.json +++ b/internal/endtoend/testdata/analyze_types/mssql/stdout.json @@ -596,5 +596,40 @@ } } ] + }, + { + "name": "NumericLiterals", + "cmd": ":one", + "columns": [ + { + "name": "over_int", + "type": { + "name": "decimal", + "args": [ + { + "int": 10 + }, + { + "int": 0 + } + ] + } + }, + { + "name": "over_bigint", + "type": { + "name": "decimal", + "args": [ + { + "int": 19 + }, + { + "int": 0 + } + ] + } + } + ], + "params": [] } ] diff --git a/internal/engine/mssql/convert.go b/internal/engine/mssql/convert.go index b78dc961f4..267c25fb58 100644 --- a/internal/engine/mssql/convert.go +++ b/internal/engine/mssql/convert.go @@ -982,9 +982,10 @@ func (c *cc) convertScalarExpression(expr tsql.ScalarExpression) ast.Node { } } -// tryCast converts TRY_CAST and TRY_CONVERT, which yield NULL for a value -// they cannot convert, so their result is nullable whatever the operand is. -// A placeholder cast this way is typed by the cast as it is by CAST. +// tryCast models the result type of TRY_CAST and TRY_CONVERT. The source SQL +// is unchanged; a TRY_CONVERT style affects parsing, not the result type. +// Failed conversions can yield NULL, so results from non-parameter operands +// are nullable. A placeholder is typed by the cast as it is by CAST. func (c *cc) tryCast(arg tsql.ScalarExpression, dataType tsql.DataTypeReference, n tsql.Node) ast.Node { return &ast.TypeCast{ Arg: c.convertScalarExpression(arg), From be33a25bec6d1ebe5c324823bc85a94b9b888b27 Mon Sep 17 00:00:00 2001 From: EliSauder <24995216+EliSauder@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:39:00 -0800 Subject: [PATCH 43/43] test(mssql): verify OUTPUT aliases in core context Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/endtoend/testdata/mssql_output_clause/mssql/exec.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/endtoend/testdata/mssql_output_clause/mssql/exec.json b/internal/endtoend/testdata/mssql_output_clause/mssql/exec.json index f4e21983b6..8ac9f9122f 100644 --- a/internal/endtoend/testdata/mssql_output_clause/mssql/exec.json +++ b/internal/endtoend/testdata/mssql_output_clause/mssql/exec.json @@ -1 +1 @@ -{"contexts": ["base"]} +{"contexts": ["base", "core"]}