// Package main provides a tool to validate migration table creation order // by checking that dependent tables (with foreign keys) are created after // the tables they reference. package main import ( "fmt" "go/ast" "go/parser" "go/token" "os" "path/filepath" "regexp" "sort" "strings" ) // TableDependency represents a foreign key relationship where // Table has a FK column that references DependsOn type TableDependency struct { Table string // Table that has the FK column DependsOn string // Table being referenced (must be created first) Field string // Field name with the FK SourceFile string // Source file where this is defined } // MigrationAction represents a table creation or column addition in migrations type MigrationAction struct { MigrationID string ActionType string // "CreateTable" or "AddColumn" Table string Column string // Only for AddColumn Order int // Order within migrations.go Line int // Line number in file } func main() { // Default paths relative to where the script is run from migrationsPath := "framework/configstore/migrations.go" tablesDir := "framework/configstore/tables" // Allow overriding via command line args if len(os.Args) > 1 { migrationsPath = os.Args[1] } if len(os.Args) > 2 { tablesDir = os.Args[2] } // Parse table definitions to get FK dependencies dependencies, err := parseTableDefinitions(tablesDir) if err != nil { fmt.Fprintf(os.Stderr, "Error parsing table definitions: %v\n", err) os.Exit(1) } // Parse migrations to get table creation order actions, err := parseMigrationOrder(migrationsPath) if err != nil { fmt.Fprintf(os.Stderr, "Error parsing migrations: %v\n", err) os.Exit(1) } // Validate dependencies violations := validateDependencies(dependencies, actions) // Report results if len(violations) == 0 { fmt.Println("✓ All migration dependencies are satisfied!") fmt.Printf(" Checked %d table dependencies across %d migration actions\n", len(dependencies), len(actions)) os.Exit(0) } fmt.Printf("✗ Found %d dependency violation(s):\n\n", len(violations)) for _, v := range violations { fmt.Println(v) } os.Exit(1) } // parseTableDefinitions parses all Go files in the tables directory // and extracts foreign key relationships from GORM struct tags // // GORM FK relationships: // 1. Belongs-to: `Budget *TableBudget gorm:"foreignKey:BudgetID"` // - FK column (BudgetID) is on THIS table // - Referenced table (TableBudget) must be created FIRST // // 2. Has-many: `Keys []TableKey gorm:"foreignKey:ProviderID"` // - FK column (ProviderID) is on the CHILD table (TableKey) // - THIS table (parent) must be created FIRST // - We don't track this as a dependency because the parent comes first naturally func parseTableDefinitions(tablesDir string) ([]TableDependency, error) { var dependencies []TableDependency // Table struct name to table name mapping tableNames := make(map[string]string) // First pass: collect all table name mappings files, err := filepath.Glob(filepath.Join(tablesDir, "*.go")) if err != nil { return nil, fmt.Errorf("failed to glob tables dir: %w", err) } fset := token.NewFileSet() for _, file := range files { node, err := parser.ParseFile(fset, file, nil, parser.ParseComments) if err != nil { return nil, fmt.Errorf("failed to parse %s: %w", file, err) } // Find struct types and their TableName methods ast.Inspect(node, func(n ast.Node) bool { // Look for TableName() methods if funcDecl, ok := n.(*ast.FuncDecl); ok { if funcDecl.Name.Name == "TableName" && funcDecl.Recv != nil { // Get the receiver type name if len(funcDecl.Recv.List) > 0 { if ident, ok := funcDecl.Recv.List[0].Type.(*ast.Ident); ok { structName := ident.Name // Extract the table name from the return statement if funcDecl.Body != nil { for _, stmt := range funcDecl.Body.List { if ret, ok := stmt.(*ast.ReturnStmt); ok { if len(ret.Results) > 0 { if lit, ok := ret.Results[0].(*ast.BasicLit); ok { tableName := strings.Trim(lit.Value, `"`) tableNames[structName] = tableName } } } } } } } } } return true }) } // Second pass: find foreign key relationships // Pattern to match GORM foreignKey tags fkPattern := regexp.MustCompile(`foreignKey:(\w+)`) for _, file := range files { node, err := parser.ParseFile(fset, file, nil, parser.ParseComments) if err != nil { continue } ast.Inspect(node, func(n ast.Node) bool { typeSpec, ok := n.(*ast.TypeSpec) if !ok { return true } structType, ok := typeSpec.Type.(*ast.StructType) if !ok { return true } structName := typeSpec.Name.Name if !strings.HasPrefix(structName, "Table") && structName != "SessionsTable" { return true } currentTableName := tableNames[structName] if currentTableName == "" { return true } // Check each field for FK relationships for _, field := range structType.Fields.List { if field.Tag == nil { continue } tag := field.Tag.Value // Skip fields that are not stored in DB if strings.Contains(tag, `gorm:"-"`) { continue } // Look for foreignKey in gorm tag fkMatches := fkPattern.FindStringSubmatch(tag) if len(fkMatches) < 2 { continue } fkColumn := fkMatches[1] // Get field name fieldName := "" if len(field.Names) > 0 { fieldName = field.Names[0].Name } // Determine the type of relationship based on field type var refTableStruct string isBelongsTo := false switch t := field.Type.(type) { case *ast.StarExpr: // Pointer type: *TableBudget - this is a "belongs-to" relationship // The FK column is on THIS table, referencing the other table if ident, ok := t.X.(*ast.Ident); ok { refTableStruct = ident.Name isBelongsTo = true } case *ast.ArrayType, *ast.SliceExpr: // Slice type: []TableKey - this is a "has-many" relationship // The FK column is on the CHILD table, not on this table // Parent must be created first, which is natural order // We don't need to track this as a dependency continue case *ast.Ident: // Direct type reference refTableStruct = t.Name isBelongsTo = true } // Only track belongs-to relationships where THIS table has the FK column if !isBelongsTo || refTableStruct == "" || !strings.HasPrefix(refTableStruct, "Table") { continue } // Verify the FK column exists on this struct hasFKColumn := false for _, f := range structType.Fields.List { if len(f.Names) > 0 && f.Names[0].Name == fkColumn { hasFKColumn = true break } } // If the FK column is on this table, it's a belongs-to relationship // The referenced table must be created before this table if hasFKColumn { refTableName := tableNames[refTableStruct] if refTableName != "" { dependencies = append(dependencies, TableDependency{ Table: currentTableName, DependsOn: refTableName, Field: fieldName, SourceFile: filepath.Base(file), }) } } } return true }) } return dependencies, nil } // parseMigrationOrder parses migrations.go and extracts the order of table creations func parseMigrationOrder(migrationsPath string) ([]MigrationAction, error) { var actions []MigrationAction content, err := os.ReadFile(migrationsPath) if err != nil { return nil, fmt.Errorf("failed to read migrations file: %w", err) } fset := token.NewFileSet() node, err := parser.ParseFile(fset, migrationsPath, content, parser.ParseComments) if err != nil { return nil, fmt.Errorf("failed to parse migrations file: %w", err) } // Track the current migration function being processed currentMigration := "" order := 0 // Table struct to table name mapping (simplified) tableMapping := map[string]string{ "TableConfigHash": "config_hashes", "TableBudget": "governance_budgets", "TableRateLimit": "governance_rate_limits", "TableProvider": "config_providers", "TableKey": "config_keys", "TableModel": "config_models", "TableOauthConfig": "oauth_configs", "TableOauthToken": "oauth_tokens", "TableMCPClient": "config_mcp_clients", "TableClientConfig": "config_client", "TableEnvKey": "config_env_keys", "TableVectorStoreConfig": "config_vector_stores", "TableLogStoreConfig": "config_log_stores", "TableCustomer": "governance_customers", "TableTeam": "governance_teams", "TableVirtualKey": "governance_virtual_keys", "TableGovernanceConfig": "governance_configs", "TableModelPricing": "model_pricing", "TablePlugin": "plugins", "TableFrameworkConfig": "framework_configs", "TableVirtualKeyProviderConfig": "governance_virtual_key_provider_configs", "TableVirtualKeyMCPConfig": "governance_virtual_key_mcp_configs", "SessionsTable": "sessions", "TableDistributedLock": "distributed_locks", "TableModelConfig": "model_configs", "TableRoutingRule": "routing_rules", } ast.Inspect(node, func(n ast.Node) bool { // Track function declarations for migration IDs if funcDecl, ok := n.(*ast.FuncDecl); ok { if strings.HasPrefix(funcDecl.Name.Name, "migration") { currentMigration = funcDecl.Name.Name } } // Look for CreateTable calls if call, ok := n.(*ast.CallExpr); ok { if sel, ok := call.Fun.(*ast.SelectorExpr); ok { if sel.Sel.Name == "CreateTable" { // Extract the table type if len(call.Args) > 0 { if unary, ok := call.Args[0].(*ast.UnaryExpr); ok { if comp, ok := unary.X.(*ast.CompositeLit); ok { if sel, ok := comp.Type.(*ast.SelectorExpr); ok { structName := sel.Sel.Name if tableName, exists := tableMapping[structName]; exists { pos := fset.Position(call.Pos()) actions = append(actions, MigrationAction{ MigrationID: currentMigration, ActionType: "CreateTable", Table: tableName, Order: order, Line: pos.Line, }) order++ } } } } } } else if sel.Sel.Name == "AddColumn" { // Extract column additions that might have FK constraints if len(call.Args) >= 2 { // First arg is table, second is column name if unary, ok := call.Args[0].(*ast.UnaryExpr); ok { if comp, ok := unary.X.(*ast.CompositeLit); ok { if sel, ok := comp.Type.(*ast.SelectorExpr); ok { structName := sel.Sel.Name if tableName, exists := tableMapping[structName]; exists { colName := "" if lit, ok := call.Args[1].(*ast.BasicLit); ok { colName = strings.Trim(lit.Value, `"`) } pos := fset.Position(call.Pos()) actions = append(actions, MigrationAction{ MigrationID: currentMigration, ActionType: "AddColumn", Table: tableName, Column: colName, Order: order, Line: pos.Line, }) order++ } } } } } } } } return true }) return actions, nil } // validateDependencies checks if tables with FK dependencies are created after their referenced tables func validateDependencies(deps []TableDependency, actions []MigrationAction) []string { var violations []string // Build a map of table -> first creation order tableCreationOrder := make(map[string]int) tableCreationLine := make(map[string]int) tableCreationMigration := make(map[string]string) for _, action := range actions { if action.ActionType == "CreateTable" { if _, exists := tableCreationOrder[action.Table]; !exists { tableCreationOrder[action.Table] = action.Order tableCreationLine[action.Table] = action.Line tableCreationMigration[action.Table] = action.MigrationID } } } // Check each dependency for _, dep := range deps { depOrder, depExists := tableCreationOrder[dep.Table] refOrder, refExists := tableCreationOrder[dep.DependsOn] // Skip if either table isn't created via migrations (might be handled elsewhere) if !depExists || !refExists { continue } // The table with the FK (dep.Table) should be created AFTER the referenced table (dep.DependsOn) // Violation: dep.Table is created before dep.DependsOn if depOrder < refOrder { violations = append(violations, fmt.Sprintf( " - Table '%s' (line %d, %s) is created before '%s' (line %d, %s)\n"+ " but '%s' has a FK column referencing '%s' via field '%s' (defined in %s)", dep.Table, tableCreationLine[dep.Table], tableCreationMigration[dep.Table], dep.DependsOn, tableCreationLine[dep.DependsOn], tableCreationMigration[dep.DependsOn], dep.Table, dep.DependsOn, dep.Field, dep.SourceFile, )) } } // Sort violations for consistent output sort.Strings(violations) return violations }