diff --git a/.gitignore b/.gitignore index dfcfd56f..6edefb35 100644 --- a/.gitignore +++ b/.gitignore @@ -113,8 +113,6 @@ ipch/ *.vspx *.sap -# Visual Studio Trace Files -*.e2e # TFS 2012 Local Workspace $tf/ diff --git a/src/Microsoft.Health.SqlServer.Api.UnitTests/Controllers/SchemaControllerTests.cs b/src/Microsoft.Health.SqlServer.Api.UnitTests/Controllers/SchemaControllerTests.cs index eac1cff6..5a6f2bdb 100644 --- a/src/Microsoft.Health.SqlServer.Api.UnitTests/Controllers/SchemaControllerTests.cs +++ b/src/Microsoft.Health.SqlServer.Api.UnitTests/Controllers/SchemaControllerTests.cs @@ -4,6 +4,7 @@ // ------------------------------------------------------------------------------------------------- using System; +using MediatR; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.Extensions.Logging.Abstractions; @@ -19,10 +20,12 @@ public class SchemaControllerTests { private readonly SchemaController _schemaController; private readonly SchemaInformation _schemaInformation; + private readonly IMediator _mediator; public SchemaControllerTests() { _schemaInformation = new SchemaInformation((int)TestSchemaVersion.Version1, (int)TestSchemaVersion.Version3); + _mediator = Substitute.For(); var urlHelperFactory = Substitute.For(); var urlHelper = Substitute.For(); @@ -31,7 +34,7 @@ public SchemaControllerTests() var scriptProvider = Substitute.For(); - _schemaController = new SchemaController(_schemaInformation, scriptProvider, urlHelperFactory, NullLogger.Instance); + _schemaController = new SchemaController(_schemaInformation, scriptProvider, urlHelperFactory, _mediator, NullLogger.Instance); } [Fact] @@ -56,6 +59,15 @@ public void GivenAnAvailableVersionsRequest_WhenCurrentVersionIsNull_ThenAllVers JToken firstResult = jArrayResult.First; Assert.Equal(1, firstResult["id"]); Assert.Equal("https://localhost/script", firstResult["script"]); + + // Ensure available versions are in the ascending order + jArrayResult.RemoveAt(0); + var previousId = (int)firstResult["id"]; + foreach (JToken item in jArrayResult) + { + var currentId = (int)item["id"]; + Assert.True(previousId < currentId, "The available versions are not in the ascending order"); + } } [Fact] @@ -74,17 +86,5 @@ public void GivenAnAvailableVersionsRequest_WhenCurrentVersionNotNull_ThenCorrec Assert.Equal(2, firstResult["id"]); Assert.Equal("https://localhost/script", firstResult["script"]); } - - [Fact] - public void GivenACurrentVersionRequest_WhenNotImplemented_ThenNotImplementedShouldBeThrown() - { - Assert.Throws(() => _schemaController.CurrentVersion()); - } - - [Fact] - public void GivenACompatibilityRequest_WhenNotImplemented_ThenNotImplementedShouldBeThrown() - { - Assert.Throws(() => _schemaController.Compatibility()); - } } } diff --git a/src/Microsoft.Health.SqlServer.Api.UnitTests/Features/CompatibilityVersionHandlerTests.cs b/src/Microsoft.Health.SqlServer.Api.UnitTests/Features/CompatibilityVersionHandlerTests.cs new file mode 100644 index 00000000..7314c047 --- /dev/null +++ b/src/Microsoft.Health.SqlServer.Api.UnitTests/Features/CompatibilityVersionHandlerTests.cs @@ -0,0 +1,50 @@ +// ------------------------------------------------------------------------------------------------- +// 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.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Health.Extensions.DependencyInjection; +using Microsoft.Health.SqlServer.Api.Features; +using Microsoft.Health.SqlServer.Features.Schema; +using Microsoft.Health.SqlServer.Features.Schema.Extensions; +using Microsoft.Health.SqlServer.Features.Schema.Messages.Get; +using Microsoft.Health.SqlServer.Features.Schema.Model; +using NSubstitute; +using Xunit; + +namespace Microsoft.Health.SqlServer.Api.UnitTests.Features +{ + public class CompatibilityVersionHandlerTests + { + private readonly ISchemaDataStore _schemaMigrationDataStore; + private readonly IMediator _mediator; + private readonly CancellationToken _cancellationToken; + + public CompatibilityVersionHandlerTests() + { + _schemaMigrationDataStore = Substitute.For(); + var collection = new ServiceCollection(); + collection.Add(sp => new CompatibilityVersionHandler(_schemaMigrationDataStore)).Singleton().AsSelf().AsImplementedInterfaces(); + + ServiceProvider provider = collection.BuildServiceProvider(); + _mediator = new Mediator(type => provider.GetService(type)); + _cancellationToken = new CancellationTokenSource().Token; + } + + [Fact] + public async Task GivenAMediator_WhenCompatibleRequest_ThenReturnsCompatibleVersions() + { + _schemaMigrationDataStore.GetLatestCompatibleVersionsAsync(Arg.Any()) + .Returns(new CompatibleVersions(1, 3)); + GetCompatibilityVersionResponse response = await _mediator.GetCompatibleVersionAsync(_cancellationToken); + + Assert.Equal(1, response.CompatibleVersions.Min); + Assert.Equal(3, response.CompatibleVersions.Max); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Health.SqlServer.Api.UnitTests/Features/CurrentVersionHandlerTests.cs b/src/Microsoft.Health.SqlServer.Api.UnitTests/Features/CurrentVersionHandlerTests.cs new file mode 100644 index 00000000..f070ef8e --- /dev/null +++ b/src/Microsoft.Health.SqlServer.Api.UnitTests/Features/CurrentVersionHandlerTests.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; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Health.Extensions.DependencyInjection; +using Microsoft.Health.SqlServer.Api.Features; +using Microsoft.Health.SqlServer.Features.Schema; +using Microsoft.Health.SqlServer.Features.Schema.Extensions; +using Microsoft.Health.SqlServer.Features.Schema.Messages.Get; +using Microsoft.Health.SqlServer.Features.Schema.Model; +using NSubstitute; +using Xunit; + +namespace Microsoft.Health.SqlServer.Api.UnitTests.Features +{ + public class CurrentVersionHandlerTests + { + private readonly ISchemaDataStore _schemaDataStore; + private readonly IMediator _mediator; + private readonly CancellationToken _cancellationToken; + + public CurrentVersionHandlerTests() + { + _schemaDataStore = Substitute.For(); + var collection = new ServiceCollection(); + collection.Add(sp => new CurrentVersionHandler(_schemaDataStore)).Singleton().AsSelf().AsImplementedInterfaces(); + + ServiceProvider provider = collection.BuildServiceProvider(); + _mediator = new Mediator(type => provider.GetService(type)); + _cancellationToken = new CancellationTokenSource().Token; + } + + [Fact] + public async Task GivenACurrentMediator_WhenCurrentRequest_ThenReturnsCurrentVersionInformation() + { + string status = "completed"; + + var mockCurrentVersions = new List() + { + new CurrentVersionInformation(1, (SchemaVersionStatus)Enum.Parse(typeof(SchemaVersionStatus), status, true), new List() { "server1", "server2" }), + new CurrentVersionInformation(2, (SchemaVersionStatus)Enum.Parse(typeof(SchemaVersionStatus), status, true), new List()), + }; + + _schemaDataStore.GetCurrentVersionAsync(Arg.Any()) + .Returns(mockCurrentVersions); + GetCurrentVersionResponse response = await _mediator.GetCurrentVersionAsync(_cancellationToken); + var currentVersionsResponse = response.CurrentVersions; + + Assert.Equal(mockCurrentVersions.Count, currentVersionsResponse.Count); + Assert.Equal(1, currentVersionsResponse[0].Id); + Assert.Equal(SchemaVersionStatus.Completed, currentVersionsResponse[0].Status); + Assert.Equal(2, currentVersionsResponse[0].Servers.Count); + } + + [Fact] + public async Task GivenACurrentMediator_WhenCurrentRequestAndEmptySchemaVersionTable_ThenReturnsEmptyArray() + { + var mockCurrentVersions = new List(); + + _schemaDataStore.GetCurrentVersionAsync(Arg.Any()) + .Returns(mockCurrentVersions); + + GetCurrentVersionResponse response = await _mediator.GetCurrentVersionAsync(_cancellationToken); + + Assert.Equal(0, response.CurrentVersions.Count); + } + } +} diff --git a/src/Microsoft.Health.SqlServer.Api.UnitTests/Features/Filters/HttpExceptionFilterTests.cs b/src/Microsoft.Health.SqlServer.Api.UnitTests/Features/Filters/HttpExceptionFilterTests.cs index 9e867823..c6130901 100644 --- a/src/Microsoft.Health.SqlServer.Api.UnitTests/Features/Filters/HttpExceptionFilterTests.cs +++ b/src/Microsoft.Health.SqlServer.Api.UnitTests/Features/Filters/HttpExceptionFilterTests.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.IO; using System.Net; +using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; @@ -17,6 +18,7 @@ using Microsoft.Health.SqlServer.Api.Controllers; using Microsoft.Health.SqlServer.Api.Features.Filters; using Microsoft.Health.SqlServer.Api.UnitTests.Controllers; +using Microsoft.Health.SqlServer.Features.Exceptions; using Microsoft.Health.SqlServer.Features.Schema; using NSubstitute; using Xunit; @@ -36,6 +38,7 @@ public HttpExceptionFilterTests() new SchemaInformation((int)TestSchemaVersion.Version1, (int)TestSchemaVersion.Version3), Substitute.For(), Substitute.For(), + Substitute.For(), NullLogger.Instance)); } @@ -68,5 +71,35 @@ public void GivenANotFoundException_WhenExecutingAnAction_ThenTheResponseShouldB Assert.NotNull(result); Assert.Equal((int)HttpStatusCode.NotFound, result.StatusCode); } + + [Fact] + public void GivenASqlRecordNotFoundException_WhenExecutingAnAction_ThenTheResponseShouldBeAJsonResultWithNotFoundStatusCode() + { + var filter = new HttpExceptionFilterAttribute(); + + _context.Exception = Substitute.For("SQL record not found"); + + filter.OnActionExecuted(_context); + + var result = _context.Result as JsonResult; + + Assert.NotNull(result); + Assert.Equal((int)HttpStatusCode.NotFound, result.StatusCode); + } + + [Fact] + public void GivenASqlOperationFailedException_WhenExecutingAnAction_ThenTheResponseShouldBeAJsonResultWithInternalServerErrorAsStatusCode() + { + var filter = new HttpExceptionFilterAttribute(); + + _context.Exception = Substitute.For("SQL operation failed"); + + filter.OnActionExecuted(_context); + + var result = _context.Result as JsonResult; + + Assert.NotNull(result); + Assert.Equal((int)HttpStatusCode.InternalServerError, result.StatusCode); + } } } diff --git a/src/Microsoft.Health.SqlServer.Api/Controllers/SchemaController.cs b/src/Microsoft.Health.SqlServer.Api/Controllers/SchemaController.cs index da0e8402..946678f8 100644 --- a/src/Microsoft.Health.SqlServer.Api/Controllers/SchemaController.cs +++ b/src/Microsoft.Health.SqlServer.Api/Controllers/SchemaController.cs @@ -3,9 +3,10 @@ // Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. // ------------------------------------------------------------------------------------------------- -using System; using System.Collections.Generic; +using System.Threading.Tasks; using EnsureThat; +using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; @@ -13,6 +14,7 @@ using Microsoft.Health.SqlServer.Api.Features.Filters; using Microsoft.Health.SqlServer.Api.Features.Routing; using Microsoft.Health.SqlServer.Features.Schema; +using Microsoft.Health.SqlServer.Features.Schema.Extensions; namespace Microsoft.Health.SqlServer.Api.Controllers { @@ -24,17 +26,20 @@ public class SchemaController : Controller private readonly IScriptProvider _scriptProvider; private readonly IUrlHelperFactory _urlHelperFactory; private readonly ILogger _logger; + private readonly IMediator _mediator; - public SchemaController(SchemaInformation schemaInformation, IScriptProvider scriptProvider, IUrlHelperFactory urlHelperFactoryFactory, ILogger logger) + public SchemaController(SchemaInformation schemaInformation, IScriptProvider scriptProvider, IUrlHelperFactory urlHelperFactoryFactory, IMediator mediator, ILogger logger) { EnsureArg.IsNotNull(schemaInformation, nameof(schemaInformation)); EnsureArg.IsNotNull(scriptProvider, nameof(scriptProvider)); EnsureArg.IsNotNull(urlHelperFactoryFactory, nameof(urlHelperFactoryFactory)); + EnsureArg.IsNotNull(mediator, nameof(mediator)); EnsureArg.IsNotNull(logger, nameof(logger)); _schemaInformation = schemaInformation; _scriptProvider = scriptProvider; _urlHelperFactory = urlHelperFactoryFactory; + _mediator = mediator; _logger = logger; } @@ -61,11 +66,11 @@ public ActionResult AvailableVersions() [HttpGet] [AllowAnonymous] [Route(KnownRoutes.Current)] - public ActionResult CurrentVersion() + public async Task CurrentVersionAsync() { _logger.LogInformation("Attempting to get current schemas"); - - throw new NotImplementedException(Resources.CurrentVersionNotImplemented); + var currentVersionResponse = await _mediator.GetCurrentVersionAsync(HttpContext.RequestAborted); + return new JsonResult(currentVersionResponse.CurrentVersions); } [HttpGet] @@ -81,11 +86,11 @@ public FileContentResult SqlScript(int id) [HttpGet] [AllowAnonymous] [Route(KnownRoutes.Compatibility)] - public ActionResult Compatibility() + public async Task CompatibilityAsync() { _logger.LogInformation("Attempting to get compatibility"); - - throw new NotImplementedException(Resources.CompatibilityNotImplemented); + var compatibleResponse = await _mediator.GetCompatibleVersionAsync(HttpContext.RequestAborted); + return new JsonResult(compatibleResponse.CompatibleVersions); } } } diff --git a/src/Microsoft.Health.SqlServer.Api/Features/CompatibilityVersionHandler.cs b/src/Microsoft.Health.SqlServer.Api/Features/CompatibilityVersionHandler.cs new file mode 100644 index 00000000..ed402ab5 --- /dev/null +++ b/src/Microsoft.Health.SqlServer.Api/Features/CompatibilityVersionHandler.cs @@ -0,0 +1,35 @@ +// ------------------------------------------------------------------------------------------------- +// 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 EnsureThat; +using MediatR; +using Microsoft.Health.SqlServer.Features.Schema; +using Microsoft.Health.SqlServer.Features.Schema.Messages.Get; +using Microsoft.Health.SqlServer.Features.Schema.Model; + +namespace Microsoft.Health.SqlServer.Api.Features +{ + public class CompatibilityVersionHandler : IRequestHandler + { + private readonly ISchemaDataStore _schemaDataStore; + + public CompatibilityVersionHandler(ISchemaDataStore schemaDataStore) + { + EnsureArg.IsNotNull(schemaDataStore, nameof(schemaDataStore)); + _schemaDataStore = schemaDataStore; + } + + public async Task Handle(GetCompatibilityVersionRequest request, CancellationToken cancellationToken) + { + EnsureArg.IsNotNull(request, nameof(request)); + + CompatibleVersions compatibleVersions = await _schemaDataStore.GetLatestCompatibleVersionsAsync(cancellationToken); + + return new GetCompatibilityVersionResponse(compatibleVersions); + } + } +} diff --git a/src/Microsoft.Health.SqlServer.Api/Features/CurrentVersionHandler.cs b/src/Microsoft.Health.SqlServer.Api/Features/CurrentVersionHandler.cs new file mode 100644 index 00000000..36ebb6c3 --- /dev/null +++ b/src/Microsoft.Health.SqlServer.Api/Features/CurrentVersionHandler.cs @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using EnsureThat; +using MediatR; +using Microsoft.Health.SqlServer.Features.Schema; +using Microsoft.Health.SqlServer.Features.Schema.Messages.Get; +using Microsoft.Health.SqlServer.Features.Schema.Model; + +namespace Microsoft.Health.SqlServer.Api.Features +{ + public class CurrentVersionHandler : IRequestHandler + { + private readonly ISchemaDataStore _schemaDataStore; + + public CurrentVersionHandler(ISchemaDataStore schemaMigrationDataStore) + { + EnsureArg.IsNotNull(schemaMigrationDataStore, nameof(schemaMigrationDataStore)); + _schemaDataStore = schemaMigrationDataStore; + } + + public async Task Handle(GetCurrentVersionRequest request, CancellationToken cancellationToken) + { + EnsureArg.IsNotNull(request, nameof(request)); + + List currentVersions = await _schemaDataStore.GetCurrentVersionAsync(cancellationToken); + + return new GetCurrentVersionResponse(currentVersions); + } + } +} diff --git a/src/Microsoft.Health.SqlServer.Api/Features/Filters/HttpExceptionFilterAttribute.cs b/src/Microsoft.Health.SqlServer.Api/Features/Filters/HttpExceptionFilterAttribute.cs index f7cf51ab..10c9a716 100644 --- a/src/Microsoft.Health.SqlServer.Api/Features/Filters/HttpExceptionFilterAttribute.cs +++ b/src/Microsoft.Health.SqlServer.Api/Features/Filters/HttpExceptionFilterAttribute.cs @@ -9,6 +9,7 @@ using EnsureThat; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Health.SqlServer.Features.Exceptions; using Newtonsoft.Json.Linq; namespace Microsoft.Health.SqlServer.Api.Features.Filters @@ -35,10 +36,16 @@ public override void OnActionExecuted(ActionExecutedContext context) context.ExceptionHandled = true; break; + case SqlRecordNotFoundException _: case FileNotFoundException _: context.Result = new JsonResult(resultJson) { StatusCode = (int)HttpStatusCode.NotFound }; context.ExceptionHandled = true; break; + + case SqlOperationFailedException _: + context.Result = new JsonResult(resultJson) { StatusCode = (int)HttpStatusCode.InternalServerError }; + context.ExceptionHandled = true; + break; } } } diff --git a/src/Microsoft.Health.SqlServer.Api/Microsoft.Health.SqlServer.Api.csproj b/src/Microsoft.Health.SqlServer.Api/Microsoft.Health.SqlServer.Api.csproj index 97c9cc96..476d61df 100644 --- a/src/Microsoft.Health.SqlServer.Api/Microsoft.Health.SqlServer.Api.csproj +++ b/src/Microsoft.Health.SqlServer.Api/Microsoft.Health.SqlServer.Api.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Microsoft.Health.SqlServer.Api/Registration/SqlServerApiRegistrationExtensions.cs b/src/Microsoft.Health.SqlServer.Api/Registration/SqlServerApiRegistrationExtensions.cs index e01be8e2..3b1ba11f 100644 --- a/src/Microsoft.Health.SqlServer.Api/Registration/SqlServerApiRegistrationExtensions.cs +++ b/src/Microsoft.Health.SqlServer.Api/Registration/SqlServerApiRegistrationExtensions.cs @@ -5,7 +5,10 @@ using EnsureThat; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Health.Extensions.DependencyInjection; using Microsoft.Health.SqlServer.Api.Controllers; +using Microsoft.Health.SqlServer.Api.Features; +using Microsoft.Health.SqlServer.Api.Features.Schema; using Microsoft.Health.SqlServer.Features.Health; namespace Microsoft.Health.SqlServer.Api.Registration @@ -23,6 +26,16 @@ public static IServiceCollection AddSqlServerApi(this IServiceCollection service .AddHealthChecks() .AddCheck(nameof(SqlServerHealthCheck)); + services.Add() + .Transient() + .AsImplementedInterfaces(); + + services.Add() + .Transient() + .AsImplementedInterfaces(); + + services.AddHostedService(); + return services; } } diff --git a/src/Microsoft.Health.SqlServer.Api/Schema/SchemaJobWorkerBackgroundService.cs b/src/Microsoft.Health.SqlServer.Api/Schema/SchemaJobWorkerBackgroundService.cs new file mode 100644 index 00000000..1d96e0ef --- /dev/null +++ b/src/Microsoft.Health.SqlServer.Api/Schema/SchemaJobWorkerBackgroundService.cs @@ -0,0 +1,47 @@ +// ------------------------------------------------------------------------------------------------- +// 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.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using EnsureThat; +using Microsoft.Extensions.Hosting; +using Microsoft.Health.SqlServer.Configs; +using Microsoft.Health.SqlServer.Features.Schema; + +namespace Microsoft.Health.SqlServer.Api.Features.Schema +{ + /// + /// The background service used to host the . + /// + public class SchemaJobWorkerBackgroundService : BackgroundService + { + private readonly string _instanceName; + private readonly SchemaJobWorker _schemaJobWorker; + private readonly SqlServerDataStoreConfiguration _sqlServerDataStoreConfiguration; + private readonly SchemaInformation _schemaInformation; + + public SchemaJobWorkerBackgroundService(SchemaJobWorker schemaJobWorker, SqlServerDataStoreConfiguration sqlServerDataStoreConfiguration, SchemaInformation schemaInformation) + { + EnsureArg.IsNotNull(schemaJobWorker, nameof(schemaJobWorker)); + EnsureArg.IsNotNull(sqlServerDataStoreConfiguration, nameof(sqlServerDataStoreConfiguration)); + EnsureArg.IsNotNull(schemaInformation, nameof(schemaInformation)); + + _schemaJobWorker = schemaJobWorker; + _sqlServerDataStoreConfiguration = sqlServerDataStoreConfiguration; + _schemaInformation = schemaInformation; + _instanceName = Guid.NewGuid() + "-" + Process.GetCurrentProcess().Id.ToString(); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + if (!_sqlServerDataStoreConfiguration.SchemaOptions.AutomaticUpdatesEnabled) + { + await _schemaJobWorker.ExecuteAsync(_schemaInformation, _instanceName, cancellationToken); + } + } + } +} diff --git a/src/Microsoft.Health.SqlServer/Configs/SqlServerDataStoreConfiguration.cs b/src/Microsoft.Health.SqlServer/Configs/SqlServerDataStoreConfiguration.cs index 68388de7..762b5906 100644 --- a/src/Microsoft.Health.SqlServer/Configs/SqlServerDataStoreConfiguration.cs +++ b/src/Microsoft.Health.SqlServer/Configs/SqlServerDataStoreConfiguration.cs @@ -29,5 +29,10 @@ public class SqlServerDataStoreConfiguration /// Configuration for transient fault retry policy. /// public SqlServerTransientFaultRetryPolicyConfiguration TransientFaultRetryPolicy { get; set; } = new SqlServerTransientFaultRetryPolicyConfiguration(); + + /// + /// Updates the schema migration options + /// + public SqlServerSchemaOptions SchemaOptions { get; } = new SqlServerSchemaOptions(); } } diff --git a/src/Microsoft.Health.SqlServer/Configs/SqlServerSchemaOptions.cs b/src/Microsoft.Health.SqlServer/Configs/SqlServerSchemaOptions.cs new file mode 100644 index 00000000..b9cfbce0 --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Configs/SqlServerSchemaOptions.cs @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------------------------------------- +// 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.Configs +{ + public class SqlServerSchemaOptions + { + /// + /// Allows the automatic schema updates to apply + /// + public bool AutomaticUpdatesEnabled { get; set; } + + /// + /// Allows the polling frequency for the schema updates + /// + public int JobPollingFrequencyInSeconds { get; set; } = 60; + + /// + /// Allows the expired instance record to delete + /// + public int InstanceRecordExpirationTimeInMinutes { get; set; } = 2; + } +} diff --git a/src/Microsoft.Health.SqlServer/Features/Exceptions/SqlOperationFailedException.cs b/src/Microsoft.Health.SqlServer/Features/Exceptions/SqlOperationFailedException.cs new file mode 100644 index 00000000..cfd0c705 --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Features/Exceptions/SqlOperationFailedException.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using EnsureThat; +using Microsoft.Health.Abstractions.Exceptions; + +namespace Microsoft.Health.SqlServer.Features.Exceptions +{ + public class SqlOperationFailedException : MicrosoftHealthException + { + public SqlOperationFailedException(string message) + : base(message) + { + EnsureArg.IsNotNullOrWhiteSpace(message, nameof(message)); + } + } +} diff --git a/src/Microsoft.Health.SqlServer/Features/Exceptions/SqlRecordNotFoundException.cs b/src/Microsoft.Health.SqlServer/Features/Exceptions/SqlRecordNotFoundException.cs new file mode 100644 index 00000000..d80b9e58 --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Features/Exceptions/SqlRecordNotFoundException.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using EnsureThat; +using Microsoft.Health.Abstractions.Exceptions; + +namespace Microsoft.Health.SqlServer.Features.Exceptions +{ + public class SqlRecordNotFoundException : MicrosoftHealthException + { + public SqlRecordNotFoundException(string message) + : base(message) + { + EnsureArg.IsNotNullOrWhiteSpace(message, nameof(message)); + } + } +} diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/Extensions/SchemaMediatorExtensions.cs b/src/Microsoft.Health.SqlServer/Features/Schema/Extensions/SchemaMediatorExtensions.cs new file mode 100644 index 00000000..3049dbc3 --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Features/Schema/Extensions/SchemaMediatorExtensions.cs @@ -0,0 +1,35 @@ +// ------------------------------------------------------------------------------------------------- +// 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 EnsureThat; +using MediatR; +using Microsoft.Health.SqlServer.Features.Schema.Messages.Get; + +namespace Microsoft.Health.SqlServer.Features.Schema.Extensions +{ + public static class SchemaMediatorExtensions + { + public static async Task GetCompatibleVersionAsync(this IMediator mediator, CancellationToken cancellationToken) + { + EnsureArg.IsNotNull(mediator, nameof(mediator)); + + var request = new GetCompatibilityVersionRequest(); + + return await mediator.Send(request, cancellationToken); + } + + public static async Task GetCurrentVersionAsync(this IMediator mediator, CancellationToken cancellationToken) + { + EnsureArg.IsNotNull(mediator, nameof(mediator)); + + var request = new GetCurrentVersionRequest(); + + GetCurrentVersionResponse response = await mediator.Send(request, cancellationToken); + return response; + } + } +} diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/ISchemaDataStore.cs b/src/Microsoft.Health.SqlServer/Features/Schema/ISchemaDataStore.cs new file mode 100644 index 00000000..8046d1e6 --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Features/Schema/ISchemaDataStore.cs @@ -0,0 +1,45 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Health.SqlServer.Features.Schema.Model; + +namespace Microsoft.Health.SqlServer.Features.Schema +{ + public interface ISchemaDataStore + { + /// + /// Get compatible version. + /// + /// The cancellation token. + /// The latest supported schema version from server. + Task GetLatestCompatibleVersionsAsync(CancellationToken cancellationToken); + + /// + /// Get current version information. + /// + /// The cancellation token. + /// The current schema versions information + Task> GetCurrentVersionAsync(CancellationToken cancellationToken); + + /// + /// Delete expired instance schema information. + /// + /// The cancellation token. + /// A task + Task DeleteExpiredInstanceSchemaAsync(CancellationToken cancellationToken); + + /// + /// Upsert current version information for the named instance. + /// + /// The instance name. + /// The Schema information + /// The cancellation token. + /// Returns current version + Task UpsertInstanceSchemaInformationAsync(string name, SchemaInformation schemaInformation, CancellationToken cancellationToken); + } +} diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/Messages/Get/GetCompatibilityVersionRequest.cs b/src/Microsoft.Health.SqlServer/Features/Schema/Messages/Get/GetCompatibilityVersionRequest.cs new file mode 100644 index 00000000..478bc054 --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Features/Schema/Messages/Get/GetCompatibilityVersionRequest.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. +// ------------------------------------------------------------------------------------------------- + +using MediatR; + +namespace Microsoft.Health.SqlServer.Features.Schema.Messages.Get +{ + public class GetCompatibilityVersionRequest : IRequest + { + } +} diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/Messages/Get/GetCompatibilityVersionResponse.cs b/src/Microsoft.Health.SqlServer/Features/Schema/Messages/Get/GetCompatibilityVersionResponse.cs new file mode 100644 index 00000000..6d2170f6 --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Features/Schema/Messages/Get/GetCompatibilityVersionResponse.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 EnsureThat; +using Microsoft.Health.SqlServer.Features.Schema.Model; + +namespace Microsoft.Health.SqlServer.Features.Schema.Messages.Get +{ + public class GetCompatibilityVersionResponse + { + public GetCompatibilityVersionResponse(CompatibleVersions versions) + { + EnsureArg.IsNotNull(versions, nameof(versions)); + + CompatibleVersions = versions; + } + + public CompatibleVersions CompatibleVersions { get; } + } +} diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/Messages/Get/GetCurrentVersionRequest.cs b/src/Microsoft.Health.SqlServer/Features/Schema/Messages/Get/GetCurrentVersionRequest.cs new file mode 100644 index 00000000..938c0a34 --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Features/Schema/Messages/Get/GetCurrentVersionRequest.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. +// ------------------------------------------------------------------------------------------------- + +using MediatR; + +namespace Microsoft.Health.SqlServer.Features.Schema.Messages.Get +{ + public class GetCurrentVersionRequest : IRequest + { + } +} diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/Messages/Get/GetCurrentVersionResponse.cs b/src/Microsoft.Health.SqlServer/Features/Schema/Messages/Get/GetCurrentVersionResponse.cs new file mode 100644 index 00000000..d94bcd81 --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Features/Schema/Messages/Get/GetCurrentVersionResponse.cs @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; +using EnsureThat; +using Microsoft.Health.SqlServer.Features.Schema.Model; + +namespace Microsoft.Health.SqlServer.Features.Schema.Messages.Get +{ + public class GetCurrentVersionResponse + { + public GetCurrentVersionResponse(IList currentVersions) + { + EnsureArg.IsNotNull(currentVersions, nameof(currentVersions)); + + CurrentVersions = currentVersions; + } + + public IList CurrentVersions { get; } + } +} diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/Migrations/BaseSchema.sql b/src/Microsoft.Health.SqlServer/Features/Schema/Migrations/BaseSchema.sql new file mode 100644 index 00000000..9f653d52 --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Features/Schema/Migrations/BaseSchema.sql @@ -0,0 +1,179 @@ +-- NOTE: This script is for reference and codegen only. In order to run the schema management, schema used by the service must have this in it. +-- Style guide: please see: https://github.com/ktaranov/sqlserver-kit/blob/master/SQL%20Server%20Name%20Convention%20and%20T-SQL%20Programming%20Style.md + +/************************************************************* + Schema Version +**************************************************************/ +CREATE TABLE dbo.SchemaVersion +( + Version int PRIMARY KEY, + Status varchar(10) +) + +INSERT INTO dbo.SchemaVersion +VALUES + (1, 'started') + +GO + +/************************************************************* + Instance Schema +**************************************************************/ +CREATE TABLE dbo.InstanceSchema +( + Name varchar(64) COLLATE Latin1_General_100_CS_AS NOT NULL, + CurrentVersion int NOT NULL, + MaxVersion int NOT NULL, + MinVersion int NOT NULL, + Timeout datetime2(0) NOT NULL +) + +CREATE UNIQUE CLUSTERED INDEX IXC_InstanceSchema ON dbo.InstanceSchema +( + Name +) + +CREATE NONCLUSTERED INDEX IX_InstanceSchema_Timeout ON dbo.InstanceSchema +( + Timeout +) + +GO + +-- +-- STORED PROCEDURE +-- Gets schema information given its instance name. +-- +-- DESCRIPTION +-- Retrieves the instance schema record from the InstanceSchema table that has the matching name. +-- +-- PARAMETERS +-- @name +-- * The unique name for a particular instance +-- +-- RETURN VALUE +-- The matching record. +-- +CREATE PROCEDURE dbo.GetInstanceSchemaByName + @name varchar(64) +AS + SET NOCOUNT ON + + SELECT CurrentVersion, MaxVersion, MinVersion, Timeout + FROM dbo.InstanceSchema + WHERE Name = @name +GO + +-- +-- STORED PROCEDURE +-- Update an instance schema. +-- +-- DESCRIPTION +-- Modifies an existing record in the InstanceSchema table. +-- +-- PARAMETERS +-- @name +-- * The unique name for a particular instance +-- @maxVersion +-- * The maximum supported schema version for the given instance +-- @minVersion +-- * The minimum supported schema version for the given instance +-- @addMinutesOnTimeout +-- * The minutes to add +-- +CREATE PROCEDURE dbo.UpsertInstanceSchema + @name varchar(64), + @maxVersion int, + @minVersion int, + @addMinutesOnTimeout int + +AS + SET NOCOUNT ON + + DECLARE @timeout datetime2(0) = DATEADD(minute, @addMinutesOnTimeout, SYSUTCDATETIME()) + DECLARE @currentVersion int = (SELECT COALESCE(MAX(Version), 0) + FROM dbo.SchemaVersion + WHERE Status = 'completed' AND Version <= @maxVersion) + IF EXISTS(SELECT * FROM dbo.InstanceSchema + WHERE Name = @name) + BEGIN + UPDATE dbo.InstanceSchema + SET CurrentVersion = @currentVersion, MaxVersion = @maxVersion, Timeout = @timeout + WHERE Name = @name + + SELECT @currentVersion + END + ELSE + BEGIN + INSERT INTO dbo.InstanceSchema + (Name, CurrentVersion, MaxVersion, MinVersion, Timeout) + VALUES + (@name, @currentVersion, @maxVersion, @minVersion, @timeout) + + SELECT @currentVersion + END +GO + +-- +-- STORED PROCEDURE +-- Delete instance schema information. +-- +-- DESCRIPTION +-- Delete all the expired records in the InstanceSchema table. +-- +CREATE PROCEDURE dbo.DeleteInstanceSchema + +AS + SET NOCOUNT ON + + DELETE FROM dbo.InstanceSchema + WHERE Timeout < SYSUTCDATETIME() + +GO + +-- +-- STORED PROCEDURE +-- SelectCompatibleSchemaVersions +-- +-- DESCRIPTION +-- Selects the compatible schema versions +-- +-- RETURNS +-- The maximum and minimum compatible versions +-- +CREATE PROCEDURE dbo.SelectCompatibleSchemaVersions + +AS +BEGIN + SET NOCOUNT ON + + SELECT MAX(MinVersion), MIN(MaxVersion) + FROM dbo.InstanceSchema + WHERE Timeout > SYSUTCDATETIME() +END +GO + +-- +-- STORED PROCEDURE +-- SelectCurrentVersionsInformation +-- +-- DESCRIPTION +-- Selects the current schema versions information +-- +-- RETURNS +-- The current versions, status and server names using that version +-- +CREATE PROCEDURE dbo.SelectCurrentVersionsInformation + +AS +BEGIN + SET NOCOUNT ON + + SELECT SV.Version, SV.Status, STRING_AGG(SCH.NAME, ',') + FROM dbo.SchemaVersion AS SV LEFT OUTER JOIN dbo.InstanceSchema AS SCH + ON SV.Version = SCH.CurrentVersion + GROUP BY Version, Status + +END +GO + diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/Model/CompatibleVersions.cs b/src/Microsoft.Health.SqlServer/Features/Schema/Model/CompatibleVersions.cs new file mode 100644 index 00000000..3f684b57 --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Features/Schema/Model/CompatibleVersions.cs @@ -0,0 +1,24 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using EnsureThat; + +namespace Microsoft.Health.SqlServer.Features.Schema.Model +{ + public class CompatibleVersions + { + public CompatibleVersions(int min, int max) + { + EnsureArg.IsLte(min, max); + + Min = min; + Max = max; + } + + public int Min { get; } + + public int Max { get; } + } +} diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/Model/CurrentVersionInformation.cs b/src/Microsoft.Health.SqlServer/Features/Schema/Model/CurrentVersionInformation.cs new file mode 100644 index 00000000..986e89ea --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Features/Schema/Model/CurrentVersionInformation.cs @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; + +namespace Microsoft.Health.SqlServer.Features.Schema.Model +{ + public class CurrentVersionInformation + { + public CurrentVersionInformation(int id, SchemaVersionStatus status, IList servers) + { + Id = id; + Status = status; + Servers = servers; + } + + public int Id { get; } + + public SchemaVersionStatus Status { get; } + + public IList Servers { get; } + } +} diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/Model/SchemaShared.Generated.cs b/src/Microsoft.Health.SqlServer/Features/Schema/Model/SchemaShared.Generated.cs new file mode 100644 index 00000000..ce937e60 --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Features/Schema/Model/SchemaShared.Generated.cs @@ -0,0 +1,121 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +namespace Microsoft.Health.SqlServer.Features.Schema.Model +{ + using Microsoft.Health.SqlServer.Features.Client; + using Microsoft.Health.SqlServer.Features.Schema.Model; + + internal class SchemaShared + { + internal readonly static InstanceSchemaTable InstanceSchema = new InstanceSchemaTable(); + internal readonly static SchemaVersionTable SchemaVersion = new SchemaVersionTable(); + internal readonly static DeleteInstanceSchemaProcedure DeleteInstanceSchema = new DeleteInstanceSchemaProcedure(); + internal readonly static GetInstanceSchemaByNameProcedure GetInstanceSchemaByName = new GetInstanceSchemaByNameProcedure(); + internal readonly static SelectCompatibleSchemaVersionsProcedure SelectCompatibleSchemaVersions = new SelectCompatibleSchemaVersionsProcedure(); + internal readonly static SelectCurrentVersionsInformationProcedure SelectCurrentVersionsInformation = new SelectCurrentVersionsInformationProcedure(); + internal readonly static UpsertInstanceSchemaProcedure UpsertInstanceSchema = new UpsertInstanceSchemaProcedure(); + internal class InstanceSchemaTable : Table + { + internal InstanceSchemaTable(): base("dbo.InstanceSchema") + { + } + + internal readonly VarCharColumn Name = new VarCharColumn("Name", 64, "Latin1_General_100_CS_AS"); + internal readonly IntColumn CurrentVersion = new IntColumn("CurrentVersion"); + internal readonly IntColumn MaxVersion = new IntColumn("MaxVersion"); + internal readonly IntColumn MinVersion = new IntColumn("MinVersion"); + internal readonly DateTime2Column Timeout = new DateTime2Column("Timeout", 0); + } + + internal class SchemaVersionTable : Table + { + internal SchemaVersionTable(): base("dbo.SchemaVersion") + { + } + + internal readonly IntColumn Version = new IntColumn("Version"); + internal readonly VarCharColumn Status = new VarCharColumn("Status", 10); + } + + internal class DeleteInstanceSchemaProcedure : StoredProcedure + { + internal DeleteInstanceSchemaProcedure(): base("dbo.DeleteInstanceSchema") + { + } + + public void PopulateCommand(SqlCommandWrapper command) + { + command.CommandType = global::System.Data.CommandType.StoredProcedure; + command.CommandText = "dbo.DeleteInstanceSchema"; + } + } + + internal class GetInstanceSchemaByNameProcedure : StoredProcedure + { + internal GetInstanceSchemaByNameProcedure(): base("dbo.GetInstanceSchemaByName") + { + } + + private readonly ParameterDefinition _name = new ParameterDefinition("@name", global::System.Data.SqlDbType.VarChar, false, 64); + public void PopulateCommand(SqlCommandWrapper command, System.String name) + { + command.CommandType = global::System.Data.CommandType.StoredProcedure; + command.CommandText = "dbo.GetInstanceSchemaByName"; + _name.AddParameter(command.Parameters, name); + } + } + + internal class SelectCompatibleSchemaVersionsProcedure : StoredProcedure + { + internal SelectCompatibleSchemaVersionsProcedure(): base("dbo.SelectCompatibleSchemaVersions") + { + } + + public void PopulateCommand(SqlCommandWrapper command) + { + command.CommandType = global::System.Data.CommandType.StoredProcedure; + command.CommandText = "dbo.SelectCompatibleSchemaVersions"; + } + } + + internal class SelectCurrentVersionsInformationProcedure : StoredProcedure + { + internal SelectCurrentVersionsInformationProcedure(): base("dbo.SelectCurrentVersionsInformation") + { + } + + public void PopulateCommand(SqlCommandWrapper command) + { + command.CommandType = global::System.Data.CommandType.StoredProcedure; + command.CommandText = "dbo.SelectCurrentVersionsInformation"; + } + } + + internal class UpsertInstanceSchemaProcedure : StoredProcedure + { + internal UpsertInstanceSchemaProcedure(): base("dbo.UpsertInstanceSchema") + { + } + + private readonly ParameterDefinition _name = new ParameterDefinition("@name", global::System.Data.SqlDbType.VarChar, false, 64); + private readonly ParameterDefinition _maxVersion = new ParameterDefinition("@maxVersion", global::System.Data.SqlDbType.Int, false); + private readonly ParameterDefinition _minVersion = new ParameterDefinition("@minVersion", global::System.Data.SqlDbType.Int, false); + private readonly ParameterDefinition _addMinutesOnTimeout = new ParameterDefinition("@addMinutesOnTimeout", global::System.Data.SqlDbType.Int, false); + public void PopulateCommand(SqlCommandWrapper command, System.String name, System.Int32 maxVersion, System.Int32 minVersion, System.Int32 addMinutesOnTimeout) + { + command.CommandType = global::System.Data.CommandType.StoredProcedure; + command.CommandText = "dbo.UpsertInstanceSchema"; + _name.AddParameter(command.Parameters, name); + _maxVersion.AddParameter(command.Parameters, maxVersion); + _minVersion.AddParameter(command.Parameters, minVersion); + _addMinutesOnTimeout.AddParameter(command.Parameters, addMinutesOnTimeout); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/SchemaInitializer.cs b/src/Microsoft.Health.SqlServer/Features/Schema/SchemaInitializer.cs index 2b70c94d..669688e6 100644 --- a/src/Microsoft.Health.SqlServer/Features/Schema/SchemaInitializer.cs +++ b/src/Microsoft.Health.SqlServer/Features/Schema/SchemaInitializer.cs @@ -66,7 +66,7 @@ public void Initialize(bool forceIncrementalSchemaUpgrade = false) } // If the current schema version needs to be upgraded - if (_schemaInformation.Current < _schemaInformation.MaximumSupportedVersion) + if (_sqlServerDataStoreConfiguration.SchemaOptions.AutomaticUpdatesEnabled && _schemaInformation.Current < _schemaInformation.MaximumSupportedVersion) { // Apply each .diff.sql file one by one. int current = _schemaInformation.Current ?? 0; diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/SchemaJobWorker.cs b/src/Microsoft.Health.SqlServer/Features/Schema/SchemaJobWorker.cs new file mode 100644 index 00000000..5331178c --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Features/Schema/SchemaJobWorker.cs @@ -0,0 +1,79 @@ +// ------------------------------------------------------------------------------------------------- +// 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 EnsureThat; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Health.Core; +using Microsoft.Health.SqlServer.Configs; + +namespace Microsoft.Health.SqlServer.Features.Schema +{ + /// + /// The worker responsible for running the schema job. + /// It inserts the instance schema information. + /// It polls the specified time to update the instance schema information and deletes the expired instance schema information, if any. + /// + public class SchemaJobWorker + { + private readonly IServiceProvider _serviceProvider; + private readonly SqlServerDataStoreConfiguration _sqlServerDataStoreConfiguration; + private readonly ILogger _logger; + + public SchemaJobWorker(IServiceProvider services, SqlServerDataStoreConfiguration sqlServerDataStoreConfiguration, ILogger logger) + { + EnsureArg.IsNotNull(services, nameof(services)); + EnsureArg.IsNotNull(sqlServerDataStoreConfiguration, nameof(sqlServerDataStoreConfiguration)); + EnsureArg.IsNotNull(logger, nameof(logger)); + + _serviceProvider = services; + _sqlServerDataStoreConfiguration = sqlServerDataStoreConfiguration; + _logger = logger; + } + + public async Task ExecuteAsync(SchemaInformation schemaInformation, string instanceName, CancellationToken cancellationToken) + { + _logger.LogInformation($"Polling started at {Clock.UtcNow}"); + + using (var scope = _serviceProvider.CreateScope()) + { + var schemaDataStore = scope.ServiceProvider.GetRequiredService(); + + // Ensure schemaInformation has the latest current version + schemaInformation.Current = await schemaDataStore.UpsertInstanceSchemaInformationAsync(instanceName, schemaInformation, cancellationToken); + } + + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(_sqlServerDataStoreConfiguration.SchemaOptions.JobPollingFrequencyInSeconds), cancellationToken); + + using (var scope = _serviceProvider.CreateScope()) + { + var schemaDataStore = scope.ServiceProvider.GetRequiredService(); + + schemaInformation.Current = await schemaDataStore.UpsertInstanceSchemaInformationAsync(instanceName, schemaInformation, cancellationToken); + + await schemaDataStore.DeleteExpiredInstanceSchemaAsync(cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Cancel requested. + break; + } + catch (Exception ex) + { + // The job failed. + _logger.LogError(ex, "Unhandled exception in the worker."); + } + } + } + } +} diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/SchemaUpgradeRunner.cs b/src/Microsoft.Health.SqlServer/Features/Schema/SchemaUpgradeRunner.cs index da94a95d..2687ab07 100644 --- a/src/Microsoft.Health.SqlServer/Features/Schema/SchemaUpgradeRunner.cs +++ b/src/Microsoft.Health.SqlServer/Features/Schema/SchemaUpgradeRunner.cs @@ -59,7 +59,7 @@ private void InsertSchemaVersion(int schemaVersion) private void CompleteSchemaVersion(int schemaVersion) { - UpsertSchemaVersion(schemaVersion, "complete"); + UpsertSchemaVersion(schemaVersion, "completed"); } private void UpsertSchemaVersion(int schemaVersion, string status) diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/SchemaVersionStatus.cs b/src/Microsoft.Health.SqlServer/Features/Schema/SchemaVersionStatus.cs new file mode 100644 index 00000000..2a3a7843 --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Features/Schema/SchemaVersionStatus.cs @@ -0,0 +1,18 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Microsoft.Health.SqlServer.Features.Schema +{ + [JsonConverter(typeof(StringEnumConverter))] + public enum SchemaVersionStatus + { + Started, + Completed, + Failed, + } +} diff --git a/src/Microsoft.Health.SqlServer/Features/Schema/ScriptProvider.cs b/src/Microsoft.Health.SqlServer/Features/Schema/ScriptProvider.cs index 12355a1a..cdaf5e6e 100644 --- a/src/Microsoft.Health.SqlServer/Features/Schema/ScriptProvider.cs +++ b/src/Microsoft.Health.SqlServer/Features/Schema/ScriptProvider.cs @@ -6,7 +6,6 @@ using System; using System.IO; using System.Reflection; -using EnsureThat; namespace Microsoft.Health.SqlServer.Features.Schema { diff --git a/src/Microsoft.Health.SqlServer/Features/Storage/SqlServerSchemaDataStore.cs b/src/Microsoft.Health.SqlServer/Features/Storage/SqlServerSchemaDataStore.cs new file mode 100644 index 00000000..df36deb9 --- /dev/null +++ b/src/Microsoft.Health.SqlServer/Features/Storage/SqlServerSchemaDataStore.cs @@ -0,0 +1,164 @@ +// ------------------------------------------------------------------------------------------------- +// 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.Data; +using System.Data.SqlClient; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using EnsureThat; +using Microsoft.Extensions.Logging; +using Microsoft.Health.SqlServer.Configs; +using Microsoft.Health.SqlServer.Features.Client; +using Microsoft.Health.SqlServer.Features.Exceptions; +using Microsoft.Health.SqlServer.Features.Schema; +using Microsoft.Health.SqlServer.Features.Schema.Model; + +namespace Microsoft.Health.SqlServer.Features.Storage +{ + internal class SqlServerSchemaDataStore : ISchemaDataStore + { + private readonly SqlConnectionWrapperFactory _sqlConnectionWrapperFactory; + private readonly SqlServerDataStoreConfiguration _configuration; + private readonly ILogger _logger; + + public SqlServerSchemaDataStore( + SqlConnectionWrapperFactory sqlConnectionWrapperFactory, + SqlServerDataStoreConfiguration sqlServerDataStoreConfiguration, + ILogger logger) + { + EnsureArg.IsNotNull(sqlConnectionWrapperFactory, nameof(sqlConnectionWrapperFactory)); + EnsureArg.IsNotNull(sqlServerDataStoreConfiguration, nameof(sqlServerDataStoreConfiguration)); + EnsureArg.IsNotNull(logger, nameof(logger)); + + _sqlConnectionWrapperFactory = sqlConnectionWrapperFactory; + _configuration = sqlServerDataStoreConfiguration; + _logger = logger; + } + + public async Task GetLatestCompatibleVersionsAsync(CancellationToken cancellationToken) + { + CompatibleVersions compatibleVersions; + using (SqlConnectionWrapper sqlConnectionWrapper = _sqlConnectionWrapperFactory.ObtainSqlConnectionWrapper()) + using (SqlCommandWrapper sqlCommandWrapper = sqlConnectionWrapper.CreateSqlCommand()) + { + SchemaShared.SelectCompatibleSchemaVersions.PopulateCommand(sqlCommandWrapper); + + using (var dataReader = await sqlCommandWrapper.ExecuteReaderAsync(cancellationToken)) + { + if (dataReader.Read()) + { + compatibleVersions = new CompatibleVersions(ConvertToInt(dataReader.GetValue(0)), ConvertToInt(dataReader.GetValue(1))); + } + else + { + throw new SqlRecordNotFoundException(Resources.CompatibilityRecordNotFound); + } + } + + return compatibleVersions; + } + + int ConvertToInt(object o) + { + if (o == DBNull.Value) + { + throw new SqlRecordNotFoundException(Resources.CompatibilityRecordNotFound); + } + else + { + return Convert.ToInt32(o); + } + } + } + + public async Task UpsertInstanceSchemaInformationAsync(string name, SchemaInformation schemaInformation, CancellationToken cancellationToken) + { + using (SqlConnectionWrapper sqlConnectionWrapper = _sqlConnectionWrapperFactory.ObtainSqlConnectionWrapper()) + using (SqlCommandWrapper sqlCommandWrapper = sqlConnectionWrapper.CreateSqlCommand()) + { + SchemaShared.UpsertInstanceSchema.PopulateCommand( + sqlCommandWrapper, + name, + schemaInformation.MaximumSupportedVersion, + schemaInformation.MinimumSupportedVersion, + _configuration.SchemaOptions.InstanceRecordExpirationTimeInMinutes); + try + { + return (int)await sqlCommandWrapper.ExecuteScalarAsync(cancellationToken); + } + catch (SqlException e) + { + _logger.LogError(e, "Error from SQL database on upserting InstanceSchema information"); + throw; + } + } + } + + public async Task DeleteExpiredInstanceSchemaAsync(CancellationToken cancellationToken) + { + using (SqlConnectionWrapper sqlConnectionWrapper = _sqlConnectionWrapperFactory.ObtainSqlConnectionWrapper()) + using (SqlCommandWrapper sqlCommandWrapper = sqlConnectionWrapper.CreateSqlCommand()) + { + SchemaShared.DeleteInstanceSchema.PopulateCommand(sqlCommandWrapper); + try + { + await sqlCommandWrapper.ExecuteNonQueryAsync(cancellationToken); + } + catch (SqlException e) + { + _logger.LogError(e, "Error from SQL database on deleting expired InstanceSchema records"); + throw; + } + } + } + + public async Task> GetCurrentVersionAsync(CancellationToken cancellationToken) + { + var currentVersions = new List(); + using (SqlConnectionWrapper sqlConnectionWrapper = _sqlConnectionWrapperFactory.ObtainSqlConnectionWrapper()) + using (SqlCommandWrapper sqlCommandWrapper = sqlConnectionWrapper.CreateSqlCommand()) + { + SchemaShared.SelectCurrentVersionsInformation.PopulateCommand(sqlCommandWrapper); + + try + { + using (var dataReader = await sqlCommandWrapper.ExecuteReaderAsync(cancellationToken)) + { + if (dataReader.HasRows) + { + while (await dataReader.ReadAsync()) + { + IList instanceNames = new List(); + if (!dataReader.IsDBNull(2)) + { + string names = dataReader.GetString(2); + instanceNames = names.Split(",").ToList(); + } + + var status = (SchemaVersionStatus)Enum.Parse(typeof(SchemaVersionStatus), (string)dataReader.GetValue(1), true); + var currentVersion = new CurrentVersionInformation((int)dataReader.GetValue(0), status, instanceNames); + currentVersions.Add(currentVersion); + } + } + else + { + return currentVersions; + } + } + } + catch (SqlException e) + { + _logger.LogError(e, "Error from SQL database on retrieving current version information"); + throw; + } + } + + return currentVersions; + } + } +} diff --git a/src/Microsoft.Health.SqlServer/Microsoft.Health.SqlServer.csproj b/src/Microsoft.Health.SqlServer/Microsoft.Health.SqlServer.csproj index efd6978f..7e4e0b26 100644 --- a/src/Microsoft.Health.SqlServer/Microsoft.Health.SqlServer.csproj +++ b/src/Microsoft.Health.SqlServer/Microsoft.Health.SqlServer.csproj @@ -7,6 +7,8 @@ + + @@ -18,8 +20,17 @@ + + + + + SqlModelGenerator + Microsoft.Health.SqlServer.Features.Schema.Model + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)\Features\Schema\Migrations\BaseSchema.sql')) + + diff --git a/src/Microsoft.Health.SqlServer/Registration/SqlServerBaseRegistrationExtensions.cs b/src/Microsoft.Health.SqlServer/Registration/SqlServerBaseRegistrationExtensions.cs index 23742c2b..f8742de9 100644 --- a/src/Microsoft.Health.SqlServer/Registration/SqlServerBaseRegistrationExtensions.cs +++ b/src/Microsoft.Health.SqlServer/Registration/SqlServerBaseRegistrationExtensions.cs @@ -36,6 +36,15 @@ public static IServiceCollection AddSqlServerBase( .Singleton() .AsSelf(); + services.Add() + .Scoped() + .AsSelf() + .AsImplementedInterfaces(); + + services.Add() + .Singleton() + .AsSelf(); + services.Add() .Singleton() .AsService(); diff --git a/src/Microsoft.Health.SqlServer/Resources.Designer.cs b/src/Microsoft.Health.SqlServer/Resources.Designer.cs index bc59062c..d64384ab 100644 --- a/src/Microsoft.Health.SqlServer/Resources.Designer.cs +++ b/src/Microsoft.Health.SqlServer/Resources.Designer.cs @@ -60,6 +60,24 @@ internal Resources() { } } + /// + /// Looks up a localized string similar to The compatibility information was not found.. + /// + internal static string CompatibilityRecordNotFound { + get { + return ResourceManager.GetString("CompatibilityRecordNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The SQL operation has failed.. + /// + internal static string OperationFailed { + get { + return ResourceManager.GetString("OperationFailed", resourceCulture); + } + } + /// /// Looks up a localized string similar to The provided version is unknown.. /// diff --git a/src/Microsoft.Health.SqlServer/Resources.resx b/src/Microsoft.Health.SqlServer/Resources.resx index 0fc7775e..d6dcfa23 100644 --- a/src/Microsoft.Health.SqlServer/Resources.resx +++ b/src/Microsoft.Health.SqlServer/Resources.resx @@ -117,6 +117,12 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + The compatibility information was not found. + + + The SQL operation has failed. + The provided version is unknown. diff --git a/test/Microsoft.Health.SqlServer.Tests.E2E/Microsoft.Health.SqlServer.Tests.E2E.csproj b/test/Microsoft.Health.SqlServer.Tests.E2E/Microsoft.Health.SqlServer.Tests.E2E.csproj index 43cafb0c..194e506f 100644 --- a/test/Microsoft.Health.SqlServer.Tests.E2E/Microsoft.Health.SqlServer.Tests.E2E.csproj +++ b/test/Microsoft.Health.SqlServer.Tests.E2E/Microsoft.Health.SqlServer.Tests.E2E.csproj @@ -1,4 +1,4 @@ - + netcoreapp3.1 @@ -6,12 +6,15 @@ Microsoft.Health.SqlServer.Tests.E2E - - - - - - + + + + + + + + + diff --git a/test/Microsoft.Health.SqlServer.Tests.E2E/Rest/HttpIntegrationTestFixture.cs b/test/Microsoft.Health.SqlServer.Tests.E2E/Rest/HttpIntegrationTestFixture.cs new file mode 100644 index 00000000..b5c181eb --- /dev/null +++ b/test/Microsoft.Health.SqlServer.Tests.E2E/Rest/HttpIntegrationTestFixture.cs @@ -0,0 +1,153 @@ +// ------------------------------------------------------------------------------------------------- +// 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.IO; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.IO; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Health.SqlServer.Tests.E2E.Rest +{ + public class HttpIntegrationTestFixture : IDisposable + { + private readonly string _environmentUrl; + private readonly HttpMessageHandler _messageHandler; + + public HttpIntegrationTestFixture() + : this(Path.Combine("test")) + { + } + + protected HttpIntegrationTestFixture(string targetProjectParentDirectory) + { + string environmentUrl = Environment.GetEnvironmentVariable("TestEnvironmentUrl"); + + if (string.IsNullOrWhiteSpace(environmentUrl)) + { + environmentUrl = "http://localhost/"; + + StartInMemoryServer(targetProjectParentDirectory); + + _messageHandler = Server.CreateHandler(); + IsUsingInProcTestServer = true; + } + else + { + if (environmentUrl.Last() != '/') + { + environmentUrl = $"{environmentUrl}/"; + } + + _messageHandler = new HttpClientHandler(); + } + + _environmentUrl = environmentUrl; + + HttpClient = CreateHttpClient(); + + RecyclableMemoryStreamManager = new RecyclableMemoryStreamManager(); + + Client = HttpClient; + } + + public bool IsUsingInProcTestServer { get; } + + public HttpClient HttpClient { get; } + + protected TestServer Server { get; private set; } + + public RecyclableMemoryStreamManager RecyclableMemoryStreamManager { get; } + + public HttpClient Client { get; } + + public HttpClient CreateHttpClient() + => new HttpClient(new SessionMessageHandler(_messageHandler)) { BaseAddress = new Uri(_environmentUrl) }; + + public void Dispose() + { + HttpClient.Dispose(); + Server?.Dispose(); + } + + /// + /// Gets the full path to the target project that we wish to test + /// + /// + /// The parent directory of the target project. + /// e.g. src, samples, test, or test/Websites + /// + /// The startup type + /// The full path to the target project. + private static string GetProjectPath(string projectRelativePath, Type startupType) + { + for (Type type = startupType; type != null; type = type.BaseType) + { + // Get name of the target project which we want to test + var projectName = type.GetTypeInfo().Assembly.GetName().Name; + + // Get currently executing test project path + var applicationBasePath = AppContext.BaseDirectory; + + // Find the path to the target project + var directoryInfo = new DirectoryInfo(applicationBasePath); + do + { + directoryInfo = directoryInfo.Parent; + + var projectDirectoryInfo = new DirectoryInfo(Path.Combine(directoryInfo.FullName, projectRelativePath)); + if (projectDirectoryInfo.Exists) + { + var projectFileInfo = new FileInfo(Path.Combine(projectDirectoryInfo.FullName, projectName, $"{projectName}.csproj")); + if (projectFileInfo.Exists) + { + return Path.Combine(projectDirectoryInfo.FullName, projectName); + } + } + } + while (directoryInfo.Parent != null); + } + + throw new Exception($"Project root could not be located for startup type {startupType.FullName}"); + } + + private void StartInMemoryServer(string targetProjectParentDirectory) + { + var contentRoot = GetProjectPath(targetProjectParentDirectory, typeof(TStartup)); + var projectDir = GetProjectPath("test", typeof(TStartup)); + + var launchSettings = JObject.Parse(File.ReadAllText(Path.Combine(projectDir, "Properties", "launchSettings.json"))); + + var configuration = launchSettings["profiles"]["Microsoft.Health.SqlServer.Web"]["environmentVariables"].Cast().ToDictionary(p => p.Name, p => p.Value.ToString()); + + var builder = WebHost.CreateDefaultBuilder() + .UseContentRoot(contentRoot) + .ConfigureAppConfiguration(configurationBuilder => + { + configurationBuilder.AddInMemoryCollection(configuration); + }) + .UseStartup(typeof(TStartup)); + + Server = new TestServer(builder); + } + + /// + /// An that maintains session consistency between requests. + /// + private class SessionMessageHandler : DelegatingHandler + { + public SessionMessageHandler(HttpMessageHandler innerHandler) + : base(innerHandler) + { + } + } + } +} diff --git a/test/Microsoft.Health.SqlServer.Tests.E2E/SchemaTests.cs b/test/Microsoft.Health.SqlServer.Tests.E2E/SchemaTests.cs index 6114f20b..f1a44121 100644 --- a/test/Microsoft.Health.SqlServer.Tests.E2E/SchemaTests.cs +++ b/test/Microsoft.Health.SqlServer.Tests.E2E/SchemaTests.cs @@ -9,22 +9,23 @@ using System.Net.Http; using System.Text; using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Health.SqlServer.Features.Schema; +using Microsoft.Health.SqlServer.Features.Schema.Model; +using Microsoft.Health.SqlServer.Tests.E2E.Rest; using Microsoft.Health.SqlServer.Web; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Xunit; namespace Microsoft.Health.SqlServer.Tests.E2E { - public class SchemaTests : IClassFixture> + public class SchemaTests : IClassFixture> { - private readonly WebApplicationFactory _factory; private readonly HttpClient _client; - public SchemaTests(WebApplicationFactory factory) + public SchemaTests(HttpIntegrationTestFixture fixture) { - _factory = factory; - _client = factory.CreateClient(); + _client = fixture.HttpClient; } public static IEnumerable Data => @@ -56,11 +57,35 @@ public async Task GivenAServerThatHasSchemas_WhenRequestingAvailable_JsonShouldB Assert.Equal(scriptUrl, firstResult["script"]); } - [Theory] - [MemberData(nameof(Data))] - public async Task GivenGetMethod_WhenRequestingSchema_TheServerShouldReturnNotImplemented(string path) + [Fact(Skip = "Deployment steps to refactor to include environmentUrl")] + public async Task WhenRequestingSchema_GivenGetMethodAndCompatibilityPathAndInstanceSchemaTableIsEmpty_TheServerShouldReturnsNotFound() + { + var request = new HttpRequestMessage + { + Method = HttpMethod.Get, + RequestUri = new Uri(_client.BaseAddress, "_schema/compatibility"), + }; + + HttpResponseMessage response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + string responseBodyAsString = await response.Content.ReadAsStringAsync(); + + CompatibleVersions jsonList = JsonConvert.DeserializeObject(responseBodyAsString); + Assert.NotNull(jsonList); + } + + [Fact(Skip = "Deployment steps to refactor to include environmentUrl")] + public async Task WhenRequestingSchema_GivenGetMethodAndCurrentVersionPath_TheServerShouldReturnSuccess() { - await SendAndVerifyStatusCode(HttpMethod.Get, path, HttpStatusCode.NotImplemented); + HttpResponseMessage response = await SendAndVerifyStatusCode(HttpMethod.Get, "_schema/versions/current", HttpStatusCode.OK); + + string responseBodyAsString = await response.Content.ReadAsStringAsync(); + var jsonList = JsonConvert.DeserializeObject>(responseBodyAsString); + Assert.Equal(2, jsonList[0].Id); + Assert.Equal(1, jsonList[0].Servers.Count); + Assert.Equal((SchemaVersionStatus)Enum.Parse(typeof(SchemaVersionStatus), "completed", true), jsonList[0].Status); } [Theory] @@ -131,7 +156,7 @@ public async Task GivenSchemaIdNotFound_WhenRequestingScript_TheServerShouldRetu await SendAndVerifyStatusCode(HttpMethod.Get, "_schema/versions/0/script", HttpStatusCode.NotFound); } - private async Task SendAndVerifyStatusCode(HttpMethod httpMethod, string path, HttpStatusCode httpStatusCode) + private async Task SendAndVerifyStatusCode(HttpMethod httpMethod, string path, HttpStatusCode expectedStatusCode) { var request = new HttpRequestMessage { @@ -139,13 +164,17 @@ private async Task SendAndVerifyStatusCode(HttpMethod httpMethod, string path, H RequestUri = new Uri(_client.BaseAddress, path), }; + HttpResponseMessage response = null; + // Setting the contentType explicitly because POST/PUT/PATCH throws UnsupportedMediaType using (var content = new StringContent(" ", Encoding.UTF8, "application/json")) { request.Content = content; - HttpResponseMessage response = await _client.SendAsync(request); - Assert.Equal(httpStatusCode, response.StatusCode); + response = await _client.SendAsync(request); + Assert.Equal(expectedStatusCode, response.StatusCode); } + + return response; } } } \ No newline at end of file diff --git a/test/Microsoft.Health.SqlServer.Web/Features/Schema/Migrations/1.sql b/test/Microsoft.Health.SqlServer.Web/Features/Schema/Migrations/1.sql index 626d1f18..24ec7686 100644 --- a/test/Microsoft.Health.SqlServer.Web/Features/Schema/Migrations/1.sql +++ b/test/Microsoft.Health.SqlServer.Web/Features/Schema/Migrations/1.sql @@ -1 +1,264 @@ -SELECT 1 \ No newline at end of file +-- NOTE: This script DROPS AND RECREATES all database objects. +-- Style guide: please see: https://github.com/ktaranov/sqlserver-kit/blob/master/SQL%20Server%20Name%20Convention%20and%20T-SQL%20Programming%20Style.md + + +/************************************************************* + Drop existing objects +**************************************************************/ + +DECLARE @sql nvarchar(max) ='' + +SELECT @sql = @sql + 'DROP PROCEDURE ' + name + '; ' +FROM sys.procedures + +SELECT @sql = @sql + 'DROP TABLE ' + name + '; ' +FROM sys.tables + +SELECT @sql = @sql + 'DROP TYPE ' + name + '; ' +FROM sys.table_types + +SELECT @sql = @sql + 'DROP SEQUENCE ' + name + '; ' +FROM sys.sequences + +EXEC(@sql) + +GO + +/************************************************************* + Configure database +**************************************************************/ + +-- Enable RCSI +IF ((SELECT is_read_committed_snapshot_on FROM sys.databases WHERE database_id = DB_ID()) = 0) BEGIN + ALTER DATABASE CURRENT SET READ_COMMITTED_SNAPSHOT ON +END + +-- Avoid blocking queries when statistics need to be rebuilt +IF ((SELECT is_auto_update_stats_async_on FROM sys.databases WHERE database_id = DB_ID()) = 0) BEGIN + ALTER DATABASE CURRENT SET AUTO_UPDATE_STATISTICS_ASYNC ON +END + +-- Use ANSI behavior for null values +IF ((SELECT is_ansi_nulls_on FROM sys.databases WHERE database_id = DB_ID()) = 0) BEGIN + ALTER DATABASE CURRENT SET ANSI_NULLS ON +END + +GO + +/************************************************************* + Schema bootstrap +**************************************************************/ +/************************************************************* + Schema Version +**************************************************************/ +CREATE TABLE dbo.SchemaVersion +( + Version int PRIMARY KEY, + Status varchar(10) +) + +INSERT INTO dbo.SchemaVersion +VALUES + (1, 'started') + +GO + +-- +-- STORED PROCEDURE +-- UpsertSchemaVersion +-- +-- DESCRIPTION +-- Creates or updates a new schema version entry +-- +-- PARAMETERS +-- @version +-- * The version number +-- @status +-- * The status of the version +-- +CREATE PROCEDURE dbo.UpsertSchemaVersion + @version int, + @status varchar(10) +AS + SET NOCOUNT ON + + IF EXISTS(SELECT * + FROM dbo.SchemaVersion + WHERE Version = @version) + BEGIN + UPDATE dbo.SchemaVersion + SET Status = @status + WHERE Version = @version + END + ELSE + BEGIN + INSERT INTO dbo.SchemaVersion + (Version, Status) + VALUES + (@version, @status) + END +GO + +/************************************************************* + Instance Schema +**************************************************************/ +CREATE TABLE dbo.InstanceSchema +( + Name varchar(64) COLLATE Latin1_General_100_CS_AS NOT NULL, + CurrentVersion int NOT NULL, + MaxVersion int NOT NULL, + MinVersion int NOT NULL, + Timeout datetime2(0) NOT NULL +) + +CREATE UNIQUE CLUSTERED INDEX IXC_InstanceSchema +ON dbo.InstanceSchema +( + Name +) + +CREATE NONCLUSTERED INDEX IX_InstanceSchema_Timeout +ON dbo.InstanceSchema +( + Timeout +) + +GO + +-- +-- STORED PROCEDURE +-- Gets schema information given its instance name. +-- +-- DESCRIPTION +-- Retrieves the instance schema record from the InstanceSchema table that has the matching name. +-- +-- PARAMETERS +-- @name +-- * The unique name for a particular instance +-- +-- RETURN VALUE +-- The matching record. +-- +CREATE PROCEDURE dbo.GetInstanceSchemaByName + @name varchar(64) +AS + SET NOCOUNT ON + + SELECT CurrentVersion, MaxVersion, MinVersion, Timeout + FROM dbo.InstanceSchema + WHERE Name = @name +GO + +-- +-- STORED PROCEDURE +-- Update an instance schema. +-- +-- DESCRIPTION +-- Modifies an existing record in the InstanceSchema table. +-- +-- PARAMETERS +-- @name +-- * The unique name for a particular instance +-- @maxVersion +-- * The maximum supported schema version for the given instance +-- @minVersion +-- * The minimum supported schema version for the given instance +-- @addMinutesOnTimeout +-- * The minutes to add +-- +CREATE PROCEDURE dbo.UpsertInstanceSchema + @name varchar(64), + @maxVersion int, + @minVersion int, + @addMinutesOnTimeout int + +AS + SET NOCOUNT ON + + DECLARE @timeout datetime2(0) = DATEADD(minute, @addMinutesOnTimeout, SYSUTCDATETIME()) + DECLARE @currentVersion int = (SELECT COALESCE(MAX(Version), 0) + FROM dbo.SchemaVersion + WHERE Status = 'completed' AND Version <= @maxVersion) + IF EXISTS(SELECT * + FROM dbo.InstanceSchema + WHERE Name = @name) + BEGIN + UPDATE dbo.InstanceSchema + SET CurrentVersion = @currentVersion, MaxVersion = @maxVersion, Timeout = @timeout + WHERE Name = @name + + SELECT @currentVersion + END + ELSE + BEGIN + INSERT INTO dbo.InstanceSchema + (Name, CurrentVersion, MaxVersion, MinVersion, Timeout) + VALUES + (@name, @currentVersion, @maxVersion, @minVersion, @timeout) + + SELECT @currentVersion + END +GO + +-- +-- STORED PROCEDURE +-- Delete instance schema information. +-- +-- DESCRIPTION +-- Delete all the expired records in the InstanceSchema table. +-- +CREATE PROCEDURE dbo.DeleteInstanceSchema + +AS + SET NOCOUNT ON + + DELETE FROM dbo.InstanceSchema + WHERE Timeout < SYSUTCDATETIME() + +GO + +-- +-- STORED PROCEDURE +-- SelectCompatibleSchemaVersions +-- +-- DESCRIPTION +-- Selects the compatible schema versions +-- +-- RETURNS +-- The maximum and minimum compatible versions +-- +CREATE PROCEDURE dbo.SelectCompatibleSchemaVersions + +AS +BEGIN + SET NOCOUNT ON + + SELECT MAX(MinVersion), MIN(MaxVersion) + FROM dbo.InstanceSchema + WHERE Timeout > SYSUTCDATETIME() +END +GO + +-- +-- STORED PROCEDURE +-- SelectCurrentVersionsInformation +-- +-- DESCRIPTION +-- Selects the current schema versions information +-- +-- RETURNS +-- The current versions, status and server names using that version +-- +CREATE PROCEDURE dbo.SelectCurrentVersionsInformation + +AS +BEGIN + SET NOCOUNT ON + + SELECT SV.Version, SV.Status, STRING_AGG(SCH.NAME, ',') + FROM dbo.SchemaVersion AS SV LEFT OUTER JOIN dbo.InstanceSchema AS SCH + ON SV.Version = SCH.CurrentVersion + GROUP BY Version, Status + +END +GO \ No newline at end of file diff --git a/test/Microsoft.Health.SqlServer.Web/Features/Schema/Migrations/2.diff.sql b/test/Microsoft.Health.SqlServer.Web/Features/Schema/Migrations/2.diff.sql new file mode 100644 index 00000000..3449e001 --- /dev/null +++ b/test/Microsoft.Health.SqlServer.Web/Features/Schema/Migrations/2.diff.sql @@ -0,0 +1,5 @@ +INSERT INTO dbo.SchemaVersion +VALUES + (2, 'started') + +GO \ No newline at end of file diff --git a/test/Microsoft.Health.SqlServer.Web/Features/Schema/Migrations/2.sql b/test/Microsoft.Health.SqlServer.Web/Features/Schema/Migrations/2.sql new file mode 100644 index 00000000..69299149 --- /dev/null +++ b/test/Microsoft.Health.SqlServer.Web/Features/Schema/Migrations/2.sql @@ -0,0 +1,262 @@ +-- NOTE: This script DROPS AND RECREATES all database objects. +-- Style guide: please see: https://github.com/ktaranov/sqlserver-kit/blob/master/SQL%20Server%20Name%20Convention%20and%20T-SQL%20Programming%20Style.md + + +/************************************************************* + Drop existing objects +**************************************************************/ + +DECLARE @sql nvarchar(max) ='' + +SELECT @sql = @sql + 'DROP PROCEDURE ' + name + '; ' +FROM sys.procedures + +SELECT @sql = @sql + 'DROP TABLE ' + name + '; ' +FROM sys.tables + +SELECT @sql = @sql + 'DROP TYPE ' + name + '; ' +FROM sys.table_types + +SELECT @sql = @sql + 'DROP SEQUENCE ' + name + '; ' +FROM sys.sequences + +EXEC(@sql) + +GO + +/************************************************************* + Configure database +**************************************************************/ + +-- Enable RCSI +IF ((SELECT is_read_committed_snapshot_on FROM sys.databases WHERE database_id = DB_ID()) = 0) BEGIN + ALTER DATABASE CURRENT SET READ_COMMITTED_SNAPSHOT ON +END + +-- Avoid blocking queries when statistics need to be rebuilt +IF ((SELECT is_auto_update_stats_async_on FROM sys.databases WHERE database_id = DB_ID()) = 0) BEGIN + ALTER DATABASE CURRENT SET AUTO_UPDATE_STATISTICS_ASYNC ON +END + +-- Use ANSI behavior for null values +IF ((SELECT is_ansi_nulls_on FROM sys.databases WHERE database_id = DB_ID()) = 0) BEGIN + ALTER DATABASE CURRENT SET ANSI_NULLS ON +END + +GO + +/************************************************************* + Schema bootstrap +**************************************************************/ +/************************************************************* + Schema Version +**************************************************************/ +CREATE TABLE dbo.SchemaVersion +( + Version int PRIMARY KEY, + Status varchar(10) +) + +INSERT INTO dbo.SchemaVersion +VALUES + (2, 'started') + +GO + +-- +-- STORED PROCEDURE +-- UpsertSchemaVersion +-- +-- DESCRIPTION +-- Creates or updates a new schema version entry +-- +-- PARAMETERS +-- @version +-- * The version number +-- @status +-- * The status of the version +-- +CREATE PROCEDURE dbo.UpsertSchemaVersion + @version int, + @status varchar(10) +AS + SET NOCOUNT ON + + IF EXISTS(SELECT * + FROM dbo.SchemaVersion + WHERE Version = @version) + BEGIN + UPDATE dbo.SchemaVersion + SET Status = @status + WHERE Version = @version + END + ELSE + BEGIN + INSERT INTO dbo.SchemaVersion + (Version, Status) + VALUES + (@version, @status) + END +GO + +/************************************************************* + Instance Schema +**************************************************************/ +CREATE TABLE dbo.InstanceSchema +( + Name varchar(64) COLLATE Latin1_General_100_CS_AS NOT NULL, + CurrentVersion int NOT NULL, + MaxVersion int NOT NULL, + MinVersion int NOT NULL, + Timeout datetime2(0) NOT NULL +) + +CREATE UNIQUE CLUSTERED INDEX IXC_InstanceSchema ON dbo.InstanceSchema +( + Name +) + +CREATE NONCLUSTERED INDEX IX_InstanceSchema_Timeout ON dbo.InstanceSchema +( + Timeout +) + +GO + +-- +-- STORED PROCEDURE +-- Gets schema information given its instance name. +-- +-- DESCRIPTION +-- Retrieves the instance schema record from the InstanceSchema table that has the matching name. +-- +-- PARAMETERS +-- @name +-- * The unique name for a particular instance +-- +-- RETURN VALUE +-- The matching record. +-- +CREATE PROCEDURE dbo.GetInstanceSchemaByName + @name varchar(64) +AS + SET NOCOUNT ON + + SELECT CurrentVersion, MaxVersion, MinVersion, Timeout + FROM dbo.InstanceSchema + WHERE Name = @name +GO + +-- +-- STORED PROCEDURE +-- Update an instance schema. +-- +-- DESCRIPTION +-- Modifies an existing record in the InstanceSchema table. +-- +-- PARAMETERS +-- @name +-- * The unique name for a particular instance +-- @maxVersion +-- * The maximum supported schema version for the given instance +-- @minVersion +-- * The minimum supported schema version for the given instance +-- @addMinutesOnTimeout +-- * The minutes to add +-- +CREATE PROCEDURE dbo.UpsertInstanceSchema + @name varchar(64), + @maxVersion int, + @minVersion int, + @addMinutesOnTimeout int + +AS + SET NOCOUNT ON + + DECLARE @timeout datetime2(0) = DATEADD(minute, @addMinutesOnTimeout, SYSUTCDATETIME()) + DECLARE @currentVersion int = (SELECT COALESCE(MAX(Version), 0) + FROM dbo.SchemaVersion + WHERE Status = 'completed' AND Version <= @maxVersion) + IF EXISTS(SELECT * + FROM dbo.InstanceSchema + WHERE Name = @name) + BEGIN + UPDATE dbo.InstanceSchema + SET CurrentVersion = @currentVersion, MaxVersion = @maxVersion, Timeout = @timeout + WHERE Name = @name + + SELECT @currentVersion + END + ELSE + BEGIN + INSERT INTO dbo.InstanceSchema + (Name, CurrentVersion, MaxVersion, MinVersion, Timeout) + VALUES + (@name, @currentVersion, @maxVersion, @minVersion, @timeout) + + SELECT @currentVersion + END +GO + +-- +-- STORED PROCEDURE +-- Delete instance schema information. +-- +-- DESCRIPTION +-- Delete all the expired records in the InstanceSchema table. +-- +CREATE PROCEDURE dbo.DeleteInstanceSchema + +AS + SET NOCOUNT ON + + DELETE FROM dbo.InstanceSchema + WHERE Timeout < SYSUTCDATETIME() + +GO + +-- +-- STORED PROCEDURE +-- SelectCompatibleSchemaVersions +-- +-- DESCRIPTION +-- Selects the compatible schema versions +-- +-- RETURNS +-- The maximum and minimum compatible versions +-- +CREATE PROCEDURE dbo.SelectCompatibleSchemaVersions + +AS +BEGIN + SET NOCOUNT ON + + SELECT MAX(MinVersion), MIN(MaxVersion) + FROM dbo.InstanceSchema + WHERE Timeout > SYSUTCDATETIME() +END +GO + +-- +-- STORED PROCEDURE +-- SelectCurrentVersionsInformation +-- +-- DESCRIPTION +-- Selects the current schema versions information +-- +-- RETURNS +-- The current versions, status and server names using that version +-- +CREATE PROCEDURE dbo.SelectCurrentVersionsInformation + +AS +BEGIN + SET NOCOUNT ON + + SELECT SV.Version, SV.Status, STRING_AGG(SCH.NAME, ',') + FROM dbo.SchemaVersion AS SV LEFT OUTER JOIN dbo.InstanceSchema AS SCH + ON SV.Version = SCH.CurrentVersion + GROUP BY Version, Status + +END +GO \ No newline at end of file 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 be37cdca..b1e2b581 100644 --- a/test/Microsoft.Health.SqlServer.Web/Microsoft.Health.SqlServer.Web.csproj +++ b/test/Microsoft.Health.SqlServer.Web/Microsoft.Health.SqlServer.Web.csproj @@ -16,7 +16,11 @@ + + + + diff --git a/test/Microsoft.Health.SqlServer.Web/Properties/launchSettings.json b/test/Microsoft.Health.SqlServer.Web/Properties/launchSettings.json index 533b13fb..376525be 100644 --- a/test/Microsoft.Health.SqlServer.Web/Properties/launchSettings.json +++ b/test/Microsoft.Health.SqlServer.Web/Properties/launchSettings.json @@ -1,9 +1,13 @@ -{ +{ "profiles": { "Microsoft.Health.SqlServer.Web": { "commandName": "Project", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "development", + "SqlServer:Initialize": "true", + "SqlServer:SchemaOptions:AutomaticUpdatesEnabled": "true", + "SqlServer:AllowDatabaseCreation": "true", + "SqlServer:ConnectionString": "server=(local);Initial Catalog=SHARED_HEALTHCARE;Integrated Security=true" }, "applicationUrl": "https://localhost:63637/" } diff --git a/test/Microsoft.Health.SqlServer.Web/Startup.cs b/test/Microsoft.Health.SqlServer.Web/Startup.cs index e00244d9..e5c04ab5 100644 --- a/test/Microsoft.Health.SqlServer.Web/Startup.cs +++ b/test/Microsoft.Health.SqlServer.Web/Startup.cs @@ -3,10 +3,12 @@ // Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. // ------------------------------------------------------------------------------------------------- +using MediatR; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Health.Extensions.DependencyInjection; +using Microsoft.Health.SqlServer.Api.Features; using Microsoft.Health.SqlServer.Api.Registration; using Microsoft.Health.SqlServer.Features.Schema; using Microsoft.Health.SqlServer.Registration; @@ -33,6 +35,8 @@ public virtual void ConfigureServices(IServiceCollection services) services.AddSqlServerBase(); services.AddSqlServerApi(); + services.AddMediatR(typeof(CompatibilityVersionHandler).Assembly); + services.Add(provider => new SchemaInformation((int)SchemaVersion.Version1, (int)SchemaVersion.Version2)) .Singleton() .AsSelf()