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 diff --git a/internal/cmd/analyze.go b/internal/cmd/analyze.go new file mode 100644 index 0000000000..51de2605b6 --- /dev/null +++ b/internal/cmd/analyze.go @@ -0,0 +1,235 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + "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" + "github.com/sqlc-dev/sqlc/internal/sql/ast" +) + +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 +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 + + # Analyze a query piped via stdin + echo "-- name: GetAuthor :one + 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") + 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() + 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}, + } + 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 + }, + } + 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 +// 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"` + AST *ast.RawStmt `json:"ast,omitempty"` +} + +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, includeAST bool) 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), + }) + } + if includeAST { + aq.AST = q.RawStmt + } + 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..d1e83b2a12 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -32,7 +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)") } // Do runs the command logic. @@ -45,7 +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(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 aca01511f1..a68ad1bee8 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,13 +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" ) -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. +// 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"` +} + +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. Examples: # Parse a SQL file with PostgreSQL dialect @@ -32,70 +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 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() + } + + // 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("failed to stat stdin: %w", err) + return fmt.Errorf("parse error: %w", err) } - if (stat.Mode() & os.ModeCharDevice) != 0 { - return fmt.Errorf("no input provided. Specify a file path or pipe SQL via stdin") + + 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) } - input = cmd.InOrStdin() - } - - // Parse SQL based on dialect - var stmts []ast.Statement - switch dialect { - case "postgresql", "postgres", "pg": - parser := postgresql.NewParser() - stmts, err = parser.Parse(input) - case "mysql": - parser := dolphin.NewParser() - stmts, err = parser.Parse(input) - case "sqlite": - parser := sqlite.NewParser() - stmts, err = parser.Parse(input) - case "clickhouse": - parser := clickhouse.NewParser() - stmts, err = parser.Parse(input) - default: - return fmt.Errorf("unsupported dialect: %s (use postgresql, mysql, sqlite, or clickhouse)", dialect) - } - if err != nil { - return fmt.Errorf("parse error: %w", err) - } - - // Output AST as JSON - stdout := cmd.OutOrStdout() - encoder := json.NewEncoder(stdout) - encoder.SetIndent("", " ") - - for _, stmt := range stmts { - if err := encoder.Encode(stmt.Raw); err != nil { + + 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 - }, + return nil + }, + } + cmd.Flags().StringP("dialect", "d", "", "SQL dialect to use (postgresql, mysql, sqlite, or clickhouse)") + return cmd } 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_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/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/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/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 + } + } +] 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 + } + } +]