* 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.
136 lines
4.0 KiB
Go
136 lines
4.0 KiB
Go
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)
|
|
}
|