Skip to content

Commit

Permalink
feat: add db lint fail-on flag (#2664)
Browse files Browse the repository at this point in the history
  • Loading branch information
avallete authored Sep 11, 2024
1 parent b409d6a commit 787bc92
Show file tree
Hide file tree
Showing 4 changed files with 98 additions and 10 deletions.
8 changes: 7 additions & 1 deletion cmd/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,16 @@ var (
Value: lint.AllowedLevels[0],
}

lintFailOn = utils.EnumFlag{
Allowed: append([]string{"none"}, lint.AllowedLevels...),
Value: "none",
}

dbLintCmd = &cobra.Command{
Use: "lint",
Short: "Checks local database for typing error",
RunE: func(cmd *cobra.Command, args []string) error {
return lint.Run(cmd.Context(), schema, level.Value, flags.DbConfig, afero.NewOsFs())
return lint.Run(cmd.Context(), schema, level.Value, lintFailOn.Value, flags.DbConfig, afero.NewOsFs())
},
}

Expand Down Expand Up @@ -310,6 +315,7 @@ func init() {
dbLintCmd.MarkFlagsMutuallyExclusive("db-url", "linked", "local")
lintFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.")
lintFlags.Var(&level, "level", "Error level to emit.")
lintFlags.Var(&lintFailOn, "fail-on", "Error level to exit with non-zero status.")
dbCmd.AddCommand(dbLintCmd)
// Build start command
dbCmd.AddCommand(dbStartCmd)
Expand Down
8 changes: 8 additions & 0 deletions docs/supabase/db/lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,11 @@ Requires the local development stack to be running when linting against the loca
Runs `plpgsql_check` extension in the local Postgres container to check for errors in all schemas. The default lint level is `warning` and can be raised to error via the `--level` flag.

To lint against specific schemas only, pass in the `--schema` flag.

The `--fail-on` flag can be used to control when the command should exit with a non-zero status code. The possible values are:

- `none` (default): Always exit with a zero status code, regardless of lint results.
- `warning`: Exit with a non-zero status code if any warnings or errors are found.
- `error`: Exit with a non-zero status code only if errors are found.

This flag is particularly useful in CI/CD pipelines where you want to fail the build based on certain lint conditions.
30 changes: 24 additions & 6 deletions internal/db/lint/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func toEnum(level string) LintLevel {
return -1
}

func Run(ctx context.Context, schema []string, level string, config pgconn.Config, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error {
func Run(ctx context.Context, schema []string, level string, failOn string, config pgconn.Config, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error {
// Sanity checks.
conn, err := utils.ConnectByConfig(ctx, config, options...)
if err != nil {
Expand All @@ -55,7 +55,26 @@ func Run(ctx context.Context, schema []string, level string, config pgconn.Confi
fmt.Fprintln(os.Stderr, "\nNo schema errors found")
return nil
}
return printResultJSON(result, toEnum(level), os.Stdout)

// Apply filtering based on the minimum level
minLevel := toEnum(level)
filtered := filterResult(result, minLevel)
err = printResultJSON(filtered, os.Stdout)
if err != nil {
return err
}
// Check for fail-on condition
failOnLevel := toEnum(failOn)
if failOnLevel != -1 {
for _, r := range filtered {
for _, issue := range r.Issues {
if toEnum(issue.Level) >= failOnLevel {
return fmt.Errorf("fail-on is set to %s, non-zero exit", AllowedLevels[failOnLevel])
}
}
}
}
return nil
}

func filterResult(result []Result, minLevel LintLevel) (filtered []Result) {
Expand All @@ -73,15 +92,14 @@ func filterResult(result []Result, minLevel LintLevel) (filtered []Result) {
return filtered
}

func printResultJSON(result []Result, minLevel LintLevel, stdout io.Writer) error {
filtered := filterResult(result, minLevel)
if len(filtered) == 0 {
func printResultJSON(result []Result, stdout io.Writer) error {
if len(result) == 0 {
return nil
}
// Pretty print output
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
if err := enc.Encode(filtered); err != nil {
if err := enc.Encode(result); err != nil {
return errors.Errorf("failed to print result json: %w", err)
}
return nil
Expand Down
62 changes: 59 additions & 3 deletions internal/db/lint/lint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func TestLintCommand(t *testing.T) {
Reply("SELECT 1", []interface{}{"f1", string(data)}).
Query("rollback").Reply("ROLLBACK")
// Run test
err = Run(context.Background(), []string{"public"}, "warning", dbConfig, fsys, conn.Intercept)
err = Run(context.Background(), []string{"public"}, "warning", "none", dbConfig, fsys, conn.Intercept)
// Check error
assert.NoError(t, err)
assert.Empty(t, apitest.ListUnmatchedRequests())
Expand Down Expand Up @@ -221,7 +221,8 @@ func TestPrintResult(t *testing.T) {
t.Run("filters warning level", func(t *testing.T) {
// Run test
var out bytes.Buffer
assert.NoError(t, printResultJSON(result, toEnum("warning"), &out))
filtered := filterResult(result, toEnum("warning"))
assert.NoError(t, printResultJSON(filtered, &out))
// Validate output
var actual []Result
assert.NoError(t, json.Unmarshal(out.Bytes(), &actual))
Expand All @@ -231,7 +232,8 @@ func TestPrintResult(t *testing.T) {
t.Run("filters error level", func(t *testing.T) {
// Run test
var out bytes.Buffer
assert.NoError(t, printResultJSON(result, toEnum("error"), &out))
filtered := filterResult(result, toEnum("error"))
assert.NoError(t, printResultJSON(filtered, &out))
// Validate output
var actual []Result
assert.NoError(t, json.Unmarshal(out.Bytes(), &actual))
Expand All @@ -240,4 +242,58 @@ func TestPrintResult(t *testing.T) {
Issues: []Issue{result[0].Issues[1]},
}}, actual)
})

t.Run("exits with non-zero status on warning", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
// Setup mock postgres
conn := pgtest.NewConn()
defer conn.Close(t)
conn.Query("begin").Reply("BEGIN").
Query(ENABLE_PGSQL_CHECK).
Reply("CREATE EXTENSION").
Query(checkSchemaScript, "public").
Reply("SELECT 1", []interface{}{"f1", `{"function":"22751","issues":[{"level":"warning","message":"test warning"}]}`}).
Query("rollback").Reply("ROLLBACK")
// Run test
err := Run(context.Background(), []string{"public"}, "warning", "warning", dbConfig, fsys, conn.Intercept)
// Check error
assert.ErrorContains(t, err, "fail-on is set to warning, non-zero exit")
})

t.Run("exits with non-zero status on error", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
// Setup mock postgres
conn := pgtest.NewConn()
defer conn.Close(t)
conn.Query("begin").Reply("BEGIN").
Query(ENABLE_PGSQL_CHECK).
Reply("CREATE EXTENSION").
Query(checkSchemaScript, "public").
Reply("SELECT 1", []interface{}{"f1", `{"function":"22751","issues":[{"level":"error","message":"test error"}]}`}).
Query("rollback").Reply("ROLLBACK")
// Run test
err := Run(context.Background(), []string{"public"}, "warning", "error", dbConfig, fsys, conn.Intercept)
// Check error
assert.ErrorContains(t, err, "fail-on is set to error, non-zero exit")
})

t.Run("does not exit with non-zero status when fail-on is none", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
// Setup mock postgres
conn := pgtest.NewConn()
defer conn.Close(t)
conn.Query("begin").Reply("BEGIN").
Query(ENABLE_PGSQL_CHECK).
Reply("CREATE EXTENSION").
Query(checkSchemaScript, "public").
Reply("SELECT 1", []interface{}{"f1", `{"function":"22751","issues":[{"level":"error","message":"test error"}]}`}).
Query("rollback").Reply("ROLLBACK")
// Run test
err := Run(context.Background(), []string{"public"}, "warning", "none", dbConfig, fsys, conn.Intercept)
// Check error
assert.NoError(t, err)
})
}

0 comments on commit 787bc92

Please sign in to comment.