diff --git a/CLAUDE.md b/CLAUDE.md index 4b4aca32df..43d86b49fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,25 @@ stdout against `stdout.txt`. A case that is expected to fail commits its `stderr.txt`. Regenerate a golden by running the command in its directory and writing the output back over the committed file. +`TestReplay` runs the whole corpus once per *context*. `base` runs each case as +committed and `managed-db` reruns it against a live database, so a context can +change the config a case is generated with and the experiments it is generated +under. A case restricts itself to some of them with `"contexts": [...]` in its +`exec.json`, and commits per-context expected errors as `stderr/.txt`. +There is only one set of committed golden files, so every context is expected +to generate identical code. + +The `core` context generates every case through the analysis core +(`SQLCEXPERIMENT=coreanalyzer`). The two paths still disagree, so it is opt-in +and needs no database: + +```bash +SQLC_TEST_CORE=1 go test ./internal/endtoend -run 'TestReplay/core' +``` + +Go aborts a test binary on panic, so a case that panics the core analyzer ends +the run early. Run a subset to get past one (`-run 'TestReplay/core/^select'`). + ### Example Tests - **Location:** `/examples/` directory diff --git a/internal/core/analyzer/dml.go b/internal/core/analyzer/dml.go index 2954a4e43c..f1cb397c1c 100644 --- a/internal/core/analyzer/dml.go +++ b/internal/core/analyzer/dml.go @@ -83,6 +83,7 @@ func (a *analyzer) analyzeDelete(s *ast.DeleteStmt) error { // report a multi-table DELETE that way — a single FROM node. func (a *analyzer) relationScope(relations, extra *ast.List, from ast.Node) (*scope, error) { sc := &scope{} + defer a.binding(sc)() for _, item := range listItems(relations) { if err := a.appendFromItem(sc, item); err != nil { return nil, err diff --git a/internal/core/analyzer/scope.go b/internal/core/analyzer/scope.go index b87d3cfc6a..020d3d1f12 100644 --- a/internal/core/analyzer/scope.go +++ b/internal/core/analyzer/scope.go @@ -22,6 +22,7 @@ type scopeRel struct { func (a *analyzer) buildScope(from *ast.List) (*scope, error) { items := listItems(from) sc := &scope{rels: make([]scopeRel, 0, len(items))} + defer a.binding(sc)() for _, item := range items { if err := a.appendFromItem(sc, item); err != nil { return nil, err @@ -30,6 +31,17 @@ func (a *analyzer) buildScope(from *ast.List) (*scope, error) { return sc, nil } +// binding makes the scope under construction the one the analyzer resolves +// against, and returns the func that puts back the scope it replaced. A FROM +// item can refer to the ones before it — a set-returning function takes its +// arguments from them — so binding an item has to see what is bound so far +// rather than no scope at all. +func (a *analyzer) binding(sc *scope) func() { + prev := a.scope + a.scope = sc + return func() { a.scope = prev } +} + func (a *analyzer) appendFromItem(sc *scope, item ast.Node) error { switch v := item.(type) { case *ast.RangeVar: @@ -202,6 +214,12 @@ func (a *analyzer) resolveColumn(relation, column string) (scopeRel, core.ClassC // relation. It reports an error when more than one relation in scope offers // that name. func (s *scope) resolveColumn(relation, column string) (rel scopeRel, col core.ClassColumn, ok bool, err error) { + // A statement whose scope is not built yet offers no columns. Report the + // column as unresolved and let the caller say so, rather than crashing on + // the way to the same answer. + if s == nil { + return rel, col, false, nil + } found := 0 for _, r := range s.rels { if relation != "" && r.alias != relation { diff --git a/internal/endtoend/endtoend_test.go b/internal/endtoend/endtoend_test.go index dd37a0ad54..5c261dc7aa 100644 --- a/internal/endtoend/endtoend_test.go +++ b/internal/endtoend/endtoend_test.go @@ -120,6 +120,10 @@ func BenchmarkExamples(b *testing.B) { type textContext struct { Mutate func(*testing.T, string) func(*config.Config) Enabled func() bool + // Experiments names the experiments every case in this context runs + // with. A case's own SQLCEXPERIMENT is appended to these, so a case can + // still turn one back off with the "no" prefix. + Experiments func() []string } func TestReplay(t *testing.T) { @@ -235,6 +239,16 @@ func TestReplay(t *testing.T) { return postgresURI != "" || mysqlURI != "" }, }, + "core": { + Mutate: func(t *testing.T, path string) func(*config.Config) { return func(c *config.Config) {} }, + Experiments: func() []string { return []string{"coreanalyzer"} }, + Enabled: func() bool { + // Running the whole corpus through the analysis core is opt-in + // while the two paths still disagree. The core needs no + // database, so nothing else gates this. + return os.Getenv("SQLC_TEST_CORE") != "" + }, + }, } for name, testctx := range contexts { @@ -276,9 +290,14 @@ func TestReplay(t *testing.T) { } } + experiments := args.Env["SQLCEXPERIMENT"] + if testctx.Experiments != nil { + experiments = strings.Join(append(testctx.Experiments(), experiments), ",") + } + opts := cmd.Options{ Env: cmd.Env{ - Experiment: opts.ExperimentFromString(args.Env["SQLCEXPERIMENT"]), + Experiment: opts.ExperimentFromString(experiments), }, Stderr: &stderr, MutateConfig: testctx.Mutate(t, path),