diff --git a/.github/workflows/dotnetbuildtest.yml b/.github/workflows/dotnetbuildtest.yml index 66638af9..5c456318 100644 --- a/.github/workflows/dotnetbuildtest.yml +++ b/.github/workflows/dotnetbuildtest.yml @@ -22,4 +22,4 @@ jobs: - name: Build with dotnet run: dotnet build --configuration Release - name: Run UnitTests with dotnet - run: dotnet test --configuration Release \ No newline at end of file + run: dotnet test --configuration Release --filter FullyQualifiedName!~Integration \ No newline at end of file diff --git a/Microsoft.Health.sln b/Microsoft.Health.sln index b886c10c..efd5de64 100644 --- a/Microsoft.Health.sln +++ b/Microsoft.Health.sln @@ -59,7 +59,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Health.Client", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Health.Client.UnitTests", "src\Microsoft.Health.Client.UnitTests\Microsoft.Health.Client.UnitTests.csproj", "{D4DD763B-5246-47F4-A618-211AA820B65E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SchemaManager.Core", "tools\SchemaManager.Core\SchemaManager.Core.csproj", "{72816760-BDE2-4CAF-AF41-F7BEE8E26158}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SchemaManager.Core", "tools\SchemaManager.Core\SchemaManager.Core.csproj", "{72816760-BDE2-4CAF-AF41-F7BEE8E26158}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SchemaManager.Core.UnitTests", "test\SchemaManager.Core.UnitTests\SchemaManager.Core.UnitTests.csproj", "{0BEB2EE3-DBC3-4DC4-BB01-141DBFFED8D2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Health.SqlServer.Tests.Integration", "test\Microsoft.Health.SqlServer.Tests.Integration\Microsoft.Health.SqlServer.Tests.Integration.csproj", "{5429C3A8-3429-43EA-BA05-E347CB4D1F61}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -159,6 +163,14 @@ Global {72816760-BDE2-4CAF-AF41-F7BEE8E26158}.Debug|Any CPU.Build.0 = Debug|Any CPU {72816760-BDE2-4CAF-AF41-F7BEE8E26158}.Release|Any CPU.ActiveCfg = Release|Any CPU {72816760-BDE2-4CAF-AF41-F7BEE8E26158}.Release|Any CPU.Build.0 = Release|Any CPU + {0BEB2EE3-DBC3-4DC4-BB01-141DBFFED8D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0BEB2EE3-DBC3-4DC4-BB01-141DBFFED8D2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0BEB2EE3-DBC3-4DC4-BB01-141DBFFED8D2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0BEB2EE3-DBC3-4DC4-BB01-141DBFFED8D2}.Release|Any CPU.Build.0 = Release|Any CPU + {5429C3A8-3429-43EA-BA05-E347CB4D1F61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5429C3A8-3429-43EA-BA05-E347CB4D1F61}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5429C3A8-3429-43EA-BA05-E347CB4D1F61}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5429C3A8-3429-43EA-BA05-E347CB4D1F61}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -187,9 +199,11 @@ Global {0EAE32B6-97C9-43C8-ABA5-8C0BAD9ED864} = {8AD2A324-DAB5-4380-94A5-31F7D817C384} {D4DD763B-5246-47F4-A618-211AA820B65E} = {8AD2A324-DAB5-4380-94A5-31F7D817C384} {72816760-BDE2-4CAF-AF41-F7BEE8E26158} = {B70945F4-01A6-4351-955B-C4A2943B5E3B} + {0BEB2EE3-DBC3-4DC4-BB01-141DBFFED8D2} = {CCD9FF99-E177-446E-B9E5-9F570FD96A34} + {5429C3A8-3429-43EA-BA05-E347CB4D1F61} = {CCD9FF99-E177-446E-B9E5-9F570FD96A34} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {562bbd07-1817-4af5-ab66-8837cf849248} RESX_SortFileContentOnSave = True + SolutionGuid = {562bbd07-1817-4af5-ab66-8837cf849248} EndGlobalSection EndGlobal diff --git a/src/Microsoft.Health.SqlServer/Extensions/SqlConnectionExtensions.cs b/src/Microsoft.Health.SqlServer/Extensions/SqlConnectionExtensions.cs new file mode 100644 index 00000000..ffa6ca92 --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Extensions/SqlConnectionExtensions.cs @@ -0,0 +1,22 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; + +namespace Microsoft.Health.SqlServer.Extensions +{ + public static class SqlConnectionExtensions + { + public static async Task TryOpenAsync(this SqlConnection connection, CancellationToken cancellationToken = default) + { + if (connection.State != System.Data.ConnectionState.Open) + { + await connection.OpenAsync(cancellationToken); + } + } + } +} diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/Manager/BaseSchemaRunner.cs b/src/Microsoft.Health.SqlServer/Features/Schema/Manager/BaseSchemaRunner.cs index 6ea9606e..e7b939e2 100644 --- a/src/Microsoft.Health.SqlServer/Features/Schema/Manager/BaseSchemaRunner.cs +++ b/src/Microsoft.Health.SqlServer/Features/Schema/Manager/BaseSchemaRunner.cs @@ -80,9 +80,17 @@ await Policy.Handle() private async Task InstanceSchemaRecordCreatedAsync(CancellationToken cancellationToken) { - if (!await _schemaManagerDataStore.InstanceSchemaRecordExistsAsync(cancellationToken)) + try { - throw new SchemaManagerException(Resources.InstanceSchemaRecordErrorMessage); + if (!await _schemaManagerDataStore.InstanceSchemaRecordExistsAsync(cancellationToken)) + { + throw new SchemaManagerException(Resources.InstanceSchemaRecordErrorMessage); + } + } + catch (SqlException e) when (e.Message.Contains("Invalid object name", StringComparison.OrdinalIgnoreCase)) + { + // Table doesn't exist + throw new SchemaManagerException(Resources.InstanceSchemaRecordTableNotFound, e); } } diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/Manager/Exceptions/SchemaManagerException.cs b/src/Microsoft.Health.SqlServer/Features/Schema/Manager/Exceptions/SchemaManagerException.cs index 12352405..fb4b30ba 100644 --- a/src/Microsoft.Health.SqlServer/Features/Schema/Manager/Exceptions/SchemaManagerException.cs +++ b/src/Microsoft.Health.SqlServer/Features/Schema/Manager/Exceptions/SchemaManagerException.cs @@ -15,5 +15,11 @@ public SchemaManagerException(string message) { Debug.Assert(!string.IsNullOrEmpty(message), "Exception message should not be empty"); } + + public SchemaManagerException(string message, Exception innerException) + : base(message, innerException) + { + Debug.Assert(!string.IsNullOrEmpty(message), "Exception message should not be empty"); + } } } diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/Manager/IBaseSchemaRunner.cs b/src/Microsoft.Health.SqlServer/Features/Schema/Manager/IBaseSchemaRunner.cs new file mode 100644 index 00000000..6a57a127 --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Features/Schema/Manager/IBaseSchemaRunner.cs @@ -0,0 +1,17 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Health.SqlServer.Features.Schema.Manager +{ + public interface IBaseSchemaRunner + { + public Task EnsureBaseSchemaExistsAsync(CancellationToken cancellationToken); + + public Task EnsureInstanceSchemaRecordExistsAsync(CancellationToken cancellationToken); + } +} diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/Manager/SchemaManagerDataStore.cs b/src/Microsoft.Health.SqlServer/Features/Schema/Manager/SchemaManagerDataStore.cs index 8f907cb3..c93e839b 100644 --- a/src/Microsoft.Health.SqlServer/Features/Schema/Manager/SchemaManagerDataStore.cs +++ b/src/Microsoft.Health.SqlServer/Features/Schema/Manager/SchemaManagerDataStore.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using EnsureThat; using Microsoft.Data.SqlClient; +using Microsoft.Health.SqlServer.Extensions; using Microsoft.SqlServer.Management.Common; using Microsoft.SqlServer.Management.Smo; @@ -33,7 +34,7 @@ public async Task ExecuteScriptAndCompleteSchemaVersionAsync(string script, int using (var connection = await _sqlConnectionFactory.GetSqlConnectionAsync(cancellationToken: cancellationToken)) { - await connection.OpenAsync(cancellationToken); + await connection.TryOpenAsync(cancellationToken); ServerConnection serverConnection = new ServerConnection(connection); try @@ -65,7 +66,7 @@ public async Task DeleteSchemaVersionAsync(int version, string status, Cancellat using (var connection = await _sqlConnectionFactory.GetSqlConnectionAsync(cancellationToken: cancellationToken)) { - await connection.OpenAsync(cancellationToken); + await connection.TryOpenAsync(cancellationToken); var deleteQuery = "DELETE FROM dbo.SchemaVersion WHERE Version = @version AND Status = @status"; using (var deleteCommand = new SqlCommand(deleteQuery, connection)) @@ -83,7 +84,7 @@ public async Task GetCurrentSchemaVersionAsync(CancellationToken cancellati { using (var connection = await _sqlConnectionFactory.GetSqlConnectionAsync(cancellationToken: cancellationToken)) { - await connection.OpenAsync(cancellationToken); + await connection.TryOpenAsync(cancellationToken); try { @@ -125,7 +126,7 @@ public async Task ExecuteScriptAsync(string script, CancellationToken cancellati using (var connection = await _sqlConnectionFactory.GetSqlConnectionAsync(cancellationToken: cancellationToken)) { - await connection.OpenAsync(cancellationToken); + await connection.TryOpenAsync(cancellationToken); var server = new Server(new ServerConnection(connection)); server.ConnectionContext.ExecuteNonQuery(script); @@ -139,7 +140,7 @@ public async Task BaseSchemaExistsAsync(CancellationToken cancellationToke using (var connection = await _sqlConnectionFactory.GetSqlConnectionAsync(cancellationToken: cancellationToken)) { - await connection.OpenAsync(cancellationToken); + await connection.TryOpenAsync(cancellationToken); using (var command = new SqlCommand(procedureQuery, connection)) { @@ -158,7 +159,7 @@ public async Task InstanceSchemaRecordExistsAsync(CancellationToken cancel using (var connection = await _sqlConnectionFactory.GetSqlConnectionAsync(cancellationToken: cancellationToken)) { - await connection.OpenAsync(cancellationToken); + await connection.TryOpenAsync(cancellationToken); using (var command = new SqlCommand(procedureQuery, connection)) { diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/SchemaUpgradeRunner.cs b/src/Microsoft.Health.SqlServer/Features/Schema/SchemaUpgradeRunner.cs index b115109b..b2e82043 100644 --- a/src/Microsoft.Health.SqlServer/Features/Schema/SchemaUpgradeRunner.cs +++ b/src/Microsoft.Health.SqlServer/Features/Schema/SchemaUpgradeRunner.cs @@ -10,6 +10,7 @@ using MediatR; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; +using Microsoft.Health.SqlServer.Extensions; using Microsoft.Health.SqlServer.Features.Schema.Extensions; using Microsoft.Health.SqlServer.Features.Schema.Manager; @@ -60,7 +61,7 @@ public async Task ApplySchemaAsync(int version, bool applyFullSchemaSnapshot, Ca await CompleteSchemaVersionAsync(version, cancellationToken); - _mediator.NotifySchemaUpgradedAsync(version, applyFullSchemaSnapshot).Wait(); + await _mediator.NotifySchemaUpgradedAsync(version, applyFullSchemaSnapshot); _logger.LogInformation("Completed applying schema {version}", version); } @@ -92,7 +93,7 @@ private async Task UpsertSchemaVersionAsync(int schemaVersion, string status, Ca upsertCommand.Parameters.AddWithValue("@version", schemaVersion); upsertCommand.Parameters.AddWithValue("@status", status); - await connection.OpenAsync(cancellationToken); + await connection.TryOpenAsync(cancellationToken); await upsertCommand.ExecuteNonQueryAsync(cancellationToken); } } diff --git a/src/Microsoft.Health.SqlServer/Resources.Designer.cs b/src/Microsoft.Health.SqlServer/Resources.Designer.cs index 0cca7060..a765de9f 100644 --- a/src/Microsoft.Health.SqlServer/Resources.Designer.cs +++ b/src/Microsoft.Health.SqlServer/Resources.Designer.cs @@ -150,6 +150,15 @@ internal static string InstanceSchemaRecordErrorMessage { } } + /// + /// Looks up a localized string similar to InstanceSchema table does not exist.. + /// + internal static string InstanceSchemaRecordTableNotFound { + get { + return ResourceManager.GetString("InstanceSchemaRecordTableNotFound", resourceCulture); + } + } + /// /// Looks up a localized string similar to Insufficient permissions to create the database.. /// diff --git a/src/Microsoft.Health.SqlServer/Resources.resx b/src/Microsoft.Health.SqlServer/Resources.resx index 0bab269a..8aaa6273 100644 --- a/src/Microsoft.Health.SqlServer/Resources.resx +++ b/src/Microsoft.Health.SqlServer/Resources.resx @@ -148,6 +148,9 @@ The current version information could not be fetched from the service. Please try again. + + InstanceSchema table does not exist. + Insufficient permissions to create the database. diff --git a/test/Microsoft.Health.SqlServer.Tests.Integration/Features/Schema/Manager/BaseSchemaRunnerTests.cs b/test/Microsoft.Health.SqlServer.Tests.Integration/Features/Schema/Manager/BaseSchemaRunnerTests.cs new file mode 100644 index 00000000..bd99f43e --- /dev/null +++ b/test/Microsoft.Health.SqlServer.Tests.Integration/Features/Schema/Manager/BaseSchemaRunnerTests.cs @@ -0,0 +1,54 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Health.SqlServer.Features.Schema.Manager; +using Microsoft.Health.SqlServer.Features.Schema.Manager.Exceptions; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Health.SqlServer.Tests.Integration.Features.Schema.Manager +{ + public class BaseSchemaRunnerTests : SqlIntegrationTestBase + { + private readonly BaseSchemaRunner _runner; + private readonly ISchemaManagerDataStore _dataStore; + + public BaseSchemaRunnerTests(ITestOutputHelper output) + : base(output) + { + var sqlConnectionFactory = new DefaultSqlConnectionFactory(ConnectionStringProvider); + _dataStore = new SchemaManagerDataStore(sqlConnectionFactory); + + _runner = new BaseSchemaRunner(sqlConnectionFactory, _dataStore, ConnectionStringProvider, NullLogger.Instance); + } + + [Fact] + public async Task EnsureBaseSchemaExist_DoesNotExist_CreatesIt() + { + Assert.False(await _dataStore.BaseSchemaExistsAsync(CancellationToken.None)); + await _runner.EnsureBaseSchemaExistsAsync(CancellationToken.None); + Assert.True(await _dataStore.BaseSchemaExistsAsync(CancellationToken.None)); + } + + [Fact] + public async Task EnsureBaseSchemaExist_Exists_DoesNothing() + { + Assert.False(await _dataStore.BaseSchemaExistsAsync(CancellationToken.None)); + await _runner.EnsureBaseSchemaExistsAsync(CancellationToken.None); + Assert.True(await _dataStore.BaseSchemaExistsAsync(CancellationToken.None)); + await _runner.EnsureBaseSchemaExistsAsync(CancellationToken.None); + Assert.True(await _dataStore.BaseSchemaExistsAsync(CancellationToken.None)); + } + + [Fact] + public async Task EnsureInstanceSchemaRecordExists_WhenNotExists_Throws() + { + await Assert.ThrowsAsync(() => _runner.EnsureInstanceSchemaRecordExistsAsync(CancellationToken.None)); + } + } +} diff --git a/test/Microsoft.Health.SqlServer.Tests.Integration/Features/Schema/SchemaInitializerTests.cs b/test/Microsoft.Health.SqlServer.Tests.Integration/Features/Schema/SchemaInitializerTests.cs new file mode 100644 index 00000000..3c1ae06e --- /dev/null +++ b/test/Microsoft.Health.SqlServer.Tests.Integration/Features/Schema/SchemaInitializerTests.cs @@ -0,0 +1,44 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Health.SqlServer.Features.Schema; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Health.SqlServer.Tests.Integration.Features.Schema +{ + public class SchemaInitializerTests : SqlIntegrationTestBase + { + public SchemaInitializerTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + [Fact] + public async Task DatabaseDoesNotExist_DoesDatabaseExistAsync_ReturnsFalse() + { + Assert.False(await SchemaInitializer.DoesDatabaseExistAsync(Connection, "doesnotexist", CancellationToken.None)); + } + + [Fact] + public async Task DatabaseExists_DoesDatabaseExistAsync_ReturnsTrue() + { + const string dbName = "willexist"; + + try + { + Assert.False(await SchemaInitializer.DoesDatabaseExistAsync(Connection, dbName, CancellationToken.None)); + Assert.True(await SchemaInitializer.CreateDatabaseAsync(Connection, dbName, CancellationToken.None)); + Assert.True(await SchemaInitializer.DoesDatabaseExistAsync(Connection, dbName, CancellationToken.None)); + } + finally + { + await DeleteDatabaseAsync(dbName); + } + } + } +} diff --git a/test/Microsoft.Health.SqlServer.Tests.Integration/Features/Schema/SchemaUpgradeRunnerTests.cs b/test/Microsoft.Health.SqlServer.Tests.Integration/Features/Schema/SchemaUpgradeRunnerTests.cs new file mode 100644 index 00000000..3b98d0a9 --- /dev/null +++ b/test/Microsoft.Health.SqlServer.Tests.Integration/Features/Schema/SchemaUpgradeRunnerTests.cs @@ -0,0 +1,76 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Health.SqlServer.Features.Schema; +using Microsoft.Health.SqlServer.Features.Schema.Manager; +using Microsoft.SqlServer.Management.Common; +using NSubstitute; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Health.SqlServer.Tests.Integration.Features.Schema +{ + public class SchemaUpgradeRunnerTests : SqlIntegrationTestBase + { + private SchemaUpgradeRunner _runner; + private SchemaManagerDataStore _schemaDataStore; + + public SchemaUpgradeRunnerTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + + var connectionFactory = Substitute.For(); + connectionFactory.GetSqlConnectionAsync(Arg.Any(), Arg.Any()).ReturnsForAnyArgs((x) => GetSqlConnection()); + _schemaDataStore = new SchemaManagerDataStore(connectionFactory); + _runner = new SchemaUpgradeRunner(new ScriptProvider(), new BaseScriptProvider(), Substitute.For(), NullLogger.Instance, connectionFactory, _schemaDataStore); + } + + [Fact] + public async Task ApplyBaseSchema_DoesNotExist_Succeeds() + { + Assert.False(await _schemaDataStore.BaseSchemaExistsAsync(CancellationToken.None)); + await _runner.ApplyBaseSchemaAsync(CancellationToken.None); + Assert.True(await _schemaDataStore.BaseSchemaExistsAsync(CancellationToken.None)); + } + + [Fact] + public async Task ApplySchema_BaseSchemaDoesNotExist_Fails() + { + Assert.False(await _schemaDataStore.BaseSchemaExistsAsync(CancellationToken.None)); + var outerException = await Assert.ThrowsAsync(() => _runner.ApplySchemaAsync(1, true, CancellationToken.None)); + Assert.Contains("Invalid object name 'dbo.SchemaVersion'", outerException.InnerException.Message); + } + + [Fact] + public async Task ApplySchema_BaseSchemaExists_Succeeds() + { + await _runner.ApplyBaseSchemaAsync(CancellationToken.None); + await _runner.ApplySchemaAsync(1, applyFullSchemaSnapshot: true, CancellationToken.None); + var version = await _schemaDataStore.GetCurrentSchemaVersionAsync(CancellationToken.None); + Assert.Equal(1, version); + } + + [Fact] + public async Task ApplySchema_UsingDiff_Succeeds() + { + await _runner.ApplyBaseSchemaAsync(CancellationToken.None); + await _runner.ApplySchemaAsync(2, applyFullSchemaSnapshot: true, CancellationToken.None); + var version = await _schemaDataStore.GetCurrentSchemaVersionAsync(CancellationToken.None); + Assert.Equal(2, version); + await _runner.ApplySchemaAsync(3, applyFullSchemaSnapshot: false, CancellationToken.None); + version = await _schemaDataStore.GetCurrentSchemaVersionAsync(CancellationToken.None); + Assert.Equal(3, version); + } + } +} diff --git a/test/Microsoft.Health.SqlServer.Tests.Integration/Features/Schema/SchemaVersion.cs b/test/Microsoft.Health.SqlServer.Tests.Integration/Features/Schema/SchemaVersion.cs new file mode 100644 index 00000000..d967b5d7 --- /dev/null +++ b/test/Microsoft.Health.SqlServer.Tests.Integration/Features/Schema/SchemaVersion.cs @@ -0,0 +1,13 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +namespace Microsoft.Health.SqlServer.Tests.Integration.Features.Schema +{ + public enum SchemaVersion + { + Version1 = 1, + Version2 = 2, + } +} \ No newline at end of file diff --git a/test/Microsoft.Health.SqlServer.Tests.Integration/Microsoft.Health.SqlServer.Tests.Integration.csproj b/test/Microsoft.Health.SqlServer.Tests.Integration/Microsoft.Health.SqlServer.Tests.Integration.csproj new file mode 100644 index 00000000..b1c48076 --- /dev/null +++ b/test/Microsoft.Health.SqlServer.Tests.Integration/Microsoft.Health.SqlServer.Tests.Integration.csproj @@ -0,0 +1,33 @@ + + + + $(SupportedFrameworks); + false + Microsoft.Health.SqlServer.Tests.Integration + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/Microsoft.Health.SqlServer.Tests.Integration/SqlIntegrationTestBase.cs b/test/Microsoft.Health.SqlServer.Tests.Integration/SqlIntegrationTestBase.cs new file mode 100644 index 00000000..e05fefa2 --- /dev/null +++ b/test/Microsoft.Health.SqlServer.Tests.Integration/SqlIntegrationTestBase.cs @@ -0,0 +1,99 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Microsoft.Health.SqlServer.Configs; +using Microsoft.Health.SqlServer.Features.Schema; +using NSubstitute; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Health.SqlServer.Tests.Integration +{ + public abstract class SqlIntegrationTestBase : IAsyncLifetime + { + public SqlIntegrationTestBase(ITestOutputHelper outputHelper) + { + Output = outputHelper; + DatabaseName = $"IntegrationTests_BaseSchemaRunner_{Guid.NewGuid().ToString().Replace("-", string.Empty)}"; + Config = new SqlServerDataStoreConfiguration + { + ConnectionString = Environment.GetEnvironmentVariable("TestSqlConnectionString") ?? $"server=(local);Initial Catalog={DatabaseName};Integrated Security=true", + AllowDatabaseCreation = true, + }; + + ConnectionStringProvider = Substitute.For(); + ConnectionStringProvider.GetSqlConnectionString(Arg.Any()).ReturnsForAnyArgs(Config.ConnectionString); + } + + protected string DatabaseName { get; set; } + + protected ISqlConnectionStringProvider ConnectionStringProvider { get; set; } + + protected ITestOutputHelper Output { get; set; } + + protected SqlConnection Connection { get; set; } + + protected SqlServerDataStoreConfiguration Config { get; set; } + + public virtual async Task InitializeAsync() + { + SqlConnectionStringBuilder connectionBuilder = new SqlConnectionStringBuilder(Config.ConnectionString); + connectionBuilder.InitialCatalog = "master"; + Connection = new SqlConnection(connectionBuilder.ToString()); + await Connection.OpenAsync(); + await SchemaInitializer.CreateDatabaseAsync(Connection, DatabaseName, CancellationToken.None); + await Connection.ChangeDatabaseAsync(DatabaseName); + Output.WriteLine($"Using database '{DatabaseName}'."); + } + + public virtual async Task DisposeAsync() + { + await Connection.ChangeDatabaseAsync("master"); + try + { + await DeleteDatabaseAsync(DatabaseName); + } + catch (Exception e) + { + Output.WriteLine($"Failed to delete test database after test run: {e.Message}{Environment.NewLine}{Environment.NewLine}{e.StackTrace}"); + throw; + } + + await Connection.CloseAsync(); + await Connection.DisposeAsync(); + } + + protected async Task DeleteDatabaseAsync(string dbName) + { + using (var deleteDatabaseCommand = new SqlCommand($"ALTER DATABASE {dbName} SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE {dbName};", Connection)) + { + if (Connection.Database == dbName) + { + Output.WriteLine($"Switching from '{dbName}' to master prior to delete."); + await Connection.ChangeDatabaseAsync("master", CancellationToken.None); + } + + int result = await deleteDatabaseCommand.ExecuteNonQueryAsync(CancellationToken.None); + if (result != -1) + { + Output.WriteLine($"Clean up of {dbName} failed with result code {result}."); + Assert.False(true); + } + } + } + + protected async Task GetSqlConnection() + { + SqlConnectionStringBuilder connectionBuilder = new SqlConnectionStringBuilder(Config.ConnectionString); + var result = new SqlConnection(connectionBuilder.ToString()); + await result.OpenAsync(); + return result; + } + } +} diff --git a/test/Microsoft.Health.SqlServer.Web/Microsoft.Health.SqlServer.Web.csproj b/test/Microsoft.Health.SqlServer.Web/Microsoft.Health.SqlServer.Web.csproj index 744b3a61..3b56a92f 100644 --- a/test/Microsoft.Health.SqlServer.Web/Microsoft.Health.SqlServer.Web.csproj +++ b/test/Microsoft.Health.SqlServer.Web/Microsoft.Health.SqlServer.Web.csproj @@ -19,14 +19,14 @@ + - - - - + + + diff --git a/test/SchemaManager.Core.UnitTests/SchemaManager.Core.UnitTests.csproj b/test/SchemaManager.Core.UnitTests/SchemaManager.Core.UnitTests.csproj new file mode 100644 index 00000000..adfd829b --- /dev/null +++ b/test/SchemaManager.Core.UnitTests/SchemaManager.Core.UnitTests.csproj @@ -0,0 +1,23 @@ + + + + netcoreapp3.1 + + false + + Microsoft.Health.SchemaManager.Core.UnitTests + + + + + + + + + + + + + + + diff --git a/test/SchemaManager.Core.UnitTests/SqlSchemaManagerTests.cs b/test/SchemaManager.Core.UnitTests/SqlSchemaManagerTests.cs new file mode 100644 index 00000000..1bb69e5c --- /dev/null +++ b/test/SchemaManager.Core.UnitTests/SqlSchemaManagerTests.cs @@ -0,0 +1,105 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Health.SqlServer.Configs; +using Microsoft.Health.SqlServer.Features.Schema.Manager; +using Microsoft.Health.SqlServer.Features.Schema.Manager.Model; +using NSubstitute; +using SchemaManager.Core.Model; +using Xunit; + +namespace SchemaManager.Core.UnitTests +{ + public class SqlSchemaManagerTests + { + private readonly SqlSchemaManager _sqlSchemaManager; + private readonly SqlServerDataStoreConfiguration _configuration; + private readonly ISchemaManagerDataStore _schemaManagerDataStore = Substitute.For(); + private readonly ISchemaClient _client = Substitute.For(); + private readonly IBaseSchemaRunner _baseSchemaRunner = Substitute.For(); + + public SqlSchemaManagerTests() + { + _configuration = new SqlServerDataStoreConfiguration + { + ConnectionString = string.Empty, + }; + + _baseSchemaRunner.EnsureBaseSchemaExistsAsync(default).ReturnsForAnyArgs(Task.FromResult(true)); + _baseSchemaRunner.EnsureInstanceSchemaRecordExistsAsync(default).ReturnsForAnyArgs(Task.FromResult(true)); + _sqlSchemaManager = new SqlSchemaManager(_configuration, _baseSchemaRunner, _schemaManagerDataStore, _client, NullLogger.Instance); + } + + [Fact] + public async Task GetCurrentSchema_OneSchema_Succeeds() + { + _client.GetCurrentVersionInformationAsync(Arg.Any()).ReturnsForAnyArgs(new List { new CurrentVersion(1, "Complete", new List { "server1" }) }); + + var current = await _sqlSchemaManager.GetCurrentSchema("connectionString", new Uri("https://localhost/")); + + Assert.NotNull(current); + Assert.Single(current); + Assert.Equal(1, current[0].Id); + await _baseSchemaRunner.ReceivedWithAnyArgs().EnsureBaseSchemaExistsAsync(default); + await _baseSchemaRunner.ReceivedWithAnyArgs().EnsureInstanceSchemaRecordExistsAsync(default); + } + + [Fact] + public async Task GetCurrentSchema_EmptyList_Succeeds() + { + _client.GetCurrentVersionInformationAsync(Arg.Any()).ReturnsForAnyArgs(new List { }); + + var current = await _sqlSchemaManager.GetCurrentSchema("connectionString", new Uri("https://localhost/")); + + Assert.NotNull(current); + Assert.Empty(current); + await _baseSchemaRunner.ReceivedWithAnyArgs().EnsureBaseSchemaExistsAsync(default); + await _baseSchemaRunner.ReceivedWithAnyArgs().EnsureInstanceSchemaRecordExistsAsync(default); + } + + [Fact] + public async Task GetAvailableSchema_SingleList_Succeeds() + { + _client.GetAvailabilityAsync(Arg.Any()).ReturnsForAnyArgs(new List { new AvailableVersion(1, "_script/1.sql", "_script/1.diff.sql") }); + var available = await _sqlSchemaManager.GetAvailableSchema(new Uri("https://localhost/")); + + Assert.NotNull(available); + Assert.Single(available); + Assert.Equal(1, available[0].Id); + Assert.Equal("_script/1.sql", available[0].ScriptUri); + Assert.Equal("_script/1.diff.sql", available[0].DiffUri); + } + + [Fact] + public async Task GetAvailableSchema_ContainsVersionZero_RemovesZero() + { + _client.GetAvailabilityAsync(Arg.Any()).ReturnsForAnyArgs(new List { new AvailableVersion(0, "_script/0.sql", "_script/0.diff.sql"), new AvailableVersion(1, "_script/1.sql", "_script/1.diff.sql") }); + var available = await _sqlSchemaManager.GetAvailableSchema(new Uri("https://localhost/")); + + Assert.NotNull(available); + Assert.Single(available); + Assert.Equal(1, available[0].Id); + Assert.Equal("_script/1.sql", available[0].ScriptUri); + Assert.Equal("_script/1.diff.sql", available[0].DiffUri); + } + + [Fact] + public async Task ApplySchema_UsingDiffScript_Succeeds() + { + _schemaManagerDataStore.GetCurrentSchemaVersionAsync(default).ReturnsForAnyArgs(Task.FromResult(1)); + _client.GetCurrentVersionInformationAsync(Arg.Any()).ReturnsForAnyArgs(new List { }); + _client.GetAvailabilityAsync(Arg.Any()).ReturnsForAnyArgs(new List { new AvailableVersion(1, "_script/1.sql", "_script/1.diff.sql"), new AvailableVersion(2, "_script/2.sql", "_script/2.diff.sql") }); + _client.GetCompatibilityAsync(Arg.Any()).ReturnsForAnyArgs(new CompatibleVersion(1, 2)); + _client.GetDiffScriptAsync(Arg.Is(new Uri("_script/2.diff.sql", UriKind.Relative)), Arg.Any()).Returns("script"); + await _sqlSchemaManager.ApplySchema("connectionString", new Uri("https://localhost/"), new MutuallyExclusiveType { Latest = false, Version = 2, Next = false }); + await _schemaManagerDataStore.Received().ExecuteScriptAndCompleteSchemaVersionAsync(Arg.Is("script"), Arg.Is(2), Arg.Any()); + } + } +} diff --git a/tools/SchemaManager.Core/SchemaManager.Core.csproj b/tools/SchemaManager.Core/SchemaManager.Core.csproj index c5df991c..d30e514c 100644 --- a/tools/SchemaManager.Core/SchemaManager.Core.csproj +++ b/tools/SchemaManager.Core/SchemaManager.Core.csproj @@ -2,7 +2,7 @@ $(SupportedFrameworks); - SchemaManager.Core + Microsoft.Health.SchemaManager.Core Library diff --git a/tools/SchemaManager.Core/SqlSchemaManager.cs b/tools/SchemaManager.Core/SqlSchemaManager.cs index 864786ef..9aebeb59 100644 --- a/tools/SchemaManager.Core/SqlSchemaManager.cs +++ b/tools/SchemaManager.Core/SqlSchemaManager.cs @@ -26,7 +26,7 @@ namespace SchemaManager.Core public class SqlSchemaManager : ISchemaManager { private SqlServerDataStoreConfiguration _sqlServerDataStoreConfiguration; - private BaseSchemaRunner _baseSchemaRunner; + private IBaseSchemaRunner _baseSchemaRunner; private ISchemaManagerDataStore _schemaManagerDataStore; private ISchemaClient _schemaClient; private ILogger _logger; @@ -36,7 +36,7 @@ public class SqlSchemaManager : ISchemaManager public SqlSchemaManager( SqlServerDataStoreConfiguration sqlServerDataStoreConfiguration, - BaseSchemaRunner baseSchemaRunner, + IBaseSchemaRunner baseSchemaRunner, ISchemaManagerDataStore schemaManagerDataStore, ISchemaClient schemaClient, ILogger logger)