From d8c512f2816939b805a755cb9afe4d18d68ec21b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 16:22:50 +0000 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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 + } + } +] From 29b615b3ec43ee4ac679833e0bef3e41d72a91ce Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 16:05:34 +0000 Subject: [PATCH 7/7] Document the parse and analyze commands Add how-to pages for the new parse and analyze commands, list them in the CLI reference, and register them in the Commands toctree. --- docs/howto/analyze.md | 97 +++++++++++++++++++++++++++++++++++++++++++ docs/howto/parse.md | 58 ++++++++++++++++++++++++++ docs/index.rst | 2 + docs/reference/cli.md | 2 + 4 files changed, 159 insertions(+) create mode 100644 docs/howto/analyze.md create mode 100644 docs/howto/parse.md diff --git a/docs/howto/analyze.md b/docs/howto/analyze.md new file mode 100644 index 0000000000..d3e0a60771 --- /dev/null +++ b/docs/howto/analyze.md @@ -0,0 +1,97 @@ +# `analyze` - Analyzing query result types + +`sqlc analyze` analyzes a query against a schema and prints the inferred result +columns and parameters as a single JSON document. + +Unlike [`generate`](generate.md), 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 directly from the provided schema. + +## Usage + +```sh +sqlc analyze --dialect --schema [query-file] +``` + +The query is read from the given file, or from standard input when no file is +provided. The schema is always read from the `--schema` file. + +## Flags + +- `--dialect`, `-d` - The SQL dialect to use. One of `postgresql`, `mysql`, or + `sqlite`. Required. +- `--schema`, `-s` - Path to the schema (DDL) file. Required. +- `--ast` - Include each statement's AST in the output. Defaults to `false`. + +## Examples + +Given a schema in `schema.sql`: + +```sql +CREATE TABLE authors ( + id BIGSERIAL PRIMARY KEY, + name text NOT NULL, + bio text +); +``` + +and a query in `query.sql`: + +```sql +-- name: GetAuthor :one +SELECT * FROM authors WHERE id = $1; +``` + +Running: + +```sh +sqlc analyze --dialect postgresql --schema schema.sql query.sql +``` + +reports the result columns and parameters: + +```json +[ + { + "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" + } + } + ] + } +] +``` + +Pass `--ast` to also include each statement's parsed AST under an `ast` key. diff --git a/docs/howto/parse.md b/docs/howto/parse.md new file mode 100644 index 0000000000..406cc12673 --- /dev/null +++ b/docs/howto/parse.md @@ -0,0 +1,58 @@ +# `parse` - Parsing SQL into an AST + +`sqlc parse` parses SQL from a file or standard input and prints the abstract +syntax tree (AST) as a single JSON document. It does not require a configuration +file or a database connection. + +Each statement is reported with its sqlc query name and command (when the +statement carries a [`-- name:`](../reference/query-annotations.md) annotation) +alongside its AST. + +## Usage + +```sh +sqlc parse --dialect [file] +``` + +The SQL is read from the given file, or from standard input when no file is +provided. + +## Flags + +- `--dialect`, `-d` - The SQL dialect to use. One of `postgresql`, `mysql`, + `sqlite`, or `clickhouse`. Required. + +## Examples + +Parse a query file: + +```sh +sqlc parse --dialect postgresql query.sql +``` + +Parse SQL piped via standard input: + +```sh +echo "SELECT 1;" | sqlc parse --dialect mysql +``` + +The output is a JSON array with one object per statement: + +```json +[ + { + "name": "GetAuthor", + "cmd": ":one", + "ast": { + "Stmt": { + "...": "..." + }, + "StmtLocation": 0, + "StmtLen": 42 + } + } +] +``` + +Statements without a `-- name:` annotation (for example schema DDL) omit the +`name` and `cmd` fields. diff --git a/docs/index.rst b/docs/index.rst index f914f3ec41..f11c8903d5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -41,7 +41,9 @@ code ever again. :caption: Commands :hidden: + howto/analyze.md howto/generate.md + howto/parse.md howto/push.md howto/verify.md howto/vet.md diff --git a/docs/reference/cli.md b/docs/reference/cli.md index dddd3e113b..b0cf1eb778 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -5,6 +5,7 @@ Usage: sqlc [command] Available Commands: + analyze Analyze a query against a schema and output the result columns and parameters compile Statically check SQL for syntax and type errors completion Generate the autocompletion script for the specified shell createdb Create an ephemeral database @@ -12,6 +13,7 @@ Available Commands: generate Generate source code from SQL help Help about any command init Create an empty sqlc.yaml settings file + parse Parse SQL and output the AST as JSON push Push the schema, queries, and configuration for this project verify Verify schema, queries, and configuration for this project version Print the sqlc version number