Files
upstream-mirror 0f4df23290
Some checks failed
kotlin / test (push) Has been skipped
python / test (push) Has been skipped
typescript / test (push) Has been skipped
go / build darwin/amd64 (push) Has been cancelled
go / build linux/amd64 (push) Has been cancelled
go / build windows/amd64 (push) Has been cancelled
go / build darwin/arm64 (push) Has been cancelled
go / build linux/arm64 (push) Has been cancelled
go / build windows/arm64 (push) Has been cancelled
go / test (push) Has been cancelled
go / vuln_check (push) Has been cancelled
snapshot of https://github.com/sqlc-dev/sqlc.git (main, 2026-09-16; blobs > 131072B omitted)
2026-09-16 02:17:34 +05:00

710 lines
18 KiB
Go

package cmd
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/sqlc-dev/sqlc/internal/constants"
"io"
"log"
"os"
"path/filepath"
"runtime/trace"
"slices"
"strings"
"sync"
"time"
_ "github.com/go-sql-driver/mysql"
"cel.dev/cel-go/cel"
"cel.dev/cel-go/ext"
"github.com/jackc/pgx/v5"
"github.com/spf13/cobra"
"google.golang.org/protobuf/encoding/protojson"
"github.com/sqlc-dev/sqlc/internal/config"
"github.com/sqlc-dev/sqlc/internal/dbmanager"
"github.com/sqlc-dev/sqlc/internal/debug"
"github.com/sqlc-dev/sqlc/internal/migrations"
"github.com/sqlc-dev/sqlc/internal/opts"
"github.com/sqlc-dev/sqlc/internal/plugin"
"github.com/sqlc-dev/sqlc/internal/quickdb"
"github.com/sqlc-dev/sqlc/internal/shfmt"
"github.com/sqlc-dev/sqlc/internal/sql/sqlpath"
"github.com/sqlc-dev/sqlc/internal/sqlcdebug"
"github.com/sqlc-dev/sqlc/internal/vet"
)
var (
debugDumpExplain = sqlcdebug.New("dumpexplain")
debugDumpVetEnv = sqlcdebug.New("dumpvetenv")
debugDatabases = sqlcdebug.New("databases")
)
var ErrFailedChecks = errors.New("failed checks")
var pjson = protojson.UnmarshalOptions{AllowPartial: true, DiscardUnknown: true}
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()
opts := &Options{
Env: ParseEnv(cmd),
Stderr: stderr,
}
dir, name := getConfigPath(stderr, cmd.Flag("file"))
if err := Vet(cmd.Context(), dir, name, opts); err != nil {
if !errors.Is(err, ErrFailedChecks) {
fmt.Fprintf(stderr, "%s\n", err)
}
os.Exit(1)
}
return nil
},
}
}
func Vet(ctx context.Context, dir, filename string, opts *Options) error {
e := opts.Env
stderr := opts.Stderr
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(),
ext.Strings(ext.StringsVersion(1)),
cel.Types(
&vet.Config{},
&vet.Query{},
&vet.PostgreSQL{},
&vet.MySQL{},
),
cel.Variable("query",
cel.ObjectType("vet.Query"),
),
cel.Variable("config",
cel.ObjectType("vet.Config"),
),
cel.Variable("postgresql",
cel.ObjectType("vet.PostgreSQL"),
),
cel.Variable("mysql",
cel.ObjectType("vet.MySQL"),
),
)
if err != nil {
return fmt.Errorf("new CEL env error: %s", err)
}
rules := map[string]rule{
constants.QueryRuleDbPrepare: {NeedsPrepare: true},
}
for _, c := range conf.Rules {
if c.Name == "" {
return fmt.Errorf("rules require a name")
}
if _, found := rules[c.Name]; found {
return fmt.Errorf("type-check error: a rule 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)
}
rule := rule{Program: &prg, Message: c.Msg}
// TODO There's probably a nicer way to do this from the ast
// https://pkg.go.dev/cel.dev/cel-go/common/ast#AllMatcher
if strings.Contains(c.Rule, "postgresql.explain") ||
strings.Contains(c.Rule, "mysql.explain") {
rule.NeedsExplain = true
}
rules[c.Name] = rule
}
c := checker{
Rules: rules,
Conf: conf,
Dir: dir,
Env: env,
Stderr: stderr,
OnlyManagedDB: debugDatabases.Value() == "managed",
Replacer: shfmt.NewReplacer(nil),
}
errored := false
for _, sql := range conf.SQL {
if err := c.checkSQL(ctx, sql); err != nil {
if !errors.Is(err, ErrFailedChecks) {
fmt.Fprintf(stderr, "%s\n", err)
}
errored = true
}
}
if errored {
return ErrFailedChecks
}
return nil
}
type preparer interface {
Prepare(context.Context, string, string) error
}
type pgxConn struct {
c *pgx.Conn
}
func (p *pgxConn) Prepare(ctx context.Context, name, query string) error {
_, err := p.c.Prepare(ctx, name, query)
return err
}
// Return a default value for a PostgreSQL column based on its type. Returns nil
// if the type is unknown.
func pgDefaultValue(col *plugin.Column) any {
if col == nil {
return nil
}
if col.Type == nil {
return nil
}
typname := strings.TrimPrefix(col.Type.Name, "pg_catalog.")
switch typname {
case "any", "void":
if col.IsArray {
return []any{}
} else {
return nil
}
case "anyarray":
return []any{}
case "bool", "boolean":
if col.IsArray {
return []bool{}
} else {
return false
}
case "double", "double precision", "real":
if col.IsArray {
return []float32{}
} else {
return 0.1
}
case "json", "jsonb":
if col.IsArray {
return []string{}
} else {
return "{}"
}
case "citext", "string", "text", "varchar":
if col.IsArray {
return []string{}
} else {
return ""
}
case "bigint", "bigserial", "integer", "int", "int2", "int4", "int8", "serial":
if col.IsArray {
return []int{}
} else {
return 1
}
case "date", "time", "timestamp", "timestamptz":
if col.IsArray {
return []time.Time{}
} else {
return time.Time{}
}
case "uuid":
if col.IsArray {
return []string{}
} else {
return "00000000-0000-0000-0000-000000000000"
}
case "numeric", "decimal":
if col.IsArray {
return []string{}
} else {
return "0.1"
}
case "inet":
if col.IsArray {
return []string{}
} else {
return "192.168.0.1/24"
}
case "cidr":
if col.IsArray {
return []string{}
} else {
return "192.168.1/24"
}
default:
return nil
}
}
// Return a default value for a MySQL column based on its type. Returns nil
// if the type is unknown.
func mysqlDefaultValue(col *plugin.Column) any {
if col == nil {
return nil
}
if col.Type == nil {
return nil
}
switch col.Type.Name {
case "any":
return nil
case "bool":
return false
case "int", "bigint", "mediumint", "smallint", "tinyint", "bit":
return 1
case "decimal": // "numeric", "dec", "fixed"
// No perfect choice here to avoid "Impossible WHERE" but I think
// 0.1 is decent. It works for all cases where `scale` > 0 which
// should be the majority. For more information refer to
// https://dev.mysql.com/doc/refman/8.1/en/fixed-point-types.html.
return 0.1
case "float", "double":
return 0.1
case "date":
return "0000-00-00"
case "datetime", "timestamp":
return "0000-00-00 00:00:00"
case "time":
return "00:00:00"
case "year":
return "0000"
case "char", "varchar", "binary", "varbinary", "tinyblob", "blob",
"mediumblob", "longblob", "tinytext", "text", "mediumtext", "longtext":
return ""
case "json":
return "{}"
default:
return nil
}
}
func (p *pgxConn) Explain(ctx context.Context, query string, args ...*plugin.Parameter) (*vetEngineOutput, error) {
eQuery := "EXPLAIN (ANALYZE false, VERBOSE, COSTS, SETTINGS, BUFFERS, FORMAT JSON) " + query
eArgs := make([]any, len(args))
for i, a := range args {
eArgs[i] = pgDefaultValue(a.Column)
}
row := p.c.QueryRow(ctx, eQuery, eArgs...)
var result []json.RawMessage
if err := row.Scan(&result); err != nil {
return nil, err
}
if debugDumpExplain.Value() == "1" {
fmt.Println(eQuery, "with args", eArgs)
fmt.Println(string(result[0]))
}
var explain vet.PostgreSQLExplain
if err := pjson.Unmarshal(result[0], &explain); err != nil {
return nil, err
}
return &vetEngineOutput{PostgreSQL: &vet.PostgreSQL{Explain: &explain}}, nil
}
type dbPreparer struct {
db *sql.DB
}
func (p *dbPreparer) Prepare(ctx context.Context, name, query string) error {
s, err := p.db.PrepareContext(ctx, query)
if s != nil {
s.Close()
}
return err
}
type explainer interface {
Explain(context.Context, string, ...*plugin.Parameter) (*vetEngineOutput, error)
}
type mysqlExplainer struct {
*sql.DB
}
func (me *mysqlExplainer) Explain(ctx context.Context, query string, args ...*plugin.Parameter) (*vetEngineOutput, error) {
eQuery := "EXPLAIN FORMAT=JSON " + query
eArgs := make([]any, len(args))
for i, a := range args {
eArgs[i] = mysqlDefaultValue(a.Column)
}
row := me.QueryRowContext(ctx, eQuery, eArgs...)
var result json.RawMessage
if err := row.Scan(&result); err != nil {
return nil, err
}
if debugDumpExplain.Value() == "1" {
fmt.Println(eQuery, "with args", eArgs)
fmt.Println(string(result))
}
var explain vet.MySQLExplain
if err := pjson.Unmarshal(result, &explain); err != nil {
return nil, err
}
if explain.QueryBlock.Message != "" {
return nil, fmt.Errorf("mysql explain: %s", explain.QueryBlock.Message)
}
return &vetEngineOutput{MySQL: &vet.MySQL{Explain: &explain}}, nil
}
type rule struct {
Program *cel.Program
Message string
NeedsPrepare bool
NeedsExplain bool
}
type checker struct {
Rules map[string]rule
Conf *config.Config
Dir string
Env *cel.Env
Stderr io.Writer
OnlyManagedDB bool
Client dbmanager.Client
clientOnce sync.Once
Replacer *shfmt.Replacer
}
// isInMemorySQLite checks if a SQLite URI refers to an in-memory database
func isInMemorySQLite(uri string) bool {
if uri == ":memory:" || uri == "" {
return true
}
// Check for file URI with mode=memory parameter
// e.g., "file:test?mode=memory&cache=shared"
if strings.Contains(uri, "mode=memory") {
return true
}
return false
}
func (c *checker) fetchDatabaseUri(ctx context.Context, s config.SQL) (string, func() error, error) {
cleanup := func() error {
return nil
}
if s.Database == nil {
panic("fetch database URI called with nil database")
}
if !s.Database.Managed {
uri, err := c.DSN(s.Database.URI)
return uri, cleanup, err
}
// Initialize the client exactly once, even if called concurrently
c.clientOnce.Do(func() {
c.Client = dbmanager.NewClient(c.Conf.Servers)
})
var ddl []string
files, err := sqlpath.Glob(s.Schema)
if err != nil {
return "", cleanup, err
}
for _, schema := range files {
contents, err := os.ReadFile(schema)
if err != nil {
return "", cleanup, fmt.Errorf("read file: %w", err)
}
ddl = append(ddl, migrations.RemoveRollbackStatements(string(contents)))
}
resp, err := c.Client.CreateDatabase(ctx, &dbmanager.CreateDatabaseRequest{
Engine: string(s.Engine),
Migrations: ddl,
})
if err != nil {
return "", cleanup, fmt.Errorf("managed: create database: %w", err)
}
var uri string
switch s.Engine {
case config.EngineMySQL:
dburi, err := quickdb.MySQLReformatURI(resp.Uri)
if err != nil {
return "", cleanup, fmt.Errorf("reformat uri: %w", err)
}
uri = dburi
default:
uri = resp.Uri
}
return uri, cleanup, nil
}
func (c *checker) DSN(dsn string) (string, error) {
return c.Replacer.Replace(dsn), nil
}
func (c *checker) checkSQL(ctx context.Context, s config.SQL) error {
// TODO: Create a separate function for this logic so we can
combo := config.Combine(*c.Conf, s)
// TODO: This feels like a hack that will bite us later
joined := make([]string, 0, len(s.Schema))
for _, s := range s.Schema {
joined = append(joined, filepath.Join(c.Dir, s))
}
s.Schema = joined
joined = make([]string, 0, len(s.Queries))
for _, q := range s.Queries {
joined = append(joined, filepath.Join(c.Dir, q))
}
s.Queries = joined
var name string
parseOpts := opts.Parser{}
result, failed := parse(ctx, name, c.Dir, s, combo, parseOpts, c.Stderr)
if failed {
return ErrFailedChecks
}
var prep preparer
var expl explainer
if s.Database != nil { // TODO only set up a database connection if a rule evaluation requires it
if s.Database.URI != "" && c.OnlyManagedDB {
return fmt.Errorf("database: connections disabled via SQLCDEBUG=databases=managed")
}
dburl, cleanup, err := c.fetchDatabaseUri(ctx, s)
if err != nil {
return err
}
defer func() {
if err := cleanup(); err != nil {
fmt.Fprintf(c.Stderr, "error cleaning up: %s\n", err)
}
}()
switch s.Engine {
case config.EnginePostgreSQL:
conn, err := pgx.Connect(ctx, dburl)
if err != nil {
return fmt.Errorf("database: connection error: %s", err)
}
if err := conn.Ping(ctx); err != nil {
return fmt.Errorf("database: connection error: %s", err)
}
defer conn.Close(ctx)
pConn := &pgxConn{conn}
prep = pConn
expl = pConn
case config.EngineMySQL:
db, err := sql.Open("mysql", dburl)
if err != nil {
return fmt.Errorf("database: connection error: %s", err)
}
if err := db.PingContext(ctx); err != nil {
return fmt.Errorf("database: connection error: %s", err)
}
defer db.Close()
prep = &dbPreparer{db}
expl = &mysqlExplainer{db}
case config.EngineSQLite:
db, err := sql.Open("sqlite3", dburl)
if err != nil {
return fmt.Errorf("database: connection error: %s", err)
}
if err := db.PingContext(ctx); err != nil {
return fmt.Errorf("database: connection error: %s", err)
}
defer db.Close()
// For in-memory SQLite databases, apply migrations
if isInMemorySQLite(dburl) {
files, err := sqlpath.Glob(s.Schema)
if err != nil {
return fmt.Errorf("schema: %w", err)
}
for _, schema := range files {
contents, err := os.ReadFile(schema)
if err != nil {
return fmt.Errorf("read schema file: %w", err)
}
ddl := migrations.RemoveRollbackStatements(string(contents))
if _, err := db.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("apply schema %s: %w", schema, err)
}
}
}
prep = &dbPreparer{db}
// SQLite really doesn't want us to depend on the output of EXPLAIN
// QUERY PLAN: https://www.sqlite.org/eqp.html
expl = nil
default:
return fmt.Errorf("unsupported database uri: %s", s.Engine)
}
}
errored := false
req := codeGenRequest(result, combo)
cfg := vetConfig(req)
for i, query := range req.Queries {
md := result.Queries[i].Metadata
if md.Flags[constants.QueryFlagSqlcVetDisable] {
// If the vet disable flag is specified without any rules listed, all rules are ignored.
if len(md.RuleSkiplist) == 0 {
if debug.Active {
log.Printf("Skipping all vet rules for query: %s\n", query.Name)
}
continue
}
// Rules which are listed to be disabled but not declared in the config file are rejected.
for r := range md.RuleSkiplist {
if !slices.Contains(s.Rules, r) {
fmt.Fprintf(c.Stderr, "%s: %s: rule-check error: rule %q does not exist in the config file\n", query.Filename, query.Name, r)
errored = true
}
}
}
evalMap := map[string]any{
"query": vetQuery(query),
"config": cfg,
}
for _, name := range s.Rules {
if _, skip := md.RuleSkiplist[name]; skip {
if debug.Active {
log.Printf("Skipping vet rule %q for query: %s\n", name, query.Name)
}
} else {
rule, ok := c.Rules[name]
if !ok {
return fmt.Errorf("type-check error: a rule with the name '%s' does not exist", name)
}
if rule.NeedsPrepare {
if prep == nil {
fmt.Fprintf(c.Stderr, "%s: %s: %s: error preparing query: database connection required\n", query.Filename, query.Name, name)
errored = true
continue
}
prepName := fmt.Sprintf("sqlc_vet_%d_%d", time.Now().Unix(), i)
if err := prep.Prepare(ctx, prepName, query.Text); err != nil {
fmt.Fprintf(c.Stderr, "%s: %s: %s: error preparing query: %s\n", query.Filename, query.Name, name, err)
errored = true
continue
}
}
// short-circuit for "sqlc/db-prepare" rule which doesn't have a CEL program
if rule.Program == nil {
continue
}
// Get explain output for this query if we need it
_, pgsqlOK := evalMap["postgresql"]
_, mysqlOK := evalMap["mysql"]
if rule.NeedsExplain && !(pgsqlOK || mysqlOK) {
if expl == nil {
fmt.Fprintf(c.Stderr, "%s: %s: %s: error explaining query: database connection required\n", query.Filename, query.Name, name)
errored = true
continue
}
engineOutput, err := expl.Explain(ctx, query.Text, query.Params...)
if err != nil {
fmt.Fprintf(c.Stderr, "%s: %s: %s: error explaining query: %s\n", query.Filename, query.Name, name, err)
errored = true
continue
}
evalMap["postgresql"] = engineOutput.PostgreSQL
evalMap["mysql"] = engineOutput.MySQL
}
if debugDumpVetEnv.Value() == "1" {
fmt.Printf("vars for rule '%s' evaluating against query '%s':\n", name, query.Name)
debug.DumpAsJSON(evalMap)
}
out, _, err := (*rule.Program).Eval(evalMap)
if err != nil {
return err
}
tripped, ok := out.Value().(bool)
if !ok {
return fmt.Errorf("expression returned non-bool value: %v", out.Value())
}
if tripped {
// TODO: Get line numbers in the output
if rule.Message == "" {
fmt.Fprintf(c.Stderr, "%s: %s: %s\n", query.Filename, query.Name, name)
} else {
fmt.Fprintf(c.Stderr, "%s: %s: %s: %s\n", query.Filename, query.Name, name, rule.Message)
}
errored = true
}
}
}
}
if errored {
return ErrFailedChecks
}
return nil
}
func vetConfig(req *plugin.GenerateRequest) *vet.Config {
return &vet.Config{
Version: req.Settings.Version,
Engine: req.Settings.Engine,
Schema: req.Settings.Schema,
Queries: req.Settings.Queries,
}
}
func vetQuery(q *plugin.Query) *vet.Query {
var params []*vet.Parameter
for _, p := range q.Params {
params = append(params, &vet.Parameter{
Number: p.Number,
})
}
return &vet.Query{
Sql: q.Text,
Name: q.Name,
Cmd: strings.TrimPrefix(q.Cmd, ":"),
Params: params,
}
}
type vetEngineOutput struct {
PostgreSQL *vet.PostgreSQL
MySQL *vet.MySQL
}