diff --git a/src/Core/Grand.Infrastructure/Migrations/IMigrationVersionStamp.cs b/src/Core/Grand.Infrastructure/Migrations/IMigrationVersionStamp.cs new file mode 100644 index 000000000..7be0ccfa6 --- /dev/null +++ b/src/Core/Grand.Infrastructure/Migrations/IMigrationVersionStamp.cs @@ -0,0 +1,12 @@ +namespace Grand.Infrastructure.Migrations; + +/// +/// Marks the migration that records a database version as reached. +/// +/// +/// A version stamp must be the last migration to run for its , and it must only +/// run when every other migration of that version succeeded. +/// filters by version, so a version recorded before its own migrations completed makes them unreachable forever — +/// they compare equal to the installed version and are never selected again. +/// +public interface IMigrationVersionStamp : IMigration; diff --git a/src/Core/Grand.Infrastructure/Migrations/MigrationManager.cs b/src/Core/Grand.Infrastructure/Migrations/MigrationManager.cs index 5c4c9c59f..03b29024e 100644 --- a/src/Core/Grand.Infrastructure/Migrations/MigrationManager.cs +++ b/src/Core/Grand.Infrastructure/Migrations/MigrationManager.cs @@ -33,7 +33,10 @@ public IEnumerable GetCurrentMigrations(DbVersion installedVersion) { return GetAllMigrations() .Where(x => x.Version.CompareTo(installedVersion) > 0) - .OrderBy(mg => mg.Version.ToString()) + //DbVersion, not its string form - "2.10" sorts before "2.2" as text + .OrderBy(mg => mg.Version) + //the version stamp closes its version, whatever priority it declares + .ThenBy(mg => mg is IMigrationVersionStamp ? 1 : 0) .ThenBy(mg => mg.Priority) .ToList(); } diff --git a/src/Modules/Grand.Module.Migration/Migrations/1.1/MigrationUpgradeDbVersion_11.cs b/src/Modules/Grand.Module.Migration/Migrations/1.1/MigrationUpgradeDbVersion_11.cs index 64881c80b..7af20c346 100644 --- a/src/Modules/Grand.Module.Migration/Migrations/1.1/MigrationUpgradeDbVersion_11.cs +++ b/src/Modules/Grand.Module.Migration/Migrations/1.1/MigrationUpgradeDbVersion_11.cs @@ -1,35 +1,10 @@ -using Grand.Data; -using Grand.Domain.Common; -using Grand.Infrastructure; using Grand.Infrastructure.Migrations; -using Microsoft.Extensions.DependencyInjection; namespace Grand.Module.Migration.Migrations._1._1; -public class MigrationUpgradeDbVersion_11 : IMigration +public class MigrationUpgradeDbVersion_11 : MigrationUpgradeDbVersionBase { - public int Priority => 0; + public override DbVersion Version => new(1, 1); - public DbVersion Version => new(1, 1); - - public Guid Identity => new("6BDB7093-4C31-4D78-9604-58188DF728D3"); - - public string Name => "Upgrade version of the database to 1.1"; - - /// - /// Upgrade process - /// - /// - /// - /// - public bool UpgradeProcess(IServiceProvider serviceProvider) - { - var repository = serviceProvider.GetRequiredService>(); - - var dbversion = repository.Table.FirstOrDefault(); - dbversion!.DataBaseVersion = $"{GrandVersion.SupportedDBVersion}"; - repository.Update(dbversion); - - return true; - } -} \ No newline at end of file + public override Guid Identity => new("6BDB7093-4C31-4D78-9604-58188DF728D3"); +} diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.0/MigrationUpgradeDbVersion_20.cs b/src/Modules/Grand.Module.Migration/Migrations/2.0/MigrationUpgradeDbVersion_20.cs index 90cca5034..5ffd22f5c 100644 --- a/src/Modules/Grand.Module.Migration/Migrations/2.0/MigrationUpgradeDbVersion_20.cs +++ b/src/Modules/Grand.Module.Migration/Migrations/2.0/MigrationUpgradeDbVersion_20.cs @@ -1,35 +1,10 @@ -using Grand.Data; -using Grand.Domain.Common; -using Grand.Infrastructure; using Grand.Infrastructure.Migrations; -using Microsoft.Extensions.DependencyInjection; namespace Grand.Module.Migration.Migrations._2._0; -public class MigrationUpgradeDbVersion_20 : IMigration +public class MigrationUpgradeDbVersion_20 : MigrationUpgradeDbVersionBase { - public int Priority => 0; + public override DbVersion Version => new(2, 0); - public DbVersion Version => new(2, 0); - - public Guid Identity => new("AEC3CF1F-4443-474A-B932-4F91D08C8F61"); - - public string Name => "Upgrade version of the database to 2.0"; - - /// - /// Upgrade process - /// - /// - /// - /// - public bool UpgradeProcess(IServiceProvider serviceProvider) - { - var repository = serviceProvider.GetRequiredService>(); - - var dbversion = repository.Table.FirstOrDefault(); - dbversion!.DataBaseVersion = $"{GrandVersion.SupportedDBVersion}"; - repository.Update(dbversion); - - return true; - } -} \ No newline at end of file + public override Guid Identity => new("AEC3CF1F-4443-474A-B932-4F91D08C8F61"); +} diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.1/MigrationUpgradeDbVersion_21.cs b/src/Modules/Grand.Module.Migration/Migrations/2.1/MigrationUpgradeDbVersion_21.cs index f17ef9e4a..e67fb3a23 100644 --- a/src/Modules/Grand.Module.Migration/Migrations/2.1/MigrationUpgradeDbVersion_21.cs +++ b/src/Modules/Grand.Module.Migration/Migrations/2.1/MigrationUpgradeDbVersion_21.cs @@ -1,35 +1,10 @@ -using Grand.Data; -using Grand.Domain.Common; -using Grand.Infrastructure; using Grand.Infrastructure.Migrations; -using Microsoft.Extensions.DependencyInjection; namespace Grand.Module.Migration.Migrations._2._1; -public class MigrationUpgradeDbVersion_21 : IMigration +public class MigrationUpgradeDbVersion_21 : MigrationUpgradeDbVersionBase { - public int Priority => 0; + public override DbVersion Version => new(2, 1); - public DbVersion Version => new(2, 1); - - public Guid Identity => new("EA674DAA-66B2-4F21-9C68-008ACE752FBD"); - - public string Name => "Upgrade version of the database to 2.1"; - - /// - /// Upgrade process - /// - /// - /// - /// - public bool UpgradeProcess(IServiceProvider serviceProvider) - { - var repository = serviceProvider.GetRequiredService>(); - - var dbversion = repository.Table.FirstOrDefault(); - dbversion!.DataBaseVersion = $"{GrandVersion.SupportedDBVersion}"; - repository.Update(dbversion); - - return true; - } -} \ No newline at end of file + public override Guid Identity => new("EA674DAA-66B2-4F21-9C68-008ACE752FBD"); +} diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.2/MigrationUpgradeDbVersion_22.cs b/src/Modules/Grand.Module.Migration/Migrations/2.2/MigrationUpgradeDbVersion_22.cs index 03a3c60a5..458a556d1 100644 --- a/src/Modules/Grand.Module.Migration/Migrations/2.2/MigrationUpgradeDbVersion_22.cs +++ b/src/Modules/Grand.Module.Migration/Migrations/2.2/MigrationUpgradeDbVersion_22.cs @@ -1,35 +1,10 @@ -using Grand.Data; -using Grand.Domain.Common; -using Grand.Infrastructure; using Grand.Infrastructure.Migrations; -using Microsoft.Extensions.DependencyInjection; namespace Grand.Module.Migration.Migrations._2._2; -public class MigrationUpgradeDbVersion_22 : IMigration +public class MigrationUpgradeDbVersion_22 : MigrationUpgradeDbVersionBase { - public int Priority => 0; + public override DbVersion Version => new(2, 2); - public DbVersion Version => new(2, 2); - - public Guid Identity => new("9B9FD138-7E67-44AA-913B-273F3D5B5DE9"); - - public string Name => "Upgrade version of the database to 2.2"; - - /// - /// Upgrade process - /// - /// - /// - /// - public bool UpgradeProcess(IServiceProvider serviceProvider) - { - var repository = serviceProvider.GetRequiredService>(); - - var dbversion = repository.Table.FirstOrDefault(); - dbversion!.DataBaseVersion = $"{GrandVersion.SupportedDBVersion}"; - repository.Update(dbversion); - - return true; - } -} \ No newline at end of file + public override Guid Identity => new("9B9FD138-7E67-44AA-913B-273F3D5B5DE9"); +} diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.3/MigrationUpgradeDbVersion_23.cs b/src/Modules/Grand.Module.Migration/Migrations/2.3/MigrationUpgradeDbVersion_23.cs index d9ddae27e..072f8d51e 100644 --- a/src/Modules/Grand.Module.Migration/Migrations/2.3/MigrationUpgradeDbVersion_23.cs +++ b/src/Modules/Grand.Module.Migration/Migrations/2.3/MigrationUpgradeDbVersion_23.cs @@ -1,36 +1,10 @@ -using Grand.Data; -using Grand.Domain.Common; -using Grand.Infrastructure; using Grand.Infrastructure.Migrations; -using Microsoft.Extensions.DependencyInjection; namespace Grand.Module.Migration.Migrations._2._3; -public class MigrationUpgradeDbVersion_23 : IMigration +public class MigrationUpgradeDbVersion_23 : MigrationUpgradeDbVersionBase { - public int Priority => 0; + public override DbVersion Version => new(2, 3); - public DbVersion Version => new(2, 3); - - public Guid Identity => new("689E5BFA-7229-41A5-AF48-07CB58C0D608"); - - public string Name => "Upgrade version of the database to 2.3"; - - /// - /// Upgrade process - /// - /// - /// - /// - public bool UpgradeProcess(IServiceProvider serviceProvider) - { - var repository = serviceProvider.GetRequiredService>(); - - var dbversion = repository.Table.FirstOrDefault(); - dbversion!.InstalledVersion = $"{GrandVersion.SupportedDBVersion}"; - dbversion!.DataBaseVersion = $"{GrandVersion.SupportedDBVersion}"; - repository.Update(dbversion); - - return true; - } -} \ No newline at end of file + public override Guid Identity => new("689E5BFA-7229-41A5-AF48-07CB58C0D608"); +} diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.4/MigrationUpgradeDbVersion_24.cs b/src/Modules/Grand.Module.Migration/Migrations/2.4/MigrationUpgradeDbVersion_24.cs index e4a86d7d1..bfc0a41ff 100644 --- a/src/Modules/Grand.Module.Migration/Migrations/2.4/MigrationUpgradeDbVersion_24.cs +++ b/src/Modules/Grand.Module.Migration/Migrations/2.4/MigrationUpgradeDbVersion_24.cs @@ -1,35 +1,10 @@ -using Grand.Data; -using Grand.Domain.Common; -using Grand.Infrastructure; using Grand.Infrastructure.Migrations; -using Microsoft.Extensions.DependencyInjection; namespace Grand.Module.Migration.Migrations._2._4; -public class MigrationUpgradeDbVersion_24 : IMigration +public class MigrationUpgradeDbVersion_24 : MigrationUpgradeDbVersionBase { - public int Priority => 0; + public override DbVersion Version => new(2, 4); - public DbVersion Version => new(2, 4); - - public Guid Identity => new("A1B2C3D4-E5F6-7890-ABCD-EF1234567890"); - - public string Name => "Upgrade version of the database to 2.4"; - - /// - /// Upgrade process - /// - /// - /// - public bool UpgradeProcess(IServiceProvider serviceProvider) - { - var repository = serviceProvider.GetRequiredService>(); - - var dbversion = repository.Table.FirstOrDefault(); - dbversion!.InstalledVersion = $"{GrandVersion.SupportedDBVersion}"; - dbversion!.DataBaseVersion = $"{GrandVersion.SupportedDBVersion}"; - repository.Update(dbversion); - - return true; - } + public override Guid Identity => new("A1B2C3D4-E5F6-7890-ABCD-EF1234567890"); } diff --git a/src/Modules/Grand.Module.Migration/Migrations/MigrationProcess.cs b/src/Modules/Grand.Module.Migration/Migrations/MigrationProcess.cs index dfe9c79bd..9237b0047 100644 --- a/src/Modules/Grand.Module.Migration/Migrations/MigrationProcess.cs +++ b/src/Modules/Grand.Module.Migration/Migrations/MigrationProcess.cs @@ -57,14 +57,45 @@ public virtual void RunMigrationProcess() { var migrationsDb = GetMigrationDb(); var version = _repositoryVersion.Table.FirstOrDefault(); - if(version == null) + if (version == null) return; - var majorVersion = string.IsNullOrEmpty(version?.InstalledVersion) ? int.Parse(version?.DataBaseVersion.Split('.')[0]!) : int.Parse(version?.InstalledVersion.Split('.')[0]!); - var minorVersion = string.IsNullOrEmpty(version?.InstalledVersion) ? int.Parse(version?.DataBaseVersion.Split('.')[1]!) : int.Parse(version?.InstalledVersion.Split('.')[1]!); + + var installedVersion = ParseDbVersion(string.IsNullOrEmpty(version.InstalledVersion) + ? version.DataBaseVersion + : version.InstalledVersion); + + if (installedVersion == null) + { + _logger.LogError("Cannot read the installed database version - migration process skipped"); + return; + } + var migrationManager = new MigrationManager(); - foreach (var item in migrationManager.GetCurrentMigrations(new DbVersion(majorVersion, minorVersion))) - if (migrationsDb.FirstOrDefault(x => x.Identity == item.Identity) == null) - RunProcess(item); + foreach (var item in migrationManager.GetCurrentMigrations(installedVersion)) + { + if (migrationsDb.Any(x => x.Identity == item.Identity)) + continue; + + if (RunProcess(item).Success) continue; + + //stop before the version stamp of this version runs - recording a version whose + //migrations did not all succeed puts them below the installed version, and + //GetCurrentMigrations never selects them again + _logger.LogError("Migration process stopped at {MigrationName} ({Version}) - it will be retried on the next start", + item.Name, item.Version); + return; + } + } + + private static DbVersion? ParseDbVersion(string? version) + { + var parts = version?.Split('.'); + if (parts is not { Length: >= 2 }) + return null; + + return int.TryParse(parts[0], out var major) && int.TryParse(parts[1], out var minor) + ? new DbVersion(major, minor) + : null; } private MigrationResult RunProcessInternal(IMigration migration) diff --git a/src/Modules/Grand.Module.Migration/Migrations/MigrationUpgradeDbVersionBase.cs b/src/Modules/Grand.Module.Migration/Migrations/MigrationUpgradeDbVersionBase.cs new file mode 100644 index 000000000..ba1a6a83d --- /dev/null +++ b/src/Modules/Grand.Module.Migration/Migrations/MigrationUpgradeDbVersionBase.cs @@ -0,0 +1,45 @@ +using Grand.Data; +using Grand.Domain.Common; +using Grand.Infrastructure.Migrations; +using Microsoft.Extensions.DependencyInjection; + +namespace Grand.Module.Migration.Migrations; + +/// +/// Records as the version the database has reached. +/// +/// +/// Runs last within its version - see . +/// +public abstract class MigrationUpgradeDbVersionBase : IMigrationVersionStamp +{ + public int Priority => 0; + + public abstract DbVersion Version { get; } + + public abstract Guid Identity { get; } + + public string Name => $"Upgrade version of the database to {Version}"; + + /// + /// Upgrade process + /// + /// + /// + public bool UpgradeProcess(IServiceProvider serviceProvider) + { + var repository = serviceProvider.GetRequiredService>(); + + var dbversion = repository.Table.FirstOrDefault(); + if (dbversion == null) + return false; + + //stamp the version this migration closes, not the version the build supports - stamping + //the latest supported version here makes every migration in between unreachable + dbversion.InstalledVersion = Version.ToString(); + dbversion.DataBaseVersion = Version.ToString(); + repository.Update(dbversion); + + return true; + } +} diff --git a/src/Tests/Grand.Modules.Tests/Services/Migrations/MigrationManagerTests.cs b/src/Tests/Grand.Modules.Tests/Services/Migrations/MigrationManagerTests.cs index a0139e364..9352077e7 100644 --- a/src/Tests/Grand.Modules.Tests/Services/Migrations/MigrationManagerTests.cs +++ b/src/Tests/Grand.Modules.Tests/Services/Migrations/MigrationManagerTests.cs @@ -1,16 +1,37 @@ -using Grand.Infrastructure.Migrations; +using Grand.Data; +using Grand.Domain.Common; +using Grand.Infrastructure.Migrations; +using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; namespace Grand.Modules.Tests.Services.Migrations; [TestClass] public class MigrationManagerTests { + /// + /// MigrationManager scans loaded assemblies, and a project reference alone does not load one. + /// Holding the assembly in a field keeps it loaded before the first scan. + /// + private static readonly System.Reflection.Assembly MigrationsAssembly = + typeof(Grand.Module.Migration.Migrations.MigrationUpgradeDbVersionBase).Assembly; + private MigrationManager _migrationManager; + /// + /// Every migration that ships, from the oldest supported database version upwards. + /// + private IList AllShippedMigrations => + _migrationManager.GetCurrentMigrations(new DbVersion(1, 0)) + //test fixtures in this assembly also implement IMigration - keep them out + .Where(x => x.GetType().Assembly == MigrationsAssembly) + .ToList(); + [TestInitialize] public void Init() { + Assert.IsNotNull(MigrationsAssembly); _migrationManager = new MigrationManager(); } @@ -20,4 +41,66 @@ public void GetCurrentMigrations_Exists() var migrations = _migrationManager.GetCurrentMigrations(new DbVersion(2, 2)); Assert.IsNotEmpty(migrations); } -} \ No newline at end of file + + [TestMethod] + public void GetCurrentMigrations_OrderedByAscendingVersion() + { + var migrations = AllShippedMigrations; + + for (var i = 1; i < migrations.Count; i++) + Assert.IsTrue(migrations[i].Version.CompareTo(migrations[i - 1].Version) >= 0, + $"{migrations[i].Name} ({migrations[i].Version}) runs after {migrations[i - 1].Name} ({migrations[i - 1].Version})"); + } + + [TestMethod] + public void GetCurrentMigrations_VersionStampIsLastWithinItsVersion() + { + var migrations = AllShippedMigrations; + + foreach (var group in migrations.GroupBy(x => x.Version.ToString())) + { + var stamp = group.OfType().SingleOrDefault(); + if (stamp == null) continue; + + Assert.AreSame(stamp, group.Last(), + $"The version stamp for {group.Key} must run last, otherwise the remaining migrations of that version are skipped permanently"); + } + } + + [TestMethod] + public void AllMigrations_HaveUniqueIdentity() + { + var duplicates = AllShippedMigrations + .GroupBy(x => x.Identity) + .Where(x => x.Count() > 1) + .Select(x => x.Key) + .ToList(); + + Assert.IsEmpty(duplicates, $"Duplicated migration identity: {string.Join(", ", duplicates)}"); + } + + [TestMethod] + public void VersionStamp_RecordsItsOwnVersion() + { + var stamps = AllShippedMigrations.OfType().ToList(); + Assert.IsNotEmpty(stamps); + + foreach (var stamp in stamps) + { + var dbVersion = new GrandNodeVersion { DataBaseVersion = "1.0", InstalledVersion = "1.0" }; + + var repository = new Mock>(); + repository.Setup(x => x.Table).Returns(new List { dbVersion }.AsQueryable()); + + var services = new ServiceCollection(); + services.AddSingleton(repository.Object); + + Assert.IsTrue(stamp.UpgradeProcess(services.BuildServiceProvider()), $"{stamp.Name} failed"); + + Assert.AreEqual(stamp.Version.ToString(), dbVersion.DataBaseVersion, + $"{stamp.Name} must record its own version, not the version the build supports"); + Assert.AreEqual(stamp.Version.ToString(), dbVersion.InstalledVersion, + $"{stamp.Name} must record its own version in InstalledVersion too - the migration process reads it first"); + } + } +}