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:
673
internal/goldeneye/spanner/analyze.go
Normal file
673
internal/goldeneye/spanner/analyze.go
Normal file
@@ -0,0 +1,673 @@
|
||||
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:])
|
||||
}
|
||||
220
internal/goldeneye/spanner/plan.go
Normal file
220
internal/goldeneye/spanner/plan.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package spanner
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"cloud.google.com/go/spanner/apiv1/spannerpb"
|
||||
)
|
||||
|
||||
// The query plan is the one thing that says where a result column comes
|
||||
// from and what a parameter stands for. It is a list of nodes: relational
|
||||
// ones, such as a Scan of a table or the Serialize Result that produces the
|
||||
// rows, and scalar ones, such as a Reference to a column or a variable, a
|
||||
// Parameter, a Function. A relational node defines variables through its
|
||||
// child links: a Scan of a table defines one per column it reads, named
|
||||
// after the column, and any node may define one as another scalar, which
|
||||
// a Reference names with a $. The children of Serialize Result after the
|
||||
// relation it serializes are the result columns, in order; a DML plan
|
||||
// lists its THEN RETURN columns first and the values it writes after
|
||||
// them: for an INSERT the columns it inserts, in the statement's order,
|
||||
// and for an UPDATE or DELETE the key columns of the table, then the
|
||||
// columns an UPDATE sets. A comparison is a Function whose description
|
||||
// reads ($col = @param).
|
||||
|
||||
// origin is what a scalar of the plan resolves to: a table column, a
|
||||
// parameter, or nothing.
|
||||
type origin struct {
|
||||
table, column string
|
||||
param string
|
||||
}
|
||||
|
||||
// plan is a parsed query plan.
|
||||
type plan struct {
|
||||
nodes []*spannerpb.PlanNode
|
||||
// vars maps a variable to the node that defines it and the scalar it
|
||||
// is defined as.
|
||||
vars map[string]definition
|
||||
}
|
||||
|
||||
type definition struct {
|
||||
owner *spannerpb.PlanNode
|
||||
child *spannerpb.PlanNode
|
||||
}
|
||||
|
||||
func newPlan(qp *spannerpb.QueryPlan) *plan {
|
||||
p := &plan{vars: map[string]definition{}}
|
||||
if qp == nil {
|
||||
return p
|
||||
}
|
||||
p.nodes = qp.PlanNodes
|
||||
for _, n := range p.nodes {
|
||||
for _, l := range n.ChildLinks {
|
||||
if l.Variable != "" {
|
||||
p.vars[l.Variable] = definition{owner: n, child: p.node(l.ChildIndex)}
|
||||
}
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *plan) node(index int32) *spannerpb.PlanNode {
|
||||
for _, n := range p.nodes {
|
||||
if n.Index == index {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *plan) root() *spannerpb.PlanNode {
|
||||
return p.node(0)
|
||||
}
|
||||
|
||||
func meta(n *spannerpb.PlanNode, key string) string {
|
||||
if n == nil || n.Metadata == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := n.Metadata.Fields[key]; ok {
|
||||
return v.GetStringValue()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func description(n *spannerpb.PlanNode) string {
|
||||
if n == nil || n.ShortRepresentation == nil {
|
||||
return ""
|
||||
}
|
||||
return n.ShortRepresentation.Description
|
||||
}
|
||||
|
||||
// serializeResult finds the node that produces the rows, the first
|
||||
// Serialize Result reached from the root.
|
||||
func (p *plan) serializeResult() *spannerpb.PlanNode {
|
||||
var found *spannerpb.PlanNode
|
||||
seen := map[int32]bool{}
|
||||
var walk func(n *spannerpb.PlanNode)
|
||||
walk = func(n *spannerpb.PlanNode) {
|
||||
if n == nil || found != nil || seen[n.Index] {
|
||||
return
|
||||
}
|
||||
seen[n.Index] = true
|
||||
if n.DisplayName == "Serialize Result" {
|
||||
found = n
|
||||
return
|
||||
}
|
||||
for _, l := range n.ChildLinks {
|
||||
walk(p.node(l.ChildIndex))
|
||||
}
|
||||
}
|
||||
walk(p.root())
|
||||
return found
|
||||
}
|
||||
|
||||
// outputs lists the scalars Serialize Result produces, in order: every
|
||||
// child after the relation it serializes.
|
||||
func (p *plan) outputs() []*spannerpb.PlanNode {
|
||||
sr := p.serializeResult()
|
||||
if sr == nil {
|
||||
return nil
|
||||
}
|
||||
var out []*spannerpb.PlanNode
|
||||
for _, l := range sr.ChildLinks {
|
||||
if c := p.node(l.ChildIndex); c != nil && c.Kind == spannerpb.PlanNode_SCALAR {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mutation names the table a DML plan writes, or "" for a query.
|
||||
func (p *plan) mutation() (table, operation string) {
|
||||
for _, n := range p.nodes {
|
||||
if n.DisplayName == "Apply Mutations" {
|
||||
return meta(n, "table"), meta(n, "operation_type")
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// resolve follows a scalar to what it stands for.
|
||||
func (p *plan) resolve(n *spannerpb.PlanNode, seen map[int32]bool) origin {
|
||||
if n == nil || seen[n.Index] {
|
||||
return origin{}
|
||||
}
|
||||
seen[n.Index] = true
|
||||
switch n.DisplayName {
|
||||
case "Parameter":
|
||||
return origin{param: meta(n, "name")}
|
||||
case "Reference":
|
||||
desc := description(n)
|
||||
if name, ok := strings.CutPrefix(desc, "$"); ok {
|
||||
return p.resolveVar(name, seen)
|
||||
}
|
||||
// A bare name is a column of the scan it is read by, or an input
|
||||
// of a union, which names it in the link's type.
|
||||
for _, m := range p.nodes {
|
||||
for _, l := range m.ChildLinks {
|
||||
if l.ChildIndex != n.Index {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case m.DisplayName == "Scan" && meta(m, "scan_type") == "TableScan":
|
||||
return origin{table: meta(m, "scan_target"), column: desc}
|
||||
case m.DisplayName == "Scan":
|
||||
// A batch scan reads the batch variable of that name.
|
||||
target := strings.TrimPrefix(meta(m, "scan_target"), "$")
|
||||
return p.resolveVar(target+".Batch."+desc, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range p.nodes {
|
||||
if m.DisplayName != "Union Input" {
|
||||
continue
|
||||
}
|
||||
for _, l := range m.ChildLinks {
|
||||
if l.Type == desc {
|
||||
return p.resolve(p.node(l.ChildIndex), seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return origin{}
|
||||
}
|
||||
|
||||
// resolveVar follows a variable to what it is defined as.
|
||||
func (p *plan) resolveVar(name string, seen map[int32]bool) origin {
|
||||
d, ok := p.vars[name]
|
||||
if !ok {
|
||||
return origin{}
|
||||
}
|
||||
return p.resolve(d.child, seen)
|
||||
}
|
||||
|
||||
var compareRe = regexp.MustCompile(`^\((.+) (=|!=|<>|<|<=|>|>=) (.+)\)$`)
|
||||
|
||||
// partners finds the table column each parameter is compared with, keyed
|
||||
// by parameter name: the other operand of a comparison Function the
|
||||
// parameter is an operand of. The first found wins.
|
||||
func (p *plan) partners() map[string]origin {
|
||||
out := map[string]origin{}
|
||||
for _, n := range p.nodes {
|
||||
if n.DisplayName != "Function" || len(n.ChildLinks) != 2 || !compareRe.MatchString(description(n)) {
|
||||
continue
|
||||
}
|
||||
sides := []*spannerpb.PlanNode{p.node(n.ChildLinks[0].ChildIndex), p.node(n.ChildLinks[1].ChildIndex)}
|
||||
for i, side := range sides {
|
||||
o := p.resolve(side, map[int32]bool{})
|
||||
if o.param == "" {
|
||||
continue
|
||||
}
|
||||
other := p.resolve(sides[1-i], map[int32]bool{})
|
||||
if other.table != "" {
|
||||
if _, ok := out[o.param]; !ok {
|
||||
out[o.param] = other
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
96
internal/goldeneye/spanner/relations.go
Normal file
96
internal/goldeneye/spanner/relations.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package spanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/dialect"
|
||||
)
|
||||
|
||||
// relationQuery lists the relations of every schema but the database's
|
||||
// own, unnamed one — INFORMATION_SCHEMA and SPANNER_SYS — with their
|
||||
// columns in declared order, so that the output is stable. A column's
|
||||
// type is spelled the way SPANNER_TYPE spells a declaration: STRING(MAX),
|
||||
// ARRAY<INT64>.
|
||||
const relationQuery = `
|
||||
SELECT t.TABLE_SCHEMA, t.TABLE_NAME, t.TABLE_TYPE,
|
||||
c.COLUMN_NAME, c.SPANNER_TYPE, c.IS_NULLABLE
|
||||
FROM INFORMATION_SCHEMA.TABLES AS t
|
||||
JOIN INFORMATION_SCHEMA.COLUMNS AS c
|
||||
ON c.TABLE_SCHEMA = t.TABLE_SCHEMA AND c.TABLE_NAME = t.TABLE_NAME
|
||||
WHERE t.TABLE_SCHEMA != ''
|
||||
ORDER BY t.TABLE_SCHEMA, t.TABLE_NAME, c.ORDINAL_POSITION`
|
||||
|
||||
// readRelations reads the views of the system schemas. Their names are
|
||||
// kept as the catalog spells them, in upper case, which is how a query
|
||||
// names them; a column's type is spelled in lower case, the way the
|
||||
// dialect's types.jsonl spells its types, with an ARRAY<T> written as T
|
||||
// with the array flag, since that is how a seed spells an array.
|
||||
func readRelations(ctx context.Context, s *server, session string) ([]dialect.Relation, error) {
|
||||
rows, err := s.query(ctx, session, relationQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var relations []dialect.Relation
|
||||
var cur *dialect.Relation
|
||||
for _, row := range rows {
|
||||
if len(row) != 6 {
|
||||
return nil, fmt.Errorf("expected 6 columns, got %d", len(row))
|
||||
}
|
||||
schema, name, kind := row[0].GetStringValue(), row[1].GetStringValue(), row[2].GetStringValue()
|
||||
if cur == nil || cur.Schema != schema || cur.Name != name {
|
||||
rel := dialect.Relation{Schema: schema, Name: name}
|
||||
if kind == "VIEW" {
|
||||
rel.Kind = "v"
|
||||
}
|
||||
relations = append(relations, rel)
|
||||
cur = &relations[len(relations)-1]
|
||||
}
|
||||
col := dialect.Column{
|
||||
Name: row[3].GetStringValue(),
|
||||
NotNull: row[5].GetStringValue() == "NO",
|
||||
}
|
||||
col.Type, col.Array = typeName(row[4].GetStringValue())
|
||||
cur.Columns = append(cur.Columns, col)
|
||||
}
|
||||
return relations, nil
|
||||
}
|
||||
|
||||
// typeName spells a SPANNER_TYPE the way a seed spells a column's type:
|
||||
// in lower case, with an ARRAY<T> as its element and the array flag, a
|
||||
// STRUCT<a T, b U> as struct(a: t, b: u) and a PROTO<p.M> or ENUM<p.E> as
|
||||
// proto('p.M') or enum('p.E'), since a seed spells a type's arguments in
|
||||
// parentheses.
|
||||
func typeName(spannerType string) (string, bool) {
|
||||
t := strings.TrimSpace(spannerType)
|
||||
if element, ok := strings.CutPrefix(t, "ARRAY<"); ok && strings.HasSuffix(element, ">") {
|
||||
name, _ := typeName(strings.TrimSuffix(element, ">"))
|
||||
return name, true
|
||||
}
|
||||
if fields, ok := strings.CutPrefix(t, "STRUCT<"); ok && strings.HasSuffix(fields, ">") {
|
||||
var args []string
|
||||
for _, f := range splitTop(strings.TrimSuffix(fields, ">"), ',') {
|
||||
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:])
|
||||
}
|
||||
name, array := typeName(typ)
|
||||
if array {
|
||||
name += "[]"
|
||||
}
|
||||
if label != "" {
|
||||
name = label + ": " + name
|
||||
}
|
||||
args = append(args, name)
|
||||
}
|
||||
return "struct(" + strings.Join(args, ", ") + ")", false
|
||||
}
|
||||
for _, kind := range []string{"PROTO<", "ENUM<"} {
|
||||
if message, ok := strings.CutPrefix(t, kind); ok && strings.HasSuffix(message, ">") {
|
||||
return strings.ToLower(strings.TrimSuffix(kind, "<")) + "('" + strings.TrimSuffix(message, ">") + "')", false
|
||||
}
|
||||
}
|
||||
return strings.ToLower(t), false
|
||||
}
|
||||
224
internal/goldeneye/spanner/spanner.go
Normal file
224
internal/goldeneye/spanner/spanner.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// Package spanner generates the GoogleSQL dialect seed under
|
||||
// internal/engine/googlesql/dialect from a live Spanner server, and
|
||||
// verifies the GoogleSQL analyze cases under internal/endtoend/testdata
|
||||
// against the same server. The server is Spanner Omni, the downloadable
|
||||
// Spanner, run from its container image; the package writes into the
|
||||
// googlesql directory, since sqlc's engine is named after the language
|
||||
// Spanner speaks rather than the database.
|
||||
//
|
||||
// Spanner keeps no catalog of its types, functions or operators, so
|
||||
// types.jsonl, functions.jsonl and operators.jsonl are written by hand and
|
||||
// are not this package's business. What it does describe is its
|
||||
// information schema: relations.jsonl is every view of INFORMATION_SCHEMA
|
||||
// and SPANNER_SYS, read from INFORMATION_SCHEMA itself.
|
||||
//
|
||||
// The package also verifies the GoogleSQL analyze cases against the same
|
||||
// server: each case's schema becomes a database of its own, its fixture is
|
||||
// written there, and each query is compiled in PLAN mode, which runs
|
||||
// nothing and reports the type of each result column and of each
|
||||
// parameter the query leaves undeclared, and the query plan, which says
|
||||
// which table column each result column and parameter stands for. 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 SPANNER_SERVER_URI, the gRPC endpoint of a
|
||||
// Spanner Omni server such as localhost:15000, which is reached without
|
||||
// TLS or credentials, as Omni is. Databases are created in the instance
|
||||
// Omni's single server provides, projects/default/instances/default.
|
||||
package spanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
database "cloud.google.com/go/spanner/admin/database/apiv1"
|
||||
"cloud.google.com/go/spanner/admin/database/apiv1/databasepb"
|
||||
instance "cloud.google.com/go/spanner/admin/instance/apiv1"
|
||||
"cloud.google.com/go/spanner/admin/instance/apiv1/instancepb"
|
||||
spannerapi "cloud.google.com/go/spanner/apiv1"
|
||||
"cloud.google.com/go/spanner/apiv1/spannerpb"
|
||||
"google.golang.org/api/option"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/dialect"
|
||||
)
|
||||
|
||||
// Engine is the name goldeneye knows the database by.
|
||||
const Engine = "spanner"
|
||||
|
||||
// Dir is the name of the engine directory the dialect lives under, and
|
||||
// Cases the name of the analyze case directories under
|
||||
// internal/endtoend/testdata: both are named after the language, GoogleSQL,
|
||||
// which is how sqlc knows the dialect.
|
||||
const (
|
||||
Dir = "googlesql"
|
||||
Cases = "googlesql"
|
||||
)
|
||||
|
||||
// Instance is the instance a Spanner Omni single server provides, which
|
||||
// every database is created in.
|
||||
const Instance = "projects/default/instances/default"
|
||||
|
||||
// Locate returns the server to generate from, named by SPANNER_SERVER_URI.
|
||||
func Locate() (string, error) {
|
||||
endpoint := os.Getenv("SPANNER_SERVER_URI")
|
||||
if endpoint == "" {
|
||||
return "", errors.New("SPANNER_SERVER_URI is not set")
|
||||
}
|
||||
if strings.Contains(endpoint, "://") {
|
||||
return "", fmt.Errorf("SPANNER_SERVER_URI: %q should be a host:port, the gRPC endpoint of a Spanner Omni server", endpoint)
|
||||
}
|
||||
return endpoint, nil
|
||||
}
|
||||
|
||||
// server holds the clients a run talks to the server through.
|
||||
type server struct {
|
||||
endpoint string
|
||||
instances *instance.InstanceAdminClient
|
||||
databases *database.DatabaseAdminClient
|
||||
data *spannerapi.Client
|
||||
}
|
||||
|
||||
// open connects to the server. Omni serves plaintext gRPC and asks for no
|
||||
// credentials.
|
||||
func open(ctx context.Context, endpoint string) (*server, error) {
|
||||
opts := []option.ClientOption{
|
||||
option.WithEndpoint(endpoint),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
|
||||
}
|
||||
s := &server{endpoint: endpoint}
|
||||
var err error
|
||||
if s.instances, err = instance.NewInstanceAdminClient(ctx, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.databases, err = database.NewDatabaseAdminClient(ctx, opts...); err != nil {
|
||||
s.Close()
|
||||
return nil, err
|
||||
}
|
||||
if s.data, err = spannerapi.NewClient(ctx, opts...); err != nil {
|
||||
s.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *server) Close() {
|
||||
if s.instances != nil {
|
||||
s.instances.Close()
|
||||
}
|
||||
if s.databases != nil {
|
||||
s.databases.Close()
|
||||
}
|
||||
if s.data != nil {
|
||||
s.data.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Version describes the server: Spanner Omni reports no release of its
|
||||
// own through the API, so the instance the databases are created in is
|
||||
// described instead.
|
||||
func Version(ctx context.Context, endpoint string) (string, error) {
|
||||
s, err := open(ctx, endpoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer s.Close()
|
||||
inst, err := s.instances.GetInstance(ctx, &instancepb.GetInstanceRequest{Name: Instance})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %w", Instance, err)
|
||||
}
|
||||
return fmt.Sprintf("Spanner Omni at %s, instance %s (%s)", endpoint, inst.Name, inst.State), nil
|
||||
}
|
||||
|
||||
// createDatabase creates a database in the instance, running the DDL
|
||||
// statements in it, and returns its full name.
|
||||
func (s *server) createDatabase(ctx context.Context, name string, ddl []string) (string, error) {
|
||||
op, err := s.databases.CreateDatabase(ctx, &databasepb.CreateDatabaseRequest{
|
||||
Parent: Instance,
|
||||
CreateStatement: "CREATE DATABASE `" + name + "`",
|
||||
ExtraStatements: ddl,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
db, err := op.Wait(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return db.Name, nil
|
||||
}
|
||||
|
||||
// dropDatabase drops a database by its full name. A database that does
|
||||
// not exist is not an error, so that a run can clear the way for itself.
|
||||
func (s *server) dropDatabase(ctx context.Context, db string) error {
|
||||
err := s.databases.DropDatabase(ctx, &databasepb.DropDatabaseRequest{Database: db})
|
||||
if status.Code(err) == codes.NotFound && strings.Contains(err.Error(), "Database") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// session opens a session on a database, multiplexed as Omni requires.
|
||||
func (s *server) session(ctx context.Context, db string) (string, error) {
|
||||
sess, err := s.data.CreateSession(ctx, &spannerpb.CreateSessionRequest{
|
||||
Database: db,
|
||||
Session: &spannerpb.Session{Multiplexed: true},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sess.Name, nil
|
||||
}
|
||||
|
||||
// query runs a statement and returns its rows as lists of values.
|
||||
func (s *server) query(ctx context.Context, session, sql string) ([][]*structpb.Value, error) {
|
||||
rs, err := s.data.ExecuteSql(ctx, &spannerpb.ExecuteSqlRequest{Session: session, Sql: sql})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows [][]*structpb.Value
|
||||
for _, row := range rs.Rows {
|
||||
rows = append(rows, row.Values)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Generate reads the dialect from the server: relations.jsonl, the views of
|
||||
// INFORMATION_SCHEMA and SPANNER_SYS, read from a database of their own,
|
||||
// since every database has the same ones.
|
||||
func Generate(ctx context.Context, endpoint string) (dialect.Files, error) {
|
||||
s, err := open(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.Close()
|
||||
db := Instance + "/databases/goldeneye_dialect"
|
||||
if err := s.dropDatabase(ctx, db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.createDatabase(ctx, "goldeneye_dialect", nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.dropDatabase(context.WithoutCancel(ctx), db)
|
||||
session, err := s.session(ctx, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
relations, err := readRelations(ctx, s, session)
|
||||
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/spanner/spanner_test.go
Normal file
66
internal/goldeneye/spanner/spanner_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package spanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/dialect"
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/endtoend"
|
||||
)
|
||||
|
||||
// TestDialect verifies the committed GoogleSQL dialect against what a
|
||||
// Spanner server reports. It skips unless SPANNER_SERVER_URI names one.
|
||||
func TestDialect(t *testing.T) {
|
||||
endpoint, err := Locate()
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
version, err := Version(ctx, endpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := Generate(ctx, endpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir, err := dialect.Dir(Dir)
|
||||
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 GoogleSQL analyze case under
|
||||
// internal/endtoend/testdata against what a Spanner server reports. It
|
||||
// skips unless SPANNER_SERVER_URI names one.
|
||||
func TestAnalyzeCases(t *testing.T) {
|
||||
endpoint, err := Locate()
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
cases, err := endtoend.Cases(Cases)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cases) == 0 {
|
||||
t.Fatal("no googlesql analyze cases found")
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
diff, err := Check(context.Background(), endpoint, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diff != "" {
|
||||
t.Errorf("%s does not match what Spanner reports (-committed +spanner):\n%s", c.Output, diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user