Files
upstream-sqlc/internal/goldeneye/duckdb/analyze.go
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

847 lines
24 KiB
Go

package duckdb
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"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 the DuckDB CLI, which runs each
// case's schema and fixture into an in-memory database of their own, one
// process per question, and is asked four things about each query. What
// its parameters are: the query is prepared and explained, as JSON, with
// a string sentinel bound to each parameter, and the unoptimized logical
// plan the CLI prints first shows each as CAST('goldeneye_k' AS T), T
// being the type the binder gave the parameter, or bare when that type
// is VARCHAR. What its result columns are:
// DESCRIBE, with each parameter replaced by a NULL of that type, names and
// types them. Which column each is read from and which column a parameter
// stands in for: DuckDB prints a plan with every column by its bare name,
// so these are read from the query text, the select list's items and the
// operand beside each parameter resolved against the FROM clause and the
// catalog, from which a column read from a table takes its declared type
// and nullability. And whether an expression can be NULL, which DuckDB
// does not track: the query is run, with each parameter bound to a value
// of its type, over the fixture and over no rows, and a column is nullable
// when either run returns a NULL for it.
// placeholder is one parameter of a query as sqlc numbers them: a $n by
// its number, and each $name, sqlc.arg name or ? in turn at its first
// appearance, taking the lowest number no $n took.
type placeholder struct {
Number int
Name string
}
var (
numberedRe = regexp.MustCompile(`^\$([0-9]+)`)
namedRe = regexp.MustCompile(`^\$([A-Za-z_][A-Za-z0-9_]*)`)
sqlcArgRe = regexp.MustCompile(`^sqlc\.(n?arg|slice)\(\s*'?([A-Za-z_][A-Za-z0-9_]*)'?\s*\)`)
)
// bind rewrites the query so that every parameter is a $k numbered as
// sqlc numbers it, and lists the parameters in that order.
func bind(query string) (string, []placeholder) {
type occurrence struct {
start, end int
number int // for $n
name string // for $name or sqlc.arg, "" for ?
}
var occ []occurrence
i := 0
for i < len(query) {
c := query[i]
switch {
case c == '\'' || c == '"':
i = quotedEnd(query, i)
case strings.HasPrefix(query[i:], "--"):
end := strings.IndexByte(query[i:], '\n')
if end < 0 {
i = len(query)
} else {
i += end
}
case strings.HasPrefix(query[i:], "/*"):
end := strings.Index(query[i:], "*/")
if end < 0 {
i = len(query)
} else {
i += end + 2
}
case c == '?':
occ = append(occ, occurrence{start: i, end: i + 1})
i++
case c == '$' && numberedRe.MatchString(query[i:]):
m := numberedRe.FindStringSubmatch(query[i:])
n, _ := strconv.Atoi(m[1])
occ = append(occ, occurrence{start: i, end: i + len(m[0]), number: n})
i += len(m[0])
case c == '$' && namedRe.MatchString(query[i:]):
m := namedRe.FindStringSubmatch(query[i:])
occ = append(occ, occurrence{start: i, end: i + len(m[0]), name: m[1]})
i += len(m[0])
case c == 's' && sqlcArgRe.MatchString(query[i:]):
m := sqlcArgRe.FindStringSubmatch(query[i:])
occ = append(occ, occurrence{start: i, end: i + len(m[0]), name: m[2]})
i += len(m[0])
default:
i++
}
}
taken := map[int]bool{}
for _, o := range occ {
if o.number > 0 {
taken[o.number] = true
}
}
byName := map[string]int{}
next := 1
assign := func(o occurrence) int {
if o.number > 0 {
return o.number
}
if n, ok := byName[o.name]; ok && o.name != "" {
return n
}
for taken[next] {
next++
}
n := next
taken[n] = true
if o.name != "" {
byName[o.name] = n
}
return n
}
var phs []placeholder
numbered := map[int]bool{}
var out strings.Builder
last := 0
for _, o := range occ {
n := assign(o)
if !numbered[n] {
numbered[n] = true
phs = append(phs, placeholder{Number: n, Name: o.name})
}
out.WriteString(query[last:o.start])
out.WriteString("$" + strconv.Itoa(n))
last = o.end
}
out.WriteString(query[last:])
for i := 1; i < len(phs); i++ {
for j := i; j > 0 && phs[j-1].Number > phs[j].Number; j-- {
phs[j-1], phs[j] = phs[j], phs[j-1]
}
}
return out.String(), phs
}
// nullMarker is what a NULL is printed as when the CLI prints CSV, unlike
// any value a case holds.
const nullMarker = "<goldeneye:null>"
// analyzer holds the CLI a case runs through, the case's schema and
// fixture, and the catalog of the schema.
type analyzer struct {
binary string
schema string // the schema's statements, each ending in ;
fixture string // the fixture's, or ""
tables map[string][]column
enums map[string][]string // the labels of each enum type the schema created
// canonical names the type the dialect reports a spelling as, for
// each alias types.jsonl lists: DuckDB spells a JSON column json,
// which its own catalog lists as a spelling of varchar.
canonical map[string]string
}
// readAliases reads the aliases the generated 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
}
// run executes a script in a fresh in-memory database loaded with the
// schema and, unless told otherwise, the fixture, and returns what the
// CLI printed in the mode asked for: "json" prints one JSON array per
// statement that returns rows, "csv" prints rows with a header and NULL
// as nullMarker, and "" prints the CLI's own text, which is how EXPLAIN
// draws a plan. A statement that fails ends the script with DuckDB's own
// error message.
func (a *analyzer) run(ctx context.Context, script, mode string, withFixture bool) (string, error) {
args := []string{"-bail"}
switch mode {
case "json":
args = append(args, "-json")
case "csv":
args = append(args, "-csv", "-header", "-nullvalue", nullMarker)
}
args = append(args, ":memory:")
cmd := exec.CommandContext(ctx, a.binary, args...)
prelude := a.schema
if withFixture {
prelude += a.fixture
}
cmd.Stdin = strings.NewReader(prelude + script)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return "", errors.New(msg)
}
return stdout.String(), nil
}
// query runs one statement and decodes the rows it printed.
func (a *analyzer) query(ctx context.Context, statement string) ([]map[string]json.RawMessage, error) {
out, err := a.run(ctx, statement+";\n", "json", true)
if err != nil {
return nil, err
}
var rows []map[string]json.RawMessage
dec := json.NewDecoder(strings.NewReader(out))
for {
var rs []map[string]json.RawMessage
err := dec.Decode(&rs)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, fmt.Errorf("decoding duckdb output: %w", err)
}
rows = append(rows, rs...)
}
return rows, nil
}
func str(v json.RawMessage) string {
var s string
json.Unmarshal(v, &s)
return s
}
// column is what the catalog says about a column of a table.
type column struct {
name string
spelling string // the type as DuckDB spells it
typ *analysis.TypeExpr
nullable bool
}
// statements joins a script's statements, each ending in a semicolon.
func statements(src string) string {
s := strings.TrimRight(strings.TrimSpace(src), ";")
if s == "" {
return ""
}
return s + ";\n"
}
// Analyze runs a case's queries through the CLI and returns what DuckDB
// reports in the JSON shape sqlc analyze prints.
func Analyze(ctx context.Context, binary string, c endtoend.Case) ([]byte, error) {
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 := &analyzer{binary: binary, schema: statements(string(schema)), fixture: statements(string(fixture))}
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)
}
// Check compares what DuckDB reports for a case with the output the case
// committed, returning a diff when they differ.
func Check(ctx context.Context, binary string, c endtoend.Case) (string, error) {
got, err := Analyze(ctx, binary, c)
if err != nil {
return "", err
}
return c.Compare(got)
}
// readCatalog reads the tables and enum types the schema created.
func (a *analyzer) readCatalog(ctx context.Context) error {
rows, err := a.query(ctx, `SELECT type_name, labels FROM duckdb_types() WHERE database_name = 'memory' AND NOT internal AND logical_type = 'ENUM' ORDER BY type_name`)
if err != nil {
return err
}
a.enums = map[string][]string{}
for _, row := range rows {
var labels []string
json.Unmarshal(row["labels"], &labels)
a.enums[strings.ToLower(str(row["type_name"]))] = labels
}
rows, err = a.query(ctx, `SELECT table_name, column_name, data_type, is_nullable FROM duckdb_columns() WHERE database_name = 'memory' ORDER BY table_oid, column_index`)
if err != nil {
return err
}
a.tables = map[string][]column{}
for _, row := range rows {
table := strings.ToLower(str(row["table_name"]))
var nullable bool
json.Unmarshal(row["is_nullable"], &nullable)
spelling := str(row["data_type"])
a.tables[table] = append(a.tables[table], column{
name: str(row["column_name"]),
spelling: spelling,
typ: a.parseType(spelling),
nullable: nullable,
})
}
return nil
}
// lookup finds a column of a table, which DuckDB matches in any case.
func (a *analyzer) lookup(table, name string) (column, bool) {
for _, col := range a.tables[strings.ToLower(table)] {
if strings.EqualFold(col.name, name) {
return col, true
}
}
return column{}, false
}
// resolve finds the table a reference names in a scope: the table its
// qualifier aliases, or the one table of the scope that has the column.
func (a *analyzer) resolve(sc scope, r ref) (string, column, bool) {
tables := sc.tables
if sc.target.name != "" {
tables = append([]tableRef{sc.target}, tables...)
}
if r.qualifier != "" {
if sc.ctes[r.qualifier] {
return "", column{}, false
}
for _, t := range tables {
if t.alias == r.qualifier || (t.alias == "" && t.name == r.qualifier) {
col, ok := a.lookup(t.name, r.column)
return t.name, col, ok
}
}
return "", column{}, false
}
var found string
var fc column
for _, t := range tables {
if col, ok := a.lookup(t.name, r.column); ok {
if found != "" && found != t.name {
return "", column{}, false
}
found, fc = t.name, col
}
}
return found, fc, found != ""
}
// describe reads what the catalog says about a column read from a table.
func describe(table string, col column) analysis.Column {
return analysis.Column{Name: col.name, Type: withNullable(col.typ, col.nullable), Table: table}
}
func withNullable(t *analysis.TypeExpr, nullable bool) *analysis.TypeExpr {
if t == nil {
return nil
}
out := *t
out.Nullable = nullable
return &out
}
// binding is what is known about one parameter: the type the binder gave
// it, spelled as DuckDB spells it, and the column it stands in for.
type binding struct {
spelling string
typ *analysis.TypeExpr
column *analysis.Column
}
var (
sentinelCastRe = regexp.MustCompile(`CAST\('goldeneye_([0-9]+)' AS `)
sentinelRe = regexp.MustCompile(`'goldeneye_([0-9]+)'`)
sentinelErrRe = regexp.MustCompile(`goldeneye_([0-9]+)`)
)
// explain prepares the query, explains it with a sentinel bound to each
// parameter and returns the type the binder gave each, keyed by number.
// A parameter whose sentinel the binder converts on the spot, as an
// INSERT's VALUES are, is bound to NULL instead and reported by nothing:
// the error names the sentinel for most types, and for the rest the
// parameters not yet bound to NULL are tried in turn.
func (a *analyzer) explain(ctx context.Context, sql string, phs []placeholder) (map[int]string, error) {
null := map[int]bool{}
for attempt := 0; attempt <= len(phs); attempt++ {
args := make([]string, len(phs))
for i, ph := range phs {
if null[ph.Number] {
args[i] = "NULL"
} else {
args[i] = fmt.Sprintf("'goldeneye_%d'", ph.Number)
}
}
script := "PREPARE goldeneye AS " + sql + ";\nSET explain_output = 'all';\nEXPLAIN (FORMAT json) EXECUTE goldeneye(" + strings.Join(args, ", ") + ");\n"
out, err := a.run(ctx, script, "", true)
if err != nil {
if !strings.Contains(err.Error(), "Conversion Error") && !strings.Contains(err.Error(), "can't be cast") {
return nil, err
}
retry := false
if m := sentinelErrRe.FindStringSubmatch(err.Error()); m != nil {
n, _ := strconv.Atoi(m[1])
if !null[n] {
null[n] = true
retry = true
}
}
if !retry {
for _, ph := range phs {
if !null[ph.Number] {
null[ph.Number] = true
retry = true
break
}
}
}
if !retry {
return nil, err
}
continue
}
return sentinelTypes(out)
}
return nil, errors.New("could not bind the parameters")
}
// planNode is one operator of a plan the CLI prints as JSON. Its
// extra_info holds the operator's expressions, each a string or a list
// of strings.
type planNode struct {
Name string `json:"name"`
Children []planNode `json:"children"`
ExtraInfo map[string]json.RawMessage `json:"extra_info"`
}
// sentinelTypes reads the type each sentinel is cast to out of the plans
// the CLI printed, one JSON array per plan: the unoptimized logical plan
// comes first, and the first cast of a sentinel wins. A sentinel the
// plan prints bare is a VARCHAR, which the binder needs no cast for.
func sentinelTypes(out string) (map[int]string, error) {
var texts []string
var walk func(n planNode)
walk = func(n planNode) {
for _, raw := range n.ExtraInfo {
var one string
if json.Unmarshal(raw, &one) == nil {
texts = append(texts, one)
continue
}
var many []string
if json.Unmarshal(raw, &many) == nil {
texts = append(texts, many...)
}
}
for _, c := range n.Children {
walk(c)
}
}
dec := json.NewDecoder(strings.NewReader(out))
for {
var plan []planNode
err := dec.Decode(&plan)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, fmt.Errorf("decoding the plan: %w", err)
}
for _, n := range plan {
walk(n)
}
}
types := map[int]string{}
for _, text := range texts {
for _, m := range sentinelCastRe.FindAllStringSubmatchIndex(text, -1) {
n, _ := strconv.Atoi(text[m[2]:m[3]])
if _, ok := types[n]; ok {
continue
}
// The type runs to the parenthesis closing the CAST.
depth := 1
i := m[1]
for ; i < len(text); i++ {
if text[i] == '(' {
depth++
} else if text[i] == ')' {
depth--
if depth == 0 {
break
}
}
}
types[n] = strings.Join(strings.Fields(text[m[1]:i]), " ")
}
}
for _, text := range texts {
for _, m := range sentinelRe.FindAllStringSubmatchIndex(text, -1) {
n, _ := strconv.Atoi(text[m[2]:m[3]])
if _, ok := types[n]; !ok && !strings.HasSuffix(text[:m[0]], "CAST(") {
types[n] = "VARCHAR"
}
}
}
return types, nil
}
// 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{},
}
t := text(tokenize(sql))
sc := t.readScope()
// Parameters: a partner column first, then the cast the query wraps
// the parameter in, then the type the binder gave it.
bindings := map[int]*binding{}
var casts []string
var castNumbers []int
for _, ph := range phs {
k := strconv.Itoa(ph.Number)
b := &binding{}
bindings[ph.Number] = b
if t.inSubquery(k) {
// A parameter inside a subquery is compared with a column of
// the subquery's own tables, which the statement's scope does
// not name.
if typ, ok := t.castOf(k); ok {
casts = append(casts, typ)
castNumbers = append(castNumbers, ph.Number)
}
continue
}
if sc.kind == "insert" {
if pos, ok := t.valuesPosition(k); ok {
cols := t.insertColumns()
if cols == nil {
for _, col := range a.tables[sc.target.name] {
cols = append(cols, col.name)
}
}
if pos < len(cols) {
if col, ok := a.lookup(sc.target.name, cols[pos]); ok {
c := describe(sc.target.name, col)
b.column, b.spelling, b.typ = &c, col.spelling, col.typ
continue
}
}
}
}
if r, ok := t.partner(k); ok {
if table, col, ok := a.resolve(sc, r); ok {
c := describe(table, col)
b.column, b.spelling, b.typ = &c, col.spelling, col.typ
continue
}
}
if typ, ok := t.castOf(k); ok {
casts = append(casts, typ)
castNumbers = append(castNumbers, ph.Number)
}
}
if len(casts) > 0 {
// DuckDB spells the cast's type: VARCHAR(5) is VARCHAR, mood is
// ENUM('sad', 'ok').
var items []string
for i, typ := range casts {
items = append(items, fmt.Sprintf("CAST(NULL AS %s) AS p%d", typ, i))
}
rows, err := a.query(ctx, "DESCRIBE SELECT "+strings.Join(items, ", "))
if err != nil {
return analysis.Query{}, err
}
for i, row := range rows {
if i < len(castNumbers) {
b := bindings[castNumbers[i]]
b.spelling = str(row["column_type"])
b.typ = a.parseType(b.spelling)
}
}
}
if len(phs) > 0 {
types, err := a.explain(ctx, sql, phs)
if err != nil {
return analysis.Query{}, err
}
for n, spelling := range types {
if b := bindings[n]; b != nil && b.typ == nil {
b.spelling = spelling
b.typ = a.parseType(spelling)
}
}
}
// Result columns.
var columns []analysis.Column
switch sc.kind {
case "select":
rows, err := a.query(ctx, "DESCRIBE "+a.substitute(sql, phs, bindings, false))
if err != nil {
return analysis.Query{}, err
}
for _, row := range rows {
columns = append(columns, analysis.Column{Name: str(row["column_name"]), Type: a.parseType(str(row["column_type"]))})
}
a.attribute(sc, t.selectItems(), columns)
default:
// A RETURNING column is the target table's column it names;
// DuckDB describes no DML, so an expression is typed by nothing.
for _, it := range t.returningItems() {
switch {
case it.star:
for _, col := range a.tables[sc.target.name] {
columns = append(columns, describe(sc.target.name, col))
}
case it.ref != nil:
if table, col, ok := a.resolve(sc, *it.ref); ok {
c := describe(table, col)
c.Name = it.name
columns = append(columns, c)
continue
}
columns = append(columns, analysis.Column{Name: it.name})
default:
columns = append(columns, analysis.Column{Name: it.name})
}
}
}
if len(columns) > 0 {
nullable := a.observe(ctx, sql, phs, bindings, len(columns))
for i := range columns {
if columns[i].Table == "" && columns[i].Type != nil && nullable[i] {
columns[i].Type = withNullable(columns[i].Type, true)
}
}
}
aq.Columns = append(aq.Columns, columns...)
for _, ph := range phs {
b := bindings[ph.Number]
ac := analysis.Column{Type: b.typ}
if b.column != nil {
ac = *b.column
}
aq.Params = append(aq.Params, analysis.Param{Number: ph.Number, Column: ac})
}
return aq, nil
}
// attribute says which table each result column is read from, by
// matching the select list's items, a star expanded to its table's
// columns, against the columns DuckDB described. A list that expands to
// another number of columns than DuckDB describes attributes nothing.
func (a *analyzer) attribute(sc scope, items []item, columns []analysis.Column) {
var origins []*analysis.Column
for _, it := range items {
switch {
case it.star && it.ref == nil:
for _, t := range sc.tables {
for _, col := range a.tables[t.name] {
c := describe(t.name, col)
origins = append(origins, &c)
}
}
case it.star:
table, ok := a.aliased(sc, it.ref.qualifier)
if !ok {
return
}
for _, col := range a.tables[table] {
c := describe(table, col)
origins = append(origins, &c)
}
case it.ref != nil:
if table, col, ok := a.resolve(sc, *it.ref); ok {
c := describe(table, col)
origins = append(origins, &c)
} else {
origins = append(origins, nil)
}
default:
origins = append(origins, nil)
}
}
if len(origins) != len(columns) {
return
}
for i, o := range origins {
if o != nil {
columns[i].Type = o.Type
columns[i].Table = o.Table
}
}
}
// aliased finds the table a qualifier names in a scope.
func (a *analyzer) aliased(sc scope, qualifier string) (string, bool) {
for _, t := range sc.tables {
if t.alias == qualifier || (t.alias == "" && t.name == qualifier) {
if _, ok := a.tables[t.name]; ok {
return t.name, true
}
}
}
return "", false
}
// substitute replaces each parameter with a NULL of its type, or with a
// value of its type when values is set, for DuckDB to describe or run the
// query. A parameter of unknown type becomes a bare NULL. Strings,
// quoted identifiers and comments are left alone.
func (a *analyzer) substitute(sql string, phs []placeholder, bindings map[int]*binding, values bool) string {
repl := map[int]string{}
for _, ph := range phs {
r := "NULL"
if b := bindings[ph.Number]; b != nil && b.spelling != "" {
r = "CAST(NULL AS " + b.spelling + ")"
if values {
// The value is chosen by the type as spelled, since an
// alias such as JSON takes values its canonical type
// does not.
if v, ok := a.zero(a.parseTypeWith(b.spelling, false)); ok {
r = "CAST('" + strings.ReplaceAll(v, "'", "''") + "' AS " + b.spelling + ")"
}
}
}
repl[ph.Number] = r
}
var out strings.Builder
i := 0
for i < len(sql) {
c := sql[i]
switch {
case c == '\'' || c == '"':
end := quotedEnd(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 == '$' && numberedRe.MatchString(sql[i:]):
m := numberedRe.FindStringSubmatch(sql[i:])
n, _ := strconv.Atoi(m[1])
if r, ok := repl[n]; ok {
out.WriteString(r)
} else {
out.WriteString(m[0])
}
i += len(m[0])
default:
out.WriteByte(c)
i++
}
}
return out.String()
}
// observe runs the query with a value bound to each parameter, over the
// fixture and over no rows, and reports which of the first n result
// columns came back NULL in either run. A run the CLI rejects observes
// nothing.
func (a *analyzer) observe(ctx context.Context, sql string, phs []placeholder, bindings map[int]*binding, n int) []bool {
nullable := make([]bool, n)
statement := a.substitute(sql, phs, bindings, true) + ";\n"
runs := []bool{true}
if a.fixture != "" {
runs = append(runs, false)
}
for _, withFixture := range runs {
out, err := a.run(ctx, statement, "csv", withFixture)
if err != nil {
continue
}
records, err := csv.NewReader(strings.NewReader(out)).ReadAll()
if err != nil {
continue
}
for _, rec := range records[min(1, len(records)):] {
for j, v := range rec {
if j < n && v == nullMarker {
nullable[j] = true
}
}
}
}
return nullable
}