// // 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.Data.Common; using System.Linq; using Microsoft.Data.SqlClient; using Microsoft.SqlTools.ServiceLayer.AutoParameterizaition; using Microsoft.SqlTools.ServiceLayer.AutoParameterizaition.Exceptions; using Microsoft.SqlTools.ServiceLayer.Workspace.Contracts; using NUnit.Framework; using static System.Linq.Enumerable; namespace Microsoft.SqlTools.ServiceLayer.UnitTests.AutoParameterization { [TestFixture] /// /// Parameterization for Always Encrypted is a feature that automatically converts Transact-SQL variables /// into query parameters (instances of SqlParameter Class). This allows the underlying .NET Framework /// Data Provider for SQL Server to detect data targeting encrypted columns, and to encrypt such data before /// sending it to the database. /// Without parameterization, the .NET Framework Data Provider passes each statement in the Query Editor, /// as a non-parameterized query. If the query contains literals or Transact-SQL variables that target encrypted columns, /// the.NET Framework Data Provider for SQL Server won't be able to detect and encrypt them, before sending the query to the database. /// As a result, the query will fail due to type mismatch (between the plaintext literal Transact-SQL variable and the encrypted column). /// This class unit-tests the functionality os the Parameterization for Always Encrypted feature. /// public class SqlParameterizerTests { #region Query Parameterization Tests /// /// SqlParameterizer should parameterize Transact-SQL variables that meet the following pre-requisite conditions: /// - Are declared and initialized in the same statement(inline initialization). /// - Are initialized using a single literal. /// [Test] public void SqlParameterizerShouldParameterizeValidVariables() { const string ssn = "795-73-9838"; const string birthday = "19990104"; const string salary = "$30000"; string sql = $@" DECLARE @SSN CHAR(11) = '{ssn}' DECLARE @BIRTHDAY DATE = '{birthday}' DECLARE @SALARY MONEY = '{salary}' SELECT * FROM [dbo].[Patients] WHERE [SSN] = @SSN AND [BIRTHDAY] = @BIRTHDAY AND [SALARY] = @SALARY"; DbCommand command = new SqlCommand { CommandText = sql }; command.Parameterize(); Assert.AreEqual(expected: 3, actual: command.Parameters.Count); } /// /// SqlParameterizer should not attempt parameterize Transact-SQL variables that do not meet the following pre-requisite conditions: /// - Are declared and initialized in the same statement(inline initialization). /// - Are initialized using a single literal. /// The first variable has initialization separate from declaration and so should not be parameterized. /// The second is using a function used instead of a literal and so should not be parameterized. /// The third is using an expression used instead of a literal and so should not be parameterized. /// [Test] public void SqlParameterizerShouldNotParameterizeInvalidVariables() { string sql = $@" DECLARE @Name nvarchar(50); SET @Name = 'Abel'; DECLARE @StartDate date = GETDATE(); DECLARE @NewSalary money = @Salary * 1.1; SELECT * FROM [dbo].[Patients] WHERE [Name] = @Name AND [StartDate] = @StartDate AND [NewSalary] = @NewSalary"; DbCommand command = new SqlCommand { CommandText = sql }; command.Parameterize(); Assert.AreEqual(expected: 0, actual: command.Parameters.Count); } /// /// SQLDOM parser currently cannot handle very large scripts and runs out of memory. /// Batch statements larger than 300000 characters (Approximately 600 Kb) should /// throw ParameterizationScriptTooLargeException. /// [Test] public void SqlParameterizerShouldThrowWhenSqlIsTooLong() { string sqlLength_300 = $@" DECLARE @SSN CHAR(11) = '123-45-6789' DECLARE @BIRTHDAY DATE = '19990104' DECLARE @SALARY MONEY = '$30000' SELECT * FROM [dbo].[Patients] WHERE [N] = @SSN AND [B] = @BIRTHDAY AND [S] = @SALARY GO"; // SQL less than or equal to 300000 should pass string smallSql = string.Concat(Repeat(element: sqlLength_300, count: 1000)); DbCommand command1 = new SqlCommand { CommandText = smallSql }; command1.Parameterize(); // SQL greater than 300000 characters should throw string bigSql = string.Concat(Repeat(element: sqlLength_300, count: 1100)); DbCommand command2 = new SqlCommand { CommandText = bigSql }; Assert.Throws(() => command2.Parameterize()); } /// /// During parameterization, if we could not parse the SQL we will throw an ParameterizationParsingException. /// Better to catch the error here than on the server. /// [Test] public void SqlParameterizerShouldThrowWhenSqlIsInvalid() { string invalidSql = "THIS IS INVALID SQL"; string sql = string.Concat(Repeat(element: invalidSql, count: 1000)); DbCommand command = new SqlCommand { CommandText = sql }; Assert.Throws(() => command.Parameterize()); } /// /// While the SqlParameterizer should parameterize Transact-SQL variables that are declared and initialized /// in the same statement(inline initialization) and are initialized using a single literal, the type of the /// literal used for the initialization of the variable must also match the type in the variable declaration. /// If not, a ParameterizationFormatException should get thrown. /// [Test] public void SqlParameterizerShouldThrowWhenLiteralHasTypeMismatch() { // variable is declared an int but is getting set to character data string sql = $@" DECLARE @Number int = 'ABCDEFG' SELECT * FROM [dbo].[Table] WHERE [N] = @Number GO"; DbCommand command = new SqlCommand { CommandText = sql }; Assert.Throws(() => command.Parameterize()); } /// /// A side effect of the parameterization process is that, when a batch script was composed /// entirely of comments, the comments were stripped away and the CommandText /// property of the DbCommand would be replaced with an empty string. When this happens, /// the DbCommand object will throw an exception with the following message: /// BeginExecuteReader: CommandText property has not been initialized /// [Test] public void CommentOnlyBatchesShouldNotBeErasedFromCommandText() { string sql = $@" -- ALTER TABLE BatchParameterization -- ALTER COLUMN -- [unique_key] [UNIQUEIDENTIFIER] NOT NULL"; DbCommand command = new SqlCommand { CommandText = sql }; command.Parameterize(); Assert.False(string.IsNullOrEmpty(command.CommandText)); Assert.AreEqual(expected: sql, actual: command.CommandText); } #endregion #region Prarmeterization Codesense Tests /// /// When requesting a collection of ScriptFileMarker by calling the SqlParameterizer.CodeSense /// method, if a null script is passed in, the reuslt should be an empty collection. /// [Test] public void CodeSenseShouldReturnEmptyListWhenGivenANullScript() { string sql = null; IList result = SqlParameterizer.CodeSense(sql); Assert.NotNull(result); Assert.That(result, Is.Empty); } /// /// When requesting a collection of ScriptFileMarker by calling the SqlParameterizer.CodeSense /// method, if a script is passed in that contains no valid parameters, the reuslt should be an empty collection. /// [Test] public void CodeSenseShouldReturnEmptyListWhenGivenAParameterlessScript() { // SQL with no parameters string sql = $@" SELECT * FROM [dbo].[Patients] WHERE [N] = @SSN AND [B] = @BIRTHDAY AND [S] = @SALARY GO"; IList result = SqlParameterizer.CodeSense(sql); Assert.NotNull(result); Assert.That(result, Is.Empty); } /// /// SQLDOM parser currently cannot handle very large scripts and runs out of memory. /// SQL statements larger than 300000 characters (Approximately 600 Kb) should /// return a max string sength code sense item. These will be returned to ADS to display to the user as intelli-sense. /// [Test] public void CodeSenseShouldReturnMaxStringLengthScriptFileMarkerErrorItemWhenScriptIsTooLong() { // SQL length of 300 characters string sqlLength_300 = $@" DECLARE @SSN CHAR(11) = '123-45-6789' DECLARE @BIRTHDAY DATE = '19990104' DECLARE @SALARY MONEY = '$30000' SELECT * FROM [dbo].[Patients] WHERE [N] = @SSN AND [B] = @BIRTHDAY AND [S] = @SALARY GO"; // Repeat the SQL 1001 times to exceed length threshold string sql = string.Concat(Repeat(element: sqlLength_300, count: 1100)); IList result = SqlParameterizer.CodeSense(sql); string expectedMessage = SR.ScriptTooLarge(maxChars: 300000, currentChars: sql.Length); Console.WriteLine(result[0].Message); Assert.That(result, Is.Not.Empty); Assert.AreEqual(expected: 1, actual: result.Count); Assert.AreEqual(expected: ScriptFileMarkerLevel.Error, actual: result[0].Level); Assert.AreEqual(expected: expectedMessage, actual: result[0].Message); } /// /// When requesting a collection of ScriptFileMarker by calling the SqlParameterizer.CodeSense /// method, if a script is passed in that contains 3 valid parameters, the reuslt should be a collection of /// three informational code sense items. These will be returned to ADS to display to the user as intelli-sense. /// [Test] public void CodeSenseShouldReturnInformationalCodeSenseItemsForValidParameters() { const string ssn = "795-73-9838"; const string birthday = "19990104"; const string salary = "$30000"; string sql = $@" DECLARE @SSN CHAR(11) = '{ssn}' DECLARE @BIRTHDAY DATE = '{birthday}' DECLARE @SALARY MONEY = '{salary}' SELECT * FROM [dbo].[Patients] WHERE [SSN] = @SSN AND [BIRTHDAY] = @BIRTHDAY AND [SALARY] = @SALARY"; IList result = SqlParameterizer.CodeSense(sql); Assert.That(result, Is.Not.Empty); Assert.AreEqual(expected: 3, actual: result.Count); Assert.True(Enumerable.All(result, i => i.Level == ScriptFileMarkerLevel.Information)); } #endregion } }