From 98b84fa0a96da7691544f9cbec44f5a54818af69 Mon Sep 17 00:00:00 2001 From: Johannes Hartmann Date: Fri, 6 Sep 2024 14:11:05 +0000 Subject: [PATCH] feature(#59): Improved SQL Server testing to also support creating test data templates instead of recreating a fresh test database for each test --- .gitlab-ci.yml | 14 +- docs/UnitTests/README.md | 39 ++-- src/Hangfire/test/TestFixture.cs | 2 +- .../src/NpgsqlDatabasePerTestStore.cs | 41 ++-- .../src/NpgsqlDatabasePerTestStoreOptions.cs | 7 +- .../src/PostgreSqlUtil.cs | 74 +++++-- .../test/TestFixture.cs | 2 +- .../src/SqlServerDatabasePerTestStore.cs | 64 +++--- .../SqlServerDatabasePerTestStoreOptions.cs | 26 ++- .../src/SqlServerTestUtil.cs | 209 ++++++++++++++++++ .../src/DatabaseTestHelper.cs} | 42 ++-- 11 files changed, 403 insertions(+), 117 deletions(-) create mode 100644 src/UnitTests.EntityFrameworkCore.SqlServer/src/SqlServerTestUtil.cs rename src/{UnitTests.EntityFrameworkCore.Npgsql/src/DatabaseHelper.cs => UnitTests.EntityFrameworkCore/src/DatabaseTestHelper.cs} (61%) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1f998cd..ee409ad 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -80,8 +80,16 @@ publish: stage: publish variables: GIT_STRATEGY: none + allow_failure: true script: - - dotnet nuget push "artifacts/package/release/*.nupkg" -k $Nuget_Key -s https://api.nuget.org/v3/index.json --skip-duplicate + - | + if [ $CI_PIPELINE_SOURCE != "merge_request_event" ]; then + echo "Publishing to NuGet.org" + dotnet nuget push "artifacts/package/release/*.nupkg" -k $Nuget_Key -s https://api.nuget.org/v3/index.json --skip-duplicate + fi + + - echo "Publishing to GitLab" + - dotnet nuget push "artifacts/package/release/*.nupkg" --source "https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/nuget/index.json" --api-key $CI_JOB_TOKEN rules: - - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH =~ /^release\// || $CI_COMMIT_TAG - when: manual + - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH =~ /^release\// || $CI_COMMIT_TAG || $CI_PIPELINE_SOURCE == "merge_request_event" + when: manual \ No newline at end of file diff --git a/docs/UnitTests/README.md b/docs/UnitTests/README.md index cceb631..ab44290 100644 --- a/docs/UnitTests/README.md +++ b/docs/UnitTests/README.md @@ -163,7 +163,10 @@ public class TestFixture : ServiceProviderTestFixture { protected sealed override void RegisterCoreDependencies(ServiceCollection services) { - var options = Configuration.Get(); + var options = new NpgsqlDatabasePerTestStoreOptions + { + ConnectionString = Configuration.GetConnectionString("Npgsql") + }; var testStore = new NpgsqlDatabasePerTestStore(options); services.AddSingleton(testStore); @@ -187,8 +190,11 @@ You can create the template directly in the TestFixture by specifying a `Templat ```cs protected sealed override void RegisterCoreDependencies(ServiceCollection services) { - var options = Configuration.Get(); - options.TemplateCreator = CreateTemplate; // Function to create the template on first connect + var options = new NpgsqlDatabasePerTestStoreOptions + { + ConnectionString = Configuration.GetConnectionString("Npgsql"), + TemplateCreator = CreateTemplate; // Function to create the template on first connect + }; // rest of the configuration } @@ -240,24 +246,28 @@ public class TestFixture : ServiceProviderTestFixture var options = new SqlServerDatabasePerTestStoreOptions { ConnectionString = Configuration.GetConnectionString("SqlServer")!, - CreateDatabase = CreateSqlServerDatabase + TemplateCreator = CreateSqlServerTemplate, + DatabasePrefix = "project_test_" // Optional. Defines a prefix for the randomly generated test database names. + DatabaseDirectoryPath = "C:/mssql/data" // Optional. Defaults to docker image default path. }; var testStore = new SqlServerDatabasePerTestStore(options); services.AddSingleton(testStore); services.AddDbContext(b => b.UseSqlServerDatabasePerTest(testStore)); } - private static async Task CreateSqlServerDatabase(string connectionString) - { - await using var dbContext = new AppDbContext(connectionString); - await dbContext.Database.EnsureCreatedAsync(); - } + private static async Task CreateSqlServerTemplate(string connectionString) + => await SqlServerTestUtil.CreateTestDbTemplate(connectionString, o => new SqlServerDbContext(o)); } ``` +The connection string must have the `Intial catalog` set. It determines the name of the template database. All tests will use a copy of the template database. + +The `TemplateCreator` specifies the method to create a template. It has to create and seed the database and create a backup for the copies used for the tests. Fortunately, the `SqlServerTestUtil` provides a method to do exactly that. + + ### Configuring any other database -The database support is not limited to PostgreSql. You just have to implement and register the `ITestStore`. +The database support is not limited to PostgreSql and SQL Server. You just have to implement and register the `ITestStore`. For a simple example with SqLite, check `Fusonic.Extensions.UnitTests.EntityFrameworkCore.Tests` -> `SqliteTestStore` and `TestFixture`. @@ -284,7 +294,7 @@ public class TestFixture : ServiceProviderTestFixture var sqlServerSettings = new SqlServerDatabasePerTestStoreOptions { ConnectionString = Configuration.GetConnectionString("SqlServer")!, - CreateDatabase = CreateSqlServerDatabase + TemplateCreator = CreateSqlServerTemplate }; var sqlServerTestStore = new SqlServerDatabasePerTestStore(sqlServerSettings); services.AddDbContext(b => b.UseSqlServerDatabasePerTest(sqlServerTestStore)); @@ -296,11 +306,8 @@ public class TestFixture : ServiceProviderTestFixture private static Task CreatePostgresTemplate(string connectionString) => PostgreSqlUtil.CreateTestDbTemplate(connectionString, o => new NpgsqlDbContext(o), seed: ctx => new TestDataSeed(ctx).Seed()); - private static async Task CreateSqlServerDatabase(string connectionString) - { - await using var dbContext = new SqlServerDbContext(connectionString); - await dbContext.Database.EnsureCreatedAsync(); - } + private static Task CreateSqlServerTemplate(string connectionString) + => SqlServerTestUtil.CreateTestDbTemplate(connectionString, o => new SqlServerDbContext(o)); } ``` diff --git a/src/Hangfire/test/TestFixture.cs b/src/Hangfire/test/TestFixture.cs index c7d026e..3c92095 100644 --- a/src/Hangfire/test/TestFixture.cs +++ b/src/Hangfire/test/TestFixture.cs @@ -51,7 +51,7 @@ private void RegisterDatabase(IServiceCollection services) var testStoreOptions = new NpgsqlDatabasePerTestStoreOptions { TemplateCreator = CreateDatabase, - ConnectionString = Configuration.GetConnectionString("Hangfire") + ConnectionString = Configuration.GetConnectionString("Hangfire")! }; var testStore = new NpgsqlDatabasePerTestStore(testStoreOptions); diff --git a/src/UnitTests.EntityFrameworkCore.Npgsql/src/NpgsqlDatabasePerTestStore.cs b/src/UnitTests.EntityFrameworkCore.Npgsql/src/NpgsqlDatabasePerTestStore.cs index a807ed7..fe84a47 100644 --- a/src/UnitTests.EntityFrameworkCore.Npgsql/src/NpgsqlDatabasePerTestStore.cs +++ b/src/UnitTests.EntityFrameworkCore.Npgsql/src/NpgsqlDatabasePerTestStore.cs @@ -11,9 +11,6 @@ public class NpgsqlDatabasePerTestStore : ITestStore private readonly NpgsqlDatabasePerTestStoreOptions options; private readonly NpgsqlConnectionStringBuilder connectionStringBuilder; - private readonly string templateDatabaseName; - private readonly string postgresConnectionString; - public string ConnectionString => connectionStringBuilder.ConnectionString; private bool isDbCreated; @@ -21,16 +18,19 @@ public class NpgsqlDatabasePerTestStore : ITestStore public NpgsqlDatabasePerTestStore(NpgsqlDatabasePerTestStoreOptions options) { this.options = new NpgsqlDatabasePerTestStoreOptions(options); - connectionStringBuilder = new NpgsqlConnectionStringBuilder(options.ConnectionString); - templateDatabaseName = connectionStringBuilder.Database - ?? throw new ArgumentException("Missing template database in connection string."); + ValidateConnectionString(); + OnTestConstruction(); + } - connectionStringBuilder.Database = "postgres"; - postgresConnectionString = connectionStringBuilder.ConnectionString; + private void ValidateConnectionString() + { + var templateDatabaseName = connectionStringBuilder.Database + ?? throw new ArgumentException("Missing database in connection string."); - OnTestConstruction(); + if (templateDatabaseName == "postgres") + throw new ArgumentException("Connection string cannot use postgres as database. It should provide the name of the template database, even if it does not exist."); } public void OnTestConstruction() @@ -41,13 +41,8 @@ public void OnTestConstruction() public async Task OnTestEnd() { - if (!isDbCreated) - return; - - var connection = new NpgsqlConnection(postgresConnectionString); - await using var _ = connection.ConfigureAwait(false); - await connection.OpenAsync().ConfigureAwait(false); - await connection.ExecuteAsync($@"DROP DATABASE IF EXISTS ""{connectionStringBuilder.Database}"" WITH (FORCE)").ConfigureAwait(false); + if (isDbCreated) + await PostgreSqlUtil.DropDatabase(ConnectionString).ConfigureAwait(false); } public async Task CreateDatabase() @@ -56,7 +51,14 @@ public async Task CreateDatabase() return; if (options.TemplateCreator != null) - await DatabaseHelper.EnsureCreated(options.ConnectionString!, options.TemplateCreator, options.AlwaysCreateTemplate).ConfigureAwait(false); + { + await PostgreSqlUtil + .EnsureTemplateDbCreated( + options.ConnectionString, + options.TemplateCreator, + options.AlwaysCreateTemplate) + .ConfigureAwait(false); + } // Creating a DB from a template can cause an exception when done in parallel. // The lock usually prevents this, however, we still encounter race conditions @@ -72,10 +74,7 @@ async Task CreateDb() if (isDbCreated) return; - var connection = new NpgsqlConnection(postgresConnectionString); - await using var _ = connection.ConfigureAwait(false); - await connection.OpenAsync().ConfigureAwait(false); - await connection.ExecuteAsync($@"CREATE DATABASE ""{connectionStringBuilder.Database}"" TEMPLATE ""{templateDatabaseName}""").ConfigureAwait(false); + await PostgreSqlUtil.CreateDatabase(options.ConnectionString, connectionStringBuilder.Database!).ConfigureAwait(false); isDbCreated = true; } diff --git a/src/UnitTests.EntityFrameworkCore.Npgsql/src/NpgsqlDatabasePerTestStoreOptions.cs b/src/UnitTests.EntityFrameworkCore.Npgsql/src/NpgsqlDatabasePerTestStoreOptions.cs index fb2b900..e8d7582 100644 --- a/src/UnitTests.EntityFrameworkCore.Npgsql/src/NpgsqlDatabasePerTestStoreOptions.cs +++ b/src/UnitTests.EntityFrameworkCore.Npgsql/src/NpgsqlDatabasePerTestStoreOptions.cs @@ -1,6 +1,8 @@ // Copyright (c) Fusonic GmbH. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using System.Diagnostics.CodeAnalysis; + namespace Fusonic.Extensions.UnitTests.EntityFrameworkCore.Npgsql; public class NpgsqlDatabasePerTestStoreOptions @@ -8,6 +10,7 @@ public class NpgsqlDatabasePerTestStoreOptions public NpgsqlDatabasePerTestStoreOptions() { } + [SetsRequiredMembers] internal NpgsqlDatabasePerTestStoreOptions(NpgsqlDatabasePerTestStoreOptions copyFrom) { ConnectionString = copyFrom.ConnectionString; @@ -16,10 +19,10 @@ internal NpgsqlDatabasePerTestStoreOptions(NpgsqlDatabasePerTestStoreOptions cop } /// Connection string to the template database. - public string? ConnectionString { get; set; } + public required string ConnectionString { get; set; } /// Action to create the database template on first connect. Only gets executed once. If AlwaysCreateTemplate is set to false, the action only gets executed if the template database does not exist. - public Func? TemplateCreator { get; set; } + public required Func? TemplateCreator { get; set; } /// Ignores an existing template database and always recreates the template on the first run. Ignored, if TemplateCreator is null. public bool AlwaysCreateTemplate { get; set; } diff --git a/src/UnitTests.EntityFrameworkCore.Npgsql/src/PostgreSqlUtil.cs b/src/UnitTests.EntityFrameworkCore.Npgsql/src/PostgreSqlUtil.cs index 002c87a..a0ebf10 100644 --- a/src/UnitTests.EntityFrameworkCore.Npgsql/src/PostgreSqlUtil.cs +++ b/src/UnitTests.EntityFrameworkCore.Npgsql/src/PostgreSqlUtil.cs @@ -1,6 +1,7 @@ // Copyright (c) Fusonic GmbH. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using System.Data.Common; using System.Diagnostics.CodeAnalysis; using System.Globalization; using Microsoft.EntityFrameworkCore; @@ -14,7 +15,21 @@ namespace Fusonic.Extensions.UnitTests.EntityFrameworkCore.Npgsql; public static class PostgreSqlUtil { - /// Creates a test database. + internal static async Task EnsureTemplateDbCreated( + string connectionString, + Func createTemplate, + bool alwaysCreateTemplate = false) + { + await DatabaseTestHelper.EnsureTemplateDbCreated( + connectionString, + CheckDatabaseExists, + DropDatabase, + NpgsqlConnection.ClearAllPools, + createTemplate, + alwaysCreateTemplate).ConfigureAwait(false); + } + + /// Creates a test database template. /// Connection string to the test database. The database does not have to exist. /// Returns a DbContext using the given options. /// The configuration action for .UseNpgsql(). @@ -80,17 +95,27 @@ public static async Task CreateTestDbTemplate( logger.LogInformation("Done"); } - public static async Task DropDatabase(string connectionString) + internal static async Task CreateDatabase(string templateConnectionString, string dbName) { - var csBuilder = new NpgsqlConnectionStringBuilder(connectionString); - var dbName = csBuilder.Database; - AssertNotPostgres(dbName); + var connection = CreatePostgresConnection(templateConnectionString, out var templateName); + await using (connection.ConfigureAwait(false)) + { + if (templateName == dbName) + throw new ArgumentException("Template and new database name must not be the same."); - csBuilder.Database = "postgres"; - var connection = new NpgsqlConnection(csBuilder.ConnectionString); - await using var _ = connection.ConfigureAwait(false); - connection.Open(); - await DropDatabase(connection, dbName).ConfigureAwait(false); + await connection.OpenAsync().ConfigureAwait(false); + await connection.ExecuteAsync($""" CREATE DATABASE "{dbName}" TEMPLATE "{templateName}"; """).ConfigureAwait(false); + } + } + + public static async Task DropDatabase(string connectionString) + { + var connection = CreatePostgresConnection(connectionString, out var dbName); + await using (connection.ConfigureAwait(false)) + { + connection.Open(); + await DropDatabase(connection, dbName).ConfigureAwait(false); + } } private static async Task DropDatabase(NpgsqlConnection connection, string dbName) @@ -104,16 +129,14 @@ private static async Task DropDatabase(NpgsqlConnection connection, string dbNam public static async Task CheckDatabaseExists(string connectionString) { - var csBuilder = new NpgsqlConnectionStringBuilder(connectionString); - var dbName = csBuilder.Database!; - - csBuilder.Database = "postgres"; - var connection = new NpgsqlConnection(csBuilder.ConnectionString); - await using var _ = connection.ConfigureAwait(false); - connection.Open(); - var exists = await CheckDatabaseExists(connection, dbName).ConfigureAwait(false); + var connection = CreatePostgresConnection(connectionString, out var dbName); + await using (connection.ConfigureAwait(false)) + { + connection.Open(); + var exists = await CheckDatabaseExists(connection, dbName).ConfigureAwait(false); - return exists; + return exists; + } } private static async Task CheckDatabaseExists(NpgsqlConnection connection, string dbName) @@ -126,6 +149,19 @@ private static async Task CheckDatabaseExists(NpgsqlConnection connection, return result != null && (bool)result; } + private static NpgsqlConnection CreatePostgresConnection(string connectionString, out string dbName) + { + var csBuilder = new NpgsqlConnectionStringBuilder(connectionString); + if (string.IsNullOrWhiteSpace(csBuilder.Database)) + throw new ArgumentException("Database name is missing in the connection string.", nameof(connectionString)); + + dbName = csBuilder.Database; + AssertNotPostgres(dbName); + + csBuilder.Database = "postgres"; + return new NpgsqlConnection(csBuilder.ConnectionString); + } + private static void AssertNotPostgres([NotNull] string? dbName) { if (string.IsNullOrWhiteSpace(dbName)) diff --git a/src/UnitTests.EntityFrameworkCore.Npgsql/test/TestFixture.cs b/src/UnitTests.EntityFrameworkCore.Npgsql/test/TestFixture.cs index b3e76a0..97eccc0 100644 --- a/src/UnitTests.EntityFrameworkCore.Npgsql/test/TestFixture.cs +++ b/src/UnitTests.EntityFrameworkCore.Npgsql/test/TestFixture.cs @@ -15,7 +15,7 @@ protected override void RegisterCoreDependencies(IServiceCollection services) var testStoreOptions = new NpgsqlDatabasePerTestStoreOptions { TemplateCreator = CreateDatabase, - ConnectionString = Configuration.GetConnectionString("Npgsql") + ConnectionString = Configuration.GetConnectionString("Npgsql")! }; var testStore = new NpgsqlDatabasePerTestStore(testStoreOptions); diff --git a/src/UnitTests.EntityFrameworkCore.SqlServer/src/SqlServerDatabasePerTestStore.cs b/src/UnitTests.EntityFrameworkCore.SqlServer/src/SqlServerDatabasePerTestStore.cs index f94ad67..81156d1 100644 --- a/src/UnitTests.EntityFrameworkCore.SqlServer/src/SqlServerDatabasePerTestStore.cs +++ b/src/UnitTests.EntityFrameworkCore.SqlServer/src/SqlServerDatabasePerTestStore.cs @@ -10,8 +10,6 @@ public class SqlServerDatabasePerTestStore : ITestStore private readonly SqlServerDatabasePerTestStoreOptions options; private readonly SqlConnectionStringBuilder connectionStringBuilder; - private readonly string masterConnectionString; - public string ConnectionString => connectionStringBuilder.ConnectionString; private bool isDbCreated; @@ -19,48 +17,32 @@ public class SqlServerDatabasePerTestStore : ITestStore public SqlServerDatabasePerTestStore(SqlServerDatabasePerTestStoreOptions options) { this.options = new SqlServerDatabasePerTestStoreOptions(options); - connectionStringBuilder = new SqlConnectionStringBuilder(options.ConnectionString); - masterConnectionString = connectionStringBuilder.ConnectionString; - + + ValidateConnectionString(); OnTestConstruction(); } + private void ValidateConnectionString() + { + var templateCatalogName = connectionStringBuilder.InitialCatalog + ?? throw new ArgumentException("Missing initial catalog in connection string."); + + if (templateCatalogName == "master") + throw new ArgumentException("Connection string cannot use master as initial catalog. It should provide the name of the template catalog, even if it does not exist."); + } + public void OnTestConstruction() { - connectionStringBuilder.InitialCatalog = string.Concat(options.DatabasePrefix, Convert.ToBase64String(Guid.NewGuid().ToByteArray()).TrimEnd('=')); + var dbName = Convert.ToBase64String(Guid.NewGuid().ToByteArray()).TrimEnd('=').Replace('/', '_'); + connectionStringBuilder.InitialCatalog = $"{options.DatabasePrefix}{dbName}"; isDbCreated = false; } public async Task OnTestEnd() { - if (!isDbCreated) - return; - - var connection = new SqlConnection(masterConnectionString); - await using var _ = connection.ConfigureAwait(continueOnCapturedContext: false); - await connection.OpenAsync().ConfigureAwait(false); - - var cmd = connection.CreateCommand(); - var dbName = connectionStringBuilder.InitialCatalog; - - // Other connections users may still access the DB. Set it to single user to disconnect other sessions and drop it then. - SqlConnection.ClearAllPools(); - cmd.CommandText = $""" - DECLARE @SQL nvarchar(1000); - IF EXISTS (SELECT 1 FROM sys.databases WHERE [name] = N'{dbName}') - BEGIN - SET @SQL = N'USE [{dbName}]; - - ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; - USE [tempdb]; - - DROP DATABASE [{dbName}];'; - EXEC (@SQL); - END; - """; - - await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + if (isDbCreated) + await SqlServerTestUtil.DropDatabase(ConnectionString).ConfigureAwait(false); } public async Task CreateDatabase() @@ -68,7 +50,21 @@ public async Task CreateDatabase() if (isDbCreated) return; - await options.CreateDatabase(ConnectionString).ConfigureAwait(false); + if (options.TemplateCreator != null) + { + await SqlServerTestUtil + .EnsureTemplateDbCreated( + options.ConnectionString, + options.TemplateCreator, + options.AlwaysCreateTemplate) + .ConfigureAwait(false); + } + + await SqlServerTestUtil.CreateDatabase( + options.ConnectionString, + connectionStringBuilder.InitialCatalog, + options.DataDirectoryPath).ConfigureAwait(false); + isDbCreated = true; } } \ No newline at end of file diff --git a/src/UnitTests.EntityFrameworkCore.SqlServer/src/SqlServerDatabasePerTestStoreOptions.cs b/src/UnitTests.EntityFrameworkCore.SqlServer/src/SqlServerDatabasePerTestStoreOptions.cs index d463251..51e5414 100644 --- a/src/UnitTests.EntityFrameworkCore.SqlServer/src/SqlServerDatabasePerTestStoreOptions.cs +++ b/src/UnitTests.EntityFrameworkCore.SqlServer/src/SqlServerDatabasePerTestStoreOptions.cs @@ -6,6 +6,8 @@ namespace Fusonic.Extensions.UnitTests.EntityFrameworkCore.SqlServer; public class SqlServerDatabasePerTestStoreOptions { + private string dataDirectoryPath = "/var/opt/mssql/data"; + public SqlServerDatabasePerTestStoreOptions() { } @@ -13,16 +15,30 @@ public SqlServerDatabasePerTestStoreOptions() internal SqlServerDatabasePerTestStoreOptions(SqlServerDatabasePerTestStoreOptions copyFrom) { ConnectionString = copyFrom.ConnectionString; - CreateDatabase = copyFrom.CreateDatabase; + TemplateCreator = copyFrom.TemplateCreator; + AlwaysCreateTemplate = copyFrom.AlwaysCreateTemplate; + DataDirectoryPath = copyFrom.DataDirectoryPath; DatabasePrefix = copyFrom.DatabasePrefix; } /// Connection string to the master database. public required string ConnectionString { get; set; } - /// Action to create the database on first connect. - public required Func CreateDatabase { get; set; } + /// Action to create the database template on first connect. Only gets executed once. If AlwaysCreateTemplate is set to false, the action only gets executed if the template database does not exist. + public required Func? TemplateCreator { get; set; } + + /// Ignores an existing template database and always recreates the template on the first run. Ignored, if TemplateCreator is null. + public bool AlwaysCreateTemplate { get; set; } + + /// + /// Path to the directory where the database files are stored. Defaults to the default SQL Server data directory in the docker image /var/opt/mssql/data. + /// + public string DataDirectoryPath + { + get => dataDirectoryPath; + set => dataDirectoryPath = value.TrimEnd('/').TrimEnd('\\'); + } - // Prefix to use for database naming - public string? DatabasePrefix { get; set; } = null; + // Allows defining an optional prefix for the randomly generated test database names. + public string? DatabasePrefix { get; set; } } \ No newline at end of file diff --git a/src/UnitTests.EntityFrameworkCore.SqlServer/src/SqlServerTestUtil.cs b/src/UnitTests.EntityFrameworkCore.SqlServer/src/SqlServerTestUtil.cs new file mode 100644 index 0000000..1367156 --- /dev/null +++ b/src/UnitTests.EntityFrameworkCore.SqlServer/src/SqlServerTestUtil.cs @@ -0,0 +1,209 @@ +// Copyright (c) Fusonic GmbH. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for license information. + +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Logging; + +namespace Fusonic.Extensions.UnitTests.EntityFrameworkCore.SqlServer; + +public static class SqlServerTestUtil +{ + private static readonly SemaphoreSlim CreationSync = new(1); + + internal static async Task EnsureTemplateDbCreated( + string connectionString, + Func createTemplate, + bool alwaysCreateTemplate = false) + { + await DatabaseTestHelper.EnsureTemplateDbCreated( + connectionString, + CheckDatabaseExists, + DropDatabase, + SqlConnection.ClearAllPools, + createTemplate, + alwaysCreateTemplate) + .ConfigureAwait(false); + } + + public static async Task CheckDatabaseExists(string connectionString) + { + var connection = CreateMasterConnection(connectionString, out var dbName); + await using (connection.ConfigureAwait(false)) + { + await connection.OpenAsync().ConfigureAwait(false); + return await DbExists(dbName, connection).ConfigureAwait(false); + } + } + + private static async Task DbExists(string dbName, SqlConnection connection) + { + var cmd = connection.CreateCommand(); + cmd.CommandText = $"SELECT COUNT(*) FROM sys.databases WHERE name = N'{dbName}'"; + return (int)(await cmd.ExecuteScalarAsync().ConfigureAwait(false) ?? 0) > 0; + } + + internal static async Task CreateDatabase(string templateConnectionString, string dbName, string dataDirectoryPath) + { + await CreationSync.WaitAsync().ConfigureAwait(false); + try + { + var connection = CreateMasterConnection(templateConnectionString, out var templateName); + await using (connection.ConfigureAwait(false)) + { + if (templateName == dbName) + throw new ArgumentException("Template and new database name must not be the same."); + + await connection.OpenAsync().ConfigureAwait(false); + + var cmd = connection.CreateCommand(); + cmd.CommandText = $""" + RESTORE DATABASE [{dbName}] FROM DISK='{templateName}.bak' + WITH + MOVE '{templateName}' TO '{dataDirectoryPath}/{dbName}.mdf', + MOVE '{templateName}_log' TO '{dataDirectoryPath}/{dbName}_log.ldf' + """; + + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + } + finally + { + CreationSync.Release(); + } + } + + public static async Task DropDatabase(string connectionString) + { + var connection = CreateMasterConnection(connectionString, out var dbName); + await using (connection.ConfigureAwait(false)) + { + connection.Open(); + await DropDatabase(dbName, connection).ConfigureAwait(false); + } + } + + internal static async Task DropDatabase(string dbName, SqlConnection connection) + { + var cmd = connection.CreateCommand(); + + // Other connections users may still access the DB. Set it to single user to disconnect other sessions and drop it then. + SqlConnection.ClearAllPools(); + cmd.CommandText = $""" + DECLARE @SQL nvarchar(1000); + IF EXISTS (SELECT 1 FROM sys.databases WHERE [name] = N'{dbName}') + BEGIN + SET @SQL = N'USE [{dbName}]; + + ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + USE [tempdb]; + + DROP DATABASE [{dbName}];'; + EXEC (@SQL); + END; + """; + + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + /// Creates a test database template. + /// + /// Connection string to the test database template. The database does not have to exist. + /// The initial catalog must be set and determines the name of the template database. + /// + /// Return a DbContext using the given options. + /// The configuration action for .UseSqlServer(). + /// Optional seed action that gets executed after creating the database. + /// Logger. Defaults to console logger. + /// + /// If the DbContext was scaffolded from an existing database, this setting should be set to false (=default).
+ /// If the DbContext is code first and the database gets created with migrations, this setting should be set to true.
+ /// Defaults to false. + /// + public static async Task CreateTestDbTemplate( + string connectionString, + Func, TDbContext> dbContextFactory, + Action? sqlServerOptionsAction = null, + Func? seed = null, + ILogger? logger = null, + bool useMigrations = false) + where TDbContext : DbContext + { + logger ??= CreateConsoleLogger(); + + var options = new DbContextOptionsBuilder() + .UseSqlServer(connectionString, sqlServerOptionsAction) + .LogTo( + (eventId, _) => eventId != RelationalEventId.CommandExecuted, + eventData => logger.Log(eventData.LogLevel, eventData.EventId, "[EF] {Message}", eventData.ToString())) + .Options; + + // Drop existing test template + await DropDatabase(connectionString).ConfigureAwait(false); + + var dbContext = dbContextFactory(options); + await using (dbContext.ConfigureAwait(false)) + { + // Migrate and run seed + if (useMigrations) + { + logger.LogInformation("Running migrations"); + await dbContext.Database.MigrateAsync().ConfigureAwait(false); + } + else + { + logger.LogInformation("Creating database"); + await dbContext.Database.EnsureCreatedAsync().ConfigureAwait(false); + } + + if (seed != null) + { + logger.LogInformation("Running seed"); + await seed(dbContext).ConfigureAwait(false); + } + } + + var connection = CreateMasterConnection(connectionString, out var dbName); + await using (connection.ConfigureAwait(false)) + { + await connection.OpenAsync().ConfigureAwait(false); + var cmd = connection.CreateCommand(); + + // Create backup + cmd.CommandText = $"BACKUP DATABASE {dbName} TO DISK='{dbName}.bak' WITH INIT"; + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + logger.LogInformation("Done"); + } + + private static SqlConnection CreateMasterConnection(string connectionString, out string dbName) + { + var csBuilder = new SqlConnectionStringBuilder(connectionString); + if (string.IsNullOrWhiteSpace(csBuilder.InitialCatalog)) + throw new ArgumentException("InitialCatalog is missing in the connection string.", nameof(connectionString)); + + dbName = csBuilder.InitialCatalog; + AssertNotMaster(dbName); + + csBuilder.InitialCatalog = "master"; + return new SqlConnection(csBuilder.ConnectionString); + } + + private static void AssertNotMaster([NotNull] string? dbName) + { + if (string.IsNullOrWhiteSpace(dbName)) + throw new ArgumentException("DB Name is empty.", nameof(dbName)); + + if ("master".Equals(dbName.ToLower(CultureInfo.InvariantCulture).Trim(), StringComparison.Ordinal)) + throw new ArgumentException("You can't do this on the master catalog."); + } + + private static ILogger CreateConsoleLogger() + => LoggerFactory.Create(b => b.AddSimpleConsole(c => c.SingleLine = true)) + .CreateLogger(nameof(SqlServerTestUtil)); +} \ No newline at end of file diff --git a/src/UnitTests.EntityFrameworkCore.Npgsql/src/DatabaseHelper.cs b/src/UnitTests.EntityFrameworkCore/src/DatabaseTestHelper.cs similarity index 61% rename from src/UnitTests.EntityFrameworkCore.Npgsql/src/DatabaseHelper.cs rename to src/UnitTests.EntityFrameworkCore/src/DatabaseTestHelper.cs index f054198..d20d54e 100644 --- a/src/UnitTests.EntityFrameworkCore.Npgsql/src/DatabaseHelper.cs +++ b/src/UnitTests.EntityFrameworkCore/src/DatabaseTestHelper.cs @@ -1,13 +1,16 @@ -// Copyright (c) Fusonic GmbH. All rights reserved. +// Copyright (c) Fusonic GmbH. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using Npgsql; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; -namespace Fusonic.Extensions.UnitTests.EntityFrameworkCore.Npgsql; - -internal static class DatabaseHelper +namespace Fusonic.Extensions.UnitTests.EntityFrameworkCore; +public static class DatabaseTestHelper { - private static bool templateCreated; + private static readonly HashSet TemplatesCreated = []; private static Exception? creationFailedException; private static readonly SemaphoreSlim CreationSync = new(1); @@ -17,17 +20,26 @@ internal static class DatabaseHelper /// Once the template is created, all future calls to this method just return without checking the template again. /// /// Connection string to the template database. + /// Function to check if the database exists. + /// Function to forcefully drop the database. + /// Action to clear all connection pools. /// Action to be executed to create the database. /// When set to true, do not check if the template database exists, but always recreate the template on the first run. Defaults to false. - public static async Task EnsureCreated(string connectionString, Func createTemplate, bool alwaysCreateTemplate = false) + public static async Task EnsureTemplateDbCreated( + string connectionString, + Func> checkDatabaseExists, + Func dropDatabase, + Action clearAllPools, + Func createTemplate, + bool alwaysCreateTemplate = false) { - if (templateCreated) + if (TemplatesCreated.Contains(connectionString)) return; await CreationSync.WaitAsync().ConfigureAwait(false); try { - if (templateCreated) + if (TemplatesCreated.Contains(connectionString)) return; if (creationFailedException != null) @@ -35,7 +47,7 @@ public static async Task EnsureCreated(string connectionString, Func