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,127 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// COR001 — SELECT *.
|
||||
// Selecting all columns by wildcard is fragile: column additions/removals
|
||||
// break callers silently. Enumerate the columns you need explicitly.
|
||||
type ruleCOR001 struct{}
|
||||
|
||||
func (ruleCOR001) ID() string { return "COR001" }
|
||||
func (ruleCOR001) Severity() diagnostics.Severity { return diagnostics.SeverityHint }
|
||||
|
||||
func (ruleCOR001) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||
var out []diagnostics.Diagnostic
|
||||
for _, raw := range stmts {
|
||||
walkSelectStar(raw.Stmt, src, &out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func walkSelectStar(node *pg_query.Node, src string, out *[]diagnostics.Diagnostic) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
sel, ok := node.GetNode().(*pg_query.Node_SelectStmt)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
checkSelectStmt(sel.SelectStmt, src, out)
|
||||
}
|
||||
|
||||
func checkSelectStmt(s *pg_query.SelectStmt, src string, out *[]diagnostics.Diagnostic) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
for _, t := range s.TargetList {
|
||||
rt, ok := t.GetNode().(*pg_query.Node_ResTarget)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cr, ok := rt.ResTarget.Val.GetNode().(*pg_query.Node_ColumnRef)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, f := range cr.ColumnRef.Fields {
|
||||
if _, isStar := f.GetNode().(*pg_query.Node_AStar); isStar {
|
||||
line, col := pgast.LocationToLineCol(src, int(cr.ColumnRef.Location))
|
||||
*out = append(*out, diagnostics.Diagnostic{
|
||||
RuleID: "COR001",
|
||||
Severity: diagnostics.SeverityHint,
|
||||
Message: "SELECT * is fragile; enumerate the columns explicitly",
|
||||
Line: line,
|
||||
Col: col,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recurse into set-operation branches (UNION, INTERSECT, EXCEPT).
|
||||
checkSelectStmt(s.Larg, src, out)
|
||||
checkSelectStmt(s.Rarg, src, out)
|
||||
}
|
||||
|
||||
// COR002 — UPDATE without WHERE.
|
||||
// An UPDATE with no WHERE clause modifies every row in the table.
|
||||
type ruleCOR002 struct{}
|
||||
|
||||
func (ruleCOR002) ID() string { return "COR002" }
|
||||
func (ruleCOR002) Severity() diagnostics.Severity { return diagnostics.SeverityWarning }
|
||||
|
||||
func (ruleCOR002) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||
var out []diagnostics.Diagnostic
|
||||
for _, raw := range stmts {
|
||||
u, ok := raw.Stmt.GetNode().(*pg_query.Node_UpdateStmt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if u.UpdateStmt.WhereClause != nil {
|
||||
continue
|
||||
}
|
||||
line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation)))
|
||||
out = append(out, diagnostics.Diagnostic{
|
||||
RuleID: "COR002",
|
||||
Severity: diagnostics.SeverityWarning,
|
||||
Message: fmt.Sprintf("UPDATE %q has no WHERE clause; this modifies every row", relName(u.UpdateStmt.Relation)),
|
||||
Line: line,
|
||||
Col: col,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// COR003 — DELETE without WHERE.
|
||||
// A DELETE with no WHERE clause removes every row from the table.
|
||||
// Use TRUNCATE if you intend a full wipe; it is faster and explicit.
|
||||
type ruleCOR003 struct{}
|
||||
|
||||
func (ruleCOR003) ID() string { return "COR003" }
|
||||
func (ruleCOR003) Severity() diagnostics.Severity { return diagnostics.SeverityWarning }
|
||||
|
||||
func (ruleCOR003) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||
var out []diagnostics.Diagnostic
|
||||
for _, raw := range stmts {
|
||||
d, ok := raw.Stmt.GetNode().(*pg_query.Node_DeleteStmt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if d.DeleteStmt.WhereClause != nil {
|
||||
continue
|
||||
}
|
||||
line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation)))
|
||||
out = append(out, diagnostics.Diagnostic{
|
||||
RuleID: "COR003",
|
||||
Severity: diagnostics.SeverityWarning,
|
||||
Message: fmt.Sprintf("DELETE FROM %q has no WHERE clause; this removes every row (use TRUNCATE if intentional)", relName(d.DeleteStmt.Relation)),
|
||||
Line: line,
|
||||
Col: col,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user