-
Notifications
You must be signed in to change notification settings - Fork 835
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
cmd/sqlc: Add the vet subcommand #2344
Changes from all commits
56d8a7d
bf4db61
54da4ec
617ba6a
c30b56a
fc53926
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,189 @@ | ||
package cmd | ||
|
||
import ( | ||
"context" | ||
"errors" | ||
"fmt" | ||
"io" | ||
"os" | ||
"path/filepath" | ||
"runtime/trace" | ||
"strings" | ||
|
||
"github.com/google/cel-go/cel" | ||
"github.com/spf13/cobra" | ||
|
||
"github.com/kyleconroy/sqlc/internal/config" | ||
"github.com/kyleconroy/sqlc/internal/debug" | ||
"github.com/kyleconroy/sqlc/internal/opts" | ||
"github.com/kyleconroy/sqlc/internal/plugin" | ||
) | ||
|
||
var ErrFailedChecks = errors.New("failed checks") | ||
|
||
func NewCmdVet() *cobra.Command { | ||
return &cobra.Command{ | ||
Use: "vet", | ||
Short: "Vet examines queries", | ||
RunE: func(cmd *cobra.Command, args []string) error { | ||
defer trace.StartRegion(cmd.Context(), "vet").End() | ||
stderr := cmd.ErrOrStderr() | ||
dir, name := getConfigPath(stderr, cmd.Flag("file")) | ||
if err := Vet(cmd.Context(), ParseEnv(cmd), dir, name, stderr); err != nil { | ||
if !errors.Is(err, ErrFailedChecks) { | ||
fmt.Fprintf(stderr, "%s\n", err) | ||
} | ||
os.Exit(1) | ||
} | ||
return nil | ||
}, | ||
} | ||
} | ||
|
||
func Vet(ctx context.Context, e Env, dir, filename string, stderr io.Writer) error { | ||
configPath, conf, err := readConfig(stderr, dir, filename) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
base := filepath.Base(configPath) | ||
if err := config.Validate(conf); err != nil { | ||
fmt.Fprintf(stderr, "error validating %s: %s\n", base, err) | ||
return err | ||
} | ||
|
||
if err := e.Validate(conf); err != nil { | ||
fmt.Fprintf(stderr, "error validating %s: %s\n", base, err) | ||
return err | ||
} | ||
|
||
env, err := cel.NewEnv( | ||
cel.StdLib(), | ||
cel.Types( | ||
&plugin.VetConfig{}, | ||
&plugin.VetQuery{}, | ||
), | ||
cel.Variable("query", | ||
cel.ObjectType("plugin.VetQuery"), | ||
), | ||
cel.Variable("config", | ||
cel.ObjectType("plugin.VetConfig"), | ||
), | ||
) | ||
if err != nil { | ||
return fmt.Errorf("new env; %s", err) | ||
} | ||
|
||
checks := map[string]cel.Program{} | ||
msgs := map[string]string{} | ||
|
||
for _, c := range conf.Rules { | ||
if c.Name == "" { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Did you consider putting some of this basic validation inside of a shared config parse/validation area? Not sure if config.Validate() would be the right spot, or somewhere else, or maybe this idea just isn't correct if you think each subcommand should be responsible for validating its own "section" of the config (if it has one)? |
||
return fmt.Errorf("checks require a name") | ||
} | ||
if _, found := checks[c.Name]; found { | ||
return fmt.Errorf("type-check error: a check with the name '%s' already exists", c.Name) | ||
} | ||
if c.Rule == "" { | ||
return fmt.Errorf("type-check error: %s is empty", c.Name) | ||
} | ||
ast, issues := env.Compile(c.Rule) | ||
if issues != nil && issues.Err() != nil { | ||
return fmt.Errorf("type-check error: %s %s", c.Name, issues.Err()) | ||
} | ||
prg, err := env.Program(ast) | ||
if err != nil { | ||
return fmt.Errorf("program construction error: %s %s", c.Name, err) | ||
} | ||
checks[c.Name] = prg | ||
msgs[c.Name] = c.Msg | ||
} | ||
|
||
errored := true | ||
for _, sql := range conf.SQL { | ||
combo := config.Combine(*conf, sql) | ||
|
||
// TODO: This feels like a hack that will bite us later | ||
joined := make([]string, 0, len(sql.Schema)) | ||
for _, s := range sql.Schema { | ||
joined = append(joined, filepath.Join(dir, s)) | ||
} | ||
sql.Schema = joined | ||
|
||
joined = make([]string, 0, len(sql.Queries)) | ||
for _, q := range sql.Queries { | ||
joined = append(joined, filepath.Join(dir, q)) | ||
} | ||
sql.Queries = joined | ||
|
||
var name string | ||
parseOpts := opts.Parser{ | ||
Debug: debug.Debug, | ||
} | ||
|
||
result, failed := parse(ctx, name, dir, sql, combo, parseOpts, stderr) | ||
if failed { | ||
return nil | ||
} | ||
req := codeGenRequest(result, combo) | ||
cfg := vetConfig(req) | ||
for _, query := range req.Queries { | ||
q := vetQuery(query) | ||
for _, name := range sql.Rules { | ||
prg, ok := checks[name] | ||
if !ok { | ||
return fmt.Errorf("type-check error: a check with the name '%s' does not exist", name) | ||
} | ||
out, _, err := prg.Eval(map[string]any{ | ||
"query": q, | ||
"config": cfg, | ||
}) | ||
if err != nil { | ||
return err | ||
} | ||
tripped, ok := out.Value().(bool) | ||
if !ok { | ||
return fmt.Errorf("expression returned non-bool: %s", err) | ||
} | ||
if tripped { | ||
// TODO: Get line numbers in the output | ||
msg := msgs[name] | ||
if msg == "" { | ||
fmt.Fprintf(stderr, query.Filename+": %s: %s\n", q.Name, name, msg) | ||
} else { | ||
fmt.Fprintf(stderr, query.Filename+": %s: %s: %s\n", q.Name, name, msg) | ||
} | ||
errored = true | ||
} | ||
} | ||
} | ||
} | ||
if errored { | ||
return ErrFailedChecks | ||
} | ||
return nil | ||
} | ||
|
||
func vetConfig(req *plugin.CodeGenRequest) *plugin.VetConfig { | ||
return &plugin.VetConfig{ | ||
Version: req.Settings.Version, | ||
Engine: req.Settings.Engine, | ||
Schema: req.Settings.Schema, | ||
Queries: req.Settings.Queries, | ||
} | ||
} | ||
|
||
func vetQuery(q *plugin.Query) *plugin.VetQuery { | ||
var params []*plugin.VetParameter | ||
for _, p := range q.Params { | ||
params = append(params, &plugin.VetParameter{ | ||
Number: p.Number, | ||
}) | ||
} | ||
return &plugin.VetQuery{ | ||
Sql: q.Text, | ||
Name: q.Name, | ||
Cmd: strings.TrimPrefix(":", q.Cmd), | ||
Params: params, | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"command": "vet" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
CREATE TABLE authors ( | ||
id BIGSERIAL PRIMARY KEY, | ||
name text NOT NULL, | ||
bio text | ||
); | ||
|
||
-- name: GetAuthor :one | ||
SELECT * FROM authors | ||
WHERE id = $1 LIMIT 1; | ||
|
||
-- name: ListAuthors :many | ||
SELECT * FROM authors | ||
ORDER BY name; | ||
|
||
-- name: CreateAuthor :one | ||
INSERT INTO authors ( | ||
name, bio | ||
) VALUES ( | ||
$1, $2 | ||
) | ||
RETURNING *; | ||
|
||
-- name: DeleteAuthor :exec | ||
DELETE FROM authors | ||
WHERE id = $1; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
CREATE TABLE authors ( | ||
id BIGSERIAL PRIMARY KEY, | ||
name text NOT NULL, | ||
bio text | ||
); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
version: 2 | ||
sql: | ||
- schema: "query.sql" | ||
queries: "query.sql" | ||
engine: "postgresql" | ||
gen: | ||
go: | ||
package: "authors" | ||
out: "db" | ||
rules: | ||
- no-pg | ||
- no-delete | ||
- only-one-param | ||
- no-exec | ||
rules: | ||
- name: no-pg | ||
message: "invalid engine: postgresql" | ||
rule: | | ||
config.engine == "postgresql" | ||
- name: no-delete | ||
message: "don't use delete statements" | ||
rule: | | ||
query.sql.contains("DELETE") | ||
- name: only-one-param | ||
message: "too many parameters" | ||
rule: | | ||
query.params.size() > 1 | ||
- name: no-exec | ||
message: "don't use exec" | ||
rule: | | ||
query.cmd == "exec" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
query.sql: GetAuthor: no-pg: invalid engine: postgresql | ||
query.sql: ListAuthors: no-pg: invalid engine: postgresql | ||
query.sql: CreateAuthor: no-pg: invalid engine: postgresql | ||
query.sql: CreateAuthor: only-one-param: too many parameters | ||
query.sql: DeleteAuthor: no-pg: invalid engine: postgresql | ||
query.sql: DeleteAuthor: no-delete: don't use delete statements |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should have ended up in the longer list of indirect dependencies below right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure honestly. I'll make sure to run
go mod tidy
as that will put it in the right spot.