From bcc1f2a48689b076538c6bae9c4b399e35944eec Mon Sep 17 00:00:00 2001 From: Aleksei Guzev Date: Fri, 24 Apr 2020 10:52:55 +0300 Subject: [PATCH] Add AQL Assessment service (#946) [SQL Assessment API](https://docs.microsoft.com/en-us/sql/sql-assessment-api/sql-assessment-api-overview) provides a mechanism to evaluate the configuration of SQL Server for best practices. SQL Assessment API gives a list of recommended actions to improve SQL Server performance or security. The SQL Assessment service is used by the expected SQL Assessment feature of Azure Data Studio. SqlAssessmentService forwards JSONRPC calls to SQL Assessment engine and wraps results as a response. `assessment/getAssessmentItems` returns a set of checks applicable to a given target. `assessment/invoke` returns a set of recommendations for improving SQL Server instance or database configurations. `assessment/generateScript` returns a T-SQL script for storing an assessment result set to a SQL data table. --- .../ReliableConnectionHelper.cs | 45 +- .../SqlConnectionHelperScripts.cs | 5 +- .../HostLoader.cs | 4 + .../Localization/sr.cs | 41 ++ .../Localization/sr.resx | 17 + .../Localization/sr.strings | 10 +- .../Localization/sr.xlf | 26 + .../Contracts/AssessmentRequest.cs | 129 +++++ .../Contracts/GenerateScriptRequest.cs | 56 ++ .../Contracts/GetAssessmentItemsRequest.cs | 33 ++ .../SqlAssessment/Contracts/InvokeRequest.cs | 82 +++ .../SqlAssessment/GenerateScriptOperation.cs | 151 ++++++ .../SqlAssessment/SqlAssessmentService.cs | 503 ++++++++++++++++++ .../SqlAssessmentServiceTests.cs | 183 +++++++ .../GenerateScriptOperationTests.cs | 159 ++++++ 15 files changed, 1439 insertions(+), 5 deletions(-) create mode 100644 src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/Contracts/AssessmentRequest.cs create mode 100644 src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/Contracts/GenerateScriptRequest.cs create mode 100644 src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/Contracts/GetAssessmentItemsRequest.cs create mode 100644 src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/Contracts/InvokeRequest.cs create mode 100644 src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/GenerateScriptOperation.cs create mode 100644 src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/SqlAssessmentService.cs create mode 100644 test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/SqlAssessment/SqlAssessmentServiceTests.cs create mode 100644 test/Microsoft.SqlTools.ServiceLayer.UnitTests/SqlAssessment/GenerateScriptOperationTests.cs diff --git a/src/Microsoft.SqlTools.ManagedBatchParser/ReliableConnection/ReliableConnectionHelper.cs b/src/Microsoft.SqlTools.ManagedBatchParser/ReliableConnection/ReliableConnectionHelper.cs index 31f253bb..c684e86c 100644 --- a/src/Microsoft.SqlTools.ManagedBatchParser/ReliableConnection/ReliableConnectionHelper.cs +++ b/src/Microsoft.SqlTools.ManagedBatchParser/ReliableConnection/ReliableConnectionHelper.cs @@ -652,6 +652,7 @@ namespace Microsoft.SqlTools.ServiceLayer.Connection.ReliableConnection public string OsVersion; public string MachineName; + public string ServerName; public Dictionary Options { get; set; } } @@ -666,6 +667,14 @@ namespace Microsoft.SqlTools.ServiceLayer.Connection.ReliableConnection public int Port; } + public class ServerHostInfo + { + public string Platform; + public string Distribution; + public string Release; + public string ServicePackLevel; + } + public static bool TryGetServerVersion(string connectionString, out ServerInfo serverInfo, string azureAccountToken) { serverInfo = null; @@ -697,6 +706,37 @@ namespace Microsoft.SqlTools.ServiceLayer.Connection.ReliableConnection azureAccountToken: azureAccountToken); return serverInfo; + } + + /// + /// Gets the server host information from sys.dm_os_host_info view + /// + /// The connection + public static ServerHostInfo GetServerHostInfo(IDbConnection connection) + { + // SQL Server 2016 and below does not provide sys.dm_os_host_info + if (!Version.TryParse(ReadServerVersion(connection), out var hostVersion) || hostVersion.Major <= 13) + { + return new ServerHostInfo + { + Platform = "Windows" + }; + } + + var hostInfo = new ServerHostInfo(); + ExecuteReader( + connection, + SqlConnectionHelperScripts.GetHostInfo, + reader => + { + reader.Read(); + hostInfo.Platform = reader[0].ToString(); + hostInfo.Distribution = reader[1].ToString(); + hostInfo.Release = reader[2].ToString(); + hostInfo.ServicePackLevel = reader[3].ToString(); + }); + + return hostInfo; } /// @@ -729,11 +769,12 @@ namespace Microsoft.SqlTools.ServiceLayer.Connection.ReliableConnection serverInfo.ServerLevel = reader[2].ToString(); serverInfo.ServerEdition = reader[3].ToString(); serverInfo.MachineName = reader[4].ToString(); + serverInfo.ServerName = reader[5].ToString(); - if (reader.FieldCount > 5) + if (reader.FieldCount > 6) { // Detect the presence of SXI - serverInfo.IsSelectiveXmlIndexMetadataPresent = reader.GetInt32(5) == 1; + serverInfo.IsSelectiveXmlIndexMetadataPresent = reader.GetInt32(6) == 1; } // The 'ProductVersion' server property is of the form ##.#[#].####.#, diff --git a/src/Microsoft.SqlTools.ManagedBatchParser/ReliableConnection/SqlConnectionHelperScripts.cs b/src/Microsoft.SqlTools.ManagedBatchParser/ReliableConnection/SqlConnectionHelperScripts.cs index 19dd5f54..c19815aa 100644 --- a/src/Microsoft.SqlTools.ManagedBatchParser/ReliableConnection/SqlConnectionHelperScripts.cs +++ b/src/Microsoft.SqlTools.ManagedBatchParser/ReliableConnection/SqlConnectionHelperScripts.cs @@ -7,8 +7,8 @@ namespace Microsoft.SqlTools.ServiceLayer.Connection.ReliableConnection { static class SqlConnectionHelperScripts { - public const string EngineEdition = "SELECT SERVERPROPERTY('EngineEdition'), SERVERPROPERTY('productversion'), SERVERPROPERTY ('productlevel'), SERVERPROPERTY ('edition'), SERVERPROPERTY ('MachineName'), (SELECT CASE WHEN EXISTS (SELECT TOP 1 1 from [sys].[all_columns] WITH (NOLOCK) WHERE name = N'xml_index_type' AND OBJECT_ID(N'sys.xml_indexes') = object_id) THEN 1 ELSE 0 END AS SXI_PRESENT)"; - public const string EngineEditionWithLock = "SELECT SERVERPROPERTY('EngineEdition'), SERVERPROPERTY('productversion'), SERVERPROPERTY ('productlevel'), SERVERPROPERTY ('edition'), SERVERPROPERTY ('MachineName'), (SELECT CASE WHEN EXISTS (SELECT TOP 1 1 from [sys].[all_columns] WHERE name = N'xml_index_type' AND OBJECT_ID(N'sys.xml_indexes') = object_id) THEN 1 ELSE 0 END AS SXI_PRESENT)"; + public const string EngineEdition = "SELECT SERVERPROPERTY('EngineEdition'), SERVERPROPERTY('productversion'), SERVERPROPERTY ('productlevel'), SERVERPROPERTY ('edition'), SERVERPROPERTY ('MachineName'), SERVERPROPERTY ('ServerName'), (SELECT CASE WHEN EXISTS (SELECT TOP 1 1 from [sys].[all_columns] WITH (NOLOCK) WHERE name = N'xml_index_type' AND OBJECT_ID(N'sys.xml_indexes') = object_id) THEN 1 ELSE 0 END AS SXI_PRESENT)"; + public const string EngineEditionWithLock = "SELECT SERVERPROPERTY('EngineEdition'), SERVERPROPERTY('productversion'), SERVERPROPERTY ('productlevel'), SERVERPROPERTY ('edition'), SERVERPROPERTY ('MachineName'), SERVERPROPERTY ('ServerName'), (SELECT CASE WHEN EXISTS (SELECT TOP 1 1 from [sys].[all_columns] WHERE name = N'xml_index_type' AND OBJECT_ID(N'sys.xml_indexes') = object_id) THEN 1 ELSE 0 END AS SXI_PRESENT)"; public const string CheckDatabaseReadonly = @"EXEC sp_dboption '{0}', 'read only'"; @@ -48,5 +48,6 @@ SELECT @filepath AS FilePath public const string GetOsVersion = @"SELECT OSVersion = RIGHT(@@version, LEN(@@version)- 3 -charindex (' on ', LOWER(@@version)))"; public const string GetClusterEndpoints = @"SELECT [name], [description], [endpoint], [protocol_desc] FROM .[sys].[dm_cluster_endpoints];"; + public const string GetHostInfo = @"SELECT [host_platform], [host_distribution], [host_release], [host_service_pack_level], [host_sku], [os_language_version] FROM sys.dm_os_host_info"; } } diff --git a/src/Microsoft.SqlTools.ServiceLayer/HostLoader.cs b/src/Microsoft.SqlTools.ServiceLayer/HostLoader.cs index bcbd9993..de0da135 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/HostLoader.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/HostLoader.cs @@ -26,6 +26,7 @@ using Microsoft.SqlTools.ServiceLayer.QueryExecution; using Microsoft.SqlTools.ServiceLayer.SchemaCompare; using Microsoft.SqlTools.ServiceLayer.Scripting; using Microsoft.SqlTools.ServiceLayer.Security; +using Microsoft.SqlTools.ServiceLayer.SqlAssessment; using Microsoft.SqlTools.ServiceLayer.SqlContext; using Microsoft.SqlTools.ServiceLayer.Workspace; @@ -129,6 +130,9 @@ namespace Microsoft.SqlTools.ServiceLayer ExternalLanguageService.Instance.InitializeService(serviceHost); serviceProvider.RegisterSingleService(ExternalLanguageService.Instance); + + SqlAssessmentService.Instance.InitializeService(serviceHost); + serviceProvider.RegisterSingleService(SqlAssessmentService.Instance); InitializeHostedServices(serviceProvider, serviceHost); serviceHost.ServiceProvider = serviceProvider; diff --git a/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.cs b/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.cs index 696f6ed3..e8fcbac5 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.cs @@ -2981,6 +2981,30 @@ namespace Microsoft.SqlTools.ServiceLayer } } + public static string SqlAssessmentGenerateScriptTaskName + { + get + { + return Keys.GetString(Keys.SqlAssessmentGenerateScriptTaskName); + } + } + + public static string SqlAssessmentQueryInvalidOwnerUri + { + get + { + return Keys.GetString(Keys.SqlAssessmentQueryInvalidOwnerUri); + } + } + + public static string SqlAssessmentConnectingError + { + get + { + return Keys.GetString(Keys.SqlAssessmentConnectingError); + } + } + public static string ConnectionServiceListDbErrorNotConnected(string uri) { return Keys.GetString(Keys.ConnectionServiceListDbErrorNotConnected, uri); @@ -3156,6 +3180,11 @@ namespace Microsoft.SqlTools.ServiceLayer return Keys.GetString(Keys.ScheduleNameAlreadyExists, scheduleName); } + public static string SqlAssessmentUnsuppoertedEdition(int editionCode) + { + return Keys.GetString(Keys.SqlAssessmentUnsuppoertedEdition, editionCode); + } + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Keys { @@ -4376,6 +4405,18 @@ namespace Microsoft.SqlTools.ServiceLayer public const string SchemaCompareSessionNotFound = "SchemaCompareSessionNotFound"; + public const string SqlAssessmentGenerateScriptTaskName = "SqlAssessmentGenerateScriptTaskName"; + + + public const string SqlAssessmentQueryInvalidOwnerUri = "SqlAssessmentQueryInvalidOwnerUri"; + + + public const string SqlAssessmentConnectingError = "SqlAssessmentConnectingError"; + + + public const string SqlAssessmentUnsuppoertedEdition = "SqlAssessmentUnsuppoertedEdition"; + + private Keys() { } diff --git a/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.resx b/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.resx index 56e08a60..c656689d 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.resx +++ b/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.resx @@ -1768,4 +1768,21 @@ Could not find the schema compare session to cancel + + Generate SQL Assessment script + + + + Not connected to a server + + + + Cannot connect to the server + + + + Unsupported engine edition {0} + . + Parameters: 0 - editionCode (int) + diff --git a/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.strings b/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.strings index 3391f094..1773a7c5 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.strings +++ b/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.strings @@ -816,4 +816,12 @@ ExtractInvalidVersion = Invalid version '{0}' passed. Version must be in the for PublishChangesTaskName = Apply schema compare changes SchemaCompareExcludeIncludeNodeNotFound = Failed to find the specified change in the model OpenScmpConnectionBasedModelParsingError = Error encountered while trying to parse connection information for endpoint '{0}' with error message '{1}' -SchemaCompareSessionNotFound = Could not find the schema compare session to cancel \ No newline at end of file +SchemaCompareSessionNotFound = Could not find the schema compare session to cancel + +############################################################################ +# SQL Assessment + +SqlAssessmentGenerateScriptTaskName = Generate SQL Assessment script +SqlAssessmentQueryInvalidOwnerUri = Not connected to a server +SqlAssessmentConnectingError = Cannot connect to the server +SqlAssessmentUnsuppoertedEdition(int editionCode) = Unsupported engine edition {0} \ No newline at end of file diff --git a/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.xlf b/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.xlf index 10139a7c..eab50707 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.xlf +++ b/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.xlf @@ -2056,6 +2056,32 @@ Encountered unsupported token {0} + + A SQL Assessment operation's Execute method should not be called more than once + A SQL Assessment operation's Execute method should not be called more than once + + + + Generate SQL Assessment script + Generate SQL Assessment script + + + + Not connected to a server + Not connected to a server + + + + Cannot connect to the server + Cannot connect to the server + + + + Unsupported engine edition {0} + Unsupported engine edition {0} + . + Parameters: 0 - editionCode (int) + \ No newline at end of file diff --git a/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/Contracts/AssessmentRequest.cs b/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/Contracts/AssessmentRequest.cs new file mode 100644 index 00000000..c360d854 --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/Contracts/AssessmentRequest.cs @@ -0,0 +1,129 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Collections.Generic; + +using Microsoft.SqlServer.Management.Assessment; + +namespace Microsoft.SqlTools.ServiceLayer.SqlAssessment.Contracts +{ + /// + /// Parameters for executing a query from a provided string + /// + public class AssessmentParams + { + /// + /// Gets or sets the owner uri to get connection from + /// + public string OwnerUri { get; set; } + + /// + /// Gets or sets the target type + /// + public SqlObjectType TargetType { get; set; } + } + + /// + /// Describes an item returned by SQL Assessment RPC methods + /// + public class AssessmentItemInfo + { + /// + /// Gets or sets assessment ruleset version. + /// + public string RulesetVersion { get; set; } + + /// + /// Gets or sets assessment ruleset name + /// + public string RulesetName { get; set; } + + /// + /// Gets or sets assessed target's type. + /// Supported values: 1 - server, 2 - database. + /// + public SqlObjectType TargetType { get; set; } + + /// + /// Gets or sets the assessed object's name. + /// + public string TargetName { get; set; } + + /// + /// Gets or sets check's ID. + /// + public string CheckId { get; set; } + + /// + /// Gets or sets tags assigned to this item. + /// + public string[] Tags { get; set; } + + /// + /// Gets or sets a display name for this item. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets a brief description of the item's purpose. + /// + public string Description { get; set; } + + /// + /// Gets or sets a containing + /// an link to a page providing detailed explanation + /// of the best practice. + /// + public string HelpLink { get; set; } + + /// + /// Gets or sets a indicating + /// severity level assigned to this items. + /// Values are: "Information", "Warning", "Critical". + /// + public string Level { get; set; } + } + + /// + /// Generic SQL Assessment Result + /// + /// + /// Result item's type derived from + /// + public class AssessmentResultData + where T : AssessmentItemInfo + { + /// + /// Gets the collection of assessment results. + /// + public List Items { get; } = new List(); + + /// + /// Gets or sets SQL Assessment API version. + /// + public string ApiVersion { get; set; } + } + + /// + /// Generic SQL Assessment Result + /// + /// + /// Result item's type derived from + /// + public class AssessmentResult : AssessmentResultData + where T : AssessmentItemInfo + { + /// + /// Gets or sets a value indicating + /// if assessment operation was successful. + /// + public bool Success { get; set; } + + /// + /// Gets or sets an status message for the operation. + /// + public string ErrorMessage { get; set; } + } +} diff --git a/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/Contracts/GenerateScriptRequest.cs b/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/Contracts/GenerateScriptRequest.cs new file mode 100644 index 00000000..ead2b543 --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/Contracts/GenerateScriptRequest.cs @@ -0,0 +1,56 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Collections.Generic; +using Microsoft.SqlTools.Hosting.Protocol.Contracts; +using Microsoft.SqlTools.ServiceLayer.TaskServices; +using Microsoft.SqlTools.ServiceLayer.Utility; + +namespace Microsoft.SqlTools.ServiceLayer.SqlAssessment.Contracts +{ + /// + /// Parameters for executing a query from a provided string + /// + public class GenerateScriptParams + { + /// + /// Gets or sets a list of assessment result items + /// to be written to a table + /// + public List Items { get; set; } + + public TaskExecutionMode TaskExecutionMode { get; set; } + + public string TargetServerName { get; set; } + + public string TargetDatabaseName { get; set; } + } + + public class GenerateScriptResult + { + /// + /// Gets or sets a value indicating + /// if assessment operation was successful + /// + public bool Success { get; set; } + + /// + /// Gets or sets an status message for the operation + /// + public string ErrorMessage { get; set; } + + /// + /// Gets or sets script text + /// + public string Script { get; set; } + } + + public class GenerateScriptRequest + { + public static readonly + RequestType Type = + RequestType.Create("assessment/generateScript"); + } +} diff --git a/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/Contracts/GetAssessmentItemsRequest.cs b/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/Contracts/GetAssessmentItemsRequest.cs new file mode 100644 index 00000000..b194da7e --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/Contracts/GetAssessmentItemsRequest.cs @@ -0,0 +1,33 @@ +// +// 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; + +namespace Microsoft.SqlTools.ServiceLayer.SqlAssessment.Contracts +{ + /// + /// Parameters for executing a query from a provided string + /// + public class GetAssessmentItemsParams : AssessmentParams + { + // a placeholder for future specialization + } + + /// + /// Describes a check used to assess SQL Server objects. + /// + public class CheckInfo : AssessmentItemInfo + { + // a placeholder for future specialization + } + + + public class GetAssessmentItemsRequest + { + public static readonly RequestType> Type = + RequestType>.Create( + "assessment/getAssessmentItems"); + } +} diff --git a/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/Contracts/InvokeRequest.cs b/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/Contracts/InvokeRequest.cs new file mode 100644 index 00000000..6df4a01d --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/Contracts/InvokeRequest.cs @@ -0,0 +1,82 @@ +// +// 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 Microsoft.SqlTools.Hosting.Protocol.Contracts; +using Microsoft.SqlTools.ServiceLayer.Utility; + +namespace Microsoft.SqlTools.ServiceLayer.SqlAssessment.Contracts +{ + /// + /// Parameters for executing a query from a provided string + /// + public class InvokeParams : AssessmentParams + { + // a placeholder for future specialization + } + + /// + /// SQL Assessment result item kind. + /// + /// + /// SQL Assessment run is a set of checks. Every check + /// may return a result item. Normally it is a note containing + /// recommendations on improving target's configuration. + /// But some checks may fail to obtain data due to access + /// restrictions or data integrity. In this case + /// the check produces an error or a warning. + /// + public enum AssessmentResultItemKind + { + /// + /// SQL Assessment item contains recommendation + /// + Note = 0, + + /// + /// SQL Assessment item contains a warning on + /// limited assessment capabilities + /// + Warning = 1, + + /// + /// SQL Assessment item contain a description of + /// error occured in the course of assessment run + /// + Error = 2 + } + + /// + /// Describes an assessment result item + /// containing a recommendation based on best practices. + /// + public class AssessmentResultItem : AssessmentItemInfo + { + /// + /// Gets or sets a message to the user + /// containing the recommendation. + /// + public string Message { get; set; } + + /// + /// Gets or sets result type: + /// 0 - real result, 1 - warning, 2 - error. + /// + public AssessmentResultItemKind Kind { get; set; } + + /// + /// Gets or sets date and time + /// when the item had been acquired. + /// + public DateTimeOffset Timestamp { get; set; } + } + + public class InvokeRequest + { + public static readonly + RequestType> Type = + RequestType>.Create("assessment/invoke"); + } +} diff --git a/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/GenerateScriptOperation.cs b/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/GenerateScriptOperation.cs new file mode 100644 index 00000000..65a1a0b3 --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/GenerateScriptOperation.cs @@ -0,0 +1,151 @@ +// +// 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.Diagnostics; +using System.Globalization; +using System.Text; +using System.Threading; +using Microsoft.SqlTools.ServiceLayer.Management; +using Microsoft.SqlTools.ServiceLayer.SqlAssessment.Contracts; +using Microsoft.SqlTools.ServiceLayer.TaskServices; +using Microsoft.SqlTools.Utility; + +namespace Microsoft.SqlTools.ServiceLayer.SqlAssessment +{ + /// + /// Generates a script storing SQL Assessment results to a table. + /// + internal sealed class GenerateScriptOperation : ITaskOperation, IDisposable + { + private readonly CancellationTokenSource cancellation = new CancellationTokenSource(); + + private bool disposed = false; + + /// + /// Gets the unique id associated with this instance. + /// + public string OperationId { get; set; } + + /// + /// Gets the parameters containing assessment results + /// to be stored in a data table. + /// + public GenerateScriptParams Parameters { get; } + + /// + /// Gets or sets the error message text + /// if an error occurred during task execution + /// + public string ErrorMessage { get; set; } + + /// + /// Gets or sets the sql task that's executing the operation + /// + public SqlTask SqlTask { get; set; } + + public GenerateScriptOperation(GenerateScriptParams parameters) + { + Validate.IsNotNull(nameof(parameters), parameters); + Parameters = parameters; + } + + /// + /// Execute a task + /// + /// Task execution mode (e.g. script or execute) + /// + /// The method has been called twice in parallel for the same instance. + /// + public void Execute(TaskExecutionMode mode) + { + try + { + var scriptText = GenerateScript(Parameters, cancellation.Token); + if (scriptText != null) + { + SqlTask?.AddScript(SqlTaskStatus.Succeeded, scriptText); + } + } + catch (Exception e) + { + ErrorMessage = e.Message; + Logger.Write(TraceEventType.Error, string.Format( + CultureInfo.InvariantCulture, + "SQL Assessment: generate script operation failed with exception {0}", + e.Message)); + + throw; + } + } + + public void Cancel() + { + cancellation.Cancel(); + } + + #region Helpers + + internal static string GenerateScript(GenerateScriptParams generateScriptParams, + CancellationToken cancellationToken) + { + const string scriptPrologue = + @"IF (NOT EXISTS(SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'dbo' AND TABLE_NAME = 'AssessmentResult')) +BEGIN + CREATE TABLE [dbo].[AssessmentResult]( + [CheckName] [nvarchar](max) NOT NULL, + [CheckId] [nvarchar](max) NOT NULL, + [RulesetName] [nvarchar](max) NOT NULL, + [RulesetVersion] [nvarchar](max) NOT NULL, + [Severity] [nvarchar](max) NOT NULL, + [Message] [nvarchar](max) NOT NULL, + [TargetPath] [nvarchar](max) NOT NULL, + [TargetType] [nvarchar](max) NOT NULL, + [HelpLink] [nvarchar](max) NOT NULL, + [Timestamp] [datetimeoffset](7) NOT NULL + ) +END +GO +INSERT INTO [dbo].[AssessmentResult] ([CheckName],[CheckId],[RulesetName],[RulesetVersion],[Severity],[Message],[TargetPath],[TargetType],[HelpLink],[Timestamp]) +VALUES"; + + var sb = new StringBuilder(); + if (generateScriptParams.Items != null) + { + sb.Append(scriptPrologue); + foreach (var item in generateScriptParams.Items) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (item.Kind == AssessmentResultItemKind.Note) + { + sb.Append( + $"\r\n('{CUtils.EscapeStringSQuote(item.DisplayName)}','{CUtils.EscapeStringSQuote(item.CheckId)}','{CUtils.EscapeStringSQuote(item.RulesetName)}','{item.RulesetVersion}','{item.Level}','{CUtils.EscapeStringSQuote(item.Message)}','{CUtils.EscapeStringSQuote(item.TargetName)}','{item.TargetType}','{CUtils.EscapeStringSQuote(item.HelpLink)}','{item.Timestamp:yyyy-MM-dd hh:mm:ss.fff zzz}'),"); + } + } + + sb.Length -= 1; + } + + return sb.ToString(); + } + + #endregion + + #region IDisposable + + public void Dispose() + { + if (!disposed) + { + Cancel(); + cancellation.Dispose(); + disposed = true; + } + } + + #endregion + } +} diff --git a/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/SqlAssessmentService.cs b/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/SqlAssessmentService.cs new file mode 100644 index 00000000..99613436 --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/SqlAssessment/SqlAssessmentService.cs @@ -0,0 +1,503 @@ +// +// 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.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; + +using Microsoft.SqlServer.Management.Assessment; +using Microsoft.SqlServer.Management.Assessment.Configuration; +using Microsoft.SqlTools.Hosting.Protocol; +using Microsoft.SqlTools.ServiceLayer.Connection; +using Microsoft.SqlTools.ServiceLayer.Connection.Contracts; +using Microsoft.SqlTools.ServiceLayer.Connection.ReliableConnection; +using Microsoft.SqlTools.ServiceLayer.Hosting; +using Microsoft.SqlTools.ServiceLayer.SqlAssessment.Contracts; +using Microsoft.SqlTools.ServiceLayer.SqlContext; +using Microsoft.SqlTools.ServiceLayer.TaskServices; +using Microsoft.SqlTools.ServiceLayer.Utility; +using Microsoft.SqlTools.ServiceLayer.Workspace; +using Microsoft.SqlTools.Utility; + +using AssessmentResultItem = Microsoft.SqlTools.ServiceLayer.SqlAssessment.Contracts.AssessmentResultItem; +using ConnectionType = Microsoft.SqlTools.ServiceLayer.Connection.ConnectionType; +using InvokeParams = Microsoft.SqlTools.ServiceLayer.SqlAssessment.Contracts.InvokeParams; +using InvokeRequest = Microsoft.SqlTools.ServiceLayer.SqlAssessment.Contracts.InvokeRequest; + +namespace Microsoft.SqlTools.ServiceLayer.SqlAssessment +{ + /// + /// Service for running SQL Assessment. + /// + public sealed class SqlAssessmentService : IDisposable + { + private const string ApiVersion = "1.0"; + + #region Singleton Instance Implementation + + private static readonly Lazy LazyInstance + = new Lazy(() => new SqlAssessmentService()); + + internal SqlAssessmentService( + ConnectionService connService, + WorkspaceService workspaceService) + { + ConnectionService = connService; + WorkspaceService = workspaceService; + } + + private SqlAssessmentService() + { + ConnectionService = ConnectionService.Instance; + WorkspaceService = WorkspaceService.Instance; + } + + /// + /// Singleton instance of the query execution service + /// + public static SqlAssessmentService Instance => LazyInstance.Value; + + #endregion + + #region Properties + + /// + /// Gets the used to run assessment operations. + /// + private Engine Engine { get; } = new Engine(); + + /// + /// Gets the instance of the connection service, + /// used to get the connection info for a given owner URI. + /// + private ConnectionService ConnectionService { get; } + + private WorkspaceService WorkspaceService { get; } + + /// + /// Holds a map from the + /// to a that is being ran. + /// + private readonly Lazy> activeRequests = + new Lazy>(() => new ConcurrentDictionary()); + + /// + /// Gets a map from the + /// to a that is being ran. + /// + internal ConcurrentDictionary ActiveRequests => activeRequests.Value; + + #endregion + + /// + /// Initializes the service with the service host, + /// registers request handlers and shutdown event handler. + /// + /// The service host instance to register with + public void InitializeService(ServiceHost serviceHost) + { + // Register handlers for requests + serviceHost.SetRequestHandler(InvokeRequest.Type, HandleInvokeRequest); + serviceHost.SetRequestHandler(GetAssessmentItemsRequest.Type, HandleGetAssessmentItemsRequest); + serviceHost.SetRequestHandler(GenerateScriptRequest.Type, HandleGenerateScriptRequest); + + // Register handler for shutdown event + serviceHost.RegisterShutdownTask((shutdownParams, requestContext) => + { + Dispose(); + return Task.FromResult(0); + }); + } + + #region Request Handlers + + internal Task HandleGetAssessmentItemsRequest( + GetAssessmentItemsParams itemsParams, + RequestContext> requestContext) + { + return this.HandleAssessmentRequest(requestContext, itemsParams, this.GetAssessmentItems); + } + + internal Task HandleInvokeRequest( + InvokeParams invokeParams, + RequestContext> requestContext) + { + return this.HandleAssessmentRequest(requestContext, invokeParams, this.InvokeSqlAssessment); + } + + internal async Task HandleGenerateScriptRequest( + GenerateScriptParams parameters, + RequestContext requestContext) + { + GenerateScriptOperation operation = null; + try + { + operation = new GenerateScriptOperation(parameters); + TaskMetadata metadata = new TaskMetadata + { + TaskOperation = operation, + TaskExecutionMode = parameters.TaskExecutionMode, + ServerName = parameters.TargetServerName, + DatabaseName = parameters.TargetDatabaseName, + Name = SR.SqlAssessmentGenerateScriptTaskName + }; + + var _ = SqlTaskManager.Instance.CreateAndRun(metadata); + + await requestContext.SendResult(new ResultStatus + { + Success = true, + ErrorMessage = operation.ErrorMessage + }); + } + catch (Exception e) + { + Logger.Write(TraceEventType.Error, "SQL Assessment: failed to generate a script. Error: " + e); + await requestContext.SendResult(new ResultStatus() + { + Success = false, + ErrorMessage = operation == null ? e.Message : operation.ErrorMessage, + }); + } + } + + #endregion + + #region Helpers + + private async Task HandleAssessmentRequest( + RequestContext> requestContext, + AssessmentParams requestParams, + Func>> assessmentFunction) + where TResult : AssessmentItemInfo + { + try + { + string randomUri = Guid.NewGuid().ToString(); + + // get connection + if (!ConnectionService.TryFindConnection(requestParams.OwnerUri, out var connInfo)) + { + await requestContext.SendError(SR.SqlAssessmentQueryInvalidOwnerUri); + return; + } + + ConnectParams connectParams = new ConnectParams + { + OwnerUri = randomUri, + Connection = connInfo.ConnectionDetails, + Type = ConnectionType.Default + }; + + if(!connInfo.TryGetConnection(ConnectionType.Default, out var connection)) + { + await requestContext.SendError(SR.SqlAssessmentConnectingError); + } + + var workTask = CallAssessmentEngine( + requestParams, + connectParams, + randomUri, + assessmentFunction) + .ContinueWith(async tsk => + { + await requestContext.SendResult(tsk.Result); + }); + + ActiveRequests.TryAdd(randomUri, workTask); + } + catch (Exception ex) + { + if (ex is StackOverflowException || ex is OutOfMemoryException) + { + throw; + } + + await requestContext.SendError(ex.ToString()); + } + } + + /// + /// This function obtains a live connection, then calls + /// an assessment operation specified by + /// + /// + /// SQL Assessment result item type. + /// + /// + /// Request parameters passed from the host. + /// + /// + /// Connection parameters used to identify and access the target. + /// + /// + /// An URI identifying the request task to enable concurrent execution. + /// + /// + /// A function performing assessment operation for given target. + /// + /// + /// Returns for given target. + /// + internal async Task> CallAssessmentEngine( + AssessmentParams requestParams, + ConnectParams connectParams, + string taskUri, + Func>> assessmentFunc) + where TResult : AssessmentItemInfo + + { + var result = new AssessmentResult + { + ApiVersion = ApiVersion + }; + + await ConnectionService.Connect(connectParams); + + var connection = await ConnectionService.Instance.GetOrOpenConnection(taskUri, ConnectionType.Query); + + try + { + var serverInfo = ReliableConnectionHelper.GetServerVersion(connection); + var hostInfo = ReliableConnectionHelper.GetServerHostInfo(connection); + + var server = new SqlObjectLocator + { + Connection = connection, + EngineEdition = GetEngineEdition(serverInfo.EngineEditionId), + Name = serverInfo.ServerName, + ServerName = serverInfo.ServerName, + Type = SqlObjectType.Server, + Urn = serverInfo.ServerName, + Version = Version.Parse(serverInfo.ServerVersion), + Platform = hostInfo.Platform + }; + + switch (requestParams.TargetType) + { + case SqlObjectType.Server: + Logger.Write( + TraceEventType.Verbose, + $"SQL Assessment: running an operation on a server, platform:{server.Platform}, edition:{server.EngineEdition.ToString()}, version:{server.Version}"); + + result.Items.AddRange(await assessmentFunc(server)); + + Logger.Write( + TraceEventType.Verbose, + $"SQL Assessment: finished an operation on a server, platform:{server.Platform}, edition:{server.EngineEdition.ToString()}, version:{server.Version}"); + break; + case SqlObjectType.Database: + var db = GetDatabaseLocator(server, connection.Database); + Logger.Write( + TraceEventType.Verbose, + $"SQL Assessment: running an operation on a database, platform:{server.Platform}, edition:{server.EngineEdition.ToString()}, version:{server.Version}"); + + result.Items.AddRange(await assessmentFunc(db)); + + Logger.Write( + TraceEventType.Verbose, + $"SQL Assessment: finished an operation on a database, platform:{server.Platform}, edition:{server.EngineEdition.ToString()}, version:{server.Version}"); + break; + } + + result.Success = true; + } + finally + { + ActiveRequests.TryRemove(taskUri, out _); + ConnectionService.Disconnect(new DisconnectParams { OwnerUri = taskUri, Type = null }); + } + + return result; + } + + /// + /// Invokes SQL Assessment and formats results. + /// + /// + /// A sequence of target servers or databases to be assessed. + /// + /// + /// Returns a + /// containing assessment results. + /// + /// + /// Internal for testing + /// + internal async Task> InvokeSqlAssessment(SqlObjectLocator target) + { + var resultsList = await Engine.GetAssessmentResultsList(target); + Logger.Write(TraceEventType.Verbose, $"SQL Assessment: got {resultsList.Count} results."); + + return resultsList.Select(TranslateAssessmentResult).ToList(); + } + + /// + /// Gets the list of checks for given target servers or databases. + /// + /// + /// A sequence of target servers or databases. + /// + /// + /// Returns an + /// containing checks available for given . + /// + internal Task> GetAssessmentItems(SqlObjectLocator target) + { + var result = new List(); + + var resultsList = Engine.GetChecks(target).ToList(); + Logger.Write(TraceEventType.Verbose, $"SQL Assessment: got {resultsList.Count} items."); + + foreach (var r in resultsList) + { + var item = new CheckInfo() + { + CheckId = r.Id, + Description = r.Description, + DisplayName = r.DisplayName, + HelpLink = r.HelpLink, + Level = r.Level.ToString(), + TargetName = $"{target.ServerName}/{target.Name}", + Tags = r.Tags.ToArray(), + TargetType = target.Type, + RulesetName = Engine.Configuration.DefaultRuleset.Name, + RulesetVersion = Engine.Configuration.DefaultRuleset.Version.ToString() + }; + + result.Add(item); + } + + return Task.FromResult(result); + } + + private AssessmentResultItem TranslateAssessmentResult(IAssessmentResult r) + { + var item = new AssessmentResultItem + { + CheckId = r.Check.Id, + Description = r.Check.Description, + DisplayName = r.Check.DisplayName, + HelpLink = r.Check.HelpLink, + Level = r.Check.Level.ToString(), + Message = r.Message, + TargetName = r.TargetPath, + Tags = r.Check.Tags.ToArray(), + TargetType = r.TargetType, + RulesetVersion = Engine.Configuration.DefaultRuleset.Version.ToString(), + RulesetName = Engine.Configuration.DefaultRuleset.Name, + Timestamp = r.Timestamp + }; + + if (r is IAssessmentNote) + { + item.Kind = AssessmentResultItemKind.Note; + } + else if (r is IAssessmentWarning) + { + item.Kind = AssessmentResultItemKind.Warning; + } + else if (r is IAssessmentError) + { + item.Kind = AssessmentResultItemKind.Error; + } + + return item; + } + + /// + /// Constructs a for specified database. + /// + /// Target server locator. + /// Target database name. + /// Returns a locator for target database. + private static SqlObjectLocator GetDatabaseLocator(SqlObjectLocator server, string databaseName) + { + return new SqlObjectLocator + { + Connection = server.Connection, + EngineEdition = server.EngineEdition, + Name = databaseName, + Platform = server.Platform, + ServerName = server.Name, + Type = SqlObjectType.Database, + Urn = $"{server.Name}/{databaseName}", + Version = server.Version + }; + } + + /// + /// Converts numeric of engine edition + /// returned by SERVERPROPERTY('EngineEdition'). + /// + /// + /// A number returned by SERVERPROPERTY('EngineEdition'). + /// + /// Engine edition is not supported. + /// + /// Returns a + /// corresponding to the . + /// + private static SqlEngineEdition GetEngineEdition(int representation) + { + switch (representation) + { + case 1: return SqlEngineEdition.PersonalOrDesktopEngine; + case 2: return SqlEngineEdition.Standard; + case 3: return SqlEngineEdition.Enterprise; + case 4: return SqlEngineEdition.Express; + case 5: return SqlEngineEdition.AzureDatabase; + case 6: return SqlEngineEdition.DataWarehouse; + case 7: return SqlEngineEdition.StretchDatabase; + case 8: return SqlEngineEdition.ManagedInstance; + default: + throw new ArgumentOutOfRangeException(nameof(representation), + SR.SqlAssessmentUnsuppoertedEdition(representation)); + } + } + + #endregion + + #region IDisposable Implementation + + private bool disposed; + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (disposed) + { + return; + } + + if (disposing) + { + foreach (var request in ActiveRequests) + { + request.Value.Dispose(); + } + + ActiveRequests.Clear(); + } + + disposed = true; + } + + ~SqlAssessmentService() + { + Dispose(false); + } + + #endregion + } +} diff --git a/test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/SqlAssessment/SqlAssessmentServiceTests.cs b/test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/SqlAssessment/SqlAssessmentServiceTests.cs new file mode 100644 index 00000000..cd6bcd15 --- /dev/null +++ b/test/Microsoft.SqlTools.ServiceLayer.IntegrationTests/SqlAssessment/SqlAssessmentServiceTests.cs @@ -0,0 +1,183 @@ +// +// 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.Generic; +using System.Reflection; +using System.Threading.Tasks; + +using Microsoft.SqlServer.Management.Assessment; +using Microsoft.SqlTools.ServiceLayer.Connection; +using Microsoft.SqlTools.ServiceLayer.Connection.Contracts; +using Microsoft.SqlTools.ServiceLayer.IntegrationTests.Utility; +using Microsoft.SqlTools.ServiceLayer.SqlAssessment; +using Microsoft.SqlTools.ServiceLayer.SqlAssessment.Contracts; +using Microsoft.SqlTools.ServiceLayer.Test.Common; +using NUnit.Framework; +using Xunit; +using Assert = Xunit.Assert; + +namespace Microsoft.SqlTools.ServiceLayer.IntegrationTests.SqlAssessment +{ + public class SqlAssessmentServiceTests + { + private delegate Task> AssessmentMethod(SqlObjectLocator locator); + + private static readonly string[] AllowedSeverityLevels = { "Information", "Warning", "Critical" }; + + [Fact] + public async void GetAssessmentItemsServerTest() + { + var response = await CallAssessment( + nameof(SqlAssessmentService.GetAssessmentItems), + SqlObjectType.Server); + + Assert.All( + response.Items, + i => + { + AssertInfoPresent(i); + }); + } + + [Fact] + public async void InvokeSqlAssessmentServerTest() + { + var response = await CallAssessment( + nameof(SqlAssessmentService.InvokeSqlAssessment), + SqlObjectType.Server); + + + Assert.All( + response.Items, + i => + { + Assert.NotNull(i.Message); + Assert.NotEmpty(i.Message); + + if (i.Kind == 0) + { + AssertInfoPresent(i); + } + }); + } + + [Fact] + public async void GetAssessmentItemsDatabaseTest() + { + const string DatabaseName = "tempdb"; + var response = await CallAssessment( + nameof(SqlAssessmentService.GetAssessmentItems), + SqlObjectType.Database, + DatabaseName); + + Assert.All( + response.Items, + i => + { + StringAssert.EndsWith("/" + DatabaseName, i.TargetName); + AssertInfoPresent(i); + }); + } + + [Fact] + public async void InvokeSqlAssessmentIDatabaseTest() + { + const string DatabaseName = "tempdb"; + var response = await CallAssessment( + nameof(SqlAssessmentService.InvokeSqlAssessment), + SqlObjectType.Database, + DatabaseName); + + Assert.All( + response.Items, + i => + { + StringAssert.EndsWith("/" + DatabaseName, i.TargetName); + Assert.NotNull(i.Message); + Assert.NotEmpty(i.Message); + + if (i.Kind == 0) + { + AssertInfoPresent(i); + } + }); + } + + private static async Task> CallAssessment( + string methodName, + SqlObjectType sqlObjectType, + string databaseName = "master") + where TResult : AssessmentItemInfo + { + var liveConnection = LiveConnectionHelper.InitLiveConnectionInfo(databaseName); + var connInfo = liveConnection.ConnectionInfo; + + AssessmentResult response; + + using (var service = new SqlAssessmentService( + TestServiceProvider.Instance.ConnectionService, + TestServiceProvider.Instance.WorkspaceService)) + { + string randomUri = Guid.NewGuid().ToString(); + AssessmentParams requestParams = + new AssessmentParams { OwnerUri = randomUri, TargetType = sqlObjectType }; + ConnectParams connectParams = new ConnectParams + { + OwnerUri = requestParams.OwnerUri, + Connection = connInfo.ConnectionDetails, + Type = ConnectionType.Default + }; + + var methodInfo = typeof(SqlAssessmentService).GetMethod( + methodName, + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(methodInfo); + + var func = (AssessmentMethod)Delegate.CreateDelegate( + typeof(AssessmentMethod), + service, + methodInfo); + + response = await service.CallAssessmentEngine( + requestParams, + connectParams, + randomUri, + t => func(t)); + } + + Assert.NotNull(response); + if (response.Success) + { + Assert.All( + response.Items, + i => + { + Assert.Equal(sqlObjectType, i.TargetType); + Assert.Contains(i.Level, AllowedSeverityLevels); + }); + } + + return response; + } + + private void AssertInfoPresent(AssessmentItemInfo item) + { + Assert.NotNull(item.CheckId); + Assert.NotEmpty(item.CheckId); + Assert.NotNull(item.DisplayName); + Assert.NotEmpty(item.DisplayName); + Assert.NotNull(item.Description); + Assert.NotEmpty(item.Description); + Assert.NotNull(item.Tags); + Assert.All(item.Tags, t => + { + Assert.NotNull(t); + Assert.NotEmpty(t); + }); + } + } +} diff --git a/test/Microsoft.SqlTools.ServiceLayer.UnitTests/SqlAssessment/GenerateScriptOperationTests.cs b/test/Microsoft.SqlTools.ServiceLayer.UnitTests/SqlAssessment/GenerateScriptOperationTests.cs new file mode 100644 index 00000000..77ab90f3 --- /dev/null +++ b/test/Microsoft.SqlTools.ServiceLayer.UnitTests/SqlAssessment/GenerateScriptOperationTests.cs @@ -0,0 +1,159 @@ +// +// 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.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SqlServer.Management.Assessment; +using Microsoft.SqlTools.ServiceLayer.SqlAssessment; +using Microsoft.SqlTools.ServiceLayer.SqlAssessment.Contracts; +using Microsoft.SqlTools.ServiceLayer.TaskServices; +using Moq; +using NUnit.Framework; +using Xunit; +using Assert = Xunit.Assert; + +namespace Microsoft.SqlTools.ServiceLayer.UnitTests.SqlAssessment +{ + public class GenerateScriptOperationTests + { + private static readonly GenerateScriptParams SampleParams = new GenerateScriptParams + { + Items = new List + { + new AssessmentResultItem + { + CheckId = "C1", + Description = "Desc1", + DisplayName = "DN1", + HelpLink = "HL1", + Kind = AssessmentResultItemKind.Note, + Level = "Information", + Message = "Msg'1", + TargetName = "proj[*]_dev", + TargetType = SqlObjectType.Server, + Timestamp = new DateTimeOffset(2001, 5, 25, 13, 42, 00, TimeSpan.Zero), + RulesetName = "Microsoft Ruleset", + RulesetVersion = "1.3" + }, + new AssessmentResultItem + { + CheckId = "C-2", + Description = "Desc2", + DisplayName = "D N2", + HelpLink = "http://HL2", + Kind = AssessmentResultItemKind.Warning, + Level = "Warning", + Message = "Msg'1", + TargetName = "proj[*]_devW", + TargetType = SqlObjectType.Database, + Timestamp = new DateTimeOffset(2001, 5, 25, 13, 42, 00, TimeSpan.FromHours(3)), + RulesetName = "Microsoft Ruleset", + RulesetVersion = "1.3" + }, + new AssessmentResultItem + { + CheckId = "C'3", + Description = "Des'c3", + DisplayName = "D'N1", + HelpLink = "HL'1", + Kind = AssessmentResultItemKind.Error, + Level = "Critical", + Message = "Msg'1", + TargetName = "proj[*]_devE", + TargetType = SqlObjectType.Server, + Timestamp = new DateTimeOffset(2001, 5, 25, 13, 42, 00, TimeSpan.FromMinutes(-90)), + RulesetName = "Microsoft Ruleset", + RulesetVersion = "1.3" + }, + new AssessmentResultItem + { + CheckId = "C-2", + Description = "Desc2", + DisplayName = "D N2", + HelpLink = "http://HL2", + Kind = AssessmentResultItemKind.Note, + Level = "Warning", + Message = "Msg'1", + TargetName = "proj[*]_dev", + TargetType = SqlObjectType.Database, + Timestamp = new DateTimeOffset(2001, 5, 25, 13, 42, 00, TimeSpan.FromHours(3)), + RulesetName = "Microsoft Ruleset", + RulesetVersion = "1.3" + }, + new AssessmentResultItem + { + CheckId = "C'3", + Description = "Des'c3", + DisplayName = "D'N1", + HelpLink = "HL'1", + Kind = AssessmentResultItemKind.Note, + Level = "Critical", + Message = "Msg'1", + TargetName = "proj[*]_dev", + TargetType = SqlObjectType.Server, + Timestamp = new DateTimeOffset(2001, 5, 25, 13, 42, 00, TimeSpan.FromMinutes(-90)), + RulesetName = "Microsoft Ruleset", + RulesetVersion = "1.3" + } + } + }; + + private const string SampleScript = + @"IF (NOT EXISTS(SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'dbo' AND TABLE_NAME = 'AssessmentResult')) +BEGIN + CREATE TABLE [dbo].[AssessmentResult]( + [CheckName] [nvarchar](max) NOT NULL, + [CheckId] [nvarchar](max) NOT NULL, + [RulesetName] [nvarchar](max) NOT NULL, + [RulesetVersion] [nvarchar](max) NOT NULL, + [Severity] [nvarchar](max) NOT NULL, + [Message] [nvarchar](max) NOT NULL, + [TargetPath] [nvarchar](max) NOT NULL, + [TargetType] [nvarchar](max) NOT NULL, + [HelpLink] [nvarchar](max) NOT NULL, + [Timestamp] [datetimeoffset](7) NOT NULL + ) +END +GO +INSERT INTO [dbo].[AssessmentResult] ([CheckName],[CheckId],[RulesetName],[RulesetVersion],[Severity],[Message],[TargetPath],[TargetType],[HelpLink],[Timestamp]) +VALUES +('DN1','C1','Microsoft Ruleset','1.3','Information','Msg''1','proj[*]_dev','Server','HL1','2001-05-25 01:42:00.000 +00:00'), +('D N2','C-2','Microsoft Ruleset','1.3','Warning','Msg''1','proj[*]_dev','Database','http://HL2','2001-05-25 01:42:00.000 +03:00'), +('D''N1','C''3','Microsoft Ruleset','1.3','Critical','Msg''1','proj[*]_dev','Server','HL''1','2001-05-25 01:42:00.000 -01:30')"; + + [Fact] + public void GenerateScriptTest() + { + var scriptText = GenerateScriptOperation.GenerateScript(SampleParams, CancellationToken.None); + Assert.Equal(SampleScript, scriptText); + } + + [Fact] + public void ExecuteTest() + { + var subject = new GenerateScriptOperation(SampleParams); + var taskMetadata = new TaskMetadata(); + using (var sqlTask = new SqlTask(taskMetadata, DummyOpFunction, DummyOpFunction)) + { + subject.SqlTask = sqlTask; + sqlTask.ScriptAdded += ValidateScriptAdded; + subject.Execute(TaskExecutionMode.Script); + } + } + + private void ValidateScriptAdded(object sender, TaskEventArgs e) + { + Assert.Equal(SqlTaskStatus.Succeeded, e.TaskData.Status); + Assert.Equal(SampleScript, e.TaskData.Script); + } + + private static Task DummyOpFunction(SqlTask _) + { + return Task.FromResult(new TaskResult() {TaskStatus = SqlTaskStatus.Succeeded}); + } + } +}