Skip to content

Commit 5ee2c0b

Browse files
authored
feat(vet): Allow selective disabling of rules per query (#3620)
* Extended the `@sqlc-vet-disable` flag to support the skipping of provided rules. This is an update to the original behaviour which had an "all-or-nothing" behaviour, either running all the rules on a query or skipping them all. Specified rules which aren't declared in the sqlc config file get rejected. * Updated tests to cover new vet disabling behaviour. * Relocated constants for query flags to a dedicated package to allow for safer reuse. * Renamed the metadata `ParseParamsAndFlags()` function to `ParseCommentFlags()` in order to be more generic as it now returns the rule skip list and could potentially return more data in the future. * Updated docs to reflect the support of rule names for the vet disable annotation. * Slight term change in vet docs. * Renamed test function for query rule skiplist.
1 parent ae2c2dd commit 5ee2c0b

File tree

9 files changed

+219
-75
lines changed

9 files changed

+219
-75
lines changed

docs/howto/vet.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,24 @@ rules:
259259
### Opting-out of lint rules
260260

261261
For any query, you can tell `sqlc vet` not to evaluate lint rules using the
262-
`@sqlc-vet-disable` query annotation.
262+
`@sqlc-vet-disable` query annotation. The annotation accepts a list of rules to ignore.
263+
264+
```sql
265+
/* name: GetAuthor :one */
266+
/* @sqlc-vet-disable sqlc/db-prepare no-pg */
267+
SELECT * FROM authors
268+
WHERE id = ? LIMIT 1;
269+
```
270+
The rules can also be split across lines.
271+
```sql
272+
/* name: GetAuthor :one */
273+
/* @sqlc-vet-disable sqlc/db-prepare */
274+
/* @sqlc-vet-disable no-pg */
275+
SELECT * FROM authors
276+
WHERE id = ? LIMIT 1;
277+
```
278+
279+
To skip all rules for a query, you can provide the `@sqlc-vet-disable` annotation without any parameters.
263280

264281
```sql
265282
/* name: GetAuthor :one */

internal/cmd/vet.go

Lines changed: 81 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import (
66
"encoding/json"
77
"errors"
88
"fmt"
9+
"github.com/sqlc-dev/sqlc/internal/constants"
910
"io"
1011
"log"
1112
"os"
1213
"path/filepath"
1314
"runtime/trace"
15+
"slices"
1416
"strings"
1517
"time"
1618

@@ -37,9 +39,6 @@ var ErrFailedChecks = errors.New("failed checks")
3739

3840
var pjson = protojson.UnmarshalOptions{AllowPartial: true, DiscardUnknown: true}
3941

40-
const RuleDbPrepare = "sqlc/db-prepare"
41-
const QueryFlagSqlcVetDisable = "@sqlc-vet-disable"
42-
4342
func NewCmdVet() *cobra.Command {
4443
return &cobra.Command{
4544
Use: "vet",
@@ -109,7 +108,7 @@ func Vet(ctx context.Context, dir, filename string, opts *Options) error {
109108
}
110109

111110
rules := map[string]rule{
112-
RuleDbPrepare: {NeedsPrepare: true},
111+
constants.QueryRuleDbPrepare: {NeedsPrepare: true},
113112
}
114113

115114
for _, c := range conf.Rules {
@@ -538,11 +537,23 @@ func (c *checker) checkSQL(ctx context.Context, s config.SQL) error {
538537
req := codeGenRequest(result, combo)
539538
cfg := vetConfig(req)
540539
for i, query := range req.Queries {
541-
if result.Queries[i].Metadata.Flags[QueryFlagSqlcVetDisable] {
542-
if debug.Active {
543-
log.Printf("Skipping vet rules for query: %s\n", query.Name)
540+
md := result.Queries[i].Metadata
541+
if md.Flags[constants.QueryFlagSqlcVetDisable] {
542+
// If the vet disable flag is specified without any rules listed, all rules are ignored.
543+
if len(md.RuleSkiplist) == 0 {
544+
if debug.Active {
545+
log.Printf("Skipping all vet rules for query: %s\n", query.Name)
546+
}
547+
continue
548+
}
549+
550+
// Rules which are listed to be disabled but not declared in the config file are rejected.
551+
for r := range md.RuleSkiplist {
552+
if !slices.Contains(s.Rules, r) {
553+
fmt.Fprintf(c.Stderr, "%s: %s: rule-check error: rule %q does not exist in the config file\n", query.Filename, query.Name, r)
554+
errored = true
555+
}
544556
}
545-
continue
546557
}
547558

548559
evalMap := map[string]any{
@@ -551,74 +562,81 @@ func (c *checker) checkSQL(ctx context.Context, s config.SQL) error {
551562
}
552563

553564
for _, name := range s.Rules {
554-
rule, ok := c.Rules[name]
555-
if !ok {
556-
return fmt.Errorf("type-check error: a rule with the name '%s' does not exist", name)
557-
}
558-
559-
if rule.NeedsPrepare {
560-
if prep == nil {
561-
fmt.Fprintf(c.Stderr, "%s: %s: %s: error preparing query: database connection required\n", query.Filename, query.Name, name)
562-
errored = true
563-
continue
565+
if _, skip := md.RuleSkiplist[name]; skip {
566+
if debug.Active {
567+
log.Printf("Skipping vet rule %q for query: %s\n", name, query.Name)
564568
}
565-
prepName := fmt.Sprintf("sqlc_vet_%d_%d", time.Now().Unix(), i)
566-
if err := prep.Prepare(ctx, prepName, query.Text); err != nil {
567-
fmt.Fprintf(c.Stderr, "%s: %s: %s: error preparing query: %s\n", query.Filename, query.Name, name, err)
568-
errored = true
569-
continue
569+
} else {
570+
rule, ok := c.Rules[name]
571+
if !ok {
572+
return fmt.Errorf("type-check error: a rule with the name '%s' does not exist", name)
570573
}
571-
}
572-
573-
// short-circuit for "sqlc/db-prepare" rule which doesn't have a CEL program
574-
if rule.Program == nil {
575-
continue
576-
}
577574

578-
// Get explain output for this query if we need it
579-
_, pgsqlOK := evalMap["postgresql"]
580-
_, mysqlOK := evalMap["mysql"]
581-
if rule.NeedsExplain && !(pgsqlOK || mysqlOK) {
582-
if expl == nil {
583-
fmt.Fprintf(c.Stderr, "%s: %s: %s: error explaining query: database connection required\n", query.Filename, query.Name, name)
584-
errored = true
585-
continue
575+
if rule.NeedsPrepare {
576+
if prep == nil {
577+
fmt.Fprintf(c.Stderr, "%s: %s: %s: error preparing query: database connection required\n", query.Filename, query.Name, name)
578+
errored = true
579+
continue
580+
}
581+
prepName := fmt.Sprintf("sqlc_vet_%d_%d", time.Now().Unix(), i)
582+
if err := prep.Prepare(ctx, prepName, query.Text); err != nil {
583+
fmt.Fprintf(c.Stderr, "%s: %s: %s: error preparing query: %s\n", query.Filename, query.Name, name, err)
584+
errored = true
585+
continue
586+
}
586587
}
587-
engineOutput, err := expl.Explain(ctx, query.Text, query.Params...)
588-
if err != nil {
589-
fmt.Fprintf(c.Stderr, "%s: %s: %s: error explaining query: %s\n", query.Filename, query.Name, name, err)
590-
errored = true
588+
589+
// short-circuit for "sqlc/db-prepare" rule which doesn't have a CEL program
590+
if rule.Program == nil {
591591
continue
592592
}
593593

594-
evalMap["postgresql"] = engineOutput.PostgreSQL
595-
evalMap["mysql"] = engineOutput.MySQL
596-
}
594+
// Get explain output for this query if we need it
595+
_, pgsqlOK := evalMap["postgresql"]
596+
_, mysqlOK := evalMap["mysql"]
597+
if rule.NeedsExplain && !(pgsqlOK || mysqlOK) {
598+
if expl == nil {
599+
fmt.Fprintf(c.Stderr, "%s: %s: %s: error explaining query: database connection required\n", query.Filename, query.Name, name)
600+
errored = true
601+
continue
602+
}
603+
engineOutput, err := expl.Explain(ctx, query.Text, query.Params...)
604+
if err != nil {
605+
fmt.Fprintf(c.Stderr, "%s: %s: %s: error explaining query: %s\n", query.Filename, query.Name, name, err)
606+
errored = true
607+
continue
608+
}
609+
610+
evalMap["postgresql"] = engineOutput.PostgreSQL
611+
evalMap["mysql"] = engineOutput.MySQL
612+
}
597613

598-
if debug.Debug.DumpVetEnv {
599-
fmt.Printf("vars for rule '%s' evaluating against query '%s':\n", name, query.Name)
600-
debug.DumpAsJSON(evalMap)
601-
}
614+
if debug.Debug.DumpVetEnv {
615+
fmt.Printf("vars for rule '%s' evaluating against query '%s':\n", name, query.Name)
616+
debug.DumpAsJSON(evalMap)
617+
}
602618

603-
out, _, err := (*rule.Program).Eval(evalMap)
604-
if err != nil {
605-
return err
606-
}
607-
tripped, ok := out.Value().(bool)
608-
if !ok {
609-
return fmt.Errorf("expression returned non-bool value: %v", out.Value())
610-
}
611-
if tripped {
612-
// TODO: Get line numbers in the output
613-
if rule.Message == "" {
614-
fmt.Fprintf(c.Stderr, "%s: %s: %s\n", query.Filename, query.Name, name)
615-
} else {
616-
fmt.Fprintf(c.Stderr, "%s: %s: %s: %s\n", query.Filename, query.Name, name, rule.Message)
619+
out, _, err := (*rule.Program).Eval(evalMap)
620+
if err != nil {
621+
return err
622+
}
623+
tripped, ok := out.Value().(bool)
624+
if !ok {
625+
return fmt.Errorf("expression returned non-bool value: %v", out.Value())
626+
}
627+
if tripped {
628+
// TODO: Get line numbers in the output
629+
if rule.Message == "" {
630+
fmt.Fprintf(c.Stderr, "%s: %s: %s\n", query.Filename, query.Name, name)
631+
} else {
632+
fmt.Fprintf(c.Stderr, "%s: %s: %s: %s\n", query.Filename, query.Name, name, rule.Message)
633+
}
634+
errored = true
617635
}
618-
errored = true
619636
}
620637
}
621638
}
639+
622640
if errored {
623641
return ErrFailedChecks
624642
}

internal/compiler/parse.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func (c *Compiler) parseQuery(stmt ast.Node, src string, o opts.Parser) (*Query,
6565
return nil, err
6666
}
6767

68-
md.Params, md.Flags, err = metadata.ParseParamsAndFlags(cleanedComments)
68+
md.Params, md.Flags, md.RuleSkiplist, err = metadata.ParseCommentFlags(cleanedComments)
6969
if err != nil {
7070
return nil, err
7171
}

internal/constants/query.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package constants
2+
3+
// Flags
4+
const (
5+
QueryFlagParam = "@param"
6+
QueryFlagSqlcVetDisable = "@sqlc-vet-disable"
7+
)
8+
9+
// Rules
10+
const (
11+
QueryRuleDbPrepare = "sqlc/db-prepare"
12+
)
Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,29 @@
1-
-- name: SkipVet :exec
1+
-- name: RunVetAll :exec
2+
SELECT true;
3+
4+
-- name: SkipVetAll :exec
25
-- @sqlc-vet-disable
36
SELECT true;
47

5-
-- name: RunVet :exec
8+
-- name: SkipVetSingleLine :exec
9+
-- @sqlc-vet-disable always-fail no-exec
10+
SELECT true;
11+
12+
-- name: SkipVetMultiLine :exec
13+
-- @sqlc-vet-disable always-fail
14+
-- @sqlc-vet-disable no-exec
15+
SELECT true;
16+
17+
-- name: SkipVet_always_fail :exec
18+
-- @sqlc-vet-disable always-fail
19+
SELECT true;
20+
21+
-- name: SkipVet_no_exec :exec
22+
-- @sqlc-vet-disable no-exec
23+
SELECT true;
24+
25+
-- name: SkipVetInvalidRule :exec
26+
-- @sqlc-vet-disable always-fail
27+
-- @sqlc-vet-disable block-delete
28+
-- @sqlc-vet-disable no-exec
629
SELECT true;

internal/endtoend/testdata/vet_disable/sqlc.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ sql:
99
out: "db"
1010
rules:
1111
- always-fail
12+
- no-exec
1213
rules:
1314
- name: always-fail
1415
message: "Fail"
1516
rule: "true"
17+
18+
- name: no-exec
19+
message: "don't use exec"
20+
rule: |
21+
query.cmd == "exec"
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
query.sql: RunVet: always-fail: Fail
1+
query.sql: RunVetAll: always-fail: Fail
2+
query.sql: RunVetAll: no-exec: don't use exec
3+
query.sql: SkipVet_always_fail: no-exec: don't use exec
4+
query.sql: SkipVet_no_exec: always-fail: Fail
5+
query.sql: SkipVetInvalidRule: rule-check error: rule "block-delete" does not exist in the config file

internal/metadata/meta.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package metadata
33
import (
44
"bufio"
55
"fmt"
6+
"github.com/sqlc-dev/sqlc/internal/constants"
67
"strings"
78
"unicode"
89

@@ -18,6 +19,10 @@ type Metadata struct {
1819
Params map[string]string
1920
Flags map[string]bool
2021

22+
// RuleSkiplist contains the names of rules to disable vetting for.
23+
// If the map is empty, but the disable vet flag is specified, then all rules are ignored.
24+
RuleSkiplist map[string]struct{}
25+
2126
Filename string
2227
}
2328

@@ -113,9 +118,12 @@ func ParseQueryNameAndType(t string, commentStyle CommentSyntax) (string, string
113118
return "", "", nil
114119
}
115120

116-
func ParseParamsAndFlags(comments []string) (map[string]string, map[string]bool, error) {
121+
// ParseCommentFlags processes the comments provided with queries to determine the metadata params, flags and rules to skip.
122+
// All flags in query comments are prefixed with `@`, e.g. @param, @@sqlc-vet-disable.
123+
func ParseCommentFlags(comments []string) (map[string]string, map[string]bool, map[string]struct{}, error) {
117124
params := make(map[string]string)
118125
flags := make(map[string]bool)
126+
ruleSkiplist := make(map[string]struct{})
119127

120128
for _, line := range comments {
121129
s := bufio.NewScanner(strings.NewReader(line))
@@ -129,7 +137,7 @@ func ParseParamsAndFlags(comments []string) (map[string]string, map[string]bool,
129137
}
130138

131139
switch token {
132-
case "@param":
140+
case constants.QueryFlagParam:
133141
s.Scan()
134142
name := s.Text()
135143
var rest []string
@@ -138,14 +146,27 @@ func ParseParamsAndFlags(comments []string) (map[string]string, map[string]bool,
138146
rest = append(rest, paramToken)
139147
}
140148
params[name] = strings.Join(rest, " ")
149+
150+
case constants.QueryFlagSqlcVetDisable:
151+
flags[token] = true
152+
153+
// Vet rules can all be disabled in the same line or split across lines .i.e.
154+
// /* @sqlc-vet-disable sqlc/db-prepare delete-without-where */
155+
// is equivalent to:
156+
// /* @sqlc-vet-disable sqlc/db-prepare */
157+
// /* @sqlc-vet-disable delete-without-where */
158+
for s.Scan() {
159+
ruleSkiplist[s.Text()] = struct{}{}
160+
}
161+
141162
default:
142163
flags[token] = true
143164
}
144165

145166
if s.Err() != nil {
146-
return params, flags, s.Err()
167+
return params, flags, ruleSkiplist, s.Err()
147168
}
148169
}
149170

150-
return params, flags, nil
171+
return params, flags, ruleSkiplist, nil
151172
}

0 commit comments

Comments
 (0)