Initial check-in of SqlProjects service addition to SqlToolsService (#1805)

* initial commit

* Initial SqlProjects service + tests

* Added SqlObject script tests; PR feedback

* Added comments for contracts

* Swapping SqlProjectResult for ResultStatus

* Updating tests

* Added automatic test base that provides a working directory and automatic cleanup.
This commit is contained in:
Benjin Dubishar
2023-01-25 14:16:15 -08:00
committed by GitHub
parent da6bb1c6c3
commit 9cce26fcbd
16 changed files with 553 additions and 1 deletions

View File

@@ -20,6 +20,7 @@
<PackageReference Update="Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider" Version="1.1.1" />
<PackageReference Update="Microsoft.SqlServer.Management.SmoMetadataProvider" Version="170.11.0" />
<PackageReference Update="Microsoft.SqlServer.DacFx" Version="161.8055.0-preview" />
<PackageReference Update="Microsoft.SqlServer.DacFx.Projects" Version="161.8056.0-alpha" />
<PackageReference Update="Microsoft.Azure.Kusto.Data" Version="9.0.4" />
<PackageReference Update="Microsoft.Azure.Kusto.Language" Version="9.0.4" />
<PackageReference Update="Microsoft.SqlServer.Assessment" Version="[1.1.17]" />

View File

@@ -41,6 +41,7 @@ using Microsoft.SqlTools.ServiceLayer.AzureBlob;
using Microsoft.SqlTools.ServiceLayer.ExecutionPlan;
using Microsoft.SqlTools.ServiceLayer.ObjectManagement;
using System.IO;
using Microsoft.SqlTools.ServiceLayer.SqlProjects;
namespace Microsoft.SqlTools.ServiceLayer
{
@@ -179,6 +180,9 @@ namespace Microsoft.SqlTools.ServiceLayer
ObjectManagementService.Instance.InitializeService(serviceHost);
serviceProvider.RegisterSingleService(ObjectManagementService.Instance);
SqlProjectsService.Instance.InitializeService(serviceHost);
serviceProvider.RegisterSingleService(SqlProjectsService.Instance);
serviceHost.InitializeRequestHandlers();
}

View File

@@ -50,6 +50,7 @@
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.Common" />
<PackageReference Include="Microsoft.SqlServer.DacFx" />
<PackageReference Include="Microsoft.SqlServer.DacFx.Projects" />
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider" />
<PackageReference Include="Microsoft.SqlServer.Assessment" />

View File

@@ -0,0 +1,15 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//
using Microsoft.SqlTools.Hosting.Protocol.Contracts;
using Microsoft.SqlTools.ServiceLayer.Utility;
namespace Microsoft.SqlTools.ServiceLayer.SqlProjects.Contracts
{
public class CloseSqlProjectRequest
{
public static readonly RequestType<SqlProjectParams, ResultStatus> Type = RequestType<SqlProjectParams, ResultStatus>.Create("sqlprojects/closeProject");
}
}

View File

@@ -0,0 +1,39 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//
using Microsoft.SqlServer.Dac.Projects;
using Microsoft.SqlTools.Hosting.Protocol.Contracts;
using Microsoft.SqlTools.ServiceLayer.Utility;
namespace Microsoft.SqlTools.ServiceLayer.SqlProjects.Contracts
{
/// <summary>
/// Parameters for creating a new SQL Project
/// </summary>
public class NewSqlProjectParams : SqlProjectParams
{
/// <summary>
/// Type of SQL Project: SDK-style or Legacy
/// </summary>
public ProjectType SqlProjectType { get; set; }
/// <summary>
/// Database schema provider for the project, in the format
/// "Microsoft.Data.Tools.Schema.Sql.SqlXYZDatabaseSchemaProvider".
/// Case sensitive.
/// </summary>
public string? DatabaseSchemaProvider { get; set; }
/// <summary>
/// Version of the Microsoft.Build.Sql SDK for the project, if overriding the default
/// </summary>
public string? BuildSdkVersion { get; set; }
}
public class NewSqlProjectRequest
{
public static readonly RequestType<NewSqlProjectParams, ResultStatus> Type = RequestType<NewSqlProjectParams, ResultStatus>.Create("sqlprojects/newProject");
}
}

View File

@@ -0,0 +1,15 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//
using Microsoft.SqlTools.Hosting.Protocol.Contracts;
using Microsoft.SqlTools.ServiceLayer.Utility;
namespace Microsoft.SqlTools.ServiceLayer.SqlProjects.Contracts
{
public class OpenSqlProjectRequest
{
public static readonly RequestType<SqlProjectParams, ResultStatus> Type = RequestType<SqlProjectParams, ResultStatus>.Create("sqlprojects/openProject");
}
}

View File

@@ -0,0 +1,15 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//
using Microsoft.SqlTools.Hosting.Protocol.Contracts;
using Microsoft.SqlTools.ServiceLayer.Utility;
namespace Microsoft.SqlTools.ServiceLayer.SqlProjects.Contracts
{
public class AddSqlObjectScriptRequest
{
public static readonly RequestType<SqlProjectScriptParams, ResultStatus> Type = RequestType<SqlProjectScriptParams, ResultStatus>.Create("sqlprojects/addSqlObjectScript");
}
}

View File

@@ -0,0 +1,15 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//
using Microsoft.SqlTools.Hosting.Protocol.Contracts;
using Microsoft.SqlTools.ServiceLayer.Utility;
namespace Microsoft.SqlTools.ServiceLayer.SqlProjects.Contracts
{
public class DeleteSqlObjectScriptRequest
{
public static readonly RequestType<SqlProjectScriptParams, ResultStatus> Type = RequestType<SqlProjectScriptParams, ResultStatus>.Create("sqlprojects/deleteSqlObjectScript");
}
}

View File

@@ -0,0 +1,15 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//
using Microsoft.SqlTools.Hosting.Protocol.Contracts;
using Microsoft.SqlTools.ServiceLayer.Utility;
namespace Microsoft.SqlTools.ServiceLayer.SqlProjects.Contracts
{
public class ExcludeSqlObjectScriptRequest
{
public static readonly RequestType<SqlProjectScriptParams, ResultStatus> Type = RequestType<SqlProjectScriptParams, ResultStatus>.Create("sqlprojects/excludeSqlObjectScript");
}
}

View File

@@ -0,0 +1,31 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//
using Microsoft.SqlTools.Utility;
namespace Microsoft.SqlTools.ServiceLayer.SqlProjects.Contracts
{
/// <summary>
/// Parameters for a generic SQL Project operation
/// </summary>
public class SqlProjectParams : GeneralRequestDetails
{
/// <summary>
/// Absolute path of the project, including .sqlproj
/// </summary>
public string ProjectUri { get; set; }
}
/// <summary>
/// Parameters for a SQL Project operation that targets a script
/// </summary>
public class SqlProjectScriptParams : SqlProjectParams
{
/// <summary>
/// Path of the script, including .sql, relative to the .sqlproj
/// </summary>
public string Path { get; set; }
}
}

View File

@@ -0,0 +1,140 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
using Microsoft.SqlServer.Dac.Projects;
using Microsoft.SqlTools.Hosting.Protocol;
using Microsoft.SqlTools.ServiceLayer.Hosting;
using Microsoft.SqlTools.ServiceLayer.SqlProjects.Contracts;
using Microsoft.SqlTools.ServiceLayer.Utility;
namespace Microsoft.SqlTools.ServiceLayer.SqlProjects
{
/// <summary>
/// Main class for SqlProjects service
/// </summary>
class SqlProjectsService
{
private static readonly Lazy<SqlProjectsService> instance = new Lazy<SqlProjectsService>(() => new SqlProjectsService());
/// <summary>
/// Gets the singleton instance object
/// </summary>
public static SqlProjectsService Instance => instance.Value;
private Lazy<ConcurrentDictionary<string, SqlProject>> projects = new Lazy<ConcurrentDictionary<string, SqlProject>>(() => new ConcurrentDictionary<string, SqlProject>(StringComparer.OrdinalIgnoreCase));
/// <summary>
/// <see cref="ConcurrentDictionary{String, TSqlModel}"/> that maps Project URI to Project
/// </summary>
public ConcurrentDictionary<string, SqlProject> Projects => projects.Value;
/// <summary>
/// Initializes the service instance
/// </summary>
/// <param name="serviceHost"></param>
public void InitializeService(ServiceHost serviceHost)
{
// Project-level functions
serviceHost.SetRequestHandler(OpenSqlProjectRequest.Type, HandleOpenSqlProjectRequest, isParallelProcessingSupported: true);
serviceHost.SetRequestHandler(CloseSqlProjectRequest.Type, HandleCloseSqlProjectRequest, isParallelProcessingSupported: true);
serviceHost.SetRequestHandler(NewSqlProjectRequest.Type, HandleNewSqlProjectRequest, isParallelProcessingSupported: true);
// SQL object script calls
serviceHost.SetRequestHandler(AddSqlObjectScriptRequest.Type, HandleAddSqlObjectScriptRequest, isParallelProcessingSupported: false);
serviceHost.SetRequestHandler(DeleteSqlObjectScriptRequest.Type, HandleDeleteSqlObjectScriptRequest, isParallelProcessingSupported: false);
serviceHost.SetRequestHandler(ExcludeSqlObjectScriptRequest.Type, HandleExcludeSqlObjectScriptRequest, isParallelProcessingSupported: false);
}
#region Handlers
#region Project-level functions
internal async Task HandleOpenSqlProjectRequest(SqlProjectParams requestParams, RequestContext<ResultStatus> requestContext)
{
await RunWithErrorHandling(() => GetProject(requestParams.ProjectUri), requestContext);
}
internal async Task HandleCloseSqlProjectRequest(SqlProjectParams requestParams, RequestContext<ResultStatus> requestContext)
{
await RunWithErrorHandling(() => Projects.TryRemove(requestParams.ProjectUri, out _), requestContext);
}
internal async Task HandleNewSqlProjectRequest(NewSqlProjectParams requestParams, RequestContext<ResultStatus> requestContext)
{
await RunWithErrorHandling(async () =>
{
await SqlProject.CreateProjectAsync(requestParams.ProjectUri, requestParams.SqlProjectType, requestParams.DatabaseSchemaProvider);
GetProject(requestParams.ProjectUri); // load into the cache
}, requestContext);
}
#endregion
#region Sql object script calls
internal async Task HandleAddSqlObjectScriptRequest(SqlProjectScriptParams requestParams, RequestContext<ResultStatus> requestContext)
{
await RunWithErrorHandling(() => GetProject(requestParams.ProjectUri).SqlObjectScripts.Add(new SqlObjectScript(requestParams.Path)), requestContext);
}
internal async Task HandleDeleteSqlObjectScriptRequest(SqlProjectScriptParams requestParams, RequestContext<ResultStatus> requestContext)
{
await RunWithErrorHandling(() => GetProject(requestParams.ProjectUri).SqlObjectScripts.Delete(requestParams.Path), requestContext);
}
internal async Task HandleExcludeSqlObjectScriptRequest(SqlProjectScriptParams requestParams, RequestContext<ResultStatus> requestContext)
{
await RunWithErrorHandling(() => GetProject(requestParams.ProjectUri).SqlObjectScripts.Exclude(requestParams.Path), requestContext);
}
#endregion
#endregion
#region Helper methods
private async Task RunWithErrorHandling(Action action, RequestContext<ResultStatus> requestContext)
{
await RunWithErrorHandling(async () => await Task.Run(action), requestContext);
}
private async Task RunWithErrorHandling(Func<Task> action, RequestContext<ResultStatus> requestContext)
{
try
{
await action();
await requestContext.SendResult(new ResultStatus()
{
Success = true,
ErrorMessage = null
});
}
catch (Exception ex)
{
await requestContext.SendResult(new ResultStatus()
{
Success = false,
ErrorMessage = ex.Message
});
}
}
private SqlProject GetProject(string projectUri)
{
if (!Projects.ContainsKey(projectUri))
{
Projects[projectUri] = new SqlProject(projectUri);
}
return Projects[projectUri];
}
#endregion
}
}

View File

@@ -0,0 +1,182 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//
using System.IO;
using System.Threading.Tasks;
using Microsoft.SqlServer.Dac.Projects;
using Microsoft.SqlTools.ServiceLayer.IntegrationTests.Utility;
using Microsoft.SqlTools.ServiceLayer.SqlProjects;
using Microsoft.SqlTools.ServiceLayer.SqlProjects.Contracts;
using Microsoft.SqlTools.ServiceLayer.Test.Common.RequestContextMocking;
using Microsoft.SqlTools.ServiceLayer.Utility;
using NUnit.Framework;
namespace Microsoft.SqlTools.ServiceLayer.IntegrationTests.SqlProjects
{
public class SqlProjectsServiceTests : TestBase
{
[Test]
public async Task TestErrorDuringExecution()
{
SqlProjectsService service = new();
string projectUri = await service.CreateSqlProject(); // validates result.Success == true
// Validate that result indicates failure when there's an exception
MockRequest<ResultStatus> requestMock = new();
await service.HandleNewSqlProjectRequest(new NewSqlProjectParams()
{
ProjectUri = projectUri,
SqlProjectType = ProjectType.SdkStyle
}, requestMock.Object);
Assert.IsFalse(requestMock.Result.Success);
Assert.IsTrue(requestMock.Result.ErrorMessage!.Contains("Cannot create a new SQL project"));
}
[Test]
public async Task TestOpenCloseProject()
{
// Setup
string sdkProjectUri = TestContext.CurrentContext.GetTestProjectPath(nameof(TestOpenCloseProject) + "Sdk");
string legacyProjectUri = TestContext.CurrentContext.GetTestProjectPath(nameof(TestOpenCloseProject) + "Legacy");
if (File.Exists(sdkProjectUri)) File.Delete(sdkProjectUri);
if (File.Exists(legacyProjectUri)) File.Delete(legacyProjectUri);
SqlProjectsService service = new();
Assert.AreEqual(0, service.Projects.Count);
// Validate creating SDK-style project
MockRequest<ResultStatus> requestMock = new();
await service.HandleNewSqlProjectRequest(new NewSqlProjectParams()
{
ProjectUri = sdkProjectUri,
SqlProjectType = ProjectType.SdkStyle
}, requestMock.Object);
Assert.IsTrue(requestMock.Result.Success);
Assert.AreEqual(1, service.Projects.Count);
Assert.IsTrue(service.Projects.ContainsKey(sdkProjectUri));
Assert.AreEqual(service.Projects[sdkProjectUri].SqlProjStyle, ProjectType.SdkStyle);
// Validate creating Legacy-style project
requestMock = new();
await service.HandleNewSqlProjectRequest(new NewSqlProjectParams()
{
ProjectUri = legacyProjectUri,
SqlProjectType = ProjectType.LegacyStyle
}, requestMock.Object);
Assert.IsTrue(requestMock.Result.Success);
Assert.AreEqual(2, service.Projects.Count);
Assert.IsTrue(service.Projects.ContainsKey(legacyProjectUri));
Assert.AreEqual(service.Projects[legacyProjectUri].SqlProjStyle, ProjectType.LegacyStyle);
// Validate closing a project
requestMock = new();
await service.HandleCloseSqlProjectRequest(new SqlProjectParams() { ProjectUri = sdkProjectUri }, requestMock.Object);
Assert.IsTrue(requestMock.Result.Success);
Assert.AreEqual(1, service.Projects.Count);
Assert.IsTrue(!service.Projects.ContainsKey(sdkProjectUri));
// Validate opening a project
requestMock = new();
await service.HandleOpenSqlProjectRequest(new SqlProjectParams() { ProjectUri = sdkProjectUri }, requestMock.Object);
Assert.IsTrue(requestMock.Result.Success);
Assert.AreEqual(2, service.Projects.Count);
Assert.IsTrue(service.Projects.ContainsKey(sdkProjectUri));
}
[Test]
public async Task TestSqlObjectScriptAddDeleteExclude()
{
// Setup
SqlProjectsService service = new();
string projectUri = await service.CreateSqlProject();
Assert.AreEqual(0, service.Projects[projectUri].SqlObjectScripts.Count);
// Validate adding a SQL object script
MockRequest<ResultStatus> requestMock = new();
string scriptRelativePath = "MyTable.sql";
string scriptFullPath = Path.Join(Path.GetDirectoryName(projectUri), scriptRelativePath);
await File.WriteAllTextAsync(scriptFullPath, "CREATE TABLE [MyTable] ([Id] INT)");
await service.HandleAddSqlObjectScriptRequest(new SqlProjectScriptParams()
{
ProjectUri = projectUri,
Path = scriptRelativePath
}, requestMock.Object);
Assert.IsTrue(requestMock.Result.Success);
Assert.AreEqual(1, service.Projects[projectUri].SqlObjectScripts.Count);
Assert.IsTrue(service.Projects[projectUri].SqlObjectScripts.Contains(scriptRelativePath));
// Validate excluding a SQL object script
requestMock = new();
await service.HandleExcludeSqlObjectScriptRequest(new SqlProjectScriptParams()
{
ProjectUri = projectUri,
Path = scriptRelativePath
}, requestMock.Object);
Assert.IsTrue(requestMock.Result.Success);
Assert.AreEqual(0, service.Projects[projectUri].SqlObjectScripts.Count);
Assert.IsTrue(File.Exists(scriptFullPath));
// Re-add to set up for Delete
requestMock = new();
await service.HandleAddSqlObjectScriptRequest(new SqlProjectScriptParams()
{
ProjectUri = projectUri,
Path = scriptRelativePath
}, requestMock.Object);
Assert.IsTrue(requestMock.Result.Success);
Assert.AreEqual(1, service.Projects[projectUri].SqlObjectScripts.Count);
// Validate deleting a SQL object script
requestMock = new();
await service.HandleDeleteSqlObjectScriptRequest(new SqlProjectScriptParams()
{
ProjectUri = projectUri,
Path = scriptRelativePath
}, requestMock.Object);
Assert.IsTrue(requestMock.Result.Success);
Assert.AreEqual(0, service.Projects[projectUri].SqlObjectScripts.Count);
Assert.IsFalse(File.Exists(scriptFullPath));
}
}
internal static class SqlProjectsExtensions
{
/// <summary>
/// Uses the service to create a new SQL project
/// </summary>
/// <param name="service"></param>
/// <returns></returns>
public async static Task<string> CreateSqlProject(this SqlProjectsService service)
{
string projectUri = TestContext.CurrentContext.GetTestProjectPath();
MockRequest<ResultStatus> requestMock = new();
await service.HandleNewSqlProjectRequest(new NewSqlProjectParams()
{
ProjectUri = projectUri,
SqlProjectType = ProjectType.SdkStyle
}, requestMock.Object);
Assert.IsTrue(requestMock.Result.Success);
return projectUri;
}
}
}

View File

@@ -0,0 +1,47 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//
using System;
using System.IO;
using NUnit.Framework;
namespace Microsoft.SqlTools.ServiceLayer.IntegrationTests.Utility
{
[TestFixture]
public abstract class TestBase
{
static TestBase()
{
RunTimestamp = DateTime.Now.ToString("yyyyMMdd-HHmmssffff");
}
public static string RunTimestamp
{
get;
private set;
}
public static string TestRunFolder => Path.Join(TestContext.CurrentContext.WorkDirectory, "SqlToolsServiceTestRuns", $"Run{RunTimestamp}");
[OneTimeSetUp]
public void SetUp()
{
if (!Directory.Exists(TestRunFolder))
{
Directory.CreateDirectory(TestRunFolder);
}
}
[OneTimeTearDown]
public void TearDown()
{
if (Directory.Exists(TestRunFolder))
{
Directory.Delete(TestRunFolder, recursive: true);
}
}
}
}

View File

@@ -0,0 +1,19 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//
using System.IO;
using NUnit.Framework;
namespace Microsoft.SqlTools.ServiceLayer.IntegrationTests.Utility
{
public static class TestContextHelpers
{
private static string TestName => TestContext.CurrentContext.Test.Name;
public static string GetTestWorkingFolder(this TestContext context) => Path.Join(TestBase.TestRunFolder, TestName);
public static string GetTestProjectPath(this TestContext context, string? projectName = null) => Path.Join(context.GetTestWorkingFolder(), $"{projectName ?? TestName}.sqlproj");
}
}

View File

@@ -13,7 +13,6 @@ namespace Microsoft.SqlTools.ServiceLayer.Test.Common.RequestContextMocking
{
public static class RequestContextMocks
{
public static Mock<RequestContext<TResponse>> Create<TResponse>(Action<TResponse> resultCallback)
{
var requestContext = new Mock<RequestContext<TResponse>>();
@@ -61,4 +60,18 @@ namespace Microsoft.SqlTools.ServiceLayer.Test.Common.RequestContextMocking
return mock;
}
}
public class MockRequest<T>
{
private T? result;
public T Result => result ?? throw new InvalidOperationException("No result has been sent for the request");
public Mock<RequestContext<T>> Mock;
public RequestContext<T> Object => Mock.Object;
public MockRequest()
{
Mock = RequestContextMocks.Create<T>(actual => result = actual);
}
}
}