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;
}
}