diff --git a/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/Exceptions/ParameterizationFormatException.cs b/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/Exceptions/ParameterizationFormatException.cs new file mode 100644 index 00000000..1e4e0eea --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/Exceptions/ParameterizationFormatException.cs @@ -0,0 +1,38 @@ +// +// 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.ServiceLayer.AutoParameterizaition.Helpers; + +namespace Microsoft.SqlTools.ServiceLayer.AutoParameterizaition.Exceptions +{ + /// + /// ParameterizationFormatException is used to surface format exceptions encountered in the TSQL batch to perform + /// auto-parameterization of literals for Always Encrypted. + /// + public class ParameterizationFormatException : FormatException + { + public readonly int LineNumber; + public readonly string LiteralValue; + public readonly string CodeSenseMessage; + public readonly string LogMessage; + public readonly string SqlDatatype; + public readonly string CSharpDataType; + + public ParameterizationFormatException(MessageHelper.MessageType type, string variableName, string sqlDataType, string cSharpDataType, string literalValue, int lineNumber) + : this(type, variableName, sqlDataType, cSharpDataType, literalValue, lineNumber, exception: null) { } + + public ParameterizationFormatException(MessageHelper.MessageType type, string variableName, string sqlDataType, string cSharpDataType, string literalValue, int lineNumber, Exception exception) + : base(MessageHelper.GetLocalizedMessage(type, variableName, sqlDataType, literalValue), exception) + { + LineNumber = lineNumber; + LiteralValue = literalValue; + SqlDatatype = sqlDataType; + CSharpDataType = cSharpDataType; + CodeSenseMessage = Message; + LogMessage = MessageHelper.GetLocaleInvariantMessage(type, variableName, sqlDataType) + ", Literal Value: " + literalValue + ", On line: " + lineNumber + ", CSharp DataType: " + cSharpDataType; + } + } +} diff --git a/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/Exceptions/ParameterizationParsingException.cs b/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/Exceptions/ParameterizationParsingException.cs new file mode 100644 index 00000000..bf07e566 --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/Exceptions/ParameterizationParsingException.cs @@ -0,0 +1,24 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; + +namespace Microsoft.SqlTools.ServiceLayer.AutoParameterizaition.Exceptions +{ + /// + /// ParameterizationParsingException is used to surface parse errors encountered in the TSQL batch while creating a parse tree + /// + public class ParameterizationParsingException : Exception + { + public readonly int ColumnNumber; + public readonly int LineNumber; + + public ParameterizationParsingException(int lineNumber, int columnNumber, string errorMessage) : base(errorMessage) + { + LineNumber = lineNumber; + ColumnNumber = columnNumber; + } + } +} diff --git a/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/Exceptions/ParameterizationScriptTooLargeException.cs b/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/Exceptions/ParameterizationScriptTooLargeException.cs new file mode 100644 index 00000000..4e5fd276 --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/Exceptions/ParameterizationScriptTooLargeException.cs @@ -0,0 +1,18 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.SqlTools.ServiceLayer.AutoParameterizaition.Exceptions +{ + internal class ParameterizationScriptTooLargeException : ParameterizationParsingException + { + public readonly int ScriptLength; + + // LineNumber and ColumnNumber are defaulted to 1 because this exception is thrown if the script is very large and lineNumber and columnNumber dont make much sense + public ParameterizationScriptTooLargeException(int scriptLength, string errorMessage) : base(lineNumber: 1, columnNumber: 1, errorMessage: errorMessage) + { + ScriptLength = scriptLength; + } + } +} diff --git a/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/MessageHelper.cs b/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/MessageHelper.cs new file mode 100644 index 00000000..78fe4cab --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/MessageHelper.cs @@ -0,0 +1,53 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Globalization; + +namespace Microsoft.SqlTools.ServiceLayer.AutoParameterizaition.Helpers +{ + public class MessageHelper + { + private static readonly string ERROR_MESSAGE_TEMPLATE = "Unable to Convert {0} to a Microsoft.Data.SqlClient.SqlParameter object. The specified literal cannot be converted to {1}(System.Data.SqlDbType)."; + private static readonly string DATE_TIME_ERROR_MESSAGE_TEMPLATE = "Unable to Convert {0} to a Microsoft.Data.SqlClient.SqlParameter object. The specified literal cannot be converted to {1}(System.Data.SqlDbType), as it used an unsupported date/time format. Use one of the supported Date/time formats."; + private static readonly string BINARY_LITERAL_PREFIX_MISSING_ERROR_TEMPLATE = "Unable to Convert {0} to a Microsoft.Data.SqlClient.SqlParameter object. The specified literal cannot be converted to {1}(System.Data.SqlDbType), as prefix 0x is expected for a binary literals."; + + internal static string GetLocalizedMessage(MessageType type, string variableName, string sqlDataType, string literalValue) + { + switch (type) + { + case MessageType.ERROR_MESSAGE: + return SR.ErrorMessage(variableName, sqlDataType, literalValue); + case MessageType.DATE_TIME_ERROR_MESSAGE: + return SR.DateTimeErrorMessage(variableName, sqlDataType, literalValue); + case MessageType.BINARY_LITERAL_PREFIX_MISSING_ERROR: + return SR.BinaryLiteralPrefixMissingError(variableName, sqlDataType, literalValue); + default: + return ""; + } + } + + internal static string GetLocaleInvariantMessage(MessageType type, string variableName, string sqlDataType) + { + switch (type) + { + case MessageType.ERROR_MESSAGE: + return string.Format(CultureInfo.InvariantCulture, ERROR_MESSAGE_TEMPLATE, variableName, sqlDataType); + case MessageType.DATE_TIME_ERROR_MESSAGE: + return string.Format(CultureInfo.InvariantCulture, DATE_TIME_ERROR_MESSAGE_TEMPLATE, variableName, sqlDataType); + case MessageType.BINARY_LITERAL_PREFIX_MISSING_ERROR: + return string.Format(CultureInfo.InvariantCulture, BINARY_LITERAL_PREFIX_MISSING_ERROR_TEMPLATE, variableName, sqlDataType); + default: + return ""; + } + } + + public enum MessageType + { + ERROR_MESSAGE, + DATE_TIME_ERROR_MESSAGE, + BINARY_LITERAL_PREFIX_MISSING_ERROR, + } + } +} diff --git a/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/ScalarExpressionTransformer.cs b/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/ScalarExpressionTransformer.cs new file mode 100644 index 00000000..4f2b2462 --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/ScalarExpressionTransformer.cs @@ -0,0 +1,718 @@ +// +// 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; +using System.Data.SqlTypes; +using System.Globalization; +using System.Linq; +using Microsoft.Data.SqlClient; +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.SqlTools.ServiceLayer.AutoParameterizaition.Exceptions; +using Microsoft.SqlTools.ServiceLayer.AutoParameterizaition.Helpers; +using Microsoft.SqlTools.ServiceLayer.Workspace.Contracts; + +namespace Microsoft.SqlTools.ServiceLayer.AutoParameterizaition +{ + internal class ScalarExpressionTransformer : TSqlFragmentVisitor + { + #region datetimeFormats + + private static readonly string[] SUPPORTED_ISO_DATE_TIME_FORMATS = { + "yyyyMMdd HH:mm:ss.fffffff", + "yyyyMMdd HH:mm:ss.ffffff", + "yyyyMMdd HH:mm:ss.fffff", + "yyyyMMdd HH:mm:ss.ffff", + "yyyyMMdd HH:mm:ss.fff", + "yyyyMMdd HH:mm:ss.ff", + "yyyyMMdd HH:mm:ss.f", + "yyyyMMdd HH:mm:ss", + "yyyyMMdd HH:mm", + "yyyyMMdd", + + "yyyy-MM-ddTHH:mm:ss.fffffff", + "yyyy-MM-ddTHH:mm:ss.ffffff", + "yyyy-MM-ddTHH:mm:ss.fffff", + "yyyy-MM-ddTHH:mm:ss.ffff", + "yyyy-MM-ddTHH:mm:ss.fff", + "yyyy-MM-ddTHH:mm:ss.ff", + "yyyy-MM-ddTHH:mm:ss.f", + "yyyy-MM-ddTHH:mm:ss", + "yyyy-MM-ddTHH:mm", + "yyyy-MM-dd", + }; + + private static readonly string[] SUPPORTED_ISO_DATE_FORMATS = { + "yyyyMMdd", + "yyyy-MM-dd", + }; + + private static readonly string[] SUPPORTED_ISO_DATE_TIME_OFFSET_FORMATS = { + // 121025 12:32:10.1234567 +01:00 – zzz in the below format represents +01:00 + "yyyyMMdd HH:mm:ss.fffffff zzz", + "yyyyMMdd HH:mm:ss.ffffff zzz", + "yyyyMMdd HH:mm:ss.fffff zzz", + "yyyyMMdd HH:mm:ss.ffff zzz", + "yyyyMMdd HH:mm:ss.fff zzz", + "yyyyMMdd HH:mm:ss.ff zzz", + "yyyyMMdd HH:mm:ss.f zzz", + "yyyyMMdd HH:mm:ss zzz", + "yyyyMMdd HH:mm zzz", + "yyyyMMdd zzz", + + "yyyy-MM-ddTHH:mm:ss.fffffff zzz", + "yyyy-MM-ddTHH:mm:ss.ffffff zzz", + "yyyy-MM-ddTHH:mm:ss.fffff zzz", + "yyyy-MM-ddTHH:mm:ss.ffff zzz", + "yyyy-MM-ddTHH:mm:ss.fff zzz", + "yyyy-MM-ddTHH:mm:ss.ff zzz", + "yyyy-MM-ddTHH:mm:ss.f zzz", + "yyyy-MM-ddTHH:mm:ss zzz", + "yyyy-MM-ddTHH:mm zzz", + "yyyy-MM-dd zzz", + + //19991212 19:30:30.1234567Z – K in the below format represents Z + "yyyyMMdd HH:mm:ss.fffffffK", + "yyyyMMdd HH:mm:ss.ffffffK", + "yyyyMMdd HH:mm:ss.fffffK", + "yyyyMMdd HH:mm:ss.ffffK", + "yyyyMMdd HH:mm:ss.fffK", + "yyyyMMdd HH:mm:ss.ffK", + "yyyyMMdd HH:mm:ss.fK", + "yyyyMMdd HH:mm:ssK", + "yyyyMMdd HH:mmK", + "yyyyMMddK", + + "yyyy-MM-ddTHH:mm:ss.fffffffK", + "yyyy-MM-ddTHH:mm:ss.ffffffK", + "yyyy-MM-ddTHH:mm:ss.fffffK", + "yyyy-MM-ddTHH:mm:ss.ffffK", + "yyyy-MM-ddTHH:mm:ss.fffK", + "yyyy-MM-ddTHH:mm:ss.ffK", + "yyyy-MM-ddTHH:mm:ss.fK", + "yyyy-MM-ddTHH:mm:ssK", + "yyyy-MM-ddTHH:mmK", + "yyyy-MM-ddK", + }; + + #endregion + + private const string C_SHARP_BYTE_ARRAY = "byte[]"; + private readonly bool IsCodeSenseRequest; + private bool IsNegative = false; + private ScalarExpression CurrentScalarExpression; + public SqlDataTypeOption SqlDataTypeOption; + public IList SqlDataTypeParameters; + public string VariableName; + + public IList Parameters { get; private set; } + + private readonly IList CodeSenseErrors; + + public ScalarExpressionTransformer(bool isCodeSenseRequest, IList codeSenseErrors) + { + Parameters = new List(); + IsCodeSenseRequest = isCodeSenseRequest; + CodeSenseErrors = codeSenseErrors; + } + + public override void ExplicitVisit(ScalarExpression node) + { + if (node == null) + { + return; + } + + CurrentScalarExpression = node; + + if (node is Literal literal) + { + if (ShouldParameterize(literal)) + { + var variableReference = new VariableReference(); + string parameterName = GetParameterName(); + variableReference.Name = parameterName; + AddToParameterCollection(literal, parameterName, SqlDataTypeOption, SqlDataTypeParameters); + CurrentScalarExpression = variableReference; + } + + return; + } + + if (node is UnaryExpression unaryExpression) + { + ScalarExpression expression = unaryExpression.Expression; + + if (expression != null) + { + if (unaryExpression.UnaryExpressionType.Equals(UnaryExpressionType.Negative)) + { + IsNegative = !IsNegative; + } + + ExplicitVisit(expression); + } + + base.ExplicitVisit(node); //let the base class finish up + return; + } + + if (node is ParenthesisExpression parenthesisExpression) + { + ScalarExpression expression = parenthesisExpression.Expression; + + if (expression != null) + { + ScalarExpression tempScalarExpression = CurrentScalarExpression; + ExplicitVisit(expression); + parenthesisExpression.Expression = GetTransformedExpression(); + CurrentScalarExpression = tempScalarExpression; + } + + base.ExplicitVisit(node); // let the base class finish up + } + } + + public ScalarExpression GetTransformedExpression() + { + return CurrentScalarExpression; + } + + public void Reset() + { + SqlDataTypeOption = SqlDataTypeOption.VarChar; + SqlDataTypeParameters = null; + VariableName = null; + IsNegative = false; + Parameters.Clear(); + } + + /// + /// Converts a hex string to a byte[] + /// Note: this method expects "0x" prefix to be stripped off from the input string + /// For example, to convert the string "0xFFFF" to byte[] the input to this method should be "FFFF" + /// + /// + /// + private byte[] StringToByteArray(string hex) + { + return Enumerable.Range(0, hex.Length) + .Where(x => x % 2 == 0) + .Select(x => Convert.ToByte(hex.Substring(x, 2), 16)) + .ToArray(); + } + + private void AddToParameterCollection(Literal literal, string parameterName, SqlDataTypeOption sqlDataTypeOption, IList sqlDataTypeParameters) + { + SqlParameter sqlParameter = new SqlParameter(); + string literalValue = literal.Value; + object parsedValue = null; + SqlDbType paramType = SqlDbType.VarChar; + bool parseSuccessful = true; + + switch (sqlDataTypeOption) + { + case SqlDataTypeOption.Binary: + paramType = SqlDbType.Binary; + try + { + parsedValue = TryParseBinaryLiteral(literalValue, VariableName, SqlDbType.Binary, literal.StartLine); + } + catch (ParameterizationFormatException) + { + if (IsCodeSenseRequest) + { + parseSuccessful = false; + AddCodeSenseErrorItem(MessageHelper.MessageType.BINARY_LITERAL_PREFIX_MISSING_ERROR, literal, literal.Value, VariableName, SqlDbType.Binary.ToString()); + } + else + { + throw; + } + } + catch (Exception e) + { + parseSuccessful = false; + HandleError(MessageHelper.MessageType.ERROR_MESSAGE, literal, VariableName, SqlDbType.Binary.ToString(), C_SHARP_BYTE_ARRAY, literalValue, literal.StartLine, e); + } + break; + + case SqlDataTypeOption.VarBinary: + paramType = SqlDbType.VarBinary; + try + { + parsedValue = TryParseBinaryLiteral(literalValue, VariableName, SqlDbType.VarBinary, literal.StartLine); + ExtractSize(sqlDataTypeParameters, sqlParameter); + } + catch (ParameterizationFormatException) + { + if (IsCodeSenseRequest) + { + parseSuccessful = false; + string sqlDataTypeString = GetSqlDataTypeStringOneParameter(SqlDbType.VarBinary, sqlDataTypeParameters); + AddCodeSenseErrorItem(MessageHelper.MessageType.BINARY_LITERAL_PREFIX_MISSING_ERROR, literal, literalValue, VariableName, sqlDataTypeString); + } + else + { + throw; + } + } + catch (Exception e) + { + parseSuccessful = false; + string sqlDataTypeString = GetSqlDataTypeStringOneParameter(SqlDbType.VarBinary, sqlDataTypeParameters); + HandleError(MessageHelper.MessageType.ERROR_MESSAGE, literal, VariableName, sqlDataTypeString, C_SHARP_BYTE_ARRAY, literalValue, literal.StartLine, e); + } + break; + + + //Integer literals of form 24.0 will not be supported + case SqlDataTypeOption.BigInt: + paramType = SqlDbType.BigInt; + long parsedLong; + literalValue = IsNegative ? "-" + literalValue : literalValue; + + if (long.TryParse(literalValue, out parsedLong)) + { + parsedValue = parsedLong; + } + else + { + parseSuccessful = false; + HandleError(MessageHelper.MessageType.ERROR_MESSAGE, literal, VariableName, SqlDbType.BigInt.ToString(), "Int64", literalValue, literal.StartLine, null); + } + + break; + + case SqlDataTypeOption.Int: + paramType = SqlDbType.Int; + int parsedInt; + literalValue = IsNegative ? "-" + literalValue : literalValue; + + if (int.TryParse(literalValue, out parsedInt)) + { + parsedValue = parsedInt; + } + else + { + parseSuccessful = false; + HandleError(MessageHelper.MessageType.ERROR_MESSAGE, literal, VariableName, SqlDbType.Int.ToString(), "Int32", literalValue, literal.StartLine, null); + } + + break; + + case SqlDataTypeOption.SmallInt: + paramType = SqlDbType.SmallInt; + short parsedShort; + literalValue = IsNegative ? "-" + literalValue : literalValue; + + if (short.TryParse(literalValue, out parsedShort)) + { + parsedValue = parsedShort; + } + else + { + parseSuccessful = false; + HandleError(MessageHelper.MessageType.ERROR_MESSAGE, literal, VariableName, SqlDbType.SmallInt.ToString(), "Int16", literalValue, literal.StartLine, null); + } + + break; + + case SqlDataTypeOption.TinyInt: + paramType = SqlDbType.TinyInt; + byte parsedByte; + literalValue = IsNegative ? "-" + literalValue : literalValue; + + if (byte.TryParse(literalValue, out parsedByte)) + { + parsedValue = parsedByte; + } + else + { + parseSuccessful = false; + HandleError(MessageHelper.MessageType.ERROR_MESSAGE, literal, VariableName, SqlDbType.TinyInt.ToString(), "Byte", literalValue, literal.StartLine, null); + } + + break; + + + case SqlDataTypeOption.Real: + paramType = SqlDbType.Real; + literalValue = IsNegative ? "-" + literalValue : literalValue; + + try + { + parsedValue = SqlSingle.Parse(literalValue); + } + catch (Exception e) + { + parseSuccessful = false; + HandleError(MessageHelper.MessageType.ERROR_MESSAGE, literal, VariableName, SqlDbType.Real.ToString(), "SqlSingle", literalValue, literal.StartLine, e); + } + + break; + + case SqlDataTypeOption.Float: + paramType = SqlDbType.Float; + literalValue = IsNegative ? "-" + literalValue : literalValue; + + try + { + parsedValue = SqlDouble.Parse(literalValue); + } + catch (Exception e) + { + parseSuccessful = false; + HandleError(MessageHelper.MessageType.ERROR_MESSAGE, literal, VariableName, SqlDbType.Float.ToString(), "SqlDouble", literalValue, literal.StartLine, e); + } + + break; + + + case SqlDataTypeOption.Decimal: + case SqlDataTypeOption.Numeric: + paramType = SqlDbType.Decimal; + ExtractPrecisionAndScale(sqlDataTypeParameters, sqlParameter); + literalValue = IsNegative ? "-" + literalValue : literalValue; + + try + { + parsedValue = SqlDecimal.Parse(literalValue); + } + catch (Exception e) + { + parseSuccessful = false; + string sqlDecimalDataType = sqlDataTypeParameters != null ? (SqlDbType.Decimal + "(" + sqlDataTypeParameters[0] + ", " + sqlDataTypeParameters[1] + ")") : ""; + HandleError(MessageHelper.MessageType.ERROR_MESSAGE, literal, VariableName, sqlDecimalDataType, "SqlDecimal", literalValue, literal.StartLine, e); + } + + break; + + case SqlDataTypeOption.Money: + paramType = SqlDbType.Money; + literalValue = IsNegative ? "-" + literalValue : literalValue; + + try + { + parsedValue = SqlMoney.Parse(literalValue); + } + catch (Exception e) + { + parseSuccessful = false; + HandleError(MessageHelper.MessageType.ERROR_MESSAGE, literal, VariableName, SqlDbType.Money.ToString(), "SqlMoney", literalValue, literal.StartLine, e); + } + + break; + + case SqlDataTypeOption.SmallMoney: + paramType = SqlDbType.SmallMoney; + literalValue = IsNegative ? "-" + literalValue : literalValue; + + try + { + parsedValue = SqlMoney.Parse(literalValue); + } + catch (Exception e) + { + parseSuccessful = false; + HandleError(MessageHelper.MessageType.ERROR_MESSAGE, literal, VariableName, SqlDbType.SmallMoney.ToString(), "SqlMoney", literalValue, literal.StartLine, e); + } + + break; + + case SqlDataTypeOption.DateTime: + paramType = SqlDbType.DateTime; + + try + { + parsedValue = ParseDateTime(literalValue); + } + catch (Exception e) + { + parseSuccessful = false; + HandleError(MessageHelper.MessageType.DATE_TIME_ERROR_MESSAGE, literal, VariableName, SqlDbType.DateTime.ToString(), "DateTime", literalValue, literal.StartLine, e); + } + + break; + + case SqlDataTypeOption.SmallDateTime: + paramType = SqlDbType.SmallDateTime; + + try + { + parsedValue = ParseDateTime(literalValue); + } + catch (Exception e) + { + parseSuccessful = false; + HandleError(MessageHelper.MessageType.DATE_TIME_ERROR_MESSAGE, literal, VariableName, SqlDbType.SmallDateTime.ToString(), "DateTime", literalValue, literal.StartLine, e); + } + + break; + + case SqlDataTypeOption.DateTime2: + paramType = SqlDbType.DateTime2; + + try + { + parsedValue = ParseDateTime(literalValue); + } + catch (Exception e) + { + parseSuccessful = false; + HandleError(MessageHelper.MessageType.DATE_TIME_ERROR_MESSAGE, literal, VariableName, SqlDbType.DateTime2.ToString(), "DateTime", literalValue, literal.StartLine, e); + } + + ExtractPrecision(sqlDataTypeParameters, sqlParameter); + break; + + case SqlDataTypeOption.Date: + paramType = SqlDbType.Date; + + try + { + parsedValue = ParseDate(literalValue); + } + catch (Exception e) + { + parseSuccessful = false; + HandleError(MessageHelper.MessageType.DATE_TIME_ERROR_MESSAGE, literal, VariableName, SqlDbType.Date.ToString(), "DateTime", literalValue, literal.StartLine, e); + } + + break; + + case SqlDataTypeOption.DateTimeOffset: + paramType = SqlDbType.DateTimeOffset; + + try + { + parsedValue = ParseDateTimeOffset(literalValue); + } + catch (Exception e) + { + parseSuccessful = false; + HandleError(MessageHelper.MessageType.DATE_TIME_ERROR_MESSAGE, literal, VariableName, SqlDbType.DateTimeOffset.ToString(), "DateTimeOffset", literalValue, literal.StartLine, e); + } + + ExtractPrecision(sqlDataTypeParameters, sqlParameter); + break; + + + case SqlDataTypeOption.Time: + paramType = SqlDbType.Time; + + try + { + parsedValue = TimeSpan.Parse(literalValue); + } + catch (Exception e) + { + parseSuccessful = false; + HandleError(MessageHelper.MessageType.ERROR_MESSAGE, literal, VariableName, SqlDbType.Time.ToString(), "TimeSpan", literalValue, literal.StartLine, e); + } + + ExtractPrecision(sqlDataTypeParameters, sqlParameter); + break; + + case SqlDataTypeOption.Char: + paramType = SqlDbType.Char; + ExtractSize(sqlDataTypeParameters, sqlParameter); + break; + + case SqlDataTypeOption.VarChar: + paramType = SqlDbType.VarChar; + ExtractSize(sqlDataTypeParameters, sqlParameter); + break; + + case SqlDataTypeOption.NChar: + paramType = SqlDbType.NChar; + ExtractSize(sqlDataTypeParameters, sqlParameter); + break; + + case SqlDataTypeOption.NVarChar: + paramType = SqlDbType.NVarChar; + ExtractSize(sqlDataTypeParameters, sqlParameter); + break; + + case SqlDataTypeOption.UniqueIdentifier: + paramType = SqlDbType.UniqueIdentifier; + + try + { + parsedValue = SqlGuid.Parse(literalValue); + } + catch (Exception e) + { + parseSuccessful = false; + HandleError(MessageHelper.MessageType.ERROR_MESSAGE, literal, VariableName, SqlDbType.UniqueIdentifier.ToString(), "SqlGuid", literalValue, literal.StartLine, e); + } + + break; + + case SqlDataTypeOption.Bit: + paramType = SqlDbType.Bit; + + try + { + parsedValue = Byte.Parse(literalValue); + } + catch (Exception e) + { + parseSuccessful = false; + HandleError(MessageHelper.MessageType.ERROR_MESSAGE, literal, VariableName, SqlDbType.Bit.ToString(), "Byte", literalValue, literal.StartLine, e); + } + break; + + default: + break; + } + + if (parseSuccessful) + { + sqlParameter.ParameterName = parameterName; + sqlParameter.SqlDbType = paramType; + sqlParameter.Value = parsedValue ?? literalValue; + sqlParameter.Direction = ParameterDirection.Input; + Parameters.Add(sqlParameter); + } + } + + private string GetSqlDataTypeStringOneParameter(SqlDbType sqlDataType, IList sqlDataTypeParameters) + { + string parameters = sqlDataTypeParameters != null ? ("(" + sqlDataTypeParameters[0] + ")") : ""; + return sqlDataType + parameters; + } + + private void HandleError(MessageHelper.MessageType errorMessage, Literal literal, string variableName, string sqlDbType, string cSharpType, string literalValue, int startLine, Exception exception) + { + if (IsCodeSenseRequest) + { + AddCodeSenseErrorItem(errorMessage, literal, literalValue, variableName, sqlDbType); + } + else + { + if (exception != null) + { + throw new ParameterizationFormatException(errorMessage, variableName, sqlDbType, cSharpType, literalValue, startLine, exception); + } + else + { + throw new ParameterizationFormatException(errorMessage, variableName, sqlDbType, cSharpType, literalValue, startLine); + } + } + } + + private void AddCodeSenseErrorItem(MessageHelper.MessageType messageType, Literal literal, string literalValue, string variableName, string sqlDbType) + { + CodeSenseErrors.Add(new ScriptFileMarker + { + Level = ScriptFileMarkerLevel.Error, + Message = MessageHelper.GetLocalizedMessage(messageType, variableName, sqlDbType, literalValue), + ScriptRegion = new ScriptRegion + { + StartLineNumber = literal.StartLine, + StartColumnNumber = literal.StartColumn, + EndLineNumber = literal.StartLine, + EndColumnNumber = literal.StartColumn + literalValue.Length + } + }); + } + + private object ParseDateTime(string literalValue) + { + return DateTime.ParseExact(literalValue, SUPPORTED_ISO_DATE_TIME_FORMATS, CultureInfo.InvariantCulture, DateTimeStyles.None); + } + + private object ParseDate(string literalValue) + { + return DateTime.ParseExact(literalValue, SUPPORTED_ISO_DATE_FORMATS, CultureInfo.InvariantCulture, DateTimeStyles.None); + } + + private object ParseDateTimeOffset(string literalValue) + { + return DateTimeOffset.ParseExact(literalValue, SUPPORTED_ISO_DATE_TIME_OFFSET_FORMATS, CultureInfo.InvariantCulture, DateTimeStyles.None); + } + + private bool ShouldParameterize(Literal literal) + { + switch (literal.LiteralType) + { + case LiteralType.Integer: + case LiteralType.Real: + case LiteralType.Money: + case LiteralType.Binary: + case LiteralType.String: + case LiteralType.Numeric: + return true; + + default: + return false; + } + } + + private void ExtractPrecisionAndScale(IList dataTypeParameters, SqlParameter sqlParameter) + { + if (dataTypeParameters != null && dataTypeParameters.Count == 2) + { + Literal precisionLiteral = dataTypeParameters[0]; + + if (byte.TryParse(precisionLiteral.Value, out byte precision)) + { + sqlParameter.Precision = precision; + } + + Literal scaleLiteral = dataTypeParameters[1]; + + if (byte.TryParse(scaleLiteral.Value, out byte scale)) + { + sqlParameter.Scale = scale; + } + } + } + + private void ExtractPrecision(IList dataTypeParameters, SqlParameter sqlParameter) + { + if (dataTypeParameters != null && dataTypeParameters.Count == 1) + { + Literal precisionLiteral = dataTypeParameters[0]; + + if (byte.TryParse(precisionLiteral.Value, out byte precision)) + { + sqlParameter.Precision = precision; + } + } + } + + private void ExtractSize(IList dataTypeParameters, SqlParameter sqlParameter) + { + if (dataTypeParameters != null && dataTypeParameters.Count == 1) + { + Literal sizeLiteral = dataTypeParameters[0]; + + if (int.TryParse(sizeLiteral.Value, out int size)) + { + sqlParameter.Size = size; + } + } + } + + private object TryParseBinaryLiteral(string literalValue, string variableName, SqlDbType sqlDbType, int lineNumber) + { + if (literalValue.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + string hexString = literalValue.Substring(2); + return StringToByteArray(hexString); + } + + throw new ParameterizationFormatException(MessageHelper.MessageType.BINARY_LITERAL_PREFIX_MISSING_ERROR, variableName, sqlDbType.ToString(), C_SHARP_BYTE_ARRAY, literalValue, lineNumber); + } + + private string GetParameterName() + { + return "@p" + Guid.NewGuid().ToString("N"); //option N will give a guid without dashes + } + } +} diff --git a/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/SqlParameterizer.cs b/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/SqlParameterizer.cs new file mode 100644 index 00000000..fdbc2373 --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/SqlParameterizer.cs @@ -0,0 +1,127 @@ +// +// 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 System.Data.Common; +using System.IO; +using System.Linq; +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.SqlTools.ServiceLayer.AutoParameterizaition.Exceptions; +using Microsoft.SqlTools.ServiceLayer.Workspace.Contracts; + +namespace Microsoft.SqlTools.ServiceLayer.AutoParameterizaition +{ + public static class SqlParameterizer + { + private const int maxStringLength = 300000; // Approximately 600 Kb + private static readonly IList EmptyCodeSenseItemList = Enumerable.Empty().ToList(); + + public static SqlScriptGenerator GetScriptGenerator() => new Sql150ScriptGenerator(); + + public static TSqlParser GetTSqlParser(bool initialQuotedIdentifiers) => new TSql150Parser(initialQuotedIdentifiers); + + /// + /// This method will parameterize the given SqlCommand. + /// Any single literal on the RHS of a declare statement will be parameterized + /// Any other literals will be ignored + /// + /// Command that will need to be parameterized + public static void Parameterize(this DbCommand commandToParameterize) + { + TSqlFragment rootFragment = GetAbstractSyntaxTree(commandToParameterize); + TsqlMultiVisitor multiVisitor = new TsqlMultiVisitor(isCodeSenseRequest: false); // Use the vistor pattern to examine the parse tree + rootFragment.AcceptChildren(multiVisitor); // Now walk the tree + + //reformat and validate the transformed command + SqlScriptGenerator scriptGenerator = GetScriptGenerator(); + scriptGenerator.GenerateScript(rootFragment, out string formattedSQL); + + if (!string.IsNullOrEmpty(formattedSQL)) + { + commandToParameterize.CommandText = formattedSQL; + } + + commandToParameterize.Parameters.AddRange(multiVisitor.Parameters.ToArray()); + + multiVisitor.Reset(); + } + + /// + /// Parses the given script to provide message, warning, error. + /// + /// Script that will be parsed + /// Used to emit telemetry events + /// + public static IList CodeSense(string scriptToParse) + { + if (scriptToParse == null) + { + return EmptyCodeSenseItemList; + } + + int CurrentScriptlength = scriptToParse.Length; + if (CurrentScriptlength > maxStringLength) + { + ScriptFileMarker maxStringLengthCodeSenseItem = new ScriptFileMarker + { + Level = ScriptFileMarkerLevel.Error, + Message = SR.ScriptTooLarge(maxStringLength, CurrentScriptlength), + ScriptRegion = new ScriptRegion + { + // underline first row in the text + StartLineNumber = 1, + StartColumnNumber = 1, + EndLineNumber = 2, + EndColumnNumber = 1 + } + }; + + return new ScriptFileMarker[] { maxStringLengthCodeSenseItem }; + } + + TSqlFragment rootFragment = GetAbstractSyntaxTree(scriptToParse); + TsqlMultiVisitor multiVisitor = new TsqlMultiVisitor(isCodeSenseRequest: true); // Use the vistor pattern to examine the parse tree + rootFragment.AcceptChildren(multiVisitor); // Now walk the tree + + if (multiVisitor.CodeSenseErrors != null && multiVisitor.CodeSenseErrors.Count != 0) + { + multiVisitor.CodeSenseMessages.AddRange(multiVisitor.CodeSenseErrors); + } + + return multiVisitor.CodeSenseMessages; + } + + private static TSqlFragment GetAbstractSyntaxTree(DbCommand commandToParameterize) + { + // Capture the current CommandText in a format that the parser can work with + string commandText = commandToParameterize.CommandText; + int currentScriptLength = commandText.Length; + + if (currentScriptLength > maxStringLength) + { + throw new ParameterizationScriptTooLargeException(currentScriptLength, errorMessage: SR.ScriptTooLarge(maxStringLength, currentScriptLength)); + } + + return GetAbstractSyntaxTree(commandText); + } + + private static TSqlFragment GetAbstractSyntaxTree(string script) + { + using (TextReader textReader = new StringReader(script)) + { + TSqlParser parser = GetTSqlParser(true); + TSqlFragment rootFragment = parser.Parse(textReader, out IList parsingErrors); // Get the parse tree + + // if we could not parse the SQL we will throw an exception. Better here than on the server + if (parsingErrors.Count > 0) + { + throw new ParameterizationParsingException(parsingErrors[0].Line, parsingErrors[0].Column, parsingErrors[0].Message); + } + + return rootFragment; + } + } + } +} diff --git a/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/TsqlMultiVisitor.cs b/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/TsqlMultiVisitor.cs new file mode 100644 index 00000000..bb0f847f --- /dev/null +++ b/src/Microsoft.SqlTools.ServiceLayer/AutoParameterizaition/TsqlMultiVisitor.cs @@ -0,0 +1,170 @@ +// +// 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 System.Text; +using Microsoft.Data.SqlClient; +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.SqlTools.ServiceLayer.Workspace.Contracts; + +namespace Microsoft.SqlTools.ServiceLayer.AutoParameterizaition +{ + /// + /// Entry point for SqlParameterization, this class is responsible for visiting the parse tree and identifying the scalar expressions to be parameterized + /// + internal class TsqlMultiVisitor : TSqlFragmentVisitor + { + private readonly ScalarExpressionTransformer ScalarExpressionTransformer; + private readonly bool IsCodeSenseRequest; + + private Dictionary _executionParameters = null; + + public List Parameters { get; private set; } + + public List CodeSenseMessages { get; private set; } + + public List CodeSenseErrors { get; private set; } + + public Dictionary ExecutionParameters + { + get + { + if (_executionParameters == null) + { + _executionParameters = new Dictionary(); + } + + return _executionParameters; + } + } + + public TsqlMultiVisitor(bool isCodeSenseRequest) + { + Parameters = new List(); + IsCodeSenseRequest = isCodeSenseRequest; + CodeSenseMessages = new List(); + CodeSenseErrors = new List(); + ScalarExpressionTransformer = new ScalarExpressionTransformer(isCodeSenseRequest, CodeSenseErrors); + } + + public override void ExplicitVisit(DeclareVariableStatement node) + { + if (node == null || node.Declarations == null) + { + return; + } + + StringBuilder codeSenseMessageStringBuilder = new StringBuilder(); + int endLine = -1; + int endCol = -1; + + foreach (DeclareVariableElement declareVariableElement in node.Declarations) + { + if (declareVariableElement.DataType is SqlDataTypeReference dataTypeReference) + { + SqlDataTypeOption sqlDataTypeOption = dataTypeReference.SqlDataTypeOption; + + if (ShouldParamterize(sqlDataTypeOption)) + { + IList sqlDataTypeParameters = dataTypeReference.Parameters; + ScalarExpressionTransformer.SqlDataTypeOption = sqlDataTypeOption; + ScalarExpressionTransformer.SqlDataTypeParameters = sqlDataTypeParameters; + ScalarExpressionTransformer.VariableName = declareVariableElement.VariableName.Value; + + ScalarExpression declareVariableElementValue = declareVariableElement.Value; + ScalarExpressionTransformer.ExplicitVisit(declareVariableElementValue); + declareVariableElement.Value = ScalarExpressionTransformer.GetTransformedExpression(); + IList sqlParameters = ScalarExpressionTransformer.Parameters; + + if (sqlParameters.Count == 1 && declareVariableElementValue != null) + { + codeSenseMessageStringBuilder.Append(SR.ParameterizationDetails(declareVariableElement.VariableName.Value, + sqlParameters[0].SqlDbType.ToString(), + sqlParameters[0].Size, + sqlParameters[0].Precision, + sqlParameters[0].Scale, + sqlParameters[0].SqlValue.ToString())); + + endLine = declareVariableElementValue.StartLine; + endCol = declareVariableElementValue.StartColumn + declareVariableElementValue.FragmentLength; + + if (!IsCodeSenseRequest) + { + string sqlParameterKey = sqlParameters[0].SqlDbType.ToString(); + ExecutionParameters.TryGetValue(sqlParameterKey, out int currentCount); + ExecutionParameters[sqlParameterKey] = currentCount + 1; + } + } + + Parameters.AddRange(sqlParameters); + ScalarExpressionTransformer.Reset(); + } + } + } + + if (codeSenseMessageStringBuilder.Length > 0) + { + CodeSenseMessages.Add(new ScriptFileMarker + { + Level = ScriptFileMarkerLevel.Information, + Message = codeSenseMessageStringBuilder.ToString(), + ScriptRegion = new ScriptRegion + { + StartLineNumber = node.StartLine, + StartColumnNumber = node.StartColumn, + EndLineNumber = endLine == -1 ? node.StartLine : endLine, + EndColumnNumber = endCol == -1 ? node.StartColumn + node.LastTokenIndex - node.FirstTokenIndex : endCol + } + }); + } + + node.AcceptChildren(this); + base.ExplicitVisit(node); // let the base class finish up + } + + private bool ShouldParamterize(SqlDataTypeOption sqlDataTypeOption) + { + switch (sqlDataTypeOption) + { + case SqlDataTypeOption.BigInt: + case SqlDataTypeOption.Int: + case SqlDataTypeOption.SmallInt: + case SqlDataTypeOption.TinyInt: + case SqlDataTypeOption.Bit: + case SqlDataTypeOption.Decimal: + case SqlDataTypeOption.Numeric: + case SqlDataTypeOption.Money: + case SqlDataTypeOption.SmallMoney: + case SqlDataTypeOption.Float: + case SqlDataTypeOption.Real: + case SqlDataTypeOption.DateTime: + case SqlDataTypeOption.SmallDateTime: + case SqlDataTypeOption.Char: + case SqlDataTypeOption.VarChar: + case SqlDataTypeOption.NChar: + case SqlDataTypeOption.NVarChar: + case SqlDataTypeOption.Binary: + case SqlDataTypeOption.VarBinary: + case SqlDataTypeOption.UniqueIdentifier: + case SqlDataTypeOption.Date: + case SqlDataTypeOption.Time: + case SqlDataTypeOption.DateTime2: + case SqlDataTypeOption.DateTimeOffset: + return true; + + default: + return false; + } + } + + public void Reset() + { + Parameters.Clear(); + CodeSenseMessages.Clear(); + CodeSenseErrors.Clear(); + ExecutionParameters.Clear(); + } + } +} diff --git a/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/LanguageService.cs b/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/LanguageService.cs index df7350a9..0bbdf45f 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/LanguageService.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/LanguageServices/LanguageService.cs @@ -22,6 +22,7 @@ using Microsoft.SqlServer.Management.SqlParser.Parser; using Microsoft.SqlServer.Management.SqlParser.SqlCodeDom; using Microsoft.SqlTools.Extensibility; using Microsoft.SqlTools.Hosting.Protocol; +using Microsoft.SqlTools.ServiceLayer.AutoParameterizaition; using Microsoft.SqlTools.ServiceLayer.Connection; using Microsoft.SqlTools.ServiceLayer.Connection.Contracts; using Microsoft.SqlTools.ServiceLayer.Hosting; @@ -790,6 +791,7 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices try { bool oldEnableIntelliSense = oldSettings.SqlTools.IntelliSense.EnableIntellisense; + bool oldAlwaysEncryptedParameterizationEnabled = oldSettings.SqlTools.QueryExecutionSettings.IsAlwaysEncryptedParameterizationEnabled; bool? oldEnableDiagnostics = oldSettings.SqlTools.IntelliSense.EnableErrorChecking; // update the current settings to reflect any changes @@ -797,13 +799,12 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices // if script analysis settings have changed we need to clear the current diagnostic markers if (oldEnableIntelliSense != newSettings.SqlTools.IntelliSense.EnableIntellisense - || oldEnableDiagnostics != newSettings.SqlTools.IntelliSense.EnableErrorChecking) + || oldEnableDiagnostics != newSettings.SqlTools.IntelliSense.EnableErrorChecking + || oldAlwaysEncryptedParameterizationEnabled != newSettings.SqlTools.QueryExecutionSettings.IsAlwaysEncryptedParameterizationEnabled) { // if the user just turned off diagnostics then send an event to clear the error markers if (!newSettings.IsDiagnosticsEnabled) { - ScriptFileMarker[] emptyAnalysisDiagnostics = new ScriptFileMarker[0]; - foreach (var scriptFile in CurrentWorkspace.GetOpenedFiles()) { await DiagnosticsHelper.ClearScriptDiagnostics(scriptFile.ClientUri, eventContext); @@ -812,7 +813,7 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices // otherwise rerun diagnostic analysis on all opened SQL files else { - await this.RunScriptDiagnostics(CurrentWorkspace.GetOpenedFiles(), eventContext); + await RunScriptDiagnostics(CurrentWorkspace.GetOpenedFiles(), eventContext); } } } @@ -1698,6 +1699,11 @@ namespace Microsoft.SqlTools.ServiceLayer.LanguageServices } } + if (CurrentWorkspaceSettings.QueryExecutionSettings.IsAlwaysEncryptedParameterizationEnabled) + { + markers.AddRange(SqlParameterizer.CodeSense(scriptFile.Contents)); + } + return markers.ToArray(); } diff --git a/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.cs b/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.cs index 8e7aae92..0bb8c7f2 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.cs @@ -3068,6 +3068,41 @@ namespace Microsoft.SqlTools.ServiceLayer return Keys.GetString(Keys.QueryServiceSaveAsFail, fileName, message); } + public static string ParameterizationDetails(string variableName, string sqlDbType, int size, int precision, int scale, string sqlValue) + { + return Keys.GetString(Keys.ParameterizationDetails, variableName, sqlDbType, size, precision, scale, sqlValue); + } + + public static string ErrorMessageHeader(int lineNumber) + { + return Keys.GetString(Keys.ErrorMessageHeader, lineNumber); + } + + public static string ErrorMessage(string variableName, string sqlDataType, string literalValue) + { + return Keys.GetString(Keys.ErrorMessage, variableName, sqlDataType, literalValue); + } + + public static string DateTimeErrorMessage(string variableName, string sqlDataType, string literalValue) + { + return Keys.GetString(Keys.DateTimeErrorMessage, variableName, sqlDataType, literalValue); + } + + public static string BinaryLiteralPrefixMissingError(string variableName, string sqlDataType, string literalValue) + { + return Keys.GetString(Keys.BinaryLiteralPrefixMissingError, variableName, sqlDataType, literalValue); + } + + public static string ParsingErrorHeader(int lineNumber, int columnNumber) + { + return Keys.GetString(Keys.ParsingErrorHeader, lineNumber, columnNumber); + } + + public static string ScriptTooLarge(int maxChars, int currentChars) + { + return Keys.GetString(Keys.ScriptTooLarge, maxChars, currentChars); + } + public static string SerializationServiceUnsupportedFormat(string formatName) { return Keys.GetString(Keys.SerializationServiceUnsupportedFormat, formatName); @@ -3366,6 +3401,27 @@ namespace Microsoft.SqlTools.ServiceLayer public const string SqlCmdUnsupportedToken = "SqlCmdUnsupportedToken"; + public const string ParameterizationDetails = "ParameterizationDetails"; + + + public const string ErrorMessageHeader = "ErrorMessageHeader"; + + + public const string ErrorMessage = "ErrorMessage"; + + + public const string DateTimeErrorMessage = "DateTimeErrorMessage"; + + + public const string BinaryLiteralPrefixMissingError = "BinaryLiteralPrefixMissingError"; + + + public const string ParsingErrorHeader = "ParsingErrorHeader"; + + + public const string ScriptTooLarge = "ScriptTooLarge"; + + public const string SerializationServiceUnsupportedFormat = "SerializationServiceUnsupportedFormat"; @@ -4461,6 +4517,12 @@ namespace Microsoft.SqlTools.ServiceLayer } + public static string GetString(string key, object arg0, object arg1, object arg2) + { + return string.Format(global::System.Globalization.CultureInfo.CurrentCulture, resourceManager.GetString(key, _culture), arg0, arg1, arg2); + } + + public static string GetString(string key, object arg0, object arg1, object arg2, object arg3) { return string.Format(global::System.Globalization.CultureInfo.CurrentCulture, resourceManager.GetString(key, _culture), arg0, arg1, arg2, arg3); diff --git a/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.resx b/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.resx index 027ae7cc..253f8b35 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.resx +++ b/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.resx @@ -348,6 +348,41 @@ Encountered unsupported token {0} + + {0} will be converted to a Microsoft.Data.SqlClient.SqlParameter object with the following properties: SqlDbType = {1}, Size = {2}, Precision = {3}, Scale = {4}, SqlValue = {5} + . + Parameters: 0 - variableName (string), 1 - sqlDbType (string), 2 - size (int), 3 - precision (int), 4 - scale (int), 5 - sqlValue (string) + + + Line {0} + . + Parameters: 0 - lineNumber (int) + + + Unable to convert {0} to a Microsoft.Data.SqlClient.SqlParameter object. The specified literal cannot be converted to {1}(Microsoft.Data.SqlDbType). Literal value: {2} + . + Parameters: 0 - variableName (string), 1 - sqlDataType (string), 2 - literalValue (string) + + + Unable to convert {0} to a Microsoft.Data.SqlClient.SqlParameter object. The specified literal cannot be converted to {1}(Microsoft.Data.SqlDbType), as it used an unsupported date/time format. Use one of the supported date/time formats. Literal value: {2} + . + Parameters: 0 - variableName (string), 1 - sqlDataType (string), 2 - literalValue (string) + + + Unable to convert {0} to a Microsoft.Data.SqlClient.SqlParameter object. The specified literal cannot be converted to {1}(Microsoft.Data.SqlDbType), as prefix 0x is expected for a binary literals. Literal value: {2} + . + Parameters: 0 - variableName (string), 1 - sqlDataType (string), 2 - literalValue (string) + + + Line {0}, column {1} + . + Parameters: 0 - lineNumber (int), 1 - columnNumber (int) + + + The current script is too large for Parameterization for Always Encrypted, please disable Parameterization for Always Encrypted in Query Options (Query > Query Options > Execution > Advanced). Maximum allowable length: {0} characters, Current script length: {1} characters + . + Parameters: 0 - maxChars (int), 1 - currentChars (int) + Unsupported Save Format: {0} . diff --git a/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.strings b/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.strings index 16ec5fb8..5004d962 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.strings +++ b/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.strings @@ -150,6 +150,22 @@ SqlCmdExitOnError = An error was encountered during execution of batch. Exiting. SqlCmdUnsupportedToken = Encountered unsupported token {0} +### AutoParameterization for Always Encrypted strings + +ParameterizationDetails (string variableName, string sqlDbType, int size, int precision, int scale, string sqlValue) = {0} will be converted to a Microsoft.Data.SqlClient.SqlParameter object with the following properties: SqlDbType = {1}, Size = {2}, Precision = {3}, Scale = {4}, SqlValue = {5} + +ErrorMessageHeader(int lineNumber) = Line {0} + +ErrorMessage (string variableName, string sqlDataType, string literalValue) = Unable to convert {0} to a Microsoft.Data.SqlClient.SqlParameter object. The specified literal cannot be converted to {1}(Microsoft.Data.SqlDbType). Literal value: {2} + +DateTimeErrorMessage (string variableName, string sqlDataType, string literalValue) = Unable to convert {0} to a Microsoft.Data.SqlClient.SqlParameter object. The specified literal cannot be converted to {1}(Microsoft.Data.SqlDbType), as it used an unsupported date/time format. Use one of the supported date/time formats. Literal value: {2} + +BinaryLiteralPrefixMissingError (string variableName, string sqlDataType, string literalValue) = Unable to convert {0} to a Microsoft.Data.SqlClient.SqlParameter object. The specified literal cannot be converted to {1}(Microsoft.Data.SqlDbType), as prefix 0x is expected for a binary literals. Literal value: {2} + +ParsingErrorHeader (int lineNumber, int columnNumber) = Line {0}, column {1} + +ScriptTooLarge (int maxChars, int currentChars) = The current script is too large for Parameterization for Always Encrypted, please disable Parameterization for Always Encrypted in Query Options (Query > Query Options > Execution > Advanced). Maximum allowable length: {0} characters, Current script length: {1} characters + ############################################################################ # Serialization Service diff --git a/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.xlf b/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.xlf index 2605049f..c34c6ac7 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.xlf +++ b/src/Microsoft.SqlTools.ServiceLayer/Localization/sr.xlf @@ -2056,11 +2056,6 @@ 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 @@ -2081,6 +2076,48 @@ Unsupported engine edition {0} . Parameters: 0 - editionCode (int) + + + {0} will be converted to a Microsoft.Data.SqlClient.SqlParameter object with the following properties: SqlDbType = {1}, Size = {2}, Precision = {3}, Scale = {4}, SqlValue = {5} + {0} will be converted to a Microsoft.Data.SqlClient.SqlParameter object with the following properties: SqlDbType = {1}, Size = {2}, Precision = {3}, Scale = {4}, SqlValue = {5} + . + Parameters: 0 - variableName (string), 1 - sqlDbType (string), 2 - size (int), 3 - precision (int), 4 - scale (int), 5 - sqlValue (string) + + + Line {0} + Line {0} + . + Parameters: 0 - lineNumber (int) + + + Unable to convert {0} to a Microsoft.Data.SqlClient.SqlParameter object. The specified literal cannot be converted to {1}(Microsoft.Data.SqlDbType). Literal value: {2} + Unable to convert {0} to a Microsoft.Data.SqlClient.SqlParameter object. The specified literal cannot be converted to {1}(Microsoft.Data.SqlDbType). Literal value: {2} + . + Parameters: 0 - variableName (string), 1 - sqlDataType (string), 2 - literalValue (string) + + + Unable to convert {0} to a Microsoft.Data.SqlClient.SqlParameter object. The specified literal cannot be converted to {1}(Microsoft.Data.SqlDbType), as it used an unsupported date/time format. Use one of the supported date/time formats. Literal value: {2} + Unable to convert {0} to a Microsoft.Data.SqlClient.SqlParameter object. The specified literal cannot be converted to {1}(Microsoft.Data.SqlDbType), as it used an unsupported date/time format. Use one of the supported date/time formats. Literal value: {2} + . + Parameters: 0 - variableName (string), 1 - sqlDataType (string), 2 - literalValue (string) + + + Unable to convert {0} to a Microsoft.Data.SqlClient.SqlParameter object. The specified literal cannot be converted to {1}(Microsoft.Data.SqlDbType), as prefix 0x is expected for a binary literals. Literal value: {2} + Unable to convert {0} to a Microsoft.Data.SqlClient.SqlParameter object. The specified literal cannot be converted to {1}(Microsoft.Data.SqlDbType), as prefix 0x is expected for a binary literals. Literal value: {2} + . + Parameters: 0 - variableName (string), 1 - sqlDataType (string), 2 - literalValue (string) + + + Line {0}, column {1} + Line {0}, column {1} + . + Parameters: 0 - lineNumber (int), 1 - columnNumber (int) + + + The current script is too large for Parameterization for Always Encrypted, please disable Parameterization for Always Encrypted in Query Options (Query > Query Options > Execution > Advanced). Maximum allowable length: {0} characters, Current script length: {1} characters + The current script is too large for Parameterization for Always Encrypted, please disable Parameterization for Always Encrypted in Query Options (Query > Query Options > Execution > Advanced). Maximum allowable length: {0} characters, Current script length: {1} characters + . + Parameters: 0 - maxChars (int), 1 - currentChars (int) Extract project files diff --git a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/Batch.cs b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/Batch.cs index 485bf00a..db863968 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/Batch.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/QueryExecution/Batch.cs @@ -19,6 +19,9 @@ using System.Globalization; using System.Collections.ObjectModel; using Microsoft.SqlTools.ServiceLayer.Connection; using Microsoft.SqlTools.ServiceLayer.BatchParser; +using Microsoft.SqlTools.ServiceLayer.AutoParameterizaition; +using Microsoft.SqlTools.ServiceLayer.Workspace; +using Microsoft.SqlTools.ServiceLayer.SqlContext; namespace Microsoft.SqlTools.ServiceLayer.QueryExecution { @@ -399,6 +402,11 @@ namespace Microsoft.SqlTools.ServiceLayer.QueryExecution dbCommand.CommandType = CommandType.Text; dbCommand.CommandTimeout = 0; + if (WorkspaceService.Instance.CurrentSettings.QueryExecutionSettings.IsAlwaysEncryptedParameterizationEnabled) + { + dbCommand.Parameterize(); + } + List columnSchemas = null; if (getFullColumnSchema) { diff --git a/src/Microsoft.SqlTools.ServiceLayer/SqlContext/QueryExecutionSettings.cs b/src/Microsoft.SqlTools.ServiceLayer/SqlContext/QueryExecutionSettings.cs index 1ef78d4b..d4112c0e 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/SqlContext/QueryExecutionSettings.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/SqlContext/QueryExecutionSettings.cs @@ -6,6 +6,7 @@ using System; using Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts; using Microsoft.SqlTools.Utility; +using Newtonsoft.Json; namespace Microsoft.SqlTools.ServiceLayer.SqlContext { @@ -168,6 +169,11 @@ namespace Microsoft.SqlTools.ServiceLayer.SqlContext /// private bool DefaultSqlCmdMode = false; + /// + /// Default value for flag to enable Always Encrypted Parameterization + /// + private bool DefaultAlwaysEncryptedParameterizationValue = false; + #endregion #region Member Variables @@ -653,6 +659,22 @@ namespace Microsoft.SqlTools.ServiceLayer.SqlContext } } + /// + /// Set Always Encrypted Parameterization Mode + /// + [JsonProperty("alwaysEncryptedParameterization")] + public bool IsAlwaysEncryptedParameterizationEnabled + { + get + { + return GetOptionValue("alwaysEncryptedParameterization", DefaultAlwaysEncryptedParameterizationValue); + } + set + { + SetOptionValue("alwaysEncryptedParameterization", value); + } + } + #endregion #region Public Methods @@ -691,6 +713,7 @@ namespace Microsoft.SqlTools.ServiceLayer.SqlContext AnsiWarnings = newSettings.AnsiWarnings; AnsiNulls = newSettings.AnsiNulls; IsSqlCmdMode = newSettings.IsSqlCmdMode; + IsAlwaysEncryptedParameterizationEnabled = newSettings.IsAlwaysEncryptedParameterizationEnabled; } #endregion diff --git a/src/Microsoft.SqlTools.ServiceLayer/SqlContext/SqlToolsSettings.cs b/src/Microsoft.SqlTools.ServiceLayer/SqlContext/SqlToolsSettings.cs index ec148dae..2d7ebdc3 100644 --- a/src/Microsoft.SqlTools.ServiceLayer/SqlContext/SqlToolsSettings.cs +++ b/src/Microsoft.SqlTools.ServiceLayer/SqlContext/SqlToolsSettings.cs @@ -93,6 +93,7 @@ namespace Microsoft.SqlTools.ServiceLayer.SqlContext if (settings != null) { this.SqlTools.IntelliSense.Update(settings.SqlTools.IntelliSense); + this.SqlTools.QueryExecutionSettings.Update(settings.SqlTools.QueryExecutionSettings); } } diff --git a/test/Microsoft.SqlTools.ServiceLayer.UnitTests/AutoParameterization/SqlParameterizerTests.cs b/test/Microsoft.SqlTools.ServiceLayer.UnitTests/AutoParameterization/SqlParameterizerTests.cs new file mode 100644 index 00000000..fab29d78 --- /dev/null +++ b/test/Microsoft.SqlTools.ServiceLayer.UnitTests/AutoParameterization/SqlParameterizerTests.cs @@ -0,0 +1,273 @@ +// +// 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.Diagnostics; +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 Xunit; + +using static System.Linq.Enumerable; + +namespace Microsoft.SqlTools.ServiceLayer.UnitTests.AutoParameterization +{ + /// + /// 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. + /// + [Fact] + 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.Equal(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. + /// + [Fact] + 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.Equal(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. + /// + [Fact] + 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. + /// + [Fact] + 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. + /// + [Fact] + 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 + /// + [Fact] + 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.Equal(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. + /// + [Fact] + public void CodeSenseShouldReturnEmptyListWhenGivenANullScript() + { + string sql = null; + IList result = SqlParameterizer.CodeSense(sql); + + Assert.NotNull(result); + Assert.Empty(result); + } + + /// + /// 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. + /// + [Fact] + 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.Empty(result); + } + + /// + /// 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. + /// + [Fact] + 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.NotEmpty(result); + Assert.Equal(expected: 1, actual: result.Count); + Assert.Equal(expected: ScriptFileMarkerLevel.Error, actual: result[0].Level); + Assert.Equal(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. + /// + [Fact] + 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.NotEmpty(result); + Assert.Equal(expected: 3, actual: result.Count); + Assert.True(Enumerable.All(result, i => i.Level == ScriptFileMarkerLevel.Information)); + } + + #endregion + } +}