Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/dotnetpull.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ jobs:
if: always()
with:
name: test-results-${{ matrix.database }}
overwrite: true
path: TestResults/
if-no-files-found: error
- name: Remove test container
Expand Down
8 changes: 8 additions & 0 deletions Migrator.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@
<Folder Name="/Solution Items/">
<File Path=".editorconfig" />
</Folder>
<Folder Name="/src/" />
<Folder Name="/src/Migrator.Extensions.DependencyInjection/">
<Project Path="src/Migrator.Extensions.DependencyInjection/DotNetProjects.Migrator.Extensions.DependencyInjection.csproj" />
</Folder>
<Folder Name="/src/Migrator.Tool/">
<Project Path="src/Migrator.Tool/DotNetProjects.Migrator.Tool.csproj" />
</Folder>
<Folder Name="/src/Migrator/" />
<Folder Name="/Tests/">
<Project Path="src/Migrator.Tests/Migrator.Tests.csproj" />
</Folder>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net9.0</TargetFramework><Version>9.0.0</Version><PackageLicenseExpression>MPL-1.1</PackageLicenseExpression><Description>Optional dependency injection, options and logging integration for Migrator.NET.</Description></PropertyGroup>
<ItemGroup>
<ProjectReference Include="../Migrator/DotNetProjects.Migrator.csproj" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.5" />
</ItemGroup>
</Project>
22 changes: 22 additions & 0 deletions src/Migrator.Extensions.DependencyInjection/MigrationLogger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Microsoft.Extensions.Logging;
namespace DotNetProjects.Migrator.Extensions.DependencyInjection;

/// <summary>Logs lifecycle events. SQL and exception messages may contain secrets and are omitted.</summary>
public sealed class MigrationLogger(Microsoft.Extensions.Logging.ILogger logger) : Framework.ILogger
{
public void Started(List<long> currentVersion, long finalVersion) => logger.LogInformation("Migration run started; target {Version}", finalVersion);
public void Finished(List<long> currentVersion, long finalVersion) => logger.LogInformation("Migration run completed; target {Version}", finalVersion);
public void MigrateUp(long version, string migrationName) => logger.LogInformation("Applying migration {Version} ({Name})", version, migrationName);
public void MigrateDown(long version, string migrationName) => logger.LogInformation("Reverting migration {Version} ({Name})", version, migrationName);
public void Skipping(long version) => logger.LogWarning("Skipping migration {Version}", version);
public void RollingBack(long originalVersion) => logger.LogWarning("Rolling back migration {Version}", originalVersion);
public void ApplyingDBChange(string sql) => logger.LogDebug("Executing a database change");
public void Exception(long version, string migrationName, Exception ex) => logger.LogError("Migration {Version} failed: {ExceptionType}", version, ex.GetType().Name);
public void Exception(string message, Exception ex) => logger.LogError("Migration operation failed: {ExceptionType}", ex.GetType().Name);
public void Log(string format, params object[] args) => logger.LogInformation("Provider informational event");
public void Warn(string format, params object[] args) => logger.LogWarning("Provider warning event");
public void Trace(string format, params object[] args) { } // Provider traces commonly contain SQL values.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System;
using System.Linq;
using System.Reflection;
using DotNetProjects.Migrator.Framework;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace DotNetProjects.Migrator.Extensions.DependencyInjection;

public static class ServiceCollectionExtensions
{
public static IServiceCollection AddMigrator(this IServiceCollection services,
Func<IServiceProvider, ITransformationProvider> providerFactory, Assembly migrations, Action<RunnerOptions> configure = null)
{
services.AddOptions<RunnerOptions>();
if (configure != null) services.Configure(configure);
services.AddScoped(providerFactory);
foreach (var type in MigrationLoader.GetMigrationTypes(migrations)) services.TryAddTransient(type);
services.AddScoped(sp =>
{
var provider = sp.GetRequiredService<ITransformationProvider>();
var loader = new MigrationLoader(provider, migrations, false);
var options = sp.GetRequiredService<IOptionsSnapshot<RunnerOptions>>().Value;
options.Activator ??= type => (IMigration)sp.GetRequiredService(type);
return new Migrator(provider, new MigrationLogger(sp.GetService<ILoggerFactory>()?.CreateLogger("Migrator.NET") ?? NullLogger.Instance), loader) { Options = options };
});
return services;
}
}
106 changes: 106 additions & 0 deletions src/Migrator.Tests/DatabaseLockTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using System;
using System.Data.Common;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using DotNetProjects.Migrator.Framework;
using DotNetProjects.Migrator;
using DotNetProjects.Migrator.Providers;
using Migrator.Tests.Settings;
using NUnit.Framework;
namespace Migrator.Tests;

[TestFixture(ProviderTypes.SqlServer, Category = "SQLServer")]
[TestFixture(ProviderTypes.PostgreSQL, Category = "PostgreSQL")]
[TestFixture(ProviderTypes.Mysql, Category = "MySQL")]
[TestFixture(ProviderTypes.MariaDB, Category = "MariaDB")]
public class DatabaseLockTests(ProviderTypes type)
{
private DbConnection Open()
{
DbConnection connection;
if (type == ProviderTypes.SqlServer)
{
var config = new ConfigurationReader().GetDatabaseConnectionConfigById("SQLServer");
var builder = new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(config.ConnectionString) { InitialCatalog = "master" };
connection = new Microsoft.Data.SqlClient.SqlConnection(builder.ConnectionString);
}
else if (type == ProviderTypes.PostgreSQL)
connection = new Npgsql.NpgsqlConnection(new ConfigurationReader().GetDatabaseConnectionConfigById("PostgreSQL").ConnectionString);
else
connection = new MySql.Data.MySqlClient.MySqlConnection(Environment.GetEnvironmentVariable(type == ProviderTypes.Mysql ? "MIGRATOR_MYSQL" : "MIGRATOR_MARIADB")
?? "Server=127.0.0.1;Database=testdb;User ID=root;Password=rootpass;Pooling=false");
connection.Open(); return connection;
}
private sealed class RunState : IDisposable
{
public int Calls;
public readonly ManualResetEventSlim Entered = new();
public readonly ManualResetEventSlim Release = new();
public void Dispose() { Entered.Dispose(); Release.Dispose(); }
}
[Migration(1)]
private sealed class CountMigration(RunState state) : Migration
{
public override void Up()
{
Interlocked.Increment(ref state.Calls);
state.Entered.Set();
if (!state.Release.Wait(TimeSpan.FromSeconds(20))) throw new TimeoutException("Test migration gate timed out.");
}
public override void Down() { }
}
private sealed class SignallingLock(ManualResetEventSlim attempted) : IMigrationLock
{
public IDisposable Acquire(ITransformationProvider provider, string scope, TimeSpan timeout)
{ attempted.Set(); return new DatabaseMigrationLock().Acquire(provider, scope, timeout); }
}
[Test]
public async Task ConcurrentRunnersReloadStaleHistoryAfterAcquiringNativeLock()
{
using var connection1 = Open(); using var connection2 = Open();
using var p1 = ProviderFactory.Create(type, connection1, null);
using var p2 = ProviderFactory.Create(type, connection2, null);
p1.SchemaInfoTable = p2.SchemaInfoTable = "lockhistory_" + Guid.NewGuid().ToString("N")[..12];
using var state = new RunState(); using var attempted = new ManualResetEventSlim();
Assert.That(p2.AppliedMigrations, Is.Empty); // Deliberately seed a stale empty cache.
var first = new DotNetProjects.Migrator.Migrator(p1, false, typeof(CountMigration));
var second = new DotNetProjects.Migrator.Migrator(p2, false, typeof(CountMigration));
first.Options.Activator = second.Options.Activator = _ => new CountMigration(state);
first.Options.Lock = new DatabaseMigrationLock(); second.Options.Lock = new SignallingLock(attempted);
first.Options.LockTimeout = second.Options.LockTimeout = TimeSpan.FromSeconds(15);
Task one = null, two = null;
try
{
one = Task.Run(first.MigrateToLastVersion);
Assert.That(state.Entered.Wait(TimeSpan.FromSeconds(10)), Is.True);
two = Task.Run(second.MigrateToLastVersion);
Assert.That(attempted.Wait(TimeSpan.FromSeconds(10)), Is.True);
state.Release.Set();
await Task.WhenAll(one, two);
Assert.That(state.Calls, Is.EqualTo(1));
Assert.That(((IMigrationHistory)p2).ReadAppliedMigrations(), Is.EqualTo(new long[] { 1 }));
using var released = new DatabaseMigrationLock().Acquire(p2, ((IMigrationHistory)p2).Scope, TimeSpan.Zero);
}
finally
{
state.Release.Set();
try { await Task.WhenAll(new[] { one, two }.Where(task => task != null)); }
finally { p1.RemoveTable(p1.SchemaInfoTable); }
}
}

[Test] public void IndependentSessionsContendAndCanAcquireAfterRelease()
{
using var connection1 = Open(); using var connection2 = Open();
using var p1 = ProviderFactory.Create(type, connection1, null);
using var p2 = ProviderFactory.Create(type, connection2, null);
var migrationLock = new DatabaseMigrationLock(); var scope = Guid.NewGuid().ToString("N");
using (migrationLock.Acquire(p1, scope, TimeSpan.FromSeconds(1)))
{
Assert.Throws<TimeoutException>(() => migrationLock.Acquire(p2, scope, TimeSpan.FromMilliseconds(100)));
using var independentScope = migrationLock.Acquire(p2, scope + "other", TimeSpan.Zero);
}
using var acquiredAfterRelease = migrationLock.Acquire(p2, scope, TimeSpan.FromSeconds(1));
}
}
4 changes: 3 additions & 1 deletion src/Migrator.Tests/Migrator.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@

<ItemGroup>
<ProjectReference Include="..\Migrator\DotNetProjects.Migrator.csproj" />
<ProjectReference Include="../Migrator.Tool/DotNetProjects.Migrator.Tool.csproj" />
<ProjectReference Include="../Migrator.Extensions.DependencyInjection/DotNetProjects.Migrator.Extensions.DependencyInjection.csproj" />
</ItemGroup>

<ItemGroup>
<EmbeddedResource Include="ScriptResource.sql" LogicalName="Migrator.Tests.ScriptResource.sql" />
<None Include="../../docs/fluent-operation-coverage.json" Link="fluent-operation-coverage.json" CopyToOutputDirectory="PreserveNewest" />
<EmbeddedResource Include="ScriptResource.sql" LogicalName="Migrator.Tests.ScriptResource.sql" />
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="appsettings.Development.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
Expand Down
Loading
Loading