diff --git a/NDB.sln b/NDB.sln index 7b90a04..b787291 100644 --- a/NDB.sln +++ b/NDB.sln @@ -56,7 +56,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NDB.Extensions.Caching", "N EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{CCEE458E-02A8-42FD-8F5F-A35481A23303}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NDB.Test.Api", "NDB.Test.Api\NDB.Test.Api.csproj", "{F717BE3D-F5F4-4D99-B96D-D0A23E8BED01}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NDB.Test.Api", "NDB.Test.Api\NDB.Test.Api.csproj", "{F717BE3D-F5F4-4D99-B96D-D0A23E8BED01}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NDB.Infrastructure.DatabaseMigration", "infrastructure\NDB.Infrastructure.DatabaseMigration\NDB.Infrastructure.DatabaseMigration.csproj", "{74C7BE02-DD5C-49C2-8E88-E3AEA729E2AB}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -104,6 +106,10 @@ Global {F717BE3D-F5F4-4D99-B96D-D0A23E8BED01}.Debug|Any CPU.Build.0 = Debug|Any CPU {F717BE3D-F5F4-4D99-B96D-D0A23E8BED01}.Release|Any CPU.ActiveCfg = Release|Any CPU {F717BE3D-F5F4-4D99-B96D-D0A23E8BED01}.Release|Any CPU.Build.0 = Release|Any CPU + {74C7BE02-DD5C-49C2-8E88-E3AEA729E2AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74C7BE02-DD5C-49C2-8E88-E3AEA729E2AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74C7BE02-DD5C-49C2-8E88-E3AEA729E2AB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74C7BE02-DD5C-49C2-8E88-E3AEA729E2AB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -131,6 +137,7 @@ Global {3E045EE6-A290-467C-B503-3A6CB0065C97} = {A206A484-3ACF-4032-8B36-AC93A72B0B88} {CCEE458E-02A8-42FD-8F5F-A35481A23303} = {E0202271-4E92-4DB8-900D-B5FD745B9278} {F717BE3D-F5F4-4D99-B96D-D0A23E8BED01} = {CCEE458E-02A8-42FD-8F5F-A35481A23303} + {74C7BE02-DD5C-49C2-8E88-E3AEA729E2AB} = {1C1D634E-06CC-4707-9564-E31A76F27D9E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {87541BAB-3FAC-4ADB-A7FB-8228DA87843D} diff --git a/infrastructure/NDB.Infrastructure.DatabaseMigration/Constants/DatabaseType.cs b/infrastructure/NDB.Infrastructure.DatabaseMigration/Constants/DatabaseType.cs new file mode 100644 index 0000000..6f3478a --- /dev/null +++ b/infrastructure/NDB.Infrastructure.DatabaseMigration/Constants/DatabaseType.cs @@ -0,0 +1,8 @@ +namespace NDB.Infrastructure.DatabaseMigration.Constants +{ + public enum DatabaseType + { + SQLite, + SQLServer + } +} diff --git a/infrastructure/NDB.Infrastructure.DatabaseMigration/DbContexts/MigrationDbContext.cs b/infrastructure/NDB.Infrastructure.DatabaseMigration/DbContexts/MigrationDbContext.cs new file mode 100644 index 0000000..7407ece --- /dev/null +++ b/infrastructure/NDB.Infrastructure.DatabaseMigration/DbContexts/MigrationDbContext.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; + +namespace NDB.Infrastructure.DatabaseMigration.DbContexts +{ + internal class MigrationDbContext : DbContext + { + public MigrationDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + } + } +} diff --git a/infrastructure/NDB.Infrastructure.DatabaseMigration/DependencyInjectionExtensions.cs b/infrastructure/NDB.Infrastructure.DatabaseMigration/DependencyInjectionExtensions.cs new file mode 100644 index 0000000..688d1c8 --- /dev/null +++ b/infrastructure/NDB.Infrastructure.DatabaseMigration/DependencyInjectionExtensions.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NDB.Infrastructure.DatabaseMigration.Constants; +using NDB.Infrastructure.DatabaseMigration.DbContexts; +using NDB.Infrastructure.DatabaseMigration.Models; +using NDB.Infrastructure.DatabaseMigration.Repositories; +using NDB.Infrastructure.DatabaseMigration.Services; +using System; + +namespace NDB.Infrastructure.DatabaseMigration +{ + public static class DependencyInjectionExtensions + { + private const string _workspacePlaceholder = "{Workspace}"; + + public static void AddMigration(this IServiceCollection services, + DatabaseType databaseType = DatabaseType.SQLite, + string connectionName = "DatabaseConnection", + string workspacePath = "Workspace", + string scriptsDirectoryPath = "Scripts") + { + var serviceConfiguration = new ServiceConfiguration(databaseType, connectionName, workspacePath, scriptsDirectoryPath); + services.AddSingleton(serviceConfiguration); + services.AddDataAccess(serviceConfiguration); + services.AddSingleton(); + } + + private static void AddDataAccess(this IServiceCollection services, ServiceConfiguration serviceConfiguration) + { + services.AddScoped(); + services.AddDbContextPool((serviceProvider, options) => + { + var connectionString = GetDatabaseConnectionString(serviceProvider, serviceConfiguration.ConnectionName, serviceConfiguration.Workspace); + + switch (serviceConfiguration.DatabaseType) + { + case DatabaseType.SQLite: + options.UseSqlite(connectionString); + break; + + case DatabaseType.SQLServer: + options.UseSqlServer(connectionString); + break; + + default: + throw new NotImplementedException($"Database type {serviceConfiguration.DatabaseType} is not implemented."); + } + }); + } + + private static string GetDatabaseConnectionString(IServiceProvider serviceProvider, string connectionName, string workspace) + { + var configuration = serviceProvider.GetService(); + var connectionString = configuration.GetConnectionString(connectionName); + + if (connectionString.Contains(_workspacePlaceholder)) + { + connectionString = connectionString.Replace(_workspacePlaceholder, workspace); + } + + return connectionString; + } + + public static void UseMigration(this IApplicationBuilder app) + { + var migrationService = app.ApplicationServices.GetService(); + migrationService.Execute(); + } + } +} diff --git a/infrastructure/NDB.Infrastructure.DatabaseMigration/Models/MigrationThumbprint.cs b/infrastructure/NDB.Infrastructure.DatabaseMigration/Models/MigrationThumbprint.cs new file mode 100644 index 0000000..425746d --- /dev/null +++ b/infrastructure/NDB.Infrastructure.DatabaseMigration/Models/MigrationThumbprint.cs @@ -0,0 +1,29 @@ +using System; + +namespace NDB.Infrastructure.DatabaseMigration.Models +{ + internal class MigrationThumbprint + { + public MigrationSignature[] MigrationSignatures { get; set; } + } + + internal class MigrationSignature + { + public DateTime MigrationDate { get; set; } + public string MachineName { get; set; } + public MigratedVersion[] MigratedVersions { get; set; } + public string LastVersion { get; set; } + } + + internal class MigratedVersion + { + public string Version { get; set; } + public string[] Scripts { get; set; } + } + + internal class ScriptPack + { + public string Path { get; set; } + public Version Version { get; set; } + } +} diff --git a/infrastructure/NDB.Infrastructure.DatabaseMigration/Models/ServiceConfiguration.cs b/infrastructure/NDB.Infrastructure.DatabaseMigration/Models/ServiceConfiguration.cs new file mode 100644 index 0000000..48a47c6 --- /dev/null +++ b/infrastructure/NDB.Infrastructure.DatabaseMigration/Models/ServiceConfiguration.cs @@ -0,0 +1,20 @@ +using NDB.Infrastructure.DatabaseMigration.Constants; + +namespace NDB.Infrastructure.DatabaseMigration.Models +{ + internal class ServiceConfiguration + { + public DatabaseType DatabaseType { get; } + public string ConnectionName { get; } + public string Workspace { get; } + public string ScriptsDirectory { get; } + + public ServiceConfiguration(DatabaseType databaseType, string connectionName, string workspace, string scriptsDirectory) + { + DatabaseType = databaseType; + ConnectionName = connectionName; + Workspace = workspace; + ScriptsDirectory = scriptsDirectory; + } + } +} diff --git a/infrastructure/NDB.Infrastructure.DatabaseMigration/NDB.Infrastructure.DatabaseMigration.csproj b/infrastructure/NDB.Infrastructure.DatabaseMigration/NDB.Infrastructure.DatabaseMigration.csproj new file mode 100644 index 0000000..5728f06 --- /dev/null +++ b/infrastructure/NDB.Infrastructure.DatabaseMigration/NDB.Infrastructure.DatabaseMigration.csproj @@ -0,0 +1,16 @@ + + + + net5.0 + + + + + + + + + + + + diff --git a/infrastructure/NDB.Infrastructure.DatabaseMigration/Repositories/IMigrationRepository.cs b/infrastructure/NDB.Infrastructure.DatabaseMigration/Repositories/IMigrationRepository.cs new file mode 100644 index 0000000..a67cf3b --- /dev/null +++ b/infrastructure/NDB.Infrastructure.DatabaseMigration/Repositories/IMigrationRepository.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace NDB.Infrastructure.DatabaseMigration.Repositories +{ + public interface IMigrationRepository + { + Task ExecuteSqlRaw(string commandText); + } +} diff --git a/infrastructure/NDB.Infrastructure.DatabaseMigration/Repositories/MigrationRepository.cs b/infrastructure/NDB.Infrastructure.DatabaseMigration/Repositories/MigrationRepository.cs new file mode 100644 index 0000000..be56474 --- /dev/null +++ b/infrastructure/NDB.Infrastructure.DatabaseMigration/Repositories/MigrationRepository.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using NDB.Infrastructure.DatabaseMigration.DbContexts; +using System.Threading.Tasks; + +namespace NDB.Infrastructure.DatabaseMigration.Repositories +{ + internal class MigrationRepository : IMigrationRepository + { + private readonly MigrationDbContext _dbContext; + + public MigrationRepository(MigrationDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task ExecuteSqlRaw(string commandText) + { + using (var command = _dbContext.Database.GetDbConnection().CreateCommand()) + { + command.CommandText = commandText; + await _dbContext.Database.OpenConnectionAsync(); + await command.ExecuteNonQueryAsync(); + } + } + } +} diff --git a/infrastructure/NDB.Infrastructure.DatabaseMigration/Services/IMigrationService.cs b/infrastructure/NDB.Infrastructure.DatabaseMigration/Services/IMigrationService.cs new file mode 100644 index 0000000..0c69a61 --- /dev/null +++ b/infrastructure/NDB.Infrastructure.DatabaseMigration/Services/IMigrationService.cs @@ -0,0 +1,7 @@ +namespace NDB.Infrastructure.DatabaseMigration.Services +{ + public interface IMigrationService + { + public void Execute(); + } +} diff --git a/infrastructure/NDB.Infrastructure.DatabaseMigration/Services/MigrationService.cs b/infrastructure/NDB.Infrastructure.DatabaseMigration/Services/MigrationService.cs new file mode 100644 index 0000000..8624d2b --- /dev/null +++ b/infrastructure/NDB.Infrastructure.DatabaseMigration/Services/MigrationService.cs @@ -0,0 +1,128 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NDB.Infrastructure.DatabaseMigration.Models; +using NDB.Infrastructure.DatabaseMigration.Repositories; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; +using System.Xml.Serialization; + +namespace NDB.Infrastructure.DatabaseMigration.Services +{ + internal class MigrationService : IMigrationService + { + private readonly string _migrationSignaturesFilePath; + private const string _migrationSignaturesFileName = "MigrationSignatures.xml"; + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + private readonly ServiceConfiguration _configuration; + + public MigrationService(ILogger logger, IServiceProvider serviceProvider, ServiceConfiguration configuration) + { + _migrationSignaturesFilePath = Path.Combine(_configuration.Workspace, _migrationSignaturesFileName); + _logger = logger; + _serviceProvider = serviceProvider; + _configuration = configuration; + } + + private void CheckWorkspace() + { + if (string.IsNullOrEmpty(_configuration.Workspace)) + throw new Exception($"Workspace path is empty! Check 'Workspace' parameter."); + + if (!Directory.Exists(_configuration.Workspace)) + Directory.CreateDirectory(_configuration.Workspace); + } + + private MigrationSignature[] GetMigrationSignatures() + { + if (!File.Exists(_migrationSignaturesFilePath)) + return null; + + var serializer = new XmlSerializer(typeof(MigrationThumbprint)); + using (var reader = XmlReader.Create(_migrationSignaturesFilePath)) + { + var migrationSignatureRoot = (MigrationThumbprint)serializer.Deserialize(reader); + return migrationSignatureRoot.MigrationSignatures; + } + } + + private void SaveMigrationSignatures(MigrationSignature[] migrationSignatures) + { + var root = new MigrationThumbprint() { MigrationSignatures = migrationSignatures }; + var serializer = new XmlSerializer(root.GetType()); + var settings = new XmlWriterSettings() { Indent = true }; + using (var writer = XmlWriter.Create(_migrationSignaturesFilePath, settings)) + { + serializer.Serialize(writer, root); + } + } + + public void Execute() + { + _logger.LogInformation("Starting migration..."); + + CheckWorkspace(); + + var localSignatures = GetMigrationSignatures(); + var lastInstalledVersion = localSignatures?.OrderByDescending(z => z.MigrationDate).FirstOrDefault()?.LastVersion ?? "0.0.0"; + var targetVersion = new Version(lastInstalledVersion); + var scriptPacks = GetScriptPacks(); + var packsToInstall = scriptPacks.Where(p => p.Version > targetVersion); + + if (!packsToInstall.Any()) + { + _logger.LogInformation("Nothing to migrate."); + return; + } + + var signature = new MigrationSignature() { MachineName = System.Environment.MachineName, MigrationDate = DateTime.Now }; + var migratedVersions = new List(); + + foreach (var pack in packsToInstall.OrderBy(p => p.Version)) + { + var scripts = Directory.GetFiles(pack.Path); + if (!scripts.Any()) + continue; + + _logger.LogInformation($"Running script pack: '{pack.Version}'"); + + Array.ForEach(scripts, s => RunScript(s)); + var migratedVersion = new MigratedVersion() { Version = pack.Version.ToString(), Scripts = scripts.Select(z => Path.GetFileName(z)).ToArray() }; + migratedVersions.Add(migratedVersion); + } + + signature.MigratedVersions = migratedVersions.ToArray(); + signature.LastVersion = packsToInstall.OrderByDescending(z => z.Version).First().Version.ToString(); + + var signatures = localSignatures != null ? new List(localSignatures) : new List(); + signatures.Add(signature); + + SaveMigrationSignatures(signatures.ToArray()); + } + + private ScriptPack[] GetScriptPacks() + { + var scripts = Directory.GetDirectories(_configuration.ScriptsDirectory); + var packs = scripts.Select(z => new ScriptPack() { Path = z, Version = new Version(new DirectoryInfo(z).Name) }); + + return packs.ToArray(); + } + + private void RunScript(string path) + { + _logger.LogInformation($"Running sql script: '{path}'"); + var sqlContent = File.ReadAllText(path); + if (string.IsNullOrEmpty(sqlContent)) + return; + + using (var scope = _serviceProvider.CreateScope()) + { + var _repository = scope.ServiceProvider.GetRequiredService(); + _repository.ExecuteSqlRaw(sqlContent); + } + } + } +}