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,135 @@
|
||||
package lint
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
pg_query "github.com/pganalyze/pg_query_go/v6"
|
||||
|
||||
"github.com/hein/pgtidy/pkg/diagnostics"
|
||||
"github.com/hein/pgtidy/pkg/pgast"
|
||||
)
|
||||
|
||||
// reSnakeCase matches valid snake_case identifiers: lowercase letters, digits,
|
||||
// and underscores, starting with a letter or underscore.
|
||||
var reSnakeCase = regexp.MustCompile(`^[a-z_][a-z0-9_]*$`)
|
||||
|
||||
func isSnakeCase(s string) bool {
|
||||
return reSnakeCase.MatchString(s)
|
||||
}
|
||||
|
||||
// NAM001 — table names must be snake_case.
|
||||
type ruleNAM001 struct{}
|
||||
|
||||
func (ruleNAM001) ID() string { return "NAM001" }
|
||||
func (ruleNAM001) Severity() diagnostics.Severity { return diagnostics.SeverityHint }
|
||||
|
||||
func (ruleNAM001) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||
var out []diagnostics.Diagnostic
|
||||
for _, raw := range stmts {
|
||||
cs, ok := raw.Stmt.GetNode().(*pg_query.Node_CreateStmt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := cs.CreateStmt.Relation.Relname
|
||||
if name == "" || isSnakeCase(name) {
|
||||
continue
|
||||
}
|
||||
line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation)))
|
||||
out = append(out, diagnostics.Diagnostic{
|
||||
RuleID: "NAM001",
|
||||
Severity: diagnostics.SeverityHint,
|
||||
Message: fmt.Sprintf("table name %q is not snake_case (expected %q)", name, toSnakeCase(name)),
|
||||
Line: line,
|
||||
Col: col,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NAM002 — column names in CREATE TABLE must be snake_case.
|
||||
type ruleNAM002 struct{}
|
||||
|
||||
func (ruleNAM002) ID() string { return "NAM002" }
|
||||
func (ruleNAM002) Severity() diagnostics.Severity { return diagnostics.SeverityHint }
|
||||
|
||||
func (ruleNAM002) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||
var out []diagnostics.Diagnostic
|
||||
for _, raw := range stmts {
|
||||
cs, ok := raw.Stmt.GetNode().(*pg_query.Node_CreateStmt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, elt := range cs.CreateStmt.TableElts {
|
||||
cd, ok := elt.GetNode().(*pg_query.Node_ColumnDef)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := cd.ColumnDef.Colname
|
||||
if name == "" || isSnakeCase(name) {
|
||||
continue
|
||||
}
|
||||
line, col := pgast.LocationToLineCol(src, int(cd.ColumnDef.Location))
|
||||
out = append(out, diagnostics.Diagnostic{
|
||||
RuleID: "NAM002",
|
||||
Severity: diagnostics.SeverityHint,
|
||||
Message: fmt.Sprintf("column name %q is not snake_case (expected %q)", name, toSnakeCase(name)),
|
||||
Line: line,
|
||||
Col: col,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NAM003 — function/procedure names must be snake_case.
|
||||
type ruleNAM003 struct{}
|
||||
|
||||
func (ruleNAM003) ID() string { return "NAM003" }
|
||||
func (ruleNAM003) Severity() diagnostics.Severity { return diagnostics.SeverityHint }
|
||||
|
||||
func (ruleNAM003) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||
var out []diagnostics.Diagnostic
|
||||
for _, raw := range stmts {
|
||||
cf, ok := raw.Stmt.GetNode().(*pg_query.Node_CreateFunctionStmt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Funcname is a list of String nodes: [schema, funcname] or [funcname].
|
||||
name := lastStringNode(cf.CreateFunctionStmt.Funcname)
|
||||
if name == "" || isSnakeCase(name) {
|
||||
continue
|
||||
}
|
||||
line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation)))
|
||||
out = append(out, diagnostics.Diagnostic{
|
||||
RuleID: "NAM003",
|
||||
Severity: diagnostics.SeverityHint,
|
||||
Message: fmt.Sprintf("function name %q is not snake_case (expected %q)", name, toSnakeCase(name)),
|
||||
Line: line,
|
||||
Col: col,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lastStringNode returns the string value of the last node in a name-list
|
||||
// (schema-qualified names like [schema, name] → name).
|
||||
func lastStringNode(nodes []*pg_query.Node) string {
|
||||
for i := len(nodes) - 1; i >= 0; i-- {
|
||||
sv, ok := nodes[i].GetNode().(*pg_query.Node_String_)
|
||||
if ok {
|
||||
return sv.String_.Sval
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// toSnakeCase converts a CamelCase or mixed-case identifier to snake_case as a
|
||||
// suggested fix shown in the diagnostic message.
|
||||
var reWordBoundary = regexp.MustCompile(`([a-z0-9])([A-Z])`)
|
||||
|
||||
func toSnakeCase(s string) string {
|
||||
s = reWordBoundary.ReplaceAllString(s, "${1}_${2}")
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
Reference in New Issue
Block a user