feat(lint): add linter rules for migration and naming conventions
* Implement migration safety rules: - MIG001: Warn on CREATE INDEX without CONCURRENT. - MIG002: Warn on ALTER TABLE ADD COLUMN NOT NULL without DEFAULT. - MIG003: Warn on ALTER TABLE ADD CONSTRAINT without NOT VALID. * Implement naming conventions rules: - NAM001: Warn on non-snake_case table names. - NAM002: Warn on non-snake_case column names in CREATE TABLE. - NAM003: Warn on non-snake_case function names. * Add test fixtures for all new rules.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
package lint
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
pg_query "github.com/pganalyze/pg_query_go/v6"
|
||||
|
||||
"github.com/hein/pgtidy/pkg/diagnostics"
|
||||
"github.com/hein/pgtidy/pkg/pgast"
|
||||
)
|
||||
|
||||
// MIG001 — CREATE INDEX without CONCURRENT.
|
||||
// A non-concurrent index build holds ShareLock for its entire duration,
|
||||
// blocking all writes on the table. In production migrations use
|
||||
// CREATE INDEX CONCURRENTLY instead.
|
||||
type ruleMIG001 struct{}
|
||||
|
||||
func (ruleMIG001) ID() string { return "MIG001" }
|
||||
func (ruleMIG001) Severity() diagnostics.Severity { return diagnostics.SeverityWarning }
|
||||
|
||||
func (ruleMIG001) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||
var out []diagnostics.Diagnostic
|
||||
for _, raw := range stmts {
|
||||
idx, ok := raw.Stmt.GetNode().(*pg_query.Node_IndexStmt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s := idx.IndexStmt
|
||||
if s.Concurrent || s.Isconstraint {
|
||||
continue
|
||||
}
|
||||
line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation)))
|
||||
out = append(out, diagnostics.Diagnostic{
|
||||
RuleID: "MIG001",
|
||||
Severity: diagnostics.SeverityWarning,
|
||||
Message: fmt.Sprintf("CREATE INDEX on %q without CONCURRENT blocks writes; use CREATE INDEX CONCURRENTLY", relName(s.Relation)),
|
||||
Line: line,
|
||||
Col: col,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MIG002 — ALTER TABLE ADD COLUMN with NOT NULL and no DEFAULT.
|
||||
// Pre-PG11 this rewrites the whole table. Even on PG11+ the column may require
|
||||
// a costly table scan to validate the NOT NULL constraint when no DEFAULT is
|
||||
// supplied. Use a nullable column first, backfill, then add the constraint.
|
||||
type ruleMIG002 struct{}
|
||||
|
||||
func (ruleMIG002) ID() string { return "MIG002" }
|
||||
func (ruleMIG002) Severity() diagnostics.Severity { return diagnostics.SeverityWarning }
|
||||
|
||||
func (ruleMIG002) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||
var out []diagnostics.Diagnostic
|
||||
for _, raw := range stmts {
|
||||
alt, ok := raw.Stmt.GetNode().(*pg_query.Node_AlterTableStmt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tbl := alt.AlterTableStmt
|
||||
for _, cmdNode := range tbl.Cmds {
|
||||
cmd, ok := cmdNode.GetNode().(*pg_query.Node_AlterTableCmd)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ac := cmd.AlterTableCmd
|
||||
if ac.Subtype != pg_query.AlterTableType_AT_AddColumn {
|
||||
continue
|
||||
}
|
||||
col, ok := ac.Def.GetNode().(*pg_query.Node_ColumnDef)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if hasNotNullNoDefault(col.ColumnDef.Constraints) {
|
||||
line, col2 := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation)))
|
||||
out = append(out, diagnostics.Diagnostic{
|
||||
RuleID: "MIG002",
|
||||
Severity: diagnostics.SeverityWarning,
|
||||
Message: fmt.Sprintf("ALTER TABLE %q ADD COLUMN with NOT NULL and no DEFAULT may rewrite the table; add nullable, backfill, then constrain", relName(tbl.Relation)),
|
||||
Line: line,
|
||||
Col: col2,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MIG003 — ALTER TABLE ADD CONSTRAINT (FK or CHECK) without NOT VALID.
|
||||
// Without NOT VALID, PostgreSQL validates all existing rows immediately,
|
||||
// holding locks that block concurrent writes. Use NOT VALID + a separate
|
||||
// VALIDATE CONSTRAINT to spread the lock window.
|
||||
type ruleMIG003 struct{}
|
||||
|
||||
func (ruleMIG003) ID() string { return "MIG003" }
|
||||
func (ruleMIG003) Severity() diagnostics.Severity { return diagnostics.SeverityWarning }
|
||||
|
||||
func (ruleMIG003) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||
var out []diagnostics.Diagnostic
|
||||
for _, raw := range stmts {
|
||||
alt, ok := raw.Stmt.GetNode().(*pg_query.Node_AlterTableStmt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tbl := alt.AlterTableStmt
|
||||
for _, cmdNode := range tbl.Cmds {
|
||||
cmd, ok := cmdNode.GetNode().(*pg_query.Node_AlterTableCmd)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if cmd.AlterTableCmd.Subtype != pg_query.AlterTableType_AT_AddConstraint {
|
||||
continue
|
||||
}
|
||||
con, ok := cmd.AlterTableCmd.Def.GetNode().(*pg_query.Node_Constraint)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
c := con.Constraint
|
||||
if c.Contype != pg_query.ConstrType_CONSTR_FOREIGN && c.Contype != pg_query.ConstrType_CONSTR_CHECK {
|
||||
continue
|
||||
}
|
||||
if c.SkipValidation {
|
||||
continue // NOT VALID is present
|
||||
}
|
||||
kind := "FOREIGN KEY"
|
||||
if c.Contype == pg_query.ConstrType_CONSTR_CHECK {
|
||||
kind = "CHECK"
|
||||
}
|
||||
line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation)))
|
||||
out = append(out, diagnostics.Diagnostic{
|
||||
RuleID: "MIG003",
|
||||
Severity: diagnostics.SeverityWarning,
|
||||
Message: fmt.Sprintf("ALTER TABLE %q ADD %s CONSTRAINT without NOT VALID validates all rows immediately; use NOT VALID + VALIDATE CONSTRAINT", relName(tbl.Relation), kind),
|
||||
Line: line,
|
||||
Col: col,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hasNotNullNoDefault returns true when the constraint list has CONSTR_NOTNULL
|
||||
// but no CONSTR_DEFAULT.
|
||||
func hasNotNullNoDefault(constraints []*pg_query.Node) bool {
|
||||
hasNN := false
|
||||
hasDef := false
|
||||
for _, cn := range constraints {
|
||||
c, ok := cn.GetNode().(*pg_query.Node_Constraint)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch c.Constraint.Contype {
|
||||
case pg_query.ConstrType_CONSTR_NOTNULL:
|
||||
hasNN = true
|
||||
case pg_query.ConstrType_CONSTR_DEFAULT:
|
||||
hasDef = true
|
||||
}
|
||||
}
|
||||
return hasNN && !hasDef
|
||||
}
|
||||
|
||||
// relName returns "schema.rel" or "rel" from a RangeVar.
|
||||
func relName(rv *pg_query.RangeVar) string {
|
||||
if rv == nil {
|
||||
return "?"
|
||||
}
|
||||
if rv.Schemaname != "" {
|
||||
return rv.Schemaname + "." + rv.Relname
|
||||
}
|
||||
return rv.Relname
|
||||
}
|
||||
Reference in New Issue
Block a user