diff --git a/Packages.props b/Packages.props index d65ed09c..b936dfcf 100644 --- a/Packages.props +++ b/Packages.props @@ -22,5 +22,6 @@ + - \ No newline at end of file + diff --git a/SqlScriptDom/ScriptDom/SqlServer/ColumnAliasStyle.cs b/SqlScriptDom/ScriptDom/SqlServer/ColumnAliasStyle.cs new file mode 100644 index 00000000..6fc6a3d3 --- /dev/null +++ b/SqlScriptDom/ScriptDom/SqlServer/ColumnAliasStyle.cs @@ -0,0 +1,33 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.SqlServer.TransactSql.ScriptDom +{ + /// + /// Represents the possible ways of rendering column aliases in SELECT projections + /// + public enum ColumnAliasStyle + { + /// + /// Render column aliases using the AS keyword: expression AS alias + /// + AsKeyword, + + /// + /// Render column aliases using the equals sign: alias = expression + /// + EqualsSign, + + /// + /// Preserve the column alias style from the original script without converting between styles + /// + Preserve + } +} diff --git a/SqlScriptDom/ScriptDom/SqlServer/CommaPlacement.cs b/SqlScriptDom/ScriptDom/SqlServer/CommaPlacement.cs new file mode 100644 index 00000000..1a053792 --- /dev/null +++ b/SqlScriptDom/ScriptDom/SqlServer/CommaPlacement.cs @@ -0,0 +1,28 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using System; + +namespace Microsoft.SqlServer.TransactSql.ScriptDom +{ + /// + /// Represents the placement of commas when a comma-separated list is written on multiple lines. + /// + public enum CommaPlacement + { + /// + /// The comma is placed at the end of the line, after the item that precedes it. + /// + Trailing, + + /// + /// The comma is placed at the start of the line, before the item that follows it. + /// When this value is used, the setting is ignored and + /// indentation always uses spaces. + /// + Leading + } +} diff --git a/SqlScriptDom/ScriptDom/SqlServer/IdentifierBracketing.cs b/SqlScriptDom/ScriptDom/SqlServer/IdentifierBracketing.cs new file mode 100644 index 00000000..0aa4cdfc --- /dev/null +++ b/SqlScriptDom/ScriptDom/SqlServer/IdentifierBracketing.cs @@ -0,0 +1,40 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.SqlServer.TransactSql.ScriptDom +{ + /// + /// Represents the possible ways of controlling square bracket usage around object + /// identifiers during script generation. + /// + public enum IdentifierBracketing + { + /// + /// Preserve the original bracketing/quoting of identifiers + /// + Preserve, + + /// + /// Wrap all object identifiers in square brackets + /// + IncludeBrackets, + + /// + /// Remove brackets from identifiers that do not require them. Identifiers that + /// conflict with reserved words or contain special characters retain their brackets. + /// This is a best-effort transformation: in rare cases an identifier lexes as an ordinary + /// identifier yet its brackets are semantically required by the surrounding syntax (for + /// example a schema-qualified name that collides with a special relational operator such as + /// AI_GENERATE_CHUNKS). Such brackets may be removed, so ExcludeBrackets is not guaranteed to + /// preserve semantics for every possible input. + /// + ExcludeBrackets + } +} diff --git a/SqlScriptDom/ScriptDom/SqlServer/IdentifierCasing.cs b/SqlScriptDom/ScriptDom/SqlServer/IdentifierCasing.cs new file mode 100644 index 00000000..bce93ada --- /dev/null +++ b/SqlScriptDom/ScriptDom/SqlServer/IdentifierCasing.cs @@ -0,0 +1,39 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.SqlServer.TransactSql.ScriptDom +{ + /// + /// Represents the possible ways of casing object identifiers during script generation. + /// This does not apply to keywords, string literals, function names, or variables. + /// + public enum IdentifierCasing + { + /// + /// Preserve the original casing of identifiers + /// + Preserve, + + /// + /// All letters in upper case + /// + Uppercase, + + /// + /// All letters in lower case + /// + Lowercase, + + /// + /// First letter of the identifier capitalized, all remaining letters lower case + /// + PascalCase + } +} diff --git a/SqlScriptDom/ScriptDom/SqlServer/IndentationMode.cs b/SqlScriptDom/ScriptDom/SqlServer/IndentationMode.cs new file mode 100644 index 00000000..20e4932b --- /dev/null +++ b/SqlScriptDom/ScriptDom/SqlServer/IndentationMode.cs @@ -0,0 +1,33 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using System; + +namespace Microsoft.SqlServer.TransactSql.ScriptDom +{ + /// + /// Represents the character used to indent generated script. + /// + public enum IndentationMode + { + /// + /// Each indentation level inserts IndentationSize space characters. + /// + Spaces, + + /// + /// Leading whitespace and the aligned gaps between tokens (for example a clause keyword and its + /// body, or column-definition fields) are written using only tab characters, rounding each + /// aligned column up to the next tab stop. Because those tab stops assume each tab is + /// columns wide, the output only appears + /// aligned when the viewer's tab width (for example the editor's tab size) is set to the same + /// value as . This mode is ignored + /// (indentation uses spaces) when is + /// . + /// + Tabs + } +} diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptGeneratorSupporter.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptGeneratorSupporter.cs index 9e9abe0e..d766f43a 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptGeneratorSupporter.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptGeneratorSupporter.cs @@ -63,6 +63,39 @@ public static string GetCasedString(string str, KeywordCasing casing) return String.Empty; } + /// + /// Retrieves a version of the specified identifier string, in the identifier casing format specified. + /// + /// The identifier string to get a specially cased version of + /// The identifier 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, IdentifierCasing casing) + { + // Empty (or null) values are returned unchanged: there is nothing to recase, and PascalCase + // would otherwise index str[0] and throw during script generation. + if (string.IsNullOrEmpty(str)) + { + return str; + } + + switch (casing) + { + case IdentifierCasing.Preserve: + return str; + case IdentifierCasing.Lowercase: + return str.ToLowerInvariant(); + case IdentifierCasing.Uppercase: + return str.ToUpperInvariant(); + case IdentifierCasing.PascalCase: + return GetPascalCase(str); + default: + Debug.Fail("Invalid IdentifierCasing value"); + break; + } + return str; + } + /// /// Retrieves a Pascal Cased version of the string /// @@ -190,6 +223,21 @@ public static TSqlParserToken CreateWhitespaceToken(Int32 count) return new TSqlParserToken(TSqlTokenType.WhiteSpace, ws); } + /// + /// Create a whitespace token consisting of tab characters (used for indentation when + /// is ). + /// + /// number of tab characters + /// + public static TSqlParserToken CreateTabToken(Int32 count) + { + // Build the tab whitespace + String ws = new String('\t', count); + + // Create a whitespace token and add it to the layout table + return new TSqlParserToken(TSqlTokenType.WhiteSpace, ws); + } + internal static void CheckForNullReference(object variable, string variableName) { if (variableName == null) diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.AlignmentPointData.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.AlignmentPointData.cs index 71825af2..f72c7b81 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.AlignmentPointData.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.AlignmentPointData.cs @@ -35,6 +35,16 @@ public AlignmentPointData(String name) public Int32 Offset { get; set; } public String Name { get; private set; } + // Tab-layout data (only used when IndentationMode is Tabs). TabTarget is the column the + // content following this alignment point is snapped to (a tab stop); TabShift is how far + // that content is shifted right versus the space layout; MaxLeftTabShift accumulates the + // TabShift of this point's left neighbours; and AbsorbsSeparator is set when this point is + // immediately followed by a single separator space that the tab run replaces. + public Int32 TabTarget { get; set; } + public Int32 TabShift { get; set; } + public Int32 MaxLeftTabShift { get; set; } + public Boolean AbsorbsSeparator { get; set; } + public void AddLeftPoint(AlignmentPointData ap, Int32 width) { Int32 currentWidth; diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.RightAlignedSeparatorElement.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.RightAlignedSeparatorElement.cs new file mode 100644 index 00000000..91c5dcd2 --- /dev/null +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.RightAlignedSeparatorElement.cs @@ -0,0 +1,49 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ +using System.Collections.Generic; + +namespace Microsoft.SqlServer.TransactSql.ScriptDom.ScriptGenerator +{ + internal partial class ScriptWriter + { + /// + /// A run of tokens (e.g. a leading comma and a space) that must be right-aligned so + /// that it ends exactly at the offset of the alignment point that immediately follows + /// it. This is used for leading comma placement, where the comma should sit just before + /// the aligned item column rather than at the start of the line. + /// + internal class RightAlignedSeparatorElement : ScriptWriterElement + { + private readonly List _tokens; + private readonly int _width; + + public RightAlignedSeparatorElement(List tokens) + { + _tokens = tokens; + _width = 0; + foreach (TSqlParserToken token in tokens) + { + if (token != null && token.Text != null) + { + _width += token.Text.Length; + } + } + + this.ElementType = ScriptWriterElementType.RightAlignedSeparator; + } + + public List Tokens + { + get { return _tokens; } + } + + public int Width + { + get { return _width; } + } + } + } +} diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.ScriptWriterElementType.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.ScriptWriterElementType.cs index b7f473ea..94ae7b72 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.ScriptWriterElementType.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.ScriptWriterElementType.cs @@ -14,6 +14,7 @@ internal enum ScriptWriterElementType AlignmentPoint, Token, NewLine, + RightAlignedSeparator, } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.cs index e1ceb0ed..07e46282 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.cs @@ -83,7 +83,13 @@ public void NewLine() // if we have some AlignmentPoints on stack, we set to the top one if (_newLineAlignmentPoints.Count > 0) { - Mark(_newLineAlignmentPoints.Peek()); + // The top entry may be null when a named-alignment scope was pushed without its own + // newline-restoration point (see PushNamedAlignmentScope); nothing to restore then. + AlignmentPoint newLineAlignmentPoint = _newLineAlignmentPoints.Peek(); + if (newLineAlignmentPoint != null) + { + Mark(newLineAlignmentPoint); + } } } @@ -92,6 +98,168 @@ public void Indent(Int32 size) AddSpace(size); } + // In Tabs indentation mode, rewrite the leading whitespace of every line so that it uses + // tab characters only. The script is first generated (and aligned) entirely with spaces; + // this pass then replaces the leading run of spaces on each line with the number of tab + // stops needed to reach (rounding up) that column: ceil(leadingSpaces / IndentationSize) + // tabs, with no trailing spaces. Whitespace that appears after content on a line (mid-line + // alignment padding) is left untouched. This pass is a no-op unless UseTabsForIndentation + // is set (never when CommaPlacement is Leading). + private List ConvertLeadingWhitespaceToTabs(List tokens) + { + if (_options.UseTabsForIndentation == false || _options.IndentationSize <= 0) + { + return tokens; + } + + Int32 size = _options.IndentationSize; + List result = new List(tokens.Count); + Boolean atLineStart = true; + Int32 leadingSpaces = 0; + + for (Int32 index = 0; index < tokens.Count; ++index) + { + TSqlParserToken token = tokens[index]; + // Whether the NEXT token starts a new line is determined by whether this token ENDS + // with a newline, not merely contains one: a multi-line block comment or string + // literal contains newlines but ends with content (e.g. "*/" or "'"), so tokens + // that follow it on the same line must not be treated as leading indentation. + Boolean endsWithNewLine = TokenEndsWithNewLine(token); + + if (atLineStart && !endsWithNewLine && IsAllSpaces(token.Text)) + { + // Accumulate the leading run of spaces (which may span multiple tokens). + leadingSpaces += token.Text.Length; + continue; + } + + if (atLineStart) + { + // Reached the end of the leading whitespace run: emit it as tab characters, + // rounding up to the next tab stop so no trailing spaces remain. + if (leadingSpaces > 0) + { + Int32 tabs = (leadingSpaces + size - 1) / size; + result.Add(ScriptGeneratorSupporter.CreateTabToken(tabs)); + leadingSpaces = 0; + } + } + + result.Add(token); + atLineStart = endsWithNewLine; + } + + return result; + } + + private static Boolean TokenEndsWithNewLine(TSqlParserToken token) + { + String text = token.Text; + if (String.IsNullOrEmpty(text)) + { + return false; + } + + Char last = text[text.Length - 1]; + return last == '\n' || last == '\r'; + } + + private static Boolean IsAllSpaces(String text) + { + if (String.IsNullOrEmpty(text)) + { + return false; + } + + foreach (Char c in text) + { + if (c != ' ') + { + return false; + } + } + return true; + } + + // Tabs mode only. True when the element right after 'index' is a single-space separator + // token. Such an alignment point is a "field separator" (for example the gap between a + // clause keyword and its body, or between column-definition fields) whose gap is rendered + // with tabs and whose trailing space is then swallowed. + private Boolean IsFollowedBySeparatorSpace(Int32 index) + { + if (index + 1 >= _scriptWriterElements.Count) + { + return false; + } + + TokenWrapper nextToken = _scriptWriterElements[index + 1] as TokenWrapper; + return nextToken != null && nextToken.Token != null && nextToken.Token.Text == " "; + } + + // Tabs mode only. Computes where this alignment point snaps to a tab stop, once its space + // offset and its left neighbours' shifts are final. A field separator snaps to the first + // tab stop strictly past its (shifted) column; every other point simply inherits the shift + // of its left neighbours. TabShift records how far the snap moved the point versus the + // space layout, and is propagated rightward so later columns on the line stay aligned. + private void ComputeTabSnappedLayout(AlignmentPointData ap) + { + Int32 size = _options.IndentationSize; + if (size <= 0) + { + return; + } + + if (ap.AbsorbsSeparator) + { + Int32 shiftedColumn = ap.Offset + ap.MaxLeftTabShift; + ap.TabTarget = ((shiftedColumn / size) + 1) * size; + ap.TabShift = ap.TabTarget - ap.Offset - 1; + } + else + { + ap.TabShift = ap.MaxLeftTabShift; + } + } + + // Tabs mode only. When 'ap' is a field separator, emits its aligned gap as tab characters + // (advancing 'offset' to the precomputed tab-stop target) and asks the caller to swallow + // the single separator space that follows. Returns false in the default Spaces mode, or for + // points that are not tab-snapped, so the caller falls back to the normal space padding. + private Boolean TryEmitTabSnappedGap(AlignmentPointData ap, List tokens, ref Int32 offset, ref Boolean absorbSeparatorSpace) + { + if (_options.UseTabsForIndentation == false + || _options.IndentationSize <= 0 + || ap.AbsorbsSeparator == false) + { + return false; + } + + // The target already accounts for the cumulative shift introduced by any earlier + // tab-snapped points on this line, so columns stay aligned. + Int32 size = _options.IndentationSize; + + // If the precomputed target is not ahead of the current offset (which can happen when + // earlier tab-snapped points on this line advanced 'offset' past this point's target), + // advance to the next tab stop from the current offset so 'offset' never moves backward + // and the tabs emitted actually land on the position we record. + Int32 target = ap.TabTarget; + if (target <= offset) + { + target = ((offset / size) + 1) * size; + } + + Int32 tabCount = target / size - offset / size; + if (tabCount < 1) + { + tabCount = 1; + } + + tokens.Add(ScriptGeneratorSupporter.CreateTabToken(tabCount)); + offset = target; + absorbSeparatorSpace = true; + return true; + } + public void Mark(AlignmentPoint ap) { if (String.IsNullOrEmpty(ap.Name) == false && @@ -102,11 +270,42 @@ public void Mark(AlignmentPoint ap) AddAlignmentPoint(ap); } + // Add a comma-and-space separator that is right-aligned so that it ends exactly at the + // offset of the alignment point that immediately follows it. Used for leading comma + // placement in keyword-aligned lists (e.g. the SELECT column list). + public void AddRightAlignedCommaSeparator() + { + List tokens = new List(2) + { + new TSqlParserToken(TSqlTokenType.Comma, ScriptGeneratorSupporter.GetTokenString(TSqlTokenType.Comma, _options.KeywordCasing)) + }; + if (_options.LeadingCommaSpaceCount > 0) + { + tokens.Add(ScriptGeneratorSupporter.CreateWhitespaceToken(_options.LeadingCommaSpaceCount)); + } + _scriptWriterElements.Add(new RightAlignedSeparatorElement(tokens)); + } + public void PushNewLineAlignmentPoint(AlignmentPoint ap) + { + PushNewLineAlignmentPoint(ap, resetNameScope: true); + } + + // When resetNameScope is true (the default) the current named-alignment-point scope is + // replaced with a fresh one, so named alignment points inside the pushed scope are + // independent of the outer scope (used e.g. for nested constructs). When false, the same + // named-alignment-point scope is kept, so named alignment points (e.g. column-definition + // field alignment) remain shared across the pushed scopes; this is needed when the push + // exists only to provide a newline-restoration point (comment preservation) and must not + // defeat cross-line alignment. + public void PushNewLineAlignmentPoint(AlignmentPoint ap, Boolean resetNameScope) { _newLineAlignmentPoints.Push(ap); _alignmentPointNameMaps.Push(_alignmentPointNameMapForCurrentScope); - _alignmentPointNameMapForCurrentScope = new Dictionary(); + if (resetNameScope) + { + _alignmentPointNameMapForCurrentScope = new Dictionary(); + } } public void PopNewLineAlignmentPoint() @@ -115,6 +314,23 @@ public void PopNewLineAlignmentPoint() _alignmentPointNameMapForCurrentScope = _alignmentPointNameMaps.Pop(); } + // Isolates the named-alignment-point scope for a list: named alignment points created after + // this call are shared among the list's items but do not leak into the enclosing scope, so + // separate lists rendered later in the same parent scope do not align against each other. + // Unlike PushNewLineAlignmentPoint(ap), this neither adds an alignment point at the current + // position nor changes newline restoration: the current newline-restoration point (if any) + // is reused so NewLine() behavior inside the scope is unchanged. + public void PushNamedAlignmentScope() + { + AlignmentPoint currentNewLineAlignmentPoint = _newLineAlignmentPoints.Count > 0 ? _newLineAlignmentPoints.Peek() : null; + PushNewLineAlignmentPoint(currentNewLineAlignmentPoint, resetNameScope: true); + } + + public void PopNamedAlignmentScope() + { + PopNewLineAlignmentPoint(); + } + public AlignmentPoint FindOrCreateAlignmentPoint(String name) { AlignmentPoint ap = null; @@ -232,7 +448,8 @@ private List TryGetAlignedTokens() if (result == null) result = GetAllTokens(); - return result; + // Tabs mode only post-pass; returns 'result' unchanged in the default Spaces mode. + return ConvertLeadingWhitespaceToTabs(result); } private List Align() @@ -269,6 +486,19 @@ private List Align() ap.Offset = Math.Max(ap.Offset, width); } + // Tabs mode only: record whether this point is a field separator so its + // gap can later be rendered as tabs. In the default Spaces mode this is + // skipped and AbsorbsSeparator stays false, so nothing downstream changes. + if (_options.UseTabsForIndentation) + { + // AlignmentPointData is shared across every occurrence of the same + // alignment point, so OR the flag instead of overwriting it: the point + // is snapped to a tab stop if it is a field separator on ANY line. The + // per-occurrence decision to actually emit the tab gap is made during + // emission below. + ap.AbsorbsSeparator |= IsFollowedBySeparatorSpace(index); + } + width = 0; previousPoint = ap; break; @@ -281,6 +511,16 @@ private List Align() width += tokenWrapper.Token.Text.Length; } break; + case ScriptWriterElementType.RightAlignedSeparator: + // The separator occupies its own width between the previous alignment + // point and the following one (which it is right-aligned against). + RightAlignedSeparatorElement separatorWidthElement = element as RightAlignedSeparatorElement; + Debug.Assert(separatorWidthElement != null, "RightAlignedSeparatorElement is expected"); + if (separatorWidthElement != null) + { + width += separatorWidthElement.Width; + } + break; case ScriptWriterElementType.NewLine: Debug.Assert(element is NewLineElement, "NewLineElement is expected"); width = 0; @@ -309,11 +549,26 @@ private List Align() return null; } + // Tabs mode only: compute where this point snaps to a tab stop, now that its + // offset and its left neighbours' shifts are final. Skipped in the default Spaces + // mode, where the offsets computed above are used exactly as-is. + if (_options.UseTabsForIndentation) + { + ComputeTabSnappedLayout(ap); + } + // let's align ap up HashSet rightPoints = ap.RightPoints; foreach (AlignmentPointData rightPoint in rightPoints) { rightPoint.AlignAndRemoveLeftPoint(ap); + + // Tabs mode only: carry this point's tab shift to its right neighbours so they + // stay aligned once earlier gaps have been snapped to tab stops. + if (_options.UseTabsForIndentation) + { + rightPoint.MaxLeftTabShift = Math.Max(rightPoint.MaxLeftTabShift, ap.TabShift); + } } // ap is done; let's remove it; @@ -324,6 +579,11 @@ private List Align() List tokens = new List(_scriptWriterElements.Count); Int32 offset = 0; + RightAlignedSeparatorElement pendingSeparator = null; + // When a mid-line alignment point has just been snapped to a tab stop in Tabs mode, the + // single separator space the generator emits after it is redundant and is swallowed so + // the following content follows the tab run directly. + Boolean absorbSeparatorSpace = false; for (Int32 index = 0; index < _scriptWriterElements.Count; ++index) { ScriptWriterElement element = _scriptWriterElements[index]; @@ -334,27 +594,81 @@ private List Align() #if !PIMODLANGUAGE Debug.Assert(ap != null, "AlignmentPointData is expected"); #endif - Debug.Assert(ap.Offset >= offset, "Incorrect offset"); + // In Tabs mode a clause body can be snapped forward onto a tab stop, which + // can leave 'offset' ahead of a later alignment point's precomputed offset. + Debug.Assert(_options.UseTabsForIndentation || ap.Offset >= offset, "Incorrect offset"); + if (pendingSeparator != null) + { + // Right-align the buffered separator so that it ends exactly at this + // alignment point's offset: pad up to (offset - separatorWidth), then + // emit the separator so the following item starts at the aligned column. + Int32 padBeforeSeparator = ap.Offset - offset - pendingSeparator.Width; + if (padBeforeSeparator > 0) + { + tokens.Add(ScriptGeneratorSupporter.CreateWhitespaceToken(padBeforeSeparator)); + offset += padBeforeSeparator; + } + + foreach (TSqlParserToken separatorToken in pendingSeparator.Tokens) + { + tokens.Add(separatorToken); + offset += separatorToken.Text.Length; + } + + pendingSeparator = null; + } + + // Tabs mode only: if THIS occurrence is followed by the single separator + // space token, emit its aligned gap as tabs and skip the default space + // padding below. The per-occurrence IsFollowedBySeparatorSpace check makes + // occurrences of a shared alignment point that are not separators fall + // through to normal padding, even though the shared AbsorbsSeparator flag is + // true. Skipped in the default Spaces mode, so that path runs unchanged. + if (_options.UseTabsForIndentation + && IsFollowedBySeparatorSpace(index) + && TryEmitTabSnappedGap(ap, tokens, ref offset, ref absorbSeparatorSpace)) + { + break; + } + if (ap.Offset > offset) { tokens.Add(ScriptGeneratorSupporter.CreateWhitespaceToken(ap.Offset - offset)); } - offset = ap.Offset; + // Use Math.Max because emitting a right-aligned separator above can advance + // 'offset' to exactly ap.Offset; never move the offset backwards here. + offset = Math.Max(offset, ap.Offset); break; case ScriptWriterElementType.Token: + FlushPendingSeparator(tokens, ref pendingSeparator, ref offset); TokenWrapper tokenWrapper = element as TokenWrapper; Debug.Assert(tokenWrapper != null, "TokenWrapper is expected"); Debug.Assert(tokenWrapper.Token.Text != null, "TokenWrapper.Token.Text should not be null"); if (tokenWrapper != null && tokenWrapper.Token != null && tokenWrapper.Token.Text != null) { + // Swallow the single separator space that follows a tab-snapped alignment point. + if (absorbSeparatorSpace && tokenWrapper.Token.Text == " ") + { + absorbSeparatorSpace = false; + break; + } + absorbSeparatorSpace = false; tokens.Add(tokenWrapper.Token); offset += tokenWrapper.Token.Text.Length; } break; + case ScriptWriterElementType.RightAlignedSeparator: + // Defer emission until the following alignment point is known so the + // separator can be right-aligned against it. + FlushPendingSeparator(tokens, ref pendingSeparator, ref offset); + pendingSeparator = element as RightAlignedSeparatorElement; + break; case ScriptWriterElementType.NewLine: + FlushPendingSeparator(tokens, ref pendingSeparator, ref offset); Debug.Assert(element is NewLineElement, "NewLineElement is expected"); tokens.Add(_newLineToken); offset = 0; + absorbSeparatorSpace = false; break; default: Debug.Assert(false, "Unknown ScriptWriterElement type"); @@ -362,9 +676,29 @@ private List Align() } } + FlushPendingSeparator(tokens, ref pendingSeparator, ref offset); + return tokens; } + // Emit a buffered right-aligned separator inline (used when it is not immediately + // followed by an alignment point to right-align against). + private static void FlushPendingSeparator(List tokens, ref RightAlignedSeparatorElement pendingSeparator, ref Int32 offset) + { + if (pendingSeparator == null) + { + return; + } + + foreach (TSqlParserToken separatorToken in pendingSeparator.Tokens) + { + tokens.Add(separatorToken); + offset += separatorToken.Text.Length; + } + + pendingSeparator = null; + } + // get all tokens without alignment private List GetAllTokens() { @@ -380,6 +714,17 @@ private List GetAllTokens() Debug.Assert(tokenWrapper != null, "TokenWrapper is expected"); tokens.Add(tokenWrapper.Token); break; + case ScriptWriterElementType.RightAlignedSeparator: + RightAlignedSeparatorElement separatorElement = element as RightAlignedSeparatorElement; + Debug.Assert(separatorElement != null, "RightAlignedSeparatorElement is expected"); + if (separatorElement != null) + { + foreach (TSqlParserToken separatorToken in separatorElement.Tokens) + { + tokens.Add(separatorToken); + } + } + break; case ScriptWriterElementType.NewLine: Debug.Assert(element is NewLineElement, "NewLineElement is expected"); tokens.Add(_newLineToken); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorOptions.Indentation.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorOptions.Indentation.cs new file mode 100644 index 00000000..78962ea7 --- /dev/null +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorOptions.Indentation.cs @@ -0,0 +1,27 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using System; + +namespace Microsoft.SqlServer.TransactSql.ScriptDom +{ + public partial class SqlScriptGeneratorOptions + { + /// + /// Whether indentation should be emitted using tab characters for the current option set. + /// This is true only when is + /// and is not . + /// + internal Boolean UseTabsForIndentation + { + get + { + return IndentationMode == IndentationMode.Tabs + && CommaPlacement != CommaPlacement.Leading; + } + } + } +} diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorOptions.LeadingComma.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorOptions.LeadingComma.cs new file mode 100644 index 00000000..6d5c8c70 --- /dev/null +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorOptions.LeadingComma.cs @@ -0,0 +1,26 @@ +//------------------------------------------------------------------------------ +// +// 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.BinaryQueryExpression.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BinaryQueryExpression.cs index 1f2d04f0..9bfdf792 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BinaryQueryExpression.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BinaryQueryExpression.cs @@ -34,6 +34,8 @@ public void GenerateBinaryQueryExpression(BinaryQueryExpression node, AlignmentP if (generator != null) { NewLine(); + // Emit any pending gap comments before UNION/EXCEPT/INTERSECT keywords + EmitPendingGapComments(); GenerateToken(generator); } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BuiltInFunctionTableReference.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BuiltInFunctionTableReference.cs index 66b82a38..e41976c5 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BuiltInFunctionTableReference.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.BuiltInFunctionTableReference.cs @@ -12,7 +12,9 @@ partial class SqlScriptGeneratorVisitor public override void ExplicitVisit(BuiltInFunctionTableReference node) { GenerateSymbol(TSqlTokenType.DoubleColon); - GenerateFragmentIfNotNull(node.Name); + // The built-in function name is a keyword-like function name, not an object name; do not + // bracket or recase it (same rationale as GlobalFunctionTableReference / FunctionCall). + GenerateWithoutIdentifierFormatting(() => GenerateFragmentIfNotNull(node.Name)); GenerateSpace(); GenerateParenthesisedCommaSeparatedList(node.Parameters, true); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ColumnDefinition.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ColumnDefinition.cs index aea6127c..da883b95 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ColumnDefinition.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.ColumnDefinition.cs @@ -118,7 +118,16 @@ public override void ExplicitVisit(ColumnDefinition node) // TODO, yangg: if constraint AP is still not marked, we can mark it here or don't. // either way we have some imperfect effect...check out the expression and table-declare unit tests - MarkForConstraintsWhenNecessary(ConstraintAP, ref firstConstraint); + // + // In Tabs mode (with a positive indentation size), when a column has no constraint the + // only remaining token is the comma, so marking the constraint alignment point here + // would just pad the column with spaces to line up commas. Skip it in that case. Spaces + // mode - and Tabs mode with IndentationSize <= 0, where no tabs are emitted and the + // layout falls back to spaces - keeps the original behavior so its output stays unchanged. + if (!_options.UseTabsForIndentation || _options.IndentationSize <= 0) + { + MarkForConstraintsWhenNecessary(ConstraintAP, ref firstConstraint); + } } public override void ExplicitVisit(ColumnStorageOptions node) diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Comments.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Comments.cs index 9a800616..6fe8d85b 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Comments.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Comments.cs @@ -130,8 +130,41 @@ protected void EmitGapComments(TSqlFragment fragment) } /// - /// Emits trailing comments after the fragment, scanning across newlines. - /// Each comment's own-line vs same-line placement is preserved from source. + /// Emits any pending gap comments that haven't been emitted yet. + /// Scans forward from lastProcessedTokenIndex and emits consecutive comment tokens. + /// Use this before generating keywords that should have comments before them. + /// + protected void EmitPendingGapComments() + { + if (!_options.PreserveComments || _currentTokenStream == null) + { + return; + } + + int startIndex = _lastProcessedTokenIndex + 1; + + // Emit consecutive comment tokens from the current position + for (int i = startIndex; i < _currentTokenStream.Count; i++) + { + var token = _currentTokenStream[i]; + + if (IsCommentToken(token) && !_emittedComments.Contains(token)) + { + EmitCommentToken(token, isLeading: true); + _emittedComments.Add(token); + _lastProcessedTokenIndex = i; + } + else if (token.TokenType != TSqlTokenType.WhiteSpace) + { + // Stop at first non-comment, non-whitespace token + break; + } + } + } + + /// + /// Emits trailing comments after the fragment, ONLY on the same line. + /// Comments on their own line are left as gap comments for the next fragment. /// protected void EmitTrailingComments(TSqlFragment fragment) { @@ -147,6 +180,7 @@ protected void EmitTrailingComments(TSqlFragment fragment) } int prevEmittedSourceIndex = lastTokenIndex; + bool skippedSeparatorComma = false; for (int i = lastTokenIndex + 1; i < _currentTokenStream.Count; i++) { var token = _currentTokenStream[i]; @@ -156,6 +190,15 @@ protected void EmitTrailingComments(TSqlFragment fragment) if (!_emittedComments.Contains(token)) { bool ownLine = SourceGapContainsNewline(prevEmittedSourceIndex, i); + + // CRITICAL FIX: Only emit same-line trailing comments. + // Comments on their own line should be left as gap comments for the next fragment. + if (ownLine) + { + // Stop here - don't emit this comment as trailing + break; + } + EmitTrailingCommentToken(token, ownLine); _emittedComments.Add(token); _lastProcessedTokenIndex = i; @@ -169,6 +212,19 @@ protected void EmitTrailingComments(TSqlFragment fragment) continue; } + // A list-separator comma on the same source line as the element does not end the + // element's trailing-comment window: a comment after it (e.g. 'col1, -- note') is + // still a trailing comment of this element and must stay on the element's line + // rather than being re-attributed as a leading comment of the next element. Skip a + // single same-line separator comma and keep scanning for the trailing comment. + if (token.TokenType == TSqlTokenType.Comma + && !skippedSeparatorComma + && !SourceGapContainsNewline(prevEmittedSourceIndex, i)) + { + skippedSeparatorComma = true; + continue; + } + // Any other token (including ';') ends the window. break; } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CommonPhrases.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CommonPhrases.cs index 00bc2bc3..ef8e600e 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CommonPhrases.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CommonPhrases.cs @@ -127,7 +127,9 @@ protected void GenerateSpaceAndCollation(Identifier collation) { GenerateSpace(); GenerateKeyword(TSqlTokenType.Collate); - GenerateSpaceAndFragmentIfNotNull(collation); + // Collation names are stored as Identifier fragments but cannot be delimited or recased + // (COLLATE [name] is invalid T-SQL), so suppress identifier formatting for them. + GenerateWithoutIdentifierFormatting(() => GenerateSpaceAndFragmentIfNotNull(collation)); } } @@ -398,15 +400,81 @@ protected void GenerateQueryExpressionInParentheses(QueryExpression queryExpress GenerateSymbol(TSqlTokenType.RightParenthesis); } + // True while rendering a SELECT projection list (QuerySpecification.SelectElements). + // Restricts the "alias = expression" ColumnAliasStyle form to real SELECT projections, + // because OUTPUT, OUTPUT INTO and RECEIVE reuse SelectScalarExpression but do not + // allow that form. + private bool _inSelectProjection; + private void GenerateSelectElementsList(IList selectElements) { - if (_options.MultilineSelectElementsList == false) + // SelectScalarExpression is shared with non-projection contexts (OUTPUT / OUTPUT INTO / + // RECEIVE). Mark that we are rendering a real SELECT projection so that column aliases + // may honor the ColumnAliasStyle option here (and only here). The previous value is + // restored so nested scalar subqueries remain independent. + bool previousInSelectProjection = _inSelectProjection; + _inSelectProjection = true; + try { - GenerateCommaSeparatedList(selectElements); + if (_options.MultilineSelectElementsList == false) + { + GenerateCommaSeparatedList(selectElements); + } + else if (_options.ColumnAliasStyle != ColumnAliasStyle.AsKeyword && _options.AlignClauseBodies) + { + // Push a dedicated alignment scope so that "alias = expression" equals signs + // align only within this SELECT list. Nested subqueries push their own scope + // and therefore align independently. + AlignmentPoint selectItems = new AlignmentPoint(); + MarkAndPushAlignmentPoint(selectItems); + + // Hold the "=" alignment point in a field for the duration of this SELECT list. + // When PreserveComments is enabled, GenerateFragmentList wraps each select + // element in its own freshly pushed alignment scope (which resets the writer's + // alignment-point name map), so resolving the point by name would yield a + // distinct point per row and the "=" signs would not align across rows. Keeping + // the point in a field lets every row share it. The previous value is restored + // so nested SELECT projections align independently. + AlignmentPoint previousEqualSignAlignmentPoint = _selectColumnAliasEqualSignAlignmentPoint; + _selectColumnAliasEqualSignAlignmentPoint = new AlignmentPoint(SelectColumnAliasEqualSign); + try + { + GenerateFragmentList(selectElements, ListGenerationOption.MultipleLineSelectElementOption); + } + finally + { + _selectColumnAliasEqualSignAlignmentPoint = previousEqualSignAlignmentPoint; + } + + PopAlignmentPoint(); + } + else + { + GenerateFragmentList(selectElements, ListGenerationOption.MultipleLineSelectElementOption); + } } - else + finally { - GenerateFragmentList(selectElements, ListGenerationOption.MultipleLineSelectElementOption); + _inSelectProjection = previousInSelectProjection; + } + } + + // Mark the alignment point used to vertically align the "=" signs when column aliases + // are rendered as "alias = expression". Only applies when the SELECT list is written + // on multiple lines and clause-body alignment is enabled. + protected void MarkColumnAliasEqualSignAlignmentWhenNecessary() + { + if (_options.MultilineSelectElementsList && _options.AlignClauseBodies) + { + // Prefer the alignment point held for the current SELECT list so that the "=" signs + // align across all rows even when PreserveComments pushes a per-row alignment scope. + // Fall back to name resolution for safety if the field was not set. + AlignmentPoint ap = _selectColumnAliasEqualSignAlignmentPoint + ?? FindOrCreateAlignmentPointByName(SelectColumnAliasEqualSign); + if (ap != null) + { + Mark(ap); + } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CreateFulltextIndexStatement.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CreateFulltextIndexStatement.cs index 2d90a624..ea7858b1 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CreateFulltextIndexStatement.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CreateFulltextIndexStatement.cs @@ -112,9 +112,30 @@ public override void ExplicitVisit(StopListFullTextIndexOption node) System.Diagnostics.Debug.Assert(node.OptionKind == FullTextIndexOptionKind.StopList); GenerateKeywordAndSpace(TSqlTokenType.StopList); if (node.IsOff) + { GenerateKeyword(TSqlTokenType.Off); + } + else if (IsSystemStopList(node.StopListName)) + { + // STOPLIST = SYSTEM is a keyword option, not a user-defined stoplist name. Bracketing + // or recasing it ("STOPLIST [SYSTEM]") makes the parser read it as a user stoplist named + // SYSTEM, silently changing the meaning, so emit it without identifier formatting. + GenerateWithoutIdentifierFormatting(() => GenerateFragmentIfNotNull(node.StopListName)); + } else + { GenerateFragmentIfNotNull(node.StopListName); + } + } + + // The STOPLIST option name is stored as an Identifier, but the unquoted value SYSTEM is the + // special "system stoplist" keyword rather than an object name. A delimited [SYSTEM] is a real + // user-defined stoplist and is left to normal identifier formatting. + private static bool IsSystemStopList(Identifier stopListName) + { + return stopListName != null + && stopListName.QuoteType == QuoteType.NotQuoted + && string.Equals(stopListName.Value, CodeGenerationSupporter.System, System.StringComparison.OrdinalIgnoreCase); } public override void ExplicitVisit(SearchPropertyListFullTextIndexOption node) diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CreatePartitionFunctionStatement.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CreatePartitionFunctionStatement.cs index 99b01ae5..c53360ab 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CreatePartitionFunctionStatement.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CreatePartitionFunctionStatement.cs @@ -51,11 +51,9 @@ public override void ExplicitVisit(PartitionParameterType node) { GenerateFragmentIfNotNull(node.DataType); - if (node.Collation != null) - { - GenerateSpaceAndKeyword(TSqlTokenType.Collate); - GenerateSpaceAndFragmentIfNotNull(node.Collation); - } + // Collation names cannot be delimited or recased; GenerateSpaceAndCollation suppresses + // identifier formatting for them. + GenerateSpaceAndCollation(node.Collation); } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.FileTableCollateFileNameTableOption.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.FileTableCollateFileNameTableOption.cs index f131d721..0ac7f3fc 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.FileTableCollateFileNameTableOption.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.FileTableCollateFileNameTableOption.cs @@ -12,7 +12,9 @@ partial class SqlScriptGeneratorVisitor public override void ExplicitVisit(FileTableCollateFileNameTableOption node) { System.Diagnostics.Debug.Assert(node.OptionKind == TableOptionKind.FileTableCollateFileName, "TableOption does not match"); - GenerateNameEqualsValue(CodeGenerationSupporter.FileTableCollateFileName, node.Value); + // The collation value (e.g. database_default) is a keyword Identifier fragment; do not + // bracket or recase it. + GenerateWithoutIdentifierFormatting(() => GenerateNameEqualsValue(CodeGenerationSupporter.FileTableCollateFileName, node.Value)); } } } \ No newline at end of file diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.FunctionCall.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.FunctionCall.cs index ffa50996..37fe8287 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.FunctionCall.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.FunctionCall.cs @@ -27,7 +27,12 @@ public override void ExplicitVisit(RightFunctionCall node) public override void ExplicitVisit(FunctionCall node) { GenerateFragmentIfNotNull(node.CallTarget); - GenerateFragmentIfNotNull(node.FunctionName); + + // Function names are not affected by the IdentifierCasing / IdentifierBracketing options + // (their casing is governed by a separate option), so emit the function name with + // identifier formatting suppressed. This has no effect under default options. + GenerateWithoutIdentifierFormatting(() => GenerateFragmentIfNotNull(node.FunctionName)); + GenerateSymbol(TSqlTokenType.LeftParenthesis); if (node.FunctionName.Value.ToUpper(CultureInfo.InvariantCulture) == CodeGenerationSupporter.Trim && @@ -39,7 +44,9 @@ public override void ExplicitVisit(FunctionCall node) if (node.TrimOptions != null) { GenerateSpace(); - GenerateFragmentIfNotNull(node.TrimOptions); + // LEADING/TRAILING/BOTH are stored as an Identifier fragment but are keywords, so + // they must not be bracketed or recased by the identifier options. + GenerateWithoutIdentifierFormatting(() => GenerateFragmentIfNotNull(node.TrimOptions)); GenerateSpace(); } GenerateFragmentIfNotNull(node.Parameters[0]); @@ -138,7 +145,9 @@ public override void ExplicitVisit(FunctionCall node) if (node.IgnoreRespectNulls?.Count > 0) { GenerateSpace(); - GenerateSpaceSeparatedList(node.IgnoreRespectNulls); + // IGNORE/RESPECT NULLS are stored as Identifier fragments but are keywords, so they + // must not be bracketed or recased by the identifier options. + GenerateWithoutIdentifierFormatting(() => GenerateSpaceSeparatedList(node.IgnoreRespectNulls)); } GenerateSpaceAndFragmentIfNotNull(node.WithinGroupClause); @@ -163,7 +172,9 @@ private void GenerateNullOnNullOrAbsentOnNull(IList list) { if (list?.Count > 0 && list[0].Value?.ToUpper(CultureInfo.InvariantCulture) == CodeGenerationSupporter.Absent) { - GenerateSpaceSeparatedList(list); + // ABSENT is stored as an Identifier fragment but is a keyword, so it must not be + // bracketed or recased by the identifier options ('[ABSENT] ON NULL' is invalid). + GenerateWithoutIdentifierFormatting(() => GenerateSpaceSeparatedList(list)); GenerateSpace(); GenerateKeyword(TSqlTokenType.On); GenerateSpace(); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.GlobalFunctionTableReference.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.GlobalFunctionTableReference.cs index 81af8f76..f0647e66 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.GlobalFunctionTableReference.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.GlobalFunctionTableReference.cs @@ -11,7 +11,10 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(GlobalFunctionTableReference node) { - GenerateFragmentIfNotNull(node.Name); + // The global table-function name (e.g. STRING_SPLIT) is a built-in function name, not an + // object name. Bracketing it ("[STRING_SPLIT]") makes the parser read it as a user-defined + // function, so emit it without identifier formatting, matching FunctionCall's handling. + GenerateWithoutIdentifierFormatting(() => GenerateFragmentIfNotNull(node.Name)); GenerateSpace(); GenerateParenthesisedCommaSeparatedList(node.Parameters, alwaysGenerateParenthses: true); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.GotoStatement.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.GotoStatement.cs index 8a719840..34e41a43 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.GotoStatement.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.GotoStatement.cs @@ -12,7 +12,11 @@ partial class SqlScriptGeneratorVisitor public override void ExplicitVisit(GoToStatement node) { GenerateKeyword(TSqlTokenType.GoTo); - GenerateSpaceAndFragmentIfNotNull(node.LabelName); + + // The label reference must not be bracketed or recased: labels cannot be delimited + // (GOTO [label] is invalid T-SQL) and the reference must keep matching the label + // declaration, which is emitted verbatim by LabelStatement. No effect under default options. + GenerateWithoutIdentifierFormatting(() => GenerateSpaceAndFragmentIfNotNull(node.LabelName)); } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Identifier.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Identifier.cs index 96c1f69d..c6e100b4 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Identifier.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Identifier.cs @@ -3,6 +3,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // //------------------------------------------------------------------------------ +using System; +using System.Collections.Generic; +using System.IO; using System.Text; using Microsoft.SqlServer.TransactSql.ScriptDom; @@ -10,19 +13,182 @@ namespace Microsoft.SqlServer.TransactSql.ScriptDom.ScriptGenerator { partial class SqlScriptGeneratorVisitor { + // When true, the IdentifierCasing and IdentifierBracketing options are not applied and + // the identifier is emitted with its original casing and quoting. Used for contexts where + // transforming the identifier would change semantics or produce invalid T-SQL, such as + // function names (governed by a separate option) and GOTO label references (labels cannot + // be delimited and must continue to match their label declaration). + private bool _suppressIdentifierFormatting; + + // Lazily-created parser used to probe whether an identifier can safely drop its brackets + // under IdentifierBracketing.ExcludeBrackets. It uses the configured SqlVersion so the + // reserved-word list matches the target dialect. + private TSqlParser _identifierBracketProbeParser; + public override void ExplicitVisit(Identifier node) { - if (node.Value != null) + if (node.Value == null) + { + return; + } + + // Default behavior is preserved: when identifier formatting is suppressed (function + // names, GOTO labels) or both options are at their default (Preserve), the identifier is + // emitted exactly as before - same value, same quoting - with no casing or bracketing + // change. Only when an option is set to a non-default value is any transformation applied. + // + // Variable and parameter names are also emitted unchanged: they are unquoted Identifier + // fragments whose value starts with '@' (the AST keeps the '@'). They must never be + // bracketed or recased - '[@x]' is invalid T-SQL and the interaction rules exclude + // @variables. The check is limited to unquoted identifiers so a delimited object identifier + // like [@Name] still participates in IdentifierCasing/IdentifierBracketing. + // + // Empty values (e.g. omitted components of a multi-part name) are also emitted unchanged: + // bracketing an empty value would produce invalid '[]'. + bool isVariableName = node.QuoteType == QuoteType.NotQuoted + && node.Value.Length > 0 + && node.Value[0] == '@'; + + if (_suppressIdentifierFormatting || + node.Value.Length == 0 || + isVariableName || + (_options.IdentifierCasing == IdentifierCasing.Preserve && + _options.IdentifierBracketing == IdentifierBracketing.Preserve)) + { + EmitIdentifier(node.Value, node.QuoteType); + return; + } + + // Bracketing is resolved first, then casing is applied to the value, per the documented + // interaction rule (IdentifierCasing is applied after IdentifierBracketing). + QuoteType quoteType = ResolveIdentifierQuoteType(node); + string value = ScriptGeneratorSupporter.GetCasedString(node.Value, _options.IdentifierCasing); + + EmitIdentifier(value, quoteType); + } + + // Emits an identifier exactly the way the generator did before the IdentifierCasing and + // IdentifierBracketing options existed: unquoted values are written as-is, quoted values are + // re-encoded with their quote type. + private void EmitIdentifier(string value, QuoteType quoteType) + { + if (quoteType == QuoteType.NotQuoted) + { + GenerateIdentifierWithoutCheck(value); + } + else + { + GenerateQuotedIdentifier(value, quoteType); + } + } + + // Runs the given emit action with identifier casing/bracketing suppressed. Used for Identifier + // fragments that represent syntax keywords/options rather than object names - function names, + // GOTO labels, TRIM's LEADING/TRAILING/BOTH, JSON ABSENT/NULL ON NULL, and window + // IGNORE/RESPECT NULLS - which must never be bracketed or recased (e.g. '[ABSENT] ON NULL' is + // invalid). Has no effect under default options, where identifiers are emitted unchanged anyway. + private void GenerateWithoutIdentifierFormatting(Action emit) + { + bool previousSuppress = _suppressIdentifierFormatting; + _suppressIdentifierFormatting = true; + try + { + emit(); + } + finally + { + _suppressIdentifierFormatting = previousSuppress; + } + } + + private QuoteType ResolveIdentifierQuoteType(Identifier node) + { + switch (_options.IdentifierBracketing) + { + case IdentifierBracketing.IncludeBrackets: + // An empty value is an omitted component of a multi-part name (e.g. the missing + // schema in "db..t"), not an identifier to delimit. Bracketing it to "[]" is + // invalid T-SQL (SQL46010), so preserve it as an empty, unquoted component before + // returning SquareBracket. (ExplicitVisit already short-circuits empty values; this + // keeps ResolveIdentifierQuoteType correct on its own as well.) + return string.IsNullOrEmpty(node.Value) ? QuoteType.NotQuoted : QuoteType.SquareBracket; + case IdentifierBracketing.ExcludeBrackets: + // This option controls square brackets only, so preserve other quote types + // (a double-quoted identifier keeps its double quotes). + if (node.QuoteType == QuoteType.SquareBracket && CanOmitBrackets(node.Value)) + { + return QuoteType.NotQuoted; + } + return node.QuoteType; + case IdentifierBracketing.Preserve: + default: + return node.QuoteType; + } + } + + // Returns true if the unquoted identifier value is a valid regular identifier that is not a + // reserved word for the configured SqlVersion (and therefore does not require brackets). + // This is determined by re-lexing the value with the version-specific parser: a safe + // identifier lexes to exactly one Identifier token whose text matches the value. Reserved + // words lex to keyword tokens; special characters or spaces produce multiple tokens or errors. + // + // Known limitation: a few identifiers lex to an ordinary Identifier token yet are contextual + // keywords whose brackets are semantically required (for example a schema-qualified name that + // collides with the AI_GENERATE_CHUNKS relational operator). This method cannot see the + // surrounding grammar context, so it may report such an identifier as bracket-optional. This + // is an accepted, documented limitation of the opt-in ExcludeBrackets option. + private bool CanOmitBrackets(string value) + { + if (string.IsNullOrEmpty(value)) + { + return false; + } + + if (_identifierBracketProbeParser == null) + { + _identifierBracketProbeParser = TSqlParser.CreateParser(_options.SqlVersion, true); + } + + IList errors; + IList tokens; + using (StringReader reader = new StringReader(value)) + { + tokens = _identifierBracketProbeParser.GetTokenStream(reader, out errors); + } + + if (errors != null && errors.Count > 0) + { + return false; + } + + // Expect exactly one meaningful token (plus the trailing EndOfFile token). + TSqlParserToken identifierToken = null; + foreach (TSqlParserToken token in tokens) { - if (node.QuoteType == QuoteType.NotQuoted) + if (token.TokenType == TSqlTokenType.EndOfFile) { - GenerateIdentifierWithoutCheck(node.Value); + continue; } - else + if (identifierToken != null) { - GenerateQuotedIdentifier(node.Value, node.QuoteType); + // More than one token means this is not a bare identifier. + return false; } + identifierToken = token; + } + + if (identifierToken == null || + !string.Equals(identifierToken.Text, value, StringComparison.Ordinal)) + { + return false; } + + // Only omit brackets when the value lexes to a genuine Identifier token. Values that lex + // to keyword tokens (e.g. AI_GENERATE_CHUNKS, TIMESTAMP) may be accepted as an unquoted + // identifier in some positions (such as a column alias) yet be invalid unquoted in others + // (such as a function name), so it is not safe to strip their brackets. Keeping the + // brackets is always valid. + return identifierToken.TokenType == TSqlTokenType.Identifier; } private void GenerateQuotedIdentifier(string identifier, QuoteType quoteType) diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.InsertBulkColumnDefinition.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.InsertBulkColumnDefinition.cs index 3927cfd9..f577bee0 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.InsertBulkColumnDefinition.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.InsertBulkColumnDefinition.cs @@ -11,7 +11,16 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(InsertBulkColumnDefinition node) { - GenerateFragmentIfNotNull(node.Column); + if (node.Column != null && node.Column.DataType == null) + { + // A column with no data type is the TIMESTAMP/rowversion shorthand; its name is the + // TIMESTAMP keyword and cannot be bracketed or recased. + GenerateWithoutIdentifierFormatting(() => GenerateFragmentIfNotNull(node.Column)); + } + else + { + GenerateFragmentIfNotNull(node.Column); + } switch (node.NullNotNull) { diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OdbcConvertSpecification.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OdbcConvertSpecification.cs index ee23c0e1..b6e4eb2c 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OdbcConvertSpecification.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OdbcConvertSpecification.cs @@ -11,7 +11,8 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(OdbcConvertSpecification node) { - GenerateFragmentIfNotNull(node.Identifier); + // The ODBC data-type name is a keyword Identifier fragment; do not bracket or recase it. + GenerateWithoutIdentifierFormatting(() => GenerateFragmentIfNotNull(node.Identifier)); } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OdbcFunctionCall.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OdbcFunctionCall.cs index b05ac54d..f1ae7117 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OdbcFunctionCall.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OdbcFunctionCall.cs @@ -13,7 +13,8 @@ public override void ExplicitVisit(OdbcFunctionCall node) { GenerateSymbol(TSqlTokenType.LeftCurly); GenerateSpaceAndIdentifier(CodeGenerationSupporter.Fn); - GenerateSpaceAndFragmentIfNotNull(node.Name); + // The ODBC function name is a keyword Identifier fragment; do not bracket or recase it. + GenerateWithoutIdentifierFormatting(() => GenerateSpaceAndFragmentIfNotNull(node.Name)); if (node.ParametersUsed) { diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OpenRowsetColumnDefinition.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OpenRowsetColumnDefinition.cs index c8692e22..f0f61703 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OpenRowsetColumnDefinition.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OpenRowsetColumnDefinition.cs @@ -14,12 +14,9 @@ public override void ExplicitVisit(OpenRowsetColumnDefinition node) GenerateSpaceAndFragmentIfNotNull(node.DataType); - if (node.Collation != null) - { - GenerateSpace(); - GenerateSymbol(TSqlTokenType.Collate); - GenerateSpaceAndFragmentIfNotNull(node.Collation); - } + // Collation names cannot be delimited or recased; GenerateSpaceAndCollation suppresses + // identifier formatting for them. + GenerateSpaceAndCollation(node.Collation); if (node.ColumnOrdinal != null) { diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OverClause.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OverClause.cs index 5791a6cd..36c072b7 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OverClause.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.OverClause.cs @@ -18,7 +18,9 @@ public override void ExplicitVisit(OverClause node) if (windowNameExists && !windowSpecExist) { - GenerateSpaceAndFragmentIfNotNull(node.WindowName); + // An unparenthesized "OVER window_name" reference must be a plain identifier; it + // cannot be bracketed or recased. (Inside "OVER (window_name ...)" it may be.) + GenerateWithoutIdentifierFormatting(() => GenerateSpaceAndFragmentIfNotNull(node.WindowName)); } else { diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Permission.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Permission.cs index fd076fdd..7fd304b9 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Permission.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Permission.cs @@ -11,7 +11,10 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(Permission node) { - GenerateSpaceSeparatedList(node.Identifiers); + // Permission names (SELECT, ALTER, ANY, COLUMN, ...) are keyword Identifier fragments and + // must not be bracketed or recased by the identifier options. The column list below is a + // real object-name list and is still transformed. + GenerateWithoutIdentifierFormatting(() => GenerateSpaceSeparatedList(node.Identifiers)); if (node.Columns != null && node.Columns.Count > 0) { diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.QualifiedJoin.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.QualifiedJoin.cs index 5d0ef28c..19de7755 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.QualifiedJoin.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.QualifiedJoin.cs @@ -36,6 +36,9 @@ public override void ExplicitVisit(QualifiedJoin node) GenerateNewLineOrSpace(_options.NewLineBeforeJoinClause); + // Emit any pending gap comments before JOIN keywords + EmitPendingGapComments(); + GenerateQualifiedJoinType(node.QualifiedJoinType); if (node.JoinHint != JoinHint.None) @@ -48,10 +51,28 @@ public override void ExplicitVisit(QualifiedJoin node) //MarkClauseBodyAlignmentWhenNecessary(_options.NewlineBeforeJoinClause); - NewLine(); + // Both new options default to true, which reproduces the original formatting exactly: + // a newline after the JOIN keyword, then a newline before ON (with no extra indentation). + GenerateNewLineOrSpace(_options.NewLineAfterJoinKeyword); GenerateFragmentIfNotNull(node.SecondTableReference); - NewLine(); + if (_options.NewLineBeforeOnClause) + { + NewLine(); + + // Opt-in refinement only: when the table source is kept on the JOIN line + // (NewLineAfterJoinKeyword = false), indent ON one level so it reads as a child of + // the JOIN. This branch never runs with the default options, so the default output + // is unchanged. + if (!_options.NewLineAfterJoinKeyword) + { + Indent(); + } + } + else + { + GenerateSpace(); + } GenerateKeyword(TSqlTokenType.On); GenerateSpaceAndFragmentIfNotNull(node.SearchCondition); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.SelectScalarExpression.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.SelectScalarExpression.cs index 91d9bf81..7797f9cd 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.SelectScalarExpression.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.SelectScalarExpression.cs @@ -11,13 +11,67 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(SelectScalarExpression node) { - GenerateFragmentIfNotNull(node.Expression); + if (node.ColumnName != null && UseEqualsSignForColumnAlias(node)) + { + GenerateFragmentIfNotNull(node.ColumnName); + MarkColumnAliasEqualSignAlignmentWhenNecessary(); + GenerateSpaceAndSymbol(TSqlTokenType.EqualsSign); + GenerateSpaceAndFragmentIfNotNull(node.Expression); + } + else + { + GenerateFragmentIfNotNull(node.Expression); - if (node.ColumnName != null) + if (node.ColumnName != null) + { + GenerateSpaceAndKeyword(TSqlTokenType.As); + GenerateSpaceAndFragmentIfNotNull(node.ColumnName); + } + } + } + + // Determines whether a column alias should be rendered as "alias = expression" + // (as opposed to "expression AS alias") based on the ColumnAliasStyle option. + private bool UseEqualsSignForColumnAlias(SelectScalarExpression node) + { + // The "alias = expression" form is only valid in a SELECT projection list. + // OUTPUT, OUTPUT INTO and RECEIVE reuse SelectScalarExpression but only accept + // "expression AS alias", so the equals-sign form must never be emitted there. + if (!_inSelectProjection) { - GenerateSpaceAndKeyword(TSqlTokenType.As); - GenerateSpaceAndFragmentIfNotNull(node.ColumnName); + return false; } + + switch (_options.ColumnAliasStyle) + { + case ColumnAliasStyle.EqualsSign: + return true; + case ColumnAliasStyle.Preserve: + return WasColumnAliasWrittenAsEqualsSign(node); + case ColumnAliasStyle.AsKeyword: + default: + return false; + } + } + + // In the equals-sign form ("alias = expression") the alias appears before the + // expression in the source, so its token index is smaller. In the AS form + // ("expression AS alias" or "expression alias") the alias appears after the + // expression. Fragments that were not parsed (built programmatically) have no + // token positions and are treated as AS-keyword style. + private static bool WasColumnAliasWrittenAsEqualsSign(SelectScalarExpression node) + { + // The caller only invokes this when ColumnName is set; a missing Expression is only + // possible for a malformed, non-parsed fragment and has no equals-sign form to preserve. + if (node?.Expression == null) + { + return false; + } + + int? aliasIndex = node?.ColumnName?.FirstTokenIndex; + int expressionIndex = node.Expression.FirstTokenIndex; + + return aliasIndex >= 0 && expressionIndex >= 0 && aliasIndex < expressionIndex; } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.SetClause.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.SetClause.cs index 06708bf6..e0271943 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.SetClause.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.SetClause.cs @@ -33,15 +33,63 @@ protected void GenerateSetClauses(IList setClauses, AlignmentPoint al Indent(); } + bool leadingComma = _options.MultilineSetClauseItems && _options.CommaPlacement == CommaPlacement.Leading; + + if (!leadingComma) + { + // Default / trailing behavior: unchanged from the original implementation so the + // generated output is identical when CommaPlacement is Trailing (the default). + GenerateKeyword(TSqlTokenType.Set); + + MarkClauseBodyAlignmentWhenNecessary(true, alignmentPoint); + + GenerateSpace(); + + AlignmentPoint setItems = new AlignmentPoint(); + MarkAndPushAlignmentPoint(setItems); + GenerateCommaSeparatedList(setClauses, _options.MultilineSetClauseItems); + PopAlignmentPoint(); + return; + } + + // Leading comma placement (opt-in). Anchor the newlines produced between SET items at + // the start of the SET clause (rather than at the item column). This lets leading + // commas be right-aligned so they end just before the aligned item column, mirroring + // the SELECT list, while the item column itself is established by the setItems + // alignment point marked on every item. Pushing a single alignment point also keeps + // the '=' sign alignment points (resolved by name within this scope) shared across all + // items. + AlignmentPoint clauseStart = new AlignmentPoint(); + MarkAndPushAlignmentPoint(clauseStart); + GenerateKeyword(TSqlTokenType.Set); MarkClauseBodyAlignmentWhenNecessary(true, alignmentPoint); GenerateSpace(); - AlignmentPoint setItems = new AlignmentPoint(); - MarkAndPushAlignmentPoint(setItems); - GenerateCommaSeparatedList(setClauses, _options.MultilineSetClauseItems); + AlignmentPoint items = new AlignmentPoint(); + bool firstItem = true; + foreach (SetClause setClause in setClauses) + { + if (firstItem) + { + Mark(items); + firstItem = false; + } + else + { + NewLine(); + GenerateRightAlignedCommaSeparator(); + + // Each multi-line item starts a new line, so re-mark the item alignment point + // to keep continuation items aligned under the first item. + Mark(items); + } + + GenerateFragmentIfNotNull(setClause); + } + PopAlignmentPoint(); } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.UnqualifiedJoin.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.UnqualifiedJoin.cs index d82b3651..519d5941 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.UnqualifiedJoin.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.UnqualifiedJoin.cs @@ -28,6 +28,9 @@ public override void ExplicitVisit(UnqualifiedJoin node) { GenerateFragmentIfNotNull(node.FirstTableReference); + // Emit any pending gap comments before JOIN keywords + EmitPendingGapComments(); + List generators = GetValueForEnumKey(_unqualifiedJoinTypeGenerators, node.UnqualifiedJoinType); if (generators != null) { diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.UseFederationStatement.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.UseFederationStatement.cs index 0f7428a0..c01dbaad 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.UseFederationStatement.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.UseFederationStatement.cs @@ -21,7 +21,9 @@ public override void ExplicitVisit(UseFederationStatement node) } else { - GenerateSpaceAndFragmentIfNotNull(node.FederationName); + // The federation name must be a plain identifier; it cannot be bracketed or recased. + // (The distribution name below is a normal identifier and is still transformed.) + GenerateWithoutIdentifierFormatting(() => GenerateSpaceAndFragmentIfNotNull(node.FederationName)); GenerateSpaceAndSymbol(TSqlTokenType.LeftParenthesis); GenerateFragmentIfNotNull(node.DistributionName); GenerateSpaceAndSymbol(TSqlTokenType.EqualsSign); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Utils.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Utils.cs index 381c6271..df7b2f49 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Utils.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Utils.cs @@ -212,16 +212,26 @@ protected void GenerateCommaSeparatedList(IList list) where T : TSqlFragme // generate a comma-separated list protected void GenerateCommaSeparatedList(IList list, Boolean insertNewLine) where T : TSqlFragment { + Boolean leadingComma = insertNewLine && _options.CommaPlacement == CommaPlacement.Leading; GenerateList(list, delegate() { - GenerateSymbol(TSqlTokenType.Comma); - if (insertNewLine) + if (leadingComma) { NewLine(); + GenerateSymbol(TSqlTokenType.Comma); + GenerateLeadingCommaSpace(); } else { - GenerateSpace(); + GenerateSymbol(TSqlTokenType.Comma); + if (insertNewLine) + { + NewLine(); + } + else + { + GenerateSpace(); + } } }); } @@ -229,22 +239,42 @@ protected void GenerateCommaSeparatedList(IList list, Boolean insertNewLin // generate a comma-separated list protected void GenerateCommaSeparatedList(IList list, bool insertNewLine, bool indent, bool generateSpaces = true) where T : TSqlFragment { + Boolean leadingComma = insertNewLine && _options.CommaPlacement == CommaPlacement.Leading; GenerateList(list, delegate() { - GenerateSymbol(TSqlTokenType.Comma); - if (insertNewLine) + if (leadingComma) { NewLine(); if (indent) { - Indent(); + // Reserve the comma width inside the indentation so the item starts at the + // normal indentation column (not indentation + LeadingCommaWidth), keeping + // continuation items aligned with the first item. + int indentColumns = _options.IndentationSize; + Indent(indentColumns >= LeadingCommaWidth ? indentColumns - LeadingCommaWidth : 0); } + + GenerateSymbol(TSqlTokenType.Comma); + GenerateLeadingCommaSpace(); } - else if (generateSpaces) + else { - GenerateSpace(); - } + GenerateSymbol(TSqlTokenType.Comma); + if (insertNewLine) + { + NewLine(); + + if (indent) + { + Indent(); + } + } + else if (generateSpaces) + { + GenerateSpace(); + } + } }); } @@ -338,6 +368,24 @@ protected void GenerateFragmentList(IList list, ListGenerationOption optio } Boolean firstItem = true; + + // When leading comma placement is requested, the comma is emitted at the start of the + // next line (after the newline and indentation) rather than trailing the previous item. + Boolean leadingComma = option.Separator == ListGenerationOption.SeparatorType.Comma + && option.NewLineBeforeItems + && _options.CommaPlacement == CommaPlacement.Leading; + + Boolean pushedListNamedAlignmentScope = false; + if (option.NewLineBeforeItems && _options.PreserveComments) + { + // Isolate a list-level named-alignment-point scope so field alignment points (e.g. + // column-definition name/type/constraint) are shared across items in this list but do + // not leak into the enclosing scope and accidentally align across separate lists + // rendered later in the same parent scope. + PushNamedAlignmentScope(); + pushedListNamedAlignmentScope = true; + } + foreach (var item in list) { if (firstItem) // do we have to produce a new line before the first item? @@ -347,28 +395,89 @@ protected void GenerateFragmentList(IList list, ListGenerationOption optio { NewLine(); } - - firstItem = false; } else { - GenerateSeparator(option); - if (option.NewLineBeforeItems) + if (leadingComma) { NewLine(); } + else + { + GenerateSeparator(option); + if (option.NewLineBeforeItems) + { + NewLine(); + } + } } - for (int indentIterator = 0; indentIterator < option.MultipleIndentItems ; indentIterator++) - Indent(); + Boolean leadingCommaThisItem = leadingComma && !firstItem; + + if (!leadingCommaThisItem) + { + // Default behavior (unchanged): indent each configured level. The separator + // for this path is emitted above via GenerateSeparator, so nothing changes + // here when MultipleIndentItems is greater than 0. + for (int indentIterator = 0; indentIterator < option.MultipleIndentItems; indentIterator++) + Indent(); + } + else if (option.MultipleIndentItems >= 1) + { + // Leading comma placement in an indented list: reserve the comma width inside + // the indentation so the item stays at the normal list indentation column and + // the comma is indented LeadingCommaWidth characters fewer. + int listIndentColumns = option.MultipleIndentItems * _options.IndentationSize; + Indent(listIndentColumns >= LeadingCommaWidth ? listIndentColumns - LeadingCommaWidth : 0); + + GenerateSymbol(TSqlTokenType.Comma); + GenerateLeadingCommaSpace(); + } + else + { + // Leading comma placement in a keyword-aligned list (no fixed indentation, e.g. + // the SELECT column list): the comma is emitted as a right-aligned separator so + // it ends exactly before the aligned item column, keeping items aligned with + // the first element and the comma just before each of them. + GenerateRightAlignedCommaSeparator(); + } if (option.NewLineBeforeItems) { - // we only mark it for multiple lines Mark(items); + + // 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) + { + AlignmentPoint itemScope = new AlignmentPoint(); + // Keep the current named-alignment-point scope so field alignment points + // (e.g. column-definition name/type/constraint) stay shared across items and + // continue to align across lines; the push only provides a newline-restoration + // point for comment-driven newlines within the item. + MarkAndPushAlignmentPointKeepingNameScope(itemScope); + + GenerateFragmentIfNotNull(item); + + PopAlignmentPoint(); + } + else + { + GenerateFragmentIfNotNull(item); + } + } + else + { + GenerateFragmentIfNotNull(item); } - GenerateFragmentIfNotNull(item); + firstItem = false; + } + + if (pushedListNamedAlignmentScope) + { + PopNamedAlignmentScope(); } // generate close parenthesis @@ -432,6 +541,23 @@ protected void GenerateSpace() _writer.AddToken(ScriptGeneratorSupporter.CreateWhitespaceToken(1)); } + // The number of columns a leading comma occupies: one column for the comma itself plus + // the configured number of trailing whitespace columns (default 1, i.e. 2 columns total). + private Int32 LeadingCommaWidth + { + // 1 is the comma itself + get { return 1 + _options.LeadingCommaSpaceCount; } + } + + // generate the whitespace that follows a leading comma (configurable width) + private void GenerateLeadingCommaSpace() + { + if (_options.LeadingCommaSpaceCount > 0) + { + _writer.AddToken(ScriptGeneratorSupporter.CreateWhitespaceToken(_options.LeadingCommaSpaceCount)); + } + } + // generate a keyword protected void GenerateKeyword(TSqlTokenType keywordId) { @@ -459,6 +585,13 @@ protected void GenerateSymbol(TSqlTokenType symbolId) GenerateKeyword(symbolId); } + // generate a comma-and-space separator that is right-aligned against the next alignment + // point (used for leading comma placement in keyword-aligned lists such as SELECT) + protected void GenerateRightAlignedCommaSeparator() + { + _writer.AddRightAlignedCommaSeparator(); + } + // generate a token for a given token type and text protected void GenerateToken(TSqlTokenType tokenType, String text) { diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.cs index a5136c96..b9890514 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.cs @@ -17,6 +17,7 @@ internal abstract partial class SqlScriptGeneratorVisitor : TSqlConcreteFragment protected const String SetClauseItemFirstEqualSign = "SetClauseItemFirstEqualSign"; protected const String SetClauseItemSecondEqualSign = "SetClauseItemSecondEqualSign"; protected const String InsertColumns = "InsertColumns"; + protected const String SelectColumnAliasEqualSign = "SelectColumnAliasEqualSign"; #endregion @@ -26,6 +27,14 @@ internal abstract partial class SqlScriptGeneratorVisitor : TSqlConcreteFragment protected ScriptWriter _writer; private Dictionary> _alignmentPointsForFragments; + // The alignment point used to vertically align the "=" signs of "alias = expression" + // column aliases for the SELECT list currently being rendered. Held as a field (rather + // than resolved by name through the writer's per-scope name map) so that it survives the + // per-item alignment scopes that GenerateFragmentList pushes when PreserveComments is + // enabled, allowing the "=" signs to align across all rows. Null outside a multiline, + // clause-body-aligned SELECT projection. Saved/restored around nested SELECT projections. + private AlignmentPoint _selectColumnAliasEqualSignAlignmentPoint; + #endregion #region constructor @@ -93,6 +102,30 @@ protected void MarkAndPushAlignmentPoint(AlignmentPoint ap) PushAlignmentPoint(ap); } + // Like MarkAndPushAlignmentPoint, but keeps the current named-alignment-point scope instead + // of starting a fresh one. Used when the pushed point exists only to restore indentation for + // comment-driven newlines and must not isolate named alignment points (e.g. column-definition + // field alignment) from the surrounding list, which would defeat cross-line alignment. + protected void MarkAndPushAlignmentPointKeepingNameScope(AlignmentPoint ap) + { + Mark(ap); + _writer.PushNewLineAlignmentPoint(ap, resetNameScope: false); + } + + // Isolates named alignment points created while rendering a list so they are shared among the + // list's items but do not leak into the enclosing scope (which would let separate lists in the + // same parent scope align against each other). Adds no alignment point at the current position + // and leaves newline restoration unchanged. + protected void PushNamedAlignmentScope() + { + _writer.PushNamedAlignmentScope(); + } + + protected void PopNamedAlignmentScope() + { + _writer.PopNamedAlignmentScope(); + } + protected AlignmentPoint FindOrCreateAlignmentPointByName(String apName) { return _writer.FindOrCreateAlignmentPoint(apName); diff --git a/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml b/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml index 44853885..6225d168 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml +++ b/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml @@ -14,6 +14,19 @@ Gets or sets the keyword casing option to use during script generation + + + + + + Gets or sets the casing applied to object identifiers during script generation. This does not affect keywords, string literals, function names, or variables. + + + + + + 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 Sql version to generate script for @@ -21,7 +34,10 @@ Gets or sets the Sql engine type (All|Engine|Azure) to generate script for - Gets or sets the number of spaces to use when indenting text + Gets or sets the number of spaces to use when indenting text. In Tabs mode each tab is treated as this many columns wide when computing alignment, so the viewer's tab size must be set to this value for the generated output to appear aligned. + + + Gets or sets whether indentation is written using spaces or tab characters. In Tabs mode the generated alignment only lines up when the viewer's tab width (for example the editor's tab size) equals IndentationSize. This setting is ignored (indentation uses spaces) when CommaPlacement is Leading. IncludeSemicolons_Title @@ -46,6 +62,9 @@ Gets or sets a boolean indicating if comments from the original script should be preserved in the generated output + + 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. + @@ -74,6 +93,12 @@ Gets or sets a boolean indicating if there should be a newline before the JOIN clause in a SELECT statement + + Gets or sets a boolean indicating if there should be a newline after the JOIN keyword (before the table source). When false, the table source is kept on the same line as the JOIN keyword. + + + Gets or sets a boolean indicating if there should be a newline before the ON keyword of a JOIN. When false, the ON keyword is kept on the same line as the table source. + Gets or sets a boolean indicating if there should be a newline before the OFFSET clause @@ -93,6 +118,12 @@ 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 value indicating how column aliases in SELECT projections are rendered (AS keyword, equals sign, or preserved from the original script) + diff --git a/Test/SqlDom/ScriptGenerator/ColumnAliasStyleTests.cs b/Test/SqlDom/ScriptGenerator/ColumnAliasStyleTests.cs new file mode 100644 index 00000000..158ca0af --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/ColumnAliasStyleTests.cs @@ -0,0 +1,577 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using System; +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 ColumnAliasStyle script-generation option (AsKeyword / EqualsSign / Preserve). + // Kept in a dedicated file to avoid churn in ScriptGeneratorTests.cs. + [TestClass] + public class ColumnAliasStyleTests + { + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleDefaultIsAsKeyword() + { + Assert.AreEqual(ColumnAliasStyle.AsKeyword, new SqlScriptGeneratorOptions().ColumnAliasStyle); + } + + // --------------------------------------------------------------------------------------- + // SELECT-projection behavior: conversion between styles, '=' alignment, Preserve, and the + // pass-through cases where the option must leave a projection element unchanged. + // --------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignSelectProjectionRoundTrips() + { + string generated = Generate( + "SELECT a AS x, b AS y FROM t;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.EqualsSign }); + + string expected = + "SELECT x = a," + Environment.NewLine + + " y = b" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignSingleLineSelect() + { + // MultilineSelectElementsList == false exercises the single-line branch. + string generated = Generate( + "SELECT a AS x FROM t;", + new SqlScriptGeneratorOptions + { + ColumnAliasStyle = ColumnAliasStyle.EqualsSign, + MultilineSelectElementsList = false + }); + + string expected = + "SELECT x = a" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignLeavesSelectStarAndUnaliasedColumns() + { + // '*' (SelectStarExpression) and unaliased columns have no alias to convert. + string generated = Generate( + "SELECT *, a, b AS y FROM t;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.EqualsSign }); + + string expected = + "SELECT *," + Environment.NewLine + + " a," + Environment.NewLine + + " y = b" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignLeavesSelectSetVariable() + { + // 'SELECT @v = a' is a SelectSetVariable, not a SelectScalarExpression column alias, + // so the option must not alter it. + string generated = Generate( + "SELECT @v = a FROM t;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.EqualsSign }); + + string expected = + "SELECT @v = a" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleAsKeywordLeavesSelectSetVariable() + { + // 'SELECT @v = a' is a SelectSetVariable, not a SelectScalarExpression column alias, + // so AsKeyword must not rewrite it into 'a AS @v'. + string generated = Generate( + "SELECT @v = a FROM t;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.AsKeyword }); + + string expected = + "SELECT @v = a" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleAsKeywordConvertsFromEqualsSign() + { + // AsKeyword rewrites "alias = expression" into "expression AS alias". + string generated = Generate( + "SELECT x = a, y = bb FROM t;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.AsKeyword }); + + string expected = + "SELECT a AS x," + Environment.NewLine + + " bb AS y" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignConvertsFromAsKeyword() + { + // EqualsSign rewrites "expression AS alias" into "alias = expression" and aligns the + // '=' signs within the SELECT list when AlignClauseBodies is on (the default). + string generated = Generate( + "SELECT a AS col1, bb AS c FROM t;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.EqualsSign }); + + string expected = + "SELECT col1 = a," + Environment.NewLine + + " c = bb" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignConvertsDelimitedAndStringAliases() + { + // The alias (ColumnName) is an IdentifierOrValueExpression: it can be a delimited + // identifier or a string literal, both of which are valid on the left of '='. + string generated = Generate( + "SELECT a AS [My Col], b AS 'Str Alias' FROM t;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.EqualsSign }); + + string expected = + "SELECT [My Col] = a," + Environment.NewLine + + " 'Str Alias' = b" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignNoAlignment() + { + // With AlignClauseBodies off, EqualsSign still applies but the '=' signs are not padded. + string generated = Generate( + "SELECT a AS col1, bb AS c FROM t;", + new SqlScriptGeneratorOptions + { + ColumnAliasStyle = ColumnAliasStyle.EqualsSign, + AlignClauseBodies = false + }); + + string expected = + "SELECT col1 = a," + Environment.NewLine + + " c = bb" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignSingleLineList() + { + // With MultilineSelectElementsList off, the list stays on one line and no '=' alignment + // is performed. + string generated = Generate( + "SELECT a AS col1, bb AS c FROM t;", + new SqlScriptGeneratorOptions + { + ColumnAliasStyle = ColumnAliasStyle.EqualsSign, + MultilineSelectElementsList = false + }); + + string expected = + "SELECT col1 = a, c = bb" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignLeavesUnaliasedExpressionsUnchanged() + { + // Expressions without an explicit alias are not affected by the option. + string generated = Generate( + "SELECT a, b AS c FROM t;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.EqualsSign }); + + string expected = + "SELECT a," + Environment.NewLine + + " c = b" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignLeavesTableAliasUnchanged() + { + // Table aliases must remain AS-style regardless of ColumnAliasStyle. + string generated = Generate( + "SELECT t.a AS c FROM t AS t;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.EqualsSign }); + + string expected = + "SELECT c = t.a" + Environment.NewLine + + "FROM t AS t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStylePreserveKeepsEqualsSign() + { + // Preserve keeps the original equals-sign form. + string generated = Generate( + "SELECT col1 = a, c = bb FROM t;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.Preserve }); + + string expected = + "SELECT col1 = a," + Environment.NewLine + + " c = bb" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStylePreserveKeepsAsKeyword() + { + // Preserve keeps the original AS-keyword form. + string generated = Generate( + "SELECT a AS col1, bb AS c FROM t;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.Preserve }); + + string expected = + "SELECT a AS col1," + Environment.NewLine + + " bb AS c" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStylePreserveMixedForms() + { + // Preserve keeps each column's original style independently. + string generated = Generate( + "SELECT col1 = a, bb AS c FROM t;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.Preserve }); + + string expected = + "SELECT col1 = a," + Environment.NewLine + + " bb AS c" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignNestedSubqueryAlignsIndependently() + { + // The outer and inner SELECT lists align their '=' signs independently. + string generated = Generate( + "SELECT longName = (SELECT x = 1), y = 2 FROM t;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.EqualsSign }); + + string expected = + "SELECT longName = (SELECT x = 1)," + Environment.NewLine + + " y = 2" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleAsKeywordConvertsFromEqualsSignInCommonTableExpression() + { + // The option applies to the SELECT list inside a CTE body, while the CTE's own column + // list "(alias1, alias2)" is a definition and must remain unchanged. + string generated = Generate( + "WITH cte (alias1, alias2) AS (SELECT alias1 = a, alias2 = bb FROM t) SELECT * FROM cte;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.AsKeyword }); + + string expected = + "WITH cte (alias1, alias2)" + Environment.NewLine + + "AS (SELECT a AS alias1," + Environment.NewLine + + " bb AS alias2" + Environment.NewLine + + " FROM t)" + Environment.NewLine + + "SELECT *" + Environment.NewLine + + "FROM cte;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignConvertsFromAsKeywordWithSubqueries() + { + // Converting AS -> '=' across a scalar subquery in the projection and a derived-table + // subquery in FROM. Each SELECT list aligns its '=' independently, and the derived + // table alias ("AS o") remains AS-style. + string generated = Generate( + "SELECT o.total AS grandTotal, (SELECT COUNT(*) FROM t2) AS cnt " + + "FROM (SELECT SUM(x) AS total FROM t1) AS o;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.EqualsSign }); + + string expected = + "SELECT grandTotal = o.total," + Environment.NewLine + + " cnt = (SELECT COUNT(*)" + Environment.NewLine + + " FROM t2)" + Environment.NewLine + + "FROM (SELECT total = SUM(x)" + Environment.NewLine + + " FROM t1) AS o;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStylePreserveOnProgrammaticallyBuiltFragmentRendersAsKeyword() + { + // A fragment built in code has no source token positions, so Preserve cannot detect an + // original equals-sign form and falls back to AS-keyword rendering. + var scalar = new SelectScalarExpression + { + Expression = new ColumnReferenceExpression + { + MultiPartIdentifier = new MultiPartIdentifier { Identifiers = { new Identifier { Value = "a" } } } + }, + ColumnName = new IdentifierOrValueExpression { Identifier = new Identifier { Value = "x" } } + }; + + var query = new QuerySpecification(); + query.SelectElements.Add(scalar); + var select = new SelectStatement { QueryExpression = query }; + + var generator = new Sql170ScriptGenerator( + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.Preserve }); + generator.GenerateScript(select, out string generated); + + string expected = "SELECT a AS x"; + + Assert.AreEqual(expected, generated); + } + + // --------------------------------------------------------------------------------------- + // Context-scoping tests: the "alias = expression" form is valid ONLY in a SELECT + // projection list. OUTPUT, OUTPUT INTO and RECEIVE reuse SelectScalarExpression but their + // grammar (outputClauseSelectColumn / receiveColumnSelectExpression) does NOT accept the + // equals-sign form, so ColumnAliasStyle must never rewrite aliases in those contexts. + // Generate() also re-parses the output, which is the key regression guard: before scoping, + // the generated "alias = expression" output could not be re-parsed in these contexts. + // --------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignDoesNotApplyToOutputClause() + { + string generated = Generate( + "DELETE dbo.T OUTPUT deleted.Id AS OldId;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.EqualsSign }); + + string expected = + "DELETE dbo.T" + Environment.NewLine + + "OUTPUT deleted.Id AS OldId;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignDoesNotApplyToOutputIntoClause() + { + string generated = Generate( + "DELETE dbo.T OUTPUT deleted.Id AS OldId INTO dbo.Removed (OldId);", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.EqualsSign }); + + string expected = + "DELETE dbo.T" + Environment.NewLine + + "OUTPUT deleted.Id AS OldId INTO dbo.Removed (OldId);" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStylePreserveDoesNotApplyToOutputClause() + { + // OUTPUT can only ever be authored in AS form, so Preserve must round-trip it unchanged. + string generated = Generate( + "DELETE dbo.T OUTPUT deleted.Id AS OldId;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.Preserve }); + + string expected = + "DELETE dbo.T" + Environment.NewLine + + "OUTPUT deleted.Id AS OldId;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignDoesNotApplyToReceiveStatement() + { + string generated = Generate( + "RECEIVE TOP (1) conversation_handle AS Handle FROM dbo.TargetQueue;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.EqualsSign }); + + string expected = + "RECEIVE TOP (1) conversation_handle AS Handle FROM dbo.TargetQueue;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignOutputAliasAndDerivedTableProjectionDoNotInterfere() + { + // A single statement mixes a non-projection OUTPUT alias (must stay AS) with a real + // derived-table projection (must convert). This exercises the save/restore of the + // in-projection flag: the derived-table SELECT sets it true and restores it, so the + // sibling OUTPUT column is never rewritten. + string generated = Generate( + "UPDATE t SET a = 1 OUTPUT inserted.a AS x " + + "FROM dbo.Target AS t INNER JOIN (SELECT v AS w FROM dbo.Src) AS d ON t.k = d.k;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.EqualsSign }); + + string expected = + "UPDATE t" + Environment.NewLine + + "SET a = 1" + Environment.NewLine + + "OUTPUT inserted.a AS x" + Environment.NewLine + + "FROM dbo.Target AS t" + Environment.NewLine + + " INNER JOIN" + Environment.NewLine + + " (SELECT w = v" + Environment.NewLine + + " FROM dbo.Src) AS d" + Environment.NewLine + + " ON t.k = d.k;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignWithPreserveCommentsRepositionsInlineComment() + { + // Known limitation: converting "expr AS alias" -> "alias = expr" reorders the alias + // ahead of the expression. With PreserveComments on, a comment authored between the + // expression and the AS keyword has no faithful home in the reordered text, so it is + // preserved (never dropped) but repositioned to the start of the element. The output + // still round-trips (Generate() asserts it re-parses). + string generated = Generate( + "SELECT a /* mid */ AS x, bb AS y FROM t;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.EqualsSign, PreserveComments = true }); + + string expected = + "SELECT /* mid */" + Environment.NewLine + + " x = a," + Environment.NewLine + + " y = bb" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignWithPreserveCommentsAlignsEqualSignsAcrossRows() + { + // Regression: with PreserveComments on, GenerateFragmentList pushes a per-row alignment + // scope for each select element. The '=' alignment point must still be shared across + // rows so the '=' signs line up to the widest alias, exactly as when PreserveComments + // is off. (Aliases of different widths are required to observe the alignment.) + string generated = Generate( + "SELECT a AS col1, bb AS c FROM t;", + new SqlScriptGeneratorOptions + { + ColumnAliasStyle = ColumnAliasStyle.EqualsSign, + PreserveComments = true + }); + + string expected = + "SELECT col1 = a," + Environment.NewLine + + " c = bb" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStylePreserveWithCommentsKeepsCommentInPlace() + { + // Preserve never reorders the alias and expression, so PreserveComments keeps inline + // comments exactly where they were authored (contrast with the EqualsSign conversion, + // which repositions them). + string generated = Generate( + "SELECT a /* mid */ AS x, bb AS y FROM t;", + new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.Preserve, PreserveComments = true }); + + string expected = + "SELECT a /* mid */ AS x," + Environment.NewLine + + " bb AS y" + Environment.NewLine + + "FROM t;" + Environment.NewLine + Environment.NewLine; + + Assert.AreEqual(expected, generated); + } + } +} diff --git a/Test/SqlDom/ScriptGenerator/IdentifierFormattingTests.cs b/Test/SqlDom/ScriptGenerator/IdentifierFormattingTests.cs new file mode 100644 index 00000000..61865b68 --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/IdentifierFormattingTests.cs @@ -0,0 +1,1008 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using System.IO; +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.SqlServer.TransactSql.ScriptDom.ScriptGenerator; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SqlStudio.Tests.AssemblyTools.TestCategory; +using static SqlStudio.Tests.UTSqlScriptDom.ScriptGeneratorTestHelper; + +namespace SqlStudio.Tests.UTSqlScriptDom +{ + // Tests for the IdentifierCasing and IdentifierBracketing script-generation options. + // Kept in a dedicated file to avoid churn in ScriptGeneratorTests.cs. + // + // The default option values (IdentifierCasing.Preserve and IdentifierBracketing.Preserve) leave + // generated script unchanged versus the previous formatter behavior. The two "DefaultIs..." tests + // pin the defaults, and TestDefaultOptionsArePureIdentity checks the default path is a no-op + // across every construct the change touches. + // + // Expected values follow the JoinClauseFormattingTests pattern: verbatim @"..." literals compared + // via the shared AssertGenerated helper (which normalizes line endings and trims). + // + // Work item: Formatter option: Identifier bracketing and casing + [TestClass] + public class IdentifierFormattingTests + { + // Builds options that toggle the identifier casing/bracketing under test while disabling + // unrelated multi-line/alignment formatting, so the expected literals stay focused on the + // identifier transformation itself. + private static SqlScriptGeneratorOptions MakeOptions(IdentifierCasing casing, IdentifierBracketing bracketing) + { + return new SqlScriptGeneratorOptions + { + IdentifierCasing = casing, + IdentifierBracketing = bracketing, + AlignColumnDefinitionFields = false, + AlignClauseBodies = false, + NewLineBeforeFromClause = false, + MultilineSelectElementsList = false, + }; + } + + // Parses with the given (version-specific) parser and generates with the matching generator, + // so the ExcludeBrackets reserved-word probe runs against that SqlVersion. Used to demonstrate + // version-specific bracketing behavior. + private static void AssertGeneratedWith( + TSqlParser parser, + SqlScriptGenerator generator, + string sql, + string expected) + { + TSqlFragment fragment = parser.Parse(new StringReader(sql), out IList errors); + Assert.AreEqual(0, errors.Count, "Input must parse without errors."); + + generator.GenerateScript(fragment, out string generated); + Assert.AreEqual(Normalize(expected).Trim(), Normalize(generated).Trim()); + } + + // ----------------------------------------------------------------------------------------- + // Defaults + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierCasingDefaultIsPreserve() + { + Assert.AreEqual(IdentifierCasing.Preserve, new SqlScriptGeneratorOptions().IdentifierCasing); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierBracketingDefaultIsPreserve() + { + Assert.AreEqual(IdentifierBracketing.Preserve, new SqlScriptGeneratorOptions().IdentifierBracketing); + } + + // With the default (Preserve/Preserve) options the identifier code path is a pure identity + // across every delimiter style and every construct the change touches (pre-bracketed, + // double-quoted, plain, @variable, schema-qualified + built-in function names, and a label). + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultOptionsArePureIdentity() + { + const string input = @" +GOTO MyLabel; +MyLabel: +SELECT [Bracketed], ""Quoted"", Plain, @Var, dbo.MyFunc(Col), LEN(Col2) FROM [My Tbl];"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.Preserve); + const string expected = @" +GOTO MyLabel; + +MyLabel: + +SELECT [Bracketed], ""Quoted"", Plain, @Var, dbo.MyFunc(Col), LEN(Col2) FROM [My Tbl];"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // IdentifierCasing + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierCasingPreserveLeavesIdentifiersUnchanged() + { + const string input = "SELECT MyCol FROM MyTbl;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.Preserve); + const string expected = @"SELECT MyCol FROM MyTbl;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierCasingLowercase() + { + // Identifiers lowered; keywords stay uppercase (governed by KeywordCasing). + const string input = "SELECT MyCol FROM MyTbl;"; + var options = MakeOptions(IdentifierCasing.Lowercase, IdentifierBracketing.Preserve); + const string expected = @"SELECT mycol FROM mytbl;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierCasingUppercase() + { + const string input = "SELECT MyCol FROM MyTbl;"; + var options = MakeOptions(IdentifierCasing.Uppercase, IdentifierBracketing.Preserve); + const string expected = @"SELECT MYCOL FROM MYTBL;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierCasingDoesNotAffectStringLiteralsOrVariables() + { + // Interaction rule: casing must not touch @variables or string literals. + const string input = "SELECT @MyVar, 'StrLit' FROM MyTbl;"; + var options = MakeOptions(IdentifierCasing.Lowercase, IdentifierBracketing.Preserve); + const string expected = @"SELECT @MyVar, 'StrLit' FROM mytbl;"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // IdentifierBracketing + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierBracketingIncludeBracketsWrapsAllIdentifiers() + { + const string input = "SELECT MyCol FROM MyTbl;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT [MyCol] FROM [MyTbl];"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierBracketingIncludeBracketsWrapsMultiPartName() + { + const string input = "SELECT MySchema.MyTbl.MyCol FROM MySchema.MyTbl;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT [MySchema].[MyTbl].[MyCol] FROM [MySchema].[MyTbl];"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierBracketingExcludeBracketsRemovesUnnecessaryBrackets() + { + const string input = "SELECT [MyCol] FROM [MyTbl];"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.ExcludeBrackets); + const string expected = @"SELECT MyCol FROM MyTbl;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierBracketingExcludeBracketsRetainsReservedWord() + { + // A reserved word (for the configured SqlVersion) must keep its brackets. + const string input = "SELECT [SELECT] FROM [MyTbl];"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.ExcludeBrackets); + const string expected = @"SELECT [SELECT] FROM MyTbl;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierBracketingExcludeBracketsRetainsSpecialCharacters() + { + // An identifier containing a space must keep its brackets. + const string input = "SELECT [My Col] FROM [MyTbl];"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.ExcludeBrackets); + const string expected = @"SELECT [My Col] FROM MyTbl;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierBracketingIncludeBracketsWrapsAliasDeclarationsAndReferences() + { + // The work item requires IncludeBrackets to wrap alias declarations and all references: + // the table alias (x), the column alias (a), and the references (t, c) are all bracketed. + const string input = "SELECT c AS a FROM t AS x;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT [c] AS [a] FROM [t] AS [x];"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierBracketingIncludeBracketsDoesNotDoubleBracket() + { + // Already-bracketed identifiers stay singly bracketed (no [[MyCol]]). + const string input = "SELECT [MyCol] FROM [MyTbl];"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT [MyCol] FROM [MyTbl];"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierBracketingIncludeBracketsEscapesClosingBracket() + { + // A closing bracket inside the identifier value must be escaped as ]] when bracketing. + const string input = "SELECT [a]]b] FROM MyTbl;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT [a]]b] FROM [MyTbl];"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierBracketingExcludeBracketsRetainsEscapedClosingBracket() + { + // An identifier containing a closing bracket must keep (and re-escape) its brackets. + const string input = "SELECT [a]]b] FROM [MyTbl];"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.ExcludeBrackets); + const string expected = @"SELECT [a]]b] FROM MyTbl;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierBracketingExcludeBracketsPreservesDoubleQuotedIdentifiers() + { + // ExcludeBrackets controls square brackets only, so double-quoted identifiers keep their + // double quotes - both the reserved word and the regular name are preserved. + const string input = "SELECT \"SELECT\" FROM \"MyTbl\";"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.ExcludeBrackets); + const string expected = @"SELECT ""SELECT"" FROM ""MyTbl"";"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierBracketingExcludeBracketsReservedWordIsVersionSpecific() + { + // The ExcludeBrackets reserved-word check honors the configured SqlVersion. PIVOT is a + // keyword starting with SQL Server 2005 but a regular identifier in SQL Server 2000 (80), + // so the same input keeps its brackets under 170 and drops them under 80. + const string input = "SELECT [PIVOT] FROM [MyTbl];"; + + // SQL Server 2000 (80): PIVOT is a regular identifier, so its brackets are removed. + AssertGeneratedWith( + new TSql80Parser(true), + new Sql80ScriptGenerator(MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.ExcludeBrackets)), + input, + @"SELECT PIVOT FROM MyTbl;"); + + // SQL Server 2025 (170): PIVOT is a keyword, so it retains its brackets. + AssertGeneratedWith( + new TSql170Parser(true), + new Sql170ScriptGenerator(MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.ExcludeBrackets)), + input, + @"SELECT [PIVOT] FROM MyTbl;"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierBracketingExcludeBracketsLeavesUnquotedIdentifiersUntouched() + { + // Coverage: ExcludeBrackets on already-unquoted identifiers is a no-op (the probe is + // skipped for QuoteType.NotQuoted); the identifiers pass through unchanged. + const string input = "SELECT MyCol FROM MyTbl;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.ExcludeBrackets); + const string expected = @"SELECT MyCol FROM MyTbl;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierBracketingExcludeBracketsRetainsOpeningBracketSpecialChar() + { + // Coverage: a value containing an unescaped '[' cannot be re-lexed as a single identifier + // (it produces a lex error), so the probe reports it as needing brackets and they are kept. + const string input = "SELECT [a[b] FROM [MyTbl];"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.ExcludeBrackets); + const string expected = @"SELECT [a[b] FROM MyTbl;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestSuppressedFunctionNamePreservesOriginalBracketingAndCasing() + { + // Coverage: a bracketed function name exercises the suppressed + quoted emit path. Under + // IncludeBrackets + Uppercase the function name keeps its exact original form ([MyFunc]), + // while the schema, argument, and table are still bracketed and uppercased. + const string input = "SELECT dbo.[MyFunc](Col) FROM MyTbl;"; + var options = MakeOptions(IdentifierCasing.Uppercase, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT [DBO].[MyFunc]([COL]) FROM [MYTBL];"; + + AssertGenerated(input, options, expected); + } + + // ----------------------------------------------------------------------------------------- + // Interaction: casing is applied after bracketing + // ----------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIdentifierBracketingAndCasingCombined() + { + // ExcludeBrackets removes safe brackets, then Lowercase recases the value; the + // reserved word retains its brackets but is still recased. + const string input = "SELECT [SELECT], [MyCol] FROM [MyTbl];"; + var options = MakeOptions(IdentifierCasing.Lowercase, IdentifierBracketing.ExcludeBrackets); + const string expected = @"SELECT [select], mycol FROM mytbl;"; + + AssertGenerated(input, options, expected); + } + + // ========================================================================================= + // GAP documentation + // + // The identifier options are applied centrally in ExplicitVisit(Identifier). The tests below + // document intentional boundaries and safe-choice behaviors that are NOT spelled out in the + // work item but are required to avoid generating invalid T-SQL or to keep the scope sane. + // ========================================================================================= + + // GAP-LABEL: GoToStatement.LabelName is an Identifier fragment, but T-SQL labels cannot be + // delimited (GOTO [x] is invalid) and the reference must keep matching the never-recased + // label declaration emitted by LabelStatement. Therefore identifier formatting is suppressed + // for label references: neither IncludeBrackets nor casing is applied. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapLabelIncludeBracketsDoesNotBracketLabel() + { + // The label declaration (MyLabel:) and the GOTO reference both stay unbracketed, while a + // regular identifier (MyCol) in the same batch is still wrapped in brackets. + const string input = @" +GOTO MyLabel; +MyLabel: +SELECT MyCol;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @" +GOTO MyLabel; + +MyLabel: + +SELECT [MyCol];"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapLabelCasingDoesNotRecaseLabel() + { + const string input = "GOTO MyLabel;"; + var options = MakeOptions(IdentifierCasing.Lowercase, IdentifierBracketing.Preserve); + const string expected = @"GOTO MyLabel;"; + + AssertGenerated(input, options, expected); + } + + // GAP-FUNC-SCOPE: Only the function's own name is excluded from formatting. A schema + // qualifier on a user-defined function (dbo in dbo.MyFunc) IS still transformed, because + // bracketing/recasing a schema is valid T-SQL. This documents the boundary of the + // "function names" exclusion (built-in/function-name casing is a separate future option). + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapFunctionNamePreservedButSchemaQualifierTransformed() + { + const string input = "SELECT dbo.MyFunc(MyCol) FROM MyTbl;"; + var options = MakeOptions(IdentifierCasing.Lowercase, IdentifierBracketing.Preserve); + const string expected = @"SELECT dbo.MyFunc(mycol) FROM mytbl;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapBuiltInFunctionNamePreserved() + { + // A built-in function name (LEN) is left untouched while its argument identifier is recased. + const string input = "SELECT LEN(MyCol) FROM MyTbl;"; + var options = MakeOptions(IdentifierCasing.Lowercase, IdentifierBracketing.Preserve); + const string expected = @"SELECT LEN(mycol) FROM mytbl;"; + + AssertGenerated(input, options, expected); + } + + // GAP-PASCAL: PascalCase reuses the existing GetPascalCase helper, which only capitalizes the + // first character of the whole value (not per word). A quoted multi-word identifier therefore + // becomes "[My col]", not "[My Col]". + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapPascalCaseOnlyCapitalizesFirstCharacter() + { + const string input = "SELECT [my col] FROM MYTBL;"; + var options = MakeOptions(IdentifierCasing.PascalCase, IdentifierBracketing.Preserve); + const string expected = @"SELECT [My col] FROM Mytbl;"; + + AssertGenerated(input, options, expected); + } + + // GAP-DQUOTE: IdentifierBracketing controls square brackets only. IncludeBrackets normalizes an + // identifier to square brackets (a double-quoted "MyCol" becomes [MyCol]); ExcludeBrackets + // removes square brackets only and preserves other quote types (a double-quoted "MyCol" stays + // "MyCol"). + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapDoubleQuotedIdentifierIncludeBracketsConvertsToSquareBrackets() + { + const string input = "SELECT \"MyCol\" FROM \"MyTbl\";"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT [MyCol] FROM [MyTbl];"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapDoubleQuotedIdentifierExcludeBracketsPreservesQuoting() + { + const string input = "SELECT \"MyCol\" FROM \"MyTbl\";"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.ExcludeBrackets); + const string expected = @"SELECT ""MyCol"" FROM ""MyTbl"";"; + + AssertGenerated(input, options, expected); + } + + // GAP-VARIABLE: variable and parameter names are Identifier fragments whose value starts with + // '@'. They must never be bracketed ('[@x]' is invalid T-SQL) or recased (the interaction + // rules exclude @variables), so the options leave them untouched while surrounding object + // identifiers are still transformed. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapVariableDeclarationIncludeBracketsDoesNotBracketVariable() + { + const string input = "DECLARE @MyVar INT;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"DECLARE @MyVar AS INT;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapVariableDeclarationCasingDoesNotRecaseVariable() + { + const string input = "DECLARE @MyVar INT;"; + var options = MakeOptions(IdentifierCasing.Uppercase, IdentifierBracketing.Preserve); + const string expected = @"DECLARE @MyVar AS INT;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapDelimitedAtNameIsTransformedAsObjectIdentifier() + { + // A delimited [@Name] is an object identifier (QuoteType.SquareBracket), not a variable, + // so - unlike an unquoted @variable - it still participates in the identifier options. + const string input = "SELECT [@Name] FROM MyTbl;"; + var options = MakeOptions(IdentifierCasing.Uppercase, IdentifierBracketing.Preserve); + const string expected = @"SELECT [@NAME] FROM MYTBL;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapTableVariableIncludeBracketsBracketsColumnsButNotVariable() + { + const string input = "DECLARE @MyTable TABLE (MyCol INT);"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @" +DECLARE @MyTable TABLE ( + [MyCol] INT);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapProcedureParameterIncludeBracketsBracketsProcNameButNotParameter() + { + const string input = "CREATE PROCEDURE MyProc @MyParam INT AS RETURN;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @" +CREATE PROCEDURE [MyProc] +@MyParam INT +AS +RETURN;"; + + AssertGenerated(input, options, expected); + } + + // GAP-TEMPTABLE: temp table names are Identifier fragments whose value starts with '#' (local) + // or '##' (global). Unlike @variables, bracketing them is valid T-SQL ([#Temp] is legal), so + // temp tables are intentionally NOT excluded: the options transform them like any other object + // identifier. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapLocalTempTableIncludeBracketsWrapsName() + { + const string input = "SELECT * FROM #Temp;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT * FROM [#Temp];"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapLocalTempTableExcludeBracketsRemovesBrackets() + { + const string input = "SELECT * FROM [#Temp];"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.ExcludeBrackets); + const string expected = @"SELECT * FROM #Temp;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapGlobalTempTableIncludeBracketsWrapsName() + { + const string input = "SELECT * FROM ##Temp;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT * FROM [##Temp];"; + + AssertGenerated(input, options, expected); + } + + // GAP-SYNTAX-KEYWORD: some syntax options are stored in the AST as Identifier fragments even + // though they are keywords (JSON ABSENT ON NULL, TRIM LEADING/TRAILING/BOTH, window + // IGNORE/RESPECT NULLS). They must never be bracketed or recased - e.g. "[ABSENT] ON NULL" is + // invalid T-SQL - so the options leave them untouched while real object identifiers around + // them are still transformed. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapJsonAbsentOnNullNotBracketed() + { + const string input = "SELECT JSON_OBJECT('a':1 ABSENT ON NULL);"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT JSON_OBJECT('a':1 ABSENT ON NULL);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapTrimOptionNotBracketed() + { + const string input = "SELECT TRIM(LEADING ' ' FROM MyCol) FROM MyTbl;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT TRIM( LEADING ' ' FROM [MyCol]) FROM [MyTbl];"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapIgnoreNullsNotBracketed() + { + const string input = "SELECT LAST_VALUE(MyCol) IGNORE NULLS OVER (ORDER BY MyId) FROM MyTbl;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT LAST_VALUE([MyCol]) IGNORE NULLS OVER (ORDER BY [MyId]) FROM [MyTbl];"; + + AssertGenerated(input, options, expected); + } + + // GAP-COLLATE: a collation name is stored as an Identifier fragment but cannot be delimited + // (COLLATE [name] is invalid T-SQL) or recased, so the options leave it untouched while the + // surrounding column/table identifiers are still transformed. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapCollationNameNotBracketed() + { + const string input = "SELECT MyCol COLLATE SQL_Latin1_General_CP1_CI_AS FROM MyTbl;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT [MyCol] COLLATE SQL_Latin1_General_CP1_CI_AS FROM [MyTbl];"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapCollationNameNotRecased() + { + const string input = "SELECT MyCol COLLATE SQL_Latin1_General_CP1_CI_AS FROM MyTbl;"; + var options = MakeOptions(IdentifierCasing.Lowercase, IdentifierBracketing.Preserve); + const string expected = @"SELECT mycol COLLATE SQL_Latin1_General_CP1_CI_AS FROM mytbl;"; + + AssertGenerated(input, options, expected); + } + + // GAP-EMPTY-VALUE: an empty Identifier.Value must be returned unchanged for every casing. + // PascalCase would otherwise index str[0] and throw during script generation. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestEmptyIdentifierValueIsNotRecased() + { + Assert.AreEqual(string.Empty, ScriptGeneratorSupporter.GetCasedString(string.Empty, IdentifierCasing.PascalCase)); + Assert.AreEqual(string.Empty, ScriptGeneratorSupporter.GetCasedString(string.Empty, IdentifierCasing.Uppercase)); + Assert.AreEqual(string.Empty, ScriptGeneratorSupporter.GetCasedString(string.Empty, IdentifierCasing.Lowercase)); + Assert.AreEqual(string.Empty, ScriptGeneratorSupporter.GetCasedString(string.Empty, IdentifierCasing.Preserve)); + } + + // GAP-MULTIPART: an omitted component of a multi-part name (e.g. the missing schema in + // "db..t") is an Identifier fragment with an empty Value. IncludeBrackets must preserve it as + // an empty, unquoted component - bracketing it to "[]" produces invalid T-SQL (SQL46010). + // Present parts are still bracketed. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapMultipartOmittedComponentNotBracketed() + { + const string input = "SELECT * FROM db..t;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT * FROM [db]..[t];"; + + AssertGenerated(input, options, expected); + } + + // GAP-MULTIPART (four-part): omitted parts in a server-qualified name are likewise preserved, + // whether the omitted part is in the middle or leading interior position. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapFourPartOmittedComponentsNotBracketed() + { + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + + AssertGenerated("SELECT * FROM srv.db..t;", options, @"SELECT * FROM [srv].[db]..[t];"); + AssertGenerated("SELECT * FROM srv..sch.t;", options, @"SELECT * FROM [srv]..[sch].[t];"); + } + + // ----------------------------------------------------------------------------------------- + // GAP-KEYWORD-POSITION: further Identifier fragments that hold keywords / syntax words rather + // than object names. Bracketing or recasing them produces invalid T-SQL, so the emit sites + // suppress identifier formatting for them while real object identifiers around them are still + // transformed. Each test asserts the complete generated script (AssertGenerated also reparses + // the output, which is the round-trip invariant each of these fixes restores). + // ----------------------------------------------------------------------------------------- + + // GAP-PERMISSION: permission names (ALTER, ANY, SCHEMA, ...) are Identifier fragments but are + // keywords - "GRANT [ALTER] [ANY] [SCHEMA]" is invalid. The grantee is still bracketed. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapPermissionNamesNotBracketed() + { + const string input = "GRANT ALTER ANY SCHEMA TO user1;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"GRANT ALTER ANY SCHEMA TO [user1];"; + + AssertGenerated(input, options, expected); + } + + // GAP-PERMISSION (GRANT): Permission.Identifiers holds the syntax keyword SELECT, not an object + // name; "GRANT [SELECT]" is rejected by the parser. The securable and principal are bracketed. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapPermissionGrantSelectNotBracketed() + { + const string input = "GRANT SELECT ON OBJECT::dbo.t TO u;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = +@"GRANT SELECT + ON OBJECT::[dbo].[t] TO [u];"; + + AssertGenerated(input, options, expected); + } + + // GAP-PERMISSION (DENY): same rule for the DENY variant. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapPermissionDenySelectNotBracketed() + { + const string input = "DENY SELECT ON OBJECT::dbo.t TO u;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = +@"DENY SELECT + ON OBJECT::[dbo].[t] TO [u];"; + + AssertGenerated(input, options, expected); + } + + // GAP-PERMISSION (REVOKE): same rule for the REVOKE variant (the generator normalizes REVOKE + // ... FROM to REVOKE ... TO, which round-trips). + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapPermissionRevokeSelectNotBracketed() + { + const string input = "REVOKE SELECT ON OBJECT::dbo.t FROM u;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = +@"REVOKE SELECT + ON OBJECT::[dbo].[t] TO [u];"; + + AssertGenerated(input, options, expected); + } + + // GAP-ODBC: the ODBC escape-function name and the ODBC data-type name are Identifier fragments + // holding keywords; "{ fn [convert] (...) }" / "[SQL_INTEGER]" are invalid. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapOdbcConvertNotBracketed() + { + const string input = "SELECT { fn convert(MyCol, SQL_INTEGER) } FROM MyTbl;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT { FN convert ([MyCol], SQL_INTEGER) } FROM [MyTbl];"; + + AssertGenerated(input, options, expected); + } + + // GAP-FEDERATION: the federation name must be a plain identifier ("USE FEDERATION [f1]" is + // invalid), but the distribution name is a normal identifier and is still bracketed. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapFederationNameNotBracketed() + { + const string input = "use federation f1 (d1 = 20) with filtering=on, reset"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"USE FEDERATION f1 ([d1] = 20) WITH FILTERING = ON, RESET;"; + + AssertGenerated(input, options, expected); + } + + // GAP-WINDOW: an unparenthesized "OVER window_name" reference must be a plain identifier + // ("OVER [Win1]" is invalid), while the WINDOW clause definition name may be bracketed. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapWindowOverReferenceNotBracketed() + { + const string input = "SELECT Sum(c1) OVER Win1 FROM t1 WINDOW Win1 AS (PARTITION BY c1)"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = +@"SELECT Sum([c1]) OVER Win1 FROM [t1] +WINDOW [Win1] AS (PARTITION BY [c1]);"; + + AssertGenerated(input, options, expected); + } + + // GAP-TIMESTAMP: the INSERT BULK timestamp/rowversion shorthand column has no data type and its + // name is the TIMESTAMP keyword ("([c2] CHAR, [TIMESTAMP])" is invalid); regular columns are + // still bracketed. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapInsertBulkTimestampColumnNotBracketed() + { + const string input = "insert bulk dbo.t1 (c1 int not null, c2 char, timestamp)"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"INSERT BULK [dbo].[t1] ([c1] INT NOT NULL, [c2] CHAR, TIMESTAMP);"; + + AssertGenerated(input, options, expected); + } + + // GAP-PARTITION-COLLATE: the partition-function parameter collation is a collation name and + // cannot be delimited; the partition function name is still bracketed. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapPartitionFunctionCollationNotBracketed() + { + const string input = "CREATE PARTITION FUNCTION myRangePF1 (char(10) COLLATE Estonian_CS_AS) AS RANGE RIGHT FOR VALUES (1)"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = +@"CREATE PARTITION FUNCTION [myRangePF1](CHAR (10) COLLATE Estonian_CS_AS) + AS RANGE RIGHT + FOR VALUES (1);"; + + AssertGenerated(input, options, expected); + } + + // GAP-OPENROWSET-COLLATE: an OPENROWSET WITH column collation cannot be delimited; the column + // name is still bracketed. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapOpenRowsetColumnCollationNotBracketed() + { + const string input = "select * from openrowset (bulk 'f1', format = 'CSV') with ([continent] varchar(100) collate latin1_general_bin2 2) as a;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT * FROM OPENROWSET (BULK 'f1', FORMAT = 'CSV') WITH ([continent] VARCHAR (100) COLLATE latin1_general_bin2 2) AS [a];"; + + AssertGenerated(input, options, expected); + } + + // GAP-FILETABLE-COLLATE: the FILETABLE_COLLATE_FILENAME option value is a collation name and + // cannot be delimited; the table name is still bracketed. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapFileTableCollationValueNotBracketed() + { + const string input = "create table t1 as filetable with(filetable_collate_filename=Latin1_General_bin, filetable_directory=null)"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = +@"CREATE TABLE [t1] AS FILETABLE +WITH (FILETABLE_COLLATE_FILENAME = Latin1_General_bin, FILETABLE_DIRECTORY = NULL);"; + + AssertGenerated(input, options, expected); + } + + // GAP-STOPLIST: "STOPLIST = SYSTEM" is a keyword option, not a stoplist name. The value is + // stored as an unquoted Identifier "SYSTEM"; bracketing it ("STOPLIST [SYSTEM]") makes the + // parser read it as a user-defined stoplist named SYSTEM, silently changing the meaning. The + // SYSTEM keyword must be preserved unbracketed. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapFulltextStoplistSystemNotBracketed() + { + const string input = "ALTER FULLTEXT INDEX ON t SET STOPLIST = SYSTEM;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"ALTER FULLTEXT INDEX ON [t] SET STOPLIST SYSTEM;"; + + AssertGenerated(input, options, expected); + } + + // GAP-STOPLIST (user name): a real, user-defined stoplist name IS a normal identifier and is + // still bracketed under IncludeBrackets. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapFulltextStoplistUserNameBracketed() + { + const string input = "ALTER FULLTEXT INDEX ON t SET STOPLIST = MyStopList;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"ALTER FULLTEXT INDEX ON [t] SET STOPLIST [MyStopList];"; + + AssertGenerated(input, options, expected); + } + + // GAP-STOPLIST (CREATE): same rule on the CREATE FULLTEXT INDEX path (both paths share the + // StopListFullTextIndexOption emit site). + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapCreateFulltextStoplistSystemNotBracketed() + { + const string input = "CREATE FULLTEXT INDEX ON t (c) KEY INDEX i WITH STOPLIST = SYSTEM;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = +@"CREATE FULLTEXT INDEX ON [t] + ([c]) + KEY INDEX [i] + WITH STOPLIST SYSTEM;"; + + AssertGenerated(input, options, expected); + } + + // GAP-TVF: built-in / global table-valued function names (STRING_SPLIT, OPENJSON, + // GENERATE_SERIES, ...) are stored on GlobalFunctionTableReference as Identifier names, but + // they are function names, not object names. Bracketing them ("[STRING_SPLIT]") makes the + // parser read them as user-defined functions, so they must stay unbracketed while a table + // alias is still bracketed. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapGlobalTableFunctionNameNotBracketed() + { + const string input = "SELECT * FROM STRING_SPLIT('a,b', ',') AS s;"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT * FROM STRING_SPLIT ('a,b', ',') AS [s];"; + + AssertGenerated(input, options, expected); + } + + // GAP-TVF (:: built-in): the "::function(...)" built-in table-function name + // (BuiltInFunctionTableReference) is likewise a function name, not an object name. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestGapBuiltInTableFunctionNameNotBracketed() + { + const string input = "SELECT * FROM ::fn_helpcollations();"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.IncludeBrackets); + const string expected = @"SELECT * FROM ::fn_helpcollations ();"; + + AssertGenerated(input, options, expected); + } + + // GAP-EXCLUDE-KEYWORD: ExcludeBrackets must keep brackets around a value that lexes to a + // keyword token (the reserved word KEY). Stripping them yields invalid T-SQL, so + // CanOmitBrackets only removes brackets from genuine Identifier tokens. The non-reserved + // table name loses its brackets as expected. + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestExcludeBracketsKeepsBracketsAroundReservedKeyword() + { + const string input = "SELECT [key] FROM [t1];"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.ExcludeBrackets); + const string expected = @"SELECT [key] FROM t1;"; + + AssertGenerated(input, options, expected); + } + + // KNOWN LIMITATION (documented on IdentifierBracketing.ExcludeBrackets and CanOmitBrackets): + // AI_GENERATE_CHUNKS lexes as an ordinary identifier, so ExcludeBrackets removes the brackets + // that were forcing a regular table-valued-function parse. That changes semantics and the + // output does not round-trip, so this test uses AssertGeneratedWith (which does NOT reparse) + // to pin the accepted behavior. If ExcludeBrackets ever becomes context aware, update the + // expected value (it should then keep the brackets and reparse cleanly). + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestExcludeBracketsSpecialOperatorNameIsKnownLimitation() + { + const string input = "SELECT SOURCE FROM userTable CROSS APPLY dbo.[AI_GENERATE_CHUNKS](SOURCE);"; + var options = MakeOptions(IdentifierCasing.Preserve, IdentifierBracketing.ExcludeBrackets); + const string expected = @"SELECT SOURCE FROM userTable CROSS APPLY dbo.AI_GENERATE_CHUNKS(SOURCE);"; + + AssertGeneratedWith(new TSql170Parser(true), new Sql170ScriptGenerator(options), input, expected); + } + } +} diff --git a/Test/SqlDom/ScriptGenerator/IndentationModeTests.cs b/Test/SqlDom/ScriptGenerator/IndentationModeTests.cs new file mode 100644 index 00000000..a86a0028 --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/IndentationModeTests.cs @@ -0,0 +1,631 @@ +//------------------------------------------------------------------------------ +// +// 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 +{ + // These tests verify the IndentationMode script generation option (Spaces vs Tabs). The + // expected constants are verbatim strings whose leading whitespace is real tab characters, so + // the expected output reads the same way it is written to the script. + [TestClass] + public class IndentationModeTests + { + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeDefaultIsSpaces() + { + Assert.AreEqual(IndentationMode.Spaces, new SqlScriptGeneratorOptions().IndentationMode); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsIndentsBlockBody() + { + // In Tabs mode the leading whitespace of every line is written with tab characters, + // and the gap between a clause keyword and its body is also a tab, so clause bodies + // line up on a tab stop. The SELECT-list and WHERE-predicate continuation lines round + // their indentation up to the same tab stop. + const string input = "BEGIN SELECT a, b FROM t WHERE a = 1 AND b = 2; END"; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs }; + const string expected = +@" +BEGIN + SELECT a, + b + FROM t + WHERE a = 1 + AND b = 2; +END"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsColumnDefinitionsSkipCommaPadding() + { + // The CREATE TABLE column definitions are indented with a tab and the gap between the + // column name and its data type is a tab. A column whose only remaining token is the + // comma is not padded to line up the commas, so the comma follows the data type directly. + const string input = "CREATE TABLE t (a INT, b NVARCHAR(10), c INT);"; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs }; + const string expected = +@" +CREATE TABLE t ( + a INT, + b NVARCHAR (10), + c INT +);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsColumnDefinitionsAlignConstraintsWithTabs() + { + // When columns have constraints, every column-definition field (name, data type, + // constraint) is aligned on a tab stop. The gaps between fields are rendered with tabs, + // and each field lines up on the next tab stop after the widest field in that column. + const string input = "CREATE TABLE t (a INT NOT NULL, b NVARCHAR(10) NULL, c INT DEFAULT 0);"; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs }; + const string expected = +@" +CREATE TABLE t ( + a INT NOT NULL, + b NVARCHAR (10) NULL, + c INT DEFAULT 0 +);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsColumnDefinitionsCascadeAlignFields() + { + // Robust cascade: with varying column-name and data-type widths, each field column is + // snapped to the next tab stop after the widest field to its left, and the snap of an + // earlier field is carried forward so later fields stay aligned across all rows. + const string input = "CREATE TABLE t (id INT NOT NULL, description NVARCHAR(200) NULL, x BIT DEFAULT 0);"; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs }; + const string expected = +@" +CREATE TABLE t ( + id INT NOT NULL, + description NVARCHAR (200) NULL, + x BIT DEFAULT 0 +);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsNestedBlocksUseMultipleTabs() + { + // Each nested indentation level adds one more tab character. + const string input = "CREATE PROCEDURE p AS BEGIN IF (1=1) BEGIN SELECT 1; END END"; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs }; + const string expected = +@" +CREATE PROCEDURE p +AS +BEGIN + IF (1 = 1) + BEGIN + SELECT 1; + END +END"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsWithLeadingCommaFallsBackToSpaces() + { + // Interaction rule: leading-comma placement offsets the comma two columns before the + // alignment column using sub-indent-level spacing, which cannot be represented with tab + // characters. When both IndentationMode = Tabs and CommaPlacement = Leading are set, + // indentation falls back to spaces so the leading comma still aligns, and no tab + // character is ever emitted. + const string input = "BEGIN SELECT a, b FROM t WHERE a = 1 AND b = 2; END"; + + string tabsLeading = Generate(input, new SqlScriptGeneratorOptions + { + IndentationMode = IndentationMode.Tabs, + CommaPlacement = CommaPlacement.Leading + }); + + string spacesLeading = Generate(input, new SqlScriptGeneratorOptions + { + IndentationMode = IndentationMode.Spaces, + CommaPlacement = CommaPlacement.Leading + }); + + // Tabs mode is ignored under leading-comma placement: output is identical to spaces + // mode and contains no tab characters. + Assert.IsFalse(tabsLeading.Contains("\t"), "No tab characters should be emitted when CommaPlacement is Leading. Actual:\n" + tabsLeading); + Assert.AreEqual(spacesLeading, tabsLeading); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsViewColumnListUsesTabs() + { + // The CREATE VIEW column list is indented one tab per line, and the view's SELECT body + // uses tabs both for the clause-keyword gap and for the continuation lines of the + // select list. + const string input = "CREATE VIEW v (a, b, c) AS SELECT 1, 2, 3;"; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs }; + 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 TestIndentationModeTabsClauseBodyUsesTab() + { + // The gap between a clause keyword and its body is rendered as a tab, so even a + // single-line SELECT places its body on a tab stop. + const string input = "SELECT 1;"; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs }; + const string expected = +@" +SELECT 1;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsUpdateSetClauseAlignsWithTabs() + { + // The UPDATE SET items are indented with tabs and their "=" signs are aligned on a tab + // stop. The single-predicate WHERE clause has no alignment, so it keeps spaces. + const string input = "UPDATE t SET a = 1, b = 2, c = 3 WHERE d = 4;"; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs }; + const string expected = +@" +UPDATE t +SET a = 1, + b = 2, + c = 3 +WHERE d = 4;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsHonorsIndentationSize() + { + // The tab-stop math depends on IndentationSize: a clause body is placed on the first + // tab stop strictly past the widest keyword, so the number of tabs between a keyword + // and its body changes with the size, while each nested level is still exactly one tab. + // + // NOTE: unlike the other tests in this file, the two expected blocks below do NOT look + // aligned when the file is viewed with the usual 4-column tab width. Their tab runs are + // sized for IndentationSize 2 and 8, so they only line up when the editor's tab width is + // set to 2 (and 8) respectively - which is exactly the tab-width/IndentationSize coupling + // this test asserts. + const string input = "BEGIN SELECT a, b FROM t WHERE a = 1 AND b = 2; END"; + + // With a small tab width (2), the 5-6 character clause keywords need two tabs to clear + // the widest keyword, and the select-list / predicate continuations land five tabs in. + var optionsSize2 = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs, IndentationSize = 2 }; + const string expectedSize2 = +@" +BEGIN + SELECT a, + b + FROM t + WHERE a = 1 + AND b = 2; +END"; + + AssertGenerated(input, optionsSize2, expectedSize2); + + // With a wide tab width (8), a single tab already clears the widest keyword, so the + // clause gaps collapse to one tab and the continuations land two tabs in. + var optionsSize8 = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs, IndentationSize = 8 }; + const string expectedSize8 = +@" +BEGIN + SELECT a, + b + FROM t + WHERE a = 1 + AND b = 2; +END"; + + AssertGenerated(input, optionsSize8, expectedSize8); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsWithZeroIndentationSizeMatchesSpaces() + { + // IndentationSize = 0 means no tab can be emitted, so every tab path falls back to the + // space layout. Tabs mode must then produce exactly the same output as Spaces mode - + // including the column-definition layout for columns without constraints - and emit no + // tab characters at all. + foreach (string sql in new[] + { + "CREATE TABLE t (a INT NOT NULL, b INT, c INT DEFAULT 0);", + "BEGIN SELECT a, b FROM t WHERE a = 1 AND b = 2; END", + }) + { + string tabs = Generate(sql, new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs, IndentationSize = 0 }); + string spaces = Generate(sql, new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Spaces, IndentationSize = 0 }); + + Assert.AreEqual(spaces, tabs, "Tabs mode with IndentationSize 0 should match Spaces mode. SQL:\n" + sql); + Assert.IsFalse(tabs.Contains("\t"), "No tab characters should be emitted when IndentationSize is 0. SQL:\n" + sql); + } + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsRegenerationIsStable() + { + // Regenerating a script that was already produced in Tabs mode must reproduce it + // exactly (the generator normalizes whitespace, so tabs in the input do not perturb the + // output). This guards against round-trip instability. + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs }; + + foreach (string sql in new[] + { + "BEGIN SELECT a, b FROM t WHERE a = 1 AND b = 2; END", + "CREATE TABLE t (a INT NOT NULL, b NVARCHAR(10) NULL, c INT DEFAULT 0);", + "UPDATE t SET a = 1, b = 2, c = 3 WHERE d = 4;", + }) + { + string first = Generate(sql, options); + string second = Generate(first, options); + Assert.AreEqual(first, second, "Tabs-mode output should be stable when regenerated. First:\n" + first); + } + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsSubqueryInWhereUsesTabs() + { + // A subquery nested in a WHERE predicate keeps aligning its own clause keywords with + // tabs; the inner clauses are indented past the outer predicate on tab stops. + const string input = "SELECT a FROM t WHERE a IN (SELECT b FROM u WHERE b > 1 AND c < 2);"; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs }; + const string expected = +@" +SELECT a +FROM t +WHERE a IN (SELECT b + FROM u + WHERE b > 1 + AND c < 2);"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsDerivedTableUsesTabs() + { + // A derived table in FROM with its own GROUP BY: the inner query's clause keywords and + // select-list continuation are aligned with tabs, indented past the outer FROM. + const string input = "SELECT o.total FROM (SELECT SUM(x) AS total, y FROM t1 GROUP BY y) AS o WHERE o.total > 10;"; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs }; + const string expected = +@" +SELECT o.total +FROM (SELECT SUM(x) AS total, + y + FROM t1 + GROUP BY y) AS o +WHERE o.total > 10;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsWithoutClauseBodyAlignmentStillIndentsWithTabs() + { + // With AlignClauseBodies off, the clause keyword and its body are separated by a single + // space (no tab-stop snapping), but the structural indentation of each line is still + // written with tabs, and continuation lines still round up to a tab stop. + const string input = "BEGIN SELECT a, b FROM t WHERE a = 1 AND b = 2; END"; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs, AlignClauseBodies = false }; + const string expected = +@" +BEGIN + SELECT a, + b + FROM t + WHERE a = 1 + AND b = 2; +END"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsIndentsLeadingCommentWithTabs() + { + // The leading-whitespace-to-tabs pass also applies to a line that begins with a + // preserved comment: the comment is indented with a tab, not spaces. + const string input = "BEGIN /* c1 */ SELECT a FROM t; END"; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs, PreserveComments = true }; + const string expected = +@" +BEGIN + /* c1 */ + SELECT a + FROM t; +END"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsMultilineCommentDoesNotIndentFollowingContent() + { + // A multi-line block comment is a single token that CONTAINS newlines but ENDS with + // "*/", so the leading-whitespace-to-tabs pass must not treat content that follows it on + // the same line as a new line's indentation. Here the space in " + 2" after the comment + // must stay a space and not be converted into a tab. + const string input = "SELECT 1 /* a\r\nb */ + 2 FROM t;"; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs, PreserveComments = true }; + const string expected = +@" +SELECT 1 /* a +b */ + 2 +FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsAlignsToWidestClauseKeyword() + { + // When a query mixes short and long clause keywords (SELECT/FROM vs GROUP BY/ORDER BY), + // every clause body is snapped to the same tab stop past the widest keyword, so the + // shorter keywords are followed by two tabs and the widest by one. + const string input = "SELECT a, b FROM t GROUP BY a, b HAVING COUNT(*) > 1 ORDER BY a;"; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs }; + const string expected = +@" +SELECT a, + b +FROM t +GROUP BY a, b +HAVING COUNT(*) > 1 +ORDER BY a;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestColumnAliasStyleEqualsSignAlignsVaryingAliasAndColumnLengths() + { + // ColumnAliasStyle.EqualsSign in the default Spaces indentation mode: aliases and + // source expressions of differing lengths, with the '=' signs padded with spaces so + // they align to the widest alias. + const string input = "SELECT a AS shortName, bb AS x, ccc AS mediumAlias FROM t;"; + var options = new SqlScriptGeneratorOptions { ColumnAliasStyle = ColumnAliasStyle.EqualsSign }; + const string expected = +@" +SELECT shortName = a, + x = bb, + mediumAlias = ccc +FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsColumnAliasEqualsSignAlignsVaryingLengths() + { + // The same varying-length EqualsSign projection as the Spaces test above, but in Tabs + // mode: the leading indentation and the gaps that align the aliases and '=' signs are + // all rendered with tabs instead of padded spaces. + const string input = "SELECT a AS shortName, bb AS x, ccc AS mediumAlias FROM t;"; + var options = new SqlScriptGeneratorOptions + { + ColumnAliasStyle = ColumnAliasStyle.EqualsSign, + IndentationMode = IndentationMode.Tabs + }; + const string expected = +@" +SELECT shortName = a, + x = bb, + mediumAlias = ccc +FROM t;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsMostRequestedJoinStyleUsesTabs() + { + // Most-requested JOIN style (NewLineAfterJoinKeyword = false, NewLineBeforeOnClause = + // true) in Tabs mode: the table source stays on the JOIN line and the ON keyword is + // placed one indentation level past the JOIN, written with tab characters. + const string input = "SELECT * FROM a INNER JOIN b ON a.x = b.x;"; + var options = new SqlScriptGeneratorOptions + { + IndentationMode = IndentationMode.Tabs, + NewLineAfterJoinKeyword = false, + NewLineBeforeOnClause = true + }; + const string expected = +@" +SELECT * +FROM a + INNER JOIN b + ON a.x = b.x;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsMultiPredicateOnWrapsWithTabs() + { + // Multi-predicate ON clause in Tabs mode: the ON keyword is one level past the JOIN and + // the wrapped AND predicate aligns under the first predicate, all using tab characters. + const string input = "SELECT * FROM a INNER JOIN b ON a.x = b.x AND a.y = b.y;"; + var options = new SqlScriptGeneratorOptions + { + IndentationMode = IndentationMode.Tabs, + NewLineAfterJoinKeyword = false, + NewLineBeforeOnClause = true, + MultilineWherePredicatesList = true + }; + const string expected = +@" +SELECT * +FROM a + INNER JOIN b + ON a.x = b.x + AND a.y = b.y;"; + + AssertGenerated(input, options, expected); + } + + // --------------------------------------------------------------------------------------- + // Edge cases: empty / whitespace-only scripts and inputs padded with blank lines. The + // generator reparses the AST and re-emits normalized whitespace, so blank lines from the + // input never survive into the output, and Tabs mode never emits a tab for a line that has + // no structural indentation. + // --------------------------------------------------------------------------------------- + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsEmptyScriptProducesEmptyOutput() + { + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs }; + + AssertGenerated("", options, ""); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsWhitespaceOnlyScriptProducesEmptyOutput() + { + // A script that is nothing but spaces, tabs and blank lines produces no output at all - + // in particular, no stray tab characters. + const string input = " \r\n\t\r\n \t \r\n"; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs }; + + AssertGenerated(input, options, ""); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsCollapsesBlankLinesBetweenStatements() + { + // Many blank lines between two statements collapse to the single blank line implied by + // NumNewlinesAfterStatement. Both statements are single-line, so no tab is emitted for + // structural indentation (only the clause-keyword gap is a tab). + const string input = "SELECT 1;\r\n\r\n\r\n\r\n\r\nSELECT 2;"; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs }; + const string expected = +@" +SELECT 1; + +SELECT 2;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsBlankLinesWithinStatementAreNormalized() + { + // Blank lines sprinkled inside a single statement are discarded; the statement is + // reformatted with tab indentation as if the blank lines were never there. + const string input = "SELECT a,\r\n\r\n\r\nb\r\n\r\nFROM t\r\nWHERE x = 1;"; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs }; + const string expected = +@" +SELECT a, + b +FROM t +WHERE x = 1;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestIndentationModeTabsExtraSpacesWithinStatementAreNormalized() + { + // Runs of extra spaces (leading, trailing and between tokens) are collapsed and the + // statement is re-emitted with tab indentation, producing the same output as the + // well-formatted equivalent. + const string input = " SELECT a, b FROM t WHERE x = 1; "; + var options = new SqlScriptGeneratorOptions { IndentationMode = IndentationMode.Tabs }; + const string expected = +@" +SELECT a, + b +FROM t +WHERE x = 1;"; + + AssertGenerated(input, options, expected); + } + } +} diff --git a/Test/SqlDom/ScriptGenerator/JoinClauseFormattingTests.cs b/Test/SqlDom/ScriptGenerator/JoinClauseFormattingTests.cs new file mode 100644 index 00000000..39ff4085 --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/JoinClauseFormattingTests.cs @@ -0,0 +1,259 @@ +//------------------------------------------------------------------------------ +// +// 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 NewLineAfterJoinKeyword and NewLineBeforeOnClause script-generation options that + // control how a QualifiedJoin (INNER/LEFT/RIGHT/FULL OUTER JOIN ... ON ...) is formatted. + [TestClass] + public class JoinClauseFormattingTests + { + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNewLineAfterJoinKeywordDefaultIsTrue() + { + Assert.AreEqual(true, new SqlScriptGeneratorOptions().NewLineAfterJoinKeyword); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestNewLineBeforeOnClauseDefaultIsTrue() + { + Assert.AreEqual(true, new SqlScriptGeneratorOptions().NewLineBeforeOnClause); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestDefaultsReproduceOriginalJoinLayout() + { + // Defaults (NewLineAfterJoinKeyword = true, NewLineBeforeOnClause = true) must reproduce + // the original formatter output exactly: table source on its own line after the JOIN + // keyword, and ON on a new line aligned with the JOIN keyword (no extra indentation). + const string input = "SELECT * FROM a INNER JOIN b ON a.x = b.x;"; + var options = new SqlScriptGeneratorOptions(); + const string expected = +@" +SELECT * +FROM a + INNER JOIN + b + ON a.x = b.x;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMostRequestedStyleTableSourceOnJoinLineOnNewLine() + { + // Most-requested style: table source kept on the JOIN line, ON on its own line indented + // one level from the JOIN keyword. + const string input = "SELECT * FROM a INNER JOIN b ON a.x = b.x;"; + var options = new SqlScriptGeneratorOptions + { + NewLineAfterJoinKeyword = false, + NewLineBeforeOnClause = true + }; + const string expected = +@" +SELECT * +FROM a + INNER JOIN b + ON a.x = b.x;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestBothFalseSingleLineJoin() + { + // Both options false: JOIN, table source and ON all stay on a single line. + const string input = "SELECT * FROM a INNER JOIN b ON a.x = b.x;"; + var options = new SqlScriptGeneratorOptions + { + NewLineAfterJoinKeyword = false, + NewLineBeforeOnClause = false + }; + const string expected = +@" +SELECT * +FROM a + INNER JOIN b ON a.x = b.x;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestTableSourceOnOwnLineOnKeptOnSameLine() + { + // Newline after JOIN keyword but ON kept on the same line as the table source. + const string input = "SELECT * FROM a INNER JOIN b ON a.x = b.x;"; + var options = new SqlScriptGeneratorOptions + { + NewLineAfterJoinKeyword = true, + NewLineBeforeOnClause = false + }; + const string expected = +@" +SELECT * +FROM a + INNER JOIN + b ON a.x = b.x;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultiPredicateOnWrapsWithMultilineWherePredicatesList() + { + // A multi-predicate ON wraps the AND predicate onto its own line, aligned under the + // first predicate, when MultilineWherePredicatesList is enabled (the same wrapping used + // for WHERE predicates). + const string input = "SELECT * FROM a INNER JOIN b ON a.x = b.x AND a.y = b.y;"; + var options = new SqlScriptGeneratorOptions + { + NewLineAfterJoinKeyword = false, + NewLineBeforeOnClause = true, + MultilineWherePredicatesList = true + }; + const string expected = +@" +SELECT * +FROM a + INNER JOIN b + ON a.x = b.x + AND a.y = b.y;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestMultiPredicateOnStaysInlineWhenMultilineDisabled() + { + // With MultilineWherePredicatesList disabled, a multi-predicate ON stays on a single line. + const string input = "SELECT * FROM a INNER JOIN b ON a.x = b.x AND a.y = b.y;"; + var options = new SqlScriptGeneratorOptions + { + NewLineAfterJoinKeyword = false, + NewLineBeforeOnClause = true, + MultilineWherePredicatesList = false + }; + const string expected = +@" +SELECT * +FROM a + INNER JOIN b + ON a.x = b.x AND a.y = b.y;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestChainedJoinsEachOnIndentedOneLevel() + { + // Chained joins: each ON is indented one level from its JOIN, independently. + const string input = "SELECT * FROM a INNER JOIN b ON a.x = b.x INNER JOIN c ON b.y = c.y;"; + var options = new SqlScriptGeneratorOptions + { + NewLineAfterJoinKeyword = false, + NewLineBeforeOnClause = true + }; + const string expected = +@" +SELECT * +FROM a + INNER JOIN b + ON a.x = b.x + INNER JOIN c + ON b.y = c.y;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCrossJoinUnaffectedByNewOptions() + { + // CROSS JOIN has no ON clause (it is an UnqualifiedJoin), so the new options do not + // change its formatting. + const string input = "SELECT * FROM a CROSS JOIN b;"; + var options = new SqlScriptGeneratorOptions + { + NewLineAfterJoinKeyword = false, + NewLineBeforeOnClause = true + }; + const string expected = +@" +SELECT * +FROM a CROSS JOIN b;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestCrossApplyUnaffectedByNewOptions() + { + // CROSS APPLY has no ON clause either, so the new options leave it unchanged. + const string input = "SELECT * FROM a CROSS APPLY dbo.f(a.id) AS b;"; + var options = new SqlScriptGeneratorOptions + { + NewLineAfterJoinKeyword = false, + NewLineBeforeOnClause = true + }; + const string expected = +@" +SELECT * +FROM a CROSS APPLY dbo.f(a.id) AS b;"; + + AssertGenerated(input, options, expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestJoinInlineWithFirstTableSourceWhenNewLineBeforeJoinClauseFalse() + { + // When NewLineBeforeJoinClause is false, the JOIN keyword stays on the same line as the + // first table source; the new options still control the table source and ON placement. + const string input = "SELECT * FROM a INNER JOIN b ON a.x = b.x;"; + var options = new SqlScriptGeneratorOptions + { + NewLineBeforeJoinClause = false, + NewLineAfterJoinKeyword = false, + NewLineBeforeOnClause = true + }; + const string expected = +@" +SELECT * +FROM a INNER JOIN b + ON a.x = b.x;"; + + AssertGenerated(input, options, expected); + } + } +} diff --git a/Test/SqlDom/ScriptGenerator/ScriptGeneratorTestHelper.cs b/Test/SqlDom/ScriptGenerator/ScriptGeneratorTestHelper.cs new file mode 100644 index 00000000..a1c10ecc --- /dev/null +++ b/Test/SqlDom/ScriptGenerator/ScriptGeneratorTestHelper.cs @@ -0,0 +1,56 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using System.IO; +using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +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 + { + // 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) + { + var parser = new TSql170Parser(true); + TSqlFragment fragment = parser.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); + + AssertReparses(generated); + return generated; + } + + public static void AssertReparses(string sql) + { + var reparser = new TSql170Parser(true); + reparser.Parse(new StringReader(sql), out IList errors); + Assert.AreEqual(0, errors.Count, "Generated script must reparse without errors. Actual:\n" + sql); + } + + // Normalizes line endings so verbatim expected constants compare equal regardless of the + // source file's line-ending style. + 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()); + } + } +} diff --git a/Test/SqlDom/ScriptGeneratorTests.cs b/Test/SqlDom/ScriptGenerator/ScriptGeneratorTests.cs similarity index 61% rename from Test/SqlDom/ScriptGeneratorTests.cs rename to Test/SqlDom/ScriptGenerator/ScriptGeneratorTests.cs index de29e542..fdaf7632 100644 --- a/Test/SqlDom/ScriptGeneratorTests.cs +++ b/Test/SqlDom/ScriptGenerator/ScriptGeneratorTests.cs @@ -632,6 +632,124 @@ public void TestPreserveCommentsEnabled_SingleLineTrailing() $"Trailing comment should appear after SELECT. SELECT at {selectIndex}, comment at {commentIndex}"); } + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsDoesNotBreakColumnDefinitionAlignment() + { + // Regression: enabling PreserveComments must not defeat AlignColumnDefinitionFields. + // When there are no comments in the input, the generated script must be identical + // whether PreserveComments is on or off, and the column fields must stay aligned. + const string sql = + "CREATE TABLE dbo.t (id INT NOT NULL, long_column_name DECIMAL(10, 2) NULL, c VARCHAR(20) NOT NULL);"; + + const string expected = +@"CREATE TABLE dbo.t ( + id INT NOT NULL, + long_column_name DECIMAL (10, 2) NULL, + c VARCHAR (20) NOT NULL +);"; + + // Aligned output must be produced when PreserveComments is enabled (AssertGenerated also + // verifies the generated script reparses without errors)... + ScriptGeneratorTestHelper.AssertGenerated( + sql, + new SqlScriptGeneratorOptions { AlignColumnDefinitionFields = true, PreserveComments = true }, + expected); + + // ...and it must match the output produced with PreserveComments disabled (no comments present). + ScriptGeneratorTestHelper.AssertGenerated( + sql, + new SqlScriptGeneratorOptions { AlignColumnDefinitionFields = true, PreserveComments = false }, + expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsKeepsColumnDefinitionAlignmentWithTrailingComments() + { + // Each column has a trailing '--' comment. With PreserveComments enabled the comments + // must be preserved and the column definition fields must remain aligned. + const string sql = +@"CREATE TABLE dbo.t ( + id INT NOT NULL, -- the identifier + long_column_name DECIMAL(10, 2) NULL, -- a decimal value + c VARCHAR(20) NOT NULL -- a string +);"; + + const string expected = +@"CREATE TABLE dbo.t ( + id INT NOT NULL, -- the identifier + long_column_name DECIMAL (10, 2) NULL, -- a decimal value + 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 }, + expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsColumnAlignmentIsIsolatedPerTable() + { + // Two CREATE TABLE column lists are rendered in the same enclosing scope. Each table's + // column fields must align only against that table's own columns; the wide column names + // in the first table must not widen the (independently aligned) second table. + const string sql = +@"CREATE TABLE dbo.wide (a_very_long_column_name INT NOT NULL, b INT NULL); +CREATE TABLE dbo.t (x INT NOT NULL, y INT NULL);"; + + const string expected = +@"CREATE TABLE dbo.wide ( + a_very_long_column_name INT NOT NULL, + b INT NULL +); + +CREATE TABLE dbo.t ( + x INT NOT NULL, + y INT NULL +);"; + + ScriptGeneratorTestHelper.AssertGenerated( + sql, + new SqlScriptGeneratorOptions { AlignColumnDefinitionFields = true, PreserveComments = true }, + expected); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsMultilineViewColumnsWithEmptyAlignmentScope() + { + // CREATE VIEW generates its multiline column list without first pushing an enclosing + // alignment point, so the list-level named scope is pushed while the newline-restoration + // stack is empty. This exercises the null newline-point path (PushNamedAlignmentScope + // reusing "no current point", and the null guard in NewLine): it must not throw and must + // still emit each column on its own line with PreserveComments enabled. + const string sql = "CREATE VIEW dbo.v (col1, long_column_name, c) AS SELECT 1, 2, 3;"; + + const string expected = +@"CREATE VIEW dbo.v ( + col1, + long_column_name, + c +) +AS +SELECT 1, + 2, + 3;"; + + ScriptGeneratorTestHelper.AssertGenerated( + sql, + new SqlScriptGeneratorOptions { MultilineViewColumnsList = true, PreserveComments = true }, + expected); + } + [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)] @@ -1219,18 +1337,56 @@ FROM users u Assert.AreEqual(0, reparseErrors.Count, "Generated SQL should reparse without errors. Actual: " + generatedSql); - // Comments preceding their JOIN in source should appear before the - // matching JOIN keyword in output where a natural newline separates - // the prior clause from the next JOIN. + // CRITICAL ASSERTIONS: Comments must appear BEFORE their JOIN keywords + // and NOT be on the same line as the previous table reference + + // Find the comment for INNER JOIN int innerJoinCommentIdx = generatedSql.IndexOf("-- Join to get user orders"); int innerJoinIdx = generatedSql.IndexOf("INNER JOIN", StringComparison.OrdinalIgnoreCase); - int leftJoinCommentIdx = generatedSql.IndexOf("/* Left join for optional address */"); - int leftJoinIdx = generatedSql.IndexOf("LEFT", StringComparison.OrdinalIgnoreCase); - + Assert.IsTrue(innerJoinCommentIdx >= 0, "INNER JOIN comment must exist"); Assert.IsTrue(innerJoinCommentIdx < innerJoinIdx, - $"INNER JOIN comment should appear before INNER JOIN. Comment at {innerJoinCommentIdx}, JOIN at {innerJoinIdx}"); + $"INNER JOIN comment MUST appear before INNER JOIN keyword. Comment at {innerJoinCommentIdx}, JOIN at {innerJoinIdx}. Generated SQL:\n{generatedSql}"); + + // Ensure comment is on its own line BEFORE INNER JOIN, not trailing after "users u" + string beforeInnerJoinComment = generatedSql.Substring(0, innerJoinCommentIdx); + int lastNewlineBeforeComment = Math.Max( + beforeInnerJoinComment.LastIndexOf("\n"), + beforeInnerJoinComment.LastIndexOf("\r") + ); + string lineBeforeComment = lastNewlineBeforeComment >= 0 + ? beforeInnerJoinComment.Substring(lastNewlineBeforeComment).Trim() + : beforeInnerJoinComment.Trim(); + + Assert.IsFalse(lineBeforeComment.Contains("users") && lineBeforeComment.Contains("--"), + $"Comment should NOT be trailing on the 'users u' line. Line before comment: '{lineBeforeComment}'. Generated SQL:\n{generatedSql}"); + + // Verify there's a newline between the comment and INNER JOIN + string betweenCommentAndJoin = generatedSql.Substring( + innerJoinCommentIdx + "-- Join to get user orders".Length, + innerJoinIdx - (innerJoinCommentIdx + "-- Join to get user orders".Length) + ); + Assert.IsTrue(betweenCommentAndJoin.Contains("\n") || betweenCommentAndJoin.Contains("\r"), + $"There must be a newline between comment and INNER JOIN. Text between: '{betweenCommentAndJoin}'. Generated SQL:\n{generatedSql}"); + + // Same checks for LEFT JOIN comment + int leftJoinCommentIdx = generatedSql.IndexOf("/* Left join for optional address */"); + int leftJoinIdx = generatedSql.IndexOf("LEFT JOIN", StringComparison.OrdinalIgnoreCase); + Assert.IsTrue(leftJoinCommentIdx >= 0, "LEFT JOIN comment must exist"); Assert.IsTrue(leftJoinCommentIdx < leftJoinIdx, - $"LEFT JOIN comment should appear before LEFT JOIN. Comment at {leftJoinCommentIdx}, JOIN at {leftJoinIdx}"); + $"LEFT JOIN comment MUST appear before LEFT JOIN keyword. Comment at {leftJoinCommentIdx}, JOIN at {leftJoinIdx}. Generated SQL:\n{generatedSql}"); + + // Verify comment is on its own line, not trailing after previous JOIN + string beforeLeftJoinComment = generatedSql.Substring(0, leftJoinCommentIdx); + int lastNewlineBeforeLeftComment = Math.Max( + beforeLeftJoinComment.LastIndexOf("\n"), + beforeLeftJoinComment.LastIndexOf("\r") + ); + string lineBeforeLeftComment = lastNewlineBeforeLeftComment >= 0 + ? beforeLeftJoinComment.Substring(lastNewlineBeforeLeftComment).Trim() + : beforeLeftJoinComment.Trim(); + + Assert.IsFalse(lineBeforeLeftComment.Contains("JOIN") && lineBeforeLeftComment.Contains("/*"), + $"Comment should NOT be trailing on the previous JOIN line. Line before comment: '{lineBeforeLeftComment}'. Generated SQL:\n{generatedSql}"); } [TestMethod] @@ -1409,16 +1565,42 @@ UNION ALL Assert.IsTrue(generatedSql.Contains("/* Combine with archived users */"), "UNION block comment should be preserved. Actual: " + generatedSql); - // Verify position: comments should appear at correct positions relative to queries + // CRITICAL ASSERTIONS: Comments must appear BEFORE UNION keyword + // and NOT be on the same line as the previous SELECT statement + int firstQueryCommentIdx = generatedSql.IndexOf("-- First query: active users"); int firstSelectIdx = generatedSql.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase); int unionCommentIdx = generatedSql.IndexOf("/* Combine with archived users */"); int unionIdx = generatedSql.IndexOf("UNION", StringComparison.OrdinalIgnoreCase); + Assert.IsTrue(firstQueryCommentIdx >= 0, "First query comment must exist"); Assert.IsTrue(firstQueryCommentIdx < firstSelectIdx, - $"First query comment should appear before first SELECT. Comment at {firstQueryCommentIdx}, SELECT at {firstSelectIdx}"); + $"First query comment MUST appear before first SELECT. Comment at {firstQueryCommentIdx}, SELECT at {firstSelectIdx}. Generated SQL:\n{generatedSql}"); + + Assert.IsTrue(unionCommentIdx >= 0, "UNION comment must exist"); Assert.IsTrue(unionCommentIdx < unionIdx, - $"UNION comment should appear before UNION. Comment at {unionCommentIdx}, UNION at {unionIdx}"); + $"UNION comment MUST appear before UNION keyword. Comment at {unionCommentIdx}, UNION at {unionIdx}. Generated SQL:\n{generatedSql}"); + + // Ensure UNION comment is on its own line BEFORE UNION, not trailing after previous SELECT + string beforeUnionComment = generatedSql.Substring(0, unionCommentIdx); + int lastNewlineBeforeUnionComment = Math.Max( + beforeUnionComment.LastIndexOf("\n"), + beforeUnionComment.LastIndexOf("\r") + ); + string lineBeforeUnionComment = lastNewlineBeforeUnionComment >= 0 + ? beforeUnionComment.Substring(lastNewlineBeforeUnionComment).Trim() + : beforeUnionComment.Trim(); + + Assert.IsFalse(lineBeforeUnionComment.Contains("SELECT") && lineBeforeUnionComment.Contains("/*"), + $"UNION comment should NOT be trailing on the previous SELECT line. Line before comment: '{lineBeforeUnionComment}'. Generated SQL:\n{generatedSql}"); + + // Verify there's a newline between the comment and UNION keyword + string betweenCommentAndUnion = generatedSql.Substring( + unionCommentIdx + "/* Combine with archived users */".Length, + unionIdx - (unionCommentIdx + "/* Combine with archived users */".Length) + ); + Assert.IsTrue(betweenCommentAndUnion.Contains("\n") || betweenCommentAndUnion.Contains("\r"), + $"There must be a newline between comment and UNION. Text between: '{betweenCommentAndUnion}'. Generated SQL:\n{generatedSql}"); } [TestMethod] @@ -1732,6 +1914,87 @@ public void TestPreserveComments_LeadingCommentAfterGoBatch() Assert.AreEqual(0, reparseErrors.Count, "Generated SQL must reparse. Actual: " + generatedSql); } + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_MultipleCommentsInTableDefinition() + { + var sqlWithComments = @"CREATE TABLE dbo.VETask +( +Id INT, +TaskNo INT, +Status INT, +--IsActive BIT NOT NULL CONSTRAINT DF_VETask_IsActive DEFAULT 1 +CONSTRAINT PK_VETask PRIMARY KEY CLUSTERED (Id), +--CONSTRAINT UQ_VETask_TaskNo UNIQUE (TaskNo) +INDEX IX_VETask_Status NONCLUSTERED (Status) +);"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count, "Input SQL should parse without errors"); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // CRITICAL ASSERTIONS: Comments must NOT merge with previous lines + // and CONSTRAINT must NOT merge with "Status INT" line + + // 1. Comment should NOT be trailing on Status INT line + Assert.IsFalse(generatedSql.Contains("Status INT --IsActive"), + "Comment should NOT be trailing on Status INT line (no comma)"); + Assert.IsFalse(generatedSql.Contains("Status INT, --IsActive"), + "Comment should NOT be trailing on Status INT line (with comma)"); + Assert.IsFalse(generatedSql.Contains("Status INT, --IsActive"), + "Comment should NOT be trailing on Status INT line (with comma and space)"); + + // 2. CONSTRAINT must NOT be on the same line as Status INT + Assert.IsFalse(generatedSql.Contains("Status INT CONSTRAINT"), + $"CONSTRAINT keyword must NOT merge with Status INT line. Generated SQL:\n{generatedSql}"); + Assert.IsFalse(generatedSql.Contains("Status INT, CONSTRAINT"), + $"CONSTRAINT keyword must NOT be on same line as Status INT (with comma). Generated SQL:\n{generatedSql}"); + + // 3. Comment must appear BEFORE CONSTRAINT on its own line + int commentIdx = generatedSql.IndexOf("--IsActive BIT NOT NULL"); + int constraintIdx = generatedSql.IndexOf("CONSTRAINT PK_VETask", StringComparison.OrdinalIgnoreCase); + + Assert.IsTrue(commentIdx >= 0, "Comment for IsActive column must exist"); + Assert.IsTrue(constraintIdx >= 0, "CONSTRAINT PK_VETask must exist"); + Assert.IsTrue(commentIdx < constraintIdx, + $"Comment must appear BEFORE CONSTRAINT. Comment at {commentIdx}, CONSTRAINT at {constraintIdx}. Generated SQL:\n{generatedSql}"); + + // 4. Verify Status INT is on a separate line from the comment + int statusIdx = generatedSql.IndexOf("Status INT", StringComparison.OrdinalIgnoreCase); + Assert.IsTrue(statusIdx >= 0, "Status INT column must exist"); + + // Extract the line containing "Status INT" + int lineStartIdx = statusIdx; + while (lineStartIdx > 0 && generatedSql[lineStartIdx - 1] != '\n' && generatedSql[lineStartIdx - 1] != '\r') + { + lineStartIdx--; + } + int lineEndIdx = statusIdx; + while (lineEndIdx < generatedSql.Length && generatedSql[lineEndIdx] != '\n' && generatedSql[lineEndIdx] != '\r') + { + lineEndIdx++; + } + string statusLine = generatedSql.Substring(lineStartIdx, lineEndIdx - lineStartIdx).Trim(); + + Assert.IsFalse(statusLine.Contains("--"), + $"Status INT line must NOT contain the comment. Actual line: '{statusLine}'. Generated SQL:\n{generatedSql}"); + Assert.IsFalse(statusLine.Contains("CONSTRAINT"), + $"Status INT line must NOT contain CONSTRAINT keyword. Actual line: '{statusLine}'. Generated SQL:\n{generatedSql}"); + + var reparser = new TSql170Parser(true); + reparser.Parse(new StringReader(generatedSql), out var reparseErrors); + Assert.AreEqual(0, reparseErrors.Count, $"Generated SQL must reparse without errors. Generated SQL:\n{generatedSql}"); + } + + [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)] @@ -1983,5 +2246,953 @@ 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() + { + // The INSERT column target list is always emitted as a single-line parenthesized + // list (via GenerateParenthesisedCommaSeparatedList), and CommaPlacement only affects + // multi-line lists. MultilineInsertTargetsList is not consumed by the INSERT visitor, + // so the targets stay on one line and CommaPlacement = Leading has no visual effect: + // the output is identical to the trailing case below. + 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 (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 TestCommaPlacementTrailingInsertTargets() + { + // Same output as the leading case: because the INSERT target list renders on a + // single line (CommaPlacement only affects multi-line lists, and + // MultilineInsertTargetsList is not consumed here), leading and trailing placement + // produce identical text. + 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 (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 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.) + 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 + }); + 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 TestCommaPlacementTrailingInsertSources() + { + // The INSERT source (VALUES) row list with CommaPlacement = Trailing (default): + // each row's comma follows it at the end of the line. + 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. + 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/UTSqlScriptDom.csproj b/Test/SqlDom/UTSqlScriptDom.csproj index c4ade393..44ebc9ca 100644 --- a/Test/SqlDom/UTSqlScriptDom.csproj +++ b/Test/SqlDom/UTSqlScriptDom.csproj @@ -12,6 +12,7 @@ + diff --git a/release-notes/180/180.59.2.md b/release-notes/180/180.59.2.md new file mode 100644 index 00000000..7aac6281 --- /dev/null +++ b/release-notes/180/180.59.2.md @@ -0,0 +1,34 @@ +# Release Notes + +## Microsoft.SqlServer.TransactSql.ScriptDom 180.59.2 +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 formatter options for identifier casing and identifier bracketing. + +### Fixed +* Fixes SQL formatter indentation after comments. +* Adds CommaPlacement option for script generation formatting. +* Adds ColumnAliasStyle option for script generation. +* Adds IndentationMode option (spaces or tabs) for script generation. +* Adds JOIN formatting options: NewLineAfterJoinKeyword and NewLineBeforeOnClause. +* Fixes PreserveComments preventing AlignColumnDefinitionFields behavior in script generation. +* Adds IdentifierCasing and IdentifierBracketing formatter options. + +### Changes +* None + +### Known Issues +* None \ No newline at end of file