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
233 lines
6.6 KiB
Go
233 lines
6.6 KiB
Go
package preprocess_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
|
|
"github.com/sqlc-dev/sqlc/internal/config"
|
|
"github.com/sqlc-dev/sqlc/internal/source"
|
|
"github.com/sqlc-dev/sqlc/internal/sql/named"
|
|
"github.com/sqlc-dev/sqlc/internal/sql/preprocess"
|
|
"github.com/sqlc-dev/sqlc/internal/sql/sqlerr"
|
|
)
|
|
|
|
var update = flag.Bool("update", false, "update the testdata golden files")
|
|
|
|
// TestRewrite runs every case under testdata. Each case is a directory holding
|
|
// the input and the expected results, grouped by engine:
|
|
//
|
|
// testdata/<engine>/<case>/input.sql the query file
|
|
// testdata/<engine>/<case>/output.sql the rewritten SQL
|
|
// testdata/<engine>/<case>/side_table.json what the preprocessor recorded
|
|
// testdata/<engine>/<case>/stderr.txt reported errors (only when invalid)
|
|
//
|
|
// Regenerate the golden files with `go test ./internal/sql/preprocess -update`.
|
|
func TestRewrite(t *testing.T) {
|
|
for _, dir := range cases(t) {
|
|
t.Run(filepath.ToSlash(dir), func(t *testing.T) {
|
|
path := filepath.Join("testdata", dir)
|
|
engine := config.Engine(filepath.Dir(dir))
|
|
|
|
input := readFile(t, filepath.Join(path, "input.sql"))
|
|
res := preprocess.File(engine, input)
|
|
|
|
compare(t, filepath.Join(path, "output.sql"), res.Text)
|
|
compare(t, filepath.Join(path, "side_table.json"), sideTable(res))
|
|
compare(t, filepath.Join(path, "stderr.txt"), errors(input, res))
|
|
|
|
// A rewrite only ever replaces text with something shorter or
|
|
// equal in lines, so a line number taken from the rewritten text is
|
|
// never past the end of the original.
|
|
if want, got := strings.Count(input, "\n"), strings.Count(res.Text, "\n"); got > want {
|
|
t.Errorf("rewrite added lines: got %d, want at most %d", got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The JSON shape of the side table the preprocessor records alongside the
|
|
// rewritten SQL. Locations are offsets into the rewritten text; origins are the
|
|
// offsets they map back to in the original source.
|
|
type statement struct {
|
|
Start int `json:"start"`
|
|
End int `json:"end"`
|
|
Dollar bool `json:"dollar"`
|
|
Params []parameter `json:"params,omitempty"`
|
|
Embeds []embed `json:"embeds,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
ErrorAt int `json:"error_at,omitempty"`
|
|
ParamError string `json:"param_error,omitempty"`
|
|
}
|
|
|
|
type parameter struct {
|
|
Number int `json:"number"`
|
|
Location int `json:"location"`
|
|
Origin int `json:"origin"`
|
|
Name string `json:"name,omitempty"`
|
|
// Named is false for a placeholder the user wrote themselves, which the
|
|
// preprocessor numbers but does not name.
|
|
Named bool `json:"named"`
|
|
// Nullable is true when sqlc.narg() forced the parameter nullable, even
|
|
// though the schema says it is NOT NULL.
|
|
Nullable bool `json:"nullable,omitempty"`
|
|
Slice bool `json:"slice,omitempty"`
|
|
}
|
|
|
|
type embed struct {
|
|
Table string `json:"table"`
|
|
Location int `json:"location"`
|
|
Origin int `json:"origin"`
|
|
Orig string `json:"orig"`
|
|
}
|
|
|
|
// sideTable renders everything the preprocessor recorded, reading it back
|
|
// through the same API the compiler uses.
|
|
func sideTable(res *preprocess.Result) string {
|
|
out := make([]statement, 0, len(res.Statements()))
|
|
for _, stmt := range res.Statements() {
|
|
s := statement{Start: stmt.Start, End: stmt.End, Dollar: stmt.Dollar}
|
|
if stmt.Err != nil {
|
|
s.Error = stmt.Err.Error()
|
|
if e, ok := stmt.Err.(*sqlerr.Error); ok {
|
|
s.ErrorAt = e.Location
|
|
}
|
|
}
|
|
if stmt.ParamErr != nil {
|
|
s.ParamError = stmt.ParamErr.Error()
|
|
}
|
|
|
|
locations := make([]int, 0, len(stmt.Numbers))
|
|
for loc := range stmt.Numbers {
|
|
locations = append(locations, loc)
|
|
}
|
|
sort.Ints(locations)
|
|
for _, loc := range locations {
|
|
number := stmt.Numbers[loc]
|
|
name, _ := stmt.Params.NameFor(number)
|
|
// Merging against a NOT NULL parameter is how the compiler asks
|
|
// whether the user overrode nullability with sqlc.narg().
|
|
p, isNamed := stmt.Params.FetchMerge(number, named.NewInferredParam(name, true))
|
|
_, isSlice := stmt.Slices[loc]
|
|
s.Params = append(s.Params, parameter{
|
|
Number: number,
|
|
Location: loc,
|
|
Origin: res.Origin(loc),
|
|
Name: name,
|
|
Named: isNamed,
|
|
Nullable: isNamed && !p.NotNull(),
|
|
Slice: isSlice,
|
|
})
|
|
}
|
|
|
|
for _, e := range stmt.Embeds {
|
|
s.Embeds = append(s.Embeds, embed{
|
|
Table: e.Table.Name,
|
|
Location: e.Location,
|
|
Origin: res.Origin(e.Location),
|
|
Orig: e.Orig(),
|
|
})
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
|
|
blob, err := json.MarshalIndent(out, "", " ")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return string(blob) + "\n"
|
|
}
|
|
|
|
// errors renders every validation error the preprocessor recorded, one per
|
|
// line, as "line:column: message" against the original source.
|
|
func errors(input string, res *preprocess.Result) string {
|
|
var out []string
|
|
for _, stmt := range res.Statements() {
|
|
for _, err := range []error{stmt.Err, stmt.ParamErr} {
|
|
if err == nil {
|
|
continue
|
|
}
|
|
loc := stmt.Start
|
|
if e, ok := err.(*sqlerr.Error); ok && e.Location != 0 {
|
|
loc = e.Location
|
|
}
|
|
line, column := source.LineNumber(input, res.Origin(loc))
|
|
out = append(out, fmt.Sprintf("%d:%d: %s", line, column, err))
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return ""
|
|
}
|
|
return strings.Join(out, "\n") + "\n"
|
|
}
|
|
|
|
// cases returns every testdata directory that holds an input.sql, relative to
|
|
// testdata and in a stable order.
|
|
func cases(t *testing.T) []string {
|
|
t.Helper()
|
|
var dirs []string
|
|
err := filepath.Walk("testdata", func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.IsDir() || info.Name() != "input.sql" {
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel("testdata", filepath.Dir(path))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dirs = append(dirs, rel)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(dirs) == 0 {
|
|
t.Fatal("no test cases found in testdata")
|
|
}
|
|
sort.Strings(dirs)
|
|
return dirs
|
|
}
|
|
|
|
func readFile(t *testing.T, path string) string {
|
|
t.Helper()
|
|
blob, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(blob)
|
|
}
|
|
|
|
// compare checks actual against a golden file. An empty expectation means the
|
|
// file should not exist.
|
|
func compare(t *testing.T, path, actual string) {
|
|
t.Helper()
|
|
if *update {
|
|
if actual == "" {
|
|
os.Remove(path)
|
|
return
|
|
}
|
|
if err := os.WriteFile(path, []byte(actual), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return
|
|
}
|
|
var expected string
|
|
if blob, err := os.ReadFile(path); err == nil {
|
|
expected = string(blob)
|
|
} else if !os.IsNotExist(err) {
|
|
t.Fatal(err)
|
|
}
|
|
if diff := cmp.Diff(expected, actual); diff != "" {
|
|
t.Errorf("%s differed (-want +got):\n%s", filepath.Base(path), diff)
|
|
}
|
|
}
|