Skip to content
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

Merged
merged 6 commits into from
Jun 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/cubicdaiya/gonp v1.0.4
github.com/davecgh/go-spew v1.1.1
github.com/go-sql-driver/mysql v1.7.1
github.com/google/cel-go v0.16.0
github.com/google/go-cmp v0.5.9
github.com/jackc/pgconn v1.14.0
github.com/jackc/pgx/v4 v4.18.1
Expand All @@ -24,6 +25,8 @@ require (
gopkg.in/yaml.v3 v3.0.1
)

require github.com/stoewer/go-strcase v1.2.0 // indirect
Copy link
Collaborator

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?

Copy link
Collaborator Author

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.


require (
github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548 // indirect
github.com/golang/protobuf v1.5.3 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/cel-go v0.16.0 h1:DG9YQ8nFCFXAs/FDDwBxmL1tpKNrdlGUM9U3537bX/Y=
github.com/google/cel-go v0.16.0/go.mod h1:HXZKzB0LXqer5lHHgfWAnlYwJaQBDKMjxjulNQzhwhY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
Expand Down Expand Up @@ -151,6 +153,8 @@ github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func Do(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int
rootCmd.AddCommand(initCmd)
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(uploadCmd)
rootCmd.AddCommand(NewCmdVet())

rootCmd.SetArgs(args)
rootCmd.SetIn(stdin)
Expand Down
189 changes: 189 additions & 0 deletions internal/cmd/vet.go
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 == "" {
Copy link
Collaborator

Choose a reason for hiding this comment

The 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,
}
}
8 changes: 8 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ type Config struct {
SQL []SQL `json:"sql" yaml:"sql"`
Gen Gen `json:"overrides,omitempty" yaml:"overrides"`
Plugins []Plugin `json:"plugins" yaml:"plugins"`
Rules []Rule `json:"rules" yaml:"rules"`
}

type Project struct {
Expand All @@ -85,6 +86,12 @@ type Plugin struct {
} `json:"wasm" yaml:"wasm"`
}

type Rule struct {
Name string `json:"name" yaml:"name"`
Rule string `json:"rule" yaml:"rule"`
Msg string `json:"message" yaml:"message"`
}

type Gen struct {
Go *GenGo `json:"go,omitempty" yaml:"go"`
}
Expand All @@ -102,6 +109,7 @@ type SQL struct {
StrictOrderBy *bool `json:"strict_order_by" yaml:"strict_order_by"`
Gen SQLGen `json:"gen" yaml:"gen"`
Codegen []Codegen `json:"codegen" yaml:"codegen"`
Rules []string `json:"rules" yaml:"rules"`
}

// TODO: Figure out a better name for this
Expand Down
2 changes: 2 additions & 0 deletions internal/endtoend/endtoend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ func TestReplay(t *testing.T) {
if err == nil {
cmpDirectory(t, path, output)
}
case "vet":
err = cmd.Vet(ctx, env, path, "", &stderr)
default:
t.Fatalf("unknown command")
}
Expand Down
3 changes: 3 additions & 0 deletions internal/endtoend/testdata/vet_failures/exec.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"command": "vet"
}
25 changes: 25 additions & 0 deletions internal/endtoend/testdata/vet_failures/query.sql
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;
5 changes: 5 additions & 0 deletions internal/endtoend/testdata/vet_failures/schema.sql
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
);
31 changes: 31 additions & 0 deletions internal/endtoend/testdata/vet_failures/sqlc.yaml
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"
6 changes: 6 additions & 0 deletions internal/endtoend/testdata/vet_failures/stderr.txt
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
Loading