snapshot of https://github.com/sqlc-dev/sqlc.git (main, 2026-09-16; blobs > 131072B omitted)
Some checks failed
kotlin / test (push) Has been skipped
python / test (push) Has been skipped
typescript / test (push) Has been skipped
go / build darwin/amd64 (push) Has been cancelled
go / build linux/amd64 (push) Has been cancelled
go / build windows/amd64 (push) Has been cancelled
go / build darwin/arm64 (push) Has been cancelled
go / build linux/arm64 (push) Has been cancelled
go / build windows/arm64 (push) Has been cancelled
go / test (push) Has been cancelled
go / vuln_check (push) Has been cancelled
Some checks failed
kotlin / test (push) Has been skipped
python / test (push) Has been skipped
typescript / test (push) Has been skipped
go / build darwin/amd64 (push) Has been cancelled
go / build linux/amd64 (push) Has been cancelled
go / build windows/amd64 (push) Has been cancelled
go / build darwin/arm64 (push) Has been cancelled
go / build linux/arm64 (push) Has been cancelled
go / build windows/arm64 (push) Has been cancelled
go / test (push) Has been cancelled
go / vuln_check (push) Has been cancelled
This commit is contained in:
725
internal/goldeneye/mssql/analyze.go
Normal file
725
internal/goldeneye/mssql/analyze.go
Normal file
@@ -0,0 +1,725 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/analysis"
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/dialect"
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/endtoend"
|
||||
)
|
||||
|
||||
// The analyze cases are checked against a live server, which is asked
|
||||
// three things about each query, none of which runs it. What a driver
|
||||
// would see: sys.dm_exec_describe_first_result_set describes each result
|
||||
// column — its name, its type spelled the way a declaration spells it,
|
||||
// whether it can be NULL, and which table column it is read from. What
|
||||
// each parameter would be: sp_describe_undeclared_parameters says what
|
||||
// type the server would give each parameter the query leaves undeclared.
|
||||
// And what each parameter stands in for: the estimated showplan, compiled
|
||||
// with the parameters declared as those types, says which column each is
|
||||
// compared with or assigned to, and a parameter with such a partner is
|
||||
// described as that column, from the catalog of the case's database.
|
||||
//
|
||||
// Two things the describing function keeps to itself: a json or vector
|
||||
// column is described by the nvarchar(max) it is sent to a driver as, so
|
||||
// a column read from a table is typed from the catalog instead, and a
|
||||
// type SQL Server calls timestamp is written as the rowversion the
|
||||
// dialect names it.
|
||||
|
||||
// placeholder is one parameter of a query as sqlc numbers them: each ?
|
||||
// in turn, and each @name or sqlc.arg name once, at its first appearance.
|
||||
// Each appearance becomes a variable of its own, since the server
|
||||
// describes an undeclared parameter only when it is used once.
|
||||
type placeholder struct {
|
||||
Number int
|
||||
Name string
|
||||
Vars []string // the variables its appearances became, without the @
|
||||
}
|
||||
|
||||
var identRe = regexp.MustCompile(`^@([A-Za-z_][A-Za-z0-9_]*)`)
|
||||
|
||||
// bind rewrites the query so that every parameter is a variable used once,
|
||||
// and lists the parameters in sqlc's order. The query's own @name
|
||||
// references are kept, since that is how sqlc's SQL Server queries name
|
||||
// their parameters; a sqlc.arg becomes one, and each ? in turn becomes
|
||||
// one named after its position.
|
||||
func bind(query string) (string, []placeholder) {
|
||||
positional := 0
|
||||
sql := endtoend.Rewrite(query, func(name, _ string) string {
|
||||
if name == "" {
|
||||
positional++
|
||||
name = "p" + strconv.Itoa(positional)
|
||||
}
|
||||
return "@" + name
|
||||
})
|
||||
var (
|
||||
phs []placeholder
|
||||
numbers = map[string]int{}
|
||||
out strings.Builder
|
||||
)
|
||||
i := 0
|
||||
for i < len(sql) {
|
||||
c := sql[i]
|
||||
switch {
|
||||
case c == '\'' || c == '"' || c == '[':
|
||||
end := skipQuoted(sql, i)
|
||||
out.WriteString(sql[i:end])
|
||||
i = end
|
||||
case strings.HasPrefix(sql[i:], "--"):
|
||||
end := strings.IndexByte(sql[i:], '\n')
|
||||
if end < 0 {
|
||||
end = len(sql)
|
||||
} else {
|
||||
end += i
|
||||
}
|
||||
out.WriteString(sql[i:end])
|
||||
i = end
|
||||
case strings.HasPrefix(sql[i:], "/*"):
|
||||
end := strings.Index(sql[i:], "*/")
|
||||
if end < 0 {
|
||||
end = len(sql)
|
||||
} else {
|
||||
end += i + 2
|
||||
}
|
||||
out.WriteString(sql[i:end])
|
||||
i = end
|
||||
case c == '@' && (i == 0 || !isWordByte(sql[i-1])) && identRe.MatchString(sql[i:]):
|
||||
m := identRe.FindStringSubmatch(sql[i:])
|
||||
name := m[1]
|
||||
n, ok := numbers[name]
|
||||
if !ok {
|
||||
n = len(phs) + 1
|
||||
numbers[name] = n
|
||||
phs = append(phs, placeholder{Number: n, Name: name})
|
||||
}
|
||||
ph := &phs[n-1]
|
||||
v := name
|
||||
if k := len(ph.Vars); k > 0 {
|
||||
v = fmt.Sprintf("%s__%d", name, k+1)
|
||||
}
|
||||
ph.Vars = append(ph.Vars, v)
|
||||
out.WriteString("@" + v)
|
||||
i += len(m[0])
|
||||
default:
|
||||
out.WriteByte(c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return out.String(), phs
|
||||
}
|
||||
|
||||
func isWordByte(c byte) bool {
|
||||
return c == '_' || c == '@' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
|
||||
}
|
||||
|
||||
// skipQuoted returns the index just past the quoted token starting at i: a
|
||||
// string, a double-quoted identifier or a bracketed one, whose closing
|
||||
// delimiter is escaped by doubling it.
|
||||
func skipQuoted(s string, i int) int {
|
||||
open := s[i]
|
||||
close := open
|
||||
if open == '[' {
|
||||
close = ']'
|
||||
}
|
||||
j := i + 1
|
||||
for j < len(s) {
|
||||
switch {
|
||||
case s[j] == close && j+1 < len(s) && s[j+1] == close:
|
||||
j += 2
|
||||
case s[j] == close:
|
||||
return j + 1
|
||||
default:
|
||||
j++
|
||||
}
|
||||
}
|
||||
return len(s)
|
||||
}
|
||||
|
||||
// splitStatements splits a script into the statements it is made of, on
|
||||
// the semicolons outside strings, brackets, comments and BEGIN ... END
|
||||
// blocks, and on the GO lines a T-SQL script separates batches with. A
|
||||
// CREATE TYPE has to be its own batch before a table can use the type, so
|
||||
// a schema is loaded one statement at a time; a trigger or procedure
|
||||
// body keeps its semicolons, since a BEGIN, or a CASE, opens a block that
|
||||
// its END closes.
|
||||
func splitStatements(src string) []string {
|
||||
var stmts []string
|
||||
flush := func(s string) {
|
||||
if s = strings.TrimSpace(s); s != "" && !strings.EqualFold(s, "go") {
|
||||
stmts = append(stmts, s)
|
||||
}
|
||||
}
|
||||
start := 0
|
||||
i := 0
|
||||
depth := 0
|
||||
if isGoLine(src, 0) {
|
||||
for i < len(src) && src[i] != '\n' {
|
||||
i++
|
||||
}
|
||||
start = i
|
||||
}
|
||||
for i < len(src) {
|
||||
c := src[i]
|
||||
switch {
|
||||
case c == '\'' || c == '"' || c == '[':
|
||||
i = skipQuoted(src, i)
|
||||
case strings.HasPrefix(src[i:], "--"):
|
||||
end := strings.IndexByte(src[i:], '\n')
|
||||
if end < 0 {
|
||||
i = len(src)
|
||||
} else {
|
||||
i += end
|
||||
}
|
||||
case strings.HasPrefix(src[i:], "/*"):
|
||||
end := strings.Index(src[i:], "*/")
|
||||
if end < 0 {
|
||||
i = len(src)
|
||||
} else {
|
||||
i += end + 2
|
||||
}
|
||||
case isWordByte(c):
|
||||
end := i
|
||||
for end < len(src) && isWordByte(src[end]) {
|
||||
end++
|
||||
}
|
||||
switch word := strings.ToLower(src[i:end]); word {
|
||||
case "begin":
|
||||
// BEGIN TRAN[SACTION] and BEGIN DISTRIBUTED are statements,
|
||||
// not blocks.
|
||||
if !nextWordIn(src, end, "tran", "transaction", "distributed") {
|
||||
depth++
|
||||
}
|
||||
case "case":
|
||||
depth++
|
||||
case "end":
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
}
|
||||
i = end
|
||||
case c == ';' && depth == 0:
|
||||
flush(src[start:i])
|
||||
start = i + 1
|
||||
i++
|
||||
case c == '\n' && isGoLine(src, i+1):
|
||||
flush(src[start:i])
|
||||
i++
|
||||
for i < len(src) && src[i] != '\n' {
|
||||
i++
|
||||
}
|
||||
start = i
|
||||
depth = 0
|
||||
default:
|
||||
i++
|
||||
}
|
||||
}
|
||||
flush(src[start:])
|
||||
return stmts
|
||||
}
|
||||
|
||||
// nextWordIn reports whether the word after position i, past spaces and
|
||||
// comments, is one of the words.
|
||||
func nextWordIn(src string, i int, words ...string) bool {
|
||||
for i < len(src) {
|
||||
switch {
|
||||
case src[i] == ' ' || src[i] == '\t' || src[i] == '\n' || src[i] == '\r':
|
||||
i++
|
||||
case strings.HasPrefix(src[i:], "--"):
|
||||
end := strings.IndexByte(src[i:], '\n')
|
||||
if end < 0 {
|
||||
return false
|
||||
}
|
||||
i += end
|
||||
case strings.HasPrefix(src[i:], "/*"):
|
||||
end := strings.Index(src[i:], "*/")
|
||||
if end < 0 {
|
||||
return false
|
||||
}
|
||||
i += end + 2
|
||||
default:
|
||||
end := i
|
||||
for end < len(src) && isWordByte(src[end]) {
|
||||
end++
|
||||
}
|
||||
for _, w := range words {
|
||||
if strings.EqualFold(src[i:end], w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isGoLine reports whether the line starting at i is a GO batch separator.
|
||||
func isGoLine(src string, i int) bool {
|
||||
end := strings.IndexByte(src[i:], '\n')
|
||||
if end < 0 {
|
||||
end = len(src)
|
||||
} else {
|
||||
end += i
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(src[i:end]), "go")
|
||||
}
|
||||
|
||||
// column is what the catalog says about a column of the case's database.
|
||||
type column struct {
|
||||
name string
|
||||
typ *analysis.TypeExpr
|
||||
nullable bool
|
||||
}
|
||||
|
||||
// relation is a table or view of the case's database, by schema and name,
|
||||
// in lower case.
|
||||
type relation struct {
|
||||
schema, name string
|
||||
}
|
||||
|
||||
// analyzer holds the session a case is described in and the catalog of
|
||||
// the case's database.
|
||||
type analyzer struct {
|
||||
conn *sql.Conn
|
||||
catalog map[relation][]column
|
||||
// userTypes are the alias types the schema created, by name in lower
|
||||
// case, which the server reports beside the system type they stand on
|
||||
// and the dialect reports by their own name.
|
||||
userTypes map[string]bool
|
||||
// canonical names the type the dialect reports a spelling as, for each
|
||||
// alias types.jsonl lists: SQL Server spells numeric and timestamp in
|
||||
// its catalog, which the dialect names decimal and rowversion.
|
||||
canonical map[string]string
|
||||
}
|
||||
|
||||
var dbNameRe = regexp.MustCompile(`[^A-Za-z0-9_]+`)
|
||||
|
||||
// Analyze loads a case's schema and fixture into a database of their own
|
||||
// on the server, describes its queries there and returns what SQL Server
|
||||
// reports in the JSON shape sqlc analyze prints.
|
||||
func Analyze(ctx context.Context, dsn string, c endtoend.Case) ([]byte, error) {
|
||||
conn, err := open(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := checkVersion(ctx, conn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
schema, err := os.ReadFile(c.Schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fixture []byte
|
||||
if c.Fixture != "" {
|
||||
if fixture, err = os.ReadFile(c.Fixture); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
queries, err := c.Queries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db := quote("goldeneye_" + dbNameRe.ReplaceAllString(c.Name, "_"))
|
||||
for _, stmt := range []string{
|
||||
"USE master",
|
||||
"DROP DATABASE IF EXISTS " + db,
|
||||
"CREATE DATABASE " + db,
|
||||
"USE " + db,
|
||||
} {
|
||||
if _, err := conn.ExecContext(ctx, stmt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
defer conn.ExecContext(context.WithoutCancel(ctx), "USE master; DROP DATABASE IF EXISTS "+db)
|
||||
for _, stmt := range splitStatements(string(schema)) {
|
||||
if _, err := conn.ExecContext(ctx, stmt); err != nil {
|
||||
return nil, fmt.Errorf("loading %s: %w", c.Schema, err)
|
||||
}
|
||||
}
|
||||
for _, stmt := range splitStatements(string(fixture)) {
|
||||
if _, err := conn.ExecContext(ctx, stmt); err != nil {
|
||||
return nil, fmt.Errorf("loading %s: %w", c.Fixture, err)
|
||||
}
|
||||
}
|
||||
|
||||
a := &analyzer{conn: conn}
|
||||
if a.canonical, err = readAliases(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.readCatalog(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]analysis.Query, 0, len(queries))
|
||||
for _, q := range queries {
|
||||
aq, err := a.analyzeQuery(ctx, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", q.Name, err)
|
||||
}
|
||||
out = append(out, aq)
|
||||
}
|
||||
return analysis.Encode(out)
|
||||
}
|
||||
|
||||
// readAliases reads the aliases the hand-written types.jsonl gives each
|
||||
// type, keyed by alias.
|
||||
func readAliases() (map[string]string, error) {
|
||||
dir, err := dialect.Dir(Engine)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
types, err := dialect.ReadTypes(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canonical := map[string]string{}
|
||||
for _, t := range types {
|
||||
for _, alias := range t.Aliases {
|
||||
canonical[strings.ToLower(alias)] = strings.ToLower(t.Name)
|
||||
}
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
// Check compares what SQL Server reports for a case with the output the
|
||||
// case committed, returning a diff when they differ.
|
||||
func Check(ctx context.Context, dsn string, c endtoend.Case) (string, error) {
|
||||
got, err := Analyze(ctx, dsn, c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.Compare(got)
|
||||
}
|
||||
|
||||
// tablesQuery lists the tables and views of the current database, and the
|
||||
// columns of each: the type as the schema declared it, whether that is a
|
||||
// type the schema created, and the dimensions of a vector, which is all
|
||||
// the describing function does not say.
|
||||
const tablesQuery = `
|
||||
SELECT s.name, o.name, c.name, t.name, t.is_user_defined, c.vector_dimensions
|
||||
FROM sys.columns c
|
||||
JOIN sys.objects o ON o.object_id = c.object_id
|
||||
JOIN sys.schemas s ON s.schema_id = o.schema_id
|
||||
JOIN sys.types t ON t.user_type_id = c.user_type_id
|
||||
WHERE o.type IN ('U', 'V')
|
||||
ORDER BY s.name, o.name, c.column_id`
|
||||
|
||||
// readCatalog reads the tables the schema created. The server spells each
|
||||
// column's type by describing a SELECT * from the table, and the catalog
|
||||
// says which columns that spelling is not the declared type of.
|
||||
func (a *analyzer) readCatalog(ctx context.Context) error {
|
||||
a.catalog = map[relation][]column{}
|
||||
a.userTypes = map[string]bool{}
|
||||
rows, err := a.conn.QueryContext(ctx, tablesQuery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type declared struct {
|
||||
name string
|
||||
typeName string
|
||||
userDefined bool
|
||||
dimensions sql.NullInt64
|
||||
}
|
||||
decls := map[relation][]declared{}
|
||||
var order []relation
|
||||
for rows.Next() {
|
||||
var (
|
||||
schema, table, col string
|
||||
d declared
|
||||
)
|
||||
if err := rows.Scan(&schema, &table, &col, &d.typeName, &d.userDefined, &d.dimensions); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
d.name = col
|
||||
rel := relation{strings.ToLower(schema), strings.ToLower(table)}
|
||||
if _, ok := decls[rel]; !ok {
|
||||
order = append(order, rel)
|
||||
}
|
||||
decls[rel] = append(decls[rel], d)
|
||||
if d.userDefined {
|
||||
a.userTypes[strings.ToLower(d.typeName)] = true
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rel := range order {
|
||||
described, err := a.describe(ctx, "SELECT * FROM "+quote(rel.schema)+"."+quote(rel.name))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s.%s: %w", rel.schema, rel.name, err)
|
||||
}
|
||||
byName := map[string]declared{}
|
||||
for _, d := range decls[rel] {
|
||||
byName[strings.ToLower(d.name)] = d
|
||||
}
|
||||
var cols []column
|
||||
for _, dc := range described {
|
||||
d, ok := byName[strings.ToLower(dc.name)]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s.%s: the server describes a column %q the catalog does not list", rel.schema, rel.name, dc.name)
|
||||
}
|
||||
t := dc.typ
|
||||
switch {
|
||||
case d.userDefined:
|
||||
t = &analysis.TypeExpr{Name: strings.ToLower(d.typeName)}
|
||||
case strings.EqualFold(d.typeName, "json"):
|
||||
t = &analysis.TypeExpr{Name: "json"}
|
||||
case strings.EqualFold(d.typeName, "vector") && d.dimensions.Valid:
|
||||
n := d.dimensions.Int64
|
||||
t = &analysis.TypeExpr{Name: "vector", Args: []analysis.TypeArg{{Int: &n}}}
|
||||
}
|
||||
cols = append(cols, column{name: strings.ToLower(dc.name), typ: t, nullable: dc.nullable})
|
||||
}
|
||||
a.catalog[rel] = cols
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// described is one result column as sys.dm_exec_describe_first_result_set
|
||||
// reports it.
|
||||
type described struct {
|
||||
name string
|
||||
typ *analysis.TypeExpr
|
||||
nullable bool
|
||||
source *columnRef // the table column it is read from, or nil
|
||||
}
|
||||
|
||||
const describeColumnsQuery = `
|
||||
SELECT column_ordinal, name, system_type_name, user_type_name, is_nullable,
|
||||
source_schema, source_table, source_column, is_computed_column, error_message
|
||||
FROM sys.dm_exec_describe_first_result_set(@p1, NULL, 1)
|
||||
WHERE is_hidden = 0
|
||||
ORDER BY column_ordinal`
|
||||
|
||||
// describe asks the server what the first result set of a statement
|
||||
// looks like. A statement that returns no rows describes as no columns; one
|
||||
// the server cannot compile describes as its error.
|
||||
func (a *analyzer) describe(ctx context.Context, query string) ([]described, error) {
|
||||
rows, err := a.conn.QueryContext(ctx, describeColumnsQuery, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []described
|
||||
for rows.Next() {
|
||||
var (
|
||||
ordinal int
|
||||
name, sysType, userType sql.NullString
|
||||
nullable sql.NullBool
|
||||
srcSchema, srcTable, srcColumn sql.NullString
|
||||
computed sql.NullBool
|
||||
errMsg sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&ordinal, &name, &sysType, &userType, &nullable, &srcSchema, &srcTable, &srcColumn, &computed, &errMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if errMsg.Valid {
|
||||
return nil, fmt.Errorf("cannot be described: %s", errMsg.String)
|
||||
}
|
||||
d := described{
|
||||
name: name.String,
|
||||
typ: a.typeOf(sysType.String, userType),
|
||||
nullable: nullable.Bool,
|
||||
}
|
||||
// A computed column names the column it is computed from, as the
|
||||
// one an update through the result set would write; a column is
|
||||
// read from a table only when it is that column.
|
||||
if srcTable.Valid && !computed.Bool {
|
||||
d.source = &columnRef{schema: srcSchema.String, table: srcTable.String, column: srcColumn.String}
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// typeOf reads a type the server spelled, which is the system type unless
|
||||
// the server also names a type the schema created, which the dialect
|
||||
// reports by that name and no argument.
|
||||
func (a *analyzer) typeOf(systemType string, userType sql.NullString) *analysis.TypeExpr {
|
||||
if userType.Valid && a.userTypes[strings.ToLower(userType.String)] {
|
||||
return &analysis.TypeExpr{Name: strings.ToLower(userType.String)}
|
||||
}
|
||||
return parseType(systemType, a.canonical)
|
||||
}
|
||||
|
||||
// parseType reads a type spelled the way a declaration spells it —
|
||||
// nvarchar(max), decimal(10,2), datetime2(7) — into an expression, named
|
||||
// the way the dialect names it when the spelling is one of the aliases
|
||||
// the dialect lists: numeric is decimal, timestamp is rowversion.
|
||||
func parseType(s string, canonical map[string]string) *analysis.TypeExpr {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
name, args := s, ""
|
||||
if open := strings.IndexByte(s, '('); open >= 0 && strings.HasSuffix(s, ")") {
|
||||
name, args = strings.TrimSpace(s[:open]), s[open+1:len(s)-1]
|
||||
}
|
||||
if c, ok := canonical[name]; ok {
|
||||
name = c
|
||||
}
|
||||
t := &analysis.TypeExpr{Name: name}
|
||||
if args == "" {
|
||||
return t
|
||||
}
|
||||
for _, arg := range strings.Split(args, ",") {
|
||||
arg = strings.TrimSpace(arg)
|
||||
if n, err := strconv.ParseInt(arg, 10, 64); err == nil {
|
||||
t.Args = append(t.Args, analysis.TypeArg{Int: &n})
|
||||
} else {
|
||||
ident := arg
|
||||
t.Args = append(t.Args, analysis.TypeArg{Ident: &ident})
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// withNullable copies a type with its nullability set.
|
||||
func withNullable(t *analysis.TypeExpr, nullable bool) *analysis.TypeExpr {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
out := *t
|
||||
out.Nullable = nullable
|
||||
return &out
|
||||
}
|
||||
|
||||
// lookup finds a column of the case's database in the catalog.
|
||||
func (a *analyzer) lookup(ref columnRef) (column, bool) {
|
||||
cols, ok := a.catalog[relation{strings.ToLower(ref.schema), strings.ToLower(ref.table)}]
|
||||
if !ok {
|
||||
return column{}, false
|
||||
}
|
||||
for _, col := range cols {
|
||||
if strings.EqualFold(col.name, ref.column) {
|
||||
return col, true
|
||||
}
|
||||
}
|
||||
return column{}, false
|
||||
}
|
||||
|
||||
// parameter is what sp_describe_undeclared_parameters says about one
|
||||
// variable the statement leaves undeclared.
|
||||
type parameter struct {
|
||||
systemType string
|
||||
userType sql.NullString
|
||||
}
|
||||
|
||||
// undeclaredParameters asks the server what type it would give each
|
||||
// parameter, keyed by the variable's name without the @.
|
||||
func (a *analyzer) undeclaredParameters(ctx context.Context, query string) (map[string]parameter, error) {
|
||||
rows, err := a.conn.QueryContext(ctx, "EXEC sp_describe_undeclared_parameters @p1", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index := map[string]int{}
|
||||
for i, c := range cols {
|
||||
index[c] = i
|
||||
}
|
||||
params := map[string]parameter{}
|
||||
for rows.Next() {
|
||||
vals := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
str := func(col string) sql.NullString {
|
||||
i, ok := index[col]
|
||||
if !ok || vals[i] == nil {
|
||||
return sql.NullString{}
|
||||
}
|
||||
return sql.NullString{String: fmt.Sprint(vals[i]), Valid: true}
|
||||
}
|
||||
name := strings.TrimPrefix(str("name").String, "@")
|
||||
params[name] = parameter{
|
||||
systemType: str("suggested_system_type_name").String,
|
||||
userType: str("suggested_user_type_name"),
|
||||
}
|
||||
}
|
||||
return params, rows.Err()
|
||||
}
|
||||
|
||||
// analyzeQuery describes one query.
|
||||
func (a *analyzer) analyzeQuery(ctx context.Context, q endtoend.Query) (analysis.Query, error) {
|
||||
sql, phs := bind(q.SQL)
|
||||
aq := analysis.Query{
|
||||
Name: q.Name,
|
||||
Cmd: q.Cmd,
|
||||
Columns: []analysis.Column{},
|
||||
Params: []analysis.Param{},
|
||||
}
|
||||
|
||||
described, err := a.describe(ctx, sql)
|
||||
if err != nil {
|
||||
return analysis.Query{}, err
|
||||
}
|
||||
for _, d := range described {
|
||||
ac := analysis.Column{Name: strings.ToLower(d.name), Type: withNullable(d.typ, d.nullable)}
|
||||
if d.source != nil {
|
||||
if col, ok := a.lookup(*d.source); ok {
|
||||
ac.Type = withNullable(col.typ, d.nullable)
|
||||
}
|
||||
ac.Table = strings.ToLower(d.source.table)
|
||||
}
|
||||
aq.Columns = append(aq.Columns, ac)
|
||||
}
|
||||
|
||||
if len(phs) == 0 {
|
||||
return aq, nil
|
||||
}
|
||||
params, err := a.undeclaredParameters(ctx, sql)
|
||||
if err != nil {
|
||||
return analysis.Query{}, err
|
||||
}
|
||||
var decl []string
|
||||
for _, ph := range phs {
|
||||
for _, v := range ph.Vars {
|
||||
p, ok := params[v]
|
||||
if !ok {
|
||||
return analysis.Query{}, fmt.Errorf("the server did not describe parameter @%s", v)
|
||||
}
|
||||
decl = append(decl, "@"+v+" "+p.systemType)
|
||||
}
|
||||
}
|
||||
partners, err := a.partners(ctx, "DECLARE "+strings.Join(decl, ", ")+";\n"+sql)
|
||||
if err != nil {
|
||||
return analysis.Query{}, err
|
||||
}
|
||||
for _, ph := range phs {
|
||||
ac := analysis.Column{}
|
||||
found := false
|
||||
for _, v := range ph.Vars {
|
||||
ref, ok := partners[v]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
col, ok := a.lookup(ref)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ac = analysis.Column{Name: col.name, Type: withNullable(col.typ, col.nullable), Table: strings.ToLower(ref.table)}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
p := params[ph.Vars[0]]
|
||||
ac.Type = a.typeOf(p.systemType, p.userType)
|
||||
}
|
||||
aq.Params = append(aq.Params, analysis.Param{Number: ph.Number, Column: ac})
|
||||
}
|
||||
return aq, nil
|
||||
}
|
||||
153
internal/goldeneye/mssql/mssql.go
Normal file
153
internal/goldeneye/mssql/mssql.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Package mssql generates the SQL Server dialect seed under
|
||||
// internal/engine/mssql/dialect from a live server, and verifies the SQL
|
||||
// Server analyze cases under internal/endtoend/testdata against the same
|
||||
// server.
|
||||
//
|
||||
// SQL Server keeps no catalog of its built-in functions or operators — the
|
||||
// intrinsic functions such as GETDATE and LEN are not objects — so
|
||||
// types.jsonl and functions.jsonl are written by hand and are not this
|
||||
// package's business. What it does describe is its catalog: relations.jsonl
|
||||
// is every view of the sys and INFORMATION_SCHEMA schemas, with each view's
|
||||
// columns as the server itself describes a SELECT * from it.
|
||||
//
|
||||
// The package also verifies the SQL Server analyze cases under
|
||||
// internal/endtoend/testdata against the same server: each case's schema
|
||||
// and fixture are loaded into a database of their own and its queries
|
||||
// described there, without being run. Result columns come from
|
||||
// sys.dm_exec_describe_first_result_set, which says what each column is
|
||||
// called, what type it has, whether it can be NULL and which table column
|
||||
// it is read from; parameters from sp_describe_undeclared_parameters, which
|
||||
// says what type the server would give each, and from the estimated
|
||||
// showplan, which says which column each is compared with or assigned to.
|
||||
// The answer is printed in the JSON shape sqlc analyze prints and compared
|
||||
// with the case's committed stdout.json byte for byte.
|
||||
//
|
||||
// The server is named by MSSQL_SERVER_URI, in any form the go-mssqldb
|
||||
// driver accepts, such as
|
||||
// sqlserver://sa:password@127.0.0.1:1433?encrypt=disable, and has to be
|
||||
// the major release in Major, since each release adds to the catalog views.
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
_ "github.com/microsoft/go-mssqldb"
|
||||
"github.com/microsoft/go-mssqldb/msdsn"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/dialect"
|
||||
)
|
||||
|
||||
// Engine is the name sqlc knows the dialect by, and the name of the analyze
|
||||
// case directories under internal/endtoend/testdata.
|
||||
const Engine = "mssql"
|
||||
|
||||
// Major is the SQL Server major release the dialect is generated from, as
|
||||
// SERVERPROPERTY('ProductMajorVersion') reports it: 17 is SQL Server 2025.
|
||||
// Bumping it is a deliberate change: every release adds views and columns
|
||||
// to the catalog, so regenerate and review the dialect after changing it.
|
||||
const Major = 17
|
||||
|
||||
// Locate returns the server to generate from, named by MSSQL_SERVER_URI.
|
||||
func Locate() (string, error) {
|
||||
dsn := os.Getenv("MSSQL_SERVER_URI")
|
||||
if dsn == "" {
|
||||
return "", errors.New("MSSQL_SERVER_URI is not set")
|
||||
}
|
||||
if _, err := msdsn.Parse(dsn); err != nil {
|
||||
return "", fmt.Errorf("MSSQL_SERVER_URI: %w", err)
|
||||
}
|
||||
return dsn, nil
|
||||
}
|
||||
|
||||
// open connects to the server and holds one session, since a case's
|
||||
// database is the session's current database and the showplan is a
|
||||
// session setting.
|
||||
func open(ctx context.Context, dsn string) (*sql.Conn, error) {
|
||||
db, err := sql.Open("sqlserver", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// Version reports the release a server is.
|
||||
func Version(ctx context.Context, dsn string) (string, error) {
|
||||
conn, err := open(ctx, dsn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer conn.Close()
|
||||
var v string
|
||||
if err := conn.QueryRowContext(ctx, "SELECT @@VERSION").Scan(&v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The first line names the product and build; the rest is the copyright
|
||||
// and the platform.
|
||||
v, _, _ = strings.Cut(v, "\n")
|
||||
return strings.TrimSpace(v), nil
|
||||
}
|
||||
|
||||
// checkVersion refuses a server of another major release than the dialect
|
||||
// is generated from, whose catalog would differ from the committed one
|
||||
// without anything being wrong.
|
||||
func checkVersion(ctx context.Context, conn *sql.Conn) error {
|
||||
var v string
|
||||
if err := conn.QueryRowContext(ctx, "SELECT CAST(SERVERPROPERTY('ProductMajorVersion') AS nvarchar(10))").Scan(&v); err != nil {
|
||||
return err
|
||||
}
|
||||
major, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ProductMajorVersion %q: %w", v, err)
|
||||
}
|
||||
if major != Major {
|
||||
return fmt.Errorf("the dialect is generated from SQL Server major release %d, but the server is release %d", Major, major)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate reads the dialect from the server: relations.jsonl, the views of
|
||||
// sys and INFORMATION_SCHEMA. They are read from a database of their own,
|
||||
// since the views a query sees are the ones a user database has, and
|
||||
// master lists internal views of its own that no query can name.
|
||||
func Generate(ctx context.Context, dsn string) (dialect.Files, error) {
|
||||
conn, err := open(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := checkVersion(ctx, conn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db := quote("goldeneye_dialect")
|
||||
for _, stmt := range []string{
|
||||
"USE master",
|
||||
"DROP DATABASE IF EXISTS " + db,
|
||||
"CREATE DATABASE " + db,
|
||||
"USE " + db,
|
||||
} {
|
||||
if _, err := conn.ExecContext(ctx, stmt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
defer conn.ExecContext(context.WithoutCancel(ctx), "USE master; DROP DATABASE IF EXISTS "+db)
|
||||
relations, err := readRelations(ctx, conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blob, err := dialect.JSONL(relations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dialect.Files{dialect.RelationsFile: blob}, nil
|
||||
}
|
||||
66
internal/goldeneye/mssql/mssql_test.go
Normal file
66
internal/goldeneye/mssql/mssql_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/dialect"
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/endtoend"
|
||||
)
|
||||
|
||||
// TestDialect verifies the committed SQL Server dialect against what the
|
||||
// server reports. It skips unless MSSQL_SERVER_URI names a server.
|
||||
func TestDialect(t *testing.T) {
|
||||
dsn, err := Locate()
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
version, err := Version(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := Generate(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir, err := dialect.Dir(Engine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, err := dialect.Check(dir, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report != "" {
|
||||
t.Errorf("%s does not match what %s reports:\n%s", dir, version, report)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnalyzeCases verifies every SQL Server analyze case under
|
||||
// internal/endtoend/testdata against what the server reports. It skips
|
||||
// unless MSSQL_SERVER_URI names a server.
|
||||
func TestAnalyzeCases(t *testing.T) {
|
||||
dsn, err := Locate()
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
cases, err := endtoend.Cases(Engine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cases) == 0 {
|
||||
t.Fatal("no mssql analyze cases found")
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
diff, err := Check(context.Background(), dsn, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diff != "" {
|
||||
t.Errorf("%s does not match what SQL Server reports (-committed +mssql):\n%s", c.Output, diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
243
internal/goldeneye/mssql/plan.go
Normal file
243
internal/goldeneye/mssql/plan.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The estimated showplan is the one thing that says what a parameter is
|
||||
// compared with or assigned to. It is compiled, not run, with SET
|
||||
// SHOWPLAN_XML ON, and prints the plan as XML in which every column is a
|
||||
// ColumnReference naming its schema, table and column, and every variable
|
||||
// one naming only @variable. A parameter's partner is the column on the
|
||||
// other side of the Compare it is an operand of, the column an Assign
|
||||
// sets to it, or the column of a seek whose range expression it is. An
|
||||
// expression the plan computes once and refers to by name, Expr1002, is
|
||||
// followed to its DefinedValue. A parameter the query casts, CAST(@p AS
|
||||
// T), has no partner: the cast says what it is, as it does to sqlc, and
|
||||
// the plan shows it as a Convert the query asked for rather than one the
|
||||
// server added.
|
||||
|
||||
// columnRef names a table column the plan refers to.
|
||||
type columnRef struct {
|
||||
schema, table, column string
|
||||
}
|
||||
|
||||
// node is one element of the showplan XML.
|
||||
type node struct {
|
||||
XMLName xml.Name
|
||||
Attrs []xml.Attr `xml:",any,attr"`
|
||||
Children []node `xml:",any"`
|
||||
}
|
||||
|
||||
func (n *node) attr(name string) string {
|
||||
for _, a := range n.Attrs {
|
||||
if a.Name.Local == name {
|
||||
return a.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// unbracket strips the [brackets] the plan quotes names with.
|
||||
func unbracket(s string) string {
|
||||
if strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]") {
|
||||
return strings.ReplaceAll(s[1:len(s)-1], "]]", "]")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// plan is a parsed showplan with the expressions it defines by name.
|
||||
type plan struct {
|
||||
root node
|
||||
defined map[string]*node // Expr1002 -> the ScalarOperator defining it
|
||||
}
|
||||
|
||||
// partners compiles a batch and returns the column each variable is
|
||||
// compared with or assigned to, keyed by the variable's name without the
|
||||
// @. A variable with no such column is absent.
|
||||
func (a *analyzer) partners(ctx context.Context, batch string) (map[string]columnRef, error) {
|
||||
if _, err := a.conn.ExecContext(ctx, "SET SHOWPLAN_XML ON"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer a.conn.ExecContext(context.WithoutCancel(ctx), "SET SHOWPLAN_XML OFF")
|
||||
rows, err := a.conn.QueryContext(ctx, batch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("showplan: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var blob string
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blob += s
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := &plan{defined: map[string]*node{}}
|
||||
if err := xml.Unmarshal([]byte(blob), &p.root); err != nil {
|
||||
return nil, fmt.Errorf("showplan: %w", err)
|
||||
}
|
||||
p.collectDefined(&p.root)
|
||||
partners := map[string]columnRef{}
|
||||
p.walk(&p.root, partners)
|
||||
return partners, nil
|
||||
}
|
||||
|
||||
// collectDefined records every DefinedValue that names an expression.
|
||||
func (p *plan) collectDefined(n *node) {
|
||||
if n.XMLName.Local == "DefinedValue" && len(n.Children) >= 2 {
|
||||
ref := &n.Children[0]
|
||||
if ref.XMLName.Local == "ColumnReference" && ref.attr("Table") == "" && !strings.HasPrefix(ref.attr("Column"), "@") {
|
||||
p.defined[ref.attr("Column")] = &n.Children[1]
|
||||
}
|
||||
}
|
||||
for i := range n.Children {
|
||||
p.collectDefined(&n.Children[i])
|
||||
}
|
||||
}
|
||||
|
||||
// walk finds the partners in a subtree, keeping the first found for each
|
||||
// variable.
|
||||
func (p *plan) walk(n *node, partners map[string]columnRef) {
|
||||
set := func(vars []string, ref columnRef) {
|
||||
for _, v := range vars {
|
||||
if _, ok := partners[v]; !ok {
|
||||
partners[v] = ref
|
||||
}
|
||||
}
|
||||
}
|
||||
switch n.XMLName.Local {
|
||||
case "Compare":
|
||||
var sides []*node
|
||||
for i := range n.Children {
|
||||
if n.Children[i].XMLName.Local == "ScalarOperator" {
|
||||
sides = append(sides, &n.Children[i])
|
||||
}
|
||||
}
|
||||
if len(sides) == 2 {
|
||||
for i, side := range sides {
|
||||
vars := p.variables(side, nil)
|
||||
if len(vars) == 0 {
|
||||
continue
|
||||
}
|
||||
if cols := p.columns(sides[1-i], nil); len(cols) > 0 {
|
||||
set(vars, cols[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
case "Assign":
|
||||
var target *columnRef
|
||||
for i := range n.Children {
|
||||
c := &n.Children[i]
|
||||
switch c.XMLName.Local {
|
||||
case "ColumnReference":
|
||||
if ref, ok := refOf(c); ok && target == nil {
|
||||
target = &ref
|
||||
}
|
||||
case "ScalarOperator":
|
||||
if target != nil {
|
||||
set(p.variables(c, nil), *target)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "Prefix", "StartRange", "EndRange":
|
||||
var cols []columnRef
|
||||
var exprs []*node
|
||||
for i := range n.Children {
|
||||
c := &n.Children[i]
|
||||
switch c.XMLName.Local {
|
||||
case "RangeColumns":
|
||||
for j := range c.Children {
|
||||
if ref, ok := refOf(&c.Children[j]); ok {
|
||||
cols = append(cols, ref)
|
||||
}
|
||||
}
|
||||
case "RangeExpressions":
|
||||
for j := range c.Children {
|
||||
exprs = append(exprs, &c.Children[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, e := range exprs {
|
||||
if i < len(cols) {
|
||||
set(p.variables(e, nil), cols[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range n.Children {
|
||||
p.walk(&n.Children[i], partners)
|
||||
}
|
||||
}
|
||||
|
||||
// refOf reads a ColumnReference that names a table column.
|
||||
func refOf(n *node) (columnRef, bool) {
|
||||
if n.XMLName.Local != "ColumnReference" || n.attr("Table") == "" {
|
||||
return columnRef{}, false
|
||||
}
|
||||
return columnRef{
|
||||
schema: unbracket(n.attr("Schema")),
|
||||
table: unbracket(n.attr("Table")),
|
||||
column: n.attr("Column"),
|
||||
}, true
|
||||
}
|
||||
|
||||
// variables lists the variables a subtree refers to, following named
|
||||
// expressions and leaving out the ones under a cast the query wrote.
|
||||
func (p *plan) variables(n *node, seen map[string]bool) []string {
|
||||
var out []string
|
||||
if n.XMLName.Local == "Convert" && n.attr("Implicit") == "0" {
|
||||
return nil
|
||||
}
|
||||
if n.XMLName.Local == "ColumnReference" {
|
||||
col := n.attr("Column")
|
||||
switch {
|
||||
case strings.HasPrefix(col, "@"):
|
||||
return []string{col[1:]}
|
||||
case n.attr("Table") == "":
|
||||
if def, ok := p.defined[col]; ok && !seen[col] {
|
||||
if seen == nil {
|
||||
seen = map[string]bool{}
|
||||
}
|
||||
seen[col] = true
|
||||
vars := p.variables(def, seen)
|
||||
delete(seen, col)
|
||||
return vars
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range n.Children {
|
||||
out = append(out, p.variables(&n.Children[i], seen)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// columns lists the table columns a subtree refers to, following named
|
||||
// expressions.
|
||||
func (p *plan) columns(n *node, seen map[string]bool) []columnRef {
|
||||
var out []columnRef
|
||||
if n.XMLName.Local == "ColumnReference" {
|
||||
if ref, ok := refOf(n); ok {
|
||||
return []columnRef{ref}
|
||||
}
|
||||
col := n.attr("Column")
|
||||
if def, ok := p.defined[col]; ok && !seen[col] {
|
||||
if seen == nil {
|
||||
seen = map[string]bool{}
|
||||
}
|
||||
seen[col] = true
|
||||
cols := p.columns(def, seen)
|
||||
delete(seen, col)
|
||||
return cols
|
||||
}
|
||||
}
|
||||
for i := range n.Children {
|
||||
out = append(out, p.columns(&n.Children[i], seen)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
123
internal/goldeneye/mssql/relations.go
Normal file
123
internal/goldeneye/mssql/relations.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/dialect"
|
||||
)
|
||||
|
||||
// systemSchemas are the schemas whose relations are generated: the catalog
|
||||
// views, which are what a query of the server's catalog reads. Both hold
|
||||
// nothing but views, so every relation a dialect seeds from them is one
|
||||
// the analysis core never hands codegen as a model.
|
||||
var systemSchemas = []string{
|
||||
"sys",
|
||||
"INFORMATION_SCHEMA",
|
||||
}
|
||||
|
||||
// viewQuery lists a schema's views. They are sorted in Go, by the bytes
|
||||
// of their lower-cased names, so that the output does not depend on the
|
||||
// server's collation.
|
||||
const viewQuery = `
|
||||
SELECT o.name
|
||||
FROM sys.all_objects o
|
||||
JOIN sys.schemas s ON s.schema_id = o.schema_id
|
||||
WHERE o.type = 'V' AND s.name = @p1`
|
||||
|
||||
// describeQuery asks the server to describe a SELECT * from a view: each
|
||||
// column's name, its type spelled the way a declaration spells it —
|
||||
// nvarchar(128), decimal(10,2), varbinary(max) — and whether it can be
|
||||
// NULL. A view the server cannot describe reports an error message on a
|
||||
// row of its own rather than failing the query.
|
||||
const describeQuery = `
|
||||
SELECT column_ordinal, name, system_type_name, is_nullable, error_message
|
||||
FROM sys.dm_exec_describe_first_result_set(@p1, NULL, 0)
|
||||
ORDER BY column_ordinal`
|
||||
|
||||
// readRelations reads the views of the system schemas. Their names are
|
||||
// written in lower case: SQL Server matches them in any case under its
|
||||
// default collations, INFORMATION_SCHEMA spells its own in upper case, and
|
||||
// sqlc's SQL Server parser lowercases every identifier, so lower case is
|
||||
// how a query reaches them. The type of each column is spelled the way the
|
||||
// server describes it to a driver, which is the way a declaration spells
|
||||
// it, numeric and timestamp included, since the seed reads the aliases
|
||||
// types.jsonl lists.
|
||||
func readRelations(ctx context.Context, conn *sql.Conn) ([]dialect.Relation, error) {
|
||||
var relations []dialect.Relation
|
||||
for _, schema := range systemSchemas {
|
||||
names, err := views(ctx, conn, schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", schema, err)
|
||||
}
|
||||
for _, name := range names {
|
||||
columns, err := describe(ctx, conn, schema, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s.%s: %w", schema, name, err)
|
||||
}
|
||||
relations = append(relations, dialect.Relation{
|
||||
Schema: strings.ToLower(schema),
|
||||
Name: strings.ToLower(name),
|
||||
Kind: "v",
|
||||
Columns: columns,
|
||||
})
|
||||
}
|
||||
}
|
||||
return relations, nil
|
||||
}
|
||||
|
||||
func views(ctx context.Context, conn *sql.Conn, schema string) ([]string, error) {
|
||||
rows, err := conn.QueryContext(ctx, viewQuery, schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var names []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Slice(names, func(i, j int) bool { return strings.ToLower(names[i]) < strings.ToLower(names[j]) })
|
||||
return names, rows.Err()
|
||||
}
|
||||
|
||||
func describe(ctx context.Context, conn *sql.Conn, schema, name string) ([]dialect.Column, error) {
|
||||
rows, err := conn.QueryContext(ctx, describeQuery, "SELECT * FROM "+quote(schema)+"."+quote(name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var columns []dialect.Column
|
||||
for rows.Next() {
|
||||
var (
|
||||
ordinal int
|
||||
colName sql.NullString
|
||||
typeName sql.NullString
|
||||
nullable sql.NullBool
|
||||
errMsg sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&ordinal, &colName, &typeName, &nullable, &errMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if errMsg.Valid {
|
||||
return nil, fmt.Errorf("cannot be described: %s", errMsg.String)
|
||||
}
|
||||
columns = append(columns, dialect.Column{
|
||||
Name: strings.ToLower(colName.String),
|
||||
Type: typeName.String,
|
||||
NotNull: !nullable.Bool,
|
||||
})
|
||||
}
|
||||
return columns, rows.Err()
|
||||
}
|
||||
|
||||
// quote brackets an identifier the way T-SQL does.
|
||||
func quote(name string) string {
|
||||
return "[" + strings.ReplaceAll(name, "]", "]]") + "]"
|
||||
}
|
||||
Reference in New Issue
Block a user