242 lines
8.8 KiB
C#
242 lines
8.8 KiB
C#
using System.Text.RegularExpressions;
|
|
|
|
namespace StellaOps.Infrastructure.Postgres.Migrations;
|
|
|
|
/// <summary>
|
|
/// Validates migration files for naming conventions, duplicates, and ordering issues.
|
|
/// </summary>
|
|
public static partial class MigrationValidator
|
|
{
|
|
/// <summary>
|
|
/// Standard migration pattern: NNN_description.sql (001-099 for startup, 100+ for release).
|
|
/// </summary>
|
|
[GeneratedRegex(@"^(\d{3})_[a-z0-9_]+\.sql$", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
|
private static partial Regex StandardPattern();
|
|
|
|
/// <summary>
|
|
/// Seed migration pattern: SNNN_description.sql.
|
|
/// </summary>
|
|
[GeneratedRegex(@"^S(\d{3})_[a-z0-9_]+\.sql$", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
|
private static partial Regex SeedPattern();
|
|
|
|
/// <summary>
|
|
/// Data migration pattern: DMNNN_description.sql.
|
|
/// </summary>
|
|
[GeneratedRegex(@"^DM(\d{3})_[a-z0-9_]+\.sql$", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
|
private static partial Regex DataMigrationPattern();
|
|
|
|
/// <summary>
|
|
/// Validation result for a set of migrations.
|
|
/// </summary>
|
|
public sealed record ValidationResult
|
|
{
|
|
public bool IsValid => Errors.Count == 0;
|
|
public IReadOnlyList<ValidationError> Errors { get; init; } = [];
|
|
public IReadOnlyList<ValidationWarning> Warnings { get; init; } = [];
|
|
|
|
public static ValidationResult Success(IReadOnlyList<ValidationWarning>? warnings = null) =>
|
|
new() { Warnings = warnings ?? [] };
|
|
|
|
public static ValidationResult Failed(IReadOnlyList<ValidationError> errors, IReadOnlyList<ValidationWarning>? warnings = null) =>
|
|
new() { Errors = errors, Warnings = warnings ?? [] };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validation error that will block migration execution.
|
|
/// </summary>
|
|
public sealed record ValidationError(string Code, string Message, string? MigrationName = null);
|
|
|
|
/// <summary>
|
|
/// Validation warning that should be addressed but won't block execution.
|
|
/// </summary>
|
|
public sealed record ValidationWarning(string Code, string Message, string? MigrationName = null);
|
|
|
|
/// <summary>
|
|
/// Validates a collection of migration file names.
|
|
/// </summary>
|
|
public static ValidationResult Validate(IEnumerable<string> migrationNames)
|
|
{
|
|
var names = migrationNames.ToList();
|
|
var errors = new List<ValidationError>();
|
|
var warnings = new List<ValidationWarning>();
|
|
|
|
// 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Detects migrations with duplicate numeric prefixes.
|
|
/// </summary>
|
|
public static IReadOnlyList<(string Prefix, IReadOnlyList<string> Names)> DetectDuplicatePrefixes(
|
|
IEnumerable<string> migrationNames)
|
|
{
|
|
var byPrefix = new Dictionary<string, List<string>>(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<string>)kvp.Value))
|
|
.ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts the numeric prefix from a migration name.
|
|
/// </summary>
|
|
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<string> 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;
|
|
}
|
|
}
|