diff --git a/CleanArchitecture.slnx b/CleanArchitecture.slnx index eb9a52985..a778b476d 100644 --- a/CleanArchitecture.slnx +++ b/CleanArchitecture.slnx @@ -15,6 +15,8 @@ + + diff --git a/Directory.Packages.props b/Directory.Packages.props index aa588ae9a..e660d6269 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,8 @@ + + diff --git a/src/Migrator/IMigrationExecutor.cs b/src/Migrator/IMigrationExecutor.cs new file mode 100644 index 000000000..1546c51ea --- /dev/null +++ b/src/Migrator/IMigrationExecutor.cs @@ -0,0 +1,6 @@ +namespace CleanArchitecture.Migrator; + +public interface IMigrationExecutor +{ + Task ExecuteAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Migrator/MigrationExecutor.cs b/src/Migrator/MigrationExecutor.cs new file mode 100644 index 000000000..a3de5439b --- /dev/null +++ b/src/Migrator/MigrationExecutor.cs @@ -0,0 +1,65 @@ +using CleanArchitecture.Infrastructure.Data; +using CleanArchitecture.Shared.SQLScripts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace CleanArchitecture.Migrator; + +public class MigrationExecutor : IMigrationExecutor +{ + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + private readonly ApplicationDbContext _context; + + public MigrationExecutor( + IConfiguration configuration, + ILogger logger, + ApplicationDbContext context) + { + _configuration = configuration; + _logger = logger; + _context = context; + } + + public async Task ExecuteAsync(CancellationToken cancellationToken = default) + { + var connectionString = _configuration.GetConnectionString("CleanArchitectureDb") + ?? throw new InvalidOperationException( + "Connection string 'CleanArchitectureDb' not found in configuration."); + + _logger.LogInformation("=== Database Migration Started ==="); + + await RunEfCoreMigrationsAsync(cancellationToken); + RunSqlScripts(connectionString); + + _logger.LogInformation("=== Database Migration Completed Successfully ==="); + } + + private async Task RunEfCoreMigrationsAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Applying EF Core migrations..."); + + var pendingMigrations = await _context.Database.GetPendingMigrationsAsync(cancellationToken); + var migrations = pendingMigrations.ToList(); + + if (migrations.Count == 0) + { + _logger.LogInformation("No pending EF Core migrations found."); + return; + } + + _logger.LogInformation("Found {Count} pending migration(s): {Migrations}", + migrations.Count, string.Join(", ", migrations)); + + await _context.Database.MigrateAsync(cancellationToken); + + _logger.LogInformation("EF Core migrations applied successfully."); + } + + private void RunSqlScripts(string connectionString) + { + _logger.LogInformation("Deploying SQL scripts via DbUp..."); + ScriptDeployer.Deploy(connectionString, _logger); + } +} diff --git a/src/Migrator/Migrator.csproj b/src/Migrator/Migrator.csproj new file mode 100644 index 000000000..8e3c5c128 --- /dev/null +++ b/src/Migrator/Migrator.csproj @@ -0,0 +1,27 @@ + + + + Exe + CleanArchitecture.Migrator + CleanArchitecture.Migrator + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/src/Migrator/MigratorModule.cs b/src/Migrator/MigratorModule.cs new file mode 100644 index 000000000..ddb3db5a9 --- /dev/null +++ b/src/Migrator/MigratorModule.cs @@ -0,0 +1,55 @@ +using CleanArchitecture.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace CleanArchitecture.Migrator; + +public static class MigratorModule +{ + public static void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("CleanArchitectureDb") + ?? throw new InvalidOperationException( + "Connection string 'CleanArchitectureDb' not found. " + + "Ensure appsettings.json is properly configured."); + + // Register DbContext with the same configuration as the main application + services.AddDbContext((sp, options) => + { + options.AddInterceptors(sp.GetServices()); + +#if (UsePostgreSQL) + options.UseNpgsql(connectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.GetName().Name); + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorCodesToAdd: null); + }); +#elif (UseSqlServer) + options.UseSqlServer(connectionString, sqlOptions => + { + sqlOptions.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.GetName().Name); + sqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorNumbersToAdd: null); + }); +#else + options.UseSqlite(connectionString, sqliteOptions => + { + sqliteOptions.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.GetName().Name); + }); +#endif + + options.ConfigureWarnings(warnings => + warnings.Ignore(RelationalEventId.PendingModelChangesWarning)); + }); + + // Register migration executor + services.AddTransient(); + } +} diff --git a/src/Migrator/Program.cs b/src/Migrator/Program.cs new file mode 100644 index 000000000..ebc5f8d8c --- /dev/null +++ b/src/Migrator/Program.cs @@ -0,0 +1,31 @@ +using CleanArchitecture.Migrator; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var host = Host.CreateDefaultBuilder(args) + .ConfigureServices((hostContext, services) => + { + MigratorModule.ConfigureServices(services, hostContext.Configuration); + }) + .Build(); + +using var scope = host.Services.CreateScope(); +var services = scope.ServiceProvider; +var logger = services.GetRequiredService>(); + +try +{ + logger.LogInformation("CleanArchitecture Database Migrator starting..."); + + var migrationExecutor = services.GetRequiredService(); + await migrationExecutor.ExecuteAsync(); + + logger.LogInformation("CleanArchitecture Database Migrator completed successfully."); + return 0; +} +catch (Exception ex) +{ + logger.LogCritical(ex, "CleanArchitecture Database Migrator terminated unexpectedly."); + return 1; +} diff --git a/src/Migrator/appsettings.json b/src/Migrator/appsettings.json new file mode 100644 index 000000000..6fd339bdc --- /dev/null +++ b/src/Migrator/appsettings.json @@ -0,0 +1,14 @@ +{ + "ConnectionStrings": { + "CleanArchitectureDb": "Server=(localdb)\\mssqllocaldb;Database=CleanArchitectureDb;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "CleanArchitecture.Migrator": "Debug" + } + } +} diff --git a/src/Shared.SQLScripts/Everytime/01_SPs/__sample_sp.sql b/src/Shared.SQLScripts/Everytime/01_SPs/__sample_sp.sql new file mode 100644 index 000000000..0e97d5c13 --- /dev/null +++ b/src/Shared.SQLScripts/Everytime/01_SPs/__sample_sp.sql @@ -0,0 +1,19 @@ +-- ============================================= +-- Sample Stored Procedure: GetTodoListsSummary +-- This script runs on every deployment (no journaling). +-- Add your stored procedures to this folder. +-- ============================================= +-- CREATE OR ALTER PROCEDURE [dbo].[GetTodoListsSummary] +-- AS +-- BEGIN +-- SET NOCOUNT ON; +-- SELECT +-- tl.Id, +-- tl.Title, +-- COUNT(ti.Id) AS ItemCount, +-- SUM(CASE WHEN ti.Done = 1 THEN 1 ELSE 0 END) AS CompletedCount +-- FROM TodoLists tl +-- LEFT JOIN TodoItems ti ON ti.ListId = tl.Id +-- GROUP BY tl.Id, tl.Title; +-- END +-- GO diff --git a/src/Shared.SQLScripts/Everytime/02_Functions/__sample_function.sql b/src/Shared.SQLScripts/Everytime/02_Functions/__sample_function.sql new file mode 100644 index 000000000..c17952379 --- /dev/null +++ b/src/Shared.SQLScripts/Everytime/02_Functions/__sample_function.sql @@ -0,0 +1,15 @@ +-- ============================================= +-- Sample Function placeholder +-- This script runs on every deployment (no journaling). +-- Add your SQL functions to this folder. +-- ============================================= +-- CREATE OR ALTER FUNCTION [dbo].[fn_GetTodoItemStatus](@TodoItemId INT) +-- RETURNS NVARCHAR(20) +-- AS +-- BEGIN +-- DECLARE @Status NVARCHAR(20); +-- SELECT @Status = CASE WHEN Done = 1 THEN 'Completed' ELSE 'Pending' END +-- FROM TodoItems WHERE Id = @TodoItemId; +-- RETURN ISNULL(@Status, 'Unknown'); +-- END +-- GO diff --git a/src/Shared.SQLScripts/Everytime/03_Views/__sample_view.sql b/src/Shared.SQLScripts/Everytime/03_Views/__sample_view.sql new file mode 100644 index 000000000..d81bb77ee --- /dev/null +++ b/src/Shared.SQLScripts/Everytime/03_Views/__sample_view.sql @@ -0,0 +1,16 @@ +-- ============================================= +-- Sample View placeholder +-- This script runs on every deployment (no journaling). +-- Add your SQL views to this folder. +-- ============================================= +-- CREATE OR ALTER VIEW [dbo].[vw_TodoListSummary] +-- AS +-- SELECT +-- tl.Id, +-- tl.Title, +-- COUNT(ti.Id) AS TotalItems, +-- SUM(CASE WHEN ti.Done = 1 THEN 1 ELSE 0 END) AS CompletedItems +-- FROM TodoLists tl +-- LEFT JOIN TodoItems ti ON ti.ListId = tl.Id +-- GROUP BY tl.Id, tl.Title; +-- GO diff --git a/src/Shared.SQLScripts/Onetime/01_Seeding/__sample_seed.sql b/src/Shared.SQLScripts/Onetime/01_Seeding/__sample_seed.sql new file mode 100644 index 000000000..b7d82e100 --- /dev/null +++ b/src/Shared.SQLScripts/Onetime/01_Seeding/__sample_seed.sql @@ -0,0 +1,9 @@ +-- ============================================= +-- Sample Seed Script +-- This script runs only once (journaled by DbUp). +-- Add your seed data scripts to this folder. +-- Use a naming convention like: 001_SeedInitialData.sql +-- ============================================= +-- INSERT INTO TodoLists (Title, Colour_Code) +-- SELECT 'Default List', '#00FF00' +-- WHERE NOT EXISTS (SELECT 1 FROM TodoLists WHERE Title = 'Default List'); diff --git a/src/Shared.SQLScripts/Onetime/02_Migrations/__sample_migration.sql b/src/Shared.SQLScripts/Onetime/02_Migrations/__sample_migration.sql new file mode 100644 index 000000000..3f80f8b4d --- /dev/null +++ b/src/Shared.SQLScripts/Onetime/02_Migrations/__sample_migration.sql @@ -0,0 +1,8 @@ +-- ============================================= +-- Sample Migration Script +-- This script runs only once (journaled by DbUp). +-- Use for one-time data migrations or schema changes +-- that are not handled by EF Core. +-- Use a naming convention like: 001_AddIndexOnTodoItems.sql +-- ============================================= +-- CREATE INDEX IX_TodoItems_Done ON TodoItems(Done) WHERE Done = 0; diff --git a/src/Shared.SQLScripts/ScriptDeployer.cs b/src/Shared.SQLScripts/ScriptDeployer.cs new file mode 100644 index 000000000..25dc0a586 --- /dev/null +++ b/src/Shared.SQLScripts/ScriptDeployer.cs @@ -0,0 +1,111 @@ +using DbUp; +using DbUp.Engine; +using DbUp.Helpers; +using Microsoft.Extensions.Logging; +using System.Reflection; + +namespace CleanArchitecture.Shared.SQLScripts; + +public static class ScriptDeployer +{ + public static void Deploy(string connectionString, ILogger logger) + { + logger.LogInformation("Starting SQL scripts deployment on connection: {ConnectionString}", + MaskConnectionString(connectionString)); + + DeployEverytimeScripts(connectionString, logger); + DeployOnetimeScripts(connectionString, logger); + + logger.LogInformation("SQL scripts deployment completed successfully."); + } + + public static void DeployFunctions(string connectionString, ILogger logger) + { + DeployEverytimeFunctionScripts(connectionString, logger); + } + + private static void DeployEverytimeScripts(string connectionString, ILogger logger) + { + logger.LogInformation("Deploying everytime scripts (SPs, Functions, Views)..."); + + var upgrader = DeployChanges.To + .SqlDatabase(connectionString) + .WithScriptsEmbeddedInAssembly( + Assembly.GetExecutingAssembly(), + s => s.Contains("everytime", StringComparison.OrdinalIgnoreCase) + && !s.Contains("02_Functions", StringComparison.OrdinalIgnoreCase)) + .LogToConsole() + .JournalTo(new NullJournal()) + .Build(); + + ExecuteUpgrade(upgrader, logger, "Everytime scripts"); + } + + private static void DeployEverytimeFunctionScripts(string connectionString, ILogger logger) + { + logger.LogInformation("Deploying everytime function scripts..."); + + var upgrader = DeployChanges.To + .SqlDatabase(connectionString) + .WithScriptsEmbeddedInAssembly( + Assembly.GetExecutingAssembly(), + s => s.Contains("02_Functions", StringComparison.OrdinalIgnoreCase)) + .LogToConsole() + .JournalTo(new NullJournal()) + .Build(); + + ExecuteUpgrade(upgrader, logger, "Function scripts"); + } + + private static void DeployOnetimeScripts(string connectionString, ILogger logger) + { + logger.LogInformation("Deploying onetime scripts (Seeding, Migrations)..."); + + var upgrader = DeployChanges.To + .SqlDatabase(connectionString) + .WithScriptsEmbeddedInAssembly( + Assembly.GetExecutingAssembly(), + s => s.Contains("onetime", StringComparison.OrdinalIgnoreCase)) + .LogToConsole() + .Build(); + + ExecuteUpgrade(upgrader, logger, "Onetime scripts"); + } + + private static void ExecuteUpgrade(UpgradeEngine upgrader, ILogger logger, string scriptCategory) + { + var result = upgrader.PerformUpgrade(); + + if (!result.Successful) + { + logger.LogError(result.Error, "Failed to deploy {ScriptCategory}.", scriptCategory); + throw new InvalidOperationException( + $"SQL script deployment failed for '{scriptCategory}'. See inner exception for details.", + result.Error); + } + + logger.LogInformation("{ScriptCategory} deployed successfully.", scriptCategory); + } + + /// + /// Masks the connection string for safe logging (hides password). + /// + private static string MaskConnectionString(string connectionString) + { + // Simple masking — replace password value if present + var parts = connectionString.Split(';'); + for (var i = 0; i < parts.Length; i++) + { + if (parts[i].TrimStart().StartsWith("Password", StringComparison.OrdinalIgnoreCase) || + parts[i].TrimStart().StartsWith("Pwd", StringComparison.OrdinalIgnoreCase)) + { + var eqIndex = parts[i].IndexOf('='); + if (eqIndex >= 0) + { + parts[i] = parts[i][..(eqIndex + 1)] + "***"; + } + } + } + return string.Join(';', parts); + } +} diff --git a/src/Shared.SQLScripts/Shared.SQLScripts.csproj b/src/Shared.SQLScripts/Shared.SQLScripts.csproj new file mode 100644 index 000000000..3d1383d3f --- /dev/null +++ b/src/Shared.SQLScripts/Shared.SQLScripts.csproj @@ -0,0 +1,27 @@ + + + + CleanArchitecture.Shared.SQLScripts + CleanArchitecture.Shared.SQLScripts + + + + + + + + + + + + + + + + + + + + + +