Files
upstream-mirror 0f4df23290
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
snapshot of https://github.com/sqlc-dev/sqlc.git (main, 2026-09-16; blobs > 131072B omitted)
2026-09-16 02:17:34 +05:00

674 lines
20 KiB
Go

package spanner
import (
"context"
"fmt"
"hash/crc32"
"os"
"regexp"
"strconv"
"strings"
"cloud.google.com/go/spanner/apiv1/spannerpb"
"github.com/sqlc-dev/sqlc/internal/goldeneye/analysis"
"github.com/sqlc-dev/sqlc/internal/goldeneye/endtoend"
)
// The analyze cases are checked against a live server, which compiles
// each query in PLAN mode: nothing runs, and the server reports the name
// and type of each result column, the type of each parameter the query
// leaves undeclared, and the query plan. The metadata types a value the
// way the wire does — STRING, ARRAY<INT64> — without the length a
// declaration gives a STRING(10) or whether the column can be NULL, so a
// result column the plan reads from a table column is described from the
// case's INFORMATION_SCHEMA instead, and a parameter the plan compares
// with or assigns to a table column is described as that column.
// placeholder is one parameter of a query as sqlc numbers them: each
// @name or sqlc.arg name once, at its first appearance, which is how
// Spanner numbers its named parameters too.
type placeholder struct {
Number int
Name string
}
var identRe = regexp.MustCompile(`^@([A-Za-z_][A-Za-z0-9_]*)`)
// bind rewrites the query so that every parameter is a Spanner @name, and
// lists the parameters in sqlc's order. The query's own @name references
// are kept, since that is how sqlc's GoogleSQL queries name their
// parameters; a sqlc.arg becomes one, and each ? in turn becomes one
// named after its position. A @@system_variable is not a parameter.
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{}
i := 0
for i < len(sql) {
c := sql[i]
switch {
case c == '\'' || c == '"' || c == '`':
i = skipQuoted(sql, i)
case strings.HasPrefix(sql[i:], "--") || strings.HasPrefix(sql[i:], "#"):
end := strings.IndexByte(sql[i:], '\n')
if end < 0 {
i = len(sql)
} else {
i += end
}
case strings.HasPrefix(sql[i:], "/*"):
end := strings.Index(sql[i:], "*/")
if end < 0 {
i = len(sql)
} else {
i += end + 2
}
case c == '@' && i+1 < len(sql) && sql[i+1] == '@':
i += 2
for i < len(sql) && (isWordByte(sql[i])) {
i++
}
case c == '@' && identRe.MatchString(sql[i:]):
m := identRe.FindStringSubmatch(sql[i:])
if _, ok := numbers[m[1]]; !ok {
numbers[m[1]] = len(phs) + 1
phs = append(phs, placeholder{Number: len(phs) + 1, Name: m[1]})
}
i += len(m[0])
default:
i++
}
}
return sql, phs
}
func isWordByte(c byte) bool {
return 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,
// honouring backslash escapes.
func skipQuoted(s string, i int) int {
q := s[i]
j := i + 1
for j < len(s) {
switch {
case s[j] == '\\' && j+1 < len(s):
j += 2
case s[j] == q:
return j + 1
default:
j++
}
}
return len(s)
}
// splitStatements splits a script on the semicolons outside strings and
// comments, which is how a schema's DDL is handed to CREATE DATABASE and a
// fixture's DML to a transaction.
func splitStatements(src string) []string {
var stmts []string
flush := func(s string) {
if s = strings.TrimSpace(s); s != "" {
stmts = append(stmts, s)
}
}
start, i := 0, 0
for i < len(src) {
c := src[i]
switch {
case c == '\'' || c == '"' || c == '`':
i = skipQuoted(src, i)
case strings.HasPrefix(src[i:], "--") || 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 c == ';':
flush(src[start:i])
start = i + 1
i++
default:
i++
}
}
flush(src[start:])
return stmts
}
// column is what INFORMATION_SCHEMA says about a column of the case's
// database: its type as the schema declared it, and whether it can be
// NULL.
type column struct {
name string
typ *analysis.TypeExpr
nullable bool
}
// analyzer holds the session a case is compiled in and the catalog of the
// case's database, keyed by table name in the database's own schema.
type analyzer struct {
s *server
session string
catalog map[string][]column
keys map[string][]string // the primary key columns of each table
}
var dbNameRe = regexp.MustCompile(`[^a-z0-9_]+`)
// Analyze creates a database of the case's own from its schema, writes its
// fixture there, compiles its queries and returns what Spanner reports in
// the JSON shape sqlc analyze prints.
func Analyze(ctx context.Context, endpoint string, c endtoend.Case) ([]byte, error) {
s, err := open(ctx, endpoint)
if err != nil {
return nil, err
}
defer s.Close()
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
}
// A database id is lower-case letters, digits and underscores, at
// most 30 characters: the case's name, cut to fit, and a hash of the
// whole of it, so that two cases with the same head stay apart.
hash := fmt.Sprintf("%08x", crc32.ChecksumIEEE([]byte(c.Name)))
name := "goldeneye_" + dbNameRe.ReplaceAllString(strings.ToLower(c.Name), "_")
if max := 30 - 1 - len(hash); len(name) > max {
name = name[:max]
}
name = strings.TrimRight(name, "_") + "_" + hash
db := Instance + "/databases/" + name
if err := s.dropDatabase(ctx, db); err != nil {
return nil, err
}
if _, err := s.createDatabase(ctx, name, splitStatements(string(schema))); err != nil {
return nil, fmt.Errorf("loading %s: %w", c.Schema, err)
}
defer s.dropDatabase(context.WithoutCancel(ctx), db)
session, err := s.session(ctx, db)
if err != nil {
return nil, err
}
if stmts := splitStatements(string(fixture)); len(stmts) > 0 {
if err := s.write(ctx, session, stmts); err != nil {
return nil, fmt.Errorf("loading %s: %w", c.Fixture, err)
}
}
a := &analyzer{s: s, session: session}
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)
}
// Check compares what Spanner reports for a case with the output the
// case committed, returning a diff when they differ.
func Check(ctx context.Context, endpoint string, c endtoend.Case) (string, error) {
got, err := Analyze(ctx, endpoint, c)
if err != nil {
return "", err
}
return c.Compare(got)
}
// write runs DML statements in a read-write transaction and commits it.
func (s *server) write(ctx context.Context, session string, stmts []string) error {
tx, err := s.data.BeginTransaction(ctx, &spannerpb.BeginTransactionRequest{
Session: session,
Options: &spannerpb.TransactionOptions{Mode: &spannerpb.TransactionOptions_ReadWrite_{ReadWrite: &spannerpb.TransactionOptions_ReadWrite{}}},
})
if err != nil {
return err
}
req := &spannerpb.ExecuteBatchDmlRequest{
Session: session,
Transaction: &spannerpb.TransactionSelector{Selector: &spannerpb.TransactionSelector_Id{Id: tx.Id}},
Seqno: 1,
}
for _, stmt := range stmts {
req.Statements = append(req.Statements, &spannerpb.ExecuteBatchDmlRequest_Statement{Sql: stmt})
}
resp, err := s.data.ExecuteBatchDml(ctx, req)
if err == nil && resp.Status != nil && resp.Status.Code != 0 {
err = fmt.Errorf("%s", resp.Status.Message)
}
if err != nil {
s.data.Rollback(context.WithoutCancel(ctx), &spannerpb.RollbackRequest{Session: session, TransactionId: tx.Id})
return err
}
_, err = s.data.Commit(ctx, &spannerpb.CommitRequest{
Session: session,
Transaction: &spannerpb.CommitRequest_TransactionId{TransactionId: tx.Id},
})
return err
}
const catalogQuery = `
SELECT TABLE_NAME, COLUMN_NAME, SPANNER_TYPE, IS_NULLABLE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = ''
ORDER BY TABLE_NAME, ORDINAL_POSITION`
const keysQuery = `
SELECT TABLE_NAME, COLUMN_NAME
FROM INFORMATION_SCHEMA.INDEX_COLUMNS
WHERE TABLE_SCHEMA = '' AND INDEX_TYPE = 'PRIMARY_KEY'
ORDER BY TABLE_NAME, ORDINAL_POSITION`
// readCatalog reads the tables the schema created, and their keys.
func (a *analyzer) readCatalog(ctx context.Context) error {
rows, err := a.s.query(ctx, a.session, catalogQuery)
if err != nil {
return err
}
a.catalog = map[string][]column{}
for _, row := range rows {
table := row[0].GetStringValue()
a.catalog[table] = append(a.catalog[table], column{
name: row[1].GetStringValue(),
typ: parseType(row[2].GetStringValue()),
nullable: row[3].GetStringValue() == "YES",
})
}
rows, err = a.s.query(ctx, a.session, keysQuery)
if err != nil {
return err
}
a.keys = map[string][]string{}
for _, row := range rows {
table := row[0].GetStringValue()
a.keys[table] = append(a.keys[table], row[1].GetStringValue())
}
return nil
}
// tableName returns the catalog's spelling of a table's name, which
// Spanner matches in any case.
func (a *analyzer) tableName(table string) string {
for t := range a.catalog {
if strings.EqualFold(t, table) {
return t
}
}
return table
}
// lookup finds a column of a table of the case's database. Spanner
// matches table and column names in any case.
func (a *analyzer) lookup(table, name string) (column, bool) {
for t, cols := range a.catalog {
if !strings.EqualFold(t, table) {
continue
}
for _, col := range cols {
if strings.EqualFold(col.name, name) {
return col, true
}
}
}
return column{}, false
}
// parseType reads a type spelled the way SPANNER_TYPE and a declaration
// spell it — STRING(10), ARRAY<STRING(MAX)>, STRUCT<a INT64, b STRING> —
// into an expression, in lower case, with MAX an identifier.
func parseType(s string) *analysis.TypeExpr {
s = strings.TrimSpace(s)
lower := strings.ToLower(s)
if element, ok := strings.CutPrefix(lower, "array<"); ok && strings.HasSuffix(element, ">") {
return &analysis.TypeExpr{Name: "array", Args: []analysis.TypeArg{{Type: parseType(s[6 : len(s)-1])}}}
}
for _, kind := range []string{"proto", "enum"} {
if message, ok := strings.CutPrefix(lower, kind+"<"); ok && strings.HasSuffix(message, ">") {
m := s[len(kind)+1 : len(s)-1]
return &analysis.TypeExpr{Name: kind, Args: []analysis.TypeArg{{String: &m}}}
}
}
if fields, ok := strings.CutPrefix(lower, "struct<"); ok && strings.HasSuffix(fields, ">") {
t := &analysis.TypeExpr{Name: "struct"}
for _, f := range splitTop(s[7:len(s)-1], ',') {
f = strings.TrimSpace(f)
label, typ := "", f
if i := strings.IndexAny(f, " \t"); i > 0 && !strings.ContainsAny(f[:i], "<(") {
label, typ = f[:i], strings.TrimSpace(f[i+1:])
}
t.Args = append(t.Args, analysis.TypeArg{Label: label, Type: parseType(typ)})
}
return t
}
name, args := lower, ""
if open := strings.IndexByte(lower, '('); open >= 0 && strings.HasSuffix(lower, ")") {
name, args = strings.TrimSpace(lower[:open]), lower[open+1:len(lower)-1]
}
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
}
// splitTop splits on a separator outside angle brackets and parentheses.
func splitTop(s string, sep byte) []string {
var out []string
depth, start := 0, 0
for i := 0; i < len(s); i++ {
switch s[i] {
case '<', '(':
depth++
case '>', ')':
depth--
case sep:
if depth == 0 {
out = append(out, s[start:i])
start = i + 1
}
}
}
return append(out, s[start:])
}
// typeOf reads a type the way the wire spells it, which names the family
// and, for an array or a struct, what it holds, and nothing of a length.
func typeOf(t *spannerpb.Type) *analysis.TypeExpr {
if t == nil {
return nil
}
switch t.Code {
case spannerpb.TypeCode_ARRAY:
return &analysis.TypeExpr{Name: "array", Args: []analysis.TypeArg{{Type: typeOf(t.ArrayElementType)}}}
case spannerpb.TypeCode_STRUCT:
out := &analysis.TypeExpr{Name: "struct"}
if t.StructType != nil {
for _, f := range t.StructType.Fields {
out.Args = append(out.Args, analysis.TypeArg{Label: f.Name, Type: typeOf(f.Type)})
}
}
return out
case spannerpb.TypeCode_PROTO, spannerpb.TypeCode_ENUM:
// A proto or enum is named by its message, the way a declaration
// spells PROTO<p.M>.
fqn := t.ProtoTypeFqn
return &analysis.TypeExpr{Name: strings.ToLower(t.Code.String()), Args: []analysis.TypeArg{{String: &fqn}}}
}
return &analysis.TypeExpr{Name: strings.ToLower(t.Code.String())}
}
// 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
}
// isDML reports whether a statement writes, and so has to be compiled in a
// read-write transaction, which is begun for it and never committed. The
// statement's first word decides, past any comment it opens with.
func isDML(sql string) bool {
i := 0
for i < len(sql) {
switch {
case sql[i] == ' ' || sql[i] == '\t' || sql[i] == '\n' || sql[i] == '\r':
i++
case strings.HasPrefix(sql[i:], "--") || strings.HasPrefix(sql[i:], "#"):
end := strings.IndexByte(sql[i:], '\n')
if end < 0 {
return false
}
i += end
case strings.HasPrefix(sql[i:], "/*"):
end := strings.Index(sql[i:], "*/")
if end < 0 {
return false
}
i += end + 2
default:
head := strings.ToLower(sql[i:])
for _, kw := range []string{"insert", "update", "delete"} {
if strings.HasPrefix(head, kw) {
return true
}
}
return false
}
}
return false
}
// compile compiles a statement in PLAN mode and returns what the server
// reports about it.
func (a *analyzer) compile(ctx context.Context, sql string) (*spannerpb.ResultSet, error) {
req := &spannerpb.ExecuteSqlRequest{
Session: a.session,
Sql: sql,
QueryMode: spannerpb.ExecuteSqlRequest_PLAN,
}
if isDML(sql) {
req.Transaction = &spannerpb.TransactionSelector{Selector: &spannerpb.TransactionSelector_Begin{
Begin: &spannerpb.TransactionOptions{Mode: &spannerpb.TransactionOptions_ReadWrite_{ReadWrite: &spannerpb.TransactionOptions_ReadWrite{}}},
}}
req.Seqno = 1
}
rs, err := a.s.data.ExecuteSql(ctx, req)
if err != nil {
return nil, err
}
if rs.Metadata != nil && rs.Metadata.Transaction != nil && len(rs.Metadata.Transaction.Id) > 0 {
a.s.data.Rollback(context.WithoutCancel(ctx), &spannerpb.RollbackRequest{Session: a.session, TransactionId: rs.Metadata.Transaction.Id})
}
return rs, nil
}
// analyzeQuery compiles 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{},
}
rs, err := a.compile(ctx, sql)
if err != nil {
return analysis.Query{}, err
}
p := newPlan(rs.Stats.GetQueryPlan())
partners := p.partners()
describe := func(o origin) (analysis.Column, bool) {
if o.param != "" {
o = partners[o.param]
}
if o.table == "" {
return analysis.Column{}, false
}
col, ok := a.lookup(o.table, o.column)
if !ok {
return analysis.Column{}, false
}
return analysis.Column{Name: col.name, Type: withNullable(col.typ, col.nullable), Table: a.tableName(o.table)}, true
}
var fields []*spannerpb.StructType_Field
if rs.Metadata != nil && rs.Metadata.RowType != nil {
fields = rs.Metadata.RowType.Fields
}
outputs := p.outputs()
table, operation := p.mutation()
if table != "" {
// A DML plan lists the columns it returns, then the values it
// writes: each of those is a parameter standing for the column
// it is written to.
written := a.written(sql, table, operation)
writes := outputs[min(len(fields), len(outputs)):]
for i, w := range written {
if i >= len(writes) {
break
}
if o := p.resolve(writes[i], map[int32]bool{}); o.param != "" {
if _, ok := partners[o.param]; !ok {
partners[o.param] = origin{table: table, column: w}
}
}
}
outputs = outputs[:min(len(fields), len(outputs))]
}
for i, f := range fields {
ac := analysis.Column{Name: f.Name, Type: typeOf(f.Type)}
var out *spannerpb.PlanNode
if i < len(outputs) {
out = outputs[i]
}
if col, ok := describe(p.resolve(out, map[int32]bool{})); ok {
ac.Type, ac.Table = col.Type, col.Table
} else if table != "" && (out == nil || out.DisplayName != "Function") {
// A THEN RETURN column the plan reads as a constant, such
// as the default a column not inserted takes, is the table's
// column of that name.
if col, ok := a.lookup(table, f.Name); ok {
ac.Type = withNullable(col.typ, col.nullable)
ac.Table = a.tableName(table)
}
}
aq.Columns = append(aq.Columns, ac)
}
declared := map[string]*spannerpb.Type{}
if rs.Metadata != nil && rs.Metadata.UndeclaredParameters != nil {
for _, f := range rs.Metadata.UndeclaredParameters.Fields {
declared[f.Name] = f.Type
}
}
for _, ph := range phs {
ac, ok := describe(origin{param: ph.Name})
if !ok {
ac = analysis.Column{Type: typeOf(declared[ph.Name])}
}
aq.Params = append(aq.Params, analysis.Param{Number: ph.Number, Column: ac})
}
return aq, nil
}
var (
insertRe = regexp.MustCompile("(?is)^insert\\s+(?:or\\s+\\w+\\s+)?into\\s+[\\w.`]+\\s*(?:\\(([^)]*)\\))?")
setRe = regexp.MustCompile("(?is)^update\\s+[\\w.`]+(?:\\s+(?:as\\s+)?\\w+)?\\s+set\\s+")
)
// written lists the columns a DML statement writes, in the order the plan
// lists their values: for an INSERT the columns it inserts, which are the
// statement's column list or every column of the table, and for an
// UPDATE or DELETE the table's key columns, then the columns an UPDATE
// sets.
func (a *analyzer) written(sql, table, operation string) []string {
switch operation {
case "INSERT":
m := insertRe.FindStringSubmatch(sql)
if m == nil {
return nil
}
if strings.TrimSpace(m[1]) == "" {
var cols []string
for _, col := range a.catalog[a.tableName(table)] {
cols = append(cols, col.name)
}
return cols
}
var cols []string
for _, c := range strings.Split(m[1], ",") {
cols = append(cols, strings.Trim(strings.TrimSpace(c), "`"))
}
return cols
case "UPDATE":
cols := append([]string(nil), a.keys[a.tableName(table)]...)
if m := setRe.FindStringIndex(sql); m != nil {
for _, assignment := range setList(sql[m[1]:]) {
target, _, _ := strings.Cut(assignment, "=")
target = strings.TrimSpace(target)
if i := strings.LastIndexByte(target, '.'); i >= 0 {
target = target[i+1:]
}
cols = append(cols, strings.Trim(target, "`"))
}
}
return cols
case "DELETE":
return a.keys[a.tableName(table)]
}
return nil
}
// setList splits an UPDATE's SET list into its assignments: the text up
// to the WHERE outside strings and parentheses, split on the commas
// outside them.
func setList(s string) []string {
var out []string
depth, start := 0, 0
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '\'' || c == '"' || c == '`':
i = skipQuoted(s, i) - 1
case c == '(':
depth++
case c == ')':
depth--
case c == ',' && depth == 0:
out = append(out, s[start:i])
start = i + 1
case depth == 0 && (i == 0 || !isWordByte(s[i-1])) && len(s)-i >= 5 && strings.EqualFold(s[i:i+5], "where") && (len(s) == i+5 || !isWordByte(s[i+5])):
return append(out, s[start:i])
}
}
return append(out, s[start:])
}