//
// 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
}
}