diff --git a/SqlScriptDom/Parser/TSql/Ast.xml b/SqlScriptDom/Parser/TSql/Ast.xml index ebf274a3..e9c2d9eb 100644 --- a/SqlScriptDom/Parser/TSql/Ast.xml +++ b/SqlScriptDom/Parser/TSql/Ast.xml @@ -3550,6 +3550,8 @@ + + @@ -4046,6 +4048,7 @@ + diff --git a/SqlScriptDom/Parser/TSql/OptionsHelper.cs b/SqlScriptDom/Parser/TSql/OptionsHelper.cs index a14a9e5b..d44e31c8 100644 --- a/SqlScriptDom/Parser/TSql/OptionsHelper.cs +++ b/SqlScriptDom/Parser/TSql/OptionsHelper.cs @@ -67,6 +67,11 @@ public OptionType Value { get { return _optionValue; } } + + public string Identifier + { + get { return _identifier; } + } } private Dictionary _optionToOptionInfo = new Dictionary(); @@ -208,6 +213,21 @@ internal bool TryGenerateSourceForOption(ScriptWriter writer, OptionType option) return false; } + // Exposes the literal source text of identifier-backed options so callers can apply their own + // casing; returns false for token-backed options, whose text comes from the token table. + internal bool TryGetOptionIdentifier(OptionType option, out string identifier) + { + OptionInfo optionInfo; + if (_optionToOptionInfo.TryGetValue(option, out optionInfo) && optionInfo.Identifier != null) + { + identifier = optionInfo.Identifier; + return true; + } + + identifier = null; + return false; + } + internal void GenerateCommaSeparatedFlagOptions(ScriptWriter writer, OptionType options) { bool first = true; diff --git a/SqlScriptDom/Parser/TSql/TSql100.g b/SqlScriptDom/Parser/TSql/TSql100.g index c17c2f9a..7b485ae6 100644 --- a/SqlScriptDom/Parser/TSql/TSql100.g +++ b/SqlScriptDom/Parser/TSql/TSql100.g @@ -133,13 +133,17 @@ script returns [TSqlScript vResult = this.FragmentFactory.CreateFragment()] +orderByClause [bool allowAll] returns [OrderByClause vResult = this.FragmentFactory.CreateFragment()] { ExpressionWithSortOrder vExpressionWithSortOrder; + SortOrder vAllSortOrder; } : tOrder:Order By { UpdateTokenInfo(vResult,tOrder); } - vExpressionWithSortOrder=expressionWithSortOrder - { - AddAndUpdateTokenInfo(vResult, vResult.OrderByElements, vExpressionWithSortOrder); - } ( - Comma vExpressionWithSortOrder=expressionWithSortOrder + tAll:All + { + // ORDER BY ALL (Fabric DW) is only allowed at the query / subquery level. + // It is not permitted inside OVER(), WINDOW definitions or WITHIN GROUP. + if (!allowAll) + ThrowIncorrectSyntaxErrorException(tAll); + + vResult.All = true; + UpdateTokenInfo(vResult, tAll); + } + (vAllSortOrder = orderByOption[vResult] + { + vResult.AllSortOrder = vAllSortOrder; + } + )? + | + vExpressionWithSortOrder=expressionWithSortOrder { AddAndUpdateTokenInfo(vResult, vResult.OrderByElements, vExpressionWithSortOrder); } - )* + ( + Comma vExpressionWithSortOrder=expressionWithSortOrder + { + AddAndUpdateTokenInfo(vResult, vResult.OrderByElements, vExpressionWithSortOrder); + } + )* + ) ; expressionWithSortOrder returns [ExpressionWithSortOrder vResult = this.FragmentFactory.CreateFragment()] @@ -29103,6 +29149,11 @@ regularColumnBody [IndexAffectingStatement statementType, ColumnDefinition vPare vParent.StorageOptions = vStorageOptions; } )? + // MASKED WITH may follow the storage options (documented SPARSE-before-MASKED order) + ( + {NextTokenMatches(CodeGenerationSupporter.Masked)}? + maskedClause[vParent] + )? )? ( {NextTokenMatches(CodeGenerationSupporter.Hidden)}? @@ -32001,7 +32052,7 @@ windowDefinition returns [WindowDefinition vResult = FragmentFactory.CreateFragm By expressionList[vResult, vResult.Partitions] )? ( - vOrderByClause=orderByClause + vOrderByClause=orderByClause[false] { vResult.OrderByClause = vOrderByClause; } @@ -32026,7 +32077,7 @@ overClause returns [OverClause vResult] } : vResult = overClauseBeginning ( - vOrderByClause=orderByClause + vOrderByClause=orderByClause[false] { vResult.OrderByClause = vOrderByClause; } @@ -32200,7 +32251,7 @@ withinGroupClause returns [WithinGroupClause vResult = FragmentFactory.CreateFra graphPathClause[vResult] ) | - vOrderByClause=orderByClause + vOrderByClause=orderByClause[false] { vResult.OrderByClause = vOrderByClause; UpdateTokenInfo(vResult,tLParen); diff --git a/SqlScriptDom/ScriptDom/SqlServer/BuiltInFunctionCasing.cs b/SqlScriptDom/ScriptDom/SqlServer/BuiltInFunctionCasing.cs new file mode 100644 index 00000000..f59463ef --- /dev/null +++ b/SqlScriptDom/ScriptDom/SqlServer/BuiltInFunctionCasing.cs @@ -0,0 +1,42 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.SqlServer.TransactSql.ScriptDom +{ + /// + /// Represents the possible ways of casing built-in / system function names during script + /// generation. Any value other than Preserve takes precedence over KeywordCasing for function + /// names; data type names, keywords, string literals, and variables are never affected. Casing + /// is applied to every unqualified, non-delimited function name, which is safe because T-SQL + /// resolves a one-part scalar function name as a built-in function and never as a user-defined + /// one (a scalar user-defined function must be called with at least a two-part name). + /// Schema-qualified and delimited function names, and method calls on a variable or column, are + /// always preserved. + /// + public enum BuiltInFunctionCasing + { + /// + /// Preserve the built-in function name exactly as it would be produced without this option + /// (backward-compatible: has no effect on the formatted output). + /// + Preserve, + + /// + /// All letters in upper case + /// + Uppercase, + + /// + /// All letters in lower case + /// + Lowercase, + + /// + /// First letter capitalized, remaining letters lower case + /// + PascalCase + } +} diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptGeneratorSupporter.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptGeneratorSupporter.cs index d766f43a..49ebfc4b 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptGeneratorSupporter.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptGeneratorSupporter.cs @@ -96,6 +96,38 @@ public static string GetCasedString(string str, IdentifierCasing casing) return str; } + /// + /// Retrieves a version of the specified built-in function name, in the built-in function + /// casing format specified. + /// + /// The built-in function name to get a specially cased version of + /// The built-in function casing method to use + /// A version of the string in the casing format specified in + [SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase")] + public static string GetCasedString(string str, BuiltInFunctionCasing casing) + { + if (string.IsNullOrEmpty(str)) + { + return str; + } + + switch (casing) + { + case BuiltInFunctionCasing.Preserve: + return str; + case BuiltInFunctionCasing.Lowercase: + return str.ToLowerInvariant(); + case BuiltInFunctionCasing.Uppercase: + return str.ToUpperInvariant(); + case BuiltInFunctionCasing.PascalCase: + return GetPascalCase(str); + default: + Debug.Fail("Invalid BuiltInFunctionCasing value"); + break; + } + return str; + } + /// /// Retrieves a Pascal Cased version of the string /// diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorOptions.LeadingComma.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorOptions.LeadingComma.cs deleted file mode 100644 index 6d5c8c70..00000000 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorOptions.LeadingComma.cs +++ /dev/null @@ -1,26 +0,0 @@ -//------------------------------------------------------------------------------ -// -// Copyright (c) Microsoft Corporation. All rights reserved. -// -//------------------------------------------------------------------------------ - -using System; - -namespace Microsoft.SqlServer.TransactSql.ScriptDom -{ - public partial class SqlScriptGeneratorOptions - { - /// - /// The number of whitespace characters written after a leading comma when - /// is . - /// The total width reserved for a leading comma is one column for the comma itself - /// plus this many columns of trailing whitespace (so the default of 1 reserves 2 columns). - /// - /// - /// This is currently an internal knob so the leading-comma spacing is defined in a single - /// place rather than hard-coded at every call site. It can later be promoted to a public - /// script generation option. - /// - internal Int32 LeadingCommaSpaceCount { get; set; } = 1; - } -} diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiAnalyzeSentimentFunction.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiAnalyzeSentimentFunction.cs index 181c775f..e5846571 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiAnalyzeSentimentFunction.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiAnalyzeSentimentFunction.cs @@ -16,7 +16,10 @@ partial class SqlScriptGeneratorVisitor /// Expression node to generate public override void ExplicitVisit(AIAnalyzeSentimentFunctionCall node) { - GenerateIdentifier(CodeGenerationSupporter.AIAnalyzeSentiment); + if (!TryGenerateBuiltInFunctionName(CodeGenerationSupporter.AIAnalyzeSentiment)) + { + GenerateIdentifier(CodeGenerationSupporter.AIAnalyzeSentiment); + } GenerateSymbol(TSqlTokenType.LeftParenthesis); GenerateFragmentIfNotNull(node.Input); GenerateSymbol(TSqlTokenType.RightParenthesis); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiClassifyFunction.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiClassifyFunction.cs index 9baf443b..d6997cba 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiClassifyFunction.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiClassifyFunction.cs @@ -18,7 +18,10 @@ partial class SqlScriptGeneratorVisitor /// Expression node to generate public override void ExplicitVisit(AIClassifyFunctionCall node) { - GenerateIdentifier(CodeGenerationSupporter.AIClassify); + if (!TryGenerateBuiltInFunctionName(CodeGenerationSupporter.AIClassify)) + { + GenerateIdentifier(CodeGenerationSupporter.AIClassify); + } GenerateSymbol(TSqlTokenType.LeftParenthesis); // input diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiExtractFunction.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiExtractFunction.cs index 0eb0fbb0..7ba23ad1 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiExtractFunction.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiExtractFunction.cs @@ -19,7 +19,10 @@ partial class SqlScriptGeneratorVisitor /// Expression node to generate public override void ExplicitVisit(AIExtractFunctionCall node) { - GenerateIdentifier(CodeGenerationSupporter.AIExtract); + if (!TryGenerateBuiltInFunctionName(CodeGenerationSupporter.AIExtract)) + { + GenerateIdentifier(CodeGenerationSupporter.AIExtract); + } GenerateSymbol(TSqlTokenType.LeftParenthesis); // input diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiFixGrammarFunction.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiFixGrammarFunction.cs index a5de5c1d..3d9f8b34 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiFixGrammarFunction.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiFixGrammarFunction.cs @@ -16,7 +16,10 @@ partial class SqlScriptGeneratorVisitor /// Expression node to generate public override void ExplicitVisit(AIFixGrammarFunctionCall node) { - GenerateIdentifier(CodeGenerationSupporter.AIFixGrammar); + if (!TryGenerateBuiltInFunctionName(CodeGenerationSupporter.AIFixGrammar)) + { + GenerateIdentifier(CodeGenerationSupporter.AIFixGrammar); + } GenerateSymbol(TSqlTokenType.LeftParenthesis); GenerateFragmentIfNotNull(node.Input); GenerateSymbol(TSqlTokenType.RightParenthesis); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiGenerateEmbeddingsFunction.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiGenerateEmbeddingsFunction.cs index 299a1c36..650b7b87 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiGenerateEmbeddingsFunction.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiGenerateEmbeddingsFunction.cs @@ -18,7 +18,10 @@ public override void ExplicitVisit(AIGenerateEmbeddingsFunctionCall node) } // Emit function name without extra space before '(' - GenerateIdentifierWithoutCasing(CodeGenerationSupporter.AIGenerateEmbeddings); + if (!TryGenerateBuiltInFunctionName(CodeGenerationSupporter.AIGenerateEmbeddings)) + { + GenerateIdentifierWithoutCasing(CodeGenerationSupporter.AIGenerateEmbeddings); + } GenerateSymbol(TSqlTokenType.LeftParenthesis); // Emit input expression diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiGenerateResponseFunction.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiGenerateResponseFunction.cs index 393a9af3..b9affe86 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiGenerateResponseFunction.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiGenerateResponseFunction.cs @@ -18,7 +18,10 @@ partial class SqlScriptGeneratorVisitor /// Expression node to generate public override void ExplicitVisit(AIGenerateResponseFunctionCall node) { - GenerateIdentifier(CodeGenerationSupporter.AIGenerateResponse); + if (!TryGenerateBuiltInFunctionName(CodeGenerationSupporter.AIGenerateResponse)) + { + GenerateIdentifier(CodeGenerationSupporter.AIGenerateResponse); + } GenerateSymbol(TSqlTokenType.LeftParenthesis); GenerateFragmentIfNotNull(node.PromptPart1); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiSummarizeFunction.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiSummarizeFunction.cs index fe1adafc..3d73ae5a 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiSummarizeFunction.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiSummarizeFunction.cs @@ -17,7 +17,10 @@ partial class SqlScriptGeneratorVisitor /// Expression node to generate public override void ExplicitVisit(AISummarizeFunctionCall node) { - GenerateIdentifier(CodeGenerationSupporter.AISummarize); + if (!TryGenerateBuiltInFunctionName(CodeGenerationSupporter.AISummarize)) + { + GenerateIdentifier(CodeGenerationSupporter.AISummarize); + } GenerateSymbol(TSqlTokenType.LeftParenthesis); GenerateFragmentIfNotNull(node.Input); GenerateSymbol(TSqlTokenType.RightParenthesis); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiTranslateFunction.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiTranslateFunction.cs index 922751c6..10c56ed2 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiTranslateFunction.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AiTranslateFunction.cs @@ -19,7 +19,10 @@ partial class SqlScriptGeneratorVisitor /// Expression node to generate public override void ExplicitVisit(AITranslateFunctionCall node) { - GenerateIdentifier(CodeGenerationSupporter.AITranslate); + if (!TryGenerateBuiltInFunctionName(CodeGenerationSupporter.AITranslate)) + { + GenerateIdentifier(CodeGenerationSupporter.AITranslate); + } GenerateSymbol(TSqlTokenType.LeftParenthesis); GenerateFragmentIfNotNull(node.Input); GenerateSymbol(TSqlTokenType.Comma); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AlterIndexStatement.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AlterIndexStatement.cs index 515faa68..29c09d34 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AlterIndexStatement.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AlterIndexStatement.cs @@ -82,7 +82,9 @@ public override void ExplicitVisit(AlterIndexStatement node) if (node.IndexOptions.Count > 0) { GenerateSpaceAndKeyword(TSqlTokenType.With); - GenerateParenthesisedCommaSeparatedList(node.IndexOptions); + // spaceBeforeSingleLineParenthesis: false preserves the pre-PR single-line output, + // which historically emits WITH(...) with no space before the parenthesis here. + GenerateWithOptionsList(node.IndexOptions, spaceBeforeSingleLineParenthesis: false); } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AlterTableRebuildStatement.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AlterTableRebuildStatement.cs index 2e1209cb..38d56478 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AlterTableRebuildStatement.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AlterTableRebuildStatement.cs @@ -19,8 +19,7 @@ public override void ExplicitVisit(AlterTableRebuildStatement node) if (node.IndexOptions.Count > 0) { GenerateSpaceAndKeyword(TSqlTokenType.With); - GenerateSpace(); - GenerateParenthesisedCommaSeparatedList(node.IndexOptions); + GenerateWithOptionsList(node.IndexOptions, spaceBeforeSingleLineParenthesis: true); } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BackupStatement.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BackupStatement.cs index 925d4b90..406f3271 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BackupStatement.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BackupStatement.cs @@ -32,8 +32,7 @@ protected void GenerateDeviceAndOption(BackupStatement node) { NewLineAndIndent(); GenerateKeyword(TSqlTokenType.With); - GenerateSpace(); - GenerateCommaSeparatedList(node.Options); + GenerateWithOptionsListNonParenthesized(node.Options); } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BooleanBinaryExpression.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BooleanBinaryExpression.cs index 85e94062..f3001f14 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BooleanBinaryExpression.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BooleanBinaryExpression.cs @@ -10,6 +10,8 @@ namespace Microsoft.SqlServer.TransactSql.ScriptDom.ScriptGenerator { partial class SqlScriptGeneratorVisitor { + private Boolean? _multilinePredicates; + public override void ExplicitVisit(BooleanBinaryExpression node) { AlignmentPoint start = new AlignmentPoint(); @@ -35,12 +37,27 @@ private Boolean RightPredicateOnNewline(BooleanBinaryExpression node) // * A Newline was inserted before the WHERE clause, AND // * The Binary Expression is an AND or an OR expression. Boolean insertNewline = - _options.MultilineWherePredicatesList && - _options.NewLineBeforeWhereClause && + (_multilinePredicates ?? + (_options.MultilineWherePredicatesList && _options.NewLineBeforeWhereClause)) && (node.BinaryExpressionType == BooleanBinaryExpressionType.And || node.BinaryExpressionType == BooleanBinaryExpressionType.Or); return insertNewline; } + private void GeneratePredicate(TSqlFragment predicate, Boolean multiline) + { + Boolean? previousMultilinePredicates = _multilinePredicates; + _multilinePredicates = multiline; + + try + { + GenerateFragmentIfNotNull(predicate); + } + finally + { + _multilinePredicates = previousMultilinePredicates; + } + } + } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BuiltInFunctionCasing.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BuiltInFunctionCasing.cs new file mode 100644 index 00000000..c5da8eea --- /dev/null +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BuiltInFunctionCasing.cs @@ -0,0 +1,80 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ +using Microsoft.SqlServer.TransactSql.ScriptDom; + +namespace Microsoft.SqlServer.TransactSql.ScriptDom.ScriptGenerator +{ + partial class SqlScriptGeneratorVisitor + { + // Emits the name of a built-in function whose name is represented in the AST by a keyword + // token (for example CONVERT, COALESCE, NULLIF, LEFT, RIGHT). When BuiltInFunctionCasing is + // Preserve this is a no-op and returns false so the caller emits its default representation + // (backward-compatible, so the name keeps following KeywordCasing). Otherwise the name is + // emitted with the configured casing applied, taking precedence over KeywordCasing, and + // true is returned. + protected bool TryGenerateBuiltInFunctionName(TSqlTokenType keywordId) + { + if (_options.BuiltInFunctionCasing == BuiltInFunctionCasing.Preserve) + { + return false; + } + + string baseText = ScriptGeneratorSupporter.GetLowerCase(keywordId); + GenerateToken(TSqlTokenType.Identifier, ScriptGeneratorSupporter.GetCasedString(baseText, _options.BuiltInFunctionCasing)); + return true; + } + + // Emits the name of a built-in function whose name is represented in the AST by a literal + // string (for example CAST, TRY_CAST, IIF). Behaves like the keyword overload above. + protected bool TryGenerateBuiltInFunctionName(string name) + { + if (_options.BuiltInFunctionCasing == BuiltInFunctionCasing.Preserve) + { + return false; + } + + GenerateToken(TSqlTokenType.Identifier, ScriptGeneratorSupporter.GetCasedString(name, _options.BuiltInFunctionCasing)); + return true; + } + + // Emits the name of a generic FunctionCall when BuiltInFunctionCasing is not Preserve. + // Returns false (leaving the caller to emit the name unchanged) for qualified calls, which + // also covers CLR/XML/spatial method invocations, and for delimited names. Every remaining + // name is re-cased without consulting a catalog of built-ins, because T-SQL resolves a + // one-part scalar function name as a built-in function and never as a user-defined one - a + // scalar UDF must be called with at least a two-part name - so a name reaching this point is + // either a built-in, whose name is matched case-insensitively under every collation, or + // already-invalid T-SQL. Table-valued functions, which do resolve one-part names against the + // default schema, are SchemaObjectFunctionTableReference and never reach this path. + private bool TryGenerateBuiltInFunctionName(FunctionCall node) + { + if (_options.BuiltInFunctionCasing == BuiltInFunctionCasing.Preserve) + { + return false; + } + + if (node.CallTarget != null) + { + return false; + } + + Identifier name = node.FunctionName; + if (name == null || name.Value == null || name.QuoteType != QuoteType.NotQuoted) + { + return false; + } + + // The replaced path emitted the name through GenerateFragmentIfNotNull, which is what + // normally runs the comment hooks and advances the last-processed token index. Emitting a + // raw token instead means they have to be driven explicitly, or comments adjacent to the + // function name are relocated into the argument list (or dropped for zero-argument calls). + HandleCommentsBeforeFragment(name); + GenerateToken(TSqlTokenType.Identifier, ScriptGeneratorSupporter.GetCasedString(name.Value, _options.BuiltInFunctionCasing)); + HandleCommentsAfterFragment(name); + return true; + } + } +} diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CastCall.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CastCall.cs index 5772f2a6..552400f6 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CastCall.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CastCall.cs @@ -11,7 +11,10 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(CastCall node) { - GenerateIdentifier(CodeGenerationSupporter.Cast); + if (!TryGenerateBuiltInFunctionName(CodeGenerationSupporter.Cast)) + { + GenerateIdentifier(CodeGenerationSupporter.Cast); + } GenerateSpaceAndSymbol(TSqlTokenType.LeftParenthesis); GenerateFragmentIfNotNull(node.Parameter); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CoalesceExpression.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CoalesceExpression.cs index 995e2f9f..38f0a576 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CoalesceExpression.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CoalesceExpression.cs @@ -11,7 +11,10 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(CoalesceExpression node) { - GenerateKeyword(TSqlTokenType.Coalesce); + if (!TryGenerateBuiltInFunctionName(TSqlTokenType.Coalesce)) + { + GenerateKeyword(TSqlTokenType.Coalesce); + } GenerateSpace(); GenerateParenthesisedCommaSeparatedList(node.Expressions); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Comments.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Comments.cs index 6fe8d85b..ef0e005a 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Comments.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Comments.cs @@ -427,6 +427,18 @@ internal void FlushDeferredTrailingSingleLineComments() _deferredTrailingSingleLineComments.Clear(); } + /// + /// Indicates that a trailing -- comment is waiting for the next newline. This allows + /// callers that are about to emit closing punctuation, such as a function's right + /// parenthesis, to start a new line first so the punctuation is not consumed by the comment. + /// + /// + /// For TRANSLATE(city, 'a', 'b' -- replacement characters, the closing parenthesis + /// must be emitted on the following line; otherwise it becomes part of the comment. + /// + protected bool HasDeferredTrailingSingleLineComments => + _deferredTrailingSingleLineComments.Count > 0; + /// /// Updates tracking after generating a fragment. /// diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CommonPhrases.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CommonPhrases.cs index 5004cc2c..16bbaec0 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CommonPhrases.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CommonPhrases.cs @@ -361,17 +361,29 @@ protected Boolean GenerateClauseBodyStart(Boolean newline, AlignmentPoint ap) return false; } + // Marks the river that lines up an INSERT statement's VALUES row constructors under its + // column list (e.g. "INSERT INTO t (a, b)" / "VALUES (1, 2)"). Only skipped for the + // Indented + !AlignClauseBodies combination, where ValuesInsertSource instead puts the row + // list on its own indented line (see ShouldMoveInsertValuesToNewLine); every other option + // combination keeps the existing river behavior so current baselines are unaffected. protected void MarkInsertColumnsAlignmentPointWhenNecessary(AlignmentPoint ap) { #if !PIMODLANGUAGE Debug.Assert(ap != null, "Alignment point should not be null"); #endif - if (ap != null) + if (ap != null && !ShouldMoveInsertValuesToNewLine()) { Mark(ap); } } + // True when an INSERT statement's VALUES row constructors should move to their own + // indented line instead of being padded (or single-spaced) onto the "VALUES" line. + protected Boolean ShouldMoveInsertValuesToNewLine() + { + return _options.ClauseBodyAlignment == ClauseBodyAlignment.Indented && !_options.AlignClauseBodies; + } + protected void GenerateSeparatorForOrderBy() { GenerateNewLineOrSpace(_options.NewLineBeforeOrderByClause); @@ -575,11 +587,34 @@ internal abstract HashSet StatementsThatCannotHaveSemiColon // part of CREATE VIEW statement, and we don't want to generate semicolon for the included statements protected Boolean _generateSemiColon = true; + // Blocks that StatementsThatCannotHaveSemiColon suppresses by default but that + // TerminateBlockStatements opts back in. TryCatchStatement covers END CATCH; END TRY is + // internal to the block and never reaches this check. + private static readonly HashSet _blockStatementsTerminatedByOption = new HashSet + { + typeof(BeginEndBlockStatement), + typeof(TryCatchStatement), + }; + + // Membership is tested on the exact runtime type, so a derived block such as + // BeginEndAtomicBlockStatement is unaffected by either set. + private Boolean CanHaveSemiColon(TSqlStatement statement) + { + Type statementType = statement.GetType(); + + if (StatementsThatCannotHaveSemiColon.Contains(statementType) == false) + { + return true; + } + + return _options.TerminateBlockStatements && _blockStatementsTerminatedByOption.Contains(statementType); + } + protected void GenerateSemiColonWhenNecessary(TSqlStatement node) { if (node != null && _generateSemiColon && - StatementsThatCannotHaveSemiColon.Contains(node.GetType()) == false) + CanHaveSemiColon(node)) { GenerateSymbol(TSqlTokenType.Semicolon); } @@ -635,7 +670,7 @@ protected void GenerateStatementWithSemiColon(TSqlStatement statement) // Only suppress for fragments at the statement boundary (LastTokenIndex). bool previousSuppressState = _suppressTrailingComments; int previousSuppressIndex = _suppressTrailingCommentsAfterIndex; - if (_options.PreserveComments && _generateSemiColon && !StatementsThatCannotHaveSemiColon.Contains(statement.GetType())) + if (_options.PreserveComments && _generateSemiColon && CanHaveSemiColon(statement)) { _suppressTrailingComments = true; _suppressTrailingCommentsAfterIndex = statement.LastTokenIndex; diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ComputeFunction.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ComputeFunction.cs index 2fa7b788..e64e8db1 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ComputeFunction.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ComputeFunction.cs @@ -10,7 +10,14 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(ComputeFunction node) { - ComputeFunctionTypeHelper.Instance.GenerateSourceForOption(_writer, node.ComputeFunctionType); + // The aggregate name here is the same built-in as in a SELECT list, so it follows + // BuiltInFunctionCasing rather than being emitted as a fixed-case literal. + if (!ComputeFunctionTypeHelper.Instance.TryGetOptionIdentifier(node.ComputeFunctionType, out string name) || + !TryGenerateBuiltInFunctionName(name)) + { + ComputeFunctionTypeHelper.Instance.GenerateSourceForOption(_writer, node.ComputeFunctionType); + } + GenerateParenthesisedFragmentIfNotNull(node.Expression); } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ConvertCall.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ConvertCall.cs index eb63a093..a3462ee9 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ConvertCall.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ConvertCall.cs @@ -11,7 +11,10 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(ConvertCall node) { - GenerateKeyword(TSqlTokenType.Convert); + if (!TryGenerateBuiltInFunctionName(TSqlTokenType.Convert)) + { + GenerateKeyword(TSqlTokenType.Convert); + } GenerateSpaceAndSymbol(TSqlTokenType.LeftParenthesis); GenerateFragmentIfNotNull(node.DataType); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CreateIndexStatement.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CreateIndexStatement.cs index 6ff2623f..c17597cb 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CreateIndexStatement.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CreateIndexStatement.cs @@ -69,8 +69,7 @@ protected virtual void GenerateIndexOptions(IList options) { GenerateSpaceAndKeyword(TSqlTokenType.With); - GenerateSpace(); - GenerateParenthesisedCommaSeparatedList(options); + GenerateWithOptionsList(options, spaceBeforeSingleLineParenthesis: true); } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CreateSelectiveXmlIndexStatement.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CreateSelectiveXmlIndexStatement.cs index 77b910b1..7b6bbbae 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CreateSelectiveXmlIndexStatement.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CreateSelectiveXmlIndexStatement.cs @@ -66,9 +66,8 @@ public override void ExplicitVisit(CreateSelectiveXmlIndexStatement node) { NewLine(); GenerateKeyword(TSqlTokenType.With); - GenerateSpace(); - GenerateParenthesisedCommaSeparatedList(node.IndexOptions); + GenerateWithOptionsList(node.IndexOptions, spaceBeforeSingleLineParenthesis: true); } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.FunctionCall.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.FunctionCall.cs index 37fe8287..baea48c4 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.FunctionCall.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.FunctionCall.cs @@ -10,28 +10,91 @@ namespace Microsoft.SqlServer.TransactSql.ScriptDom.ScriptGenerator { partial class SqlScriptGeneratorVisitor { + private int _multilineFunctionCallDepth; + public override void ExplicitVisit(LeftFunctionCall node) { - GenerateKeyword(TSqlTokenType.Left); - GenerateParenthesisedCommaSeparatedList(node.Parameters, true); - GenerateSpaceAndCollation(node.Collation); + if (!ShouldFormatFunctionCallParameterList(node.Parameters)) + { + if (!TryGenerateBuiltInFunctionName(TSqlTokenType.Left)) + { + GenerateKeyword(TSqlTokenType.Left); + } + GenerateSymbol(TSqlTokenType.LeftParenthesis); + GenerateCommaSeparatedList(node.Parameters); + GenerateFunctionCallRightParenthesis(); + GenerateSpaceAndCollation(node.Collation); + return; + } + + AlignmentPoint functionCallStart = PushFunctionCallAlignmentPoint(); + try + { + if (!TryGenerateBuiltInFunctionName(TSqlTokenType.Left)) + { + GenerateKeyword(TSqlTokenType.Left); + } + GenerateMultilineFunctionCallParameterList(node.Parameters); + GenerateSpaceAndCollation(node.Collation); + } + finally + { + PopFunctionCallAlignmentPoint(functionCallStart); + } } public override void ExplicitVisit(RightFunctionCall node) { - GenerateKeyword(TSqlTokenType.Right); - GenerateParenthesisedCommaSeparatedList(node.Parameters, true); - GenerateSpaceAndCollation(node.Collation); + if (!ShouldFormatFunctionCallParameterList(node.Parameters)) + { + if (!TryGenerateBuiltInFunctionName(TSqlTokenType.Right)) + { + GenerateKeyword(TSqlTokenType.Right); + } + GenerateSymbol(TSqlTokenType.LeftParenthesis); + GenerateCommaSeparatedList(node.Parameters); + GenerateFunctionCallRightParenthesis(); + GenerateSpaceAndCollation(node.Collation); + return; + } + + AlignmentPoint functionCallStart = PushFunctionCallAlignmentPoint(); + try + { + if (!TryGenerateBuiltInFunctionName(TSqlTokenType.Right)) + { + GenerateKeyword(TSqlTokenType.Right); + } + GenerateMultilineFunctionCallParameterList(node.Parameters); + GenerateSpaceAndCollation(node.Collation); + } + finally + { + PopFunctionCallAlignmentPoint(functionCallStart); + } } public override void ExplicitVisit(FunctionCall node) { + if (ShouldFormatFunctionCall(node)) + { + GenerateMultilineFunctionCall(node); + return; + } + GenerateFragmentIfNotNull(node.CallTarget); + // Recognized, unqualified built-in function names follow the BuiltInFunctionCasing + // option. When that option is Preserve (the default) or the name is a user-defined + // function, fall back to emitting the name with its original casing. + // // Function names are not affected by the IdentifierCasing / IdentifierBracketing options - // (their casing is governed by a separate option), so emit the function name with + // (their casing is governed by BuiltInFunctionCasing), so emit the function name with // identifier formatting suppressed. This has no effect under default options. - GenerateWithoutIdentifierFormatting(() => GenerateFragmentIfNotNull(node.FunctionName)); + if (!TryGenerateBuiltInFunctionName(node)) + { + GenerateWithoutIdentifierFormatting(() => GenerateFragmentIfNotNull(node.FunctionName)); + } GenerateSymbol(TSqlTokenType.LeftParenthesis); @@ -50,11 +113,18 @@ public override void ExplicitVisit(FunctionCall node) GenerateSpace(); } GenerateFragmentIfNotNull(node.Parameters[0]); - GenerateSpace(); + if (HasDeferredTrailingSingleLineComments) + { + NewLine(); + } + else + { + GenerateSpace(); + } GenerateKeyword(TSqlTokenType.From); GenerateSpace(); GenerateFragmentIfNotNull(node.Parameters[1]); - GenerateSymbol(TSqlTokenType.RightParenthesis); + GenerateFunctionCallRightParenthesis(); } else if (node.FunctionName.Value.ToUpper(CultureInfo.InvariantCulture) == CodeGenerationSupporter.JsonObject) { @@ -65,7 +135,7 @@ public override void ExplicitVisit(FunctionCall node) if (node.JsonParameters?.Count > 0 && node.ReturnType?.Count > 0) //If there are values and null on null or absent on null present then generate space in between them GenerateSpace(); GenerateReturnType(node?.ReturnType); - GenerateSymbol(TSqlTokenType.RightParenthesis); + GenerateFunctionCallRightParenthesis(); } else if (node.FunctionName.Value.ToUpper(CultureInfo.InvariantCulture) == CodeGenerationSupporter.JsonObjectAgg) { @@ -76,7 +146,7 @@ public override void ExplicitVisit(FunctionCall node) if (node.JsonParameters?.Count > 0 && node.ReturnType?.Count > 0) //If there are values and null on null or absent on null present then generate space in between them GenerateSpace(); GenerateReturnType(node?.ReturnType); - GenerateSymbol(TSqlTokenType.RightParenthesis); + GenerateFunctionCallRightParenthesis(); // Generate OVER clause for windowed json_objectagg GenerateSpaceAndFragmentIfNotNull(node.OverClause); } @@ -89,7 +159,7 @@ public override void ExplicitVisit(FunctionCall node) if (node.ReturnType?.Count > 0) //If there are values and null on null or absent on null present then generate space in between them GenerateSpace(); GenerateReturnType(node?.ReturnType); - GenerateSymbol(TSqlTokenType.RightParenthesis); + GenerateFunctionCallRightParenthesis(); } else if (node.FunctionName.Value.ToUpper(CultureInfo.InvariantCulture) == CodeGenerationSupporter.JsonArrayAgg) { @@ -102,7 +172,7 @@ public override void ExplicitVisit(FunctionCall node) if (node.ReturnType?.Count > 0) //If there are values and null on null or absent on null present then generate space in between them GenerateSpace(); GenerateReturnType(node?.ReturnType); - GenerateSymbol(TSqlTokenType.RightParenthesis); + GenerateFunctionCallRightParenthesis(); // Generate OVER clause for windowed json_arrayagg GenerateSpaceAndFragmentIfNotNull(node.OverClause); } @@ -121,7 +191,7 @@ public override void ExplicitVisit(FunctionCall node) GenerateIdentifier(CodeGenerationSupporter.Wrapper); } - GenerateSymbol(TSqlTokenType.RightParenthesis); + GenerateFunctionCallRightParenthesis(); } else if (node.FunctionName.Value.ToUpper(CultureInfo.InvariantCulture) == CodeGenerationSupporter.JsonValue) { @@ -131,7 +201,7 @@ public override void ExplicitVisit(FunctionCall node) GenerateSpace(); GenerateReturnType(node?.ReturnType); } - GenerateSymbol(TSqlTokenType.RightParenthesis); + GenerateFunctionCallRightParenthesis(); } else { @@ -140,7 +210,7 @@ public override void ExplicitVisit(FunctionCall node) GenerateSpace(); GenerateCommaSeparatedList(node.Parameters); - GenerateSymbol(TSqlTokenType.RightParenthesis); + GenerateFunctionCallRightParenthesis(); if (node.IgnoreRespectNulls?.Count > 0) { @@ -158,6 +228,188 @@ public override void ExplicitVisit(FunctionCall node) GenerateSpaceAndCollation(node.Collation); } + /// + /// Generates an ordinary function call using multiline parameter formatting while preserving + /// its optional null-handling, grouping, windowing, and collation clauses. + /// + private void GenerateMultilineFunctionCall(FunctionCall node) + { + AlignmentPoint functionCallStart = PushFunctionCallAlignmentPoint(); + try + { + GenerateFragmentIfNotNull(node.CallTarget); + if (!TryGenerateBuiltInFunctionName(node)) + { + GenerateWithoutIdentifierFormatting(() => GenerateFragmentIfNotNull(node.FunctionName)); + } + + GenerateMultilineFunctionCallParameterList(node.Parameters); + + if (node.IgnoreRespectNulls?.Count > 0) + { + GenerateSpace(); + GenerateWithoutIdentifierFormatting(() => GenerateSpaceSeparatedList(node.IgnoreRespectNulls)); + } + + GenerateSpaceAndFragmentIfNotNull(node.WithinGroupClause); + GenerateSpaceAndFragmentIfNotNull(node.OverClause); + GenerateSpaceAndCollation(node.Collation); + } + finally + { + PopFunctionCallAlignmentPoint(functionCallStart); + } + } + + /// + /// Determines whether to render a function's parameters with the generic multiline layout. + /// The option must be enabled, the function must use an ordinary comma-separated parameter + /// list without a DISTINCT or ALL qualifier, and the function must either be inside an + /// already-formatted nested call tree or contain another function in its parameter subtree. + /// + private bool ShouldFormatFunctionCall(FunctionCall node) + { + return node.UniqueRowFilter == UniqueRowFilter.NotSpecified + && !UsesSpecialFunctionCallSyntax(node) + && ShouldFormatFunctionCallParameterList(node.Parameters); + } + + /// + /// Determines whether a function contains nonstandard argument grammar, such as TRIM's FROM + /// syntax or JSON-specific key/value, null-handling, ordering, wrapper, and RETURNING clauses. + /// Such functions must bypass generic comma-separated multiline parameter rendering to + /// preserve valid SQL. + /// + private static bool UsesSpecialFunctionCallSyntax(FunctionCall node) + { + string functionName = node.FunctionName.Value.ToUpper(CultureInfo.InvariantCulture); + return (functionName == CodeGenerationSupporter.Trim && node.Parameters.Count == 2) + || functionName == CodeGenerationSupporter.JsonObject + || functionName == CodeGenerationSupporter.JsonObjectAgg + || functionName == CodeGenerationSupporter.JsonArray + || functionName == CodeGenerationSupporter.JsonArrayAgg + || functionName == CodeGenerationSupporter.JsonQuery + || functionName == CodeGenerationSupporter.JsonValue; + } + + /// + /// Determines whether a parameter list belongs to the nested function tree that should use + /// multiline formatting. The option must be enabled, and the list must either be inside an + /// already-formatted function call or contain another function in its parameter subtree. + /// This overload is also used by LEFT and RIGHT, which have dedicated AST node types. + /// + private bool ShouldFormatFunctionCallParameterList(IList parameters) where T : TSqlFragment + { + return _options.MultilineNestedFunctionCalls + && parameters.Count > 0 + && (_multilineFunctionCallDepth > 0 || ContainsFunctionCall(parameters)); + } + + /// + /// Anchors the outermost multiline function call so new lines return to its starting column. + /// Nested calls reuse that alignment scope. + /// + private AlignmentPoint PushFunctionCallAlignmentPoint() + { + if (_multilineFunctionCallDepth > 0) + { + return null; + } + + var functionCallStart = new AlignmentPoint(); + MarkAndPushAlignmentPointKeepingNameScope(functionCallStart); + return functionCallStart; + } + + /// + /// Removes the alignment scope created for an outermost multiline function call. + /// + private void PopFunctionCallAlignmentPoint(AlignmentPoint functionCallStart) + { + if (functionCallStart != null) + { + PopAlignmentPoint(); + } + } + + /// + /// Generates a multiline parameter list and tracks nested calls. A sole non-function + /// parameter remains beside the opening parenthesis. + /// + private void GenerateMultilineFunctionCallParameterList(IList parameters) where T : TSqlFragment + { + _multilineFunctionCallDepth++; + try + { + ListGenerationOption option = ListGenerationOption.CreateMultilineFunctionCallOption(_options); + if (parameters.Count == 1 && !ContainsFunctionCall(parameters)) + { + option.NewLineAfterOpenParenthesis = false; + option.NewLineBeforeCloseParenthesis = false; + option.NewLineBeforeItems = false; + option.MultipleIndentItems = 0; + } + + GenerateFragmentList(parameters, option); + } + finally + { + _multilineFunctionCallDepth--; + } + } + + /// + /// Determines whether any parameter subtree contains a supported function-call node. + /// + private static bool ContainsFunctionCall(IList parameters) where T : TSqlFragment + { + var visitor = new FunctionCallFindingVisitor(); + foreach (T parameter in parameters) + { + parameter.Accept(visitor); + if (visitor.Found) + { + return true; + } + } + + return false; + } + + private sealed class FunctionCallFindingVisitor : TSqlFragmentVisitor + { + public bool Found { get; private set; } + + public override void Visit(FunctionCall node) + { + Found = true; + } + + public override void ExplicitVisit(LeftFunctionCall node) + { + Found = true; + } + + public override void ExplicitVisit(RightFunctionCall node) + { + Found = true; + } + } + + /// + /// Generates a closing parenthesis on a new line when required to terminate a deferred + /// trailing single-line comment first. + /// + private void GenerateFunctionCallRightParenthesis() + { + if (HasDeferredTrailingSingleLineComments) + { + NewLine(); + } + + GenerateSymbol(TSqlTokenType.RightParenthesis); + } + public override void ExplicitVisit(JsonKeyValue pair) { GenerateFragmentIfNotNull(pair.JsonKeyName); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.GroupByClause.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.GroupByClause.cs index d3d73987..d96a9285 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.GroupByClause.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.GroupByClause.cs @@ -22,11 +22,26 @@ public override void ExplicitVisit(GroupByClause node) } AlignmentPoint clauseBody = GetAlignmentPointForFragment(node, ClauseBody); - if (!GenerateClauseBodyStart(_options.NewLineBeforeGroupByClause, clauseBody)) + + // Modern GROUP BY ALL (no explicit column list) has no grouping specifications; + // the grouping columns are inferred from the SELECT list. Only emit the body + // (and its leading space/newline) when a column list is actually present. + if (!node.All || node.GroupingSpecifications.Count > 0) { - GenerateSpace(); + if (!GenerateClauseBodyStart(_options.NewLineBeforeGroupByClause, clauseBody)) + { + GenerateSpace(); + } + + if (_options.MultilineGroupByElementsList) + { + GenerateAlignedMultilineCommaSeparatedList(node.GroupingSpecifications); + } + else + { + GenerateCommaSeparatedList(node.GroupingSpecifications); + } } - GenerateCommaSeparatedList(node.GroupingSpecifications); if (node.GroupByOption != GroupByOption.None) { diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.HavingClause.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.HavingClause.cs index c2b3bfe9..9b754ac6 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.HavingClause.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.HavingClause.cs @@ -19,11 +19,12 @@ public override void ExplicitVisit(HavingClause node) AlignmentPoint clauseBody = GetAlignmentPointForFragment(node, ClauseBody); if (GenerateClauseBodyStart(_options.NewLineBeforeHavingClause, clauseBody)) { - GenerateFragmentIfNotNull(node.SearchCondition); + GeneratePredicate(node.SearchCondition, _options.MultilineHavingPredicatesList); } else { - GenerateSpaceAndFragmentIfNotNull(node.SearchCondition); + GenerateSpace(); + GeneratePredicate(node.SearchCondition, _options.MultilineHavingPredicatesList); } PopAlignmentPoint(); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.IifCall.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.IifCall.cs index 2f2980c0..726c58dd 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.IifCall.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.IifCall.cs @@ -11,7 +11,10 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(IIfCall node) { - GenerateIdentifier(CodeGenerationSupporter.IIf); + if (!TryGenerateBuiltInFunctionName(CodeGenerationSupporter.IIf)) + { + GenerateIdentifier(CodeGenerationSupporter.IIf); + } GenerateSpaceAndSymbol(TSqlTokenType.LeftParenthesis); GenerateFragmentIfNotNull(node.Predicate); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.InsertStatement.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.InsertStatement.cs index 45957598..fe97db26 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.InsertStatement.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.InsertStatement.cs @@ -65,15 +65,30 @@ public override void ExplicitVisit(InsertSpecification node) if (node.Columns.Count > 0) { - MarkInsertColumnsAlignmentPointWhenNecessary(insertColumns); if (_options.MultilineInsertTargetsList) { ListGenerationOption option = ListGenerationOption.CreateOptionFromFormattingConfig(_options); - GenerateFragmentList(node.Columns, option); + if (option.NewLineBeforeOpenParenthesis || ShouldMoveInsertValuesToNewLine()) + { + // No inline "(" to align under (it moves to its own line), or the VALUES + // rows move to their own indented line: keep the pre-existing mark position. + MarkInsertColumnsAlignmentPointWhenNecessary(insertColumns); + GenerateFragmentList(node.Columns, option); + } + else + { + // Inline "(": mark insertColumns at the list's "(" (the same column the + // non-multiline branch marks) so the VALUES row constructors line up under + // the target list's opening parenthesis. + GenerateFragmentList(node.Columns, option, insertColumns); + } } else { + // Mark insertColumns right at "(" (after the separating space), matching where + // ValuesInsertSource marks it for its own row list, so both parentheses line up. GenerateSpace(); + MarkInsertColumnsAlignmentPointWhenNecessary(insertColumns); GenerateParenthesisedCommaSeparatedList(node.Columns); } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.InvokeExternalApiFunction.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.InvokeExternalApiFunction.cs index 8b6e8188..4e7fb3c9 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.InvokeExternalApiFunction.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.InvokeExternalApiFunction.cs @@ -31,7 +31,10 @@ public override void ExplicitVisit(InvokeExternalApiFunctionCall node) throw new InvalidOperationException("InvokeExternalApiFunctionCall.FunctionName is required."); } - GenerateIdentifier(CodeGenerationSupporter.InvokeExternalApi); + if (!TryGenerateBuiltInFunctionName(CodeGenerationSupporter.InvokeExternalApi)) + { + GenerateIdentifier(CodeGenerationSupporter.InvokeExternalApi); + } GenerateSymbol(TSqlTokenType.LeftParenthesis); GenerateFragmentIfNotNull(node.FunctionSetName); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ListGenerationOption.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ListGenerationOption.cs index 2fbb9c26..1df5d7d7 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ListGenerationOption.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ListGenerationOption.cs @@ -30,6 +30,7 @@ internal enum SeparatorType public Boolean NewLineBeforeFirstItem { get; set; } public Boolean NewLineBeforeItems { get; set; } + public Boolean AlignItemsForNewLines { get; set; } public int MultipleIndentItems { get; set; } public static readonly ListGenerationOption MultipleLineSelectElementOption = new ListGenerationOption() @@ -81,6 +82,25 @@ public static ListGenerationOption CreateOptionFromFormattingConfig(SqlScriptGen return option; } + + public static ListGenerationOption CreateMultilineFunctionCallOption(SqlScriptGeneratorOptions formatting) + { + return new ListGenerationOption + { + Parenthesised = true, + AlwaysGenerateParenthesis = true, + NewLineBeforeOpenParenthesis = formatting.NewLineBeforeOpenParenthesisInMultilineList, + NewLineAfterOpenParenthesis = true, + IndentParentheses = false, + NewLineBeforeCloseParenthesis = formatting.NewLineBeforeCloseParenthesisInMultilineList, + AlignParentheses = false, + NewLineBeforeItems = true, + NewLineBeforeFirstItem = false, + AlignItemsForNewLines = true, + MultipleIndentItems = 1, + Separator = SeparatorType.Comma, + }; + } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.NullIfExpression.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.NullIfExpression.cs index 2619ec56..b2ae046d 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.NullIfExpression.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.NullIfExpression.cs @@ -11,7 +11,10 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(NullIfExpression node) { - GenerateKeyword(TSqlTokenType.NullIf); + if (!TryGenerateBuiltInFunctionName(TSqlTokenType.NullIf)) + { + GenerateKeyword(TSqlTokenType.NullIf); + } GenerateSpaceAndSymbol(TSqlTokenType.LeftParenthesis); GenerateFragmentIfNotNull(node.FirstExpression); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OptimizerHints.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OptimizerHints.cs index 201df617..22f05f50 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OptimizerHints.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OptimizerHints.cs @@ -17,8 +17,11 @@ protected void GenerateOptimizerHints(IList hints) AlignmentPoint start = new AlignmentPoint(); MarkAndPushAlignmentPoint(start); - GenerateKeywordAndSpace(TSqlTokenType.Option); - GenerateParenthesisedCommaSeparatedList(hints); + // GenerateKeyword (not GenerateKeywordAndSpace): the separating space before the + // open parenthesis is emitted by GenerateWithOptionsList so the single-line output + // (OPTION (...)) is unchanged while multiline output is handled uniformly. + GenerateKeyword(TSqlTokenType.Option); + GenerateWithOptionsList(hints, spaceBeforeSingleLineParenthesis: true); PopAlignmentPoint(); } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OrderByClause.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OrderByClause.cs index c74949e7..a9375204 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OrderByClause.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OrderByClause.cs @@ -18,11 +18,39 @@ public override void ExplicitVisit(OrderByClause node) GenerateSpaceAndKeyword(TSqlTokenType.By); AlignmentPoint clauseBody = GetAlignmentPointForFragment(node, ClauseBody); - if (!GenerateClauseBodyStart(_options.NewLineBeforeOrderByClause, clauseBody)) + + if (node.All) { + // ORDER BY ALL shorthand: orders by every column in the select list. + // ALL is a keyword modifier of the clause (like GROUP BY ALL), so keep it on + // the same line as ORDER BY. The NewLineBeforeOrderByClause option only applies + // to an explicit column list, not to the ALL shorthand. GenerateSpace(); + GenerateKeyword(TSqlTokenType.All); + + TokenGenerator sortOrderGenerator = GetValueForEnumKey(_sortOrderGenerators, node.AllSortOrder); + if (sortOrderGenerator != null && node.AllSortOrder != SortOrder.NotSpecified) + { + GenerateSpace(); + GenerateToken(sortOrderGenerator); + } + } + else + { + if (!GenerateClauseBodyStart(_options.NewLineBeforeOrderByClause, clauseBody)) + { + GenerateSpace(); + } + + if (_options.MultilineOrderByElementsList) + { + GenerateAlignedMultilineCommaSeparatedList(node.OrderByElements); + } + else + { + GenerateCommaSeparatedList(node.OrderByElements); + } } - GenerateCommaSeparatedList(node.OrderByElements); PopAlignmentPoint(); } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OverClause.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OverClause.cs index 36c072b7..a9d8a42a 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OverClause.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OverClause.cs @@ -40,7 +40,14 @@ public override void ExplicitVisit(OverClause node) GenerateSpaceAndKeyword(TSqlTokenType.By); GenerateSpace(); - GenerateCommaSeparatedList(node.Partitions); + if (_options.MultilinePartitionByElementsList) + { + GenerateAlignedMultilineCommaSeparatedList(node.Partitions); + } + else + { + GenerateCommaSeparatedList(node.Partitions); + } } if (node.OrderByClause != null) diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ParseCall.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ParseCall.cs index c81f67cc..b769a93a 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ParseCall.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ParseCall.cs @@ -11,7 +11,10 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(ParseCall node) { - GenerateIdentifier(CodeGenerationSupporter.Parse); + if (!TryGenerateBuiltInFunctionName(CodeGenerationSupporter.Parse)) + { + GenerateIdentifier(CodeGenerationSupporter.Parse); + } GenerateSpaceAndSymbol(TSqlTokenType.LeftParenthesis); GenerateFragmentIfNotNull(node.StringValue); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.RestoreStatement.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.RestoreStatement.cs index ee632b72..669929a9 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.RestoreStatement.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.RestoreStatement.cs @@ -65,12 +65,11 @@ public override void ExplicitVisit(RestoreStatement node) NewLineAndIndent(); GenerateKeyword(TSqlTokenType.With); - GenerateSpace(); // could be // MoveRestoreOption // SimpleRestoreOption // StopRestoreOption - GenerateCommaSeparatedList(node.Options); + GenerateWithOptionsListNonParenthesized(node.Options); } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSEqualCall.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSEqualCall.cs index 9e93cf2d..2f8ec041 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSEqualCall.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSEqualCall.cs @@ -11,7 +11,10 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(TSEqualCall node) { - GenerateKeyword(TSqlTokenType.TSEqual); + if (!TryGenerateBuiltInFunctionName(TSqlTokenType.TSEqual)) + { + GenerateKeyword(TSqlTokenType.TSEqual); + } GenerateSpaceAndSymbol(TSqlTokenType.LeftParenthesis); GenerateFragmentIfNotNull(node.FirstExpression); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlBatch.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlBatch.cs index a5ed633c..3151471e 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlBatch.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlBatch.cs @@ -23,8 +23,10 @@ public override void ExplicitVisit(TSqlBatch node) if (statement is TSqlStatementSnippet == false) { - NewLine(); - NewLine(); + for (int i = 0; i < _options.NumNewlinesAfterBatchStatement; i++) + { + NewLine(); + } } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs index 5f568500..ced6634a 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs @@ -27,16 +27,38 @@ public override void ExplicitVisit(TSqlScript node) } else { + // GO always starts a new line, whatever the batch statements left behind. NewLine(); GenerateKeyword(TSqlTokenType.Go); - NewLine(); + GenerateNewLinesAfterBatch(); } GenerateFragmentIfNotNull(item); } + // Preserve the trailing GO separators when the parsed script ended with them and the option is enabled. + if (_options.PersistTrailingGo && node.TrailingGoCount > 0) + { + // Emit comments that precede the trailing GO(s) so they stay above the batch separator. + EmitCommentsUntilNextNonTriviaToken(); + for (int i = 0; i < node.TrailingGoCount; i++) + { + NewLine(); + GenerateKeyword(TSqlTokenType.Go); + GenerateNewLinesAfterBatch(); + } + } + // Emit any remaining comments at end of script (after the last statement) EmitRemainingComments(); } + + private void GenerateNewLinesAfterBatch() + { + for (int i = 0; i < _options.NumNewlinesAfterBatches; i++) + { + NewLine(); + } + } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TableHints.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TableHints.cs index 60f15d2d..5ecb4d92 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TableHints.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TableHints.cs @@ -15,8 +15,7 @@ protected void GenerateWithTableHints(IList tableHints) if (tableHints.Count > 0) { GenerateSpaceAndKeyword(TSqlTokenType.With); - GenerateSpace(); - GenerateParenthesisedCommaSeparatedList(tableHints); + GenerateWithOptionsList(tableHints, spaceBeforeSingleLineParenthesis: true); } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TryCastCall.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TryCastCall.cs index e7f2b8ba..4aa51776 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TryCastCall.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TryCastCall.cs @@ -11,7 +11,10 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(TryCastCall node) { - GenerateIdentifier(CodeGenerationSupporter.TryCast); + if (!TryGenerateBuiltInFunctionName(CodeGenerationSupporter.TryCast)) + { + GenerateIdentifier(CodeGenerationSupporter.TryCast); + } GenerateSpaceAndSymbol(TSqlTokenType.LeftParenthesis); GenerateFragmentIfNotNull(node.Parameter); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TryConvertCall.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TryConvertCall.cs index 268ef1d1..85675cb7 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TryConvertCall.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TryConvertCall.cs @@ -11,7 +11,10 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(TryConvertCall node) { - GenerateKeyword(TSqlTokenType.TryConvert); + if (!TryGenerateBuiltInFunctionName(TSqlTokenType.TryConvert)) + { + GenerateKeyword(TSqlTokenType.TryConvert); + } GenerateSpaceAndSymbol(TSqlTokenType.LeftParenthesis); GenerateFragmentIfNotNull(node.DataType); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TryParseCall.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TryParseCall.cs index 47338c31..04b83415 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TryParseCall.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TryParseCall.cs @@ -11,7 +11,10 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(TryParseCall node) { - GenerateIdentifier(CodeGenerationSupporter.TryParse); + if (!TryGenerateBuiltInFunctionName(CodeGenerationSupporter.TryParse)) + { + GenerateIdentifier(CodeGenerationSupporter.TryParse); + } GenerateSpaceAndSymbol(TSqlTokenType.LeftParenthesis); GenerateFragmentIfNotNull(node.StringValue); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Utils.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Utils.cs index df7b2f49..cdefa265 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Utils.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Utils.cs @@ -224,7 +224,7 @@ protected void GenerateCommaSeparatedList(IList list, Boolean insertNewLin else { GenerateSymbol(TSqlTokenType.Comma); - if (insertNewLine) + if (insertNewLine || HasDeferredTrailingSingleLineComments) { NewLine(); } @@ -236,6 +236,41 @@ protected void GenerateCommaSeparatedList(IList list, Boolean insertNewLin }); } + // generate a multiline comma-separated list whose elements remain aligned when commas lead + protected void GenerateAlignedMultilineCommaSeparatedList(IList list) where T : TSqlFragment + { + if (list == null) + { + return; + } + + Boolean first = true; + Boolean leadingComma = _options.CommaPlacement == CommaPlacement.Leading; + AlignmentPoint elements = new AlignmentPoint(); + + foreach (T fragment in list) + { + if (!first) + { + if (leadingComma) + { + NewLine(); + GenerateRightAlignedCommaSeparator(); + } + else + { + GenerateSymbol(TSqlTokenType.Comma); + NewLine(); + } + } + + MarkAndPushAlignmentPoint(elements); + GenerateFragmentIfNotNull(fragment); + PopAlignmentPoint(); + first = false; + } + } + // generate a comma-separated list protected void GenerateCommaSeparatedList(IList list, bool insertNewLine, bool indent, bool generateSpaces = true) where T : TSqlFragment { @@ -334,9 +369,12 @@ protected void GenerateParenthesisedCommaSeparatedList(IList list, Boolean } } - protected void GenerateFragmentList(IList list, ListGenerationOption option) where T : TSqlFragment + protected void GenerateFragmentList(IList list, ListGenerationOption option, AlignmentPoint openParenthesisAlignmentPoint = null) where T : TSqlFragment { - AlignmentPoint parentheses = new AlignmentPoint(); + // When the caller supplies its own open-parenthesis alignment point, mark it at the "(" + // so downstream content (e.g. an INSERT statement's VALUES row constructors) can line up + // under the list's opening parenthesis. Defaults to a private point for all other callers. + AlignmentPoint parentheses = openParenthesisAlignmentPoint ?? new AlignmentPoint(); AlignmentPoint items = new AlignmentPoint(); Boolean generateParentheses = (option.AlwaysGenerateParenthesis || (list.Count > 0 && option.Parenthesised)); @@ -449,7 +487,7 @@ protected void GenerateFragmentList(IList list, ListGenerationOption optio // Only push alignment point for NewLine() restoration when comment preservation is enabled. // This ensures NewLine() calls within items (e.g., from EmitCommentToken) can restore // to the correct indented position, without affecting general formatting. - if (_options.PreserveComments) + if (_options.PreserveComments || option.AlignItemsForNewLines) { AlignmentPoint itemScope = new AlignmentPoint(); // Keep the current named-alignment-point scope so field alignment points @@ -483,7 +521,7 @@ protected void GenerateFragmentList(IList list, ListGenerationOption optio // generate close parenthesis if (generateParentheses) { - if (option.NewLineBeforeCloseParenthesis) + if (option.NewLineBeforeCloseParenthesis || HasDeferredTrailingSingleLineComments) { NewLine(); if (option.AlignParentheses) diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ValuesInsertSource.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ValuesInsertSource.cs index cff3ddcd..726644a5 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ValuesInsertSource.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ValuesInsertSource.cs @@ -25,15 +25,72 @@ public override void ExplicitVisit(ValuesInsertSource node) } else { - GenerateKeywordAndSpace(TSqlTokenType.Values); - - MarkClauseBodyAlignmentWhenNecessary(true, clauseBody); + GenerateKeyword(TSqlTokenType.Values); AlignmentPoint insertColumns = GetAlignmentPointForFragment(node, InsertColumns); + bool moveToNewLine = ShouldMoveInsertValuesToNewLine(); + + if (moveToNewLine) + { + NewLineAndIndent(); + } + else + { + GenerateSpace(); + MarkClauseBodyAlignmentWhenNecessary(true, clauseBody); + MarkInsertColumnsAlignmentPointWhenNecessary(insertColumns); + } + + // insertColumns is null for a ValuesInsertSource scripted on its own (no enclosing + // INSERT/MERGE registered the column-list point); skip all re-anchoring in that case. + bool alignLeadingCommaRows = !moveToNewLine + && _options.CommaPlacement == CommaPlacement.Leading + && insertColumns != null; + + if (alignLeadingCommaRows) + { + // A plain leading comma after NewLine() would land 2 columns past the aligned "(". + // Restore each continuation row to a neutral anchor, then re-mark insertColumns + // after a right-aligned comma so every row's "(" lines up with the first row's. + AlignmentPoint rowAnchor = new AlignmentPoint(); + PushAlignmentPoint(rowAnchor); + + bool firstRow = true; + foreach (RowValue rowValue in node.RowValues) + { + if (!firstRow) + { + NewLine(); + GenerateRightAlignedCommaSeparator(); + // insertColumns is non-null here (guaranteed by alignLeadingCommaRows). + Mark(insertColumns); + } + + GenerateFragmentIfNotNull(rowValue); + firstRow = false; + } + + PopAlignmentPoint(); + } + else + { + // Align continuation rows under the first row when an enclosing INSERT/MERGE + // registered insertColumns. For a bare source (null) we intentionally skip the + // push: rows fall back to the statement's start anchor, which is null-safe and + // avoids an unreachable branch (the non-moved bare-source path can't run in debug + // builds because the null clauseBody/insertColumns marks above assert first). + if (insertColumns != null) + { + PushAlignmentPoint(insertColumns); + } - MarkInsertColumnsAlignmentPointWhenNecessary(insertColumns); + GenerateCommaSeparatedList(node.RowValues, true, moveToNewLine); - GenerateCommaSeparatedList(node.RowValues, true); + if (insertColumns != null) + { + PopAlignmentPoint(); + } + } } PopAlignmentPoint(); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.WhereClause.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.WhereClause.cs index 2e222117..ac94831d 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.WhereClause.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.WhereClause.cs @@ -22,13 +22,16 @@ public override void ExplicitVisit(WhereClause node) if (node.SearchCondition != null) { + bool multilinePredicates = _options.MultilineWherePredicatesList && _options.NewLineBeforeWhereClause; + if (indented) { - GenerateFragmentIfNotNull(node.SearchCondition); + GeneratePredicate(node.SearchCondition, multilinePredicates); } else { - GenerateSpaceAndFragmentIfNotNull(node.SearchCondition); + GenerateSpace(); + GeneratePredicate(node.SearchCondition, multilinePredicates); } } else diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.WindowDefinition.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.WindowDefinition.cs index dc40eb84..6d77ae23 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.WindowDefinition.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.WindowDefinition.cs @@ -31,7 +31,14 @@ public override void ExplicitVisit(WindowDefinition node) GenerateSpaceAndKeyword(TSqlTokenType.By); GenerateSpace(); - GenerateCommaSeparatedList(node.Partitions); + if (_options.MultilinePartitionByElementsList) + { + GenerateAlignedMultilineCommaSeparatedList(node.Partitions); + } + else + { + GenerateCommaSeparatedList(node.Partitions); + } } if (node.OrderByClause != null) diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.WithOptionsList.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.WithOptionsList.cs new file mode 100644 index 00000000..47e92e44 --- /dev/null +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.WithOptionsList.cs @@ -0,0 +1,92 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ +using System.Collections.Generic; +using Microsoft.SqlServer.TransactSql.ScriptDom; + +namespace Microsoft.SqlServer.TransactSql.ScriptDom.ScriptGenerator +{ + partial class SqlScriptGeneratorVisitor + { + // Scope of MultilineWithOptionsList (what these helpers do and do NOT cover): + // + // Covered (routed through these helpers): + // * Index options for every statement that funnels through the shared virtual + // GenerateIndexOptions (CREATE/ALTER INDEX, CREATE COLUMNSTORE/JSON/VECTOR INDEX, + // inline index definitions and UNIQUE/PRIMARY KEY constraints in CREATE TABLE, and + // ALTER TABLE ... ALTER INDEX / REBUILD). + // * CREATE SELECTIVE XML INDEX options, table hints (WITH), query hints (OPTION), and + // the non-parenthesized BACKUP / RESTORE WITH options. + // + // Intentionally NOT covered: + // * The Sql80ScriptGeneratorVisitor.GenerateIndexOptions override, which emits the legacy + // SQL Server 2000 "WITH opt, opt" (non-parenthesized) index-option syntax. It does not + // call these helpers, so the option is a no-op for that generator. + // * ALTER INDEX ... SET (...): SET is a distinct clause from WITH and is left single-line. + // * The non-selective CREATE XML INDEX and CREATE SPATIAL INDEX option lists, which emit + // their WITH (...) options through their own generators rather than the shared + // GenerateIndexOptions (only CREATE SELECTIVE XML INDEX is routed through these helpers). + // * Other WITH / option lists outside the list above, e.g. CREATE TABLE table options + // (MEMORY_OPTIMIZED, DATA_COMPRESSION, DISTRIBUTION), CREATE/ALTER PROCEDURE|FUNCTION| + // TRIGGER WITH options (ENCRYPTION, SCHEMABINDING, EXECUTE AS), CREATE STATISTICS WITH, + // FULLTEXT INDEX WITH, and DBCC ... WITH. CREATE EXTERNAL TABLE already has its own + // multiline handling and is not affected here. The WITH keyword of a common table + // expression and XMLNAMESPACES are unrelated constructs and are never touched. + + // Generates a parenthesized WITH / OPTION clause option list (index options, table hints, + // query hints). When MultilineWithOptionsList is enabled the options are written one per + // line inside the parentheses (honoring CommaPlacement and the parenthesis-placement + // options); otherwise the original single-line parenthesized list is produced. Callers that + // have not already written the separating space before the open parenthesis (for example + // ALTER INDEX ... REBUILD, which historically emits WITH(...) with no space) pass + // spaceBeforeSingleLineParenthesis = true so their single-line output is unchanged. + protected void GenerateWithOptionsList(IList options, bool spaceBeforeSingleLineParenthesis) where T : TSqlFragment + { + if (options == null || options.Count == 0) + { + return; + } + + if (_options.MultilineWithOptionsList) + { + GenerateFragmentList(options, ListGenerationOption.CreateOptionFromFormattingConfig(_options)); + } + else + { + if (spaceBeforeSingleLineParenthesis) + { + GenerateSpace(); + } + + GenerateParenthesisedCommaSeparatedList(options); + } + } + + // Generates a non-parenthesized WITH clause option list (BACKUP / RESTORE options). The + // caller has already written the WITH keyword. When MultilineWithOptionsList is enabled each + // option is written on its own line indented one level from the statement keyword (aligned + // beneath the WITH keyword), honoring CommaPlacement; otherwise the options remain on a + // single line following the WITH keyword. + protected void GenerateWithOptionsListNonParenthesized(IList options) where T : TSqlFragment + { + if (options == null || options.Count == 0) + { + return; + } + + if (_options.MultilineWithOptionsList) + { + // Same layout as procedure parameters: non-parenthesized, one option per line, + // indented one level, with the leading new line produced by the option itself. + GenerateFragmentList(options, ListGenerationOption.MultipleLineProcedureParameterOption); + } + else + { + GenerateSpace(); + GenerateCommaSeparatedList(options); + } + } + } +} diff --git a/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml b/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml index e8b6bd1c..a7c86591 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml +++ b/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml @@ -27,6 +27,13 @@ Gets or sets how square brackets are applied to object identifiers during script generation. IncludeBrackets wraps all identifiers in square brackets; ExcludeBrackets removes brackets from identifiers that do not require them (identifiers conflicting with reserved words for the configured SqlVersion or containing special characters retain their brackets). + + + + + + Gets or sets the casing applied to built-in / system function names (such as GETDATE, ISNULL, COALESCE, CAST, COUNT) during script generation. The default (Preserve) has no effect on the formatted output. When set to any other value it takes precedence over KeywordCasing for function names; under Preserve, keyword-backed built-ins such as CAST and CONVERT continue to follow KeywordCasing. Data type names, keywords, string literals, and variables are never affected. Casing is applied to every unqualified, non-delimited function name, which is safe because T-SQL resolves a one-part scalar function name as a built-in function and never as a user-defined one (a scalar user-defined function must be called with at least a two-part name). Schema-qualified (for example dbo.MyFunc) and delimited (for example [MyFunc]) function names, and method calls on a variable or column (for example @g.STArea()), are always preserved. Keyword-form niladic functions (such as CURRENT_TIMESTAMP, SYSTEM_USER) and built-in table-valued functions (such as STRING_SPLIT, OPENJSON) are not affected. + Gets or sets the Sql version to generate script for @@ -44,6 +51,9 @@ IncludeSemiColons_Description Gets or sets a boolean indicating if a semi colon should be included after each statement + + Gets or sets a boolean indicating if a semicolon terminator is written after a BEGIN...END block and after the END CATCH of a BEGIN TRY...END TRY BEGIN CATCH...END CATCH block. When false (the default), these blocks are written without a terminator, preserving the previous script-generation behavior. When true, a semicolon is appended, so a terminator supplied in the source script survives a parse and generate round-trip. END TRY is never terminated because it is internal to the TRY...CATCH block. + Gets or sets a boolean indicating if index definitions should have UNIQUE, INCLUDE and WHERE on their own line @@ -57,7 +67,16 @@ Gets or sets a boolean indicating if a space should be included between parameters in a data type - Gets or sets the number of newlines to include after each statement + Gets or sets the number of newlines to include after each statement of a nested statement list, such as the body of a BEGIN...END block, stored procedure, trigger, function, IF or WHILE. The newlines are written between consecutive statements only, not after the last one, so the default of 1 puts each nested statement on its own line with no blank line between them. Statements owned directly by a batch are not affected by this setting; use NumNewlinesAfterBatchStatement for those. + + + Gets or sets the number of newlines to include after each statement owned directly by a batch, that is, each top level statement of a script between GO separators. The newlines are written after every such statement, including the last one in the batch, except when the statement is a TSqlStatementSnippet (snippets are emitted as-is and do not receive additional trailing newlines from this option). The default of 2 leaves one blank line between consecutive top level statements and, together with the newline that always precedes GO, two blank lines before the GO that closes the batch. Statements nested inside another statement are not affected by this setting; use NumNewlinesAfterStatement for those. + + + Gets or sets the number of newlines to include after the GO separator that is written between two batches. GO is always preceded by a newline of its own so that it starts a new line, and the blank lines before it are controlled by NumNewlinesAfterBatchStatement; this setting only controls what follows GO, so the default of 1 starts the next batch on the following line with no blank line in between. + + + Gets or sets a boolean indicating whether the trailing GO batch separators are generated when the parsed script ended with them (see TSqlScript.TrailingGoCount). Defaults to false, so a trailing GO is not emitted and generation behavior is unchanged. Gets or sets a boolean indicating if comments from the original script should be preserved in the generated output @@ -65,6 +84,9 @@ Gets or sets a value indicating whether commas in a multi-line comma-separated list are placed at the end of the line (trailing) or at the start of the next line (leading). When set to Leading, the IndentationMode setting is ignored and indentation always uses spaces. + + Gets or sets the number of spaces written after a leading comma when CommaPlacement is Leading. Valid values are 0 and 1. The default is 1. + @@ -110,10 +132,10 @@ - Gets or sets how a clause body (the part after FROM, WHERE, GROUP BY, etc.) is laid out. Aligned keeps the body on the keyword's line, lining all clause bodies up under a shared column past the widest keyword. Indented puts the body on its own new line, one indent level (IndentationSize) past the keyword, so nesting grows one step per level instead of drifting right. When Indented, AlignClauseBodies is ignored. + Gets or sets how a clause body (the part after FROM, WHERE, GROUP BY, etc.) is laid out. Aligned keeps the body on the keyword's line, lining all clause bodies up under a shared column past the widest keyword. Indented puts the body on its own new line, one indent level (IndentationSize) past the keyword, so nesting grows one step per level instead of drifting right. When Indented, AlignClauseBodies is ignored for clause bodies. - Gets or sets whether clause bodies are padded to line up under a shared column. Only applies when ClauseBodyAlignment is Aligned: true pads to the aligned column, false uses a single space after the keyword. Ignored when ClauseBodyAlignment is Indented. + Gets or sets whether clause bodies are padded to line up under a shared column. Only applies when ClauseBodyAlignment is Aligned: true pads to the aligned column, false uses a single space after the keyword. When ClauseBodyAlignment is Indented, this setting is generally ignored for clause bodies; however, it also affects an INSERT statement's VALUES row constructors: when ClauseBodyAlignment is Indented and this is false, the row constructors move to their own indented line instead of being padded to line up under the column list. @@ -123,6 +145,18 @@ Gets or sets a boolean indicating if WHERE predicates (expressions separated by AND, and OR) should be written on multiple lines + + Gets or sets a boolean indicating if GROUP BY elements should be listed as a multi-line list + + + Gets or sets a boolean indicating if HAVING predicates (expressions separated by AND or OR) should be written on multiple lines + + + Gets or sets a boolean indicating if ORDER BY elements should be listed as a multi-line list + + + Gets or sets a boolean indicating if PARTITION BY elements in window specifications should be listed as a multi-line list + Gets or sets a boolean indicating if the values in an IN (values) predicate should be listed as a multi-line list. When false, the IN list is written on a single line. @@ -176,6 +210,11 @@ Gets or sets a boolean indicating if a newline should be placed before an close parenthesis when writing a multi-line list in parenthesis + + + Gets or sets a boolean indicating if parameters in a nested function call tree should be written on separate lines and indented one level per call. Isolated function calls remain on one line. When false (the default), all function calls retain the existing compact layout. + + Gets or sets a boolean indicating if file paths can be used for external library content @@ -186,4 +225,9 @@ Gets or sets a boolean indicating if file paths can be used for external language content + + + Gets or sets a boolean indicating if the options in a WITH (or OPTION) clause should be listed on separate lines. This applies to CREATE/ALTER INDEX and other index options, table hints (WITH), query hints (OPTION), and BACKUP/RESTORE options (WITH). It does not apply to the WITH keyword of common table expressions or XMLNAMESPACES, nor to the option lists of CREATE XML INDEX (non-selective) or CREATE SPATIAL INDEX, which have their own generators. When false (default), the options are written on a single line. CommaPlacement applies within the options list. For parenthesized clauses, NewLineBeforeOpenParenthesisInMultilineList and NewLineBeforeCloseParenthesisInMultilineList control parenthesis placement. When the WITH keyword begins the option list, the options are indented one level from the statement. + + diff --git a/Test/SqlDom/Baselines100/InsertStatementTests100.sql b/Test/SqlDom/Baselines100/InsertStatementTests100.sql index 9160ad48..d6047940 100644 --- a/Test/SqlDom/Baselines100/InsertStatementTests100.sql +++ b/Test/SqlDom/Baselines100/InsertStatementTests100.sql @@ -3,11 +3,11 @@ AS BEGIN INSERT INTO t1 VALUES (1, 2), - (DEFAULT, 0), - (NULL, NULL); + (DEFAULT, 0), + (NULL, NULL); INSERT INTO t2 VALUES ('aaa', 'bbb', 1), - ('ccc', 'ddd', 20); + ('ccc', 'ddd', 20); END @@ -32,5 +32,5 @@ CREATE PROCEDURE p1 AS BEGIN INSERT INTO t1 ($ACTION, $CUID, $ROWGUID) - VALUES (1, 2, 3); + VALUES (1, 2, 3); END \ No newline at end of file diff --git a/Test/SqlDom/Baselines130/CreateTableTests130.sql b/Test/SqlDom/Baselines130/CreateTableTests130.sql index 2e6f8d1d..7590e1f7 100644 --- a/Test/SqlDom/Baselines130/CreateTableTests130.sql +++ b/Test/SqlDom/Baselines130/CreateTableTests130.sql @@ -330,6 +330,13 @@ CREATE TABLE t ( ); +GO +CREATE TABLE t ( + COL0 VARCHAR (100) COLLATE SQL_Latin1_General_CP1_CI_AS SPARSE MASKED WITH (FUNCTION = 'default()') NULL, + COL1 VARCHAR (100) SPARSE MASKED WITH (FUNCTION = 'default()') NULL +); + + GO CREATE TABLE t ( COL0 INT NOT NULL, diff --git a/Test/SqlDom/Baselines170/AiGenerateChunksTests170.sql b/Test/SqlDom/Baselines170/AiGenerateChunksTests170.sql index a11724cd..ef7cb3c0 100644 --- a/Test/SqlDom/Baselines170/AiGenerateChunksTests170.sql +++ b/Test/SqlDom/Baselines170/AiGenerateChunksTests170.sql @@ -44,9 +44,9 @@ CREATE TABLE t2 ( GO INSERT INTO t2 (Text) -VALUES (N'This is the first text.'), -(N'Second sample text.'), -(N'Third example text.'); +VALUES (N'This is the first text.'), + (N'Second sample text.'), + (N'Third example text.'); GO CREATE VIEW v_GeneratedChunksFromTable diff --git a/Test/SqlDom/Baselines180/CreateDatabaseCollateBeforeEditionTests120.sql b/Test/SqlDom/Baselines180/CreateDatabaseCollateBeforeEditionTests120.sql new file mode 100644 index 00000000..761e9284 --- /dev/null +++ b/Test/SqlDom/Baselines180/CreateDatabaseCollateBeforeEditionTests120.sql @@ -0,0 +1,12 @@ +CREATE DATABASE [db1] COLLATE SQL_Latin1_General_CP1_CI_AS + (EDITION = 'Standard'); + + +GO +CREATE DATABASE hito COLLATE Japanese_Bushu_Kakusu_100_CS_AS_KS_WS + (MAXSIZE = 500 MB, EDITION = 'GeneralPurpose', SERVICE_OBJECTIVE = 'GP_Gen5_8'); + + +GO +CREATE DATABASE d1 COLLATE SQL_Latin1_General_CP1_CI_AS + (EDITION = 'business', SERVICE_OBJECTIVE = 'shared'); diff --git a/Test/SqlDom/BaselinesCommon/SelectStatementTests.sql b/Test/SqlDom/BaselinesCommon/SelectStatementTests.sql index a0af4ad7..ee84ff09 100644 --- a/Test/SqlDom/BaselinesCommon/SelectStatementTests.sql +++ b/Test/SqlDom/BaselinesCommon/SelectStatementTests.sql @@ -93,6 +93,10 @@ SELECT * FROM t1 GROUP BY ALL c1; +SELECT * +FROM t1 +GROUP BY ALL c1, c2, c3; + SELECT * FROM t1 GROUP BY c1, c2, c3; diff --git a/Test/SqlDom/BaselinesFabricDW/CreateDatabaseCollateBeforeEditionTestsFabricDW.sql b/Test/SqlDom/BaselinesFabricDW/CreateDatabaseCollateBeforeEditionTestsFabricDW.sql new file mode 100644 index 00000000..761e9284 --- /dev/null +++ b/Test/SqlDom/BaselinesFabricDW/CreateDatabaseCollateBeforeEditionTestsFabricDW.sql @@ -0,0 +1,12 @@ +CREATE DATABASE [db1] COLLATE SQL_Latin1_General_CP1_CI_AS + (EDITION = 'Standard'); + + +GO +CREATE DATABASE hito COLLATE Japanese_Bushu_Kakusu_100_CS_AS_KS_WS + (MAXSIZE = 500 MB, EDITION = 'GeneralPurpose', SERVICE_OBJECTIVE = 'GP_Gen5_8'); + + +GO +CREATE DATABASE d1 COLLATE SQL_Latin1_General_CP1_CI_AS + (EDITION = 'business', SERVICE_OBJECTIVE = 'shared'); diff --git a/Test/SqlDom/BaselinesFabricDW/ModernGroupByAllTestsFabricDW.sql b/Test/SqlDom/BaselinesFabricDW/ModernGroupByAllTestsFabricDW.sql new file mode 100644 index 00000000..5acd429b --- /dev/null +++ b/Test/SqlDom/BaselinesFabricDW/ModernGroupByAllTestsFabricDW.sql @@ -0,0 +1,71 @@ +SELECT City, + Region, + COUNT(*) AS NumEmps +FROM dbo.Employees +GROUP BY ALL; +GO + +SELECT City, + COUNT(*) AS NumEmps +FROM dbo.Employees +WHERE HireDate >= '19930101' +GROUP BY ALL +HAVING COUNT(*) > 5 +ORDER BY City; +GO + +SELECT Region, + YEAR(OrderDate) AS OrderYear, + Category, + COUNT(*) AS NumOrders, + SUM(Amount) AS Total +FROM Sales +WHERE Amount > 0 +GROUP BY ALL; +GO + +SELECT c.CustomerName, + COUNT(*) AS Orders +FROM Orders AS o + INNER JOIN + Customers AS c + ON o.CustomerId = c.CustomerId +GROUP BY ALL; +GO + +SELECT City, + COUNT(*) AS NumEmps +FROM dbo.Employees +GROUP BY ALL +ORDER BY City DESC; +GO + +SELECT * +FROM (SELECT City, + COUNT(*) AS Cnt + FROM dbo.Employees + GROUP BY ALL) AS t; +GO + +SELECT Region, + SUM(Amount) / COUNT(DISTINCT CustomerId) AS AvgSpend +FROM Sales +GROUP BY ALL; +GO + +CREATE VIEW v_GroupByAllView +AS +SELECT City, + COUNT(*) AS NumEmps +FROM dbo.Employees +GROUP BY ALL; +GO + +CREATE PROCEDURE usp_GroupByAll +AS +BEGIN + SELECT City, + COUNT(*) AS NumEmps + FROM dbo.Employees + GROUP BY ALL; +END diff --git a/Test/SqlDom/BaselinesFabricDW/OrderByAllTestsFabricDW.sql b/Test/SqlDom/BaselinesFabricDW/OrderByAllTestsFabricDW.sql new file mode 100644 index 00000000..89b452ff --- /dev/null +++ b/Test/SqlDom/BaselinesFabricDW/OrderByAllTestsFabricDW.sql @@ -0,0 +1,95 @@ +SELECT c1, + c2 +FROM t1 +ORDER BY ALL; +GO + +SELECT c1, + c2 +FROM t1 +ORDER BY ALL ASC; +GO + +SELECT c1, + c2 +FROM t1 +ORDER BY ALL DESC; +GO + +SELECT * +FROM t1 +ORDER BY ALL; +GO + +SELECT c1, + c2 +FROM t1 +ORDER BY ALL +OFFSET 2 ROWS FETCH NEXT 5 ROWS ONLY; +GO + +SELECT c1 +FROM t1 +UNION ALL +SELECT c1 +FROM t2 +ORDER BY ALL; +GO + +SELECT * +FROM (SELECT TOP 5 c1, + c2 + FROM t1 + ORDER BY ALL) AS sub; +GO + +SELECT c1, + COUNT(*) AS Cnt +FROM t1 +GROUP BY c1 +ORDER BY ALL; +GO + +SELECT c1, + COUNT(*) AS Cnt +FROM t1 +GROUP BY c1 +HAVING COUNT(*) > 1 +ORDER BY ALL DESC; +GO + +SELECT a.c1, + b.c2 +FROM t1 AS a + INNER JOIN + t2 AS b + ON a.c1 = b.c1 +WHERE b.c2 > 0 +ORDER BY ALL; +GO + +SELECT c1, + COUNT(*) AS Cnt, + SUM(COUNT(*)) OVER (ORDER BY c1 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS RunningCnt +FROM t1 +GROUP BY c1 +ORDER BY ALL; +GO + +CREATE VIEW v_OrderByAllView +AS +SELECT c1, + c2 +FROM t1 +ORDER BY ALL +OFFSET 0 ROWS; +GO + +CREATE PROCEDURE usp_OrderByAll +AS +BEGIN + SELECT c1, + c2 + FROM t1 + ORDER BY ALL; +END \ No newline at end of file diff --git a/Test/SqlDom/Only130SyntaxTests.cs b/Test/SqlDom/Only130SyntaxTests.cs index 0665459f..3b71eb28 100644 --- a/Test/SqlDom/Only130SyntaxTests.cs +++ b/Test/SqlDom/Only130SyntaxTests.cs @@ -26,7 +26,7 @@ public partial class SqlDomTests new ParserTest130("AlterTableDropTableElementStatementTests130.sql", 1, 1, 1, 1, 1), new ParserTest130("ColumnStoreInlineIndex130.sql", 10, 10, 10, 10, 10), new ParserTest130("CreateIndexStatementTests130.sql", 6, 6, 6, 6, 6), - new ParserTest130("CreateTableTests130.sql", 62, 62, 62, 62, 62), + new ParserTest130("CreateTableTests130.sql", 63, 63, 63, 63, 63), new ParserTest130("CreateAlterSecurityPolicyStatementTests130.sql", 2, 33, 33, 33, 33), new ParserTest130("JsonForClauseTests130.sql", 14, 14, 14, 14, 14), new ParserTest130("DropStatementsTests130.sql", 10, 10, 9, 9, 9), diff --git a/Test/SqlDom/Only180SyntaxTests.cs b/Test/SqlDom/Only180SyntaxTests.cs index 9be98076..e26be85e 100644 --- a/Test/SqlDom/Only180SyntaxTests.cs +++ b/Test/SqlDom/Only180SyntaxTests.cs @@ -16,6 +16,7 @@ public partial class SqlDomTests new ParserTest180("TrimFromReturnTests160.sql"), new ParserTest180("PersistSamplePercentStatisticsTests130.sql"), new ParserTest180("CreateSemanticIndexTests180.sql"), + new ParserTest180("CreateDatabaseCollateBeforeEditionTests120.sql", nErrors120: 0, nErrors130: 0, nErrors140: 0, nErrors150: 0, nErrors160: 0, nErrors170: 0) }; private static readonly ParserTest[] SqlAzure180_TestInfos = diff --git a/Test/SqlDom/OnlyFabricDWSyntaxTests.cs b/Test/SqlDom/OnlyFabricDWSyntaxTests.cs index 483e08ef..e3a53fef 100644 --- a/Test/SqlDom/OnlyFabricDWSyntaxTests.cs +++ b/Test/SqlDom/OnlyFabricDWSyntaxTests.cs @@ -27,6 +27,10 @@ public partial class SqlDomTests new ParserTestFabricDW("AiTranslateTestsFabricDW.sql", nErrors80: 3, nErrors90: 1, nErrors100: 2, nErrors110: 2, nErrors120: 2, nErrors130: 0, nErrors140: 0, nErrors150: 0, nErrors160: 0, nErrors170: 0, nErrors180: 0), new ParserTestFabricDW("InvokeExternalApiTestsFabricDW.sql", nErrors80: 2, nErrors90: 1, nErrors100: 0, nErrors110: 0, nErrors120: 0, nErrors130: 0, nErrors140: 0, nErrors150: 0, nErrors160: 0, nErrors170: 0, nErrors180: 0), new ParserTestFabricDW("ExternalFunctionTestsFabricDW.sql", nErrors80: 12, nErrors90: 4, nErrors100: 4, nErrors110: 4, nErrors120: 4, nErrors130: 11, nErrors140: 11, nErrors150: 11, nErrors160: 11, nErrors170: 11, nErrors180: 0), + new ParserTestFabricDW("CreateDatabaseCollateBeforeEditionTestsFabricDW.sql", nErrors80: 3, nErrors90: 3, nErrors100: 3, nErrors110: 3, nErrors120: 0, nErrors130: 0, nErrors140: 0, nErrors150: 0, nErrors160: 0, nErrors170: 0, nErrors180: 0), + new ParserTestFabricDW("ModernGroupByAllTestsFabricDW.sql", nErrors80: 9, nErrors90: 9, nErrors100: 9, nErrors110: 9, nErrors120: 9, nErrors130: 9, nErrors140: 9, nErrors150: 9, nErrors160: 9, nErrors170: 9, nErrors180: 9), + new ParserTestFabricDW("OrderByAllTestsFabricDW.sql", nErrors80: 13, nErrors90: 13, nErrors100: 13, nErrors110: 13, nErrors120: 13, nErrors130: 13, nErrors140: 13, nErrors150: 13, nErrors160: 13, nErrors170: 13, nErrors180: 13), + }; [TestMethod] diff --git a/Test/SqlDom/ParserErrorsTests.cs b/Test/SqlDom/ParserErrorsTests.cs index faeb5a68..26e48488 100644 --- a/Test/SqlDom/ParserErrorsTests.cs +++ b/Test/SqlDom/ParserErrorsTests.cs @@ -2918,6 +2918,20 @@ public void SQL46062Test() } + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void CreateDatabaseCollateWithAzureOptionsDownlevelErrorTest() + { + // TSql110 wires azureOptions and collationOpt as mutually-exclusive alternatives, so COLLATE + // and parenthesized Azure edition options cannot coexist in either order (see TSql110.g createDatabase). + ParserTestUtils.ErrorTest110("CREATE DATABASE [db1] COLLATE SQL_Latin1_General_CP1_CI_AS (EDITION = 'Standard')", + new ParserErrorInfo(60, "SQL46010", "EDITION")); + ParserTestUtils.ErrorTest110("CREATE DATABASE d1 (EDITION = 'business') COLLATE SQL_Latin1_General_CP1_CI_AS", + new ParserErrorInfo(42, "SQL46010", "COLLATE")); + } + + [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)] @@ -7270,6 +7284,80 @@ Description NVARCHAR(200) ParserTestUtils.ErrorTestFabricDW(identityColumnSyntax2, new ParserErrorInfo(errorOffSet, "SQL46010", "(")); } + /// + /// Negative tests for the modern GROUP BY ALL (no explicit column list) syntax on Fabric DW. + /// The grammar accepts GROUP BY ALL with no columns, but WITH CUBE / WITH ROLLUP are rejected + /// at parse time (SQL46084), matching the source engine behavior. + /// + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void ModernGroupByAllNegativeTestsFabricDW() + { + // GROUP BY ALL (no columns) cannot be combined with WITH CUBE. + string withCube = "SELECT City, COUNT(*) AS NumEmps FROM dbo.Employees GROUP BY ALL WITH CUBE"; + ParserTestUtils.ErrorTestFabricDW(withCube, new ParserErrorInfo(withCube.IndexOf("CUBE"), "SQL46084")); + + // GROUP BY ALL (no columns) cannot be combined with WITH ROLLUP. + string withRollup = "SELECT City, COUNT(*) AS NumEmps FROM dbo.Employees GROUP BY ALL WITH ROLLUP"; + ParserTestUtils.ErrorTestFabricDW(withRollup, new ParserErrorInfo(withRollup.IndexOf("ROLLUP"), "SQL46084")); + + // GROUP BY ALL cannot be combined with a GROUPING SETS specification. + string groupingSets = "SELECT City, COUNT(*) AS NumEmps FROM dbo.Employees GROUP BY ALL GROUPING SETS ((City), ())"; + ParserTestUtils.ErrorTestFabricDW(groupingSets, new ParserErrorInfo(groupingSets.IndexOf("GROUPING"), "SQL46084")); + } + + /// + /// Negative tests for the ORDER BY ALL syntax on Fabric DW. ORDER BY ALL must stand alone + /// at the query / subquery level; mixing it with explicit items, or using it inside an + /// OVER() window clause or a WITHIN GROUP ordered-set aggregate, is rejected at parse time. + /// + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void OrderByAllNegativeTestsFabricDW() + { + // ALL followed by an explicit column is a syntax error. + string allThenColumn = "SELECT c1, c2 FROM t1 ORDER BY ALL, c1"; + ParserTestUtils.ErrorTestFabricDW(allThenColumn, new ParserErrorInfo(allThenColumn.IndexOf(",", allThenColumn.IndexOf("ALL")), "SQL46010", ",")); + + // An explicit column followed by ALL is a syntax error. + string columnThenAll = "SELECT c1, c2 FROM t1 ORDER BY c1, ALL"; + ParserTestUtils.ErrorTestFabricDW(columnThenAll, new ParserErrorInfo(columnThenAll.LastIndexOf("ALL"), "SQL46010", "ALL")); + + // ORDER BY ALL is not allowed inside an OVER() window clause. + string inOver = "SELECT ROW_NUMBER() OVER (ORDER BY ALL) FROM t1"; + ParserTestUtils.ErrorTestFabricDW(inOver, new ParserErrorInfo(inOver.IndexOf("ALL"), "SQL46010", "ALL")); + + // ORDER BY ALL is not allowed inside a WITHIN GROUP ordered-set aggregate. + string inWithinGroup = "SELECT STRING_AGG(c2, ',') WITHIN GROUP (ORDER BY ALL) FROM t1"; + ParserTestUtils.ErrorTestFabricDW(inWithinGroup, new ParserErrorInfo(inWithinGroup.LastIndexOf("ALL"), "SQL46010", "ALL")); + + // ORDER BY ALL is not allowed inside an OVER() window clause with PARTITION BY. + string inOverPartition = "SELECT ROW_NUMBER() OVER (PARTITION BY c2 ORDER BY ALL) FROM t1"; + ParserTestUtils.ErrorTestFabricDW(inOverPartition, new ParserErrorInfo(inOverPartition.LastIndexOf("ALL"), "SQL46010", "ALL")); + + // ORDER BY ALL inside a derived table without TOP / OFFSET / FOR is invalid (SQL46047), + // exactly like any other ORDER BY in that position. + string subqueryNoTop = "SELECT * FROM (SELECT c1, c2 FROM t1 ORDER BY ALL) AS sub"; + ParserTestUtils.ErrorTestFabricDW(subqueryNoTop, new ParserErrorInfo(subqueryNoTop.IndexOf("SELECT", 1), "SQL46047")); + + // ORDER BY ALL is not allowed inside an OVER() window clause with a window frame (ROWS BETWEEN). + string inOverFrame = "SELECT SUM(c1) OVER (ORDER BY ALL ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) FROM t1"; + ParserTestUtils.ErrorTestFabricDW(inOverFrame, new ParserErrorInfo(inOverFrame.IndexOf("ALL"), "SQL46010", "ALL")); + + // ORDER BY ALL is not allowed inside a named WINDOW definition. + string inNamedWindow = "SELECT ROW_NUMBER() OVER w FROM t1 WINDOW w AS (ORDER BY ALL)"; + ParserTestUtils.ErrorTestFabricDW(inNamedWindow, new ParserErrorInfo(inNamedWindow.LastIndexOf("ALL"), "SQL46010", "ALL")); + + // ORDER BY ALL is not allowed inside a named WINDOW definition with PARTITION BY. + string inNamedWindowPartition = "SELECT ROW_NUMBER() OVER w FROM t1 WINDOW w AS (PARTITION BY c2 ORDER BY ALL)"; + ParserTestUtils.ErrorTestFabricDW(inNamedWindowPartition, new ParserErrorInfo(inNamedWindowPartition.LastIndexOf("ALL"), "SQL46010", "ALL")); + + string inJsonArrayAgg = "SELECT JSON_ARRAYAGG(c1 ORDER BY ALL) FROM t1"; + ParserTestUtils.ErrorTestFabricDW(inJsonArrayAgg, new ParserErrorInfo(inJsonArrayAgg.IndexOf("ORDER"), "SQL46010", "ORDER")); + } + /// /// Negative tests for AI_GENERATE_CHUNKS syntax /// diff --git a/Test/SqlDom/ScriptGenerator/BlockStatementTerminatorTests.cs b/Test/SqlDom/ScriptGenerator/BlockStatementTerminatorTests.cs new file mode 100644 index 00000000..208fe119 --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/BlockStatementTerminatorTests.cs @@ -0,0 +1,479 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using System.IO; +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SqlStudio.Tests.AssemblyTools.TestCategory; +using static SqlStudio.Tests.UTSqlScriptDom.ScriptGeneratorTestHelper; + +namespace SqlStudio.Tests.UTSqlScriptDom +{ + // Tests for the TerminateBlockStatements script-generation option, which controls whether a + // semicolon terminator is written after a BEGIN...END block and after the END CATCH of a + // TRY...CATCH block. When false (the default) these blocks are written without a terminator, + // preserving the previous behavior; when true a semicolon is appended so a terminator supplied + // in the source survives a parse and generate round-trip. + // + // Reported at https://developercommunity.visualstudio.com/t/SSMS-SQL-Formatter-removes-semicolons-af/11126731 + [TestClass] + public class BlockStatementTerminatorTests + { + // Options that opt in to block terminators, leaving everything else at default. + private static SqlScriptGeneratorOptions Terminated() + { + return new SqlScriptGeneratorOptions { TerminateBlockStatements = true }; + } + + // ----------------------------------------------------------------------------------------- + // Default: the option is off, so the existing unterminated form is preserved. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTerminateBlockStatementsDefaultIsFalse() + { + Assert.AreEqual(false, new SqlScriptGeneratorOptions().TerminateBlockStatements); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestBlockDefaultDropsSuppliedTerminator() + { + // The reported behavior, retained as the default: a supplied semicolon is not preserved. + const string input = "BEGIN SELECT 1; END;"; + const string expected = +@" +BEGIN + SELECT 1; +END"; + AssertGenerated(input, new SqlScriptGeneratorOptions(), expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTryCatchDefaultDropsSuppliedTerminator() + { + const string input = "BEGIN TRY SELECT 1; END TRY BEGIN CATCH SELECT ERROR_NUMBER(); END CATCH;"; + const string expected = +@" +BEGIN TRY + SELECT 1; +END TRY +BEGIN CATCH + SELECT ERROR_NUMBER(); +END CATCH"; + AssertGenerated(input, new SqlScriptGeneratorOptions(), expected); + } + + // ----------------------------------------------------------------------------------------- + // Opt in: blocks are terminated. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestBlockIsTerminatedWhenOptionIsSet() + { + const string input = "BEGIN SELECT 1; END;"; + const string expected = +@" +BEGIN + SELECT 1; +END;"; + AssertGenerated(input, Terminated(), expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestBlockIsTerminatedEvenWhenSourceOmitsTheTerminator() + { + // The option normalizes to the terminated form; it does not merely preserve what was supplied. + const string input = "BEGIN SELECT 1; END"; + const string expected = +@" +BEGIN + SELECT 1; +END;"; + AssertGenerated(input, Terminated(), expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestEndCatchIsTerminatedButEndTryIsNot() + { + // END TRY is internal to the TRY...CATCH block, so only END CATCH receives a terminator. + const string input = "BEGIN TRY SELECT 1; END TRY BEGIN CATCH SELECT ERROR_NUMBER(); END CATCH;"; + const string expected = +@" +BEGIN TRY + SELECT 1; +END TRY +BEGIN CATCH + SELECT ERROR_NUMBER(); +END CATCH;"; + AssertGenerated(input, Terminated(), expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNestedBlocksInsideProcedureAreTerminated() + { + // The exact scenario from the customer report. + const string input = +@"CREATE PROCEDURE dbo.SemicolonTerminatedBlocks +AS +BEGIN + BEGIN TRY + SELECT 1; + END TRY + BEGIN CATCH + SELECT ERROR_NUMBER(); + END CATCH; +END;"; + const string expected = +@" +CREATE PROCEDURE dbo.SemicolonTerminatedBlocks +AS +BEGIN + BEGIN TRY + SELECT 1; + END TRY + BEGIN CATCH + SELECT ERROR_NUMBER(); + END CATCH; +END;"; + AssertGenerated(input, Terminated(), expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTerminatedBlockRoundTripsUnchanged() + { + // Generating the already-terminated output again must be a fixed point. + const string expected = +@" +BEGIN + SELECT 1; +END;"; + string once = Generate("BEGIN SELECT 1; END;", Terminated()); + Assert.AreEqual(Normalize(expected).Trim(), Normalize(once).Trim()); + AssertGenerated(once, Terminated(), expected); + } + + // ----------------------------------------------------------------------------------------- + // Interaction with the separating semicolon injected before CTEs and THROW. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCteAfterTerminatedBlockDoesNotGetASecondSemicolon() + { + // The block now supplies its own terminator, so the separating semicolon must not be added. + const string input = "BEGIN SELECT 1; END WITH cte AS (SELECT 1 AS c) SELECT c FROM cte;"; + const string expected = +@" +BEGIN + SELECT 1; +END; + +WITH cte +AS (SELECT 1 AS c) +SELECT c +FROM cte;"; + AssertGenerated(input, Terminated(), expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestThrowAfterTerminatedBlockDoesNotGetASecondSemicolon() + { + const string input = "BEGIN SELECT 1; END THROW 50000, 'x', 1;"; + const string expected = +@" +BEGIN + SELECT 1; +END; + +THROW 50000, 'x', 1;"; + AssertGenerated(input, Terminated(), expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIfStatementIsStillNotTerminated() + { + // The option opts in only BEGIN...END and TRY...CATCH; IF, WHILE and labels keep their + // existing unterminated form, so the separating semicolon before a CTE is still required. + const string input = "IF 1 = 1 SELECT 1 WITH cte AS (SELECT 1 AS c) SELECT c FROM cte;"; + const string expected = +@" +IF 1 = 1 + SELECT 1; + +WITH cte +AS (SELECT 1 AS c) +SELECT c +FROM cte;"; + AssertGenerated(input, Terminated(), expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestWhileStatementIsStillNotTerminated() + { + const string input = "WHILE 1 = 1 SELECT 1 WITH cte AS (SELECT 1 AS c) SELECT c FROM cte;"; + const string expected = +@" +WHILE 1 = 1 + SELECT 1; + +WITH cte +AS (SELECT 1 AS c) +SELECT c +FROM cte;"; + AssertGenerated(input, Terminated(), expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestLabelStatementIsStillNotTerminated() + { + const string input = "lbl: SELECT 1;"; + const string expected = +@" +lbl: + +SELECT 1;"; + AssertGenerated(input, Terminated(), expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestStatementSnippetIsStillNotTerminated() + { + // Snippets are emitted verbatim, so a terminator here would corrupt the preserved text. + // They are not produced by parsing, so the AST is built directly. + var batch = new TSqlBatch(); + batch.Statements.Add(new TSqlStatementSnippet { Script = "EXEC dbo.SomeProc" }); + var script = new TSqlScript(); + script.Batches.Add(batch); + + var generator = new Sql170ScriptGenerator(Terminated()); + generator.GenerateScript(script, out string generated); + Assert.AreEqual("EXEC dbo.SomeProc", Normalize(generated).Trim()); + } + + // ----------------------------------------------------------------------------------------- + // BeginEndAtomicBlockStatement derives from BeginEndBlockStatement, so these pin the + // exact-runtime-type membership test: an atomic block must not follow the option. + // ----------------------------------------------------------------------------------------- + + private const string AtomicProcedure = +@"CREATE PROCEDURE dbo.NativeProc +WITH NATIVE_COMPILATION, SCHEMABINDING +AS +BEGIN ATOMIC WITH (TRANSACTION ISOLATION LEVEL = SNAPSHOT, LANGUAGE = N'us_english') + SELECT 1; +END"; + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestAtomicBlockIsTerminatedByDefault() + { + // Atomic blocks are not in StatementsThatCannotHaveSemiColon, so they already carry a + // terminator before this option existed. + const string expected = +@" +CREATE PROCEDURE dbo.NativeProc +WITH NATIVE_COMPILATION, SCHEMABINDING +AS +BEGIN ATOMIC +WITH (TRANSACTION ISOLATION LEVEL = SNAPSHOT, LANGUAGE = N'us_english') + SELECT 1; +END;"; + AssertGenerated(AtomicProcedure, new SqlScriptGeneratorOptions(), expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestAtomicBlockOutputIsUnaffectedByTheOption() + { + const string expected = +@" +CREATE PROCEDURE dbo.NativeProc +WITH NATIVE_COMPILATION, SCHEMABINDING +AS +BEGIN ATOMIC +WITH (TRANSACTION ISOLATION LEVEL = SNAPSHOT, LANGUAGE = N'us_english') + SELECT 1; +END;"; + AssertGenerated(AtomicProcedure, Terminated(), expected); + } + + // ----------------------------------------------------------------------------------------- + // Comment placement: the terminator must land before a trailing comment, not inside it. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTerminatorIsPlacedBeforeTrailingSingleLineComment() + { + const string input = "BEGIN SELECT 1; END -- trailing"; + var options = new SqlScriptGeneratorOptions { TerminateBlockStatements = true, PreserveComments = true }; + const string expected = +@" +BEGIN + SELECT 1; +END; -- trailing"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrailingCommentPlacementIsUnchangedByDefault() + { + const string input = "BEGIN SELECT 1; END -- trailing"; + var options = new SqlScriptGeneratorOptions { PreserveComments = true }; + const string expected = +@" +BEGIN + SELECT 1; +END -- trailing"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTerminatorIsPlacedBeforeTrailingBlockComment() + { + const string input = "BEGIN SELECT 1; END /* trailing */"; + var options = new SqlScriptGeneratorOptions { TerminateBlockStatements = true, PreserveComments = true }; + const string expected = +@" +BEGIN + SELECT 1; +END; /* trailing */"; + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // The option is honored by every generator version, including Fabric DW and Serverless. + // Each version supplies its own StatementsThatCannotHaveSemiColon, which the gate consults. + // ----------------------------------------------------------------------------------------- + + private static IEnumerable AllGenerators(SqlScriptGeneratorOptions options) + { + yield return new Sql80ScriptGenerator(options); + yield return new Sql90ScriptGenerator(options); + yield return new Sql100ScriptGenerator(options); + yield return new Sql110ScriptGenerator(options); + yield return new Sql120ScriptGenerator(options); + yield return new Sql130ScriptGenerator(options); + yield return new Sql140ScriptGenerator(options); + yield return new Sql150ScriptGenerator(options); + yield return new Sql160ScriptGenerator(options); + yield return new Sql170ScriptGenerator(options); + yield return new Sql180ScriptGenerator(options); + yield return new SqlFabricDWScriptGenerator(options); + yield return new SqlServerlessScriptGenerator(options); + } + + private static TSqlFragment ParseBlock() + { + var parser = new TSql80Parser(true); + TSqlFragment fragment = parser.Parse(new StringReader("BEGIN SELECT 1; END"), out IList errors); + Assert.AreEqual(0, errors.Count, "Input must parse without errors."); + return fragment; + } + + private static void AssertGeneratedBy(SqlScriptGenerator generator, TSqlFragment fragment, string expected) + { + generator.GenerateScript(fragment, out string generated); + Assert.AreEqual(Normalize(expected).Trim(), Normalize(generated).Trim(), generator.GetType().Name); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestEveryGeneratorVersionTerminatesWhenOptionIsSet() + { + // Every version supplies its own StatementsThatCannotHaveSemiColon, so assert the exact + // output for each rather than trusting that the shared gate is reached. + const string expected = +@" +BEGIN + SELECT 1; +END;"; + TSqlFragment fragment = ParseBlock(); + foreach (SqlScriptGenerator generator in AllGenerators(Terminated())) + { + AssertGeneratedBy(generator, fragment, expected); + } + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestEveryGeneratorVersionLeavesBlockUnterminatedByDefault() + { + const string expected = +@" +BEGIN + SELECT 1; +END"; + TSqlFragment fragment = ParseBlock(); + foreach (SqlScriptGenerator generator in AllGenerators(new SqlScriptGeneratorOptions())) + { + AssertGeneratedBy(generator, fragment, expected); + } + } + + // ----------------------------------------------------------------------------------------- + // The oldest parser must accept the terminated form the generator now produces. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestSql80GeneratorOutputReparsesWithSql80Parser() + { + const string expected = +@" +BEGIN + SELECT 1; +END;"; + TSqlFragment fragment = ParseBlock(); + + var generator = new Sql80ScriptGenerator(Terminated()); + generator.GenerateScript(fragment, out string generated); + Assert.AreEqual(Normalize(expected).Trim(), Normalize(generated).Trim()); + + var reparser = new TSql80Parser(true); + reparser.Parse(new StringReader(generated), out IList reErrors); + Assert.AreEqual(0, reErrors.Count, "Generated script must reparse without errors. Actual:\n" + generated); + } + } +} diff --git a/Test/SqlDom/ScriptGenerator/BuiltInFunctionCasingTests.cs b/Test/SqlDom/ScriptGenerator/BuiltInFunctionCasingTests.cs new file mode 100644 index 00000000..a3216497 --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/BuiltInFunctionCasingTests.cs @@ -0,0 +1,780 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SqlStudio.Tests.AssemblyTools.TestCategory; +using static SqlStudio.Tests.UTSqlScriptDom.ScriptGeneratorTestHelper; + +namespace SqlStudio.Tests.UTSqlScriptDom +{ + // Tests for the BuiltInFunctionCasing script-generation option. + // + // Expected values follow the IdentifierFormattingTests pattern: verbatim @"..." literals compared + // via the shared AssertGenerated helper (which normalizes line endings and trims). MakeOptions + // sets the casing under test and disables unrelated multi-line/alignment formatting. + // + // Work item: Formatter option: Built-in function name casing + [TestClass] + public class BuiltInFunctionCasingTests + { + // Builds options that set the BuiltInFunctionCasing (and KeywordCasing) under test while + // disabling unrelated multi-line/alignment formatting, so the expected literals stay focused + // on the function-name transformation itself. + private static SqlScriptGeneratorOptions MakeOptions( + BuiltInFunctionCasing casing, + KeywordCasing keywordCasing = KeywordCasing.Uppercase, + bool preserveComments = false) + { + return new SqlScriptGeneratorOptions + { + BuiltInFunctionCasing = casing, + KeywordCasing = keywordCasing, + PreserveComments = preserveComments, + AlignColumnDefinitionFields = false, + AlignClauseBodies = false, + NewLineBeforeFromClause = false, + NewLineBeforeJoinClause = false, + MultilineSelectElementsList = false, + }; + } + + // ----------------------------------------------------------------------------------------- + // Default / backward compatibility + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestBuiltInFunctionCasingDefaultIsPreserve() + { + Assert.AreEqual(BuiltInFunctionCasing.Preserve, new SqlScriptGeneratorOptions().BuiltInFunctionCasing); + } + + // Preserve must reproduce the pre-feature output exactly: generic function names keep their + // original casing, while keyword-backed built-ins (CAST, CONVERT, COALESCE) continue to + // follow KeywordCasing as they always have. This is the backward-compatibility sentinel. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveHasZeroImpact() + { + const string input = "SELECT cast(x AS INT), Convert(INT, y), Coalesce(a, b), getdate(), IsNull(a, b);"; + var options = MakeOptions(BuiltInFunctionCasing.Preserve); + const string expected = @"SELECT CAST (x AS INT), CONVERT (INT, y), COALESCE (a, b), getdate(), IsNull(a, b);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Generic FunctionCall names (GETDATE, ISNULL, COUNT, OBJECT_ID, ...) + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGenericFunctionUppercase() + { + const string input = "SELECT getdate(), isnull(a, b), object_id('t');"; + var options = MakeOptions(BuiltInFunctionCasing.Uppercase); + const string expected = @"SELECT GETDATE(), ISNULL(a, b), OBJECT_ID('t');"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGenericFunctionLowercase() + { + const string input = "SELECT GETDATE(), ISNULL(A, B), OBJECT_ID('t');"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = @"SELECT getdate(), isnull(A, B), object_id('t');"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGenericFunctionPascalCase() + { + // PascalCase capitalizes the first letter and lowercases the rest (matching the shared + // PascalCase helper used by KeywordCasing/IdentifierCasing); underscores are preserved. + const string input = "SELECT GETDATE(), object_id('t');"; + var options = MakeOptions(BuiltInFunctionCasing.PascalCase); + const string expected = @"SELECT Getdate(), Object_id('t');"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMixedCaseInputIsNormalized() + { + const string input = "SELECT GetDate(), IsNull(a, b);"; + var options = MakeOptions(BuiltInFunctionCasing.Uppercase); + const string expected = @"SELECT GETDATE(), ISNULL(a, b);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Aggregate functions + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestAggregateFunctions() + { + const string input = "SELECT count(*), sum(a), max(b), min(c), avg(d) FROM t;"; + var options = MakeOptions(BuiltInFunctionCasing.Uppercase); + const string expected = @"SELECT COUNT(*), SUM(a), MAX(b), MIN(c), AVG(d) FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestWindowedAggregate() + { + const string input = "SELECT count(*) OVER (PARTITION BY a) FROM t;"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = @"SELECT count(*) OVER (PARTITION BY a) FROM t;"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Dedicated built-in nodes (CAST, CONVERT, COALESCE, NULLIF, IIF, LEFT, RIGHT, TRY_*) + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCastLowercase() + { + const string input = "SELECT CAST(x AS INT);"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = @"SELECT cast (x AS INT);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestConvertLowercase() + { + const string input = "SELECT CONVERT(INT, y, 1);"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = @"SELECT convert (INT, y, 1);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCoalesceAndNullIfLowercase() + { + const string input = "SELECT COALESCE(a, b), NULLIF(a, b);"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = @"SELECT coalesce (a, b), nullif (a, b);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIIfLowercase() + { + const string input = "SELECT IIF(a > b, 1, 0);"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = @"SELECT iif (a > b, 1, 0);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestLeftAndRightLowercase() + { + const string input = "SELECT LEFT(s, 1), RIGHT(s, 1);"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = @"SELECT left(s, 1), right(s, 1);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTryCastAndTryConvertLowercase() + { + const string input = "SELECT TRY_CAST(x AS INT), TRY_CONVERT(INT, y);"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = @"SELECT try_cast (x AS INT), try_convert (INT, y);"; + + AssertGenerated(input, options, expected); + } + + // PascalCase on the dedicated (keyword/literal-backed) built-in nodes: the shared PascalCase + // helper lowercases the whole name then capitalizes the first letter, so CAST -> Cast and + // TRY_CONVERT -> Try_convert. LEFT/RIGHT emit without a space before '(' while the other + // dedicated nodes keep the pre-existing space. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDedicatedNodesPascalCase() + { + const string input = "SELECT CAST(x AS INT), CONVERT(INT, y), COALESCE(a, b), NULLIF(a, b), IIF(a > b, 1, 0), LEFT(s, 1), RIGHT(s, 1), TRY_CAST(x AS INT), TRY_CONVERT(INT, y);"; + var options = MakeOptions(BuiltInFunctionCasing.PascalCase); + const string expected = @"SELECT Cast (x AS INT), Convert (INT, y), Coalesce (a, b), Nullif (a, b), Iif (a > b, 1, 0), Left(s, 1), Right(s, 1), Try_cast (x AS INT), Try_convert (INT, y);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Interaction rule: takes precedence over KeywordCasing for function names, so the two can be + // set to different casings. + // ----------------------------------------------------------------------------------------- + + // KeywordCasing lowercases keywords, but built-in function names follow their own option. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndependentOfKeywordCasing_KeywordsLowerFunctionsUpper() + { + const string input = "select getdate(), coalesce(a, b) from t;"; + var options = MakeOptions(BuiltInFunctionCasing.Uppercase, KeywordCasing.Lowercase); + const string expected = @"select GETDATE(), COALESCE (a, b) from t;"; + + AssertGenerated(input, options, expected); + } + + // KeywordCasing uppercases keywords, but built-in function names follow their own option. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndependentOfKeywordCasing_KeywordsUpperFunctionsLower() + { + const string input = "select GETDATE(), COALESCE(a, b) from t;"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase, KeywordCasing.Uppercase); + const string expected = @"SELECT getdate(), coalesce (a, b) FROM t;"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Interaction rule: AS inside CAST follows KeywordCasing, not BuiltInFunctionCasing + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCastAsKeywordFollowsKeywordCasing() + { + // Function name CAST -> uppercase (BuiltInFunctionCasing); AS -> lowercase (KeywordCasing). + const string input = "SELECT CAST(x AS INT);"; + var options = MakeOptions(BuiltInFunctionCasing.Uppercase, KeywordCasing.Lowercase); + const string expected = @"select CAST (x as int);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Interaction rule: data type names are not affected + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDataTypeNamesNotAffected() + { + // BuiltInFunctionCasing is Lowercase, but the INT / VARCHAR data type names follow + // KeywordCasing (Uppercase here), not the function-casing option. + const string input = "SELECT CAST(x AS INT), CONVERT(VARCHAR(10), y);"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase, KeywordCasing.Uppercase); + const string expected = @"SELECT cast (x AS INT), convert (VARCHAR (10), y);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Interaction rule: only qualified / delimited names are preserved + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestSchemaQualifiedFunctionNotAffected() + { + // Schema-qualified => user-defined, even if the name matches a built-in. + const string input = "SELECT dbo.GetDate(), dbo.Count(a);"; + var options = MakeOptions(BuiltInFunctionCasing.Uppercase); + const string expected = @"SELECT dbo.GetDate(), dbo.Count(a);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestUnqualifiedUnknownFunctionIsRecased() + { + // Unrecognized one-part names are re-cased too. That is safe rather than merely + // unavoidable: SQL Server rejects a one-part scalar function call ("'MyCustomFunc' is not + // a recognized built-in function name"), so no executable script reaches this path with a + // UDF name. TestSchemaQualifiedFunctionNotAffected covers the callable two-part form. + const string input = "SELECT MyCustomFunc(a), fn_MyHelper(b);"; + var options = MakeOptions(BuiltInFunctionCasing.Uppercase); + const string expected = @"SELECT MYCUSTOMFUNC(a), FN_MYHELPER(b);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDelimitedFunctionNameNotAffected() + { + // A delimited (bracketed) name is treated as an identifier / UDF and left untouched. + const string input = "SELECT [GETDATE]();"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = @"SELECT [GETDATE]();"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMethodCallOnVariableOrColumnNotAffected() + { + // CLR/spatial/XML method invocations parse as FunctionCall with a non-null CallTarget, + // so they take the same qualified-call exit as dbo.MyFunc() and keep their casing. + const string input = "SELECT @g.STArea(), x.value('(/a)[1]', 'int');"; + var options = MakeOptions(BuiltInFunctionCasing.Uppercase); + const string expected = @"SELECT @g.STArea(), x.value('(/a)[1]', 'int');"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTableValuedFunctionNameNotAffected() + { + // A TVF in a FROM clause is a SchemaObjectFunctionTableReference, not a FunctionCall, so + // it never reaches the re-casing path - including the one-part form, which (unlike a + // scalar UDF) T-SQL does resolve against the default schema. + const string input = "SELECT * FROM MyTvf(1) AS t CROSS APPLY dbo.OtherTvf(t.a) AS u;"; + var options = MakeOptions(BuiltInFunctionCasing.Uppercase); + const string expected = @"SELECT * FROM MyTvf(1) AS t CROSS APPLY dbo.OtherTvf(t.a) AS u;"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Nested and mixed usage + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNestedBuiltInFunctions() + { + const string input = "SELECT isnull(convert(varchar(20), getdate()), 'n/a');"; + var options = MakeOptions(BuiltInFunctionCasing.Uppercase); + const string expected = @"SELECT ISNULL(CONVERT (VARCHAR (20), GETDATE()), 'n/a');"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestBuiltInMixedWithUdf() + { + // Built-ins re-cased, the qualified UDF left alone, in a single expression. + const string input = "SELECT getdate(), dbo.MyFunc(datediff(day, a, b));"; + var options = MakeOptions(BuiltInFunctionCasing.Uppercase); + const string expected = @"SELECT GETDATE(), dbo.MyFunc(DATEDIFF(day, a, b));"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // System / error functions + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestSystemFunctions() + { + const string input = "SELECT error_number(), error_message(), newid();"; + var options = MakeOptions(BuiltInFunctionCasing.Uppercase); + const string expected = @"SELECT ERROR_NUMBER(), ERROR_MESSAGE(), NEWID();"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Dedicated conversion nodes: PARSE / TRY_PARSE (mirror the CAST / CONVERT family) + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestParseAndTryParseLowercase() + { + // PARSE / TRY_PARSE are keyword-backed conversion built-ins like CAST/CONVERT: the name + // follows BuiltInFunctionCasing (lower) while AS and the data type follow KeywordCasing. + const string input = "SELECT PARSE('42' AS INT), TRY_PARSE('42' AS INT);"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = @"SELECT parse ('42' AS INT), try_parse ('42' AS INT);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Built-in AI_* scalar functions (SQL Server 2025 / 170) follow BuiltInFunctionCasing + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestAiFunctionNameUppercase() + { + // The AI_* function name is a built-in and is re-cased; USE / MODEL and the model name + // keep their own rules (KeywordCasing / IdentifierCasing). + const string input = "SELECT ai_generate_embeddings('t' USE MODEL MyModel);"; + var options = MakeOptions(BuiltInFunctionCasing.Uppercase); + const string expected = @"SELECT AI_GENERATE_EMBEDDINGS('t' USE MODEL MyModel);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestAiFunctionNameLowercase() + { + const string input = "SELECT AI_GENERATE_EMBEDDINGS('t' USE MODEL MyModel);"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = @"SELECT ai_generate_embeddings('t' USE MODEL MyModel);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestInvokeExternalApiFunctionNameLowercase() + { + const string input = "SELECT INVOKE_EXTERNAL_API('MySet', 'MyFunc', 1);"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = @"SELECT invoke_external_api('MySet', 'MyFunc', 1);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Out-of-scope constructs: niladic system functions and built-in table-valued functions + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestParameterlessSystemFunctionsNotAffected() + { + // Keyword-form niladic functions are emitted as keywords and are NOT re-cased by + // BuiltInFunctionCasing; they continue to follow KeywordCasing (Uppercase here). + const string input = "SELECT current_timestamp, system_user, current_user;"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase, KeywordCasing.Uppercase); + const string expected = @"SELECT CURRENT_TIMESTAMP, SYSTEM_USER, CURRENT_USER;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestBuiltInTableValuedFunctionNotAffected() + { + // Built-in table-valued functions flow through table-reference nodes, not FunctionCall, + // so their names are governed by IdentifierCasing and are NOT re-cased. + const string input = "SELECT * FROM STRING_SPLIT('a,b,c', ',');"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = @"SELECT * FROM STRING_SPLIT ('a,b,c', ',');"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Out-of-scope constructs: keyword-operator forms. Each is its own AST node rather than a + // FunctionCall, so none of them reaches the re-casing helper. These pin that boundary. + // ----------------------------------------------------------------------------------------- + + // Asserts the option leaves a construct untouched by comparing output with it off and on, + // so the assertion does not depend on unrelated layout options. Names in sql must be + // uppercase or mixed case, otherwise lowercasing would be invisible. + private static void AssertNotAffectedByCasing(string sql) + { + Assert.AreEqual( + Generate(sql, MakeOptions(BuiltInFunctionCasing.Preserve)), + Generate(sql, MakeOptions(BuiltInFunctionCasing.Lowercase))); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentityFunctionNotAffected() + { + AssertNotAffectedByCasing("SELECT IDENTITY(INT, 1, 1) AS id INTO t2 FROM t;"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestAtTimeZoneNotAffected() + { + AssertNotAffectedByCasing("SELECT d AT TIME ZONE 'UTC' FROM t;"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNextValueForNotAffected() + { + AssertNotAffectedByCasing("SELECT NEXT VALUE FOR dbo.MySeq;"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPartitionFunctionNotAffected() + { + AssertNotAffectedByCasing("SELECT $PARTITION.MyRangeFn(1);"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestOdbcFunctionCallNotAffected() + { + AssertNotAffectedByCasing("SELECT {fn NOW()};"); + } + + // ----------------------------------------------------------------------------------------- + // Remaining guard branches on the generic FunctionCall path + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDoubleQuotedFunctionNameNotAffected() + { + // The guard is QuoteType != NotQuoted, so double-quoted names are preserved just like + // bracketed ones. + const string input = "SELECT \"GETDATE\"();"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = "SELECT \"GETDATE\"();"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Generic FunctionCall nodes that branch on the function name to emit a custom body. + // Re-casing the emitted name must not disturb that body generation, because the branches + // dispatch on the unmodified AST value (node.FunctionName.Value). + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestJsonConstructorFunctionsAreRecased() + { + const string input = "SELECT JSON_OBJECT('a':'1'), JSON_ARRAY(1, 2);"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = @"SELECT json_object('a':'1'), json_array(1, 2);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrimWithOptionsIsRecased() + { + // The two-argument TRIM form keeps its LEADING/TRAILING/BOTH keyword and FROM body. + const string input = "SELECT TRIM(LEADING ' ' FROM s);"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = @"SELECT trim( LEADING ' ' FROM s);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Interaction with PreserveComments. Re-casing emits the function name as a raw token rather + // than as a fragment, so the comment hooks have to be driven explicitly; without them a + // comment trailing the name is absorbed into the argument list or dropped entirely. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommentAfterFunctionNameStaysWithName() + { + const string input = "SELECT LEN /*c*/ (x);"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase, preserveComments: true); + const string expected = @"SELECT len /*c*/(x);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommentAfterParameterlessFunctionNameIsPreserved() + { + // A zero-argument call has no following fragment inside the parentheses, so the comment + // has nowhere to migrate to and would be lost outright without the trailing-comment hook. + const string input = "SELECT GETDATE /*c*/ ();"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase, preserveComments: true); + const string expected = @"SELECT getdate /*c*/();"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommentBeforeFunctionNameIsPreserved() + { + // Exercises the leading hook; the trailing-comment tests above only cover the other side. + const string input = "SELECT /*c*/ LEN(x);"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase, preserveComments: true); + const string expected = @"SELECT /*c*/ +len(x);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Fabric DW dialect. The helper lives on the shared SqlScriptGeneratorVisitor base, so every + // derived generator inherits it; these also cover the seven AI_* functions that the SQL-170 + // grammar does not accept. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestFabricDwGenericFunctionLowercase() + { + const string input = "SELECT GETDATE(), ISNULL(a, b);"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = @"SELECT getdate(), isnull(a, b);"; + + AssertGeneratedFabric(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestFabricDwAiFunctionNamesLowercase() + { + const string input = + "SELECT AI_ANALYZE_SENTIMENT('text'), AI_CLASSIFY('text', 'spam', 'ham'), " + + "AI_EXTRACT('text', 'spam', 'ham'), AI_FIX_GRAMMAR('text');"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase); + const string expected = + @"SELECT ai_analyze_sentiment('text'), ai_classify('text', 'spam', 'ham'), ai_extract('text', 'spam', 'ham'), ai_fix_grammar('text');"; + + AssertGeneratedFabric(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestFabricDwAiFunctionNamesUppercase() + { + const string input = + "SELECT ai_generate_response('Hello'), ai_summarize('text'), ai_translate('text', 'es');"; + var options = MakeOptions(BuiltInFunctionCasing.Uppercase); + const string expected = + @"SELECT AI_GENERATE_RESPONSE('Hello'), AI_SUMMARIZE('text'), AI_TRANSLATE('text', 'es');"; + + AssertGeneratedFabric(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Dedicated built-in forms outside the scalar-expression path: the TSEQUAL predicate and the + // aggregate name in a COMPUTE clause. Both name a built-in function, so both follow the + // option rather than KeywordCasing or a fixed-case literal. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTSEqualLowercase() + { + const string input = "SELECT * FROM t WHERE TSEQUAL(c1, c2);"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase, KeywordCasing.Uppercase); + const string expected = @"SELECT * FROM t +WHERE tsequal (c1, c2);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTSEqualPreserveIsUnchanged() + { + const string input = "SELECT * FROM t WHERE tsequal(c1, c2);"; + var options = MakeOptions(BuiltInFunctionCasing.Preserve, KeywordCasing.Uppercase); + const string expected = @"SELECT * FROM t +WHERE TSEQUAL (c1, c2);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestComputeClauseAggregateLowercase() + { + const string input = "SELECT a FROM t COMPUTE SUM(a);"; + var options = MakeOptions(BuiltInFunctionCasing.Lowercase, KeywordCasing.Uppercase); + const string expected = @"SELECT a FROM t +COMPUTE sum(a);"; + + AssertGenerated100(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestComputeClauseAggregatePreserveIsUnchanged() + { + // Preserve must keep the historical fixed-case literal, not the input's casing. + const string input = "SELECT a FROM t COMPUTE sum(a);"; + var options = MakeOptions(BuiltInFunctionCasing.Preserve, KeywordCasing.Uppercase); + const string expected = @"SELECT a FROM t +COMPUTE SUM(a);"; + + AssertGenerated100(input, options, expected); + } + } +} diff --git a/Test/SqlDom/ScriptGenerator/ClauseBodyAlignmentTests.cs b/Test/SqlDom/ScriptGenerator/ClauseBodyAlignmentTests.cs index 7de4b8dd..f42cb4c3 100644 --- a/Test/SqlDom/ScriptGenerator/ClauseBodyAlignmentTests.cs +++ b/Test/SqlDom/ScriptGenerator/ClauseBodyAlignmentTests.cs @@ -201,6 +201,113 @@ ORDER BY AssertGenerated(input, options, expected); } + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentedLegacyGroupByAllColumnsStillIndent() + { + // Legacy GROUP BY ALL (accepted by the box parsers too): ALL stays on the + // GROUP BY line as a keyword modifier, but the explicit column list still drops to its + // own indented lines under Indented layout. + const string input = "SELECT a, COUNT(*) FROM t GROUP BY ALL a, b;"; + var options = new SqlScriptGeneratorOptions { ClauseBodyAlignment = ClauseBodyAlignment.Indented }; + const string expected = +@" +SELECT + a, + COUNT(*) +FROM + t +GROUP BY ALL + a, b;"; + + AssertGenerated(input, options, expected); + } + + // --- GROUP BY ALL / ORDER BY ALL shorthands (Fabric DW first syntax) ---------------------- + // GROUP BY ALL (no column list) and ORDER BY ALL are Fabric DW first, so these go through the + // Fabric DW pipeline (AssertGeneratedFabric). They confirm the ALL keyword is a clause-keyword + // modifier that stays on the GROUP BY / ORDER BY line under both Aligned and Indented layouts. + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestAlignedGroupByAll() + { + // Aligned: clause bodies line up under the widest keyword that has a body (here SELECT / + // FROM). The ALL shorthand has no body, so GROUP BY does not widen the alignment column; + // ALL simply stays on the GROUP BY line. + const string input = "SELECT City, COUNT(*) AS NumEmps FROM dbo.Employees GROUP BY ALL;"; + var options = new SqlScriptGeneratorOptions { ClauseBodyAlignment = ClauseBodyAlignment.Aligned }; + const string expected = +@" +SELECT City, + COUNT(*) AS NumEmps +FROM dbo.Employees +GROUP BY ALL;"; + + AssertGeneratedFabric(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentedGroupByAllKeepsAllInline() + { + // Indented: every clause body drops to its own indented line, but the ALL shorthand is a + // keyword modifier, so it stays on the GROUP BY line (no empty indented body line). + const string input = "SELECT City, COUNT(*) AS NumEmps FROM dbo.Employees GROUP BY ALL;"; + var options = new SqlScriptGeneratorOptions { ClauseBodyAlignment = ClauseBodyAlignment.Indented }; + const string expected = +@" +SELECT + City, + COUNT(*) AS NumEmps +FROM + dbo.Employees +GROUP BY ALL;"; + + AssertGeneratedFabric(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestAlignedOrderByAll() + { + const string input = "SELECT c1, c2 FROM t1 ORDER BY ALL;"; + var options = new SqlScriptGeneratorOptions { ClauseBodyAlignment = ClauseBodyAlignment.Aligned }; + const string expected = +@" +SELECT c1, + c2 +FROM t1 +ORDER BY ALL;"; + + AssertGeneratedFabric(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentedOrderByAllKeepsAllInline() + { + // Indented: the ALL shorthand (with its optional sort order) stays on the ORDER BY line + // rather than being pushed onto its own indented line. + const string input = "SELECT c1, c2 FROM t1 ORDER BY ALL DESC;"; + var options = new SqlScriptGeneratorOptions { ClauseBodyAlignment = ClauseBodyAlignment.Indented }; + const string expected = +@" +SELECT + c1, + c2 +FROM + t1 +ORDER BY ALL DESC;"; + + AssertGeneratedFabric(input, options, expected); + } + // --- Nested derived table (the motivating case: linear growth vs. rightward drift) ------- [TestMethod] diff --git a/Test/SqlDom/ScriptGenerator/CommaPlacementTests.cs b/Test/SqlDom/ScriptGenerator/CommaPlacementTests.cs new file mode 100644 index 00000000..c48dc771 --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/CommaPlacementTests.cs @@ -0,0 +1,606 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SqlStudio.Tests.AssemblyTools.TestCategory; +using static SqlStudio.Tests.UTSqlScriptDom.ScriptGeneratorTestHelper; + +namespace SqlStudio.Tests.UTSqlScriptDom +{ + [TestClass] + public class CommaPlacementTests + { + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementDefaultIsTrailing() + { + Assert.AreEqual(CommaPlacement.Trailing, new SqlScriptGeneratorOptions().CommaPlacement); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingSelectList() + { + const string input = "SELECT a, b, c FROM t;"; + var options = new SqlScriptGeneratorOptions { CommaPlacement = CommaPlacement.Leading }; + const string expected = @" +SELECT a + , b + , c +FROM t;"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementTrailingSelectList() + { + const string input = "SELECT a, b, c FROM t;"; + var options = new SqlScriptGeneratorOptions { CommaPlacement = CommaPlacement.Trailing }; + const string expected = @" +SELECT a, + b, + c +FROM t;"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingParenthesizedList() + { + const string input = "CREATE TABLE t (a INT, b INT, c INT);"; + var options = new SqlScriptGeneratorOptions { CommaPlacement = CommaPlacement.Leading }; + const string expected = @" +CREATE TABLE t ( + a INT + , b INT + , c INT +);"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementTrailingParenthesizedList() + { + const string input = "CREATE TABLE t (a INT, b INT, c INT);"; + var options = new SqlScriptGeneratorOptions { CommaPlacement = CommaPlacement.Trailing }; + const string expected = @" +CREATE TABLE t ( + a INT, + b INT, + c INT +);"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingInsertTargets() + { + const string input = "INSERT INTO t (a, b, c) VALUES (1, 2, 3);"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Leading, + MultilineInsertTargetsList = true, + }; + const string expected = @" +INSERT INTO t ( + a + , b + , c +) +VALUES (1, 2, 3);"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementTrailingInsertTargets() + { + const string input = "INSERT INTO t (a, b, c) VALUES (1, 2, 3);"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Trailing, + MultilineInsertTargetsList = true, + }; + const string expected = @" +INSERT INTO t ( + a, + b, + c +) +VALUES (1, 2, 3);"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingInsertSources() + { + const string input = "INSERT INTO t (a, b, c) VALUES (1, 2, 3), (4, 5, 6), (7, 8, 9);"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Leading, + MultilineInsertSourcesList = true, + MultilineInsertTargetsList = true, + }; + const string expected = @" +INSERT INTO t ( + a + , b + , c +) +VALUES (1, 2, 3) + , (4, 5, 6) + , (7, 8, 9);"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementTrailingInsertSources() + { + const string input = "INSERT INTO t (a, b, c) VALUES (1, 2, 3), (4, 5, 6), (7, 8, 9);"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Trailing, + MultilineInsertSourcesList = true, + }; + const string expected = @" +INSERT INTO t (a, b, c) +VALUES (1, 2, 3), + (4, 5, 6), + (7, 8, 9);"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingViewColumns() + { + const string input = "CREATE VIEW v (a, b, c) AS SELECT 1, 2, 3;"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Leading, + MultilineViewColumnsList = true, + }; + const string expected = @" +CREATE VIEW v ( + a + , b + , c +) +AS +SELECT 1 + , 2 + , 3;"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementTrailingViewColumns() + { + const string input = "CREATE VIEW v (a, b, c) AS SELECT 1, 2, 3;"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Trailing, + MultilineViewColumnsList = true, + }; + const string expected = @" +CREATE VIEW v ( + a, + b, + c +) +AS +SELECT 1, + 2, + 3;"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingSetClauseItems() + { + const string input = "UPDATE t SET a = 1, b = 2, c = 3;"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Leading, + MultilineSetClauseItems = true, + }; + const string expected = @" +UPDATE t +SET a = 1 + , b = 2 + , c = 3;"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementTrailingSetClauseItems() + { + const string input = "UPDATE t SET a = 1, b = 2, c = 3;"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Trailing, + MultilineSetClauseItems = true, + }; + const string expected = @" +UPDATE t +SET a = 1, + b = 2, + c = 3;"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingSingleColumnHasNoComma() + { + const string input = "SELECT a FROM t;"; + var options = new SqlScriptGeneratorOptions { CommaPlacement = CommaPlacement.Leading }; + const string expected = @" +SELECT a +FROM t;"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingWithPreserveCommentsDoesNotAbsorbComma() + { + // A line comment after an element must not absorb the following leading comma. + const string input = @"SELECT col1, -- first column + col2, -- second column + col3 +FROM t;"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Leading, + PreserveComments = true, + }; + const string expected = @" +SELECT col1 -- first column + , col2 -- second column + , col3 +FROM t;"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementTrailingWithPreserveCommentsKeepsCommentsAfterComma() + { + const string input = @"SELECT col1, -- first column + col2, -- second column + col3 +FROM t;"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Trailing, + PreserveComments = true, + }; + const string expected = @" +SELECT col1, -- first column + col2, -- second column + col3 +FROM t;"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementTrailingWithCommentBeforeCommaForcesNewLine() + { + const string input = @"SELECT col1 -- first column +, col2 +FROM t;"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Trailing, + PreserveComments = true, + }; + const string expected = @" +SELECT col1, -- first column + col2 +FROM t;"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingSelectListMultilineFalse() + { + const string input = "SELECT a, b, c FROM t;"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Leading, + MultilineSelectElementsList = false, + }; + const string expected = @" +SELECT a, b, c +FROM t;"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingViewColumnsMultilineFalse() + { + const string input = "CREATE VIEW v (a, b, c) AS SELECT 1, 2, 3;"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Leading, + MultilineViewColumnsList = false, + MultilineSelectElementsList = false, + }; + const string expected = @" +CREATE VIEW v (a, b, c) +AS +SELECT 1, 2, 3;"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingSetClauseItemsMultilineFalse() + { + const string input = "UPDATE t SET a = 1, b = 2, c = 3;"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Leading, + MultilineSetClauseItems = false, + }; + const string expected = @" +UPDATE t +SET a = 1, b = 2, c = 3;"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingInsertTargetsMultilineFalse() + { + const string input = "INSERT INTO t (a, b, c) VALUES (1, 2, 3);"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Leading, + MultilineInsertTargetsList = false, + }; + const string expected = @" +INSERT INTO t (a, b, c) +VALUES (1, 2, 3);"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingInsertSourcesMultilineFalse() + { + // VALUES rows remain multiline even when MultilineInsertSourcesList is false. + const string input = "INSERT INTO t (a, b, c) VALUES (1, 2, 3), (4, 5, 6), (7, 8, 9);"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Leading, + MultilineInsertSourcesList = false, + }; + const string expected = @" +INSERT INTO t (a, b, c) +VALUES (1, 2, 3) + , (4, 5, 6) + , (7, 8, 9);"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingParenthesizedListAlwaysMultiline() + { + // CREATE TABLE columns have no single-line toggle. + const string input = "CREATE TABLE t (a INT, b INT, c INT);"; + var options = new SqlScriptGeneratorOptions { CommaPlacement = CommaPlacement.Leading }; + const string expected = @" +CREATE TABLE t ( + a INT + , b INT + , c INT +);"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingIndentedOptionList() + { + // Exercises the indented comma-list path used by WITH parameters. + const string input = "CREATE COLUMN MASTER KEY CMK1 WITH (KEY_STORE_PROVIDER_NAME = 'MSSQL_CERTIFICATE_STORE', KEY_PATH = 'some/path');"; + var options = new SqlScriptGeneratorOptions { CommaPlacement = CommaPlacement.Leading }; + const string expected = @" +CREATE COLUMN MASTER KEY CMK1 +WITH ( + KEY_STORE_PROVIDER_NAME = 'MSSQL_CERTIFICATE_STORE' + , KEY_PATH = 'some/path' +);"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingSetClauseItemsIndented() + { + const string input = "UPDATE t SET a = 1, b = 2, c = 3;"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Leading, + MultilineSetClauseItems = true, + IndentSetClause = true, + }; + const string expected = @" +UPDATE t + SET a = 1 + , b = 2 + , c = 3;"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingCreateTableWithComments() + { + // Exercises preserved line comments in the indented leading-comma path. + const string input = @"CREATE TABLE t (a INT, -- first +b INT, -- second +c INT);"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Leading, + PreserveComments = true, + }; + const string expected = @" +CREATE TABLE t ( + a INT -- first + , b INT -- second + , c INT +);"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingWithBlockCommentTrailingElement() + { + // Block comments use the inline path rather than the deferred line-comment path. + const string input = "SELECT a /* note */, b, c FROM t;"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Leading, + PreserveComments = true, + }; + const string expected = @" +SELECT a /* note */ + , b + , c +FROM t;"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingSpaceCountDefaultIsOne() + { + Assert.AreEqual(1, new SqlScriptGeneratorOptions().LeadingCommaSpaceCount); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingSpaceCountAboveOneClampsToOne() + { + Assert.AreEqual(1, new SqlScriptGeneratorOptions { LeadingCommaSpaceCount = 2 }.LeadingCommaSpaceCount); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestLeadingCommaSpaceCountZeroInSelectList() + { + const string input = "SELECT a, b, c FROM t;"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Leading, + LeadingCommaSpaceCount = 0, + }; + const string expected = @" +SELECT a + ,b + ,c +FROM t;"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestLeadingCommaSpaceCountZeroInCreateTableColumns() + { + const string input = "CREATE TABLE t (a INT, b INT, c INT);"; + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Leading, + LeadingCommaSpaceCount = 0, + }; + const string expected = @" +CREATE TABLE t ( + a INT + ,b INT + ,c INT +);"; + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCommaPlacementLeadingSpaceCountOne() + { + var options = new SqlScriptGeneratorOptions + { + CommaPlacement = CommaPlacement.Leading, + LeadingCommaSpaceCount = 1, + }; + const string selectInput = "SELECT a, b, c FROM t;"; + const string selectExpected = @" +SELECT a + , b + , c +FROM t;"; + AssertGenerated(selectInput, options, selectExpected); + + const string tableInput = "CREATE TABLE t (a INT, b INT, c INT);"; + const string tableExpected = @" +CREATE TABLE t ( + a INT + , b INT + , c INT +);"; + AssertGenerated(tableInput, options, tableExpected); + } + } +} diff --git a/Test/SqlDom/ScriptGenerator/FunctionCallFormattingTests.cs b/Test/SqlDom/ScriptGenerator/FunctionCallFormattingTests.cs new file mode 100644 index 00000000..27c9d909 --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/FunctionCallFormattingTests.cs @@ -0,0 +1,467 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SqlStudio.Tests.AssemblyTools.TestCategory; +using static SqlStudio.Tests.UTSqlScriptDom.ScriptGeneratorTestHelper; + +namespace SqlStudio.Tests.UTSqlScriptDom +{ + [TestClass] + public class FunctionCallFormattingTests + { + private static SqlScriptGeneratorOptions MakeOptions() + { + return new SqlScriptGeneratorOptions + { + AlignClauseBodies = false, + MultilineSelectElementsList = false, + PreserveComments = true, + }; + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestFunctionCallFormattingDefaultsPreserveExistingOutput() + { + var defaults = new SqlScriptGeneratorOptions(); + Assert.IsFalse(defaults.MultilineNestedFunctionCalls); + + const string input = "SELECT REPLACE(value, 'x', 'y');"; + const string expected = "SELECT REPLACE(value, 'x', 'y');"; + + AssertGenerated(input, MakeOptions(), expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNestedFunctionCallsRemainCompactByDefault() + { + const string input = "SELECT REPLACE(LOWER(value), 'x', 'y');"; + const string expected = "SELECT REPLACE(LOWER(value), 'x', 'y');"; + + AssertGenerated(input, MakeOptions(), expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineNestedFunctionCallsPreservesParameterComments() + { + const string input = +@" +SELECT TRIM( +REPLACE( +TRANSLATE( +city, +'0123456789', -- source characters +'~~~~~~~~~~' -- replacement characters +), +'~', '' +) +);"; + var options = MakeOptions(); + options.ClauseBodyAlignment = ClauseBodyAlignment.Indented; + options.MultilineNestedFunctionCalls = true; + + const string expected = +@" +SELECT + TRIM ( + REPLACE ( + TRANSLATE ( + city, + '0123456789', -- source characters + '~~~~~~~~~~' -- replacement characters + ), + '~', + '' + ) + );"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineNestedFunctionCallsAlignsLeadingParameterComment() + { + const string input = +@" +SELECT REPLACE( +-- normalize value +LOWER(value), +'x', +'y' +);"; + var options = MakeOptions(); + options.ClauseBodyAlignment = ClauseBodyAlignment.Indented; + options.MultilineNestedFunctionCalls = true; + + const string expected = +@" +SELECT + REPLACE ( + -- normalize value + LOWER (value), + 'x', + 'y' + );"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineNestedFunctionCallsWithAlignedClauseBodies() + { + const string input = "SELECT TRIM(REPLACE(city, 'x', 'y')) AS result FROM addresses WHERE city IS NOT NULL;"; + var options = new SqlScriptGeneratorOptions + { + ClauseBodyAlignment = ClauseBodyAlignment.Aligned, + MultilineNestedFunctionCalls = true, + }; + + const string expected = +@" +SELECT TRIM ( + REPLACE ( + city, + 'x', + 'y' + ) + ) AS result +FROM addresses +WHERE city IS NOT NULL;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineNestedFunctionCallsWithIndentedClauseBodies() + { + const string input = "SELECT TRIM(REPLACE(city, 'x', 'y')) AS result FROM addresses WHERE city IS NOT NULL;"; + var options = new SqlScriptGeneratorOptions + { + ClauseBodyAlignment = ClauseBodyAlignment.Indented, + MultilineNestedFunctionCalls = true, + }; + + const string expected = +@" +SELECT + TRIM ( + REPLACE ( + city, + 'x', + 'y' + ) + ) AS result +FROM + addresses +WHERE + city IS NOT NULL;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineNestedFunctionCallsNewLineBeforeOpenParenthesis() + { + const string input = "SELECT REPLACE(LOWER(value), 'x', 'y');"; + var options = MakeOptions(); + options.MultilineNestedFunctionCalls = true; + options.NewLineBeforeOpenParenthesisInMultilineList = true; + + const string expected = +@" +SELECT REPLACE + ( + LOWER + (value), + 'x', + 'y' + );"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineNestedFunctionCallsNoNewLineBeforeCloseParenthesis() + { + const string input = "SELECT REPLACE(LOWER(value), 'x', 'y');"; + var options = MakeOptions(); + options.MultilineNestedFunctionCalls = true; + options.NewLineBeforeCloseParenthesisInMultilineList = false; + + const string expected = +@" +SELECT REPLACE ( + LOWER (value), + 'x', + 'y');"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIsolatedFunctionCallRemainsCompact() + { + const string input = "SELECT REPLACE(value, 'x', 'y');"; + var options = MakeOptions(); + options.MultilineNestedFunctionCalls = true; + const string expected = "SELECT REPLACE(value, 'x', 'y');"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestFunctionCallNestedInsideParenthesesUsesMultilineLayout() + { + const string input = "SELECT REPLACE((LOWER(value)), 'x', 'y');"; + var options = MakeOptions(); + options.MultilineNestedFunctionCalls = true; + + const string expected = +@" +SELECT REPLACE ( + (LOWER (value)), + 'x', + 'y' + );"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNestedParameterlessFunctionCallRemainsCompact() + { + const string input = "SELECT REPLACE(GETDATE(), 'x', 'y');"; + var options = MakeOptions(); + options.MultilineNestedFunctionCalls = true; + + const string expected = +@" +SELECT REPLACE ( + GETDATE(), + 'x', + 'y' + );"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrailingParameterCommentForcesNewLineWithCompactFunctionCalls() + { + const string input = +@" +SELECT TRANSLATE(city, +'a', -- source characters +'b' -- replacement characters +);"; + var options = MakeOptions(); + + const string expected = +@" +SELECT TRANSLATE(city, 'a', -- source characters +'b' -- replacement characters +);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestLeftFunctionCallCommentBeforeCommaForcesNewLine() + { + const string input = +@" +SELECT LEFT(value -- input value +, 2);"; + var options = MakeOptions(); + + const string expected = +@" +SELECT LEFT(value, -- input value +2);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestRightFunctionCallCommentBeforeClosingParenthesisForcesNewLine() + { + const string input = +@" +SELECT RIGHT(value, 2 -- character count +);"; + var options = MakeOptions(); + + const string expected = +@" +SELECT RIGHT(value, 2 -- character count +);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestSyntaxSpecialFunctionsRemainValidWhenNestedFormattingIsEnabled() + { + const string input = "SELECT TRIM(BOTH 'x' FROM value), JSON_OBJECT('a':1);"; + var options = MakeOptions(); + options.MultilineNestedFunctionCalls = true; + + const string expected = "SELECT TRIM( BOTH 'x' FROM value), JSON_OBJECT('a':1);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrimPreservesCommentBeforeFromClause() + { + const string input = +@" +SELECT TRIM(BOTH 'x' -- trim character +FROM LOWER(value));"; + var options = MakeOptions(); + options.MultilineNestedFunctionCalls = true; + + const string expected = +@" +SELECT TRIM( BOTH 'x' -- trim character +FROM LOWER(value));"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestLeftAndRightFunctionCallsUseNestedFormattingOnlyWhenNeeded() + { + var options = MakeOptions(); + options.MultilineNestedFunctionCalls = true; + + AssertGenerated( + "SELECT LEFT(value, 2), RIGHT(value, 2);", + options, + "SELECT LEFT(value, 2), RIGHT(value, 2);"); + + const string leftInput = "SELECT REPLACE(LEFT(LOWER(value), 2), 'x', 'y');"; + const string leftExpected = +@" +SELECT REPLACE ( + LEFT ( + LOWER (value), + 2 + ), + 'x', + 'y' + );"; + AssertGenerated(leftInput, options, leftExpected); + + const string rightInput = "SELECT REPLACE(RIGHT(UPPER(value), 2), 'x', 'y');"; + const string rightExpected = +@" +SELECT REPLACE ( + RIGHT ( + UPPER (value), + 2 + ), + 'x', + 'y' + );"; + AssertGenerated(rightInput, options, rightExpected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestJsonSpecialSyntaxRemainsValidWithNestedFunctionArguments() + { + const string input = +@" +SELECT + JSON_OBJECTAGG('name':LOWER(value)), + JSON_ARRAY(LOWER(value), 2), + JSON_ARRAYAGG(LOWER(value) ORDER BY value), + JSON_QUERY(LOWER(value), '$' WITH ARRAY WRAPPER), + JSON_VALUE(LOWER(value), '$' RETURNING INT);"; + var options = MakeOptions(); + options.MultilineNestedFunctionCalls = true; + options.MultilineSelectElementsList = true; + + const string expected = +@" +SELECT JSON_OBJECTAGG('name':LOWER(value)), + JSON_ARRAY(LOWER(value), 2), + JSON_ARRAYAGG(LOWER(value) ORDER BY value), + JSON_QUERY(LOWER(value), '$' WITH ARRAY WRAPPER), + JSON_VALUE(LOWER(value), '$' RETURNING INT);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDistinctAndAllFunctionCallsRemainCompact() + { + const string input = "SELECT COUNT(DISTINCT LOWER(value)), SUM(ALL ABS(value));"; + var options = MakeOptions(); + options.MultilineNestedFunctionCalls = true; + const string expected = "SELECT COUNT(DISTINCT LOWER(value)), SUM(ALL ABS(value));"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineFunctionCallPreservesNullHandlingAndOverClause() + { + const string input = "SELECT FIRST_VALUE(LOWER(Measure)) IGNORE NULLS OVER ();"; + var options = MakeOptions(); + options.MultilineNestedFunctionCalls = true; + + const string expected = +@" +SELECT FIRST_VALUE ( + LOWER (Measure) + ) IGNORE NULLS OVER ();"; + + AssertGenerated(input, options, expected); + } + } +} diff --git a/Test/SqlDom/ScriptGenerator/GroupByElementsListFormattingTests.cs b/Test/SqlDom/ScriptGenerator/GroupByElementsListFormattingTests.cs new file mode 100644 index 00000000..38ef05fc --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/GroupByElementsListFormattingTests.cs @@ -0,0 +1,225 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SqlStudio.Tests.AssemblyTools.TestCategory; +using static SqlStudio.Tests.UTSqlScriptDom.ScriptGeneratorTestHelper; + +namespace SqlStudio.Tests.UTSqlScriptDom +{ + [TestClass] + public class GroupByElementsListFormattingTests + { + private static SqlScriptGeneratorOptions MakeOptions(bool multiline) + { + return new SqlScriptGeneratorOptions + { + AlignClauseBodies = false, + MultilineGroupByElementsList = multiline, + MultilineSelectElementsList = false, + }; + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultKeepsGroupByElementsOnOneLine() + { + const string input = "SELECT a, b, COUNT(*) FROM t GROUP BY a, b;"; + var options = new SqlScriptGeneratorOptions { MultilineSelectElementsList = false }; + const string expected = @" +SELECT a, b, COUNT(*) +FROM t +GROUP BY a, b;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGroupByElementsStayOnOneLineWhenDisabled() + { + const string input = "SELECT a, b, COUNT(*) FROM t GROUP BY a, b;"; + SqlScriptGeneratorOptions options = MakeOptions(false); + const string expected = @" +SELECT a, b, COUNT(*) +FROM t +GROUP BY a, b;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGroupByElementsAreMultilineWhenEnabled() + { + const string input = "SELECT a, b, COUNT(*) FROM t GROUP BY a, b;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + const string expected = @" +SELECT a, b, COUNT(*) +FROM t +GROUP BY a, + b;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineGroupByElementsWithIndentedClauseBodies() + { + const string input = "SELECT a, b, COUNT(*) FROM t GROUP BY a, b;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.ClauseBodyAlignment = ClauseBodyAlignment.Indented; + const string expected = @" +SELECT + a, b, COUNT(*) +FROM + t +GROUP BY + a, + b;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineLegacyGroupByAllElements() + { + const string input = "SELECT a, b, COUNT(*) FROM t GROUP BY ALL a, b;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + const string expected = @" +SELECT a, b, COUNT(*) +FROM t +GROUP BY ALL a, + b;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineGroupByElementsBeforeWithCube() + { + const string input = "SELECT a, b, COUNT(*) FROM t GROUP BY a, b WITH CUBE;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + const string expected = @" +SELECT a, b, COUNT(*) +FROM t +GROUP BY a, + b WITH CUBE;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineGroupByElementsHonorLeadingCommaPlacement() + { + const string input = "SELECT a, b, COUNT(*) FROM t GROUP BY a, b;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.CommaPlacement = CommaPlacement.Leading; + const string expected = @" +SELECT a, b, COUNT(*) +FROM t +GROUP BY a + , b;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentedMultilineGroupByElementsHonorLeadingCommaPlacement() + { + const string input = "SELECT a, b, COUNT(*) FROM t GROUP BY a, b;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.ClauseBodyAlignment = ClauseBodyAlignment.Indented; + options.CommaPlacement = CommaPlacement.Leading; + const string expected = @" +SELECT + a, b, COUNT(*) +FROM + t +GROUP BY + a + , b;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestLeadingCommasWithMultilineSelectAndIndentedGroupByElements() + { + const string input = "SELECT a, b, COUNT(*) FROM t GROUP BY a, b;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.MultilineSelectElementsList = true; + options.ClauseBodyAlignment = ClauseBodyAlignment.Indented; + options.CommaPlacement = CommaPlacement.Leading; + const string expected = @" +SELECT + a + , b + , COUNT(*) +FROM + t +GROUP BY + a + , b;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrailingCommasWithMultilineSelectAndAlignedGroupByElements() + { + const string input = "SELECT a, b, COUNT(*) FROM t GROUP BY a, b;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.MultilineSelectElementsList = true; + options.ClauseBodyAlignment = ClauseBodyAlignment.Aligned; + options.AlignClauseBodies = true; + options.CommaPlacement = CommaPlacement.Trailing; + const string expected = @" +SELECT a, + b, + COUNT(*) +FROM t +GROUP BY a, + b;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineGroupByOnlySplitsTopLevelElements() + { + const string input = "SELECT a, b, c, COUNT(*) FROM t GROUP BY ROLLUP(a, b), c;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + const string expected = @" +SELECT a, b, c, COUNT(*) +FROM t +GROUP BY ROLLUP(a, b), + c;"; + + AssertGenerated(input, options, expected); + } + } +} \ No newline at end of file diff --git a/Test/SqlDom/ScriptGenerator/HavingPredicatesListFormattingTests.cs b/Test/SqlDom/ScriptGenerator/HavingPredicatesListFormattingTests.cs new file mode 100644 index 00000000..b11c4ef0 --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/HavingPredicatesListFormattingTests.cs @@ -0,0 +1,180 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SqlStudio.Tests.AssemblyTools.TestCategory; +using static SqlStudio.Tests.UTSqlScriptDom.ScriptGeneratorTestHelper; + +namespace SqlStudio.Tests.UTSqlScriptDom +{ + [TestClass] + public class HavingPredicatesListFormattingTests + { + private static SqlScriptGeneratorOptions MakeOptions(bool multiline) + { + return new SqlScriptGeneratorOptions + { + AlignClauseBodies = false, + MultilineHavingPredicatesList = multiline, + MultilineSelectElementsList = false, + MultilineWherePredicatesList = false, + }; + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultWritesHavingPredicatesOnMultipleLines() + { + const string input = "SELECT a FROM t GROUP BY a HAVING COUNT(*) > 1 AND SUM(a) > 2;"; + var options = new SqlScriptGeneratorOptions { MultilineSelectElementsList = false }; + const string expected = @" +SELECT a +FROM t +GROUP BY a +HAVING COUNT(*) > 1 + AND SUM(a) > 2;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestHavingPredicatesStayOnOneLineWhenDisabled() + { + const string input = "SELECT a FROM t GROUP BY a HAVING COUNT(*) > 1 AND SUM(a) > 2;"; + SqlScriptGeneratorOptions options = MakeOptions(false); + const string expected = @" +SELECT a +FROM t +GROUP BY a +HAVING COUNT(*) > 1 AND SUM(a) > 2;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestHavingPredicatesAreMultilineWhenEnabled() + { + const string input = "SELECT a FROM t WHERE a > 0 AND a < 10 GROUP BY a HAVING COUNT(*) > 1 AND SUM(a) > 2;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + const string expected = @" +SELECT a +FROM t +WHERE a > 0 AND a < 10 +GROUP BY a +HAVING COUNT(*) > 1 + AND SUM(a) > 2;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineHavingPredicatesWithIndentedClauseBodies() + { + const string input = "SELECT a FROM t GROUP BY a HAVING COUNT(*) > 1 AND SUM(a) > 2;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.ClauseBodyAlignment = ClauseBodyAlignment.Indented; + const string expected = @" +SELECT + a +FROM + t +GROUP BY + a +HAVING + COUNT(*) > 1 + AND SUM(a) > 2;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineHavingOrPredicates() + { + const string input = "SELECT a FROM t GROUP BY a HAVING COUNT(*) > 1 OR SUM(a) > 2;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + const string expected = @" +SELECT a +FROM t +GROUP BY a +HAVING COUNT(*) > 1 + OR SUM(a) > 2;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineHavingPreservesParenthesizedMixedPredicates() + { + const string input = "SELECT a FROM t GROUP BY a HAVING (COUNT(*) > 1 OR SUM(a) > 2) AND MAX(a) < 10;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + const string expected = @" +SELECT a +FROM t +GROUP BY a +HAVING (COUNT(*) > 1 + OR SUM(a) > 2) + AND MAX(a) < 10;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNestedWhereUsesItsOwnPredicateSetting() + { + const string input = "SELECT a FROM t GROUP BY a HAVING EXISTS (SELECT 1 FROM u WHERE u.a = t.a AND u.b > 0) AND COUNT(*) > 1;"; + SqlScriptGeneratorOptions options = MakeOptions(false); + options.MultilineWherePredicatesList = true; + const string expected = @" +SELECT a +FROM t +GROUP BY a +HAVING EXISTS (SELECT 1 + FROM u + WHERE u.a = t.a + AND u.b > 0) AND COUNT(*) > 1;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentedWhereAndHavingUseIndependentPredicateSettings() + { + const string input = "SELECT a FROM t WHERE a > 0 AND a < 10 GROUP BY a HAVING COUNT(*) > 1 AND SUM(a) > 2;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.ClauseBodyAlignment = ClauseBodyAlignment.Indented; + const string expected = @" +SELECT + a +FROM + t +WHERE + a > 0 AND a < 10 +GROUP BY + a +HAVING + COUNT(*) > 1 + AND SUM(a) > 2;"; + + AssertGenerated(input, options, expected); + } + } +} \ No newline at end of file diff --git a/Test/SqlDom/ScriptGenerator/InValuesListFormattingTests.cs b/Test/SqlDom/ScriptGenerator/InValuesListFormattingTests.cs index 0a76defc..bde1944b 100644 --- a/Test/SqlDom/ScriptGenerator/InValuesListFormattingTests.cs +++ b/Test/SqlDom/ScriptGenerator/InValuesListFormattingTests.cs @@ -143,6 +143,26 @@ public void TestMultilineNoNewLineBeforeCloseParenthesis() AssertGenerated(input, options, expected); } + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineCommentBeforeCloseParenthesisForcesNewLine() + { + const string input = +@"SELECT * FROM t WHERE x IN (1, 2 -- last value +);"; + var options = MakeOptions(true); + options.NewLineBeforeCloseParenthesisInMultilineList = false; + options.PreserveComments = true; + const string expected = @" +SELECT * FROM t WHERE x IN ( + 1, + 2 -- last value + );"; + + AssertGenerated(input, options, expected); + } + // ----------------------------------------------------------------------------------------- // Variants and edge cases // ----------------------------------------------------------------------------------------- diff --git a/Test/SqlDom/ScriptGenerator/InsertTargetsListFormattingTests.cs b/Test/SqlDom/ScriptGenerator/InsertTargetsListFormattingTests.cs index 0b8250f3..549a26e5 100644 --- a/Test/SqlDom/ScriptGenerator/InsertTargetsListFormattingTests.cs +++ b/Test/SqlDom/ScriptGenerator/InsertTargetsListFormattingTests.cs @@ -48,7 +48,7 @@ public void TestSingleLineTargetsIsDefault() const string expected = @" INSERT INTO t (a, b, c) -VALUES (1, 2, 3);"; +VALUES (1, 2, 3);"; AssertGenerated(input, options, expected); } @@ -69,7 +69,7 @@ INSERT INTO t ( b, c ) -VALUES (1, 2, 3);"; +VALUES (1, 2, 3);"; AssertGenerated(input, options, expected); } @@ -85,7 +85,7 @@ public void TestSingleLineTargetsWhenDisabled() const string expected = @" INSERT INTO t (a, b, c) -VALUES (1, 2, 3);"; +VALUES (1, 2, 3);"; AssertGenerated(input, options, expected); } @@ -103,7 +103,7 @@ public void TestSingleTargetMultiline() INSERT INTO t ( a ) -VALUES (1);"; +VALUES (1);"; AssertGenerated(input, options, expected); } @@ -199,7 +199,7 @@ INSERT INTO t ( , b , c ) -VALUES (1, 2, 3);"; +VALUES (1, 2, 3);"; AssertGenerated(input, options, expected); } @@ -219,7 +219,36 @@ INSERT INTO t ( b, c ) -VALUES (1, 2, 3);"; +VALUES (1, 2, 3);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineTargetsWithValuesMovedToNewLine() + { + // Multi-line target list combined with the Indented + AlignClauseBodies = false mode that + // moves the VALUES row constructors to their own indented line: the target list is still + // multi-line and the rows are not aligned under the target parenthesis. + const string input = "INSERT INTO t (a, b, c) VALUES (1, 2, 3), (4, 5, 6);"; + var options = new SqlScriptGeneratorOptions + { + MultilineInsertTargetsList = true, + ClauseBodyAlignment = ClauseBodyAlignment.Indented, + AlignClauseBodies = false, + }; + const string expected = +@" +INSERT INTO t ( + a, + b, + c +) +VALUES + (1, 2, 3), + (4, 5, 6);"; AssertGenerated(input, options, expected); } @@ -273,7 +302,7 @@ INSERT INTO t ( a, b, c) -VALUES (1, 2, 3);"; +VALUES (1, 2, 3);"; AssertGenerated(input, options, expected); } @@ -297,7 +326,7 @@ INSERT INTO t ( b ) OUTPUT inserted.a, inserted.b -VALUES (1, 2);"; +VALUES (1, 2);"; AssertGenerated(input, options, expected); } @@ -317,7 +346,7 @@ INSERT INTO t ( b ) OUTPUT inserted.a INTO @log -VALUES (1, 2);"; +VALUES (1, 2);"; AssertGenerated(input, options, expected); } @@ -391,7 +420,8 @@ INSERT INTO t ( [SqlStudioTestCategory(Category.UnitTest)] public void TestMultilineTargetsWithMultiRowValues() { - // Only the target list is affected; a multi-row VALUES source is left as-is. + // Only the target list is affected; a multi-row VALUES source keeps aligning its + // continuation rows under the first row. const string input = "INSERT INTO t (a, b) VALUES (1, 2), (3, 4);"; var options = new SqlScriptGeneratorOptions { MultilineInsertTargetsList = true }; const string expected = @@ -400,8 +430,8 @@ INSERT INTO t ( a, b ) -VALUES (1, 2), -(3, 4);"; +VALUES (1, 2), + (3, 4);"; AssertGenerated(input, options, expected); } diff --git a/Test/SqlDom/ScriptGenerator/InsertValuesAlignmentTests.cs b/Test/SqlDom/ScriptGenerator/InsertValuesAlignmentTests.cs new file mode 100644 index 00000000..2115dc83 --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/InsertValuesAlignmentTests.cs @@ -0,0 +1,284 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SqlStudio.Tests.AssemblyTools.TestCategory; +using static SqlStudio.Tests.UTSqlScriptDom.ScriptGeneratorTestHelper; +namespace SqlStudio.Tests.UTSqlScriptDom +{ + // Tests for the "river" that lines up an INSERT statement's VALUES row constructors under its + // column list (e.g. "INSERT INTO t (a, b)" / "VALUES (1, 2)"). The opening parenthesis of + // the VALUES row constructor lines up exactly with the opening parenthesis of the target's + // column list, regardless of how long the target name or column list is. + // Continuation rows (the 2nd, 3rd, ... row constructors) always align under the first row's + // column - either under the river's column (default Aligned behavior), or at the fixed indent + // used when the row constructors are moved to their own line (Indented + AlignClauseBodies = + // false). + [TestClass] + public class InsertValuesAlignmentTests + { + // ----------------------------------------------------------------------------------------- + // Default behavior: continuation rows align under the first row's column + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultAlignsValuesRowUnderColumnList() + { + // Default options: the VALUES row's opening parenthesis lines up exactly under the + // target column list's opening parenthesis, even for a long target/column list. + // Continuation rows align under the first row's column. + const string input = + "INSERT INTO dbo.Nurses ([NurseID], [FNme], [LNme], [Spclty], [Crt], [CrtDt], [DtCrtd], [DtLst]) " + + "VALUES (1, 'Susie', 'Derkins', 'Cardiology', 1, GETDATE() - 23139, GETDATE() - 2319, GETDATE()), " + + "(2, 'Jo', 'Harding', 'Radiology', 1, GETDATE() - 23139, GETDATE() - 2319, GETDATE());"; + var options = new SqlScriptGeneratorOptions(); + const string expected = +@" +INSERT INTO dbo.Nurses ([NurseID], [FNme], [LNme], [Spclty], [Crt], [CrtDt], [DtCrtd], [DtLst]) +VALUES (1, 'Susie', 'Derkins', 'Cardiology', 1, GETDATE() - 23139, GETDATE() - 2319, GETDATE()), + (2, 'Jo', 'Harding', 'Radiology', 1, GETDATE() - 23139, GETDATE() - 2319, GETDATE());"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultAlignsShortColumnListToo() + { + const string input = "INSERT INTO t1 (c1, c2) VALUES (1, 2), (3, 4);"; + var options = new SqlScriptGeneratorOptions(); + const string expected = +@" +INSERT INTO t1 (c1, c2) +VALUES (1, 2), + (3, 4);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultAlignsSingleColumnList() + { + // Edge case: a single-column target list still lines up its VALUES row constructors + // exactly under the target list's opening parenthesis. + const string input = "INSERT INTO t (a) VALUES (1), (2);"; + var options = new SqlScriptGeneratorOptions(); + const string expected = +@" +INSERT INTO t (a) +VALUES (1), + (2);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // AlignClauseBodies = false alone: does not move the first VALUES row (only Indented + + // AlignClauseBodies = false does that - see below), but continuation rows still align under + // the first row's column, same as the default. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestAlignClauseBodiesDisabledAloneAlignsValuesRowUnderColumnList() + { + const string input = + "INSERT INTO dbo.Nurses ([NurseID], [FNme], [LNme], [Spclty], [Crt], [CrtDt], [DtCrtd], [DtLst]) " + + "VALUES (1, 'Susie', 'Derkins', 'Cardiology', 1, GETDATE() - 23139, GETDATE() - 2319, GETDATE()), " + + "(2, 'Jo', 'Harding', 'Radiology', 1, GETDATE() - 23139, GETDATE() - 2319, GETDATE());"; + var options = new SqlScriptGeneratorOptions { AlignClauseBodies = false }; + const string expected = +@" +INSERT INTO dbo.Nurses ([NurseID], [FNme], [LNme], [Spclty], [Crt], [CrtDt], [DtCrtd], [DtLst]) +VALUES (1, 'Susie', 'Derkins', 'Cardiology', 1, GETDATE() - 23139, GETDATE() - 2319, GETDATE()), + (2, 'Jo', 'Harding', 'Radiology', 1, GETDATE() - 23139, GETDATE() - 2319, GETDATE());"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // ClauseBodyAlignment = Indented alone: also does not move the first VALUES row, since + // AlignClauseBodies is still true (the default). Continuation rows still align under the + // first row's column, same as the default. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentedAloneAlignsValuesRowUnderColumnList() + { + const string input = + "INSERT INTO dbo.Nurses ([NurseID], [FNme], [LNme], [Spclty], [Crt], [CrtDt], [DtCrtd], [DtLst]) " + + "VALUES (1, 'Susie', 'Derkins', 'Cardiology', 1, GETDATE() - 23139, GETDATE() - 2319, GETDATE()), " + + "(2, 'Jo', 'Harding', 'Radiology', 1, GETDATE() - 23139, GETDATE() - 2319, GETDATE());"; + var options = new SqlScriptGeneratorOptions { ClauseBodyAlignment = ClauseBodyAlignment.Indented }; + const string expected = +@" +INSERT INTO dbo.Nurses ([NurseID], [FNme], [LNme], [Spclty], [Crt], [CrtDt], [DtCrtd], [DtLst]) +VALUES (1, 'Susie', 'Derkins', 'Cardiology', 1, GETDATE() - 23139, GETDATE() - 2319, GETDATE()), + (2, 'Jo', 'Harding', 'Radiology', 1, GETDATE() - 23139, GETDATE() - 2319, GETDATE());"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // ClauseBodyAlignment = Indented and AlignClauseBodies = false together: the VALUES row + // constructors move to their own indented line instead of being padded onto the VALUES line, + // and every row constructor (not just the first) is indented consistently. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentedWithAlignClauseBodiesDisabledMovesValuesRowToNewLine() + { + const string input = + "INSERT INTO dbo.Nurses ([NurseID], [FNme], [LNme], [Spclty], [Crt], [CrtDt], [DtCrtd], [DtLst]) " + + "VALUES (1, 'Susie', 'Derkins', 'Cardiology', 1, GETDATE() - 23139, GETDATE() - 2319, GETDATE()), " + + "(2, 'Jo', 'Harding', 'Radiology', 1, GETDATE() - 23139, GETDATE() - 2319, GETDATE());"; + var options = new SqlScriptGeneratorOptions + { + ClauseBodyAlignment = ClauseBodyAlignment.Indented, + AlignClauseBodies = false, + }; + const string expected = +@" +INSERT INTO dbo.Nurses ([NurseID], [FNme], [LNme], [Spclty], [Crt], [CrtDt], [DtCrtd], [DtLst]) +VALUES + (1, 'Susie', 'Derkins', 'Cardiology', 1, GETDATE() - 23139, GETDATE() - 2319, GETDATE()), + (2, 'Jo', 'Harding', 'Radiology', 1, GETDATE() - 23139, GETDATE() - 2319, GETDATE());"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentedWithAlignClauseBodiesDisabledMovesShortValuesRowToNewLine() + { + const string input = "INSERT INTO t1 (c1, c2) VALUES (1, 2), (3, 4);"; + var options = new SqlScriptGeneratorOptions + { + ClauseBodyAlignment = ClauseBodyAlignment.Indented, + AlignClauseBodies = false, + }; + const string expected = +@" +INSERT INTO t1 (c1, c2) +VALUES + (1, 2), + (3, 4);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // CommaPlacement = Leading: continuation rows carry a right-aligned leading comma so that + // every row constructor's opening parenthesis still lines up under the first row's column. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestLeadingCommaAlignsValuesRowUnderColumnList() + { + const string input = "INSERT INTO t1 (c1, c2) VALUES (1, 2), (3, 4);"; + var options = new SqlScriptGeneratorOptions { CommaPlacement = CommaPlacement.Leading }; + const string expected = +@" +INSERT INTO t1 (c1, c2) +VALUES (1, 2) + , (3, 4);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // MERGE ... WHEN NOT MATCHED THEN INSERT (...) VALUES (...) routes through the same + // ValuesInsertSource path. MERGE permits only a single VALUES row, so it exercises the + // path where the column-list alignment point is registered but never re-marked. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMergeInsertActionValuesGenerates() + { + const string input = + "MERGE INTO t AS tgt USING s AS src ON tgt.c1 = src.c1 " + + "WHEN NOT MATCHED THEN INSERT (c1, c2) VALUES (src.c1, src.c2);"; + var options = new SqlScriptGeneratorOptions(); + const string expected = +@" +MERGE INTO t + AS tgt +USING s AS src ON tgt.c1 = src.c1 +WHEN NOT MATCHED THEN INSERT (c1, c2) VALUES (src.c1, src.c2);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // A ValuesInsertSource scripted on its own has no column-list alignment point (that point is + // registered by the enclosing INSERT/MERGE). Generation must still succeed and emit every + // row instead of dereferencing the missing point. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestBareValuesInsertSourceGeneratesWithoutColumnAlignmentPoint() + { + var source = new ValuesInsertSource(); + source.RowValues.Add(new RowValue { ColumnValues = { new IntegerLiteral { Value = "1" } } }); + source.RowValues.Add(new RowValue { ColumnValues = { new IntegerLiteral { Value = "2" } } }); + + var options = new SqlScriptGeneratorOptions + { + ClauseBodyAlignment = ClauseBodyAlignment.Indented, + AlignClauseBodies = false, + CommaPlacement = CommaPlacement.Leading, + }; + var generator = new Sql170ScriptGenerator(options); + generator.GenerateScript(source, out string generated); + + const string expected = +@" +VALUES + (1) + , (2)"; + + Assert.AreEqual(Normalize(expected).Trim(), Normalize(generated).Trim()); + } + + // ----------------------------------------------------------------------------------------- + // INSERT ... DEFAULT VALUES takes the IsDefaultValues branch of the VALUES source (no row + // constructors, so no river alignment). + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultValuesSource() + { + const string input = "INSERT INTO t DEFAULT VALUES;"; + var options = new SqlScriptGeneratorOptions(); + const string expected = +@" +INSERT INTO t +DEFAULT VALUES;"; + + AssertGenerated(input, options, expected); + } + } +} diff --git a/Test/SqlDom/ScriptGenerator/NumNewlinesAfterBatchStatementTests.cs b/Test/SqlDom/ScriptGenerator/NumNewlinesAfterBatchStatementTests.cs new file mode 100644 index 00000000..ef44255e --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/NumNewlinesAfterBatchStatementTests.cs @@ -0,0 +1,154 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SqlStudio.Tests.AssemblyTools.TestCategory; +using static SqlStudio.Tests.UTSqlScriptDom.ScriptGeneratorTestHelper; + +namespace SqlStudio.Tests.UTSqlScriptDom +{ + // Tests for the NumNewlinesAfterBatchStatement script-generation option, which controls how many + // newlines follow each statement owned directly by a TSqlBatch. Statements nested inside another + // statement are governed by NumNewlinesAfterStatement instead. + [TestClass] + public class NumNewlinesAfterBatchStatementTests + { + private const string TwoSelectsInBatch = @" +SELECT * FROM sys.databases; +SELECT * FROM sys.databases;"; + + private const string TwoSelectsInBeginEnd = @" +BEGIN +SELECT * FROM sys.databases; +SELECT * FROM sys.databases; +END"; + + private const string OneSelectInBatch = @"SELECT * FROM sys.databases;"; + + private static SqlScriptGeneratorOptions MakeOptions(int numNewlinesAfterBatchStatement) + { + return new SqlScriptGeneratorOptions { NumNewlinesAfterBatchStatement = numNewlinesAfterBatchStatement }; + } + + // ----------------------------------------------------------------------------------------- + // Default + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNumNewlinesAfterBatchStatementDefaultIsTwo() + { + Assert.AreEqual(2, new SqlScriptGeneratorOptions().NumNewlinesAfterBatchStatement); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNegativeNumNewlinesAfterBatchStatementClampsToZero() + { + Assert.AreEqual(0, MakeOptions(-1).NumNewlinesAfterBatchStatement); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestBatchStatementsWithDefaultOptions() + { + AssertGenerated(TwoSelectsInBatch, new SqlScriptGeneratorOptions(), @" +SELECT * +FROM sys.databases; + +SELECT * +FROM sys.databases;"); + } + + // ----------------------------------------------------------------------------------------- + // Batch-level statements (option applies) + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestBatchStatementsWithOneNewline() + { + AssertGenerated(TwoSelectsInBatch, MakeOptions(1), @" +SELECT * +FROM sys.databases; +SELECT * +FROM sys.databases;"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestBatchStatementsWithFourNewlines() + { + AssertGenerated(TwoSelectsInBatch, MakeOptions(4), @" +SELECT * +FROM sys.databases; + + + +SELECT * +FROM sys.databases;"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestBatchStatementsWithZeroNewlines() + { + // With no separating newline the second statement starts on the first one's last line, + // so its remaining clauses are aligned under that column. + AssertGenerated(TwoSelectsInBatch, MakeOptions(0), @" +SELECT * +FROM sys.databases;SELECT * + FROM sys.databases;"); + } + + // Exact (non-trimmed) assertions that pin the trailing newlines emitted after the final + // batch's last statement, which AssertGenerated's Trim() would otherwise discard. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrailingNewlinesAfterLastStatementDefault() + { + AssertGeneratedExact(OneSelectInBatch, new SqlScriptGeneratorOptions(), + "SELECT *\nFROM sys.databases;\n\n"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrailingNewlinesAfterLastStatementZero() + { + AssertGeneratedExact(OneSelectInBatch, MakeOptions(0), + "SELECT *\nFROM sys.databases;"); + } + + // ----------------------------------------------------------------------------------------- + // Nested statement lists (option does not apply) + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNestedStatementListWithFourNewlines() + { + // Statements nested in a BEGIN...END block follow NumNewlinesAfterStatement instead, so + // this matches the default. + AssertGenerated(TwoSelectsInBeginEnd, MakeOptions(4), @" +BEGIN + SELECT * + FROM sys.databases; + SELECT * + FROM sys.databases; +END"); + } + } +} diff --git a/Test/SqlDom/ScriptGenerator/NumNewlinesAfterBatchesTests.cs b/Test/SqlDom/ScriptGenerator/NumNewlinesAfterBatchesTests.cs new file mode 100644 index 00000000..ba7dc877 --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/NumNewlinesAfterBatchesTests.cs @@ -0,0 +1,180 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SqlStudio.Tests.AssemblyTools.TestCategory; +using static SqlStudio.Tests.UTSqlScriptDom.ScriptGeneratorTestHelper; + +namespace SqlStudio.Tests.UTSqlScriptDom +{ + // Tests for the NumNewlinesAfterBatches script-generation option, which controls how many + // newlines follow the GO separator between two batches. GO is always preceded by a newline of + // its own, and the blank lines before it come from NumNewlinesAfterBatchStatement. + [TestClass] + public class NumNewlinesAfterBatchesTests + { + private const string TwoBatches = @" +SELECT * FROM sys.databases; +GO +SELECT * FROM sys.databases;"; + + private const string BatchWithNestedBlockThenBatch = @" +SELECT * FROM sys.databases; +BEGIN +SELECT * FROM sys.databases; +SELECT * FROM sys.databases; +END +GO +SELECT * FROM sys.databases;"; + + private static SqlScriptGeneratorOptions MakeOptions(int numNewlinesAfterBatches) + { + return new SqlScriptGeneratorOptions { NumNewlinesAfterBatches = numNewlinesAfterBatches }; + } + + // ----------------------------------------------------------------------------------------- + // Default + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNumNewlinesAfterBatchesDefaultIsOne() + { + Assert.AreEqual(1, new SqlScriptGeneratorOptions().NumNewlinesAfterBatches); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTwoBatchesWithDefaultOptions() + { + // The two blank lines before GO come from NumNewlinesAfterBatchStatement, which is + // applied after the last statement of the batch as well. + AssertGenerated(TwoBatches, new SqlScriptGeneratorOptions(), @" +SELECT * +FROM sys.databases; + + +GO +SELECT * +FROM sys.databases;"); + } + + // ----------------------------------------------------------------------------------------- + // Newlines after GO + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTwoBatchesWithThreeNewlines() + { + AssertGenerated(TwoBatches, MakeOptions(3), @" +SELECT * +FROM sys.databases; + + +GO + + +SELECT * +FROM sys.databases;"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTwoBatchesWithZeroNewlinesClampsToOne() + { + // The next batch must start on its own line, so the setting is clamped to its minimum of 1. + Assert.AreEqual(1, MakeOptions(0).NumNewlinesAfterBatches); + } + + // ----------------------------------------------------------------------------------------- + // Interaction with NumNewlinesAfterBatchStatement + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGoWithNoBlankLineOnEitherSide() + { + SqlScriptGeneratorOptions options = new SqlScriptGeneratorOptions + { + NumNewlinesAfterBatchStatement = 0, + NumNewlinesAfterBatches = 1 + }; + + AssertGenerated(TwoBatches, options, @" +SELECT * +FROM sys.databases; +GO +SELECT * +FROM sys.databases;"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestOneBlankLineOnEachSideOfGo() + { + SqlScriptGeneratorOptions options = new SqlScriptGeneratorOptions + { + NumNewlinesAfterBatchStatement = 1, + NumNewlinesAfterBatches = 2 + }; + + AssertGenerated(TwoBatches, options, @" +SELECT * +FROM sys.databases; + +GO + +SELECT * +FROM sys.databases;"); + } + + // ----------------------------------------------------------------------------------------- + // All three newline options together + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestAllThreeNewlineOptionsTogether() + { + SqlScriptGeneratorOptions options = new SqlScriptGeneratorOptions + { + NumNewlinesAfterStatement = 2, + NumNewlinesAfterBatchStatement = 2, + NumNewlinesAfterBatches = 2 + }; + + // One blank line between statements, nested and top level alike. The batch closes with + // two blank lines because the newline that carries GO stacks on top of the two written + // after END. + AssertGenerated(BatchWithNestedBlockThenBatch, options, @" +SELECT * +FROM sys.databases; + +BEGIN + SELECT * + FROM sys.databases; + + SELECT * + FROM sys.databases; +END + + +GO + +SELECT * +FROM sys.databases;"); + } + } +} diff --git a/Test/SqlDom/ScriptGenerator/NumNewlinesAfterStatementTests.cs b/Test/SqlDom/ScriptGenerator/NumNewlinesAfterStatementTests.cs new file mode 100644 index 00000000..2c07b998 --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/NumNewlinesAfterStatementTests.cs @@ -0,0 +1,156 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SqlStudio.Tests.AssemblyTools.TestCategory; +using static SqlStudio.Tests.UTSqlScriptDom.ScriptGeneratorTestHelper; + +namespace SqlStudio.Tests.UTSqlScriptDom +{ + // Tests for the NumNewlinesAfterStatement script-generation option, which controls how many + // newlines separate consecutive statements of a StatementList. It does not apply to statements + // held directly by a TSqlBatch, which are governed by NumNewlinesAfterBatchStatement. + [TestClass] + public class NumNewlinesAfterStatementTests + { + private const string TwoSelectsInBatch = @" +SELECT * FROM sys.databases; +SELECT * FROM sys.databases;"; + + private const string TwoSelectsInBeginEnd = @" +BEGIN +SELECT * FROM sys.databases; +SELECT * FROM sys.databases; +END"; + + private static SqlScriptGeneratorOptions MakeOptions(int numNewlinesAfterStatement) + { + return new SqlScriptGeneratorOptions { NumNewlinesAfterStatement = numNewlinesAfterStatement }; + } + + // ----------------------------------------------------------------------------------------- + // Default + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNumNewlinesAfterStatementDefaultIsOne() + { + Assert.AreEqual(1, new SqlScriptGeneratorOptions().NumNewlinesAfterStatement); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNegativeNumNewlinesAfterStatementClampsToZero() + { + Assert.AreEqual(0, MakeOptions(-1).NumNewlinesAfterStatement); + } + + // ----------------------------------------------------------------------------------------- + // Batch-level statements (option does not apply) + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestBatchStatementsWithDefaultOptions() + { + AssertGenerated(TwoSelectsInBatch, new SqlScriptGeneratorOptions(), @" +SELECT * +FROM sys.databases; + +SELECT * +FROM sys.databases;"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestBatchStatementsWithFourNewlines() + { + // Statements owned by a TSqlBatch follow NumNewlinesAfterBatchStatement instead, so + // this matches the default. + AssertGenerated(TwoSelectsInBatch, MakeOptions(4), @" +SELECT * +FROM sys.databases; + +SELECT * +FROM sys.databases;"); + } + + // ----------------------------------------------------------------------------------------- + // Nested statement lists (option applies) + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNestedStatementListWithDefaultOptions() + { + AssertGenerated(TwoSelectsInBeginEnd, new SqlScriptGeneratorOptions(), @" +BEGIN + SELECT * + FROM sys.databases; + SELECT * + FROM sys.databases; +END"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNestedStatementListWithTwoNewlines() + { + // Every newline is followed by the current indentation, so the separating blank line + // below is not empty: it carries the four-space indent. + AssertGenerated(TwoSelectsInBeginEnd, MakeOptions(2), @" +BEGIN + SELECT * + FROM sys.databases; + + SELECT * + FROM sys.databases; +END"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNestedStatementListWithFourNewlines() + { + // Every newline is followed by the current indentation, so the separating blank lines + // below are not empty: each one carries the four-space indent. + AssertGenerated(TwoSelectsInBeginEnd, MakeOptions(4), @" +BEGIN + SELECT * + FROM sys.databases; + + + + SELECT * + FROM sys.databases; +END"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNestedStatementListWithZeroNewlines() + { + // With no separating newline the second statement starts on the first one's last line, + // so its remaining clauses are aligned under that column. + AssertGenerated(TwoSelectsInBeginEnd, MakeOptions(0), @" +BEGIN + SELECT * + FROM sys.databases;SELECT * + FROM sys.databases; +END"); + } + } +} diff --git a/Test/SqlDom/ScriptGenerator/OrderByElementsListFormattingTests.cs b/Test/SqlDom/ScriptGenerator/OrderByElementsListFormattingTests.cs new file mode 100644 index 00000000..9c7fe050 --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/OrderByElementsListFormattingTests.cs @@ -0,0 +1,239 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SqlStudio.Tests.AssemblyTools.TestCategory; +using static SqlStudio.Tests.UTSqlScriptDom.ScriptGeneratorTestHelper; + +namespace SqlStudio.Tests.UTSqlScriptDom +{ + [TestClass] + public class OrderByElementsListFormattingTests + { + private static SqlScriptGeneratorOptions MakeOptions(bool multiline) + { + return new SqlScriptGeneratorOptions + { + AlignClauseBodies = false, + MultilineOrderByElementsList = multiline, + MultilineSelectElementsList = false, + }; + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultKeepsOrderByElementsOnOneLine() + { + const string input = "SELECT a, b FROM t ORDER BY a, b DESC;"; + var options = new SqlScriptGeneratorOptions { MultilineSelectElementsList = false }; + const string expected = @" +SELECT a, b +FROM t +ORDER BY a, b DESC;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestOrderByElementsStayOnOneLineWhenDisabled() + { + const string input = "SELECT a, b FROM t ORDER BY a, b DESC;"; + SqlScriptGeneratorOptions options = MakeOptions(false); + const string expected = @" +SELECT a, b +FROM t +ORDER BY a, b DESC;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestOrderByElementsAreMultilineWhenEnabled() + { + const string input = "SELECT a, b FROM t ORDER BY a, b DESC;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + const string expected = @" +SELECT a, b +FROM t +ORDER BY a, + b DESC;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineOrderByElementsWithIndentedClauseBodies() + { + const string input = "SELECT a, b FROM t ORDER BY a, b DESC;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.ClauseBodyAlignment = ClauseBodyAlignment.Indented; + const string expected = @" +SELECT + a, b +FROM + t +ORDER BY + a, + b DESC;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineOrderByElementsHonorLeadingCommaPlacement() + { + const string input = "SELECT a, b FROM t ORDER BY a, b DESC;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.CommaPlacement = CommaPlacement.Leading; + const string expected = @" +SELECT a, b +FROM t +ORDER BY a + , b DESC;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentedMultilineOrderByElementsHonorLeadingCommaPlacement() + { + const string input = "SELECT a, b FROM t ORDER BY a, b DESC;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.ClauseBodyAlignment = ClauseBodyAlignment.Indented; + options.CommaPlacement = CommaPlacement.Leading; + const string expected = @" +SELECT + a, b +FROM + t +ORDER BY + a + , b DESC;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestLeadingCommasWithMultilineSelectAndIndentedOrderByElements() + { + const string input = "SELECT a, b FROM t ORDER BY a, b DESC;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.MultilineSelectElementsList = true; + options.ClauseBodyAlignment = ClauseBodyAlignment.Indented; + options.CommaPlacement = CommaPlacement.Leading; + const string expected = @" +SELECT + a + , b +FROM + t +ORDER BY + a + , b DESC;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrailingCommasWithMultilineSelectAndAlignedOrderByElements() + { + const string input = "SELECT a, b FROM t ORDER BY a, b DESC;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.MultilineSelectElementsList = true; + options.ClauseBodyAlignment = ClauseBodyAlignment.Aligned; + options.AlignClauseBodies = true; + options.CommaPlacement = CommaPlacement.Trailing; + const string expected = @" +SELECT a, + b +FROM t +ORDER BY a, + b DESC;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestThreeMultilineOrderByElementsHonorZeroLeadingCommaSpacing() + { + const string input = "SELECT a, b, c FROM t ORDER BY a, b DESC, c;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.CommaPlacement = CommaPlacement.Leading; + options.LeadingCommaSpaceCount = 0; + const string expected = @" +SELECT a, b, c +FROM t +ORDER BY a + ,b DESC + ,c;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineOrderByElementsInOverClause() + { + const string input = "SELECT ROW_NUMBER() OVER (ORDER BY a, b DESC) FROM t;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + const string expected = @" +SELECT ROW_NUMBER() OVER (ORDER BY a, + b DESC) +FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestOrderByAllIgnoresMultilineElementsSetting() + { + const string input = "SELECT a, b FROM t ORDER BY ALL DESC;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + const string expected = @" +SELECT a, b +FROM t +ORDER BY ALL DESC;"; + + AssertGeneratedFabric(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestOrderByAllWithoutSortOrderIgnoresMultilineElementsSetting() + { + const string input = "SELECT a, b FROM t ORDER BY ALL;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + const string expected = @" +SELECT a, b +FROM t +ORDER BY ALL;"; + + AssertGeneratedFabric(input, options, expected); + } + } +} \ No newline at end of file diff --git a/Test/SqlDom/ScriptGenerator/PartitionByElementsListFormattingTests.cs b/Test/SqlDom/ScriptGenerator/PartitionByElementsListFormattingTests.cs new file mode 100644 index 00000000..1fe9894a --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/PartitionByElementsListFormattingTests.cs @@ -0,0 +1,227 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SqlStudio.Tests.AssemblyTools.TestCategory; +using static SqlStudio.Tests.UTSqlScriptDom.ScriptGeneratorTestHelper; + +namespace SqlStudio.Tests.UTSqlScriptDom +{ + [TestClass] + public class PartitionByElementsListFormattingTests + { + private static SqlScriptGeneratorOptions MakeOptions(bool multiline) + { + return new SqlScriptGeneratorOptions + { + AlignClauseBodies = false, + MultilinePartitionByElementsList = multiline, + MultilineSelectElementsList = false, + }; + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultKeepsPartitionByElementsOnOneLine() + { + const string input = "SELECT SUM(c) OVER (PARTITION BY a, b) FROM t;"; + var options = new SqlScriptGeneratorOptions { MultilineSelectElementsList = false }; + const string expected = @" +SELECT SUM(c) OVER (PARTITION BY a, b) +FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPartitionByElementsStayOnOneLineWhenDisabled() + { + const string input = "SELECT SUM(c) OVER (PARTITION BY a, b) FROM t;"; + SqlScriptGeneratorOptions options = MakeOptions(false); + const string expected = @" +SELECT SUM(c) OVER (PARTITION BY a, b) +FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPartitionByElementsAreMultilineInOverClause() + { + const string input = "SELECT SUM(c) OVER (PARTITION BY a, b) FROM t;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + const string expected = @" +SELECT SUM(c) OVER (PARTITION BY a, + b) +FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPartitionByElementsAreMultilineInNamedWindow() + { + const string input = "SELECT SUM(c) OVER win FROM t WINDOW win AS (PARTITION BY a, b);"; + SqlScriptGeneratorOptions options = MakeOptions(true); + const string expected = @" +SELECT SUM(c) OVER win +FROM t +WINDOW win AS (PARTITION BY a, + b);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilinePartitionByElementsWithIndentedClauseBodies() + { + const string input = "SELECT SUM(c) OVER (PARTITION BY a, b) FROM t;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.ClauseBodyAlignment = ClauseBodyAlignment.Indented; + const string expected = @" +SELECT + SUM(c) OVER (PARTITION BY a, + b) +FROM + t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilinePartitionByElementsBeforeOrderByAndFrame() + { + const string input = "SELECT SUM(c) OVER (PARTITION BY a, b ORDER BY c ROWS UNBOUNDED PRECEDING) FROM t;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + const string expected = @" +SELECT SUM(c) OVER (PARTITION BY a, + b ORDER BY c ROWS UNBOUNDED PRECEDING) +FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilinePartitionByElementsBeforeOrderByInNamedWindow() + { + const string input = "SELECT SUM(c) OVER win FROM t WINDOW win AS (PARTITION BY a, b ORDER BY c);"; + SqlScriptGeneratorOptions options = MakeOptions(true); + const string expected = @" +SELECT SUM(c) OVER win +FROM t +WINDOW win AS (PARTITION BY a, + b ORDER BY c);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilinePartitionByElementsHonorLeadingCommaPlacement() + { + const string input = "SELECT SUM(c) OVER (PARTITION BY a, b) FROM t;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.CommaPlacement = CommaPlacement.Leading; + const string expected = @" +SELECT SUM(c) OVER (PARTITION BY a + , b) +FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentedMultilinePartitionByElementsHonorLeadingCommaPlacement() + { + const string input = "SELECT SUM(c) OVER (PARTITION BY a, b) FROM t;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.ClauseBodyAlignment = ClauseBodyAlignment.Indented; + options.CommaPlacement = CommaPlacement.Leading; + const string expected = @" +SELECT + SUM(c) OVER (PARTITION BY a + , b) +FROM + t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestLeadingCommasWithMultilineSelectAndIndentedPartitionByElements() + { + const string input = "SELECT a, SUM(c) OVER (PARTITION BY a, b) FROM t;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.MultilineSelectElementsList = true; + options.ClauseBodyAlignment = ClauseBodyAlignment.Indented; + options.CommaPlacement = CommaPlacement.Leading; + const string expected = @" +SELECT + a + , SUM(c) OVER (PARTITION BY a + , b) +FROM + t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrailingCommasWithMultilineSelectAndAlignedPartitionByElements() + { + const string input = "SELECT a, SUM(c) OVER (PARTITION BY a, b) FROM t;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + options.MultilineSelectElementsList = true; + options.ClauseBodyAlignment = ClauseBodyAlignment.Aligned; + options.AlignClauseBodies = true; + options.CommaPlacement = CommaPlacement.Trailing; + const string expected = @" +SELECT a, + SUM(c) OVER (PARTITION BY a, + b) +FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilinePartitionByOnlySplitsTopLevelElements() + { + const string input = "SELECT SUM(c) OVER (PARTITION BY COALESCE(a, b), c, d) FROM t;"; + SqlScriptGeneratorOptions options = MakeOptions(true); + const string expected = @" +SELECT SUM(c) OVER (PARTITION BY COALESCE (a, b), + c, + d) +FROM t;"; + + AssertGenerated(input, options, expected); + } + } +} \ No newline at end of file diff --git a/Test/SqlDom/ScriptGenerator/ScriptGeneratorTestHelper.cs b/Test/SqlDom/ScriptGenerator/ScriptGeneratorTestHelper.cs index a1c10ecc..3569cabb 100644 --- a/Test/SqlDom/ScriptGenerator/ScriptGeneratorTestHelper.cs +++ b/Test/SqlDom/ScriptGenerator/ScriptGeneratorTestHelper.cs @@ -4,6 +4,7 @@ // //------------------------------------------------------------------------------ +using System; using System.Collections.Generic; using System.IO; using Microsoft.SqlServer.TransactSql.ScriptDom; @@ -11,32 +12,97 @@ namespace SqlStudio.Tests.UTSqlScriptDom { - // Shared helpers for script-generation option tests: generate a script from SQL with the given - // options and verify both the input and the generated output parse without errors. - internal static class ScriptGeneratorTestHelper + // Template-method pipeline for script-generation option tests. The Generate/Verify/reparse + // steps are identical across dialects; only the parser and generator types differ. They are + // supplied as type parameters, and factory delegates create the concrete instances (whose + // constructors take arguments, so a new() constraint is not usable). + internal sealed class ScriptGenerationPipeline + where TParser : TSqlParser + where TGenerator : SqlScriptGenerator { - // Parses the input, generates a script with the given options (using the SQL-170 generator), - // asserts the input parses and the generated script reparses, and returns the generated script. - public static string Generate(string sql, SqlScriptGeneratorOptions options) + private readonly Func _createParser; + private readonly Func _createGenerator; + + public ScriptGenerationPipeline( + Func createParser, + Func createGenerator) + { + _createParser = createParser; + _createGenerator = createGenerator; + } + + // Template method: parse the input, generate a script with the given options, assert the + // input parses and the generated script reparses, and return the generated script. + public string Generate(string sql, SqlScriptGeneratorOptions options) { - var parser = new TSql170Parser(true); - TSqlFragment fragment = parser.Parse(new StringReader(sql), out IList errors); + TSqlFragment fragment = _createParser().Parse(new StringReader(sql), out IList errors); Assert.AreEqual(0, errors.Count, "Input must parse without errors."); - var generator = new Sql170ScriptGenerator(options); - generator.GenerateScript(fragment, out string generated); + _createGenerator(options).GenerateScript(fragment, out string generated); AssertReparses(generated); return generated; } - public static void AssertReparses(string sql) + public void AssertReparses(string sql) { - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(sql), out IList errors); + _createParser().Parse(new StringReader(sql), out IList errors); Assert.AreEqual(0, errors.Count, "Generated script must reparse without errors. Actual:\n" + sql); } + // Generates a script from the given SQL with the given options and asserts it equals the + // expected value. The expected literals typically start with a newline right after @" so the + // first SQL line lines up with the rest in source; Trim() removes that leading newline (and + // any trailing whitespace from the generated output) before comparing. + public void AssertGenerated(string sql, SqlScriptGeneratorOptions options, string expected) + { + Assert.AreEqual( + ScriptGeneratorTestHelper.Normalize(expected).Trim(), + ScriptGeneratorTestHelper.Normalize(Generate(sql, options)).Trim()); + } + + // Exact comparison without Trim(), so leading and trailing newline counts are part of the + // assertion. Use this to lock down the newlines emitted after the final batch's last statement. + public void AssertGeneratedExact(string sql, SqlScriptGeneratorOptions options, string expected) + { + Assert.AreEqual( + ScriptGeneratorTestHelper.Normalize(expected), + ScriptGeneratorTestHelper.Normalize(Generate(sql, options))); + } + } + + // Shared helpers for script-generation option tests. The default (SQL-170) and Fabric DW + // pipelines share all logic via ScriptGenerationPipeline; these static + // methods are thin entry points so existing tests keep their call style. + internal static class ScriptGeneratorTestHelper + { + private static readonly ScriptGenerationPipeline Sql170 = + new ScriptGenerationPipeline( + () => new TSql170Parser(true), options => new Sql170ScriptGenerator(options)); + + private static readonly ScriptGenerationPipeline FabricDW = + new ScriptGenerationPipeline( + () => new TSqlFabricDWParser(true), options => new SqlFabricDWScriptGenerator(options)); + + // SQL-100 pipeline, for syntax that later versions removed (for example the COMPUTE clause). + private static readonly ScriptGenerationPipeline Sql100 = + new ScriptGenerationPipeline( + () => new TSql100Parser(true), options => new Sql100ScriptGenerator(options)); + + // SQL-170 pipeline. + public static string Generate(string sql, SqlScriptGeneratorOptions options) => Sql170.Generate(sql, options); + public static void AssertReparses(string sql) => Sql170.AssertReparses(sql); + public static void AssertGenerated(string sql, SqlScriptGeneratorOptions options, string expected) => Sql170.AssertGenerated(sql, options, expected); + + // Fabric DW pipeline. + public static string GenerateFabric(string sql, SqlScriptGeneratorOptions options) => FabricDW.Generate(sql, options); + public static void AssertReparsesFabric(string sql) => FabricDW.AssertReparses(sql); + public static void AssertGeneratedFabric(string sql, SqlScriptGeneratorOptions options, string expected) => FabricDW.AssertGenerated(sql, options, expected); + + // SQL-100 pipeline. + public static string Generate100(string sql, SqlScriptGeneratorOptions options) => Sql100.Generate(sql, options); + public static void AssertGenerated100(string sql, SqlScriptGeneratorOptions options, string expected) => Sql100.AssertGenerated(sql, options, expected); + // Normalizes line endings so verbatim expected constants compare equal regardless of the // source file's line-ending style. public static string Normalize(string value) @@ -44,13 +110,8 @@ public static string Normalize(string value) return value.Replace("\r\n", "\n").Replace("\r", "\n"); } - // Generates a script from the given SQL with the given options and asserts it equals the - // expected value. The expected literals typically start with a newline right after @" so the - // first SQL line lines up with the rest in source; Trim() removes that leading newline (and - // any trailing whitespace from the generated output) before comparing. - public static void AssertGenerated(string sql, SqlScriptGeneratorOptions options, string expected) - { - Assert.AreEqual(Normalize(expected).Trim(), Normalize(Generate(sql, options)).Trim()); - } + // Exact comparison without Trim(), so leading and trailing newline counts are part of the + // assertion. Use this to lock down the newlines emitted after the final batch's last statement. + public static void AssertGeneratedExact(string sql, SqlScriptGeneratorOptions options, string expected) => Sql170.AssertGeneratedExact(sql, options, expected); } } diff --git a/Test/SqlDom/ScriptGenerator/ScriptGeneratorTests.cs b/Test/SqlDom/ScriptGenerator/ScriptGeneratorTests.cs index a153791d..72f0b680 100644 --- a/Test/SqlDom/ScriptGenerator/ScriptGeneratorTests.cs +++ b/Test/SqlDom/ScriptGenerator/ScriptGeneratorTests.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // Copyright (c) Microsoft Corporation. All rights reserved. // @@ -429,6 +429,270 @@ public void TestAlterTableAddIndexOnly_EmitsNoLeadingSeparator() "Generated SQL must reparse. Actual:\n" + generated); } + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrailingGo_IsPreservedOnRoundTrip() + { + // With PersistTrailingGo enabled, a trailing GO present on input is regenerated. + var sql = @"DECLARE @v1 AS VARCHAR (100) = ''; +SELECT id, name FROM sys.databases; +GO +"; + var expected = @"DECLARE @v1 AS VARCHAR (100) = ''; + +SELECT id, + name +FROM sys.databases; + + +GO +"; + ScriptGeneratorTestHelper.AssertGeneratedExact( + sql, + new SqlScriptGeneratorOptions { PersistTrailingGo = true }, + expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrailingGo_NotEmittedByDefault() + { + // PersistTrailingGo defaults to false, so the trailing GO is not regenerated. + var sql = @"DECLARE @v1 AS VARCHAR (100) = ''; +SELECT id, name FROM sys.databases; +GO +"; + var expected = @"DECLARE @v1 AS VARCHAR (100) = ''; + +SELECT id, + name +FROM sys.databases; + +"; + ScriptGeneratorTestHelper.AssertGeneratedExact( + sql, + new SqlScriptGeneratorOptions(), + expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNoTrailingGo_IsNotAdded() + { + // The trailing GO is optional on input; when absent it must not be generated even when enabled. + var sql = @"DECLARE @v1 AS VARCHAR (100) = ''; +SELECT id, name FROM sys.databases; +"; + var expected = @"DECLARE @v1 AS VARCHAR (100) = ''; + +SELECT id, + name +FROM sys.databases; + +"; + ScriptGeneratorTestHelper.AssertGeneratedExact( + sql, + new SqlScriptGeneratorOptions { PersistTrailingGo = true }, + expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrailingGo_ExtraNewLinesAroundGoAreNormalized() + { + // Several blank lines before and after the GO must not affect the generated output. + var sql = @"DECLARE @v1 AS VARCHAR (100) = ''; +SELECT id, name FROM sys.databases; + + + +GO + + + +"; + var expected = @"DECLARE @v1 AS VARCHAR (100) = ''; + +SELECT id, + name +FROM sys.databases; + + +GO +"; + ScriptGeneratorTestHelper.AssertGeneratedExact( + sql, + new SqlScriptGeneratorOptions { PersistTrailingGo = true }, + expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrailingGo_CommentBeforeGoStaysAboveSeparator() + { + // With PreserveComments + PersistTrailingGo, a comment between the last statement + // and the trailing GO must stay above the GO, not cross the batch separator. + var sql = @"SELECT 1; +-- trailing note +GO +"; + var expected = @"SELECT 1; + + -- trailing note +GO +"; + ScriptGeneratorTestHelper.AssertGeneratedExact( + sql, + new SqlScriptGeneratorOptions { PersistTrailingGo = true, PreserveComments = true }, + expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrailingGo_BlockCommentBeforeAndCommentAfterGo() + { + // With PreserveComments + PersistTrailingGo, a block comment before the trailing GO + // must stay above it and a comment after the GO must stay below it — neither crosses + // the batch separator. + var sql = @"SELECT 1; +/* before */ +GO +-- after +"; + var expected = @"SELECT 1; + + +/* before */ +GO + +-- after"; + ScriptGeneratorTestHelper.AssertGeneratedExact( + sql, + new SqlScriptGeneratorOptions { PersistTrailingGo = true, PreserveComments = true }, + expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrailingGo_MultiBatchWithoutTrailingGoAddsNone() + { + // A GO between batches with no trailing GO must reset TrailingGoCount to 0, so even with + // PersistTrailingGo enabled the between-batch GO is preserved but no extra GO is appended. + var sql = @"SELECT 1; +GO +SELECT 2; +"; + var expected = @"SELECT 1; + + +GO +SELECT 2; + +"; + ScriptGeneratorTestHelper.AssertGeneratedExact( + sql, + new SqlScriptGeneratorOptions { PersistTrailingGo = true }, + expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrailingGo_MultiBatchWithTrailingGoPreserved() + { + // A multi-batch script that also ends with a trailing GO keeps both the between-batch + // GO and the trailing GO when PersistTrailingGo is enabled. + var sql = @"SELECT 1; +GO +SELECT 2; +GO +"; + var expected = @"SELECT 1; + + +GO +SELECT 2; + + +GO +"; + ScriptGeneratorTestHelper.AssertGeneratedExact( + sql, + new SqlScriptGeneratorOptions { PersistTrailingGo = true }, + expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrailingGo_ConsecutiveTrailingGosArePreserved() + { + // Consecutive trailing GO separators must be counted and re-emitted one-for-one so a + // parse -> generate -> reparse round-trip preserves the exact number of trailing GOs. + var sql = @"SELECT 1; +GO +GO +"; + var expected = @"SELECT 1; + + +GO + +GO +"; + ScriptGeneratorTestHelper.AssertGeneratedExact( + sql, + new SqlScriptGeneratorOptions { PersistTrailingGo = true }, + expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTrailingGo_MultiBatchVaryingGoCountsBetweenBatches() + { + // Between-batch GO runs (any count) collapse to a single batch separator, while only the + // consecutive trailing GO run is counted and re-emitted one-for-one. + var sql = @"SELECT 1; +GO +GO +SELECT 2; +GO +SELECT 3; +GO +GO +GO +"; + var expected = @"SELECT 1; + + +GO +SELECT 2; + + +GO +SELECT 3; + + +GO + +GO + +GO +"; + ScriptGeneratorTestHelper.AssertGeneratedExact( + sql, + new SqlScriptGeneratorOptions { PersistTrailingGo = true }, + expected); + } + [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)] @@ -688,7 +952,12 @@ c VARCHAR (20) NOT NULL -- a string // AssertGenerated also verifies the generated script (comments included) reparses cleanly. ScriptGeneratorTestHelper.AssertGenerated( sql, - new SqlScriptGeneratorOptions { AlignColumnDefinitionFields = true, PreserveComments = true }, + new SqlScriptGeneratorOptions + { + AlignColumnDefinitionFields = true, + NewLineBeforeCloseParenthesisInMultilineList = false, + PreserveComments = true, + }, expected); } @@ -2247,968 +2516,5 @@ public void TestPreserveComments_RealWorldXmlModifyBatchFromDocs() #endregion - #region CommaPlacement - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementDefaultIsTrailing() - { - Assert.AreEqual(CommaPlacement.Trailing, new SqlScriptGeneratorOptions().CommaPlacement); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingSelectList() - { - var sql = "SELECT a, b, c FROM t;"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading - }); - generator.GenerateScript(fragment, out var generated); - - // Leading comma style for a keyword-aligned list: the columns stay aligned with the - // first element and each comma is placed two columns before them. - string expected = - "SELECT a" + Environment.NewLine + - " , b" + Environment.NewLine + - " , c" + Environment.NewLine + - "FROM t;" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementTrailingSelectList() - { - var sql = "SELECT a, b, c FROM t;"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Trailing - }); - generator.GenerateScript(fragment, out var generated); - - // Trailing comma style (default): the comma is placed at the end of each line. - string expected = - "SELECT a," + Environment.NewLine + - " b," + Environment.NewLine + - " c" + Environment.NewLine + - "FROM t;" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingParenthesizedList() - { - var sql = "CREATE TABLE t (a INT, b INT, c INT);"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading - }); - generator.GenerateScript(fragment, out var generated); - - // Leading comma style in a parenthesized (CREATE TABLE) column list. - // Elements stay at the list indentation level (4); the comma is indented two - // characters fewer (column 2), per the CommaPlacement=Leading rule. - string expected = - "CREATE TABLE t (" + Environment.NewLine + - " a INT" + Environment.NewLine + - " , b INT" + Environment.NewLine + - " , c INT" + Environment.NewLine + - ");" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementTrailingParenthesizedList() - { - var sql = "CREATE TABLE t (a INT, b INT, c INT);"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Trailing - }); - generator.GenerateScript(fragment, out var generated); - - // Trailing comma style (default) in a parenthesized (CREATE TABLE) column list: - // each comma follows the element on the same line and the elements stay at the - // list indentation level (4). - string expected = - "CREATE TABLE t (" + Environment.NewLine + - " a INT," + Environment.NewLine + - " b INT," + Environment.NewLine + - " c INT" + Environment.NewLine + - ");" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingInsertTargets() - { - // With MultilineInsertTargetsList = true the INSERT column target list is emitted as a - // multi-line parenthesized list (like CREATE TABLE / VIEW columns): each column on its - // own line indented one level. CommaPlacement = Leading places each column's comma at - // the start of its line (indented two characters fewer than the column). - var sql = "INSERT INTO t (a, b, c) VALUES (1, 2, 3);"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading, - MultilineInsertTargetsList = true - }); - generator.GenerateScript(fragment, out var generated); - - string expected = - "INSERT INTO t (" + Environment.NewLine + - " a" + Environment.NewLine + - " , b" + Environment.NewLine + - " , c" + Environment.NewLine + - ")" + Environment.NewLine + - "VALUES (1, 2, 3);" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementTrailingInsertTargets() - { - // With MultilineInsertTargetsList = true and CommaPlacement = Trailing (default) the - // INSERT column target list is emitted multi-line with each column on its own line and - // its comma trailing the column. - var sql = "INSERT INTO t (a, b, c) VALUES (1, 2, 3);"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Trailing, - MultilineInsertTargetsList = true - }); - generator.GenerateScript(fragment, out var generated); - - string expected = - "INSERT INTO t (" + Environment.NewLine + - " a," + Environment.NewLine + - " b," + Environment.NewLine + - " c" + Environment.NewLine + - ")" + Environment.NewLine + - "VALUES (1, 2, 3);" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingInsertSources() - { - // The INSERT source (VALUES) row list is a multi-line comma-separated list, so - // CommaPlacement = Leading places each continuation row's comma at the start of its - // line. (The rows are not aligned under the first row: this list is emitted via the - // newline comma-list path, so continuation rows begin at column 0.) The target column - // list is emitted multi-line because MultilineInsertTargetsList is explicitly enabled - // (it is no longer the default). - var sql = "INSERT INTO t (a, b, c) VALUES (1, 2, 3), (4, 5, 6), (7, 8, 9);"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading, - MultilineInsertSourcesList = true, - MultilineInsertTargetsList = true - }); - generator.GenerateScript(fragment, out var generated); - - string expected = - "INSERT INTO t (" + Environment.NewLine + - " a" + Environment.NewLine + - " , b" + Environment.NewLine + - " , c" + Environment.NewLine + - ")" + Environment.NewLine + - "VALUES (1, 2, 3)" + Environment.NewLine + - ", (4, 5, 6)" + Environment.NewLine + - ", (7, 8, 9);" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementTrailingInsertSources() - { - // The INSERT source (VALUES) row list with CommaPlacement = Trailing (default): - // each row's comma follows it at the end of the line. The target column list stays - // on a single line because MultilineInsertTargetsList is left at its default (false), - // exercising the common case of enabling only the source list. - var sql = "INSERT INTO t (a, b, c) VALUES (1, 2, 3), (4, 5, 6), (7, 8, 9);"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Trailing, - MultilineInsertSourcesList = true - }); - generator.GenerateScript(fragment, out var generated); - - string expected = - "INSERT INTO t (a, b, c)" + Environment.NewLine + - "VALUES (1, 2, 3)," + Environment.NewLine + - "(4, 5, 6)," + Environment.NewLine + - "(7, 8, 9);" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingViewColumns() - { - // The CREATE VIEW column list is a parenthesized, indented list (like CREATE TABLE): - // with CommaPlacement = Leading the columns stay at the list indentation level (4) - // and each comma is indented two characters fewer (column 2). The SELECT list in the - // view body is keyword-aligned, so its leading commas sit two columns before the - // aligned expression column. - var sql = "CREATE VIEW v (a, b, c) AS SELECT 1, 2, 3;"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading, - MultilineViewColumnsList = true - }); - generator.GenerateScript(fragment, out var generated); - - string expected = - "CREATE VIEW v (" + Environment.NewLine + - " a" + Environment.NewLine + - " , b" + Environment.NewLine + - " , c" + Environment.NewLine + - ")" + Environment.NewLine + - "AS" + Environment.NewLine + - "SELECT 1" + Environment.NewLine + - " , 2" + Environment.NewLine + - " , 3;" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementTrailingViewColumns() - { - // Trailing comma style (default): the CREATE VIEW column list keeps each comma on the - // element's line at the list indentation level (4), and the SELECT list in the view - // body places each comma at the end of its line. - var sql = "CREATE VIEW v (a, b, c) AS SELECT 1, 2, 3;"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Trailing, - MultilineViewColumnsList = true - }); - generator.GenerateScript(fragment, out var generated); - - string expected = - "CREATE VIEW v (" + Environment.NewLine + - " a," + Environment.NewLine + - " b," + Environment.NewLine + - " c" + Environment.NewLine + - ")" + Environment.NewLine + - "AS" + Environment.NewLine + - "SELECT 1," + Environment.NewLine + - " 2," + Environment.NewLine + - " 3;" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingSetClauseItems() - { - // The UPDATE SET item list is keyword-aligned: with CommaPlacement = Leading the items - // stay aligned with the first item and each comma is placed two columns before them - // (the '=' signs remain aligned via a separate alignment point), matching the SELECT - // list behavior. - var sql = "UPDATE t SET a = 1, b = 2, c = 3;"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading, - MultilineSetClauseItems = true - }); - generator.GenerateScript(fragment, out var generated); - - string expected = - "UPDATE t" + Environment.NewLine + - "SET a = 1" + Environment.NewLine + - " , b = 2" + Environment.NewLine + - " , c = 3;" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementTrailingSetClauseItems() - { - // Trailing comma style (default): the UPDATE SET items stay aligned with the first - // item and each comma follows the item at the end of its line. - var sql = "UPDATE t SET a = 1, b = 2, c = 3;"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Trailing, - MultilineSetClauseItems = true - }); - generator.GenerateScript(fragment, out var generated); - - string expected = - "UPDATE t" + Environment.NewLine + - "SET a = 1," + Environment.NewLine + - " b = 2," + Environment.NewLine + - " c = 3;" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingSingleColumnHasNoComma() - { - // A list with a single element must never emit a comma regardless of placement. - var sql = "SELECT a FROM t;"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading - }); - generator.GenerateScript(fragment, out var generated); - - // A list with a single element must never emit a comma regardless of placement. - string expected = - "SELECT a" + Environment.NewLine + - "FROM t;" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingWithPreserveCommentsDoesNotAbsorbComma() - { - // Interaction rule (FeedbackTicket 3016816 "SQL Formatter reformats leading - // commas to invalid SQL"): with - // CommaPlacement = Leading and PreserveComments = true, a line comment trailing - // an element must not cause the following comma to land on the comment line - // (which would comment out the comma). The comma belongs on the next element's line. - var sql = - "SELECT col1, -- first column" + Environment.NewLine + - " col2, -- second column" + Environment.NewLine + - " col3" + Environment.NewLine + - "FROM t;"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading, - PreserveComments = true - }); - generator.GenerateScript(fragment, out var generated); - - // The full generated script: each line comment stays on its element's line, and the - // leading comma is placed at the start of the next element's line (before a column, - // never before a comment), so the comment is never commented out and the script - // reparses cleanly. - string expected = - "SELECT col1 -- first column" + Environment.NewLine + - " , col2 -- second column" + Environment.NewLine + - " , col3" + Environment.NewLine + - "FROM t;" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - // The generated script must reparse cleanly. - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementTrailingWithPreserveCommentsKeepsCommentsAfterComma() - { - // Counterpart to TestCommaPlacementLeadingWithPreserveCommentsDoesNotAbsorbComma - // (FeedbackTicket 3016816 "SQL Formatter reformats leading commas to invalid SQL"). With - // CommaPlacement = Trailing (default) and PreserveComments = true, each trailing comma - // stays on the element's line and its line comment follows the comma, so the generated - // script still reparses cleanly. - var sql = - "SELECT col1, -- first column" + Environment.NewLine + - " col2, -- second column" + Environment.NewLine + - " col3" + Environment.NewLine + - "FROM t;"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Trailing, - PreserveComments = true - }); - generator.GenerateScript(fragment, out var generated); - - // The full generated script: the trailing comma stays on the element's line and its - // line comment follows the comma on that same line, so the script reparses cleanly. - string expected = - "SELECT col1, -- first column" + Environment.NewLine + - " col2, -- second column" + Environment.NewLine + - " col3" + Environment.NewLine + - "FROM t;" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - // The generated script must reparse cleanly. - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - // ---- Interaction rule: CommaPlacement = Leading has no visual effect when the - // ---- corresponding Multiline* option is false (the list stays on a single line). - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingSelectListMultilineFalse() - { - // With MultilineSelectElementsList = false the SELECT list is emitted on a single - // line, so CommaPlacement = Leading has no visual effect (commas remain inline). - var sql = "SELECT a, b, c FROM t;"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading, - MultilineSelectElementsList = false - }); - generator.GenerateScript(fragment, out var generated); - - string expected = - "SELECT a, b, c" + Environment.NewLine + - "FROM t;" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingViewColumnsMultilineFalse() - { - // With MultilineViewColumnsList = false the VIEW column list is emitted as a single - // parenthesized line, so CommaPlacement = Leading has no visual effect there. (The - // SELECT body still honors leading placement via MultilineSelectElementsList, which - // defaults to true.) - var sql = "CREATE VIEW v (a, b, c) AS SELECT 1, 2, 3;"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading, - MultilineViewColumnsList = false, - MultilineSelectElementsList = false - }); - generator.GenerateScript(fragment, out var generated); - - string expected = - "CREATE VIEW v (a, b, c)" + Environment.NewLine + - "AS" + Environment.NewLine + - "SELECT 1, 2, 3;" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingSetClauseItemsMultilineFalse() - { - // With MultilineSetClauseItems = false the UPDATE SET item list is emitted on a - // single line, so CommaPlacement = Leading has no visual effect (commas remain - // inline). - var sql = "UPDATE t SET a = 1, b = 2, c = 3;"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading, - MultilineSetClauseItems = false - }); - generator.GenerateScript(fragment, out var generated); - - string expected = - "UPDATE t" + Environment.NewLine + - "SET a = 1, b = 2, c = 3;" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingInsertTargetsMultilineFalse() - { - // The INSERT target list is always emitted on a single line, so CommaPlacement = - // Leading has no visual effect regardless of MultilineInsertTargetsList. - var sql = "INSERT INTO t (a, b, c) VALUES (1, 2, 3);"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading, - MultilineInsertTargetsList = false - }); - generator.GenerateScript(fragment, out var generated); - - string expected = - "INSERT INTO t (a, b, c)" + Environment.NewLine + - "VALUES (1, 2, 3);" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingInsertSourcesMultilineFalse() - { - // The INSERT source (VALUES) row list is always emitted multi-line (the generator - // does not gate it on MultilineInsertSourcesList), so setting that option to false - // does NOT collapse it to one line: CommaPlacement = Leading still applies to the - // row separators. The target column list stays on a single line because - // MultilineInsertTargetsList is left at its default (false). - var sql = "INSERT INTO t (a, b, c) VALUES (1, 2, 3), (4, 5, 6), (7, 8, 9);"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading, - MultilineInsertSourcesList = false - }); - generator.GenerateScript(fragment, out var generated); - - string expected = - "INSERT INTO t (a, b, c)" + Environment.NewLine + - "VALUES (1, 2, 3)" + Environment.NewLine + - ", (4, 5, 6)" + Environment.NewLine + - ", (7, 8, 9);" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingParenthesizedListAlwaysMultiline() - { - // The CREATE TABLE column list has no single-line toggle: it is always emitted - // multi-line. There is therefore no "Multiline = false" state for it, so - // CommaPlacement = Leading always applies (comma at indent - 2). - var sql = "CREATE TABLE t (a INT, b INT, c INT);"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading - }); - generator.GenerateScript(fragment, out var generated); - - string expected = - "CREATE TABLE t (" + Environment.NewLine + - " a INT" + Environment.NewLine + - " , b INT" + Environment.NewLine + - " , c INT" + Environment.NewLine + - ");" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingIndentedOptionList() - { - // The WITH-parameter list of CREATE COLUMN MASTER KEY is generated through the indented - // multi-line comma-list path (GenerateCommaSeparatedList with insertNewLine and indent - // both true). With CommaPlacement = Leading the leading comma is emitted at the start - // of the next parameter's (indented) line. - var sql = "CREATE COLUMN MASTER KEY CMK1 WITH (KEY_STORE_PROVIDER_NAME = 'MSSQL_CERTIFICATE_STORE', KEY_PATH = 'some/path');"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading - }); - generator.GenerateScript(fragment, out var generated); - - // The continuation parameter's comma is emitted at the start of its (indented) line - // via the 4-arg GenerateCommaSeparatedList leading branch. The comma width is reserved - // inside the indentation, so the parameter stays aligned with the first parameter and - // the leading comma sits in the reserved columns before it. - string expected = - "CREATE COLUMN MASTER KEY CMK1" + Environment.NewLine + - "WITH (" + Environment.NewLine + - " KEY_STORE_PROVIDER_NAME = 'MSSQL_CERTIFICATE_STORE'" + Environment.NewLine + - " , KEY_PATH = 'some/path'" + Environment.NewLine + - ");" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingSetClauseItemsIndented() - { - // With IndentSetClause = true the SET keyword is indented; CommaPlacement = Leading - // still aligns the items and places each comma two columns before them. - var sql = "UPDATE t SET a = 1, b = 2, c = 3;"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading, - MultilineSetClauseItems = true, - IndentSetClause = true - }); - generator.GenerateScript(fragment, out var generated); - - string expected = - "UPDATE t" + Environment.NewLine + - " SET a = 1" + Environment.NewLine + - " , b = 2" + Environment.NewLine + - " , c = 3;" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingCreateTableWithComments() - { - // PreserveComments + CommaPlacement.Leading in an indented (CREATE TABLE) column list: - // each trailing line comment stays on its column's line and the leading comma is - // emitted at the start of the next column's line (comma at indent - 2). Verifies the - // comment/comma-skip fix in the indent-based leading path, not just the SELECT list. - var sql = - "CREATE TABLE t (a INT, -- first" + Environment.NewLine + - "b INT, -- second" + Environment.NewLine + - "c INT);"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading, - PreserveComments = true - }); - generator.GenerateScript(fragment, out var generated); - - string expected = - "CREATE TABLE t (" + Environment.NewLine + - " a INT -- first" + Environment.NewLine + - " , b INT -- second" + Environment.NewLine + - " , c INT" + Environment.NewLine + - ");" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingWithBlockCommentTrailingElement() - { - // PreserveComments + CommaPlacement.Leading with a block comment trailing an element. - // Block comments are emitted inline (a different path than the deferred '--' comments), - // so this confirms leading placement is not broken by an inline block comment. - var sql = "SELECT a /* note */, b, c FROM t;"; - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading, - PreserveComments = true - }); - generator.GenerateScript(fragment, out var generated); - - string expected = - "SELECT a /* note */" + Environment.NewLine + - " , b" + Environment.NewLine + - " , c" + Environment.NewLine + - "FROM t;" + Environment.NewLine + Environment.NewLine; - Assert.AreEqual(expected, generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingSpaceCountZero() - { - // LeadingCommaSpaceCount = 0: the comma occupies a single column (no trailing space). - // Items stay aligned with the first element; only the comma column shifts. - const string selectSql = "SELECT a, b, c FROM t;"; - const string tableSql = "CREATE TABLE t (a INT, b INT, c INT);"; - - // Keyword-aligned list (SELECT): comma ends immediately before the aligned column. - string expectedSelect = - "SELECT a" + Environment.NewLine + - " ,b" + Environment.NewLine + - " ,c" + Environment.NewLine + - "FROM t;" + Environment.NewLine + Environment.NewLine; - - // Indented list (CREATE TABLE): elements stay at indentation column 4; the comma is - // indented one column fewer (column 3) so the comma+item still occupy 4 columns. - string expectedTable = - "CREATE TABLE t (" + Environment.NewLine + - " a INT" + Environment.NewLine + - " ,b INT" + Environment.NewLine + - " ,c INT" + Environment.NewLine + - ");" + Environment.NewLine + Environment.NewLine; - - AssertLeadingCommaSpaceCount(0, selectSql, expectedSelect); - AssertLeadingCommaSpaceCount(0, tableSql, expectedTable); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingSpaceCountOne() - { - // LeadingCommaSpaceCount = 1 (the default): comma + one space, occupying 2 columns. - const string selectSql = "SELECT a, b, c FROM t;"; - const string tableSql = "CREATE TABLE t (a INT, b INT, c INT);"; - - string expectedSelect = - "SELECT a" + Environment.NewLine + - " , b" + Environment.NewLine + - " , c" + Environment.NewLine + - "FROM t;" + Environment.NewLine + Environment.NewLine; - - string expectedTable = - "CREATE TABLE t (" + Environment.NewLine + - " a INT" + Environment.NewLine + - " , b INT" + Environment.NewLine + - " , c INT" + Environment.NewLine + - ");" + Environment.NewLine + Environment.NewLine; - - AssertLeadingCommaSpaceCount(1, selectSql, expectedSelect); - AssertLeadingCommaSpaceCount(1, tableSql, expectedTable); - } - - [TestMethod] - [Priority(0)] - [SqlStudioTestCategory(Category.UnitTest)] - public void TestCommaPlacementLeadingSpaceCountTwo() - { - // LeadingCommaSpaceCount = 2: comma + two spaces, occupying 3 columns. - const string selectSql = "SELECT a, b, c FROM t;"; - const string tableSql = "CREATE TABLE t (a INT, b INT, c INT);"; - - string expectedSelect = - "SELECT a" + Environment.NewLine + - " , b" + Environment.NewLine + - " , c" + Environment.NewLine + - "FROM t;" + Environment.NewLine + Environment.NewLine; - - // Elements stay at indentation column 4; the comma is indented three columns fewer - // (column 1) so the comma plus its two trailing spaces still occupy 4 columns. - string expectedTable = - "CREATE TABLE t (" + Environment.NewLine + - " a INT" + Environment.NewLine + - " , b INT" + Environment.NewLine + - " , c INT" + Environment.NewLine + - ");" + Environment.NewLine + Environment.NewLine; - - AssertLeadingCommaSpaceCount(2, selectSql, expectedSelect); - AssertLeadingCommaSpaceCount(2, tableSql, expectedTable); - } - - // Generates the given SQL with CommaPlacement.Leading and the specified leading-comma space - // count, asserts the generated script matches the expectation, and that it reparses cleanly. - private static void AssertLeadingCommaSpaceCount(int spaceCount, string sql, string expected) - { - var parser = new TSql170Parser(true); - var fragment = parser.Parse(new StringReader(sql), out var errors); - Assert.AreEqual(0, errors.Count); - - var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions - { - CommaPlacement = CommaPlacement.Leading, - LeadingCommaSpaceCount = spaceCount - }); - generator.GenerateScript(fragment, out var generated); - - Assert.AreEqual(expected, generated, - "LeadingCommaSpaceCount=" + spaceCount + " produced unexpected output. Actual:\n" + generated); - - var reparser = new TSql170Parser(true); - reparser.Parse(new StringReader(generated), out var reErrors); - Assert.AreEqual(0, reErrors.Count, "Generated SQL must reparse. Actual:\n" + generated); - } - - #endregion } } diff --git a/Test/SqlDom/ScriptGenerator/WithOptionsListFormattingTests.cs b/Test/SqlDom/ScriptGenerator/WithOptionsListFormattingTests.cs new file mode 100644 index 00000000..0367c64a --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/WithOptionsListFormattingTests.cs @@ -0,0 +1,647 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SqlStudio.Tests.AssemblyTools.TestCategory; +using static SqlStudio.Tests.UTSqlScriptDom.ScriptGeneratorTestHelper; + +namespace SqlStudio.Tests.UTSqlScriptDom +{ + // Tests for the MultilineWithOptionsList script-generation option, which controls whether the + // options in a WITH clause (index options, table hints, BACKUP/RESTORE options) or an OPTION + // clause (query hints) are written on a single line (default) or one per line. Kept in a + // dedicated file to avoid churn in ScriptGeneratorTests.cs. + // + // Work item: Formatter option: WITH clause options width + [TestClass] + public class WithOptionsListFormattingTests + { + // Builds options that isolate the WITH/OPTION option-list layout: clause bodies are not + // aligned and clauses are not broken onto their own lines, so the surrounding statement stays + // compact and the expectations focus on the option list itself. + private static SqlScriptGeneratorOptions MakeOptions(bool multilineWithOptionsList) + { + return new SqlScriptGeneratorOptions + { + MultilineWithOptionsList = multilineWithOptionsList, + AlignClauseBodies = false, + NewLineBeforeFromClause = false, + NewLineBeforeWhereClause = false, + MultilineSelectElementsList = false, + MultilineWherePredicatesList = false, + }; + } + + // ----------------------------------------------------------------------------------------- + // Default + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineWithOptionsListDefaultIsFalse() + { + Assert.IsFalse(new SqlScriptGeneratorOptions().MultilineWithOptionsList); + } + + // ----------------------------------------------------------------------------------------- + // CREATE INDEX (parenthesized index options) + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultKeepsCreateIndexOptionsOnSingleLine() + { + const string input = "CREATE INDEX i1 ON t1 (c1) WITH (PAD_INDEX = ON, FILLFACTOR = 50);"; + var options = MakeOptions(false); + const string expected = +@" +CREATE INDEX i1 + ON t1(c1) WITH (PAD_INDEX = ON, FILLFACTOR = 50);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineCreateIndexOptionsTrailingComma() + { + const string input = "CREATE INDEX i1 ON t1 (c1) WITH (PAD_INDEX = ON, FILLFACTOR = 50);"; + var options = MakeOptions(true); + const string expected = +@" +CREATE INDEX i1 + ON t1(c1) WITH ( + PAD_INDEX = ON, + FILLFACTOR = 50 +);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineCreateIndexOptionsLeadingComma() + { + // CommaPlacement = Leading applies within the options list: each option sits at the list + // indentation level and the comma is indented two characters fewer. + const string input = "CREATE INDEX i1 ON t1 (c1) WITH (PAD_INDEX = ON, FILLFACTOR = 50);"; + var options = MakeOptions(true); + options.CommaPlacement = CommaPlacement.Leading; + const string expected = +@" +CREATE INDEX i1 + ON t1(c1) WITH ( + PAD_INDEX = ON + , FILLFACTOR = 50 +);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineCreateIndexOptionsNewLineBeforeOpenParenthesis() + { + // NewLineBeforeOpenParenthesisInMultilineList moves the open parenthesis onto its own line. + const string input = "CREATE INDEX i1 ON t1 (c1) WITH (PAD_INDEX = ON, FILLFACTOR = 50);"; + var options = MakeOptions(true); + options.NewLineBeforeOpenParenthesisInMultilineList = true; + const string expected = +@" +CREATE INDEX i1 + ON t1(c1) WITH +( + PAD_INDEX = ON, + FILLFACTOR = 50 +);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineCreateIndexOptionsCloseParenthesisOnLastItemLine() + { + // NewLineBeforeCloseParenthesisInMultilineList = false keeps the close parenthesis on the + // same line as the last option instead of on its own line. + const string input = "CREATE INDEX i1 ON t1 (c1) WITH (PAD_INDEX = ON, FILLFACTOR = 50);"; + var options = MakeOptions(true); + options.NewLineBeforeCloseParenthesisInMultilineList = false; + const string expected = +@" +CREATE INDEX i1 + ON t1(c1) WITH ( + PAD_INDEX = ON, + FILLFACTOR = 50);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineCreateIndexCommentBeforeCloseParenthesisForcesNewLine() + { + const string input = +@"CREATE INDEX i1 ON t1 (c1) WITH (PAD_INDEX = ON, FILLFACTOR = 50 -- last option +);"; + var options = MakeOptions(true); + options.NewLineBeforeCloseParenthesisInMultilineList = false; + options.PreserveComments = true; + const string expected = +@" +CREATE INDEX i1 + ON t1(c1) WITH ( + PAD_INDEX = ON, + FILLFACTOR = 50 -- last option +);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // ALTER INDEX ... REBUILD (index options WITHOUT a space before the parenthesis by default) + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultKeepsAlterIndexRebuildOptionsOnSingleLine() + { + // The default single-line ALTER INDEX ... REBUILD form emits WITH(...) with no space + // before the open parenthesis; enabling the option must not change the default output. + const string input = "ALTER INDEX i1 ON t1 REBUILD WITH (ONLINE = ON, MAXDOP = 2);"; + var options = MakeOptions(false); + const string expected = +@" +ALTER INDEX i1 + ON t1 REBUILD WITH(ONLINE = ON, MAXDOP = 2);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineAlterIndexRebuildOptions() + { + const string input = "ALTER INDEX i1 ON t1 REBUILD WITH (ONLINE = ON, MAXDOP = 2);"; + var options = MakeOptions(true); + const string expected = +@" +ALTER INDEX i1 + ON t1 REBUILD WITH ( + ONLINE = ON, + MAXDOP = 2 +);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // ALTER TABLE ... REBUILD (index options) + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineAlterTableRebuildOptions() + { + const string input = "ALTER TABLE t1 REBUILD WITH (PAD_INDEX = ON, FILLFACTOR = 50);"; + var options = MakeOptions(true); + const string expected = +@" +ALTER TABLE t1 REBUILD WITH ( + PAD_INDEX = ON, + FILLFACTOR = 50 +);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Table hints (WITH (...)) + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultKeepsTableHintsOnSingleLine() + { + const string input = "SELECT * FROM t1 WITH (NOLOCK, INDEX (i1));"; + var options = MakeOptions(false); + const string expected = "SELECT * FROM t1 WITH (NOLOCK, INDEX (i1));"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineTableHints() + { + // The table hints align under the table reference, with each hint indented one level past + // that alignment point. + const string input = "SELECT * FROM t1 WITH (NOLOCK, INDEX (i1));"; + var options = MakeOptions(true); + const string expected = +@" +SELECT * FROM t1 WITH ( + NOLOCK, + INDEX (i1) + );"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineTableHintsLeadingComma() + { + const string input = "SELECT * FROM t1 WITH (NOLOCK, INDEX (i1));"; + var options = MakeOptions(true); + options.CommaPlacement = CommaPlacement.Leading; + const string expected = +@" +SELECT * FROM t1 WITH ( + NOLOCK + , INDEX (i1) + );"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Query hints (OPTION (...)) + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultKeepsQueryHintsOnSingleLine() + { + const string input = "SELECT * FROM t1 OPTION (RECOMPILE, MAXDOP 2);"; + var options = MakeOptions(false); + const string expected = +@" +SELECT * FROM t1 +OPTION (RECOMPILE, MAXDOP 2);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineQueryHints() + { + const string input = "SELECT * FROM t1 OPTION (RECOMPILE, MAXDOP 2);"; + var options = MakeOptions(true); + const string expected = +@" +SELECT * FROM t1 +OPTION ( + RECOMPILE, + MAXDOP 2 +);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineQueryHintsLeadingComma() + { + const string input = "SELECT * FROM t1 OPTION (RECOMPILE, MAXDOP 2);"; + var options = MakeOptions(true); + options.CommaPlacement = CommaPlacement.Leading; + const string expected = +@" +SELECT * FROM t1 +OPTION ( + RECOMPILE + , MAXDOP 2 +);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // BACKUP (non-parenthesized WITH options) + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultKeepsBackupOptionsOnSingleLine() + { + const string input = "BACKUP DATABASE d1 TO DISK = 'd:' WITH BLOCKSIZE = 10, CHECKSUM;"; + var options = MakeOptions(false); + const string expected = +@" +BACKUP DATABASE d1 + TO DISK = 'd:' + WITH BLOCKSIZE = 10, CHECKSUM;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineBackupOptions() + { + // Each option is written on its own line, indented one level from the statement keyword + // (aligned beneath the WITH keyword). + const string input = "BACKUP DATABASE d1 TO DISK = 'd:' WITH BLOCKSIZE = 10, CHECKSUM;"; + var options = MakeOptions(true); + const string expected = +@" +BACKUP DATABASE d1 + TO DISK = 'd:' + WITH + BLOCKSIZE = 10, + CHECKSUM;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineBackupOptionsLeadingComma() + { + const string input = "BACKUP DATABASE d1 TO DISK = 'd:' WITH BLOCKSIZE = 10, CHECKSUM;"; + var options = MakeOptions(true); + options.CommaPlacement = CommaPlacement.Leading; + const string expected = +@" +BACKUP DATABASE d1 + TO DISK = 'd:' + WITH + BLOCKSIZE = 10 + , CHECKSUM;"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // RESTORE (non-parenthesized WITH options) + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultKeepsRestoreOptionsOnSingleLine() + { + const string input = "RESTORE DATABASE db1 FROM DISK = 'z:' WITH REPLACE, RECOVERY;"; + var options = MakeOptions(false); + const string expected = +@" +RESTORE DATABASE db1 FROM DISK = 'z:' + WITH REPLACE, RECOVERY;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineRestoreOptions() + { + const string input = "RESTORE DATABASE db1 FROM DISK = 'z:' WITH REPLACE, RECOVERY;"; + var options = MakeOptions(true); + const string expected = +@" +RESTORE DATABASE db1 FROM DISK = 'z:' + WITH + REPLACE, + RECOVERY;"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // CREATE SELECTIVE XML INDEX (trailing WITH index options) + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineSelectiveXmlIndexOptions() + { + const string input = + "CREATE SELECTIVE XML INDEX sxi1 ON t1 (c1) " + + "FOR (path1 = '/a/b/c' AS SQL NVARCHAR(50)) " + + "WITH (DROP_EXISTING = ON, FILLFACTOR = 2);"; + var options = MakeOptions(true); + const string expected = +@" +CREATE SELECTIVE XML INDEX sxi1 ON t1(c1) +FOR( path1 = '/a/b/c' AS SQL NVARCHAR (50) +) +WITH ( + DROP_EXISTING = ON, + FILLFACTOR = 2 +);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Indentation: when IndentationMode is Tabs, the multiline option list is indented with tab + // characters rather than spaces. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineCreateIndexOptionsWithTabIndentation() + { + const string input = "CREATE INDEX i1 ON t1 (c1) WITH (PAD_INDEX = ON, FILLFACTOR = 50);"; + var options = MakeOptions(true); + options.IndentationMode = IndentationMode.Tabs; + // Explicit \t escapes (regular string, not verbatim) so the tab alignment characters are + // visible in source rather than hidden inside the literal. + const string expected = + "CREATE INDEX i1\n" + + "\tON t1(c1) WITH (\n" + + "\tPAD_INDEX = ON,\n" + + "\tFILLFACTOR = 50\n" + + ");"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Real-world: a production online index build whose long WITH option list is exactly the case + // this formatter option targets. One option per line makes the options readable and diffable. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineProductionOnlineIndexBuild() + { + const string input = + "CREATE NONCLUSTERED INDEX IX_SalesOrderHeader_CustomerID " + + "ON Sales.SalesOrderHeader (CustomerID) " + + "INCLUDE (OrderDate, TotalDue) " + + "WITH (PAD_INDEX = ON, FILLFACTOR = 80, ONLINE = ON, DATA_COMPRESSION = PAGE);"; + var options = MakeOptions(true); + const string expected = +@" +CREATE NONCLUSTERED INDEX IX_SalesOrderHeader_CustomerID + ON Sales.SalesOrderHeader(CustomerID) + INCLUDE(OrderDate, TotalDue) WITH ( + PAD_INDEX = ON, + FILLFACTOR = 80, + ONLINE = ON, + DATA_COMPRESSION = PAGE +);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Shared GenerateIndexOptions entry points: inline index definitions and UNIQUE constraints + // in CREATE TABLE, and ALTER TABLE ... ALTER INDEX, all funnel through the same virtual + // GenerateIndexOptions method as CREATE INDEX, so the option applies to them too. These + // guard that the shared path stays multiline through those distinct entry points. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineInlineIndexDefinitionOptions() + { + const string input = + "CREATE TABLE t1 (c1 INT, INDEX ix1 NONCLUSTERED (c1) WITH (PAD_INDEX = ON, FILLFACTOR = 50));"; + var options = MakeOptions(true); + const string expected = +@" +CREATE TABLE t1 ( + c1 INT, + INDEX ix1 NONCLUSTERED (c1) WITH ( + PAD_INDEX = ON, + FILLFACTOR = 50 +) +);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineUniqueConstraintOptions() + { + const string input = + "CREATE TABLE t1 (c1 INT, CONSTRAINT uq1 UNIQUE (c1) WITH (PAD_INDEX = ON, FILLFACTOR = 50));"; + var options = MakeOptions(true); + const string expected = +@" +CREATE TABLE t1 ( + c1 INT, + CONSTRAINT uq1 UNIQUE (c1) WITH ( + PAD_INDEX = ON, + FILLFACTOR = 50 +) +);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultKeepsAlterTableAlterIndexOptionsOnSingleLine() + { + // Guards the default (option off) rendering through the shared GenerateIndexOptions + // virtual chain for the ALTER TABLE ... ALTER INDEX entry point: WITH ( with a space. + const string input = + "ALTER TABLE t1 ALTER INDEX i1 REBUILD WITH (BUCKET_COUNT = 1);"; + var options = MakeOptions(false); + const string expected = +@" +ALTER TABLE t1 ALTER INDEX i1 REBUILD WITH (BUCKET_COUNT = 1);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineAlterTableAlterIndexOptions() + { + const string input = + "ALTER TABLE t1 ALTER INDEX i1 REBUILD WITH (BUCKET_COUNT = 1);"; + var options = MakeOptions(true); + const string expected = +@" +ALTER TABLE t1 ALTER INDEX i1 REBUILD WITH ( + BUCKET_COUNT = 1 +);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Interaction: combining MultilineWithOptionsList with another multiline layout option + // (MultilineSelectElementsList) must format both lists independently without interfering. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineWithOptionsListCombinedWithMultilineSelectElements() + { + const string input = + "SELECT c1, c2 FROM t1 OPTION (RECOMPILE, MAXDOP 2);"; + var options = MakeOptions(true); + options.MultilineSelectElementsList = true; + const string expected = +@" +SELECT c1, + c2 FROM t1 +OPTION ( + RECOMPILE, + MAXDOP 2 +);"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Scope: the option must NOT affect the WITH keyword of a common table expression. + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultilineDoesNotAffectCommonTableExpression() + { + const string input = "WITH cte AS (SELECT 1 AS c) SELECT * FROM cte;"; + var options = MakeOptions(true); + const string expected = +@" +WITH cte +AS (SELECT 1 AS c) +SELECT * FROM cte;"; + + AssertGenerated(input, options, expected); + } + } +} diff --git a/Test/SqlDom/SparseMaskedColumnOrderRegressionTests.cs b/Test/SqlDom/SparseMaskedColumnOrderRegressionTests.cs new file mode 100644 index 00000000..349a1fd0 --- /dev/null +++ b/Test/SqlDom/SparseMaskedColumnOrderRegressionTests.cs @@ -0,0 +1,139 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SqlStudio.Tests.AssemblyTools.TestCategory; +using System.Collections.Generic; +using System.IO; + +namespace SqlStudio.Tests.UTSqlScriptDom +{ + public partial class SqlDomTests + { + /// + /// Regression test for https://github.com/microsoft/SqlScriptDOM/issues/216 + /// The Microsoft-documented column-definition order (SPARSE before MASKED WITH) + /// must parse cleanly, matching the already-working reversed order. + /// + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void SparseThenMaskedColumnOrderParses() + { + ParserTestUtils.ExecuteTestForParsers(parser => + { + string script = @"CREATE TABLE t (c varchar(100) COLLATE SQL_Latin1_General_CP1_CI_AS SPARSE MASKED WITH (FUNCTION = 'default()') NULL);"; + using (var scriptReader = new StringReader(script)) + { + parser.Parse(scriptReader, out IList errors); + Assert.AreEqual(0, errors.Count); + } + }, new TSql130Parser(true), new TSql140Parser(true), new TSql150Parser(true), new TSql160Parser(true), new TSql170Parser(true), new TSql180Parser(true), new TSqlFabricDWParser(true)); + } + + /// + /// Regression test for https://github.com/microsoft/SqlScriptDOM/issues/216 + /// Minimal reduction (no COLLATE) of the documented SPARSE-before-MASKED order. + /// + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void SparseThenMaskedColumnOrderWithoutCollateParses() + { + ParserTestUtils.ExecuteTestForParsers(parser => + { + string script = @"CREATE TABLE t (c varchar(100) SPARSE MASKED WITH (FUNCTION = 'default()') NULL);"; + using (var scriptReader = new StringReader(script)) + { + parser.Parse(scriptReader, out IList errors); + Assert.AreEqual(0, errors.Count); + } + }, new TSql130Parser(true), new TSql140Parser(true), new TSql150Parser(true), new TSql160Parser(true), new TSql170Parser(true), new TSql180Parser(true), new TSqlFabricDWParser(true)); + } + + /// + /// Regression guard for https://github.com/microsoft/SqlScriptDOM/issues/216 + /// The reversed (non-standard) MASKED-before-SPARSE order already parses today and + /// must keep parsing after the fix. + /// + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void MaskedThenSparseColumnOrderParses() + { + ParserTestUtils.ExecuteTestForParsers(parser => + { + string script = @"CREATE TABLE t (c varchar(100) MASKED WITH (FUNCTION = 'default()') SPARSE NULL);"; + using (var scriptReader = new StringReader(script)) + { + parser.Parse(scriptReader, out IList errors); + Assert.AreEqual(0, errors.Count); + } + }, new TSql130Parser(true), new TSql140Parser(true), new TSql150Parser(true), new TSql160Parser(true), new TSql170Parser(true), new TSql180Parser(true), new TSqlFabricDWParser(true)); + } + + /// + /// Round-trip guard for https://github.com/microsoft/SqlScriptDOM/issues/216 + /// Both orderings normalize to the documented SPARSE-before-MASKED order through the + /// script generator, and the generated script re-parses cleanly. + /// + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void SparseMaskedColumnOrderRoundTrips() + { + foreach (string script in new[] + { + @"CREATE TABLE t (c varchar(100) SPARSE MASKED WITH (FUNCTION = 'default()') NULL);", + @"CREATE TABLE t (c varchar(100) MASKED WITH (FUNCTION = 'default()') SPARSE NULL);" + }) + { + var parser = new TSql160Parser(true); + TSqlFragment fragment; + using (var scriptReader = new StringReader(script)) + { + fragment = parser.Parse(scriptReader, out IList errors); + Assert.AreEqual(0, errors.Count); + } + + SqlScriptGenerator scriptGen = ParserTestUtils.CreateScriptGen(SqlVersion.Sql160); + scriptGen.GenerateScript(fragment, out string generated); + + // Generator always emits storage options (SPARSE) before MASKED WITH. + Assert.IsTrue(generated.IndexOf("SPARSE", System.StringComparison.Ordinal) < + generated.IndexOf("MASKED", System.StringComparison.Ordinal)); + + using (var generatedReader = new StringReader(generated)) + { + new TSql160Parser(true).Parse(generatedReader, out IList reparseErrors); + Assert.AreEqual(0, reparseErrors.Count); + } + } + } + + /// + /// Negative guard for https://github.com/microsoft/SqlScriptDOM/issues/216 + /// SPARSE predates SQL Server 2008, so TSql80/TSql90 must still reject it — the fix + /// must not accidentally enable SPARSE (or MASKED) on those versions. + /// + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void SparseMaskedColumnOrderRejectedBefore100() + { + ParserTestUtils.ExecuteTestForParsers(parser => + { + string script = @"CREATE TABLE t (c varchar(100) SPARSE MASKED WITH (FUNCTION = 'default()') NULL);"; + using (var scriptReader = new StringReader(script)) + { + parser.Parse(scriptReader, out IList errors); + Assert.IsTrue(errors.Count > 0); + } + }, new TSql80Parser(true), new TSql90Parser(true)); + } + } +} diff --git a/Test/SqlDom/TestScripts/CreateDatabaseCollateBeforeEditionTests120.sql b/Test/SqlDom/TestScripts/CreateDatabaseCollateBeforeEditionTests120.sql new file mode 100644 index 00000000..91e73061 --- /dev/null +++ b/Test/SqlDom/TestScripts/CreateDatabaseCollateBeforeEditionTests120.sql @@ -0,0 +1,6 @@ +CREATE DATABASE [db1] COLLATE SQL_Latin1_General_CP1_CI_AS (EDITION = 'Standard') +GO +CREATE DATABASE hito COLLATE Japanese_Bushu_Kakusu_100_CS_AS_KS_WS (MAXSIZE = 500 MB, EDITION = 'GeneralPurpose', SERVICE_OBJECTIVE = 'GP_Gen5_8') +GO +CREATE DATABASE d1 (EDITION = 'business', SERVICE_OBJECTIVE = 'shared') COLLATE SQL_Latin1_General_CP1_CI_AS +GO diff --git a/Test/SqlDom/TestScripts/CreateDatabaseCollateBeforeEditionTestsFabricDW.sql b/Test/SqlDom/TestScripts/CreateDatabaseCollateBeforeEditionTestsFabricDW.sql new file mode 100644 index 00000000..91e73061 --- /dev/null +++ b/Test/SqlDom/TestScripts/CreateDatabaseCollateBeforeEditionTestsFabricDW.sql @@ -0,0 +1,6 @@ +CREATE DATABASE [db1] COLLATE SQL_Latin1_General_CP1_CI_AS (EDITION = 'Standard') +GO +CREATE DATABASE hito COLLATE Japanese_Bushu_Kakusu_100_CS_AS_KS_WS (MAXSIZE = 500 MB, EDITION = 'GeneralPurpose', SERVICE_OBJECTIVE = 'GP_Gen5_8') +GO +CREATE DATABASE d1 (EDITION = 'business', SERVICE_OBJECTIVE = 'shared') COLLATE SQL_Latin1_General_CP1_CI_AS +GO diff --git a/Test/SqlDom/TestScripts/CreateTableTests130.sql b/Test/SqlDom/TestScripts/CreateTableTests130.sql index 5ebb933a..f82a4331 100644 --- a/Test/SqlDom/TestScripts/CreateTableTests130.sql +++ b/Test/SqlDom/TestScripts/CreateTableTests130.sql @@ -395,6 +395,15 @@ CREATE TABLE t ( ); GO +-- Combines SPARSE and MASKED WITH on the same column, in both the documented +-- (SPARSE before MASKED) and reversed orders. See GitHub issue #216. +-- +CREATE TABLE t ( + COL0 VARCHAR(100) COLLATE SQL_Latin1_General_CP1_CI_AS SPARSE MASKED WITH (FUNCTION = 'default()') NULL, + COL1 VARCHAR(100) MASKED WITH (FUNCTION = 'default()') SPARSE NULL +); +GO + CREATE TABLE t ( COL0 INT NOT NULL, COL1 VARCHAR (20), diff --git a/Test/SqlDom/TestScripts/ModernGroupByAllTestsFabricDW.sql b/Test/SqlDom/TestScripts/ModernGroupByAllTestsFabricDW.sql new file mode 100644 index 00000000..5acd429b --- /dev/null +++ b/Test/SqlDom/TestScripts/ModernGroupByAllTestsFabricDW.sql @@ -0,0 +1,71 @@ +SELECT City, + Region, + COUNT(*) AS NumEmps +FROM dbo.Employees +GROUP BY ALL; +GO + +SELECT City, + COUNT(*) AS NumEmps +FROM dbo.Employees +WHERE HireDate >= '19930101' +GROUP BY ALL +HAVING COUNT(*) > 5 +ORDER BY City; +GO + +SELECT Region, + YEAR(OrderDate) AS OrderYear, + Category, + COUNT(*) AS NumOrders, + SUM(Amount) AS Total +FROM Sales +WHERE Amount > 0 +GROUP BY ALL; +GO + +SELECT c.CustomerName, + COUNT(*) AS Orders +FROM Orders AS o + INNER JOIN + Customers AS c + ON o.CustomerId = c.CustomerId +GROUP BY ALL; +GO + +SELECT City, + COUNT(*) AS NumEmps +FROM dbo.Employees +GROUP BY ALL +ORDER BY City DESC; +GO + +SELECT * +FROM (SELECT City, + COUNT(*) AS Cnt + FROM dbo.Employees + GROUP BY ALL) AS t; +GO + +SELECT Region, + SUM(Amount) / COUNT(DISTINCT CustomerId) AS AvgSpend +FROM Sales +GROUP BY ALL; +GO + +CREATE VIEW v_GroupByAllView +AS +SELECT City, + COUNT(*) AS NumEmps +FROM dbo.Employees +GROUP BY ALL; +GO + +CREATE PROCEDURE usp_GroupByAll +AS +BEGIN + SELECT City, + COUNT(*) AS NumEmps + FROM dbo.Employees + GROUP BY ALL; +END diff --git a/Test/SqlDom/TestScripts/OrderByAllTestsFabricDW.sql b/Test/SqlDom/TestScripts/OrderByAllTestsFabricDW.sql new file mode 100644 index 00000000..89b452ff --- /dev/null +++ b/Test/SqlDom/TestScripts/OrderByAllTestsFabricDW.sql @@ -0,0 +1,95 @@ +SELECT c1, + c2 +FROM t1 +ORDER BY ALL; +GO + +SELECT c1, + c2 +FROM t1 +ORDER BY ALL ASC; +GO + +SELECT c1, + c2 +FROM t1 +ORDER BY ALL DESC; +GO + +SELECT * +FROM t1 +ORDER BY ALL; +GO + +SELECT c1, + c2 +FROM t1 +ORDER BY ALL +OFFSET 2 ROWS FETCH NEXT 5 ROWS ONLY; +GO + +SELECT c1 +FROM t1 +UNION ALL +SELECT c1 +FROM t2 +ORDER BY ALL; +GO + +SELECT * +FROM (SELECT TOP 5 c1, + c2 + FROM t1 + ORDER BY ALL) AS sub; +GO + +SELECT c1, + COUNT(*) AS Cnt +FROM t1 +GROUP BY c1 +ORDER BY ALL; +GO + +SELECT c1, + COUNT(*) AS Cnt +FROM t1 +GROUP BY c1 +HAVING COUNT(*) > 1 +ORDER BY ALL DESC; +GO + +SELECT a.c1, + b.c2 +FROM t1 AS a + INNER JOIN + t2 AS b + ON a.c1 = b.c1 +WHERE b.c2 > 0 +ORDER BY ALL; +GO + +SELECT c1, + COUNT(*) AS Cnt, + SUM(COUNT(*)) OVER (ORDER BY c1 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS RunningCnt +FROM t1 +GROUP BY c1 +ORDER BY ALL; +GO + +CREATE VIEW v_OrderByAllView +AS +SELECT c1, + c2 +FROM t1 +ORDER BY ALL +OFFSET 0 ROWS; +GO + +CREATE PROCEDURE usp_OrderByAll +AS +BEGIN + SELECT c1, + c2 + FROM t1 + ORDER BY ALL; +END \ No newline at end of file diff --git a/Test/SqlDom/TestScripts/SelectStatementTests.sql b/Test/SqlDom/TestScripts/SelectStatementTests.sql index 93b06f1d..2ed1979e 100644 --- a/Test/SqlDom/TestScripts/SelectStatementTests.sql +++ b/Test/SqlDom/TestScripts/SelectStatementTests.sql @@ -53,6 +53,8 @@ GO Select * from t1 group by c1; -- testing all Select * from t1 group by all c1; +-- testing all with multiple columns +Select * from t1 group by all c1, c2, c3; -- testing multiple expressions select * from t1 group by c1, c2, c3 -- testing with diff --git a/release-notes/180/180.102.0.md b/release-notes/180/180.102.0.md new file mode 100644 index 00000000..89fe2f46 --- /dev/null +++ b/release-notes/180/180.102.0.md @@ -0,0 +1,36 @@ +# Release Notes + +## Microsoft.SqlServer.TransactSql.ScriptDom 180.102.0 +This update brings the following changes over the previous release: + +### Target Platform Support + +* .NET Framework 4.7.2 (Windows x86, Windows x64) +* .NET 8 (Windows x86, Windows x64, Linux, macOS) +* .NET Standard 2.0+ (Windows x86, Windows x64, Linux, macOS) + +### Dependencies +* None + +#### .NET Framework +#### .NET Core + +### New Features +* Adds support for GROUP BY ALL and ORDER BY ALL in the Fabric DW parser. +* Adds the BuiltInFunctionCasing script generation option for controlling the casing of built-in function names. +* Adds the MultilineWithOptionsList script generation option for formatting WITH and OPTION clause options on separate lines. +* Adds the MultilineGroupByElementsList, MultilineHavingPredicatesList, MultilineOrderByElementsList, and MultilinePartitionByElementsList script generation options. +* Adds the NumNewlinesAfterBatchStatement and NumNewlinesAfterBatches script generation options. +* Adds the PersistTrailingGo script generation option for preserving trailing GO batch separators. +* Adds the TerminateBlockStatements script generation option for emitting semicolons after BEGIN...END and TRY...CATCH blocks. + +### Fixed +* Fixes INSERT VALUES formatting so row constructor parentheses align with the target column list. +* Fixes CREATE DATABASE parsing when COLLATE appears before Azure edition options. +* Fixes CREATE TABLE parsing when SPARSE appears before MASKED WITH in a column definition [#216](https://github.com/microsoft/SqlScriptDOM/issues/216). + +### Changes +* None + +### Known Issues +* None \ No newline at end of file