Skip to content

Commit 3e0fca0

Browse files
authored
feat(cmd/sqlc): Add the vet subcommand (#2344)
This is a proposal to add a new subcommand that runs queries through a set of lint rules. Rules Rules are defined in the configuration file. They consist of a name, message, and an expression. If the expression evaluates to true, an error is reported. These expressions are evaluated using cel-go. Each expression has access to a query object, which is defined as the following struct: --------- Co-authored-by: Kyle Conroy <kyle@sqlc.dev>
1 parent 91c99b9 commit 3e0fca0

File tree

14 files changed

+1684
-126
lines changed

14 files changed

+1684
-126
lines changed

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/cubicdaiya/gonp v1.0.4
99
github.com/davecgh/go-spew v1.1.1
1010
github.com/go-sql-driver/mysql v1.7.1
11+
github.com/google/cel-go v0.16.0
1112
github.com/google/go-cmp v0.5.9
1213
github.com/jackc/pgconn v1.14.0
1314
github.com/jackc/pgx/v4 v4.18.1
@@ -24,6 +25,8 @@ require (
2425
gopkg.in/yaml.v3 v3.0.1
2526
)
2627

28+
require github.com/stoewer/go-strcase v1.2.0 // indirect
29+
2730
require (
2831
github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548 // indirect
2932
github.com/golang/protobuf v1.5.3 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
3636
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
3737
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
3838
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
39+
github.com/google/cel-go v0.16.0 h1:DG9YQ8nFCFXAs/FDDwBxmL1tpKNrdlGUM9U3537bX/Y=
40+
github.com/google/cel-go v0.16.0/go.mod h1:HXZKzB0LXqer5lHHgfWAnlYwJaQBDKMjxjulNQzhwhY=
3941
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
4042
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
4143
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -151,6 +153,8 @@ github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
151153
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
152154
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
153155
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
156+
github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
157+
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
154158
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
155159
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
156160
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=

internal/cmd/cmd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ func Do(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int
4444
rootCmd.AddCommand(initCmd)
4545
rootCmd.AddCommand(versionCmd)
4646
rootCmd.AddCommand(uploadCmd)
47+
rootCmd.AddCommand(NewCmdVet())
4748

4849
rootCmd.SetArgs(args)
4950
rootCmd.SetIn(stdin)

internal/cmd/vet.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"os"
9+
"path/filepath"
10+
"runtime/trace"
11+
"strings"
12+
13+
"github.com/google/cel-go/cel"
14+
"github.com/spf13/cobra"
15+
16+
"github.com/kyleconroy/sqlc/internal/config"
17+
"github.com/kyleconroy/sqlc/internal/debug"
18+
"github.com/kyleconroy/sqlc/internal/opts"
19+
"github.com/kyleconroy/sqlc/internal/plugin"
20+
)
21+
22+
var ErrFailedChecks = errors.New("failed checks")
23+
24+
func NewCmdVet() *cobra.Command {
25+
return &cobra.Command{
26+
Use: "vet",
27+
Short: "Vet examines queries",
28+
RunE: func(cmd *cobra.Command, args []string) error {
29+
defer trace.StartRegion(cmd.Context(), "vet").End()
30+
stderr := cmd.ErrOrStderr()
31+
dir, name := getConfigPath(stderr, cmd.Flag("file"))
32+
if err := Vet(cmd.Context(), ParseEnv(cmd), dir, name, stderr); err != nil {
33+
if !errors.Is(err, ErrFailedChecks) {
34+
fmt.Fprintf(stderr, "%s\n", err)
35+
}
36+
os.Exit(1)
37+
}
38+
return nil
39+
},
40+
}
41+
}
42+
43+
func Vet(ctx context.Context, e Env, dir, filename string, stderr io.Writer) error {
44+
configPath, conf, err := readConfig(stderr, dir, filename)
45+
if err != nil {
46+
return err
47+
}
48+
49+
base := filepath.Base(configPath)
50+
if err := config.Validate(conf); err != nil {
51+
fmt.Fprintf(stderr, "error validating %s: %s\n", base, err)
52+
return err
53+
}
54+
55+
if err := e.Validate(conf); err != nil {
56+
fmt.Fprintf(stderr, "error validating %s: %s\n", base, err)
57+
return err
58+
}
59+
60+
env, err := cel.NewEnv(
61+
cel.StdLib(),
62+
cel.Types(
63+
&plugin.VetConfig{},
64+
&plugin.VetQuery{},
65+
),
66+
cel.Variable("query",
67+
cel.ObjectType("plugin.VetQuery"),
68+
),
69+
cel.Variable("config",
70+
cel.ObjectType("plugin.VetConfig"),
71+
),
72+
)
73+
if err != nil {
74+
return fmt.Errorf("new env; %s", err)
75+
}
76+
77+
checks := map[string]cel.Program{}
78+
msgs := map[string]string{}
79+
80+
for _, c := range conf.Rules {
81+
if c.Name == "" {
82+
return fmt.Errorf("checks require a name")
83+
}
84+
if _, found := checks[c.Name]; found {
85+
return fmt.Errorf("type-check error: a check with the name '%s' already exists", c.Name)
86+
}
87+
if c.Rule == "" {
88+
return fmt.Errorf("type-check error: %s is empty", c.Name)
89+
}
90+
ast, issues := env.Compile(c.Rule)
91+
if issues != nil && issues.Err() != nil {
92+
return fmt.Errorf("type-check error: %s %s", c.Name, issues.Err())
93+
}
94+
prg, err := env.Program(ast)
95+
if err != nil {
96+
return fmt.Errorf("program construction error: %s %s", c.Name, err)
97+
}
98+
checks[c.Name] = prg
99+
msgs[c.Name] = c.Msg
100+
}
101+
102+
errored := true
103+
for _, sql := range conf.SQL {
104+
combo := config.Combine(*conf, sql)
105+
106+
// TODO: This feels like a hack that will bite us later
107+
joined := make([]string, 0, len(sql.Schema))
108+
for _, s := range sql.Schema {
109+
joined = append(joined, filepath.Join(dir, s))
110+
}
111+
sql.Schema = joined
112+
113+
joined = make([]string, 0, len(sql.Queries))
114+
for _, q := range sql.Queries {
115+
joined = append(joined, filepath.Join(dir, q))
116+
}
117+
sql.Queries = joined
118+
119+
var name string
120+
parseOpts := opts.Parser{
121+
Debug: debug.Debug,
122+
}
123+
124+
result, failed := parse(ctx, name, dir, sql, combo, parseOpts, stderr)
125+
if failed {
126+
return nil
127+
}
128+
req := codeGenRequest(result, combo)
129+
cfg := vetConfig(req)
130+
for _, query := range req.Queries {
131+
q := vetQuery(query)
132+
for _, name := range sql.Rules {
133+
prg, ok := checks[name]
134+
if !ok {
135+
return fmt.Errorf("type-check error: a check with the name '%s' does not exist", name)
136+
}
137+
out, _, err := prg.Eval(map[string]any{
138+
"query": q,
139+
"config": cfg,
140+
})
141+
if err != nil {
142+
return err
143+
}
144+
tripped, ok := out.Value().(bool)
145+
if !ok {
146+
return fmt.Errorf("expression returned non-bool: %s", err)
147+
}
148+
if tripped {
149+
// TODO: Get line numbers in the output
150+
msg := msgs[name]
151+
if msg == "" {
152+
fmt.Fprintf(stderr, query.Filename+": %s: %s\n", q.Name, name, msg)
153+
} else {
154+
fmt.Fprintf(stderr, query.Filename+": %s: %s: %s\n", q.Name, name, msg)
155+
}
156+
errored = true
157+
}
158+
}
159+
}
160+
}
161+
if errored {
162+
return ErrFailedChecks
163+
}
164+
return nil
165+
}
166+
167+
func vetConfig(req *plugin.CodeGenRequest) *plugin.VetConfig {
168+
return &plugin.VetConfig{
169+
Version: req.Settings.Version,
170+
Engine: req.Settings.Engine,
171+
Schema: req.Settings.Schema,
172+
Queries: req.Settings.Queries,
173+
}
174+
}
175+
176+
func vetQuery(q *plugin.Query) *plugin.VetQuery {
177+
var params []*plugin.VetParameter
178+
for _, p := range q.Params {
179+
params = append(params, &plugin.VetParameter{
180+
Number: p.Number,
181+
})
182+
}
183+
return &plugin.VetQuery{
184+
Sql: q.Text,
185+
Name: q.Name,
186+
Cmd: strings.TrimPrefix(":", q.Cmd),
187+
Params: params,
188+
}
189+
}

internal/config/config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ type Config struct {
6262
SQL []SQL `json:"sql" yaml:"sql"`
6363
Gen Gen `json:"overrides,omitempty" yaml:"overrides"`
6464
Plugins []Plugin `json:"plugins" yaml:"plugins"`
65+
Rules []Rule `json:"rules" yaml:"rules"`
6566
}
6667

6768
type Project struct {
@@ -85,6 +86,12 @@ type Plugin struct {
8586
} `json:"wasm" yaml:"wasm"`
8687
}
8788

89+
type Rule struct {
90+
Name string `json:"name" yaml:"name"`
91+
Rule string `json:"rule" yaml:"rule"`
92+
Msg string `json:"message" yaml:"message"`
93+
}
94+
8895
type Gen struct {
8996
Go *GenGo `json:"go,omitempty" yaml:"go"`
9097
}
@@ -102,6 +109,7 @@ type SQL struct {
102109
StrictOrderBy *bool `json:"strict_order_by" yaml:"strict_order_by"`
103110
Gen SQLGen `json:"gen" yaml:"gen"`
104111
Codegen []Codegen `json:"codegen" yaml:"codegen"`
112+
Rules []string `json:"rules" yaml:"rules"`
105113
}
106114

107115
// TODO: Figure out a better name for this

internal/endtoend/endtoend_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ func TestReplay(t *testing.T) {
123123
if err == nil {
124124
cmpDirectory(t, path, output)
125125
}
126+
case "vet":
127+
err = cmd.Vet(ctx, env, path, "", &stderr)
126128
default:
127129
t.Fatalf("unknown command")
128130
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"command": "vet"
3+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
CREATE TABLE authors (
2+
id BIGSERIAL PRIMARY KEY,
3+
name text NOT NULL,
4+
bio text
5+
);
6+
7+
-- name: GetAuthor :one
8+
SELECT * FROM authors
9+
WHERE id = $1 LIMIT 1;
10+
11+
-- name: ListAuthors :many
12+
SELECT * FROM authors
13+
ORDER BY name;
14+
15+
-- name: CreateAuthor :one
16+
INSERT INTO authors (
17+
name, bio
18+
) VALUES (
19+
$1, $2
20+
)
21+
RETURNING *;
22+
23+
-- name: DeleteAuthor :exec
24+
DELETE FROM authors
25+
WHERE id = $1;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
CREATE TABLE authors (
2+
id BIGSERIAL PRIMARY KEY,
3+
name text NOT NULL,
4+
bio text
5+
);
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
version: 2
2+
sql:
3+
- schema: "query.sql"
4+
queries: "query.sql"
5+
engine: "postgresql"
6+
gen:
7+
go:
8+
package: "authors"
9+
out: "db"
10+
rules:
11+
- no-pg
12+
- no-delete
13+
- only-one-param
14+
- no-exec
15+
rules:
16+
- name: no-pg
17+
message: "invalid engine: postgresql"
18+
rule: |
19+
config.engine == "postgresql"
20+
- name: no-delete
21+
message: "don't use delete statements"
22+
rule: |
23+
query.sql.contains("DELETE")
24+
- name: only-one-param
25+
message: "too many parameters"
26+
rule: |
27+
query.params.size() > 1
28+
- name: no-exec
29+
message: "don't use exec"
30+
rule: |
31+
query.cmd == "exec"
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
query.sql: GetAuthor: no-pg: invalid engine: postgresql
2+
query.sql: ListAuthors: no-pg: invalid engine: postgresql
3+
query.sql: CreateAuthor: no-pg: invalid engine: postgresql
4+
query.sql: CreateAuthor: only-one-param: too many parameters
5+
query.sql: DeleteAuthor: no-pg: invalid engine: postgresql
6+
query.sql: DeleteAuthor: no-delete: don't use delete statements

0 commit comments

Comments
 (0)