Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CleanArchitecture.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
<Project Path="src/Infrastructure/Infrastructure.csproj" />
<Project Path="src/Shared/Shared.csproj" />
<Project Path="src/Web/Web.csproj" />
<Project Path="src/Migrator/Migrator.csproj" />
<Project Path="src/Shared.SQLScripts/Shared.SQLScripts.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Application.FunctionalTests/Application.FunctionalTests.csproj" />
Expand Down
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
<PackageVersion Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.0" />
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
<PackageVersion Include="coverlet.collector" Version="8.0.1" />
<PackageVersion Include="dbup-core" Version="6.0.4" />
<PackageVersion Include="dbup-sqlserver" Version="6.0.0" />
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageVersion Include="MediatR" Version="14.1.0" />
<PackageVersion Include="MediatR.Contracts" Version="2.0.1" />
Expand Down
6 changes: 6 additions & 0 deletions src/Migrator/IMigrationExecutor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace CleanArchitecture.Migrator;

public interface IMigrationExecutor
{
Task ExecuteAsync(CancellationToken cancellationToken = default);
}
65 changes: 65 additions & 0 deletions src/Migrator/MigrationExecutor.cs
Original file line number Diff line number Diff line change
@@ -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<MigrationExecutor> _logger;
private readonly ApplicationDbContext _context;

public MigrationExecutor(
IConfiguration configuration,
ILogger<MigrationExecutor> 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);
}
}
27 changes: 27 additions & 0 deletions src/Migrator/Migrator.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>CleanArchitecture.Migrator</RootNamespace>
<AssemblyName>CleanArchitecture.Migrator</AssemblyName>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Infrastructure\Infrastructure.csproj" />
<ProjectReference Include="..\Shared.SQLScripts\Shared.SQLScripts.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>

<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
55 changes: 55 additions & 0 deletions src/Migrator/MigratorModule.cs
Original file line number Diff line number Diff line change
@@ -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<ApplicationDbContext>((sp, options) =>
{
options.AddInterceptors(sp.GetServices<ISaveChangesInterceptor>());

#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<IMigrationExecutor, MigrationExecutor>();
}
}
31 changes: 31 additions & 0 deletions src/Migrator/Program.cs
Original file line number Diff line number Diff line change
@@ -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<ILogger<Program>>();

try
{
logger.LogInformation("CleanArchitecture Database Migrator starting...");

var migrationExecutor = services.GetRequiredService<IMigrationExecutor>();
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;
}
14 changes: 14 additions & 0 deletions src/Migrator/appsettings.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
19 changes: 19 additions & 0 deletions src/Shared.SQLScripts/Everytime/01_SPs/__sample_sp.sql
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions src/Shared.SQLScripts/Everytime/02_Functions/__sample_function.sql
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions src/Shared.SQLScripts/Everytime/03_Views/__sample_view.sql
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions src/Shared.SQLScripts/Onetime/01_Seeding/__sample_seed.sql
Original file line number Diff line number Diff line change
@@ -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');
Original file line number Diff line number Diff line change
@@ -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;
Loading