From d8c512f2816939b805a755cb9afe4d18d68ec21b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 16:22:50 +0000 Subject: [PATCH 1/6] Add analyze command for static query analysis Add a new `sqlc analyze` command that analyzes a query file against a schema file and outputs the inferred result columns and parameters as JSON. Unlike `sqlc generate`, this command does not require a configuration file and does not connect to a database. It drives sqlc's native static analysis (the catalog-based compiler) to infer types directly from the provided schema, supporting the postgresql, mysql, and sqlite dialects. Usage: sqlc analyze --dialect postgresql --schema schema.sql query.sql --- internal/cmd/analyze.go | 179 ++++++++++++++++++++++++++++++++++++++++ internal/cmd/cmd.go | 3 + 2 files changed, 182 insertions(+) create mode 100644 internal/cmd/analyze.go diff --git a/internal/cmd/analyze.go b/internal/cmd/analyze.go new file mode 100644 index 0000000000..e929b49eb3 --- /dev/null +++ b/internal/cmd/analyze.go @@ -0,0 +1,179 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/sqlc-dev/sqlc/internal/compiler" + "github.com/sqlc-dev/sqlc/internal/config" + "github.com/sqlc-dev/sqlc/internal/multierr" + "github.com/sqlc-dev/sqlc/internal/opts" +) + +var analyzeCmd = &cobra.Command{ + Use: "analyze [query-file]", + Short: "Analyze a query against a schema and output the result columns and parameters", + Long: `Analyze a query file against a schema file and output the inferred result +columns and parameters as JSON. + +Unlike "sqlc generate", this command does not require a configuration file and +does not connect to a database. It uses sqlc's native static analysis to infer +types from the provided schema. + +Examples: + # Analyze a PostgreSQL query + sqlc analyze --dialect postgresql --schema schema.sql query.sql + + # Analyze a MySQL query + sqlc analyze --dialect mysql --schema schema.sql query.sql + + # Analyze a SQLite query + sqlc analyze --dialect sqlite --schema schema.sql query.sql`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dialect, err := cmd.Flags().GetString("dialect") + if err != nil { + return err + } + if dialect == "" { + return fmt.Errorf("--dialect flag is required (postgresql, mysql, or sqlite)") + } + + schemaPath, err := cmd.Flags().GetString("schema") + if err != nil { + return err + } + if schemaPath == "" { + return fmt.Errorf("--schema flag is required") + } + + queryPath := args[0] + + var engine config.Engine + switch dialect { + case "postgresql", "postgres", "pg": + engine = config.EnginePostgreSQL + case "mysql": + engine = config.EngineMySQL + case "sqlite": + engine = config.EngineSQLite + default: + return fmt.Errorf("unsupported dialect: %s (use postgresql, mysql, or sqlite)", dialect) + } + + sql := config.SQL{ + Engine: engine, + Schema: config.Paths{schemaPath}, + Queries: config.Paths{queryPath}, + } + combo := config.Combine(config.Config{}, sql) + parserOpts := opts.Parser{} + + ctx := cmd.Context() + c, err := compiler.NewCompiler(sql, combo, parserOpts) + if err != nil { + return fmt.Errorf("error creating compiler: %w", err) + } + defer c.Close(ctx) + + if err := c.ParseCatalog(sql.Schema); err != nil { + return fmt.Errorf("error parsing schema: %w", formatParseError(err)) + } + if err := c.ParseQueries(sql.Queries, parserOpts); err != nil { + return fmt.Errorf("error parsing queries: %w", formatParseError(err)) + } + + result := c.Result() + + out := make([]analyzedQuery, 0, len(result.Queries)) + for _, q := range result.Queries { + out = append(out, newAnalyzedQuery(q)) + } + + stdout := cmd.OutOrStdout() + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(out); err != nil { + return fmt.Errorf("failed to encode analysis: %w", err) + } + + return nil + }, +} + +// formatParseError unwraps a multierr.Error into a single error containing all +// of the underlying file errors, so the analyze command can report each one with +// its file location. +func formatParseError(err error) error { + parserErr, ok := err.(*multierr.Error) + if !ok { + return err + } + var msgs []string + for _, fileErr := range parserErr.Errs() { + msgs = append(msgs, fmt.Sprintf("%s:%d:%d: %s", + fileErr.Filename, fileErr.Line, fileErr.Column, fileErr.Err)) + } + if len(msgs) == 0 { + return err + } + return fmt.Errorf("%s", strings.Join(msgs, "; ")) +} + +type analyzedQuery struct { + Name string `json:"name"` + Cmd string `json:"cmd"` + Columns []analyzedColumn `json:"columns"` + Params []analyzedParam `json:"params"` +} + +type analyzedColumn struct { + Name string `json:"name"` + DataType string `json:"data_type"` + NotNull bool `json:"not_null"` + IsArray bool `json:"is_array"` + Table string `json:"table,omitempty"` +} + +type analyzedParam struct { + Number int `json:"number"` + Column analyzedColumn `json:"column"` +} + +func newAnalyzedQuery(q *compiler.Query) analyzedQuery { + aq := analyzedQuery{ + Name: q.Metadata.Name, + Cmd: q.Metadata.Cmd, + Columns: make([]analyzedColumn, 0, len(q.Columns)), + Params: make([]analyzedParam, 0, len(q.Params)), + } + for _, col := range q.Columns { + aq.Columns = append(aq.Columns, newAnalyzedColumn(col)) + } + for _, p := range q.Params { + aq.Params = append(aq.Params, analyzedParam{ + Number: p.Number, + Column: newAnalyzedColumn(p.Column), + }) + } + return aq +} + +func newAnalyzedColumn(col *compiler.Column) analyzedColumn { + if col == nil { + return analyzedColumn{} + } + ac := analyzedColumn{ + Name: col.Name, + DataType: col.DataType, + NotNull: col.NotNull, + IsArray: col.IsArray, + } + if col.Table != nil { + ac.Table = col.Table.Name + } + return ac +} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 4079b3c1d3..36aa8a7317 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -33,6 +33,8 @@ func init() { initCmd.Flags().BoolP("v2", "", true, "generate v2 config yaml file") initCmd.MarkFlagsMutuallyExclusive("v1", "v2") parseCmd.Flags().StringP("dialect", "d", "", "SQL dialect to use (postgresql, mysql, or sqlite)") + analyzeCmd.Flags().StringP("dialect", "d", "", "SQL dialect to use (postgresql, mysql, or sqlite)") + analyzeCmd.Flags().StringP("schema", "s", "", "path to the schema file") } // Do runs the command logic. @@ -46,6 +48,7 @@ func Do(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int rootCmd.AddCommand(genCmd) rootCmd.AddCommand(initCmd) rootCmd.AddCommand(parseCmd) + rootCmd.AddCommand(analyzeCmd) rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(verifyCmd) rootCmd.AddCommand(pushCmd) From 3715ae644e868118c1920ef5c367472a3d688779 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 16:31:55 +0000 Subject: [PATCH 2/6] Support stdin in analyze and align parse output to a single document Add stdin support to `sqlc analyze`: when no query file argument is given, the query is read from stdin (written to a temporary file so the compiler can read it), mirroring `sqlc parse`. Align the two commands on a single-document JSON output. `parse` previously emitted one JSON object per statement (newline-delimited), which is not parseable as a single document; it now emits a single JSON array of statements, matching the array `analyze` already produces. --- internal/cmd/analyze.go | 43 ++++++++++++++++++++++++++++++++++++++--- internal/cmd/parse.go | 14 ++++++++------ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/internal/cmd/analyze.go b/internal/cmd/analyze.go index e929b49eb3..bd4a283e44 100644 --- a/internal/cmd/analyze.go +++ b/internal/cmd/analyze.go @@ -3,6 +3,8 @@ package cmd import ( "encoding/json" "fmt" + "io" + "os" "strings" "github.com/spf13/cobra" @@ -31,8 +33,12 @@ Examples: sqlc analyze --dialect mysql --schema schema.sql query.sql # Analyze a SQLite query - sqlc analyze --dialect sqlite --schema schema.sql query.sql`, - Args: cobra.ExactArgs(1), + sqlc analyze --dialect sqlite --schema schema.sql query.sql + + # Analyze a query piped via stdin + echo "-- name: GetAuthor :one + SELECT * FROM authors WHERE id = $1;" | sqlc analyze --dialect postgresql --schema schema.sql`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { dialect, err := cmd.Flags().GetString("dialect") if err != nil { @@ -50,7 +56,38 @@ Examples: return fmt.Errorf("--schema flag is required") } - queryPath := args[0] + // The query comes from a file argument or, when none is given, from + // stdin. The compiler reads queries from files, so stdin is written to + // a temporary file. + var queryPath string + if len(args) == 1 { + queryPath = args[0] + } else { + stat, err := os.Stdin.Stat() + if err != nil { + return fmt.Errorf("failed to stat stdin: %w", err) + } + if (stat.Mode() & os.ModeCharDevice) != 0 { + return fmt.Errorf("no query provided. Specify a query file or pipe SQL via stdin") + } + data, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return fmt.Errorf("failed to read stdin: %w", err) + } + tmp, err := os.CreateTemp("", "sqlc-analyze-*.sql") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("failed to write temp file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + queryPath = tmp.Name() + } var engine config.Engine switch dialect { diff --git a/internal/cmd/parse.go b/internal/cmd/parse.go index aca01511f1..3b9ff05b9e 100644 --- a/internal/cmd/parse.go +++ b/internal/cmd/parse.go @@ -85,15 +85,17 @@ Examples: return fmt.Errorf("parse error: %w", err) } - // Output AST as JSON + // Output the AST as a single JSON document + raws := make([]*ast.RawStmt, 0, len(stmts)) + for _, stmt := range stmts { + raws = append(raws, stmt.Raw) + } + stdout := cmd.OutOrStdout() encoder := json.NewEncoder(stdout) encoder.SetIndent("", " ") - - for _, stmt := range stmts { - if err := encoder.Encode(stmt.Raw); err != nil { - return fmt.Errorf("failed to encode AST: %w", err) - } + if err := encoder.Encode(raws); err != nil { + return fmt.Errorf("failed to encode AST: %w", err) } return nil From d018aaaf0753c0b1c87f3c02d2c32373dab8d23e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 15:56:39 +0000 Subject: [PATCH 3/6] Include query name and command in parse output Each parsed statement now reports its sqlc query name and command, extracted from the "-- name:" annotation using the dialect's comment syntax. The fields are omitted for statements without an annotation (e.g. schema DDL). The statement AST is nested under an "ast" key. --- internal/cmd/parse.go | 67 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/internal/cmd/parse.go b/internal/cmd/parse.go index 3b9ff05b9e..f5934347dd 100644 --- a/internal/cmd/parse.go +++ b/internal/cmd/parse.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "strings" "github.com/spf13/cobra" @@ -12,14 +13,36 @@ import ( "github.com/sqlc-dev/sqlc/internal/engine/dolphin" "github.com/sqlc-dev/sqlc/internal/engine/postgresql" "github.com/sqlc-dev/sqlc/internal/engine/sqlite" + "github.com/sqlc-dev/sqlc/internal/metadata" + "github.com/sqlc-dev/sqlc/internal/source" "github.com/sqlc-dev/sqlc/internal/sql/ast" ) +// dialectParser is the subset of the engine parsers that the parse command +// needs: parsing SQL into statements and reporting the dialect's comment syntax +// (used to extract the sqlc query name and command). +type dialectParser interface { + Parse(io.Reader) ([]ast.Statement, error) + CommentSyntax() source.CommentSyntax +} + +// parsedStatement is the JSON representation of a single parsed statement. The +// name and cmd are extracted from the sqlc query annotation (e.g. +// "-- name: GetAuthor :one") and are omitted when the statement has none. +type parsedStatement struct { + Name string `json:"name,omitempty"` + Cmd string `json:"cmd,omitempty"` + AST *ast.RawStmt `json:"ast"` +} + var parseCmd = &cobra.Command{ Use: "parse [file]", Short: "Parse SQL and output the AST as JSON", Long: `Parse SQL from a file or stdin and output the abstract syntax tree as JSON. +Each statement is reported with its sqlc query name and command (when the +statement carries a "-- name:" annotation) alongside the AST. + Examples: # Parse a SQL file with PostgreSQL dialect sqlc parse --dialect postgresql schema.sql @@ -63,38 +86,56 @@ Examples: input = cmd.InOrStdin() } - // Parse SQL based on dialect - var stmts []ast.Statement + // Select the parser for the requested dialect + var parser dialectParser switch dialect { case "postgresql", "postgres", "pg": - parser := postgresql.NewParser() - stmts, err = parser.Parse(input) + parser = postgresql.NewParser() case "mysql": - parser := dolphin.NewParser() - stmts, err = parser.Parse(input) + parser = dolphin.NewParser() case "sqlite": - parser := sqlite.NewParser() - stmts, err = parser.Parse(input) + parser = sqlite.NewParser() case "clickhouse": - parser := clickhouse.NewParser() - stmts, err = parser.Parse(input) + parser = clickhouse.NewParser() default: return fmt.Errorf("unsupported dialect: %s (use postgresql, mysql, sqlite, or clickhouse)", dialect) } + + // Read the full source so each statement's name and command can be + // extracted from its annotation comment. + src, err := io.ReadAll(input) + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + + stmts, err := parser.Parse(strings.NewReader(string(src))) if err != nil { return fmt.Errorf("parse error: %w", err) } + commentSyntax := metadata.CommentSyntax(parser.CommentSyntax()) + // Output the AST as a single JSON document - raws := make([]*ast.RawStmt, 0, len(stmts)) + out := make([]parsedStatement, 0, len(stmts)) for _, stmt := range stmts { - raws = append(raws, stmt.Raw) + ps := parsedStatement{AST: stmt.Raw} + rawSQL, err := source.Pluck(string(src), stmt.Raw.StmtLocation, stmt.Raw.StmtLen) + if err != nil { + return fmt.Errorf("failed to read statement source: %w", err) + } + name, cmd, err := metadata.ParseQueryNameAndType(rawSQL, commentSyntax) + if err != nil { + return fmt.Errorf("failed to parse query annotation: %w", err) + } + ps.Name = name + ps.Cmd = cmd + out = append(out, ps) } stdout := cmd.OutOrStdout() encoder := json.NewEncoder(stdout) encoder.SetIndent("", " ") - if err := encoder.Encode(raws); err != nil { + if err := encoder.Encode(out); err != nil { return fmt.Errorf("failed to encode AST: %w", err) } From e4792a50d053024766e3720975508d41dc774717 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 16:02:15 +0000 Subject: [PATCH 4/6] Add --ast flag to analyze to include the statement AST When --ast is passed, each analyzed query includes its raw statement AST under an "ast" key, matching the AST that "sqlc parse" emits. The field is omitted by default. --- internal/cmd/analyze.go | 19 ++++++++++++++++--- internal/cmd/cmd.go | 1 + 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/internal/cmd/analyze.go b/internal/cmd/analyze.go index bd4a283e44..4e83296ded 100644 --- a/internal/cmd/analyze.go +++ b/internal/cmd/analyze.go @@ -13,6 +13,7 @@ import ( "github.com/sqlc-dev/sqlc/internal/config" "github.com/sqlc-dev/sqlc/internal/multierr" "github.com/sqlc-dev/sqlc/internal/opts" + "github.com/sqlc-dev/sqlc/internal/sql/ast" ) var analyzeCmd = &cobra.Command{ @@ -37,7 +38,10 @@ Examples: # Analyze a query piped via stdin echo "-- name: GetAuthor :one - SELECT * FROM authors WHERE id = $1;" | sqlc analyze --dialect postgresql --schema schema.sql`, + SELECT * FROM authors WHERE id = $1;" | sqlc analyze --dialect postgresql --schema schema.sql + + # Include the statement AST in the output + sqlc analyze --dialect postgresql --schema schema.sql --ast query.sql`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { dialect, err := cmd.Flags().GetString("dialect") @@ -56,6 +60,11 @@ Examples: return fmt.Errorf("--schema flag is required") } + includeAST, err := cmd.Flags().GetBool("ast") + if err != nil { + return err + } + // The query comes from a file argument or, when none is given, from // stdin. The compiler reads queries from files, so stdin is written to // a temporary file. @@ -127,7 +136,7 @@ Examples: out := make([]analyzedQuery, 0, len(result.Queries)) for _, q := range result.Queries { - out = append(out, newAnalyzedQuery(q)) + out = append(out, newAnalyzedQuery(q, includeAST)) } stdout := cmd.OutOrStdout() @@ -165,6 +174,7 @@ type analyzedQuery struct { Cmd string `json:"cmd"` Columns []analyzedColumn `json:"columns"` Params []analyzedParam `json:"params"` + AST *ast.RawStmt `json:"ast,omitempty"` } type analyzedColumn struct { @@ -180,7 +190,7 @@ type analyzedParam struct { Column analyzedColumn `json:"column"` } -func newAnalyzedQuery(q *compiler.Query) analyzedQuery { +func newAnalyzedQuery(q *compiler.Query, includeAST bool) analyzedQuery { aq := analyzedQuery{ Name: q.Metadata.Name, Cmd: q.Metadata.Cmd, @@ -196,6 +206,9 @@ func newAnalyzedQuery(q *compiler.Query) analyzedQuery { Column: newAnalyzedColumn(p.Column), }) } + if includeAST { + aq.AST = q.RawStmt + } return aq } diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 36aa8a7317..abef62d7e2 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -35,6 +35,7 @@ func init() { parseCmd.Flags().StringP("dialect", "d", "", "SQL dialect to use (postgresql, mysql, or sqlite)") analyzeCmd.Flags().StringP("dialect", "d", "", "SQL dialect to use (postgresql, mysql, or sqlite)") analyzeCmd.Flags().StringP("schema", "s", "", "path to the schema file") + analyzeCmd.Flags().BoolP("ast", "", false, "include the statement AST in the output") } // Do runs the command logic. From b6f323e459075a52b6e659377069ce5a42db2efd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 16:17:48 +0000 Subject: [PATCH 5/6] Add endtoend replay coverage for parse and analyze Extend the endtoend replay framework to run config-less, flag-driven commands. Exec gains an "args" field, FindTests discovers directories by exec.json when no sqlc config is present, and TestReplay dispatches parse/analyze through the CLI entry point, comparing stdout to a stdout.txt golden file. The config-based consumers (TestValidSchema, TestFormat) skip these config-less cases. Add two cases that pin the JSON output format of each command so future changes don't break it: parse of a named PostgreSQL query and analyze of a SELECT * query against a schema. --- internal/endtoend/case_test.go | 46 +++++++++++++- internal/endtoend/ddl_test.go | 5 ++ internal/endtoend/endtoend_test.go | 23 +++++++ internal/endtoend/fmt_test.go | 4 ++ .../analyze_basic/postgresql/exec.json | 5 ++ .../analyze_basic/postgresql/query.sql | 2 + .../analyze_basic/postgresql/schema.sql | 5 ++ .../analyze_basic/postgresql/stdout.txt | 41 +++++++++++++ .../testdata/parse_basic/postgresql/exec.json | 5 ++ .../testdata/parse_basic/postgresql/query.sql | 2 + .../parse_basic/postgresql/stdout.txt | 60 +++++++++++++++++++ 11 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 internal/endtoend/testdata/analyze_basic/postgresql/exec.json create mode 100644 internal/endtoend/testdata/analyze_basic/postgresql/query.sql create mode 100644 internal/endtoend/testdata/analyze_basic/postgresql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_basic/postgresql/stdout.txt create mode 100644 internal/endtoend/testdata/parse_basic/postgresql/exec.json create mode 100644 internal/endtoend/testdata/parse_basic/postgresql/query.sql create mode 100644 internal/endtoend/testdata/parse_basic/postgresql/stdout.txt diff --git a/internal/endtoend/case_test.go b/internal/endtoend/case_test.go index 4389a4da28..183b965a2a 100644 --- a/internal/endtoend/case_test.go +++ b/internal/endtoend/case_test.go @@ -15,6 +15,7 @@ type Testcase struct { Path string ConfigName string Stderr []byte + Stdout []byte Exec *Exec } @@ -24,6 +25,7 @@ type ExecMeta struct { type Exec struct { Command string `json:"command"` + Args []string `json:"args"` Contexts []string `json:"contexts"` Process string `json:"process"` OS []string `json:"os"` @@ -50,6 +52,29 @@ func parseStderr(t *testing.T, dir, testctx string) []byte { return nil } +func parseStdout(t *testing.T, dir string) []byte { + t.Helper() + path := filepath.Join(dir, "stdout.txt") + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil + } + blob, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return blob +} + +// hasSQLCConfig reports whether dir contains an sqlc configuration file. +func hasSQLCConfig(dir string) bool { + for _, name := range []string{"sqlc.json", "sqlc.yaml", "sqlc.yml"} { + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + return true + } + } + return false +} + func parseExec(t *testing.T, dir string) *Exec { t.Helper() path := filepath.Join(dir, "exec.json") @@ -76,17 +101,34 @@ func FindTests(t *testing.T, root, testctx string) []*Testcase { if err != nil { return err } - if info.Name() == "sqlc.json" || info.Name() == "sqlc.yaml" || info.Name() == "sqlc.yml" { + name := info.Name() + if name == "sqlc.json" || name == "sqlc.yaml" || name == "sqlc.yml" { dir := filepath.Dir(path) tcs = append(tcs, &Testcase{ Path: dir, Name: strings.TrimPrefix(dir, root+string(filepath.Separator)), - ConfigName: info.Name(), + ConfigName: name, Stderr: parseStderr(t, dir, testctx), + Stdout: parseStdout(t, dir), Exec: parseExec(t, dir), }) return filepath.SkipDir } + // Config-less command tests (e.g. parse, analyze) are discovered by + // their exec.json when no sqlc config is present in the directory. + if name == "exec.json" { + dir := filepath.Dir(path) + if !hasSQLCConfig(dir) { + tcs = append(tcs, &Testcase{ + Path: dir, + Name: strings.TrimPrefix(dir, root+string(filepath.Separator)), + Stderr: parseStderr(t, dir, testctx), + Stdout: parseStdout(t, dir), + Exec: parseExec(t, dir), + }) + return filepath.SkipDir + } + } return nil }) if err != nil { diff --git a/internal/endtoend/ddl_test.go b/internal/endtoend/ddl_test.go index bed9333743..689b48df77 100644 --- a/internal/endtoend/ddl_test.go +++ b/internal/endtoend/ddl_test.go @@ -20,6 +20,11 @@ func TestValidSchema(t *testing.T) { } } + // Config-less command tests (parse, analyze) have no schema to validate. + if replay.ConfigName == "" { + continue + } + file := filepath.Join(replay.Path, replay.ConfigName) rd, err := os.Open(file) if err != nil { diff --git a/internal/endtoend/endtoend_test.go b/internal/endtoend/endtoend_test.go index f8bb5a6e0f..9eeb70d8bc 100644 --- a/internal/endtoend/endtoend_test.go +++ b/internal/endtoend/endtoend_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "fmt" "os" osexec "os/exec" "path/filepath" @@ -298,6 +299,28 @@ func TestReplay(t *testing.T) { } case "vet": err = cmd.Vet(ctx, path, "", &opts) + case "parse", "analyze": + // These commands are config-less and flag-driven. Run them + // through the real CLI entry point from inside the test + // directory so file arguments resolve and the output stays + // independent of the absolute path. + var stdout bytes.Buffer + wd, werr := os.Getwd() + if werr != nil { + t.Fatal(werr) + } + if cerr := os.Chdir(path); cerr != nil { + t.Fatal(cerr) + } + code := cmd.Do(append([]string{args.Command}, args.Args...), nil, &stdout, &stderr) + if cerr := os.Chdir(wd); cerr != nil { + t.Fatal(cerr) + } + if code != 0 { + err = fmt.Errorf("%s exited with code %d", args.Command, code) + } else if diff := cmp.Diff(strings.TrimSpace(string(tc.Stdout)), strings.TrimSpace(stdout.String()), lineEndings()); diff != "" { + t.Errorf("stdout differed (-want +got):\n%s", diff) + } default: t.Fatalf("unknown command") } diff --git a/internal/endtoend/fmt_test.go b/internal/endtoend/fmt_test.go index eac3fa0390..f1be75bf4d 100644 --- a/internal/endtoend/fmt_test.go +++ b/internal/endtoend/fmt_test.go @@ -32,6 +32,10 @@ func TestFormat(t *testing.T) { t.Parallel() for _, tc := range FindTests(t, "testdata", "base") { tc := tc + // Config-less command tests (parse, analyze) have no config to format. + if tc.ConfigName == "" { + continue + } t.Run(tc.Name, func(t *testing.T) { // Parse the config file to determine the engine configPath := filepath.Join(tc.Path, tc.ConfigName) diff --git a/internal/endtoend/testdata/analyze_basic/postgresql/exec.json b/internal/endtoend/testdata/analyze_basic/postgresql/exec.json new file mode 100644 index 0000000000..b102755fb6 --- /dev/null +++ b/internal/endtoend/testdata/analyze_basic/postgresql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "postgresql", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_basic/postgresql/query.sql b/internal/endtoend/testdata/analyze_basic/postgresql/query.sql new file mode 100644 index 0000000000..55ef1faf82 --- /dev/null +++ b/internal/endtoend/testdata/analyze_basic/postgresql/query.sql @@ -0,0 +1,2 @@ +-- name: GetAuthor :one +SELECT * FROM authors WHERE id = $1; diff --git a/internal/endtoend/testdata/analyze_basic/postgresql/schema.sql b/internal/endtoend/testdata/analyze_basic/postgresql/schema.sql new file mode 100644 index 0000000000..69b607d902 --- /dev/null +++ b/internal/endtoend/testdata/analyze_basic/postgresql/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE authors ( + id BIGSERIAL PRIMARY KEY, + name text NOT NULL, + bio text +); diff --git a/internal/endtoend/testdata/analyze_basic/postgresql/stdout.txt b/internal/endtoend/testdata/analyze_basic/postgresql/stdout.txt new file mode 100644 index 0000000000..b93421c32a --- /dev/null +++ b/internal/endtoend/testdata/analyze_basic/postgresql/stdout.txt @@ -0,0 +1,41 @@ +[ + { + "name": "GetAuthor", + "cmd": ":one", + "columns": [ + { + "name": "id", + "data_type": "bigserial", + "not_null": true, + "is_array": false, + "table": "authors" + }, + { + "name": "name", + "data_type": "text", + "not_null": true, + "is_array": false, + "table": "authors" + }, + { + "name": "bio", + "data_type": "text", + "not_null": false, + "is_array": false, + "table": "authors" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "id", + "data_type": "bigserial", + "not_null": true, + "is_array": false, + "table": "authors" + } + } + ] + } +] diff --git a/internal/endtoend/testdata/parse_basic/postgresql/exec.json b/internal/endtoend/testdata/parse_basic/postgresql/exec.json new file mode 100644 index 0000000000..0a75ff458d --- /dev/null +++ b/internal/endtoend/testdata/parse_basic/postgresql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "parse", + "args": ["--dialect", "postgresql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/parse_basic/postgresql/query.sql b/internal/endtoend/testdata/parse_basic/postgresql/query.sql new file mode 100644 index 0000000000..11dff59f08 --- /dev/null +++ b/internal/endtoend/testdata/parse_basic/postgresql/query.sql @@ -0,0 +1,2 @@ +-- name: GetValue :one +SELECT 1; diff --git a/internal/endtoend/testdata/parse_basic/postgresql/stdout.txt b/internal/endtoend/testdata/parse_basic/postgresql/stdout.txt new file mode 100644 index 0000000000..fe35a664c7 --- /dev/null +++ b/internal/endtoend/testdata/parse_basic/postgresql/stdout.txt @@ -0,0 +1,60 @@ +[ + { + "name": "GetValue", + "cmd": ":one", + "ast": { + "Stmt": { + "DistinctClause": { + "Items": null + }, + "IntoClause": null, + "TargetList": { + "Items": [ + { + "Name": null, + "Indirection": { + "Items": null + }, + "Val": { + "Val": { + "Ival": 1 + }, + "Location": 30 + }, + "Location": 30 + } + ] + }, + "FromClause": { + "Items": null + }, + "WhereClause": {}, + "GroupClause": { + "Items": null + }, + "HavingClause": {}, + "WindowClause": { + "Items": null + }, + "ValuesLists": { + "Items": null + }, + "SortClause": { + "Items": null + }, + "LimitOffset": {}, + "LimitCount": {}, + "LockingClause": { + "Items": null + }, + "WithClause": null, + "Op": 0, + "All": false, + "Larg": null, + "Rarg": null + }, + "StmtLocation": 0, + "StmtLen": 31 + } + } +] From 46e9f157f6a104f9fd4c7a86f751f8ce3963048f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 16:23:39 +0000 Subject: [PATCH 6/6] Cover every supported engine for parse and analyze; fix flag reuse Convert the parse and analyze commands to constructor functions (newParseCmd/newAnalyzeCmd), matching the existing NewCmdVet pattern, so each Do invocation gets fresh flag state. Previously the shared package-level command vars leaked flag values (e.g. --ast) between calls, which surfaced when running multiple analyze cases in one test process. Add replay cases pinning the output format for each supported engine: parse for postgresql, mysql, sqlite, and clickhouse; analyze for postgresql, mysql, and sqlite; plus an analyze case exercising --ast. --- internal/cmd/analyze.go | 208 +++++++++--------- internal/cmd/cmd.go | 8 +- internal/cmd/parse.go | 168 +++++++------- .../testdata/analyze_ast/postgresql/exec.json | 5 + .../testdata/analyze_ast/postgresql/query.sql | 2 + .../analyze_ast/postgresql/schema.sql | 5 + .../analyze_ast/postgresql/stdout.txt | 122 ++++++++++ .../testdata/analyze_basic/mysql/exec.json | 5 + .../testdata/analyze_basic/mysql/query.sql | 2 + .../testdata/analyze_basic/mysql/schema.sql | 5 + .../testdata/analyze_basic/mysql/stdout.txt | 34 +++ .../testdata/analyze_basic/sqlite/exec.json | 5 + .../testdata/analyze_basic/sqlite/query.sql | 2 + .../testdata/analyze_basic/sqlite/schema.sql | 5 + .../testdata/analyze_basic/sqlite/stdout.txt | 34 +++ .../testdata/parse_basic/clickhouse/exec.json | 5 + .../testdata/parse_basic/clickhouse/query.sql | 2 + .../parse_basic/clickhouse/stdout.txt | 42 ++++ .../testdata/parse_basic/mysql/exec.json | 5 + .../testdata/parse_basic/mysql/query.sql | 2 + .../testdata/parse_basic/mysql/stdout.txt | 50 +++++ .../testdata/parse_basic/sqlite/exec.json | 5 + .../testdata/parse_basic/sqlite/query.sql | 2 + .../testdata/parse_basic/sqlite/stdout.txt | 52 +++++ 24 files changed, 586 insertions(+), 189 deletions(-) create mode 100644 internal/endtoend/testdata/analyze_ast/postgresql/exec.json create mode 100644 internal/endtoend/testdata/analyze_ast/postgresql/query.sql create mode 100644 internal/endtoend/testdata/analyze_ast/postgresql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_ast/postgresql/stdout.txt create mode 100644 internal/endtoend/testdata/analyze_basic/mysql/exec.json create mode 100644 internal/endtoend/testdata/analyze_basic/mysql/query.sql create mode 100644 internal/endtoend/testdata/analyze_basic/mysql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_basic/mysql/stdout.txt create mode 100644 internal/endtoend/testdata/analyze_basic/sqlite/exec.json create mode 100644 internal/endtoend/testdata/analyze_basic/sqlite/query.sql create mode 100644 internal/endtoend/testdata/analyze_basic/sqlite/schema.sql create mode 100644 internal/endtoend/testdata/analyze_basic/sqlite/stdout.txt create mode 100644 internal/endtoend/testdata/parse_basic/clickhouse/exec.json create mode 100644 internal/endtoend/testdata/parse_basic/clickhouse/query.sql create mode 100644 internal/endtoend/testdata/parse_basic/clickhouse/stdout.txt create mode 100644 internal/endtoend/testdata/parse_basic/mysql/exec.json create mode 100644 internal/endtoend/testdata/parse_basic/mysql/query.sql create mode 100644 internal/endtoend/testdata/parse_basic/mysql/stdout.txt create mode 100644 internal/endtoend/testdata/parse_basic/sqlite/exec.json create mode 100644 internal/endtoend/testdata/parse_basic/sqlite/query.sql create mode 100644 internal/endtoend/testdata/parse_basic/sqlite/stdout.txt diff --git a/internal/cmd/analyze.go b/internal/cmd/analyze.go index 4e83296ded..51de2605b6 100644 --- a/internal/cmd/analyze.go +++ b/internal/cmd/analyze.go @@ -16,10 +16,11 @@ import ( "github.com/sqlc-dev/sqlc/internal/sql/ast" ) -var analyzeCmd = &cobra.Command{ - Use: "analyze [query-file]", - Short: "Analyze a query against a schema and output the result columns and parameters", - Long: `Analyze a query file against a schema file and output the inferred result +func newAnalyzeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "analyze [query-file]", + Short: "Analyze a query against a schema and output the result columns and parameters", + Long: `Analyze a query file against a schema file and output the inferred result columns and parameters as JSON. Unlike "sqlc generate", this command does not require a configuration file and @@ -42,112 +43,117 @@ Examples: # Include the statement AST in the output sqlc analyze --dialect postgresql --schema schema.sql --ast query.sql`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - dialect, err := cmd.Flags().GetString("dialect") - if err != nil { - return err - } - if dialect == "" { - return fmt.Errorf("--dialect flag is required (postgresql, mysql, or sqlite)") - } - - schemaPath, err := cmd.Flags().GetString("schema") - if err != nil { - return err - } - if schemaPath == "" { - return fmt.Errorf("--schema flag is required") - } - - includeAST, err := cmd.Flags().GetBool("ast") - if err != nil { - return err - } - - // The query comes from a file argument or, when none is given, from - // stdin. The compiler reads queries from files, so stdin is written to - // a temporary file. - var queryPath string - if len(args) == 1 { - queryPath = args[0] - } else { - stat, err := os.Stdin.Stat() + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dialect, err := cmd.Flags().GetString("dialect") if err != nil { - return fmt.Errorf("failed to stat stdin: %w", err) + return err } - if (stat.Mode() & os.ModeCharDevice) != 0 { - return fmt.Errorf("no query provided. Specify a query file or pipe SQL via stdin") + if dialect == "" { + return fmt.Errorf("--dialect flag is required (postgresql, mysql, or sqlite)") } - data, err := io.ReadAll(cmd.InOrStdin()) + + schemaPath, err := cmd.Flags().GetString("schema") + if err != nil { + return err + } + if schemaPath == "" { + return fmt.Errorf("--schema flag is required") + } + + includeAST, err := cmd.Flags().GetBool("ast") if err != nil { - return fmt.Errorf("failed to read stdin: %w", err) + return err + } + + // The query comes from a file argument or, when none is given, from + // stdin. The compiler reads queries from files, so stdin is written to + // a temporary file. + var queryPath string + if len(args) == 1 { + queryPath = args[0] + } else { + stat, err := os.Stdin.Stat() + if err != nil { + return fmt.Errorf("failed to stat stdin: %w", err) + } + if (stat.Mode() & os.ModeCharDevice) != 0 { + return fmt.Errorf("no query provided. Specify a query file or pipe SQL via stdin") + } + data, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return fmt.Errorf("failed to read stdin: %w", err) + } + tmp, err := os.CreateTemp("", "sqlc-analyze-*.sql") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("failed to write temp file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + queryPath = tmp.Name() + } + + var engine config.Engine + switch dialect { + case "postgresql", "postgres", "pg": + engine = config.EnginePostgreSQL + case "mysql": + engine = config.EngineMySQL + case "sqlite": + engine = config.EngineSQLite + default: + return fmt.Errorf("unsupported dialect: %s (use postgresql, mysql, or sqlite)", dialect) + } + + sql := config.SQL{ + Engine: engine, + Schema: config.Paths{schemaPath}, + Queries: config.Paths{queryPath}, } - tmp, err := os.CreateTemp("", "sqlc-analyze-*.sql") + combo := config.Combine(config.Config{}, sql) + parserOpts := opts.Parser{} + + ctx := cmd.Context() + c, err := compiler.NewCompiler(sql, combo, parserOpts) if err != nil { - return fmt.Errorf("failed to create temp file: %w", err) + return fmt.Errorf("error creating compiler: %w", err) + } + defer c.Close(ctx) + + if err := c.ParseCatalog(sql.Schema); err != nil { + return fmt.Errorf("error parsing schema: %w", formatParseError(err)) + } + if err := c.ParseQueries(sql.Queries, parserOpts); err != nil { + return fmt.Errorf("error parsing queries: %w", formatParseError(err)) } - defer os.Remove(tmp.Name()) - if _, err := tmp.Write(data); err != nil { - tmp.Close() - return fmt.Errorf("failed to write temp file: %w", err) + + result := c.Result() + + out := make([]analyzedQuery, 0, len(result.Queries)) + for _, q := range result.Queries { + out = append(out, newAnalyzedQuery(q, includeAST)) } - if err := tmp.Close(); err != nil { - return fmt.Errorf("failed to close temp file: %w", err) + + stdout := cmd.OutOrStdout() + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(out); err != nil { + return fmt.Errorf("failed to encode analysis: %w", err) } - queryPath = tmp.Name() - } - - var engine config.Engine - switch dialect { - case "postgresql", "postgres", "pg": - engine = config.EnginePostgreSQL - case "mysql": - engine = config.EngineMySQL - case "sqlite": - engine = config.EngineSQLite - default: - return fmt.Errorf("unsupported dialect: %s (use postgresql, mysql, or sqlite)", dialect) - } - - sql := config.SQL{ - Engine: engine, - Schema: config.Paths{schemaPath}, - Queries: config.Paths{queryPath}, - } - combo := config.Combine(config.Config{}, sql) - parserOpts := opts.Parser{} - - ctx := cmd.Context() - c, err := compiler.NewCompiler(sql, combo, parserOpts) - if err != nil { - return fmt.Errorf("error creating compiler: %w", err) - } - defer c.Close(ctx) - - if err := c.ParseCatalog(sql.Schema); err != nil { - return fmt.Errorf("error parsing schema: %w", formatParseError(err)) - } - if err := c.ParseQueries(sql.Queries, parserOpts); err != nil { - return fmt.Errorf("error parsing queries: %w", formatParseError(err)) - } - - result := c.Result() - - out := make([]analyzedQuery, 0, len(result.Queries)) - for _, q := range result.Queries { - out = append(out, newAnalyzedQuery(q, includeAST)) - } - - stdout := cmd.OutOrStdout() - encoder := json.NewEncoder(stdout) - encoder.SetIndent("", " ") - if err := encoder.Encode(out); err != nil { - return fmt.Errorf("failed to encode analysis: %w", err) - } - - return nil - }, + + return nil + }, + } + cmd.Flags().StringP("dialect", "d", "", "SQL dialect to use (postgresql, mysql, or sqlite)") + cmd.Flags().StringP("schema", "s", "", "path to the schema file") + cmd.Flags().BoolP("ast", "", false, "include the statement AST in the output") + return cmd } // formatParseError unwraps a multierr.Error into a single error containing all diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index abef62d7e2..d1e83b2a12 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -32,10 +32,6 @@ func init() { initCmd.Flags().BoolP("v1", "", false, "generate v1 config yaml file") initCmd.Flags().BoolP("v2", "", true, "generate v2 config yaml file") initCmd.MarkFlagsMutuallyExclusive("v1", "v2") - parseCmd.Flags().StringP("dialect", "d", "", "SQL dialect to use (postgresql, mysql, or sqlite)") - analyzeCmd.Flags().StringP("dialect", "d", "", "SQL dialect to use (postgresql, mysql, or sqlite)") - analyzeCmd.Flags().StringP("schema", "s", "", "path to the schema file") - analyzeCmd.Flags().BoolP("ast", "", false, "include the statement AST in the output") } // Do runs the command logic. @@ -48,8 +44,8 @@ func Do(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int rootCmd.AddCommand(diffCmd) rootCmd.AddCommand(genCmd) rootCmd.AddCommand(initCmd) - rootCmd.AddCommand(parseCmd) - rootCmd.AddCommand(analyzeCmd) + rootCmd.AddCommand(newParseCmd()) + rootCmd.AddCommand(newAnalyzeCmd()) rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(verifyCmd) rootCmd.AddCommand(pushCmd) diff --git a/internal/cmd/parse.go b/internal/cmd/parse.go index f5934347dd..a68ad1bee8 100644 --- a/internal/cmd/parse.go +++ b/internal/cmd/parse.go @@ -35,10 +35,11 @@ type parsedStatement struct { AST *ast.RawStmt `json:"ast"` } -var parseCmd = &cobra.Command{ - Use: "parse [file]", - Short: "Parse SQL and output the AST as JSON", - Long: `Parse SQL from a file or stdin and output the abstract syntax tree as JSON. +func newParseCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "parse [file]", + Short: "Parse SQL and output the AST as JSON", + Long: `Parse SQL from a file or stdin and output the abstract syntax tree as JSON. Each statement is reported with its sqlc query name and command (when the statement carries a "-- name:" annotation) alongside the AST. @@ -55,90 +56,93 @@ Examples: # Parse ClickHouse SQL sqlc parse --dialect clickhouse queries.sql`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - dialect, err := cmd.Flags().GetString("dialect") - if err != nil { - return err - } - if dialect == "" { - return fmt.Errorf("--dialect flag is required (postgresql, mysql, sqlite, or clickhouse)") - } - - // Determine input source - var input io.Reader - if len(args) == 1 { - file, err := os.Open(args[0]) + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dialect, err := cmd.Flags().GetString("dialect") if err != nil { - return fmt.Errorf("failed to open file: %w", err) + return err } - defer file.Close() - input = file - } else { - // Check if stdin has data - stat, err := os.Stdin.Stat() - if err != nil { - return fmt.Errorf("failed to stat stdin: %w", err) + if dialect == "" { + return fmt.Errorf("--dialect flag is required (postgresql, mysql, sqlite, or clickhouse)") + } + + // Determine input source + var input io.Reader + if len(args) == 1 { + file, err := os.Open(args[0]) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + input = file + } else { + // Check if stdin has data + stat, err := os.Stdin.Stat() + if err != nil { + return fmt.Errorf("failed to stat stdin: %w", err) + } + if (stat.Mode() & os.ModeCharDevice) != 0 { + return fmt.Errorf("no input provided. Specify a file path or pipe SQL via stdin") + } + input = cmd.InOrStdin() } - if (stat.Mode() & os.ModeCharDevice) != 0 { - return fmt.Errorf("no input provided. Specify a file path or pipe SQL via stdin") + + // Select the parser for the requested dialect + var parser dialectParser + switch dialect { + case "postgresql", "postgres", "pg": + parser = postgresql.NewParser() + case "mysql": + parser = dolphin.NewParser() + case "sqlite": + parser = sqlite.NewParser() + case "clickhouse": + parser = clickhouse.NewParser() + default: + return fmt.Errorf("unsupported dialect: %s (use postgresql, mysql, sqlite, or clickhouse)", dialect) } - input = cmd.InOrStdin() - } - - // Select the parser for the requested dialect - var parser dialectParser - switch dialect { - case "postgresql", "postgres", "pg": - parser = postgresql.NewParser() - case "mysql": - parser = dolphin.NewParser() - case "sqlite": - parser = sqlite.NewParser() - case "clickhouse": - parser = clickhouse.NewParser() - default: - return fmt.Errorf("unsupported dialect: %s (use postgresql, mysql, sqlite, or clickhouse)", dialect) - } - - // Read the full source so each statement's name and command can be - // extracted from its annotation comment. - src, err := io.ReadAll(input) - if err != nil { - return fmt.Errorf("failed to read input: %w", err) - } - - stmts, err := parser.Parse(strings.NewReader(string(src))) - if err != nil { - return fmt.Errorf("parse error: %w", err) - } - - commentSyntax := metadata.CommentSyntax(parser.CommentSyntax()) - - // Output the AST as a single JSON document - out := make([]parsedStatement, 0, len(stmts)) - for _, stmt := range stmts { - ps := parsedStatement{AST: stmt.Raw} - rawSQL, err := source.Pluck(string(src), stmt.Raw.StmtLocation, stmt.Raw.StmtLen) + + // Read the full source so each statement's name and command can be + // extracted from its annotation comment. + src, err := io.ReadAll(input) if err != nil { - return fmt.Errorf("failed to read statement source: %w", err) + return fmt.Errorf("failed to read input: %w", err) } - name, cmd, err := metadata.ParseQueryNameAndType(rawSQL, commentSyntax) + + stmts, err := parser.Parse(strings.NewReader(string(src))) if err != nil { - return fmt.Errorf("failed to parse query annotation: %w", err) + return fmt.Errorf("parse error: %w", err) } - ps.Name = name - ps.Cmd = cmd - out = append(out, ps) - } - - stdout := cmd.OutOrStdout() - encoder := json.NewEncoder(stdout) - encoder.SetIndent("", " ") - if err := encoder.Encode(out); err != nil { - return fmt.Errorf("failed to encode AST: %w", err) - } - - return nil - }, + + commentSyntax := metadata.CommentSyntax(parser.CommentSyntax()) + + // Output the AST as a single JSON document + out := make([]parsedStatement, 0, len(stmts)) + for _, stmt := range stmts { + ps := parsedStatement{AST: stmt.Raw} + rawSQL, err := source.Pluck(string(src), stmt.Raw.StmtLocation, stmt.Raw.StmtLen) + if err != nil { + return fmt.Errorf("failed to read statement source: %w", err) + } + name, cmd, err := metadata.ParseQueryNameAndType(rawSQL, commentSyntax) + if err != nil { + return fmt.Errorf("failed to parse query annotation: %w", err) + } + ps.Name = name + ps.Cmd = cmd + out = append(out, ps) + } + + stdout := cmd.OutOrStdout() + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(out); err != nil { + return fmt.Errorf("failed to encode AST: %w", err) + } + + return nil + }, + } + cmd.Flags().StringP("dialect", "d", "", "SQL dialect to use (postgresql, mysql, sqlite, or clickhouse)") + return cmd } diff --git a/internal/endtoend/testdata/analyze_ast/postgresql/exec.json b/internal/endtoend/testdata/analyze_ast/postgresql/exec.json new file mode 100644 index 0000000000..7d04ef8cab --- /dev/null +++ b/internal/endtoend/testdata/analyze_ast/postgresql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "postgresql", "--schema", "schema.sql", "--ast", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_ast/postgresql/query.sql b/internal/endtoend/testdata/analyze_ast/postgresql/query.sql new file mode 100644 index 0000000000..17af794d2a --- /dev/null +++ b/internal/endtoend/testdata/analyze_ast/postgresql/query.sql @@ -0,0 +1,2 @@ +-- name: GetAuthorName :one +SELECT name FROM authors WHERE id = $1; diff --git a/internal/endtoend/testdata/analyze_ast/postgresql/schema.sql b/internal/endtoend/testdata/analyze_ast/postgresql/schema.sql new file mode 100644 index 0000000000..69b607d902 --- /dev/null +++ b/internal/endtoend/testdata/analyze_ast/postgresql/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE authors ( + id BIGSERIAL PRIMARY KEY, + name text NOT NULL, + bio text +); diff --git a/internal/endtoend/testdata/analyze_ast/postgresql/stdout.txt b/internal/endtoend/testdata/analyze_ast/postgresql/stdout.txt new file mode 100644 index 0000000000..b74264f687 --- /dev/null +++ b/internal/endtoend/testdata/analyze_ast/postgresql/stdout.txt @@ -0,0 +1,122 @@ +[ + { + "name": "GetAuthorName", + "cmd": ":one", + "columns": [ + { + "name": "name", + "data_type": "text", + "not_null": true, + "is_array": false, + "table": "authors" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "id", + "data_type": "bigserial", + "not_null": true, + "is_array": false, + "table": "authors" + } + } + ], + "ast": { + "Stmt": { + "DistinctClause": { + "Items": null + }, + "IntoClause": null, + "TargetList": { + "Items": [ + { + "Name": null, + "Indirection": { + "Items": null + }, + "Val": { + "Name": "", + "Fields": { + "Items": [ + { + "Str": "name" + } + ] + }, + "Location": 35 + }, + "Location": 35 + } + ] + }, + "FromClause": { + "Items": [ + { + "Catalogname": null, + "Schemaname": null, + "Relname": "authors", + "Inh": true, + "Relpersistence": 112, + "Alias": null, + "Location": 45 + } + ] + }, + "WhereClause": { + "Kind": 1, + "Name": { + "Items": [ + { + "Str": "=" + } + ] + }, + "Lexpr": { + "Name": "", + "Fields": { + "Items": [ + { + "Str": "id" + } + ] + }, + "Location": 59 + }, + "Rexpr": { + "Number": 1, + "Location": 64, + "Dollar": true + }, + "Location": 62 + }, + "GroupClause": { + "Items": null + }, + "HavingClause": {}, + "WindowClause": { + "Items": null + }, + "ValuesLists": { + "Items": null + }, + "SortClause": { + "Items": null + }, + "LimitOffset": {}, + "LimitCount": {}, + "LockingClause": { + "Items": null + }, + "WithClause": null, + "Op": 0, + "All": false, + "Larg": null, + "Rarg": null + }, + "StmtLocation": 0, + "StmtLen": 66 + } + } +] diff --git a/internal/endtoend/testdata/analyze_basic/mysql/exec.json b/internal/endtoend/testdata/analyze_basic/mysql/exec.json new file mode 100644 index 0000000000..a5b24d3361 --- /dev/null +++ b/internal/endtoend/testdata/analyze_basic/mysql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "mysql", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_basic/mysql/query.sql b/internal/endtoend/testdata/analyze_basic/mysql/query.sql new file mode 100644 index 0000000000..137c1d1a42 --- /dev/null +++ b/internal/endtoend/testdata/analyze_basic/mysql/query.sql @@ -0,0 +1,2 @@ +-- name: GetUser :one +SELECT id, name FROM users WHERE id = ?; diff --git a/internal/endtoend/testdata/analyze_basic/mysql/schema.sql b/internal/endtoend/testdata/analyze_basic/mysql/schema.sql new file mode 100644 index 0000000000..52f994807a --- /dev/null +++ b/internal/endtoend/testdata/analyze_basic/mysql/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE users ( + id BIGINT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + bio TEXT +); diff --git a/internal/endtoend/testdata/analyze_basic/mysql/stdout.txt b/internal/endtoend/testdata/analyze_basic/mysql/stdout.txt new file mode 100644 index 0000000000..e599e249aa --- /dev/null +++ b/internal/endtoend/testdata/analyze_basic/mysql/stdout.txt @@ -0,0 +1,34 @@ +[ + { + "name": "GetUser", + "cmd": ":one", + "columns": [ + { + "name": "id", + "data_type": "bigint", + "not_null": true, + "is_array": false, + "table": "users" + }, + { + "name": "name", + "data_type": "varchar", + "not_null": true, + "is_array": false, + "table": "users" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "id", + "data_type": "bigint", + "not_null": true, + "is_array": false, + "table": "users" + } + } + ] + } +] diff --git a/internal/endtoend/testdata/analyze_basic/sqlite/exec.json b/internal/endtoend/testdata/analyze_basic/sqlite/exec.json new file mode 100644 index 0000000000..aa77909cb2 --- /dev/null +++ b/internal/endtoend/testdata/analyze_basic/sqlite/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "sqlite", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_basic/sqlite/query.sql b/internal/endtoend/testdata/analyze_basic/sqlite/query.sql new file mode 100644 index 0000000000..137c1d1a42 --- /dev/null +++ b/internal/endtoend/testdata/analyze_basic/sqlite/query.sql @@ -0,0 +1,2 @@ +-- name: GetUser :one +SELECT id, name FROM users WHERE id = ?; diff --git a/internal/endtoend/testdata/analyze_basic/sqlite/schema.sql b/internal/endtoend/testdata/analyze_basic/sqlite/schema.sql new file mode 100644 index 0000000000..884e5c9a77 --- /dev/null +++ b/internal/endtoend/testdata/analyze_basic/sqlite/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + age INTEGER +); diff --git a/internal/endtoend/testdata/analyze_basic/sqlite/stdout.txt b/internal/endtoend/testdata/analyze_basic/sqlite/stdout.txt new file mode 100644 index 0000000000..9a80444890 --- /dev/null +++ b/internal/endtoend/testdata/analyze_basic/sqlite/stdout.txt @@ -0,0 +1,34 @@ +[ + { + "name": "GetUser", + "cmd": ":one", + "columns": [ + { + "name": "id", + "data_type": "INTEGER", + "not_null": true, + "is_array": false, + "table": "users" + }, + { + "name": "name", + "data_type": "TEXT", + "not_null": true, + "is_array": false, + "table": "users" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "id", + "data_type": "INTEGER", + "not_null": true, + "is_array": false, + "table": "users" + } + } + ] + } +] diff --git a/internal/endtoend/testdata/parse_basic/clickhouse/exec.json b/internal/endtoend/testdata/parse_basic/clickhouse/exec.json new file mode 100644 index 0000000000..9481db4c86 --- /dev/null +++ b/internal/endtoend/testdata/parse_basic/clickhouse/exec.json @@ -0,0 +1,5 @@ +{ + "command": "parse", + "args": ["--dialect", "clickhouse", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/parse_basic/clickhouse/query.sql b/internal/endtoend/testdata/parse_basic/clickhouse/query.sql new file mode 100644 index 0000000000..11dff59f08 --- /dev/null +++ b/internal/endtoend/testdata/parse_basic/clickhouse/query.sql @@ -0,0 +1,2 @@ +-- name: GetValue :one +SELECT 1; diff --git a/internal/endtoend/testdata/parse_basic/clickhouse/stdout.txt b/internal/endtoend/testdata/parse_basic/clickhouse/stdout.txt new file mode 100644 index 0000000000..e2c49df3fa --- /dev/null +++ b/internal/endtoend/testdata/parse_basic/clickhouse/stdout.txt @@ -0,0 +1,42 @@ +[ + { + "ast": { + "Stmt": { + "DistinctClause": null, + "IntoClause": null, + "TargetList": { + "Items": [ + { + "Name": null, + "Indirection": null, + "Val": { + "Val": { + "Ival": 1 + }, + "Location": 31 + }, + "Location": 31 + } + ] + }, + "FromClause": null, + "WhereClause": null, + "GroupClause": null, + "HavingClause": null, + "WindowClause": null, + "ValuesLists": null, + "SortClause": null, + "LimitOffset": null, + "LimitCount": null, + "LockingClause": null, + "WithClause": null, + "Op": 0, + "All": false, + "Larg": null, + "Rarg": null + }, + "StmtLocation": 24, + "StmtLen": 0 + } + } +] diff --git a/internal/endtoend/testdata/parse_basic/mysql/exec.json b/internal/endtoend/testdata/parse_basic/mysql/exec.json new file mode 100644 index 0000000000..b3326c09a0 --- /dev/null +++ b/internal/endtoend/testdata/parse_basic/mysql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "parse", + "args": ["--dialect", "mysql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/parse_basic/mysql/query.sql b/internal/endtoend/testdata/parse_basic/mysql/query.sql new file mode 100644 index 0000000000..11dff59f08 --- /dev/null +++ b/internal/endtoend/testdata/parse_basic/mysql/query.sql @@ -0,0 +1,2 @@ +-- name: GetValue :one +SELECT 1; diff --git a/internal/endtoend/testdata/parse_basic/mysql/stdout.txt b/internal/endtoend/testdata/parse_basic/mysql/stdout.txt new file mode 100644 index 0000000000..e9ed28784f --- /dev/null +++ b/internal/endtoend/testdata/parse_basic/mysql/stdout.txt @@ -0,0 +1,50 @@ +[ + { + "name": "GetValue", + "cmd": ":one", + "ast": { + "Stmt": { + "DistinctClause": null, + "IntoClause": null, + "TargetList": { + "Items": [ + { + "Name": null, + "Indirection": null, + "Val": { + "Val": { + "Ival": 1 + }, + "Location": 30 + }, + "Location": 30 + } + ] + }, + "FromClause": { + "Items": null + }, + "WhereClause": null, + "GroupClause": { + "Items": null + }, + "HavingClause": null, + "WindowClause": { + "Items": [] + }, + "ValuesLists": null, + "SortClause": null, + "LimitOffset": null, + "LimitCount": null, + "LockingClause": null, + "WithClause": null, + "Op": 0, + "All": false, + "Larg": null, + "Rarg": null + }, + "StmtLocation": 0, + "StmtLen": 31 + } + } +] diff --git a/internal/endtoend/testdata/parse_basic/sqlite/exec.json b/internal/endtoend/testdata/parse_basic/sqlite/exec.json new file mode 100644 index 0000000000..13abc589ed --- /dev/null +++ b/internal/endtoend/testdata/parse_basic/sqlite/exec.json @@ -0,0 +1,5 @@ +{ + "command": "parse", + "args": ["--dialect", "sqlite", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/parse_basic/sqlite/query.sql b/internal/endtoend/testdata/parse_basic/sqlite/query.sql new file mode 100644 index 0000000000..11dff59f08 --- /dev/null +++ b/internal/endtoend/testdata/parse_basic/sqlite/query.sql @@ -0,0 +1,2 @@ +-- name: GetValue :one +SELECT 1; diff --git a/internal/endtoend/testdata/parse_basic/sqlite/stdout.txt b/internal/endtoend/testdata/parse_basic/sqlite/stdout.txt new file mode 100644 index 0000000000..c1303a9a1e --- /dev/null +++ b/internal/endtoend/testdata/parse_basic/sqlite/stdout.txt @@ -0,0 +1,52 @@ +[ + { + "name": "GetValue", + "cmd": ":one", + "ast": { + "Stmt": { + "DistinctClause": null, + "IntoClause": null, + "TargetList": { + "Items": [ + { + "Name": null, + "Indirection": null, + "Val": { + "Val": { + "Ival": 1 + }, + "Location": 30 + }, + "Location": 30 + } + ] + }, + "FromClause": { + "Items": null + }, + "WhereClause": null, + "GroupClause": { + "Items": null + }, + "HavingClause": null, + "WindowClause": { + "Items": null + }, + "ValuesLists": { + "Items": null + }, + "SortClause": null, + "LimitOffset": null, + "LimitCount": null, + "LockingClause": null, + "WithClause": null, + "Op": 0, + "All": false, + "Larg": null, + "Rarg": null + }, + "StmtLocation": 0, + "StmtLen": 31 + } + } +]