Skip to content

Commit d8c512f

Browse files
committed
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
1 parent a3b0cfd commit d8c512f

2 files changed

Lines changed: 182 additions & 0 deletions

File tree

internal/cmd/analyze.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/spf13/cobra"
9+
10+
"github.com/sqlc-dev/sqlc/internal/compiler"
11+
"github.com/sqlc-dev/sqlc/internal/config"
12+
"github.com/sqlc-dev/sqlc/internal/multierr"
13+
"github.com/sqlc-dev/sqlc/internal/opts"
14+
)
15+
16+
var analyzeCmd = &cobra.Command{
17+
Use: "analyze [query-file]",
18+
Short: "Analyze a query against a schema and output the result columns and parameters",
19+
Long: `Analyze a query file against a schema file and output the inferred result
20+
columns and parameters as JSON.
21+
22+
Unlike "sqlc generate", this command does not require a configuration file and
23+
does not connect to a database. It uses sqlc's native static analysis to infer
24+
types from the provided schema.
25+
26+
Examples:
27+
# Analyze a PostgreSQL query
28+
sqlc analyze --dialect postgresql --schema schema.sql query.sql
29+
30+
# Analyze a MySQL query
31+
sqlc analyze --dialect mysql --schema schema.sql query.sql
32+
33+
# Analyze a SQLite query
34+
sqlc analyze --dialect sqlite --schema schema.sql query.sql`,
35+
Args: cobra.ExactArgs(1),
36+
RunE: func(cmd *cobra.Command, args []string) error {
37+
dialect, err := cmd.Flags().GetString("dialect")
38+
if err != nil {
39+
return err
40+
}
41+
if dialect == "" {
42+
return fmt.Errorf("--dialect flag is required (postgresql, mysql, or sqlite)")
43+
}
44+
45+
schemaPath, err := cmd.Flags().GetString("schema")
46+
if err != nil {
47+
return err
48+
}
49+
if schemaPath == "" {
50+
return fmt.Errorf("--schema flag is required")
51+
}
52+
53+
queryPath := args[0]
54+
55+
var engine config.Engine
56+
switch dialect {
57+
case "postgresql", "postgres", "pg":
58+
engine = config.EnginePostgreSQL
59+
case "mysql":
60+
engine = config.EngineMySQL
61+
case "sqlite":
62+
engine = config.EngineSQLite
63+
default:
64+
return fmt.Errorf("unsupported dialect: %s (use postgresql, mysql, or sqlite)", dialect)
65+
}
66+
67+
sql := config.SQL{
68+
Engine: engine,
69+
Schema: config.Paths{schemaPath},
70+
Queries: config.Paths{queryPath},
71+
}
72+
combo := config.Combine(config.Config{}, sql)
73+
parserOpts := opts.Parser{}
74+
75+
ctx := cmd.Context()
76+
c, err := compiler.NewCompiler(sql, combo, parserOpts)
77+
if err != nil {
78+
return fmt.Errorf("error creating compiler: %w", err)
79+
}
80+
defer c.Close(ctx)
81+
82+
if err := c.ParseCatalog(sql.Schema); err != nil {
83+
return fmt.Errorf("error parsing schema: %w", formatParseError(err))
84+
}
85+
if err := c.ParseQueries(sql.Queries, parserOpts); err != nil {
86+
return fmt.Errorf("error parsing queries: %w", formatParseError(err))
87+
}
88+
89+
result := c.Result()
90+
91+
out := make([]analyzedQuery, 0, len(result.Queries))
92+
for _, q := range result.Queries {
93+
out = append(out, newAnalyzedQuery(q))
94+
}
95+
96+
stdout := cmd.OutOrStdout()
97+
encoder := json.NewEncoder(stdout)
98+
encoder.SetIndent("", " ")
99+
if err := encoder.Encode(out); err != nil {
100+
return fmt.Errorf("failed to encode analysis: %w", err)
101+
}
102+
103+
return nil
104+
},
105+
}
106+
107+
// formatParseError unwraps a multierr.Error into a single error containing all
108+
// of the underlying file errors, so the analyze command can report each one with
109+
// its file location.
110+
func formatParseError(err error) error {
111+
parserErr, ok := err.(*multierr.Error)
112+
if !ok {
113+
return err
114+
}
115+
var msgs []string
116+
for _, fileErr := range parserErr.Errs() {
117+
msgs = append(msgs, fmt.Sprintf("%s:%d:%d: %s",
118+
fileErr.Filename, fileErr.Line, fileErr.Column, fileErr.Err))
119+
}
120+
if len(msgs) == 0 {
121+
return err
122+
}
123+
return fmt.Errorf("%s", strings.Join(msgs, "; "))
124+
}
125+
126+
type analyzedQuery struct {
127+
Name string `json:"name"`
128+
Cmd string `json:"cmd"`
129+
Columns []analyzedColumn `json:"columns"`
130+
Params []analyzedParam `json:"params"`
131+
}
132+
133+
type analyzedColumn struct {
134+
Name string `json:"name"`
135+
DataType string `json:"data_type"`
136+
NotNull bool `json:"not_null"`
137+
IsArray bool `json:"is_array"`
138+
Table string `json:"table,omitempty"`
139+
}
140+
141+
type analyzedParam struct {
142+
Number int `json:"number"`
143+
Column analyzedColumn `json:"column"`
144+
}
145+
146+
func newAnalyzedQuery(q *compiler.Query) analyzedQuery {
147+
aq := analyzedQuery{
148+
Name: q.Metadata.Name,
149+
Cmd: q.Metadata.Cmd,
150+
Columns: make([]analyzedColumn, 0, len(q.Columns)),
151+
Params: make([]analyzedParam, 0, len(q.Params)),
152+
}
153+
for _, col := range q.Columns {
154+
aq.Columns = append(aq.Columns, newAnalyzedColumn(col))
155+
}
156+
for _, p := range q.Params {
157+
aq.Params = append(aq.Params, analyzedParam{
158+
Number: p.Number,
159+
Column: newAnalyzedColumn(p.Column),
160+
})
161+
}
162+
return aq
163+
}
164+
165+
func newAnalyzedColumn(col *compiler.Column) analyzedColumn {
166+
if col == nil {
167+
return analyzedColumn{}
168+
}
169+
ac := analyzedColumn{
170+
Name: col.Name,
171+
DataType: col.DataType,
172+
NotNull: col.NotNull,
173+
IsArray: col.IsArray,
174+
}
175+
if col.Table != nil {
176+
ac.Table = col.Table.Name
177+
}
178+
return ac
179+
}

internal/cmd/cmd.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ func init() {
3333
initCmd.Flags().BoolP("v2", "", true, "generate v2 config yaml file")
3434
initCmd.MarkFlagsMutuallyExclusive("v1", "v2")
3535
parseCmd.Flags().StringP("dialect", "d", "", "SQL dialect to use (postgresql, mysql, or sqlite)")
36+
analyzeCmd.Flags().StringP("dialect", "d", "", "SQL dialect to use (postgresql, mysql, or sqlite)")
37+
analyzeCmd.Flags().StringP("schema", "s", "", "path to the schema file")
3638
}
3739

3840
// Do runs the command logic.
@@ -46,6 +48,7 @@ func Do(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int
4648
rootCmd.AddCommand(genCmd)
4749
rootCmd.AddCommand(initCmd)
4850
rootCmd.AddCommand(parseCmd)
51+
rootCmd.AddCommand(analyzeCmd)
4952
rootCmd.AddCommand(versionCmd)
5053
rootCmd.AddCommand(verifyCmd)
5154
rootCmd.AddCommand(pushCmd)

0 commit comments

Comments
 (0)