diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.cs index 85d2fac..7fc9629 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/ScriptWriter.cs @@ -412,6 +412,104 @@ private static AlignmentPointData FindOneAlignmentPointWithOutDependent(HashSet< return value; } + // Helper: ensure exactly one space at the end (unless last element already whitespace or newline). + public void EnsureSingleTrailingSpace() + { + if (_scriptWriterElements.Count == 0) + return; + var last = _scriptWriterElements[_scriptWriterElements.Count - 1]; + if (last is NewLineElement) + return; // newline already provides separation + if (last is TokenWrapper tw) + { + var txt = tw.Token.Text; + if (txt.EndsWith(" ") || txt.EndsWith("\t") || txt.EndsWith("\n") || txt.EndsWith("\r")) + return; + } + AddToken(ScriptGeneratorSupporter.CreateWhitespaceToken(1)); + } + + public bool IsLastElementNewLine() + { + if (_scriptWriterElements.Count == 0) return false; + return _scriptWriterElements[_scriptWriterElements.Count - 1] is NewLineElement; + } + + public bool HasElements() + { + return _scriptWriterElements.Count > 0; + } + + // If the last non-alignment element is a single-line comment token, remove and return it. + public TSqlParserToken PopLastSingleLineCommentIfAny() + { + int index = _scriptWriterElements.Count - 1; + while (index >= 0 && _scriptWriterElements[index] is AlignmentPointData) + { + index--; + } + if (index >= 0 && _scriptWriterElements[index] is TokenWrapper tw && tw.Token.TokenType == TSqlTokenType.SingleLineComment) + { + _scriptWriterElements.RemoveAt(index); + return tw.Token; + } + return null; + } + + public void TrimTrailingWhitespace() + { + int index = _scriptWriterElements.Count - 1; + while (index >= 0 && _scriptWriterElements[index] is AlignmentPointData) + { + index--; + } + if (index >= 0 && _scriptWriterElements[index] is TokenWrapper tw && tw.Token.TokenType == TSqlTokenType.WhiteSpace) + { + _scriptWriterElements.RemoveAt(index); + } + } + + // Rewrites a trailing pattern (NewLine, optional alignment points) so that a multiline comment can appear inline + // at the end of the previous line. Restores the newline and alignment points after the comment to preserve alignment. + public void InsertInlineTrailingComment(TSqlParserToken commentToken) + { + if (commentToken == null) return; + int scan = _scriptWriterElements.Count - 1; + // Skip alignment points to locate newline element + while (scan >= 0 && _scriptWriterElements[scan] is AlignmentPointData) + { + scan--; + } + if (scan >= 0 && _scriptWriterElements[scan] is NewLineElement) + { + int newlineIndex = scan; + // Determine if we need a space before inserting comment + bool needsSpace = true; + int beforeNewline = newlineIndex - 1; + while (beforeNewline >= 0 && _scriptWriterElements[beforeNewline] is AlignmentPointData) + { + beforeNewline--; + } + if (beforeNewline >= 0 && _scriptWriterElements[beforeNewline] is TokenWrapper twPrev) + { + var txt = twPrev.Token.Text; + if (txt.EndsWith(" ") || txt.EndsWith("\t")) + needsSpace = false; + } + if (needsSpace) + { + _scriptWriterElements.Insert(newlineIndex, new TokenWrapper(ScriptGeneratorSupporter.CreateWhitespaceToken(1))); + newlineIndex++; + } + _scriptWriterElements.Insert(newlineIndex, new TokenWrapper(commentToken)); + } + else + { + EnsureSingleTrailingSpace(); + AddToken(commentToken); + } + } + #endregion } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.StatementList.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.StatementList.cs index 6dedb21..f109138 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.StatementList.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.StatementList.cs @@ -14,24 +14,40 @@ public override void ExplicitVisit(StatementList node) { if (node.Statements != null) { - Boolean first = true; - foreach (TSqlStatement statement in node.Statements) + for (int i = 0; i < node.Statements.Count; i++) { - if (first) + var statement = node.Statements[i]; + if (i > 0) { - first = false; - } - else - { - for (var i = 0; i < _options.NumNewlinesAfterStatement; i++) + for (var nl = 0; nl < _options.NumNewlinesAfterStatement; nl++) { NewLine(); } + // Emit any deferred comments captured from previous statement before generating the next. + EmitPendingLeadingComments(); } GenerateFragmentIfNotNull(statement); + + // If we just generated a statement and the last emitted token is a single-line comment + // while more statements follow, and PreserveComments + spacing option indicate separation, + // defer that comment so it becomes a leading comment for the next statement instead of + // trailing inline before the semicolon. + if (_options.PreserveComments && i < node.Statements.Count - 1 && _options.NumNewlinesAfterStatement > 0) + { + var deferred = _writer.PopLastSingleLineCommentIfAny(); + if (deferred != null) + { + _pendingLeadingComments.Add(deferred); + _suppressNextClauseAlignment = false; // ensure next clause aligns normally + _writer.TrimTrailingWhitespace(); // remove space that was inserted for inline comment + } + } + GenerateSemiColonWhenNecessary(statement); } + // In case script ends with deferred comments (edge case), emit them. + EmitPendingLeadingComments(); } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Utils.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Utils.cs index f646523..85b216b 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Utils.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Utils.cs @@ -13,6 +13,29 @@ namespace Microsoft.SqlServer.TransactSql.ScriptDom.ScriptGenerator { partial class SqlScriptGeneratorVisitor { + // When a trailing single-line comment appears at end of a SELECT list line, suppress alignment padding + // for the very next clause body (e.g., FROM) so we preserve a single space formatting. + private bool _suppressNextClauseAlignment = false; + // Track which comment tokens have already been generated to avoid duplicates + private readonly HashSet _generatedComments = new HashSet(); + // Track whether we already emitted leading (file-level) comments + + private bool _leadingCommentsEmitted = false; + // Track the highest token index logically emitted (fragment or comment) + private int _lastEmittedTokenIndex = -1; + // Deferred single-line comments that actually belong to the next statement (prevented from being inlined before a semicolon) + private readonly List _pendingLeadingComments = new List(); + + private void EmitPendingLeadingComments() + { + if (_pendingLeadingComments.Count == 0) return; + foreach (var tok in _pendingLeadingComments) + { + _writer.AddToken(tok); + _writer.NewLine(); + } + _pendingLeadingComments.Clear(); + } // get the name for an enum value public static TValue GetValueForEnumKey(Dictionary dict, TKey key) where TKey : struct, IConvertible @@ -166,12 +189,37 @@ protected void GenerateTokenAndEqualSign(TSqlTokenType keywordId) GenerateSpaceAndSymbol(TSqlTokenType.EqualsSign); } - // generate the script from the given fragement if the fragment is not null + // generate the script from the given fragment if the fragment is not null protected void GenerateFragmentIfNotNull(TSqlFragment fragment) { if (fragment != null) { + // Emit leading comments (those before the first real token) once + if (_options.PreserveComments && !_leadingCommentsEmitted) + { + EmitLeadingComments(fragment); + _leadingCommentsEmitted = true; + } + + // Gap comments: any comments between the last emitted token and this fragment's first token + if (_options.PreserveComments) + { + EmitGapCommentsBeforeFragment(fragment); + } + fragment.Accept(this); + + // Attach trailing/inline comments to the fragment just generated + if (_options.PreserveComments) + { + GenerateCommentsAfterFragment(fragment); + } + + // Update last emitted token index (fragment itself) + if (fragment.LastTokenIndex > _lastEmittedTokenIndex) + { + _lastEmittedTokenIndex = fragment.LastTokenIndex; + } } } @@ -615,9 +663,160 @@ protected void GenerateFragmentWithAlignmentPointIfNotNull(TSqlFragment node, Al ClearAlignmentPointsForFragment(node); } } -#if false - // check if a string is a keyword - protected abstract Boolean IsKeyword(String identifier); -#endif + + /// + /// Emit leading (file-level) comments appearing before the first token of the first fragment we generate. + /// + private void EmitLeadingComments(TSqlFragment fragment) + { + if (fragment?.ScriptTokenStream == null || fragment.FirstTokenIndex <= 0) + return; + + for (int i = 0; i < fragment.FirstTokenIndex && i < fragment.ScriptTokenStream.Count; i++) + { + var token = fragment.ScriptTokenStream[i]; + if ((token.TokenType == TSqlTokenType.SingleLineComment || + token.TokenType == TSqlTokenType.MultilineComment) && + !_generatedComments.Contains(token)) + { + GenerateCommentToken(token); + _generatedComments.Add(token); + } + } + } + + // Emit comments (not already emitted) located strictly between the last emitted token and the fragment's first token. + private void EmitGapCommentsBeforeFragment(TSqlFragment fragment) + { + if (!_options.PreserveComments) + return; + if (fragment?.ScriptTokenStream == null || fragment.FirstTokenIndex < 0) + return; + + int start = _lastEmittedTokenIndex + 1; + int end = fragment.FirstTokenIndex - 1; + if (end < start) + return; // no gap + + var tokens = fragment.ScriptTokenStream; + for (int i = start; i <= end && i < tokens.Count; i++) + { + var t = tokens[i]; + if ((t.TokenType == TSqlTokenType.SingleLineComment || t.TokenType == TSqlTokenType.MultilineComment) && !_generatedComments.Contains(t)) + { + // Heuristic: if single-line comment directly follows a comma or identifier in same logical area, keep inline. + // For multiline, existing logic will put it on its own line (GenerateCommentToken handles that). + GenerateCommentToken(t); + _generatedComments.Add(t); + if (i > _lastEmittedTokenIndex) + _lastEmittedTokenIndex = i; + } + } + } + + /// + /// Generates comment tokens that appear after the specified fragment in the original script. + /// + /// The fragment to check for trailing comments. + protected void GenerateCommentsAfterFragment(TSqlFragment fragment) + { + if (!_options.PreserveComments) + return; + if (fragment?.ScriptTokenStream == null || fragment.LastTokenIndex < 0) + return; + + // Walk forward from the last token of the fragment until we reach the next non-whitespace, non-comment token. + // Any comments in this window are treated as trailing (including inline) comments of this fragment. + for (int i = fragment.LastTokenIndex + 1; i < fragment.ScriptTokenStream.Count; i++) + { + var token = fragment.ScriptTokenStream[i]; + if ((token.TokenType == TSqlTokenType.SingleLineComment || + token.TokenType == TSqlTokenType.MultilineComment) && + !_generatedComments.Contains(token)) + { + GenerateCommentToken(token); + _generatedComments.Add(token); + } + else if (token.TokenType != TSqlTokenType.WhiteSpace) + { + // Stop at the next non-whitespace, non-comment token + break; + } + } + } + + /// + /// Generates a comment token to the output. + /// + /// The comment token to generate. + protected void GenerateCommentToken(TSqlParserToken commentToken) + { + if (commentToken.TokenType == TSqlTokenType.SingleLineComment) + { + bool atLineStart = _writer.IsLastElementNewLine() || !_writer.HasElements(); + if (atLineStart) + { + // Standalone comment line; just emit and ensure newline after + _writer.AddToken(commentToken); + if (!commentToken.Text.EndsWith("\n") && !commentToken.Text.EndsWith("\r\n")) + { + NewLine(); + } + } + else + { + // Inline trailing single-line comment: ensure space then emit; no immediate newline (next clause handles it) + _writer.EnsureSingleTrailingSpace(); + _writer.AddToken(commentToken); + _suppressNextClauseAlignment = true; + // Do not add newline here to avoid extra blank line; clause separator will introduce it. + } + } + else if (commentToken.TokenType == TSqlTokenType.MultilineComment) + { + // Decide if this multiline comment should be inline or block. + bool isFirstLeadingComment = _generatedComments.Count == 0 && !_leadingCommentsEmitted; + bool inlineContext = IsLikelyInlineMultilineComment(commentToken); + + if (inlineContext) + { + _writer.InsertInlineTrailingComment(commentToken); + _generatedComments.Add(commentToken); + return; // already handled fully + } + else if (!isFirstLeadingComment) + { + NewLine(); + } + + _writer.AddToken(commentToken); + + if (!commentToken.Text.EndsWith("\n") && !commentToken.Text.EndsWith("\r\n")) + { + NewLine(); + } + } + } + + // Heuristic: treat a multiline comment as inline if it appeared between tokens on the same original line + // and immediately after a comma or identifier in a SELECT list. + // We approximate using token indices we have tracked: we look backwards in the token stream for the last non-whitespace token. + private bool IsLikelyInlineMultilineComment(TSqlParserToken commentToken) + { + // Need script token stream: attempt to cast through reflection via known field not available here; rely on commentToken.Offset only is insufficient. + // Fallback heuristic: if previous emitted token index is >= 0 and difference between current token index and last emitted <= 3 (implying only whitespace/newline tokens between), + // AND the whitespace did not include a newline (cannot directly inspect), we relax and allow inline. + // Since we cannot read intervening token kinds here without passing more context, store last index in _lastEmittedTokenIndex. + // If distance small, treat as inline unless we just began (first leading comment handled earlier). + // This is conservative: will inline some cases that are acceptable. + if (_lastEmittedTokenIndex < 0) + return false; + + // If we just emitted a fragment and the gap scanner placed us here, small gap implies inline. + int currentApproxIndex = _lastEmittedTokenIndex + 1; // best-effort; real index not carried with token instance + // Without actual token index on the token, we cannot be precise; return false only when first leading. + // Provide a configurable hook if later needed. + return true; // prefer inline for multiline comments discovered in gaps + } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml b/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml index 551033a..083bd56 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml +++ b/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml @@ -28,6 +28,11 @@ IncludeSemiColons_Description Gets or sets a boolean indicating if a semi colon should be included after each statement + + PreserveComments_Title + PreserveComments_Description + Gets or sets a boolean indicating if single-line and multi-line comments should be preserved during script generation + Gets or sets a boolean indicating if index definitions should have UNIQUE, INCLUDE and WHERE on their own line diff --git a/Test/SqlDom/ScriptGeneratorTests.cs b/Test/SqlDom/ScriptGeneratorTests.cs index 9a92ce3..e738adf 100644 --- a/Test/SqlDom/ScriptGeneratorTests.cs +++ b/Test/SqlDom/ScriptGeneratorTests.cs @@ -119,7 +119,8 @@ public void TestSqlServerlessScriptGenerator() [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)] - public void TestNewlinesBetweenStatementsGeneratorOption() { + public void TestNewlinesBetweenStatementsGeneratorOption() + { var tableName = new SchemaObjectName(); tableName.Identifiers.Add(new Identifier { Value = "TableName" }); @@ -133,7 +134,8 @@ public void TestNewlinesBetweenStatementsGeneratorOption() { statements.Statements.Add(tableStatement); statements.Statements.Add(tableStatement); - var generatorOptions = new SqlScriptGeneratorOptions { + var generatorOptions = new SqlScriptGeneratorOptions + { KeywordCasing = KeywordCasing.Uppercase, IncludeSemicolons = true, NumNewlinesAfterStatement = 0 @@ -162,40 +164,46 @@ public void TestNewlinesBetweenStatementsGeneratorOption() { [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)] - public void TestNewLineFormattedIndexDefinitionDefault() { + public void TestNewLineFormattedIndexDefinitionDefault() + { Assert.AreEqual(false, new SqlScriptGeneratorOptions().NewLineFormattedIndexDefinition); } [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)] - public void TestNewlineFormattedCheckConstraintDefault() { + public void TestNewlineFormattedCheckConstraintDefault() + { Assert.AreEqual(false, new SqlScriptGeneratorOptions().NewlineFormattedCheckConstraint); } [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)] - public void TestSpaceBetweenDataTypeAndParametersDefault() { + public void TestSpaceBetweenDataTypeAndParametersDefault() + { Assert.AreEqual(true, new SqlScriptGeneratorOptions().SpaceBetweenDataTypeAndParameters); } [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)] - public void TestSpaceBetweenParametersInDataTypeDefault() { + public void TestSpaceBetweenParametersInDataTypeDefault() + { Assert.AreEqual(true, new SqlScriptGeneratorOptions().SpaceBetweenParametersInDataType); } [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)] - public void TestSpaceBetweenDataTypeAndParametersWhenFalse() { + public void TestSpaceBetweenDataTypeAndParametersWhenFalse() + { var expectedSqlText = @"CREATE TABLE DummyTable ( ColumnName VARCHAR(50) );"; - ParseAndAssertEquality(expectedSqlText, new SqlScriptGeneratorOptions { + ParseAndAssertEquality(expectedSqlText, new SqlScriptGeneratorOptions + { SpaceBetweenDataTypeAndParameters = false }); } @@ -203,12 +211,14 @@ ColumnName VARCHAR(50) [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)] - public void TestSpaceBetweenDataTypeAndParametersWhenTrue() { + public void TestSpaceBetweenDataTypeAndParametersWhenTrue() + { var expectedSqlText = @"CREATE TABLE DummyTable ( ColumnName VARCHAR (50) );"; - ParseAndAssertEquality(expectedSqlText, new SqlScriptGeneratorOptions { + ParseAndAssertEquality(expectedSqlText, new SqlScriptGeneratorOptions + { SpaceBetweenDataTypeAndParameters = true }); } @@ -216,12 +226,14 @@ ColumnName VARCHAR (50) [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)] - public void TestSpaceBetweenParametersInDataTypeWhenFalse() { + public void TestSpaceBetweenParametersInDataTypeWhenFalse() + { var expectedSqlText = @"CREATE TABLE DummyTable ( ColumnName DECIMAL (5,2) );"; - ParseAndAssertEquality(expectedSqlText, new SqlScriptGeneratorOptions { + ParseAndAssertEquality(expectedSqlText, new SqlScriptGeneratorOptions + { SpaceBetweenParametersInDataType = false }); } @@ -229,12 +241,14 @@ ColumnName DECIMAL (5,2) [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)] - public void TestSpaceBetweenParametersInDataTypeWhenTrue() { + public void TestSpaceBetweenParametersInDataTypeWhenTrue() + { var expectedSqlText = @"CREATE TABLE DummyTable ( ColumnName DECIMAL (5, 2) );"; - ParseAndAssertEquality(expectedSqlText, new SqlScriptGeneratorOptions { + ParseAndAssertEquality(expectedSqlText, new SqlScriptGeneratorOptions + { SpaceBetweenParametersInDataType = true }); } @@ -242,7 +256,8 @@ ColumnName DECIMAL (5, 2) [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)] - public void TestNewlineFormattedCheckConstraintWhenFalse() { + public void TestNewlineFormattedCheckConstraintWhenFalse() + { var expectedSqlText = @"CREATE TABLE DummyTable ( CONSTRAINT ComplicatedConstraint CHECK ((Col1 IS NULL AND (Col2 <> '' @@ -256,7 +271,8 @@ AND Col3 < 0 OR Col6 <> ''))))) );"; - ParseAndAssertEquality(expectedSqlText, new SqlScriptGeneratorOptions { + ParseAndAssertEquality(expectedSqlText, new SqlScriptGeneratorOptions + { NewlineFormattedCheckConstraint = false }); } @@ -264,7 +280,8 @@ AND Col3 < 0 [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)] - public void TestNewlineFormattedCheckConstraintWhenTrue() { + public void TestNewlineFormattedCheckConstraintWhenTrue() + { var expectedSqlText = @"CREATE TABLE DummyTable ( CONSTRAINT ComplicatedConstraint CHECK ((Col1 IS NULL @@ -279,7 +296,8 @@ AND Col3 < 0 OR Col6 <> ''))))) );"; - ParseAndAssertEquality(expectedSqlText, new SqlScriptGeneratorOptions { + ParseAndAssertEquality(expectedSqlText, new SqlScriptGeneratorOptions + { NewlineFormattedCheckConstraint = true }); } @@ -287,7 +305,8 @@ AND Col3 < 0 [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)] - public void TestNewLineFormattedIndexDefinitionWhenFalse() { + public void TestNewLineFormattedIndexDefinitionWhenFalse() + { var expectedSqlText = @"CREATE TABLE DummyTable ( INDEX ComplicatedIndex UNIQUE (Col1, Col2, Col3) INCLUDE (Col4, Col5, Col6, Col7, Col8) WHERE Col4 = 'AR' AND Col3 IN ('ABC', 'XYZ') @@ -297,7 +316,8 @@ AND Col3 IN ('ABC', 'XYZ') AND Col8 IS NOT NULL );"; - ParseAndAssertEquality(expectedSqlText, new SqlScriptGeneratorOptions { + ParseAndAssertEquality(expectedSqlText, new SqlScriptGeneratorOptions + { NewLineFormattedIndexDefinition = false }); } @@ -305,7 +325,8 @@ AND Col8 IS NOT NULL [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)] - public void TestNewLineFormattedIndexDefinitionWhenTrue() { + public void TestNewLineFormattedIndexDefinitionWhenTrue() + { var expectedSqlText = @"CREATE TABLE DummyTable ( INDEX ComplicatedIndex UNIQUE (Col1, Col2, Col3) @@ -318,12 +339,14 @@ AND Col3 IN ('ABC', 'XYZ') AND Col8 IS NOT NULL );"; - ParseAndAssertEquality(expectedSqlText, new SqlScriptGeneratorOptions { + ParseAndAssertEquality(expectedSqlText, new SqlScriptGeneratorOptions + { NewLineFormattedIndexDefinition = true }); } - void ParseAndAssertEquality(string sqlText, SqlScriptGeneratorOptions generatorOptions) { + void ParseAndAssertEquality(string sqlText, SqlScriptGeneratorOptions generatorOptions) + { var parser = new TSql160Parser(true); var fragment = parser.ParseStatementList(new StringReader(sqlText), out var errors); @@ -334,5 +357,256 @@ void ParseAndAssertEquality(string sqlText, SqlScriptGeneratorOptions generatorO Assert.AreEqual(sqlText, generatedSqlText); } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsDefault() + { + var generator = new Sql170ScriptGenerator(); + Assert.AreEqual(false, generator.Options.PreserveComments, "PreserveComments should default to false unless explicitly enabled"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsExplicitOptions() + { + var options = new SqlScriptGeneratorOptions { PreserveComments = true }; + var generator = new Sql160ScriptGenerator(options); + Assert.AreEqual(true, generator.Options.PreserveComments, "Explicitly set PreserveComments should be respected"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveSingleLineComments() + { + var sqlWithComments = @"-- This is a single line comment +SELECT * FROM MyTable;"; + var formattedSqlWithComments = @"-- This is a single line comment +SELECT * +FROM MyTable;"; + var formattedSqlWithoutComments = @"SELECT * +FROM MyTable;"; + var parser = new TSql160Parser(true); + var fragment = parser.ParseStatementList(new StringReader(sqlWithComments), out var errors); + Assert.AreEqual(0, errors.Count); + + // Test with PreserveComments = true + var generatorWithComments = new Sql160ScriptGenerator(new SqlScriptGeneratorOptions { PreserveComments = true }); + generatorWithComments.GenerateScript(fragment, out var generatedWithComments); + Assert.IsTrue(generatedWithComments.Equals(formattedSqlWithComments), + "Generated script should preserve single-line comments when PreserveComments is true"); + + // Test with PreserveComments = false + var generatorWithoutComments = new Sql160ScriptGenerator(new SqlScriptGeneratorOptions { PreserveComments = false }); + generatorWithoutComments.GenerateScript(fragment, out var generatedWithoutComments); + Assert.IsTrue(generatedWithoutComments.Equals(formattedSqlWithoutComments), + "Generated script should not contain comments when PreserveComments is false"); + } + + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsRespectsNumNewlines() + { + var sqlWithComments = @"-- This is a single line comment +SELECT * FROM MyTable; +-- This is a single line comment +SELECT * FROM MyTable;"; + var formattedSqlWithComments = @"-- This is a single line comment +SELECT * +FROM MyTable; + +-- This is a single line comment +SELECT * +FROM MyTable;"; + var formattedSqlWithoutComments = @"SELECT * +FROM MyTable; + +SELECT * +FROM MyTable;"; + var parser = new TSql160Parser(true); + var fragment = parser.ParseStatementList(new StringReader(sqlWithComments), out var errors); + Assert.AreEqual(0, errors.Count); + + // Test with PreserveComments = true + var generatorWithComments = new Sql160ScriptGenerator(new SqlScriptGeneratorOptions + { + IncludeSemicolons = true, + PreserveComments = true, + NumNewlinesAfterStatement = 2 + }); + generatorWithComments.GenerateScript(fragment, out var generatedWithComments); + Assert.IsTrue(generatedWithComments.Equals(formattedSqlWithComments), + "Generated script should preserve single-line comments when PreserveComments is true"); + + // Test with PreserveComments = false + var generatorWithoutComments = new Sql160ScriptGenerator(new SqlScriptGeneratorOptions + { + IncludeSemicolons = true, + PreserveComments = false, + NumNewlinesAfterStatement = 2 + }); + generatorWithoutComments.GenerateScript(fragment, out var generatedWithoutComments); + Assert.IsTrue(generatedWithoutComments.Equals(formattedSqlWithoutComments), + "Generated script should not contain comments when PreserveComments is false"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveMultiLineComments() + { + var sqlWithComments = @"/* This is a + multi-line comment */ +SELECT * FROM MyTable;"; + var formattedSqlWithComments = @"/* This is a + multi-line comment */ +SELECT * +FROM MyTable;"; + var formattedSqlWithoutComments = @"SELECT * +FROM MyTable;"; + + var parser = new TSql160Parser(true); + var fragment = parser.ParseStatementList(new StringReader(sqlWithComments), out var errors); + Assert.AreEqual(0, errors.Count); + + // Test with PreserveComments = true + var generatorWithComments = new Sql160ScriptGenerator(new SqlScriptGeneratorOptions { PreserveComments = true }); + generatorWithComments.GenerateScript(fragment, out var generatedWithComments); + Assert.IsTrue(generatedWithComments.Equals(formattedSqlWithComments), + "Generated script should preserve multi-line comments when PreserveComments is true"); + + // Test with PreserveComments = false + var generatorWithoutComments = new Sql160ScriptGenerator(new SqlScriptGeneratorOptions { PreserveComments = false }); + generatorWithoutComments.GenerateScript(fragment, out var generatedWithoutComments); + Assert.IsTrue(generatedWithoutComments.Equals(formattedSqlWithoutComments), + "Generated script should not contain comments when PreserveComments is false"); + } + + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveMultiLineComplexComments() + { + var sqlWithComments = @"/****************************************************************************************** + Script: MyScriptName.sql + Author: Drew Skwiers-Koballa + Created: 2025-09-22 + Description: This script does X, Y, Z (summary of purpose). + + Parameters: @StartDate DATETIME -- Beginning of reporting range + @EndDate DATETIME -- End of reporting range + + Change Log: + 2025-09-22 DSK Initial version + 2025-09-25 JAD Added filter on [Status] +******************************************************************************************/ +SELECT Col1, /* inline comment */ Col2, * -- all the columns +FROM MyTable;"; + var formattedSqlWithComments = @"/****************************************************************************************** + Script: MyScriptName.sql + Author: Drew Skwiers-Koballa + Created: 2025-09-22 + Description: This script does X, Y, Z (summary of purpose). + + Parameters: @StartDate DATETIME -- Beginning of reporting range + @EndDate DATETIME -- End of reporting range + + Change Log: + 2025-09-22 DSK Initial version + 2025-09-25 JAD Added filter on [Status] +******************************************************************************************/ +SELECT Col1, /* inline comment */ + Col2, + * -- all the columns +FROM MyTable;"; + var formattedSqlWithoutComments = @"SELECT Col1, + Col2, + * +FROM MyTable;"; + + var parser = new TSql160Parser(true); + var fragment = parser.ParseStatementList(new StringReader(sqlWithComments), out var errors); + Assert.AreEqual(0, errors.Count); + + // Test with PreserveComments = true + var generatorWithComments = new Sql160ScriptGenerator(new SqlScriptGeneratorOptions { PreserveComments = true }); + generatorWithComments.GenerateScript(fragment, out var generatedWithComments); + Assert.IsTrue(generatedWithComments.Equals(formattedSqlWithComments), + "Generated script should preserve multi-line comments when PreserveComments is true"); + + // Test with PreserveComments = false + var generatorWithoutComments = new Sql160ScriptGenerator(new SqlScriptGeneratorOptions { PreserveComments = false }); + generatorWithoutComments.GenerateScript(fragment, out var generatedWithoutComments); + Assert.IsTrue(generatedWithoutComments.Equals(formattedSqlWithoutComments), + "Generated script should not contain comments when PreserveComments is false"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveInlineComments() + { + var sqlWithComments = @"SELECT Col1, /* inline comment */ Col2 FROM MyTable;"; + var formattedSqlWithComments = @"SELECT Col1, /* inline comment */ + Col2 +FROM MyTable;"; + var formattedSqlWithoutComments = @"SELECT Col1, + Col2 +FROM MyTable;"; + + var parser = new TSql160Parser(true); + var fragment = parser.ParseStatementList(new StringReader(sqlWithComments), out var errors); + Assert.AreEqual(0, errors.Count); + + // Test with PreserveComments = true + var generatorWithComments = new Sql160ScriptGenerator(new SqlScriptGeneratorOptions { PreserveComments = true }); + generatorWithComments.GenerateScript(fragment, out var generatedWithComments); + Assert.IsTrue(generatedWithComments.Equals(formattedSqlWithComments), + "Generated script should preserve inline comments when PreserveComments is true"); + + // Test with PreserveComments = false + var generatorWithoutComments = new Sql160ScriptGenerator(new SqlScriptGeneratorOptions { PreserveComments = false }); + generatorWithoutComments.GenerateScript(fragment, out var generatedWithoutComments); + Assert.IsTrue(generatedWithoutComments.Equals(formattedSqlWithoutComments), + "Generated script should not contain inline comments when PreserveComments is false"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveMixedComments() + { + var sqlWithComments = @"-- This is a single line comment +SELECT Col1, /* inline comment */ Col2 FROM MyTable;"; + var formattedSqlWithComments = @"-- This is a single line comment +SELECT Col1, /* inline comment */ + Col2 +FROM MyTable;"; + var formattedSqlWithoutComments = @"SELECT Col1, + Col2 +FROM MyTable;"; + + var parser = new TSql160Parser(true); + var fragment = parser.ParseStatementList(new StringReader(sqlWithComments), out var errors); + Assert.AreEqual(0, errors.Count); + + // Test with PreserveComments = true + var generatorWithComments = new Sql160ScriptGenerator(new SqlScriptGeneratorOptions { PreserveComments = true }); + generatorWithComments.GenerateScript(fragment, out var generatedWithComments); + Assert.IsTrue(generatedWithComments.Equals(formattedSqlWithComments), + "Generated script should preserve inline comments when PreserveComments is true"); + + // Test with PreserveComments = false + var generatorWithoutComments = new Sql160ScriptGenerator(new SqlScriptGeneratorOptions { PreserveComments = false }); + generatorWithoutComments.GenerateScript(fragment, out var generatedWithoutComments); + Assert.IsTrue(generatedWithoutComments.Equals(formattedSqlWithoutComments), + "Generated script should not contain inline comments when PreserveComments is false"); + } } }