using System.Text.RegularExpressions; namespace StellaOps.Infrastructure.Postgres.Migrations; /// /// Validates migration files for naming conventions, duplicates, and ordering issues. /// public static partial class MigrationValidator { /// /// Standard migration pattern: NNN_description.sql (001-099 for startup, 100+ for release). /// [GeneratedRegex(@"^(\d{3})_[a-z0-9_]+\.sql$", RegexOptions.IgnoreCase | RegexOptions.Compiled)] private static partial Regex StandardPattern(); /// /// Seed migration pattern: SNNN_description.sql. /// [GeneratedRegex(@"^S(\d{3})_[a-z0-9_]+\.sql$", RegexOptions.IgnoreCase | RegexOptions.Compiled)] private static partial Regex SeedPattern(); /// /// Data migration pattern: DMNNN_description.sql. /// [GeneratedRegex(@"^DM(\d{3})_[a-z0-9_]+\.sql$", RegexOptions.IgnoreCase | RegexOptions.Compiled)] private static partial Regex DataMigrationPattern(); /// /// Validation result for a set of migrations. /// public sealed record ValidationResult { public bool IsValid => Errors.Count == 0; public IReadOnlyList Errors { get; init; } = []; public IReadOnlyList Warnings { get; init; } = []; public static ValidationResult Success(IReadOnlyList? warnings = null) => new() { Warnings = warnings ?? [] }; public static ValidationResult Failed(IReadOnlyList errors, IReadOnlyList? warnings = null) => new() { Errors = errors, Warnings = warnings ?? [] }; } /// /// Validation error that will block migration execution. /// public sealed record ValidationError(string Code, string Message, string? MigrationName = null); /// /// Validation warning that should be addressed but won't block execution. /// public sealed record ValidationWarning(string Code, string Message, string? MigrationName = null); /// /// Validates a collection of migration file names. /// public static ValidationResult Validate(IEnumerable migrationNames) { var names = migrationNames.ToList(); var errors = new List(); var warnings = new List(); // Check for duplicates (same numeric prefix) var duplicates = DetectDuplicatePrefixes(names); foreach (var (prefix, duplicateNames) in duplicates) { errors.Add(new ValidationError( "DUPLICATE_PREFIX", $"Multiple migrations with prefix '{prefix}': {string.Join(", ", duplicateNames)}", duplicateNames.First())); } // Check naming conventions foreach (var name in names) { var conventionResult = ValidateNamingConvention(name); if (conventionResult is not null) { if (conventionResult.Value.IsError) { errors.Add(new ValidationError(conventionResult.Value.Code, conventionResult.Value.Message, name)); } else { warnings.Add(new ValidationWarning(conventionResult.Value.Code, conventionResult.Value.Message, name)); } } } // Check for gaps in numbering var gaps = DetectNumberingGaps(names); foreach (var gap in gaps) { warnings.Add(new ValidationWarning( "NUMBERING_GAP", $"Gap in migration numbering: {gap.After} is followed by {gap.Before} (missing {gap.Missing})", gap.Before)); } return errors.Count > 0 ? ValidationResult.Failed(errors, warnings) : ValidationResult.Success(warnings); } /// /// Detects migrations with duplicate numeric prefixes. /// public static IReadOnlyList<(string Prefix, IReadOnlyList Names)> DetectDuplicatePrefixes( IEnumerable migrationNames) { var byPrefix = new Dictionary>(StringComparer.Ordinal); foreach (var name in migrationNames) { var prefix = ExtractNumericPrefix(name); if (prefix is null) continue; if (!byPrefix.TryGetValue(prefix, out var list)) { list = []; byPrefix[prefix] = list; } list.Add(name); } return byPrefix .Where(kvp => kvp.Value.Count > 1) .Select(kvp => (kvp.Key, (IReadOnlyList)kvp.Value)) .ToList(); } /// /// Extracts the numeric prefix from a migration name. /// public static string? ExtractNumericPrefix(string migrationName) { var name = Path.GetFileNameWithoutExtension(migrationName); // Handle seed migrations (S001, S002, etc.) if (name.StartsWith('S') && char.IsDigit(name.ElementAtOrDefault(1))) { return "S" + new string(name.Skip(1).TakeWhile(char.IsDigit).ToArray()); } // Handle data migrations (DM001, DM002, etc.) if (name.StartsWith("DM", StringComparison.OrdinalIgnoreCase) && char.IsDigit(name.ElementAtOrDefault(2))) { return "DM" + new string(name.Skip(2).TakeWhile(char.IsDigit).ToArray()); } // Handle standard migrations (001, 002, etc.) var digits = new string(name.TakeWhile(char.IsDigit).ToArray()); return string.IsNullOrEmpty(digits) ? null : digits.TrimStart('0').PadLeft(3, '0'); } private static (bool IsError, string Code, string Message)? ValidateNamingConvention(string migrationName) { var name = Path.GetFileName(migrationName); // Check standard pattern if (StandardPattern().IsMatch(name)) { return null; // Valid } // Check seed pattern if (SeedPattern().IsMatch(name)) { return null; // Valid } // Check data migration pattern if (DataMigrationPattern().IsMatch(name)) { return null; // Valid } // Check for non-standard but common patterns if (name.StartsWith("V", StringComparison.OrdinalIgnoreCase)) { return (false, "FLYWAY_STYLE", $"Migration '{name}' uses Flyway-style naming. Consider standardizing to NNN_description.sql format."); } if (name.Length > 15 && char.IsDigit(name[0]) && name.Contains("_")) { // Likely EF Core timestamp pattern like 20251214000001_AddSchema.sql return (false, "EFCORE_STYLE", $"Migration '{name}' uses EF Core timestamp naming. Consider standardizing to NNN_description.sql format."); } // Check for 4-digit prefixes (like 0059_scans_table.sql) var fourDigitMatch = System.Text.RegularExpressions.Regex.Match(name, @"^(\d{4})_"); if (fourDigitMatch.Success) { return (false, "FOUR_DIGIT_PREFIX", $"Migration '{name}' uses 4-digit prefix. Standard is 3-digit (NNN_description.sql)."); } return (false, "NON_STANDARD_NAME", $"Migration '{name}' does not match standard naming pattern (NNN_description.sql)."); } private static IReadOnlyList<(string After, string Before, string Missing)> DetectNumberingGaps( IEnumerable migrationNames) { var gaps = new List<(string, string, string)>(); var standardMigrations = new List<(int Number, string Name)>(); foreach (var name in migrationNames) { var prefix = ExtractNumericPrefix(name); if (prefix is null) continue; // Only check standard migrations (not S or DM) if (prefix.StartsWith('S') || prefix.StartsWith("DM", StringComparison.OrdinalIgnoreCase)) { continue; } if (int.TryParse(prefix, out var num)) { standardMigrations.Add((num, name)); } } var sorted = standardMigrations.OrderBy(m => m.Number).ToList(); for (var i = 1; i < sorted.Count; i++) { var prev = sorted[i - 1]; var curr = sorted[i]; var expected = prev.Number + 1; if (curr.Number > expected && curr.Number - prev.Number > 1) { var missing = expected == curr.Number - 1 ? expected.ToString("D3") : $"{expected:D3}-{(curr.Number - 1):D3}"; gaps.Add((prev.Name, curr.Name, missing)); } } return gaps; } }