Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
1b00606
fix(mssql): make the optional side of outer joins nullable
EliSauder Nov 12, 2026
cc09068
feat(mssql): support CROSS APPLY and OUTER APPLY
EliSauder Nov 12, 2026
42ea003
fix(mssql): type TOP and OFFSET/FETCH parameters as bigint
EliSauder Nov 12, 2026
3b901d6
feat(mssql): support recursive common table expressions
EliSauder Nov 12, 2026
b4fd639
feat(mssql): support OPENJSON, STRING_SPLIT and GENERATE_SERIES in FROM
EliSauder Nov 12, 2026
94a9d7a
feat(mssql): support sqlc.arg, sqlc.narg and sqlc.slice
EliSauder Nov 12, 2026
948ff95
feat(mssql): support sqlc.embed
EliSauder Nov 12, 2026
e46519f
fix(core): let set operation branches see the enclosing query
EliSauder Nov 12, 2026
d130dd5
feat(mssql): support VALUES as a derived table
EliSauder Nov 12, 2026
6bdd726
fix(mssql): resolve INSERTED and DELETED in OUTPUT against the target
EliSauder Nov 12, 2026
108d8a1
fix(mssql): type TRY_CAST and TRY_CONVERT as nullable
EliSauder Nov 12, 2026
dd318d5
feat(mssql): type PARSE and TRY_PARSE
EliSauder Nov 12, 2026
142a713
feat(mssql): type @@ROWCOUNT and the other system variables
EliSauder Nov 12, 2026
6476de9
feat(mssql): support PIVOT and UNPIVOT in FROM
EliSauder Nov 12, 2026
4ac96f6
fix(mssql): quote expanded identifiers with brackets
EliSauder Nov 12, 2026
033df82
feat(mssql): type FOR JSON and FOR XML results
EliSauder Nov 12, 2026
b6aef71
fix(golang): expand every use of a named slice
EliSauder Nov 12, 2026
e85a2ac
fix(mssql): expand a star qualified with double quotes
EliSauder Nov 12, 2026
969a10e
fix(mssql): embed a table only when the star is all of its columns
EliSauder Nov 12, 2026
6a3e9b2
fix(core): keep a WITH clause's names out of the enclosing query
EliSauder Nov 12, 2026
1fe1a2d
fix(mssql): leave bracketed identifiers alone when preprocessing
EliSauder Nov 12, 2026
d4a19b5
fix(mssql): reject a macro name that is not a valid @name
EliSauder Nov 12, 2026
d630cd1
fix(mssql): keep the placeholders of FOR SYSTEM_TIME
EliSauder Nov 12, 2026
c3ceb10
fix(mssql): keep the placeholders of CONTAINS and FREETEXT
EliSauder Nov 12, 2026
de920bc
feat(mssql): support WITH on INSERT
EliSauder Nov 12, 2026
0e560ff
feat(mssql): type a call to a scalar function the schema creates
EliSauder Nov 12, 2026
a903576
feat(mssql): select from a table-valued function the schema creates
EliSauder Nov 12, 2026
2420922
fix(core): type a placeholder INSERT ... SELECT inserts as its column
EliSauder Nov 12, 2026
2861faa
fix(mssql): type an N'...' literal as nvarchar
EliSauder Nov 12, 2026
444e97c
fix(core): find the family of a type given default arguments
EliSauder Nov 12, 2026
043680e
fix(mssql): type a number with a decimal point as decimal
EliSauder Nov 12, 2026
fe55ab3
fix(mssql): keep the placeholders of TABLESAMPLE
EliSauder Nov 13, 2026
896a112
fix(core): stop pairing INSERT ... SELECT items with columns after a …
EliSauder Nov 13, 2026
70864b8
fix(mssql): leave sqlc.embed out of the @name check
EliSauder Nov 13, 2026
916869a
fix(mssql): read a placeholder's name through #, $ and @
EliSauder Nov 13, 2026
8e0928f
fix(mssql): measure a star qualifier with escaped quotes as written
EliSauder Nov 13, 2026
d1d16c9
fix(mssql): embed a table only when its columns are as nullable as th…
EliSauder Nov 13, 2026
d44a092
test(mssql): clarify variable scanning
EliSauder Nov 13, 2026
b6f2cc9
test(compiler): cover quoted SQL Server star references
EliSauder Nov 13, 2026
bbc976e
test(compiler): verify core sqlc.embed output
EliSauder Nov 13, 2026
d1ad515
fix(core): scope recursive CTE nullability by dialect
EliSauder Nov 13, 2026
ce98a0d
test(mssql): cover conversion and literal type analysis
EliSauder Nov 13, 2026
be33a25
test(mssql): verify OUTPUT aliases in core context
EliSauder Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/reference/macros.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<n>` 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).
12 changes: 10 additions & 2 deletions internal/codegen/golang/imports.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}{}
}
}
Expand All @@ -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{}{}
}
Expand Down
20 changes: 20 additions & 0 deletions internal/codegen/golang/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down Expand Up @@ -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 {
Expand Down
30 changes: 29 additions & 1 deletion internal/codegen/golang/templates/stdlib/queryCode.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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<ordinal>, 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.ReplaceAll(query, "/*SLICE:{{.Column.Name}}*/@{{.Column.Name}}", strings.Join(placeholders, ","))
} else {
query = strings.ReplaceAll(query, "/*SLICE:{{.Column.Name}}*/@{{.Column.Name}}", "NULL")
}
{{- else if .HasSqlcSlice }}
if len({{$arg.VariableForField .}}) > 0 {
for _, v := range {{$arg.VariableForField .}} {
queryParams = append(queryParams, v)
Expand All @@ -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 }}
Expand All @@ -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.ReplaceAll(query, "/*SLICE:{{.Arg.Column.Name}}*/@{{.Arg.Column.Name}}", strings.Join(placeholders, ","))
} else {
query = strings.ReplaceAll(query, "/*SLICE:{{.Arg.Column.Name}}*/@{{.Arg.Column.Name}}", "NULL")
}
{{- else }}
if len({{.Arg.Name}}) > 0 {
for _, v := range {{.Arg.Name}} {
queryParams = append(queryParams, v)
Expand All @@ -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...)
Expand Down
16 changes: 15 additions & 1 deletion internal/compiler/expand.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,21 @@ 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
}

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 + "\""
}
Expand All @@ -98,9 +106,15 @@ 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(o); strings.HasPrefix(s[length:], quoted) {
} else if quoted := c.quote(parts[i]); strings.HasPrefix(s[length:], quoted) {
length += len(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)
} else {
length += len(o)
Expand Down
79 changes: 79 additions & 0 deletions internal/compiler/parse_core.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, c.coreCatalog)
placeholders, err := placeholderNames(raw)
if err != nil {
return nil, err
Expand Down Expand Up @@ -105,6 +106,84 @@ func (c *Compiler) parseQueryCore(raw *ast.RawStmt, src string, pre *preprocess.
}, nil
}

// 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 {
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 && 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]...)
}
i += n
}
return out
}

// isWholeTable reports whether cols are every column of one table, in the
// 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 {
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 || cols[i].NotNull != tc.NotNull {
return false
}
i++
}
return i == len(cols)
}

func coreColumn(c core.Column) *Column {
col := &Column{
Name: c.Name,
Expand Down
56 changes: 53 additions & 3 deletions internal/core/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package analyzer

import (
"fmt"
"maps"

"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) {
Expand Down Expand Up @@ -65,6 +67,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.
Expand All @@ -82,10 +88,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, 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 || len(outer.rels) == 0 {
outer = a.outer
}
sub := &analyzer{
cat: a.cat,
params: a.params,
outer: a.scope,
outer: outer,
ctes: a.ctes,
stars: a.stars,
}
Expand Down Expand Up @@ -291,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 {
Expand All @@ -300,20 +318,52 @@ func (a *analyzer) bindCTEs(with *ast.WithClause) error {
if !ok {
continue
}
// 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{}
// 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
if a.cat.RecursiveCTEsNullable() {
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) {
Expand Down
Loading
Loading