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.
191 lines
5.4 KiB
Go
191 lines
5.4 KiB
Go
package inspector
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
|
)
|
|
|
|
// Inspector performs validation on database models
|
|
type Inspector struct {
|
|
config *Config
|
|
db *models.Database
|
|
}
|
|
|
|
// ValidationResult represents the result of a single validation check
|
|
type ValidationResult struct {
|
|
RuleName string `json:"rule_name"`
|
|
Level string `json:"level"` // "error" or "warning"
|
|
Message string `json:"message"`
|
|
Location string `json:"location"` // e.g., "schema.table.column"
|
|
Context map[string]interface{} `json:"context"`
|
|
Passed bool `json:"passed"`
|
|
}
|
|
|
|
// InspectorReport contains the complete validation report
|
|
type InspectorReport struct {
|
|
Summary ReportSummary `json:"summary"`
|
|
Violations []ValidationResult `json:"violations"`
|
|
GeneratedAt time.Time `json:"generated_at"`
|
|
Database string `json:"database"`
|
|
SourceFormat string `json:"source_format"`
|
|
}
|
|
|
|
// ReportSummary contains aggregate statistics
|
|
type ReportSummary struct {
|
|
TotalRules int `json:"total_rules"`
|
|
RulesChecked int `json:"rules_checked"`
|
|
RulesSkipped int `json:"rules_skipped"`
|
|
ErrorCount int `json:"error_count"`
|
|
WarningCount int `json:"warning_count"`
|
|
PassedCount int `json:"passed_count"`
|
|
}
|
|
|
|
// NewInspector creates a new inspector with the given database and configuration
|
|
func NewInspector(db *models.Database, config *Config) *Inspector {
|
|
return &Inspector{
|
|
config: config,
|
|
db: db,
|
|
}
|
|
}
|
|
|
|
// Inspect runs all enabled validation rules and returns a report
|
|
func (i *Inspector) Inspect() (*InspectorReport, error) {
|
|
results := []ValidationResult{}
|
|
|
|
// Run all enabled validators in deterministic (alphabetical) rule-name order
|
|
ruleNames := make([]string, 0, len(i.config.Rules))
|
|
for ruleName := range i.config.Rules {
|
|
ruleNames = append(ruleNames, ruleName)
|
|
}
|
|
sort.Strings(ruleNames)
|
|
|
|
for _, ruleName := range ruleNames {
|
|
rule := i.config.Rules[ruleName]
|
|
if !rule.IsEnabled() {
|
|
continue
|
|
}
|
|
|
|
// Get the validator function for this rule using the function field
|
|
validator, exists := getValidator(rule.Function)
|
|
if !exists {
|
|
// Skip unknown validator functions
|
|
continue
|
|
}
|
|
|
|
// Run the validator
|
|
ruleResults := validator(i.db, rule, ruleName)
|
|
|
|
// Set the level based on rule configuration
|
|
level := "warning"
|
|
if rule.IsEnforced() {
|
|
level = "error"
|
|
}
|
|
|
|
for idx := range ruleResults {
|
|
ruleResults[idx].Level = level
|
|
}
|
|
|
|
results = append(results, ruleResults...)
|
|
}
|
|
|
|
// Generate summary
|
|
summary := i.generateSummary(results)
|
|
|
|
report := &InspectorReport{
|
|
Summary: summary,
|
|
Violations: results,
|
|
GeneratedAt: time.Now(),
|
|
Database: i.db.Name,
|
|
SourceFormat: i.db.SourceFormat,
|
|
}
|
|
|
|
return report, nil
|
|
}
|
|
|
|
// generateSummary creates summary statistics from validation results
|
|
func (i *Inspector) generateSummary(results []ValidationResult) ReportSummary {
|
|
summary := ReportSummary{
|
|
TotalRules: len(i.config.Rules),
|
|
}
|
|
|
|
// Count enabled rules
|
|
for _, rule := range i.config.Rules {
|
|
if rule.IsEnabled() {
|
|
summary.RulesChecked++
|
|
} else {
|
|
summary.RulesSkipped++
|
|
}
|
|
}
|
|
|
|
// Count violations by level
|
|
for _, result := range results {
|
|
if result.Passed {
|
|
summary.PassedCount++
|
|
} else {
|
|
if result.Level == "error" {
|
|
summary.ErrorCount++
|
|
} else {
|
|
summary.WarningCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
return summary
|
|
}
|
|
|
|
// HasErrors returns true if the report contains any errors
|
|
func (r *InspectorReport) HasErrors() bool {
|
|
return r.Summary.ErrorCount > 0
|
|
}
|
|
|
|
// validatorFunc is a function that validates a rule against a database
|
|
type validatorFunc func(*models.Database, Rule, string) []ValidationResult
|
|
|
|
// getValidator returns the validator function for a given function name
|
|
func getValidator(functionName string) (validatorFunc, bool) {
|
|
validators := map[string]validatorFunc{
|
|
"primary_key_naming": validatePrimaryKeyNaming,
|
|
"primary_key_datatype": validatePrimaryKeyDatatype,
|
|
"primary_key_auto_increment": validatePrimaryKeyAutoIncrement,
|
|
"foreign_key_column_naming": validateForeignKeyColumnNaming,
|
|
"foreign_key_constraint_naming": validateForeignKeyConstraintNaming,
|
|
"foreign_key_index": validateForeignKeyIndex,
|
|
"table_regexpr": validateTableNamingCase,
|
|
"column_regexpr": validateColumnNamingCase,
|
|
"table_name_length": validateTableNameLength,
|
|
"column_name_length": validateColumnNameLength,
|
|
"reserved_words": validateReservedKeywords,
|
|
"have_primary_key": validateMissingPrimaryKey,
|
|
"orphaned_foreign_key": validateOrphanedForeignKey,
|
|
"circular_dependency": validateCircularDependency,
|
|
}
|
|
|
|
fn, exists := validators[functionName]
|
|
return fn, exists
|
|
}
|
|
|
|
// createResult is a helper to create a validation result
|
|
func createResult(ruleName string, passed bool, message string, location string, context map[string]interface{}) ValidationResult {
|
|
return ValidationResult{
|
|
RuleName: ruleName,
|
|
Message: message,
|
|
Location: location,
|
|
Context: context,
|
|
Passed: passed,
|
|
}
|
|
}
|
|
|
|
// formatLocation creates a location string from schema, table, and optional column
|
|
func formatLocation(schema, table, column string) string {
|
|
if column != "" {
|
|
return fmt.Sprintf("%s.%s.%s", schema, table, column)
|
|
}
|
|
if table != "" {
|
|
return fmt.Sprintf("%s.%s", schema, table)
|
|
}
|
|
return schema
|
|
}
|