Table.Columns/Constraints/Indexes/Relationships are Go maps, and every writer, reader, diff, inspector, and merge code path that iterated them directly was subject to Go's randomized map order, so identical input could produce different output (or a different in-report violation/diff order) on every run. Most visibly this showed up as bun/gorm `unique:` struct tags changing order across consecutive `make models` runs with no source change. Fixed by sorting map iteration (by Sequence then Name, or alphabetically for string-keyed maps) everywhere the order affects generated output or first-match tie-break logic, across the bun, gorm, sqlite, dbml, drawdb, pgsql, prisma, graphql, typeorm, drizzle, and dctx writers; the dctx, prisma, and typeorm readers; the shared models.GetPrimaryKey/ GetForeignKeys helpers; pkg/diff, pkg/inspector, and pkg/merge; and the TUI column/relationship pickers in pkg/ui.
646 lines
17 KiB
Go
646 lines
17 KiB
Go
package inspector
|
|
|
|
import (
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
|
)
|
|
|
|
// sortedKeys returns a map's keys sorted alphabetically, so validators report
|
|
// violations in a deterministic order instead of Go's randomized map order.
|
|
func sortedKeys[T any](m map[string]T) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
// sortColumns returns columns sorted by Sequence then Name for deterministic output.
|
|
func sortColumns(columns map[string]*models.Column) []*models.Column {
|
|
result := make([]*models.Column, 0, len(columns))
|
|
for _, col := range columns {
|
|
result = append(result, col)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
if result[i].Sequence > 0 && result[j].Sequence > 0 {
|
|
return result[i].Sequence < result[j].Sequence
|
|
}
|
|
return result[i].Name < result[j].Name
|
|
})
|
|
return result
|
|
}
|
|
|
|
// sortConstraints returns constraints sorted by Sequence then Name for deterministic output.
|
|
func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint {
|
|
result := make([]*models.Constraint, 0, len(constraints))
|
|
for _, c := range constraints {
|
|
result = append(result, c)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
if result[i].Sequence > 0 && result[j].Sequence > 0 {
|
|
return result[i].Sequence < result[j].Sequence
|
|
}
|
|
return result[i].Name < result[j].Name
|
|
})
|
|
return result
|
|
}
|
|
|
|
// validatePrimaryKeyNaming checks that primary key column names match a pattern
|
|
func validatePrimaryKeyNaming(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
|
results := []ValidationResult{}
|
|
pattern, err := regexp.Compile(rule.Pattern)
|
|
if err != nil {
|
|
return results
|
|
}
|
|
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
for _, col := range sortColumns(table.Columns) {
|
|
if col.IsPrimaryKey {
|
|
location := formatLocation(schema.Name, table.Name, col.Name)
|
|
passed := pattern.MatchString(col.Name)
|
|
|
|
results = append(results, createResult(
|
|
ruleName,
|
|
passed,
|
|
rule.Message,
|
|
location,
|
|
map[string]interface{}{
|
|
"schema": schema.Name,
|
|
"table": table.Name,
|
|
"column": col.Name,
|
|
"expected_pattern": rule.Pattern,
|
|
},
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// validatePrimaryKeyDatatype checks that primary keys use approved data types
|
|
func validatePrimaryKeyDatatype(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
|
results := []ValidationResult{}
|
|
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
for _, col := range sortColumns(table.Columns) {
|
|
if col.IsPrimaryKey {
|
|
location := formatLocation(schema.Name, table.Name, col.Name)
|
|
|
|
// Normalize type (remove size/precision)
|
|
normalizedType := normalizeDataType(col.Type)
|
|
passed := contains(rule.AllowedTypes, normalizedType)
|
|
|
|
results = append(results, createResult(
|
|
ruleName,
|
|
passed,
|
|
rule.Message,
|
|
location,
|
|
map[string]interface{}{
|
|
"schema": schema.Name,
|
|
"table": table.Name,
|
|
"column": col.Name,
|
|
"current_type": col.Type,
|
|
"allowed_types": rule.AllowedTypes,
|
|
},
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// validatePrimaryKeyAutoIncrement checks primary key auto-increment settings
|
|
func validatePrimaryKeyAutoIncrement(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
|
results := []ValidationResult{}
|
|
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
for _, col := range sortColumns(table.Columns) {
|
|
if col.IsPrimaryKey {
|
|
location := formatLocation(schema.Name, table.Name, col.Name)
|
|
|
|
// Check if auto-increment matches requirement
|
|
passed := col.AutoIncrement == rule.RequireAutoIncrement
|
|
|
|
if !passed {
|
|
results = append(results, createResult(
|
|
ruleName,
|
|
false,
|
|
rule.Message,
|
|
location,
|
|
map[string]interface{}{
|
|
"schema": schema.Name,
|
|
"table": table.Name,
|
|
"column": col.Name,
|
|
"has_auto_increment": col.AutoIncrement,
|
|
"require_auto_increment": rule.RequireAutoIncrement,
|
|
},
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// validateForeignKeyColumnNaming checks that foreign key column names match a pattern
|
|
func validateForeignKeyColumnNaming(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
|
results := []ValidationResult{}
|
|
pattern, err := regexp.Compile(rule.Pattern)
|
|
if err != nil {
|
|
return results
|
|
}
|
|
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
// Check foreign key constraints
|
|
for _, constraint := range sortConstraints(table.Constraints) {
|
|
if constraint.Type == models.ForeignKeyConstraint {
|
|
for _, colName := range constraint.Columns {
|
|
location := formatLocation(schema.Name, table.Name, colName)
|
|
passed := pattern.MatchString(colName)
|
|
|
|
results = append(results, createResult(
|
|
ruleName,
|
|
passed,
|
|
rule.Message,
|
|
location,
|
|
map[string]interface{}{
|
|
"schema": schema.Name,
|
|
"table": table.Name,
|
|
"column": colName,
|
|
"constraint": constraint.Name,
|
|
"expected_pattern": rule.Pattern,
|
|
},
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// validateForeignKeyConstraintNaming checks that foreign key constraint names match a pattern
|
|
func validateForeignKeyConstraintNaming(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
|
results := []ValidationResult{}
|
|
pattern, err := regexp.Compile(rule.Pattern)
|
|
if err != nil {
|
|
return results
|
|
}
|
|
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
for _, constraint := range sortConstraints(table.Constraints) {
|
|
if constraint.Type == models.ForeignKeyConstraint {
|
|
location := formatLocation(schema.Name, table.Name, "")
|
|
passed := pattern.MatchString(constraint.Name)
|
|
|
|
results = append(results, createResult(
|
|
ruleName,
|
|
passed,
|
|
rule.Message,
|
|
location,
|
|
map[string]interface{}{
|
|
"schema": schema.Name,
|
|
"table": table.Name,
|
|
"constraint": constraint.Name,
|
|
"expected_pattern": rule.Pattern,
|
|
},
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// validateForeignKeyIndex checks that foreign key columns have indexes
|
|
func validateForeignKeyIndex(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
|
results := []ValidationResult{}
|
|
|
|
if !rule.RequireIndex {
|
|
return results
|
|
}
|
|
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
// Get all foreign key columns
|
|
fkColumns := make(map[string]bool)
|
|
for _, constraint := range table.Constraints {
|
|
if constraint.Type == models.ForeignKeyConstraint {
|
|
for _, col := range constraint.Columns {
|
|
fkColumns[col] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if each FK column has an index
|
|
for _, fkCol := range sortedKeys(fkColumns) {
|
|
hasIndex := false
|
|
|
|
// Check table indexes
|
|
for _, index := range table.Indexes {
|
|
// Index is good if FK column is the first column
|
|
if len(index.Columns) > 0 && index.Columns[0] == fkCol {
|
|
hasIndex = true
|
|
break
|
|
}
|
|
}
|
|
|
|
location := formatLocation(schema.Name, table.Name, fkCol)
|
|
results = append(results, createResult(
|
|
ruleName,
|
|
hasIndex,
|
|
rule.Message,
|
|
location,
|
|
map[string]interface{}{
|
|
"schema": schema.Name,
|
|
"table": table.Name,
|
|
"column": fkCol,
|
|
"has_index": hasIndex,
|
|
},
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// validateTableNamingCase checks table name casing
|
|
func validateTableNamingCase(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
|
results := []ValidationResult{}
|
|
pattern, err := regexp.Compile(rule.Pattern)
|
|
if err != nil {
|
|
return results
|
|
}
|
|
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
location := formatLocation(schema.Name, table.Name, "")
|
|
passed := pattern.MatchString(table.Name)
|
|
|
|
results = append(results, createResult(
|
|
ruleName,
|
|
passed,
|
|
rule.Message,
|
|
location,
|
|
map[string]interface{}{
|
|
"schema": schema.Name,
|
|
"table": table.Name,
|
|
"expected_case": rule.Case,
|
|
"expected_pattern": rule.Pattern,
|
|
},
|
|
))
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// validateColumnNamingCase checks column name casing
|
|
func validateColumnNamingCase(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
|
results := []ValidationResult{}
|
|
pattern, err := regexp.Compile(rule.Pattern)
|
|
if err != nil {
|
|
return results
|
|
}
|
|
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
for _, col := range sortColumns(table.Columns) {
|
|
location := formatLocation(schema.Name, table.Name, col.Name)
|
|
passed := pattern.MatchString(col.Name)
|
|
|
|
results = append(results, createResult(
|
|
ruleName,
|
|
passed,
|
|
rule.Message,
|
|
location,
|
|
map[string]interface{}{
|
|
"schema": schema.Name,
|
|
"table": table.Name,
|
|
"column": col.Name,
|
|
"expected_case": rule.Case,
|
|
"expected_pattern": rule.Pattern,
|
|
},
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// validateTableNameLength checks table name length
|
|
func validateTableNameLength(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
|
results := []ValidationResult{}
|
|
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
location := formatLocation(schema.Name, table.Name, "")
|
|
passed := len(table.Name) <= rule.MaxLength
|
|
|
|
results = append(results, createResult(
|
|
ruleName,
|
|
passed,
|
|
rule.Message,
|
|
location,
|
|
map[string]interface{}{
|
|
"schema": schema.Name,
|
|
"table": table.Name,
|
|
"length": len(table.Name),
|
|
"max_length": rule.MaxLength,
|
|
},
|
|
))
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// validateColumnNameLength checks column name length
|
|
func validateColumnNameLength(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
|
results := []ValidationResult{}
|
|
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
for _, col := range sortColumns(table.Columns) {
|
|
location := formatLocation(schema.Name, table.Name, col.Name)
|
|
passed := len(col.Name) <= rule.MaxLength
|
|
|
|
results = append(results, createResult(
|
|
ruleName,
|
|
passed,
|
|
rule.Message,
|
|
location,
|
|
map[string]interface{}{
|
|
"schema": schema.Name,
|
|
"table": table.Name,
|
|
"column": col.Name,
|
|
"length": len(col.Name),
|
|
"max_length": rule.MaxLength,
|
|
},
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// validateReservedKeywords checks for reserved SQL keywords
|
|
func validateReservedKeywords(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
|
results := []ValidationResult{}
|
|
|
|
// Build keyword map from PostgreSQL keywords
|
|
keywordSlice := pgsql.GetPostgresKeywords()
|
|
keywords := make(map[string]bool)
|
|
for _, kw := range keywordSlice {
|
|
keywords[strings.ToUpper(kw)] = true
|
|
}
|
|
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
// Check table name
|
|
if rule.CheckTables {
|
|
location := formatLocation(schema.Name, table.Name, "")
|
|
passed := !keywords[strings.ToUpper(table.Name)]
|
|
|
|
results = append(results, createResult(
|
|
ruleName,
|
|
passed,
|
|
rule.Message,
|
|
location,
|
|
map[string]interface{}{
|
|
"schema": schema.Name,
|
|
"table": table.Name,
|
|
"object_type": "table",
|
|
},
|
|
))
|
|
}
|
|
|
|
// Check column names
|
|
if rule.CheckColumns {
|
|
for _, col := range sortColumns(table.Columns) {
|
|
location := formatLocation(schema.Name, table.Name, col.Name)
|
|
passed := !keywords[strings.ToUpper(col.Name)]
|
|
|
|
results = append(results, createResult(
|
|
ruleName,
|
|
passed,
|
|
rule.Message,
|
|
location,
|
|
map[string]interface{}{
|
|
"schema": schema.Name,
|
|
"table": table.Name,
|
|
"column": col.Name,
|
|
"object_type": "column",
|
|
},
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// validateMissingPrimaryKey checks for tables without primary keys
|
|
func validateMissingPrimaryKey(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
|
results := []ValidationResult{}
|
|
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
hasPrimaryKey := false
|
|
|
|
// Check columns for primary key
|
|
for _, col := range table.Columns {
|
|
if col.IsPrimaryKey {
|
|
hasPrimaryKey = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Also check constraints
|
|
if !hasPrimaryKey {
|
|
for _, constraint := range table.Constraints {
|
|
if constraint.Type == models.PrimaryKeyConstraint {
|
|
hasPrimaryKey = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
location := formatLocation(schema.Name, table.Name, "")
|
|
results = append(results, createResult(
|
|
ruleName,
|
|
hasPrimaryKey,
|
|
rule.Message,
|
|
location,
|
|
map[string]interface{}{
|
|
"schema": schema.Name,
|
|
"table": table.Name,
|
|
},
|
|
))
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// validateOrphanedForeignKey checks for foreign keys referencing non-existent tables
|
|
func validateOrphanedForeignKey(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
|
results := []ValidationResult{}
|
|
|
|
// Build a map of existing tables for quick lookup
|
|
tableExists := make(map[string]bool)
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
key := schema.Name + "." + table.Name
|
|
tableExists[key] = true
|
|
}
|
|
}
|
|
|
|
// Check all foreign key constraints
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
for _, constraint := range sortConstraints(table.Constraints) {
|
|
if constraint.Type == models.ForeignKeyConstraint {
|
|
// Build referenced table key
|
|
refSchema := constraint.ReferencedSchema
|
|
if refSchema == "" {
|
|
refSchema = schema.Name
|
|
}
|
|
refKey := refSchema + "." + constraint.ReferencedTable
|
|
|
|
location := formatLocation(schema.Name, table.Name, "")
|
|
passed := tableExists[refKey]
|
|
|
|
results = append(results, createResult(
|
|
ruleName,
|
|
passed,
|
|
rule.Message,
|
|
location,
|
|
map[string]interface{}{
|
|
"schema": schema.Name,
|
|
"table": table.Name,
|
|
"constraint": constraint.Name,
|
|
"referenced_schema": refSchema,
|
|
"referenced_table": constraint.ReferencedTable,
|
|
},
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// validateCircularDependency checks for circular foreign key dependencies
|
|
func validateCircularDependency(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
|
results := []ValidationResult{}
|
|
|
|
// Build dependency graph
|
|
dependencies := make(map[string][]string)
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
tableKey := schema.Name + "." + table.Name
|
|
|
|
for _, constraint := range sortConstraints(table.Constraints) {
|
|
if constraint.Type == models.ForeignKeyConstraint {
|
|
refSchema := constraint.ReferencedSchema
|
|
if refSchema == "" {
|
|
refSchema = schema.Name
|
|
}
|
|
refKey := refSchema + "." + constraint.ReferencedTable
|
|
|
|
dependencies[tableKey] = append(dependencies[tableKey], refKey)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for cycles using DFS
|
|
for _, tableKey := range sortedKeys(dependencies) {
|
|
visited := make(map[string]bool)
|
|
recStack := make(map[string]bool)
|
|
|
|
if hasCycle(tableKey, dependencies, visited, recStack) {
|
|
parts := strings.Split(tableKey, ".")
|
|
location := formatLocation(parts[0], parts[1], "")
|
|
|
|
results = append(results, createResult(
|
|
ruleName,
|
|
false,
|
|
rule.Message,
|
|
location,
|
|
map[string]interface{}{
|
|
"schema": parts[0],
|
|
"table": parts[1],
|
|
},
|
|
))
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
// hasCycle performs DFS to detect cycles in dependency graph
|
|
func hasCycle(node string, graph map[string][]string, visited, recStack map[string]bool) bool {
|
|
visited[node] = true
|
|
recStack[node] = true
|
|
|
|
for _, neighbor := range graph[node] {
|
|
if !visited[neighbor] {
|
|
if hasCycle(neighbor, graph, visited, recStack) {
|
|
return true
|
|
}
|
|
} else if recStack[neighbor] {
|
|
return true
|
|
}
|
|
}
|
|
|
|
recStack[node] = false
|
|
return false
|
|
}
|
|
|
|
// normalizeDataType removes size/precision from data type
|
|
func normalizeDataType(dataType string) string {
|
|
// Remove everything in parentheses
|
|
idx := strings.Index(dataType, "(")
|
|
if idx > 0 {
|
|
dataType = dataType[:idx]
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(dataType))
|
|
}
|
|
|
|
// contains checks if a string slice contains a value
|
|
func contains(slice []string, value string) bool {
|
|
for _, item := range slice {
|
|
if strings.EqualFold(item, value) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|