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
270 lines
7.3 KiB
Go
270 lines
7.3 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/sqlc-dev/sqlc/internal/goldeneye/analysis"
|
|
"github.com/sqlc-dev/sqlc/internal/goldeneye/endtoend"
|
|
)
|
|
|
|
// The analyze cases are checked against the analysis shell, which is asked
|
|
// three things about each query. What the library reports about the
|
|
// prepared statement, through `.stats stmt`: each result column's name and,
|
|
// for one read straight from a table, its declared type and which table
|
|
// column it is, with the column's NOT NULL looked up in the catalog. What
|
|
// the query returns, over the case's fixture and again over no rows, for
|
|
// the columns the library has no answer for: an expression's type is the
|
|
// storage class of its value, since SQLite types values rather than
|
|
// expressions, and any column is nullable when a NULL comes back — an
|
|
// aggregate over no rows, the far side of an outer join. And the bytecode
|
|
// the statement compiles to, which is where the parameters are found,
|
|
// since the library reports nothing about them but their number.
|
|
|
|
// placeholder is one parameter of a query as sqlc numbers them: each ? in
|
|
// turn, and each sqlc.arg name once, at its first appearance. SQLite
|
|
// numbers ?NNN the same way, so the query is rewritten with those.
|
|
type placeholder struct {
|
|
Number int
|
|
Name string
|
|
}
|
|
|
|
func bind(sql string) (string, []placeholder) {
|
|
var phs []placeholder
|
|
numbers := map[string]int{}
|
|
out := endtoend.Rewrite(sql, func(name, _ string) string {
|
|
n, ok := numbers[name]
|
|
if name == "" || !ok {
|
|
n = len(phs) + 1
|
|
phs = append(phs, placeholder{Number: n, Name: name})
|
|
if name != "" {
|
|
numbers[name] = n
|
|
}
|
|
}
|
|
return fmt.Sprintf("?%d", n)
|
|
})
|
|
return out, phs
|
|
}
|
|
|
|
// Analyze runs a case's queries through the analysis shell and returns what
|
|
// SQLite reports in the JSON shape sqlc analyze prints.
|
|
func Analyze(ctx context.Context, dir string, c endtoend.Case) ([]byte, error) {
|
|
if err := checkOptions(ctx, dir, analysisShell); err != nil {
|
|
return nil, err
|
|
}
|
|
binary := analysisShell.binary(dir)
|
|
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
|
|
}
|
|
out := make([]analysis.Query, 0, len(queries))
|
|
for _, q := range queries {
|
|
aq, err := analyzeQuery(ctx, binary, string(schema), string(fixture), q)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", q.Name, err)
|
|
}
|
|
out = append(out, aq)
|
|
}
|
|
return analysis.Encode(out)
|
|
}
|
|
|
|
// Check compares what SQLite reports for a case with the output the case
|
|
// committed, returning a diff when they differ.
|
|
func Check(ctx context.Context, dir string, c endtoend.Case) (string, error) {
|
|
got, err := Analyze(ctx, dir, c)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return c.Compare(got)
|
|
}
|
|
|
|
func analyzeQuery(ctx context.Context, binary, schema, fixture string, q endtoend.Query) (analysis.Query, error) {
|
|
sql, phs := bind(q.SQL)
|
|
|
|
// What the library says: the catalog, the bytecode, and the statement
|
|
// prepared, with nothing bound to its parameters.
|
|
s := newScript()
|
|
s.sql(schema)
|
|
s.sql(fixture)
|
|
s.add(".mode json")
|
|
s.add(".explain off")
|
|
for _, cq := range catalogQueries {
|
|
s.section(cq.section)
|
|
s.sql(cq.sql)
|
|
}
|
|
s.section("explain")
|
|
s.sql("EXPLAIN " + sql)
|
|
s.section("query")
|
|
s.add(".stats stmt")
|
|
line := s.sql(sql)
|
|
s.section("end")
|
|
out, err := run(ctx, binary, s)
|
|
if err != nil {
|
|
return analysis.Query{}, err
|
|
}
|
|
if errs := out.errorsBefore(line); len(errs) > 0 {
|
|
return analysis.Query{}, errors.New(strings.Join(errs, "\n"))
|
|
}
|
|
query := out.sections["query"]
|
|
if query == nil || !query.prepared {
|
|
msg := strings.TrimSpace(out.stderr)
|
|
if msg == "" {
|
|
msg = "the statement was not prepared"
|
|
}
|
|
return analysis.Query{}, errors.New(msg)
|
|
}
|
|
cat, err := readCatalog(out)
|
|
if err != nil {
|
|
return analysis.Query{}, err
|
|
}
|
|
var prog []instr
|
|
if explain := out.sections["explain"]; explain != nil && len(explain.blocks) > 0 {
|
|
if err := explain.decode(0, &prog); err != nil {
|
|
return analysis.Query{}, fmt.Errorf("reading the bytecode: %w", err)
|
|
}
|
|
}
|
|
var names []string
|
|
for _, m := range query.columns {
|
|
names = append(names, m.Name)
|
|
}
|
|
t := newTracer(cat, names)
|
|
t.run(prog)
|
|
params := make([]analysis.Column, len(phs))
|
|
for i, ph := range phs {
|
|
params[i] = t.param(ph.Number)
|
|
}
|
|
|
|
// What the statement returns: over the fixture, with each parameter
|
|
// bound to a value of the column it stands in for, so that the rows
|
|
// come through the WHERE clause, and over no rows.
|
|
s = newScript()
|
|
s.sql(schema)
|
|
s.sql(fixture)
|
|
s.add(".parameter init")
|
|
for i, ph := range phs {
|
|
if v := sample(params[i]); v != "" {
|
|
s.sql(fmt.Sprintf("REPLACE INTO temp.sqlite_parameters(key, value) VALUES ('?%d', %s)", ph.Number, v))
|
|
}
|
|
}
|
|
s.add(".mode quote")
|
|
s.section("query")
|
|
s.sql(sql)
|
|
s.section("end")
|
|
bound, err := run(ctx, binary, s)
|
|
if err != nil {
|
|
return analysis.Query{}, err
|
|
}
|
|
s = newScript()
|
|
s.sql(schema)
|
|
s.add(".mode quote")
|
|
s.section("query")
|
|
s.sql(sql)
|
|
s.section("end")
|
|
empty, err := run(ctx, binary, s)
|
|
if err != nil {
|
|
return analysis.Query{}, err
|
|
}
|
|
var rows []string
|
|
for _, o := range []*output{bound, empty} {
|
|
if q := o.sections["query"]; q != nil {
|
|
rows = append(rows, q.rows...)
|
|
}
|
|
}
|
|
|
|
aq := analysis.Query{
|
|
Name: q.Name,
|
|
Cmd: q.Cmd,
|
|
Columns: []analysis.Column{},
|
|
Params: []analysis.Param{},
|
|
}
|
|
for i, m := range query.columns {
|
|
aq.Columns = append(aq.Columns, describeColumn(cat, m, classes(rows, i)))
|
|
}
|
|
for i, ph := range phs {
|
|
ac := params[i]
|
|
if ph.Name != "" {
|
|
ac.Name = ph.Name
|
|
}
|
|
aq.Params = append(aq.Params, analysis.Param{Number: ph.Number, Column: ac})
|
|
}
|
|
return aq, nil
|
|
}
|
|
|
|
// sample is an expression for a value to bind to a parameter: one of its
|
|
// column's values in the fixture, or a value of its type when all that is
|
|
// known is the type. Empty when nothing is known.
|
|
func sample(ac analysis.Column) string {
|
|
if ac.Table != "" && ac.Name != "" {
|
|
col, tbl := quoteIdent(ac.Name), quoteIdent(ac.Table)
|
|
return fmt.Sprintf("(SELECT %s FROM %s WHERE %s IS NOT NULL LIMIT 1)", col, tbl, col)
|
|
}
|
|
if ac.Type == nil {
|
|
return ""
|
|
}
|
|
switch ac.Type.Name {
|
|
case "integer", "numeric":
|
|
return "1"
|
|
case "real":
|
|
return "1.0"
|
|
case "text":
|
|
return "'a'"
|
|
case "blob":
|
|
return "x'00'"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func quoteIdent(s string) string {
|
|
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
|
}
|
|
|
|
// classes lists the storage classes of a column's values across rows.
|
|
func classes(rows []string, i int) []string {
|
|
var out []string
|
|
for _, row := range rows {
|
|
if c := cells(row); i < len(c) {
|
|
out = append(out, storageClass(c[i]))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// describeColumn describes a result column: as the table column it is read
|
|
// from when it is one, otherwise by the storage class of its values, and
|
|
// nullable when any of its values was NULL.
|
|
func describeColumn(cat *catalog, m columnMeta, classes []string) analysis.Column {
|
|
ac := analysis.Column{Name: m.Name}
|
|
if col := cat.lookup(m.Table, m.Origin); col != nil {
|
|
d := col.describe()
|
|
ac.Type, ac.Table = d.Type, d.Table
|
|
} else {
|
|
for _, class := range classes {
|
|
if class != "null" {
|
|
ac.Type = &analysis.TypeExpr{Name: class}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if ac.Type != nil {
|
|
for _, class := range classes {
|
|
if class == "null" {
|
|
ac.Type.Nullable = true
|
|
}
|
|
}
|
|
}
|
|
return ac
|
|
}
|