Skip to content

Commit 079ffda

Browse files
committed
goldeneye: fix what an adversarial review of the new engines found
SQL Server: a schema is split into statements outside BEGIN ... END and CASE ... END blocks, so a trigger or procedure body keeps its semicolons, and a GO on the first line is honoured; each ? becomes a parameter of its own rather than every ? the same one; the catalog's columns are matched to the described ones by name rather than position; Analyze refuses a server of another major release the way Generate does; the views are ordered by the bytes of their names rather than the server's collation; the plan walk's cycle guard is scoped to the path; and the generator's comment no longer claims a canonicalization it does not do. Spanner: Close no longer dereferences a nil client when opening fails half way; a DML statement is recognised past a leading comment; a DML plan's outputs are read as the THEN RETURN columns followed by the written values, which is the order Omni prints, and a returned expression is no longer taken for the column it is named after; an UPDATE's SET list is read with quotes and parentheses honoured up to the last top-level WHERE; a case's database is named with a hash of the case, so two cases with the same head stay apart; NotFound is checked by status code; a failed fixture write is rolled back; @@variables are not parameters; and PROTO and ENUM types are spelled one way. DuckDB: sentinel types are read from EXPLAIN (FORMAT json), whose expressions are strings, rather than from the box the CLI draws, and a sentinel the plan prints bare is a VARCHAR; a conversion error that names a sentinel in any form, or none, rebinds a parameter to NULL; the type after a :: or CAST stops at anything but a multi-word type's words; a CTE that shares a table's name is not the table; a parameter inside a subquery takes the binder's type rather than a partner from the outer scope; parameters are replaced token-wise, leaving string literals alone; the value a parameter is bound to is chosen by its type as spelled, so a JSON parameter gets a JSON value; and two enums with the same labels resolve to the same name every run. The MSSQL converter also treats a schema-qualified sys.sysname as NOT NULL by default. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F7cPsawATXMfiYqWg8nBVb
1 parent 2d75b8c commit 079ffda

12 files changed

Lines changed: 480 additions & 137 deletions

File tree

internal/engine/mssql/convert.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1071,7 +1071,7 @@ func (c *cc) convertColumnDefinition(n *tsql.ColumnDefinition, tablePrimaryKey m
10711071
colDef.IsNotNull = true
10721072
}
10731073
if n.Nullable == nil {
1074-
switch colDef.TypeName.Name {
1074+
switch strings.TrimPrefix(colDef.TypeName.Name, "sys.") {
10751075
case "rowversion", "timestamp", "sysname":
10761076
colDef.IsNotNull = true
10771077
}

internal/goldeneye/README.md

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ the tests compare it with what is committed, byte for byte. A difference
1010
means the committed dialect has drifted from the database.
1111

1212
It is a nested Go module, so its only dependencies beyond the standard
13-
library are the database drivers — PostgreSQL's, MySQL's, SQL Server's and
14-
the Spanner client — and it never shares code with the analysis that reads
15-
the files: the files are the contract. Run it from this directory:
13+
library are the database clients — PostgreSQL's, MySQL's and SQL Server's
14+
drivers, and Spanner's gRPC client with what it stands on — and it never
15+
shares code with the analysis that reads the files: the files are the
16+
contract. Run it from this directory:
1617

1718
```bash
1819
go run ./cmd/goldeneye install clickhouse # download the pinned clickhouse binary once
@@ -115,8 +116,10 @@ the hand-written files alone, and the checks do not look at them.
115116
`INFORMATION_SCHEMA` and `SPANNER_SYS`, read from `INFORMATION_SCHEMA`
116117
itself in a database created for the purpose in the instance Omni's
117118
single server provides, `projects/default/instances/default`. Names are
118-
kept as the catalog spells them, in upper case, which is how a query
119-
names them; a column's type is spelled in lower case the way the seed
119+
kept as the catalog spells them, in upper case: Spanner matches a name
120+
in any case, but sqlc's GoogleSQL engine matches one as it is spelled,
121+
so a query reaches these views by their upper-case names until the
122+
engine folds case; a column's type is spelled in lower case the way the seed
120123
spells one, an `ARRAY<T>` as `T` with the array flag, a `STRUCT<a T>` as
121124
`struct(a: t)` and a `PROTO<p.M>` as `proto('p.M')`, since a seed writes
122125
a type's arguments in parentheses. The container image is pinned in the
@@ -246,13 +249,13 @@ asks for `--ast` is skipped, since only sqlc can print that.
246249
- **`duckdb`** runs each case through the CLI, which loads the schema and
247250
fixture into an in-memory database of their own, one process per
248251
question, and is asked four things about each query. What its
249-
parameters are: the query is prepared and explained with a string
250-
sentinel bound to each parameter, `EXECUTE q('goldeneye_1', ...)`, and
251-
the unoptimized logical plan the CLI prints under
252+
parameters are: the query is prepared and explained as JSON with a
253+
string sentinel bound to each parameter, `EXECUTE q('goldeneye_1',
254+
...)`, and the unoptimized logical plan the CLI prints under
252255
`explain_output = 'all'` shows each as `CAST('goldeneye_k' AS T)`, `T`
253-
being the type the binder gave the parameter; a sentinel the binder
254-
converts on the spot, as an `INSERT`'s `VALUES` are, is bound to NULL
255-
instead. What its result columns are: `DESCRIBE`, with each parameter
256+
being the type the binder gave the parameter, or bare when that type is
257+
`VARCHAR`; a sentinel the binder converts on the spot, as an `INSERT`'s
258+
`VALUES` are, is bound to NULL instead. What its result columns are: `DESCRIBE`, with each parameter
256259
replaced by a NULL of its type, names and types them; DuckDB describes
257260
no DML, so a `RETURNING` column is the target table's column it names.
258261
Which table a result column is read from and which column a parameter
@@ -262,7 +265,9 @@ asks for `--ast` is skipped, since only sqlc can print that.
262265
columns, and the operand beside each parameter, resolved against the
263266
`FROM` clause and the catalog, `duckdb_columns()`, from which a column
264267
read from a table takes its declared type and nullability; a parameter
265-
the query casts takes the cast's type as DuckDB spells it. And whether
268+
the query casts takes the cast's type as DuckDB spells it, and one
269+
inside a subquery, whose tables the statement's scope does not name,
270+
takes the binder's. And whether
266271
an expression can be NULL, which DuckDB does not track: the query is
267272
run, with each parameter bound to a value of its type, over the fixture
268273
and over no rows, and a column is nullable when either run returns a

internal/goldeneye/duckdb/analyze.go

Lines changed: 167 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ import (
2222
// The analyze cases are checked against the DuckDB CLI, which runs each
2323
// case's schema and fixture into an in-memory database of their own, one
2424
// process per question, and is asked four things about each query. What
25-
// its parameters are: the query is prepared and explained with a string
26-
// sentinel bound to each parameter, and the unoptimized logical plan the
27-
// CLI prints first shows each as CAST('goldeneye_k' AS T), T being the
28-
// type the binder gave the parameter. What its result columns are:
25+
// its parameters are: the query is prepared and explained, as JSON, with
26+
// a string sentinel bound to each parameter, and the unoptimized logical
27+
// plan the CLI prints first shows each as CAST('goldeneye_k' AS T), T
28+
// being the type the binder gave the parameter, or bare when that type
29+
// is VARCHAR. What its result columns are:
2930
// DESCRIBE, with each parameter replaced by a NULL of that type, names and
3031
// types them. Which column each is read from and which column a parameter
3132
// stands in for: DuckDB prints a plan with every column by its bare name,
@@ -409,13 +410,16 @@ type binding struct {
409410

410411
var (
411412
sentinelCastRe = regexp.MustCompile(`CAST\('goldeneye_([0-9]+)' AS `)
412-
conversionRe = regexp.MustCompile(`Could not convert string 'goldeneye_([0-9]+)'`)
413+
sentinelRe = regexp.MustCompile(`'goldeneye_([0-9]+)'`)
414+
sentinelErrRe = regexp.MustCompile(`goldeneye_([0-9]+)`)
413415
)
414416

415417
// explain prepares the query, explains it with a sentinel bound to each
416418
// parameter and returns the type the binder gave each, keyed by number.
417419
// A parameter whose sentinel the binder converts on the spot, as an
418-
// INSERT's VALUES are, is bound to NULL instead and reported by nothing.
420+
// INSERT's VALUES are, is bound to NULL instead and reported by nothing:
421+
// the error names the sentinel for most types, and for the rest the
422+
// parameters not yet bound to NULL are tried in turn.
419423
func (a *analyzer) explain(ctx context.Context, sql string, phs []placeholder) (map[int]string, error) {
420424
null := map[int]bool{}
421425
for attempt := 0; attempt <= len(phs); attempt++ {
@@ -427,58 +431,117 @@ func (a *analyzer) explain(ctx context.Context, sql string, phs []placeholder) (
427431
args[i] = fmt.Sprintf("'goldeneye_%d'", ph.Number)
428432
}
429433
}
430-
script := "PREPARE goldeneye AS " + sql + ";\nSET explain_output = 'all';\nEXPLAIN EXECUTE goldeneye(" + strings.Join(args, ", ") + ");\n"
434+
script := "PREPARE goldeneye AS " + sql + ";\nSET explain_output = 'all';\nEXPLAIN (FORMAT json) EXECUTE goldeneye(" + strings.Join(args, ", ") + ");\n"
431435
out, err := a.run(ctx, script, "", true)
432436
if err != nil {
433-
if m := conversionRe.FindStringSubmatch(err.Error()); m != nil {
437+
if !strings.Contains(err.Error(), "Conversion Error") && !strings.Contains(err.Error(), "can't be cast") {
438+
return nil, err
439+
}
440+
retry := false
441+
if m := sentinelErrRe.FindStringSubmatch(err.Error()); m != nil {
434442
n, _ := strconv.Atoi(m[1])
435443
if !null[n] {
436444
null[n] = true
437-
continue
445+
retry = true
438446
}
439447
}
440-
return nil, err
448+
if !retry {
449+
for _, ph := range phs {
450+
if !null[ph.Number] {
451+
null[ph.Number] = true
452+
retry = true
453+
break
454+
}
455+
}
456+
}
457+
if !retry {
458+
return nil, err
459+
}
460+
continue
441461
}
442-
return sentinelTypes(out), nil
462+
return sentinelTypes(out)
443463
}
444464
return nil, errors.New("could not bind the parameters")
445465
}
446466

447-
// sentinelTypes reads the type each sentinel is cast to out of the plan
448-
// the CLI drew, which wraps long expressions across lines inside its
449-
// boxes.
450-
func sentinelTypes(plan string) map[int]string {
451-
var b strings.Builder
452-
for _, line := range strings.Split(plan, "\n") {
453-
if strings.ContainsAny(line, "╭╮╰╯─┬┴├") {
454-
continue
467+
// planNode is one operator of a plan the CLI prints as JSON. Its
468+
// extra_info holds the operator's expressions, each a string or a list
469+
// of strings.
470+
type planNode struct {
471+
Name string `json:"name"`
472+
Children []planNode `json:"children"`
473+
ExtraInfo map[string]json.RawMessage `json:"extra_info"`
474+
}
475+
476+
// sentinelTypes reads the type each sentinel is cast to out of the plans
477+
// the CLI printed, one JSON array per plan: the unoptimized logical plan
478+
// comes first, and the first cast of a sentinel wins. A sentinel the
479+
// plan prints bare is a VARCHAR, which the binder needs no cast for.
480+
func sentinelTypes(out string) (map[int]string, error) {
481+
var texts []string
482+
var walk func(n planNode)
483+
walk = func(n planNode) {
484+
for _, raw := range n.ExtraInfo {
485+
var one string
486+
if json.Unmarshal(raw, &one) == nil {
487+
texts = append(texts, one)
488+
continue
489+
}
490+
var many []string
491+
if json.Unmarshal(raw, &many) == nil {
492+
texts = append(texts, many...)
493+
}
494+
}
495+
for _, c := range n.Children {
496+
walk(c)
455497
}
456-
b.WriteString(strings.Trim(line, "│ "))
457-
b.WriteByte(' ')
458498
}
459-
flat := b.String()
460-
types := map[int]string{}
461-
for _, m := range sentinelCastRe.FindAllStringSubmatchIndex(flat, -1) {
462-
n, _ := strconv.Atoi(flat[m[2]:m[3]])
463-
if _, ok := types[n]; ok {
464-
continue
499+
dec := json.NewDecoder(strings.NewReader(out))
500+
for {
501+
var plan []planNode
502+
err := dec.Decode(&plan)
503+
if errors.Is(err, io.EOF) {
504+
break
465505
}
466-
// The type runs to the parenthesis closing the CAST.
467-
depth := 1
468-
i := m[1]
469-
for ; i < len(flat); i++ {
470-
if flat[i] == '(' {
471-
depth++
472-
} else if flat[i] == ')' {
473-
depth--
474-
if depth == 0 {
475-
break
506+
if err != nil {
507+
return nil, fmt.Errorf("decoding the plan: %w", err)
508+
}
509+
for _, n := range plan {
510+
walk(n)
511+
}
512+
}
513+
types := map[int]string{}
514+
for _, text := range texts {
515+
for _, m := range sentinelCastRe.FindAllStringSubmatchIndex(text, -1) {
516+
n, _ := strconv.Atoi(text[m[2]:m[3]])
517+
if _, ok := types[n]; ok {
518+
continue
519+
}
520+
// The type runs to the parenthesis closing the CAST.
521+
depth := 1
522+
i := m[1]
523+
for ; i < len(text); i++ {
524+
if text[i] == '(' {
525+
depth++
526+
} else if text[i] == ')' {
527+
depth--
528+
if depth == 0 {
529+
break
530+
}
476531
}
477532
}
533+
types[n] = strings.Join(strings.Fields(text[m[1]:i]), " ")
534+
}
535+
}
536+
for _, text := range texts {
537+
for _, m := range sentinelRe.FindAllStringSubmatchIndex(text, -1) {
538+
n, _ := strconv.Atoi(text[m[2]:m[3]])
539+
if _, ok := types[n]; !ok && !strings.HasSuffix(text[:m[0]], "CAST(") {
540+
types[n] = "VARCHAR"
541+
}
478542
}
479-
types[n] = strings.Join(strings.Fields(flat[m[1]:i]), " ")
480543
}
481-
return types
544+
return types, nil
482545
}
483546

484547
// analyzeQuery describes one query.
@@ -502,6 +565,16 @@ func (a *analyzer) analyzeQuery(ctx context.Context, q endtoend.Query) (analysis
502565
k := strconv.Itoa(ph.Number)
503566
b := &binding{}
504567
bindings[ph.Number] = b
568+
if t.inSubquery(k) {
569+
// A parameter inside a subquery is compared with a column of
570+
// the subquery's own tables, which the statement's scope does
571+
// not name.
572+
if typ, ok := t.castOf(k); ok {
573+
casts = append(casts, typ)
574+
castNumbers = append(castNumbers, ph.Number)
575+
}
576+
continue
577+
}
505578
if sc.kind == "insert" {
506579
if pos, ok := t.valuesPosition(k); ok {
507580
cols := t.insertColumns()
@@ -678,24 +751,67 @@ func (a *analyzer) aliased(sc scope, qualifier string) (string, bool) {
678751

679752
// substitute replaces each parameter with a NULL of its type, or with a
680753
// value of its type when values is set, for DuckDB to describe or run the
681-
// query. A parameter of unknown type becomes a bare NULL.
754+
// query. A parameter of unknown type becomes a bare NULL. Strings,
755+
// quoted identifiers and comments are left alone.
682756
func (a *analyzer) substitute(sql string, phs []placeholder, bindings map[int]*binding, values bool) string {
683-
out := sql
684-
for i := len(phs) - 1; i >= 0; i-- {
685-
ph := phs[i]
686-
b := bindings[ph.Number]
687-
repl := "NULL"
688-
if b != nil && b.spelling != "" {
689-
repl = "CAST(NULL AS " + b.spelling + ")"
757+
repl := map[int]string{}
758+
for _, ph := range phs {
759+
r := "NULL"
760+
if b := bindings[ph.Number]; b != nil && b.spelling != "" {
761+
r = "CAST(NULL AS " + b.spelling + ")"
690762
if values {
691-
if v, ok := a.zero(b.typ); ok {
692-
repl = "CAST('" + strings.ReplaceAll(v, "'", "''") + "' AS " + b.spelling + ")"
763+
// The value is chosen by the type as spelled, since an
764+
// alias such as JSON takes values its canonical type
765+
// does not.
766+
if v, ok := a.zero(a.parseTypeWith(b.spelling, false)); ok {
767+
r = "CAST('" + strings.ReplaceAll(v, "'", "''") + "' AS " + b.spelling + ")"
693768
}
694769
}
695770
}
696-
out = strings.ReplaceAll(out, "$"+strconv.Itoa(ph.Number), repl)
771+
repl[ph.Number] = r
772+
}
773+
var out strings.Builder
774+
i := 0
775+
for i < len(sql) {
776+
c := sql[i]
777+
switch {
778+
case c == '\'' || c == '"':
779+
end := quotedEnd(sql, i)
780+
out.WriteString(sql[i:end])
781+
i = end
782+
case strings.HasPrefix(sql[i:], "--"):
783+
end := strings.IndexByte(sql[i:], '\n')
784+
if end < 0 {
785+
end = len(sql)
786+
} else {
787+
end += i
788+
}
789+
out.WriteString(sql[i:end])
790+
i = end
791+
case strings.HasPrefix(sql[i:], "/*"):
792+
end := strings.Index(sql[i:], "*/")
793+
if end < 0 {
794+
end = len(sql)
795+
} else {
796+
end += i + 2
797+
}
798+
out.WriteString(sql[i:end])
799+
i = end
800+
case c == '$' && numberedRe.MatchString(sql[i:]):
801+
m := numberedRe.FindStringSubmatch(sql[i:])
802+
n, _ := strconv.Atoi(m[1])
803+
if r, ok := repl[n]; ok {
804+
out.WriteString(r)
805+
} else {
806+
out.WriteString(m[0])
807+
}
808+
i += len(m[0])
809+
default:
810+
out.WriteByte(c)
811+
i++
812+
}
697813
}
698-
return out
814+
return out.String()
699815
}
700816

701817
// observe runs the query with a value bound to each parameter, over the

0 commit comments

Comments
 (0)