diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 5b66d8fb..4f2cabdf 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -21,6 +21,7 @@ + diff --git a/src/Verify.EntityFramework.Tests/DbUpdateExceptionTests.cs b/src/Verify.EntityFramework.Tests/DbUpdateExceptionTests.cs index 33d56df1..6aa628c9 100644 --- a/src/Verify.EntityFramework.Tests/DbUpdateExceptionTests.cs +++ b/src/Verify.EntityFramework.Tests/DbUpdateExceptionTests.cs @@ -4,7 +4,7 @@ public class DbUpdateExceptionTests [Test] public async Task Run() { - var instance = new SqlInstance(builder => new(builder.Options)); + var instance = new SqlInstanceProvider(builder => new(builder.Options)); var id = Guid.NewGuid(); var entity = new TestEntity { diff --git a/src/Verify.EntityFramework.Tests/GlobalUsings.cs b/src/Verify.EntityFramework.Tests/GlobalUsings.cs index 55bfb73a..e7aba465 100644 --- a/src/Verify.EntityFramework.Tests/GlobalUsings.cs +++ b/src/Verify.EntityFramework.Tests/GlobalUsings.cs @@ -1,11 +1,14 @@ -global using System.ComponentModel.DataAnnotations.Schema; +global using System.ComponentModel.DataAnnotations.Schema; global using System.Net.Http.Json; global using Argon; +global using DotNet.Testcontainers.Containers; global using Microsoft.AspNetCore.Builder; global using Microsoft.AspNetCore.Hosting; global using Microsoft.AspNetCore.Mvc.Testing; global using Microsoft.AspNetCore.TestHost; +global using Microsoft.Data.SqlClient; global using Microsoft.Data.Sqlite; global using Microsoft.EntityFrameworkCore; global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Hosting; \ No newline at end of file +global using Microsoft.Extensions.Hosting; +global using Testcontainers.MsSql; \ No newline at end of file diff --git a/src/Verify.EntityFramework.Tests/Snippets/ContainerSqlDatabase.cs b/src/Verify.EntityFramework.Tests/Snippets/ContainerSqlDatabase.cs new file mode 100644 index 00000000..8063050e --- /dev/null +++ b/src/Verify.EntityFramework.Tests/Snippets/ContainerSqlDatabase.cs @@ -0,0 +1,13 @@ +public sealed class ContainerSqlDatabase(Func dbContext) : ISqlDatabase + where TDbContext : DbContext +{ + public string ConnectionString => Context.Database.GetConnectionString()!; + + public TDbContext Context { get; } = dbContext(); + + public TDbContext NewDbContext() => dbContext(); + + public Task AddData(params object[] entities) => Context.AddData(entities); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} \ No newline at end of file diff --git a/src/Verify.EntityFramework.Tests/Snippets/DbContextBuilder.cs b/src/Verify.EntityFramework.Tests/Snippets/DbContextBuilder.cs index bdf6529a..9e3e83e7 100644 --- a/src/Verify.EntityFramework.Tests/Snippets/DbContextBuilder.cs +++ b/src/Verify.EntityFramework.Tests/Snippets/DbContextBuilder.cs @@ -1,4 +1,4 @@ -// LocalDb is used to make the sample simpler. +// LocalDb is used to make the sample simpler. // Replace with a real DbContext public static class DbContextBuilder @@ -14,7 +14,7 @@ static DbContextBuilder() }); descriptiveAliasSqlInstance = new( buildTemplate: CreateDb, - storage: Storage.FromSuffix("DescriptiveTableAliases"), + storageSuffix: "DescriptiveTableAliases", constructInstance: builder => { builder.EnableRecording(); @@ -23,7 +23,7 @@ static DbContextBuilder() }); descriptiveParameterNamesSqlInstance = new( buildTemplate: CreateDb, - storage: Storage.FromSuffix("DescriptiveParameterNames"), + storageSuffix: "DescriptiveParameterNames", constructInstance: builder => { builder.EnableRecording(); @@ -32,9 +32,9 @@ static DbContextBuilder() }); } - static SqlInstance sqlInstance; - static SqlInstance descriptiveAliasSqlInstance; - static SqlInstance descriptiveParameterNamesSqlInstance; + static SqlInstanceProvider sqlInstance; + static SqlInstanceProvider descriptiveAliasSqlInstance; + static SqlInstanceProvider descriptiveParameterNamesSqlInstance; static async Task CreateDb(SampleDbContext data) { @@ -85,12 +85,12 @@ static async Task CreateDb(SampleDbContext data) await data.SaveChangesAsync(); } - public static Task> GetDatabase([CallerMemberName] string suffix = "") + public static Task> GetDatabase([CallerMemberName] string suffix = "") => sqlInstance.Build(suffix); - public static Task> GetDescriptiveAliasDatabase([CallerMemberName] string suffix = "") + public static Task> GetDescriptiveAliasDatabase([CallerMemberName] string suffix = "") => descriptiveAliasSqlInstance.Build(suffix); - public static Task> GetDescriptiveParameterNamesDatabase([CallerMemberName] string suffix = "") + public static Task> GetDescriptiveParameterNamesDatabase([CallerMemberName] string suffix = "") => descriptiveParameterNamesSqlInstance.Build(suffix); } \ No newline at end of file diff --git a/src/Verify.EntityFramework.Tests/Snippets/ISqlDatabase.cs b/src/Verify.EntityFramework.Tests/Snippets/ISqlDatabase.cs new file mode 100644 index 00000000..6791432c --- /dev/null +++ b/src/Verify.EntityFramework.Tests/Snippets/ISqlDatabase.cs @@ -0,0 +1,11 @@ +public interface ISqlDatabase : IAsyncDisposable + where TDbContext : DbContext +{ + string ConnectionString { get; } + + TDbContext Context { get; } + + TDbContext NewDbContext(); + + Task AddData(params object[] entities); +} \ No newline at end of file diff --git a/src/Verify.EntityFramework.Tests/Snippets/LocalDbSqlDatabase.cs b/src/Verify.EntityFramework.Tests/Snippets/LocalDbSqlDatabase.cs new file mode 100644 index 00000000..4c436125 --- /dev/null +++ b/src/Verify.EntityFramework.Tests/Snippets/LocalDbSqlDatabase.cs @@ -0,0 +1,13 @@ +public sealed class LocalDbSqlDatabase(SqlDatabase sqlDatabase) : ISqlDatabase + where TDbContext : DbContext +{ + public string ConnectionString { get; } = sqlDatabase.ConnectionString; + + public TDbContext Context { get; } = sqlDatabase.Context; + + public TDbContext NewDbContext() => sqlDatabase.NewDbContext(); + + public Task AddData(params object[] entities) => sqlDatabase.AddData(entities); + + public ValueTask DisposeAsync() => sqlDatabase.DisposeAsync(); +} \ No newline at end of file diff --git a/src/Verify.EntityFramework.Tests/Snippets/SqlInstanceProvider.cs b/src/Verify.EntityFramework.Tests/Snippets/SqlInstanceProvider.cs new file mode 100644 index 00000000..528809ca --- /dev/null +++ b/src/Verify.EntityFramework.Tests/Snippets/SqlInstanceProvider.cs @@ -0,0 +1,107 @@ +public class SqlInstanceProvider : IAsyncDisposable + where TDbContext : DbContext +{ + readonly TemplateFromContext? buildTemplate; + readonly ConstructInstance constructInstance; + + SemaphoreSlim semaphore = new(1); + SqlInstance? sqlInstance; + MsSqlContainer? sqlContainer; + + public SqlInstanceProvider(ConstructInstance constructInstance, TemplateFromContext? buildTemplate = null, string? storageSuffix = null) + { + this.buildTemplate = buildTemplate; + this.constructInstance = constructInstance; + if (string.Equals(Environment.GetEnvironmentVariable("VERIFY_ENTITYFRAMEWORK_TESTS_SQLENGINE"), "Docker", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows()) + { + var suffix = storageSuffix == null ? "" : $".{storageSuffix}"; + sqlContainer = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest") + .WithName($"Verify.EntityFramework.Tests{suffix}") + .WithReuse(true) + .Build(); + } + else + { + sqlInstance = new( + buildTemplate: buildTemplate, + storage: storageSuffix == null ? null : Storage.FromSuffix(storageSuffix), + constructInstance: constructInstance); + } + } + + public async Task> Build(IEnumerable data, [CallerFilePath] string testFile = "", string? databaseSuffix = null, [CallerMemberName] string memberName = "") + { + if (sqlInstance != null) + { + var sqlDatabase = await sqlInstance.Build(data, testFile, databaseSuffix, memberName); + return new LocalDbSqlDatabase(sqlDatabase); + } + + if (sqlContainer != null) + { + var testClass = Path.GetFileNameWithoutExtension(testFile); + var dbName = databaseSuffix == null ? $"{testClass}_{memberName}" : $"{testClass}_{memberName}_{databaseSuffix}"; + var sqlDatabase = await Build(dbName); + await sqlDatabase.AddData(data); + return sqlDatabase; + } + + throw new UnreachableException("Both sqlInstance and sqlContainer can't be null at the same time"); + } + + public async Task> Build([CallerMemberName] string dbName = "") + { + if (sqlInstance != null) + { + var sqlDatabase = await sqlInstance.Build(dbName); + return new LocalDbSqlDatabase(sqlDatabase); + } + + if (sqlContainer != null) + { + await semaphore.WaitAsync(); + try + { + if (sqlContainer.State != TestcontainersStates.Running) + { + await sqlContainer.StartAsync(); + } + } + finally + { + semaphore.Release(); + } + + var result = await sqlContainer.ExecScriptAsync($"DROP DATABASE IF EXISTS [{dbName}]"); + if (result.ExitCode != 0) + { + throw new InvalidOperationException($"Failed to drop database '{dbName}' ({result.ExitCode})\n{result.Stdout}\n{result.Stderr}"); + } + + await using var dbContext = CreateDbContext(dbName); + await dbContext.Database.EnsureCreatedAsync(); + + if (buildTemplate != null) + { + await buildTemplate(dbContext); + } + + return new ContainerSqlDatabase(() => CreateDbContext(dbName)); + } + + throw new UnreachableException("Both sqlInstance and sqlContainer can't be null at the same time"); + } + + private TDbContext CreateDbContext(string dbName) + { + var connectionString = new SqlConnectionStringBuilder(sqlContainer!.GetConnectionString()) { InitialCatalog = dbName }.ConnectionString; + return constructInstance(new DbContextOptionsBuilder().UseSqlServer(connectionString)); + } + + public ValueTask DisposeAsync() + { + semaphore.Dispose(); + sqlInstance?.Dispose(); + return sqlContainer?.DisposeAsync() ?? ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Verify.EntityFramework.Tests/Verify.EntityFramework.Tests.csproj b/src/Verify.EntityFramework.Tests/Verify.EntityFramework.Tests.csproj index 1a2584b0..43ae01af 100644 --- a/src/Verify.EntityFramework.Tests/Verify.EntityFramework.Tests.csproj +++ b/src/Verify.EntityFramework.Tests/Verify.EntityFramework.Tests.csproj @@ -9,6 +9,7 @@ +