diff --git a/src/grate.core/Configuration/GrateConfiguration.cs b/src/grate.core/Configuration/GrateConfiguration.cs index 62059fdd..1d7a4fb6 100644 --- a/src/grate.core/Configuration/GrateConfiguration.cs +++ b/src/grate.core/Configuration/GrateConfiguration.cs @@ -172,4 +172,6 @@ public record GrateConfiguration /// Defer writing to the run tables until the end of the migration (only used in bootstrapping) /// internal bool DeferWritingToRunTables { get; set; } + + public bool UpToDateCheck { get; set; } } diff --git a/src/grate.core/Migration/DbMigrator.cs b/src/grate.core/Migration/DbMigrator.cs index 7b588a93..bc773b2c 100644 --- a/src/grate.core/Migration/DbMigrator.cs +++ b/src/grate.core/Migration/DbMigrator.cs @@ -85,18 +85,13 @@ public async Task RunSql(string sql, string scriptName, MigrationsFolder f var type = folder.Type; - async Task LogAndRunSql() + async Task LogAndRunSql() { Logger.LogInformation(" Running '{ScriptName}'.", scriptName); - - if (Configuration.DryRun) - { - return false; - } - else + + if (!Configuration.DryRun) { await RunTheActualSql(sql, scriptName, type, versionId, connectionType, transactionHandling); - return true; } } @@ -132,7 +127,8 @@ async Task LogAndRunSql() case MigrationType.Once when changeHandling == ChangedScriptHandling.WarnAndRun: LogScriptChangedWarning(scriptName); Logger.LogDebug("Running script anyway due to WarnOnOneTimeScriptChanges option being set."); - theSqlWasRun = await LogAndRunSql(); + await LogAndRunSql(); + theSqlWasRun = true; break; case MigrationType.Once when changeHandling == ChangedScriptHandling.WarnAndIgnore: @@ -143,7 +139,8 @@ async Task LogAndRunSql() case MigrationType.AnyTime: case MigrationType.EveryTime: - theSqlWasRun = await LogAndRunSql(); + await LogAndRunSql(); + theSqlWasRun = true; break; } } @@ -154,7 +151,8 @@ async Task LogAndRunSql() } else { - theSqlWasRun = await LogAndRunSql(); + await LogAndRunSql(); + theSqlWasRun = true; } return theSqlWasRun; @@ -167,21 +165,6 @@ public async Task RunSqlWithoutLogging( ConnectionType connectionType, TransactionHandling transactionHandling) { - async Task PrintLogAndRunSql() - { - Logger.LogInformation(" Running '{ScriptName}'.", scriptName); - - if (Configuration.DryRun) - { - return false; - } - else - { - await RunTheActualSqlWithoutLogging(sql, scriptName, connectionType, transactionHandling); - return true; - } - } - if (!InCorrectEnvironment(scriptName, environment)) { return false; @@ -191,7 +174,15 @@ async Task PrintLogAndRunSql() { sql = ReplaceTokensIn(sql); } - return await PrintLogAndRunSql(); + + Logger.LogInformation(" Running '{ScriptName}'.", scriptName); + + if (!Configuration.DryRun) + { + await RunTheActualSqlWithoutLogging(sql, scriptName, connectionType, transactionHandling); + } + + return true; } diff --git a/src/grate.core/Migration/GrateMigrator.cs b/src/grate.core/Migration/GrateMigrator.cs index 5c0edda0..4d581b93 100644 --- a/src/grate.core/Migration/GrateMigrator.cs +++ b/src/grate.core/Migration/GrateMigrator.cs @@ -39,7 +39,9 @@ private init DbMigrator.Database = value; } } - + + public MigrationResult MigrationResult { get; } = new(); + public IGrateMigrator WithConfiguration(GrateConfiguration configuration) { return this with { Configuration = configuration }; @@ -225,6 +227,18 @@ public async Task Migrate() // Ignore! } } + + // If it's an up-to-date check, we output on the console if it's up-to-date or not. + if (config.UpToDateCheck) + { + var logger = _loggerFactory.CreateLogger(LogCategory + ".IsUpToDate"); + + logger.LogInformation("Up to date: {IsUpToDate}", MigrationResult.IsUpToDate); + foreach (var script in MigrationResult.ScriptsRun) + { + logger.LogDebug("Changed script: {ScriptName}", script); + } + } _logger.LogInformation( "\n\ngrate v{Version} (build date {BuildDate}) has grated your database ({DatabaseName})! You are now at version {NewVersion}. All changes and backups can be found at \"{ChangeDropFolder}\".", @@ -235,8 +249,6 @@ public async Task Migrate() changeDropFolder); Separator(' '); - - } private async Task EnsureConnectionIsOpen(ConnectionType connectionType) @@ -408,6 +420,7 @@ private async ValueTask Process(DirectoryInfo root, MigrationsFolder folde if (theSqlRan) { anySqlRun = true; + AddScriptRunToResult(folder, fileNameToLog); try { CopyToChangeDropFolder(path.Parent!, file, changeDropFolder); @@ -460,6 +473,7 @@ private async ValueTask ProcessWithoutLogging(DirectoryInfo root, Migratio if (theSqlRan) { anySqlRun = true; + AddScriptRunToResult(folder, fileNameToLog); try { CopyToChangeDropFolder(path.Parent!, file, changeDropFolder); @@ -471,7 +485,7 @@ private async ValueTask ProcessWithoutLogging(DirectoryInfo root, Migratio } } - if (!anySqlRun) + if (!anySqlRun && !DbMigrator.Configuration.DryRun) { _logger.LogInformation(" No sql run, either an empty folder, or all files run against destination previously."); } @@ -481,6 +495,7 @@ private async ValueTask ProcessWithoutLogging(DirectoryInfo root, Migratio } + private void CopyToChangeDropFolder(DirectoryInfo migrationRoot, FileSystemInfo file, string changeDropFolder) { var relativePath = Path.GetRelativePath(migrationRoot.ToString(), file.FullName); @@ -663,6 +678,16 @@ await Bootstrapping.WriteBootstrapScriptsToFolder( sqlFolderNamePrefix); return internalMigrationFolders; } + + private void AddScriptRunToResult(MigrationsFolder folder, string fileNameToLog) + { + MigrationResult.AddScriptRun(fileNameToLog); + // If we (would have) run a script that is not an EveryTime script, we were not up to date. + if (folder.Type != MigrationType.EveryTime) + { + MigrationResult.IsUpToDate = false; + } + } public async ValueTask DisposeAsync() { diff --git a/src/grate.core/Migration/MigrationResult.cs b/src/grate.core/Migration/MigrationResult.cs new file mode 100644 index 00000000..ae7a252d --- /dev/null +++ b/src/grate.core/Migration/MigrationResult.cs @@ -0,0 +1,12 @@ +namespace grate.Migration; + +// ReSharper disable once ClassNeverInstantiated.Global +internal record MigrationResult +{ + private readonly List _scriptsRun = []; + + public IReadOnlyList ScriptsRun => _scriptsRun.AsReadOnly(); + public bool IsUpToDate { get; set; } = true; + + public void AddScriptRun(string scriptName) => _scriptsRun.Add(scriptName); +} diff --git a/src/grate/Commands/MigrateCommand.cs b/src/grate/Commands/MigrateCommand.cs index 21839c85..cf3a87f4 100644 --- a/src/grate/Commands/MigrateCommand.cs +++ b/src/grate/Commands/MigrateCommand.cs @@ -40,6 +40,7 @@ public MigrateCommand(IGrateMigrator mi) : base("Migrates the database") Add(DryRun()); Add(Restore()); Add(IgnoreDirectoryNames()); + Add(UpToDateCheck()); Handler = CommandHandler.Create( async () => @@ -334,4 +335,8 @@ private static Option IgnoreDirectoryNames() => new[] { "--ignoredirectorynames", "--searchallinsteadoftraverse", "--searchallsubdirectoriesinsteadoftraverse" }, "IgnoreDirectoryNames - By default, scripts are ordered by relative path including subdirectories. This option searches subdirectories, but order is based on filename alone." ); + + internal static Option UpToDateCheck() => new( + ["--uptodatecheck", "--isuptodate"], + "Outputs whether the database is up to date or not (whether any non-everytime scripts would be run)"); } diff --git a/src/grate/Program.cs b/src/grate/Program.cs index 9dd44319..accfdd04 100644 --- a/src/grate/Program.cs +++ b/src/grate/Program.cs @@ -26,13 +26,19 @@ public static class Program public static async Task Main(string[] args) { - // Temporarily parse the configuration, to get the verbosity level + // Temporarily parse the configuration, to get the verbosity level, and potentially set parameters + // to support the "IsUpToDate" check. var cfg = await ParseGrateConfiguration(args); + if (cfg.UpToDateCheck) + { + cfg = cfg with { Verbosity = LogLevel.Critical, DryRun = true }; + } + _serviceProvider = BuildServiceProvider(cfg).CreateAsyncScope().ServiceProvider; var rootCommand = Create(); rootCommand.Add(Verbosity()); - + rootCommand.Description = $"grate v{GetVersion()} - sql for the 20s"; var parser = new CommandLineBuilder(rootCommand) @@ -79,7 +85,10 @@ private static async Task ParseGrateConfiguration CommandLineGrateConfiguration cfg = new CommandLineGrateConfiguration(); var handler = CommandHandler.Create((CommandLineGrateConfiguration config) => cfg = config); - var cmd = new MigrateCommand(null!) { Verbosity() }; + var cmd = new MigrateCommand(null!) + { + Verbosity(), + }; ParseResult p = new Parser(cmd).Parse(commandline); @@ -126,6 +135,7 @@ private static ServiceProvider BuildServiceProvider(CommandLineGrateConfiguratio options.LogToStandardErrorThreshold = LogLevel.Warning; }) .AddFilter("Grate.Migration.Internal", LogLevel.Critical) + .AddFilter("Grate.Migration.IsUpToDate", LogLevel.Information) .SetMinimumLevel(config.Verbosity) .AddConsoleFormatter()); @@ -145,7 +155,7 @@ private static ServiceProvider BuildServiceProvider(CommandLineGrateConfiguratio } internal static Option Verbosity() => new( - new[] { "-v", "--verbosity" }, + ["-v", "--verbosity"], "Verbosity level (as defined here: https://docs.microsoft.com/dotnet/api/Microsoft.Extensions.Logging.LogLevel)"); private static T Create() where T : notnull => _serviceProvider.GetRequiredService(); diff --git a/unittests/Basic_tests/CommandLineParsing/Basic_CommandLineParsing.cs b/unittests/Basic_tests/CommandLineParsing/Basic_CommandLineParsing.cs index 28631374..85085ba7 100644 --- a/unittests/Basic_tests/CommandLineParsing/Basic_CommandLineParsing.cs +++ b/unittests/Basic_tests/CommandLineParsing/Basic_CommandLineParsing.cs @@ -378,6 +378,18 @@ public async Task IgnoreDirectoryNames(string args, bool expected) var cfg = await ParseGrateConfiguration(args); cfg?.IgnoreDirectoryNames.Should().Be(expected); } + + [Theory] + [InlineData("", false)] + [InlineData("--isuptodate", true)] + [InlineData("--isuptodate false", false)] + [InlineData("--uptodatecheck", true)] + [InlineData("--uptodatecheck false", false)] + public async Task UpToDateCheck(string args, bool expected) + { + var cfg = await ParseGrateConfiguration(args); + cfg?.UpToDateCheck.Should().Be(expected); + } private static async Task ParseGrateConfiguration(string commandline) { diff --git a/unittests/Basic_tests/GrateMigrator_MigrationStatus/IsUpToDate_.cs b/unittests/Basic_tests/GrateMigrator_MigrationStatus/IsUpToDate_.cs new file mode 100644 index 00000000..9efc977b --- /dev/null +++ b/unittests/Basic_tests/GrateMigrator_MigrationStatus/IsUpToDate_.cs @@ -0,0 +1,140 @@ +using Basic_tests.Infrastructure; +using FluentAssertions; +using grate.Configuration; +using grate.Infrastructure; +using grate.Migration; +using NSubstitute; + +namespace Basic_tests.GrateMigrator_MigrationStatus; + +// ReSharper disable once InconsistentNaming +public class IsUpToDate_: IDisposable +{ + private static readonly DirectoryInfo SqlFilesDirectory = Directory.CreateTempSubdirectory(); + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task False_if_scripts_run(bool dryRun) + { + var folders = new Dictionary> + { + { "up", + [ + ("script_that_is_run.sql", "-- ThisIsRun"), + ("script_that_is_not_run.sql", "-- ThisIsNotRun") + ] + } + }; + + var grateMigrator = CreateMigrator(folders, dryRun); + await grateMigrator.Migrate(); + + grateMigrator.MigrationResult.Should().NotBeNull(); + grateMigrator.MigrationResult.IsUpToDate.Should().BeFalse(); + + _logger.LoggedMessages.Should().Contain("Up to date: False"); + _logger.LoggedMessages.Should().Contain("Changed script: script_that_is_run.sql"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task True_if_no_scripts_run(bool dryRun) + { + var folders = new Dictionary> + { + { "up", + [ + ("script_that_is_not_run_either.sql", "-- ThisIsDefinitelyNotRun"), + ("script_that_is_not_run.sql", "-- ThisIsNotRun") + ] + } + }; + + var grateMigrator = CreateMigrator(folders, dryRun); + await grateMigrator.Migrate(); + + grateMigrator.MigrationResult.Should().NotBeNull(); + grateMigrator.MigrationResult.IsUpToDate.Should().BeTrue(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task True_if_only_everytime_scripts_run(bool dryRun) + { + var folders = new Dictionary> + { + { "up", + [ + ("script_that_is_not_run_either.sql", "-- ThisIsDefinitelyNotRun"), + ("script_that_is_not_run.sql", "-- ThisIsNotRun") + ] + }, + { "permissions", + [ + ("script_that_is_run.sql", "-- ThisIsRun"), + ("script_that_is_not_run.sql", "-- ThisIsNotRun") + ] + } + }; + + var grateMigrator = CreateMigrator(folders, dryRun); + await grateMigrator.Migrate(); + + grateMigrator.MigrationResult.Should().NotBeNull(); + grateMigrator.MigrationResult.IsUpToDate.Should().BeTrue(); + } + + private GrateMigrator CreateMigrator(Dictionary> scripts, bool dryRun) + { + foreach (var folder in scripts.Keys) + { + foreach (var (filename, content) in scripts[folder]) + { + var parent = Path.Combine(SqlFilesDirectory.ToString(), folder); + Directory.CreateDirectory(parent); + + var fullPath = Path.Combine(parent, filename); + File.WriteAllText(fullPath, content); + } + } + + var config = new GrateConfiguration() + { + SqlFilesDirectory = SqlFilesDirectory, + NonInteractive = true, + DryRun = dryRun, + UpToDateCheck = true + }; + + var dbMigrator = Substitute.ForPartsOf(); + dbMigrator.RunSql( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ) + .Returns(info => + { + var sql = info.ArgAt(0); + return sql.Contains("ThisIsRun"); + }); + + dbMigrator.Configuration = config; + var grateMigrator = new GrateMigrator(new MockGrateLoggerFactory(_logger), dbMigrator); + + return grateMigrator; + } + + private readonly MockGrateLogger _logger = new(); + + public void Dispose() + { + Directory.Delete(SqlFilesDirectory.FullName, true); + } +} diff --git a/unittests/Basic_tests/GrateMigrator_MigrationStatus/MigrationStatus_.cs b/unittests/Basic_tests/GrateMigrator_MigrationStatus/MigrationStatus_.cs new file mode 100644 index 00000000..0e05955f --- /dev/null +++ b/unittests/Basic_tests/GrateMigrator_MigrationStatus/MigrationStatus_.cs @@ -0,0 +1,72 @@ +using Basic_tests.Infrastructure; +using FluentAssertions; +using grate.Configuration; +using grate.Infrastructure; +using grate.Migration; +using NSubstitute; + +namespace Basic_tests.GrateMigrator_MigrationStatus; + +// ReSharper disable once InconsistentNaming +public class MigrationStatus_: IDisposable +{ + private static readonly DirectoryInfo SqlFilesDirectory = Directory.CreateTempSubdirectory(); + + [Fact] + public async Task Includes_list_of_ScriptsRun() + { + var grateMigrator = CreateMigrator(new List<(string, string)>() + { + ("script_that_is_run.sql", "-- ThisIsRun"), + ("script_that_is_not_run.sql", "-- ThisIsNotRun"), + }); + await grateMigrator.Migrate(); + + grateMigrator.MigrationResult.Should().NotBeNull(); + grateMigrator.MigrationResult.ScriptsRun.Should().BeEquivalentTo("script_that_is_run.sql"); + } + + private static GrateMigrator CreateMigrator(List<(string, string)> scripts) + { + foreach (var (filename, content) in scripts) + { + var parent = Path.Combine(SqlFilesDirectory.ToString(), "up"); + Directory.CreateDirectory(parent); + + var fullPath = Path.Combine(parent, filename); + File.WriteAllText(fullPath, content); + } + + var config = new GrateConfiguration() + { + SqlFilesDirectory = SqlFilesDirectory, + NonInteractive = true, + }; + + var dbMigrator = Substitute.ForPartsOf(); + dbMigrator.RunSql( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ) + .Returns(info => + { + var sql = info.ArgAt(0); + return sql.Contains("ThisIsRun"); + }); + + dbMigrator.Configuration = config; + var grateMigrator = new GrateMigrator(new MockGrateLoggerFactory(), dbMigrator); + + return grateMigrator; + } + + public void Dispose() + { + Directory.Delete(SqlFilesDirectory.FullName, true); + } +} diff --git a/unittests/Basic_tests/Infrastructure/MockDbMigrator.cs b/unittests/Basic_tests/Infrastructure/MockDbMigrator.cs index 2f9b12f1..e46d1a39 100644 --- a/unittests/Basic_tests/Infrastructure/MockDbMigrator.cs +++ b/unittests/Basic_tests/Infrastructure/MockDbMigrator.cs @@ -72,13 +72,13 @@ public Task CloseAdminConnection() throw new NotImplementedException(); } - public Task RunSql(string sql, string scriptName, MigrationsFolder folder, long versionId, GrateEnvironment? environment, + public virtual Task RunSql(string sql, string scriptName, MigrationsFolder folder, long versionId, GrateEnvironment? environment, ConnectionType connectionType, TransactionHandling transactionHandling) { return Task.FromResult(false); } - public Task RunSqlWithoutLogging(string sql, string scriptName, GrateEnvironment? environment, ConnectionType connectionType, + public virtual Task RunSqlWithoutLogging(string sql, string scriptName, GrateEnvironment? environment, ConnectionType connectionType, TransactionHandling transactionHandling) { throw new NotImplementedException(); diff --git a/unittests/Basic_tests/Infrastructure/MockDbMigratorLogger.cs b/unittests/Basic_tests/Infrastructure/MockDbMigratorLogger.cs new file mode 100644 index 00000000..89dd8560 --- /dev/null +++ b/unittests/Basic_tests/Infrastructure/MockDbMigratorLogger.cs @@ -0,0 +1,18 @@ +using grate.Migration; +using Microsoft.Extensions.Logging; + +namespace Basic_tests.Infrastructure; + +public class MockDbMigratorLogger: ILogger +{ + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + } + + public bool IsEnabled(LogLevel logLevel) => false; + + public IDisposable? BeginScope(TState state) where TState : notnull + { + return null; + } +}