From 67832586a19845d15c23a77df6e97a55ae4bf6b3 Mon Sep 17 00:00:00 2001 From: Drew Skwiers-Koballa Date: Thu, 29 Jan 2026 13:44:08 -0800 Subject: [PATCH 1/5] revised approach --- .../SqlServer/ScriptGenerator/CommentInfo.cs | 66 +++++ .../ScriptGenerator/CommentPosition.cs | 29 ++ .../SqlScriptGeneratorVisitor.Comments.cs | 264 ++++++++++++++++++ .../SqlScriptGeneratorVisitor.TSqlBatch.cs | 12 + .../SqlScriptGeneratorVisitor.TSqlScript.cs | 12 + .../Settings/SqlScriptGeneratorOptions.xml | 3 + .../Baselines170/MultiLineCommentTests170.sql | 9 + .../SingleLineCommentTests170.sql | 10 + Test/SqlDom/Only170SyntaxTests.cs | 5 +- Test/SqlDom/ScriptGeneratorTests.cs | 165 +++++++++++ .../TestScripts/MultiLineCommentTests170.sql | 16 ++ .../TestScripts/SingleLineCommentTests170.sql | 14 + 12 files changed, 604 insertions(+), 1 deletion(-) create mode 100644 SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/CommentInfo.cs create mode 100644 SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/CommentPosition.cs create mode 100644 SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Comments.cs create mode 100644 Test/SqlDom/Baselines170/MultiLineCommentTests170.sql create mode 100644 Test/SqlDom/Baselines170/SingleLineCommentTests170.sql create mode 100644 Test/SqlDom/TestScripts/MultiLineCommentTests170.sql create mode 100644 Test/SqlDom/TestScripts/SingleLineCommentTests170.sql diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/CommentInfo.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/CommentInfo.cs new file mode 100644 index 0000000..b9b2f58 --- /dev/null +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/CommentInfo.cs @@ -0,0 +1,66 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.SqlServer.TransactSql.ScriptDom.ScriptGenerator +{ + /// + /// Intermediate structure for processing comments during script generation. + /// + internal sealed class CommentInfo + { + /// + /// Gets or sets the original comment token from the token stream. + /// + public TSqlParserToken Token { get; set; } + + /// + /// Gets or sets the position of the comment relative to code. + /// + public CommentPosition Position { get; set; } + + /// + /// Gets or sets the token index of the associated fragment. + /// + public int AssociatedFragmentIndex { get; set; } + + /// + /// Gets whether this is a single-line comment (-- style). + /// + public bool IsSingleLineComment + { + get { return Token != null && Token.TokenType == TSqlTokenType.SingleLineComment; } + } + + /// + /// Gets whether this is a multi-line comment (/* */ style). + /// + public bool IsMultiLineComment + { + get { return Token != null && Token.TokenType == TSqlTokenType.MultilineComment; } + } + + /// + /// Gets the text content of the comment. + /// + public string Text + { + get { return Token?.Text; } + } + + /// + /// Creates a new CommentInfo instance. + /// + /// The comment token. + /// The position relative to code. + /// The associated fragment token index. + public CommentInfo(TSqlParserToken token, CommentPosition position, int fragmentIndex) + { + Token = token; + Position = position; + AssociatedFragmentIndex = fragmentIndex; + } + } +} diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/CommentPosition.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/CommentPosition.cs new file mode 100644 index 0000000..f58c6be --- /dev/null +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/CommentPosition.cs @@ -0,0 +1,29 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.SqlServer.TransactSql.ScriptDom.ScriptGenerator +{ + /// + /// Categorizes comment placement relative to code during script generation. + /// + internal enum CommentPosition + { + /// + /// Comment appears before the associated code on previous line(s). + /// + Leading, + + /// + /// Comment appears after code on the same line. + /// + Trailing, + + /// + /// Comment not directly associated with code (e.g., end of file, standalone block). + /// + Standalone + } +} diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Comments.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Comments.cs new file mode 100644 index 0000000..365b3bc --- /dev/null +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Comments.cs @@ -0,0 +1,264 @@ +//------------------------------------------------------------------------------ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//------------------------------------------------------------------------------ +using System; +using System.Collections.Generic; + +namespace Microsoft.SqlServer.TransactSql.ScriptDom.ScriptGenerator +{ + internal abstract partial class SqlScriptGeneratorVisitor + { + #region Comment Tracking Fields + + /// + /// Tracks the last token index processed for comment emission. + /// Used to find comments between visited fragments. + /// + private int _lastProcessedTokenIndex = -1; + + /// + /// The current script's token stream, set when visiting begins. + /// + private IList _currentTokenStream; + + #endregion + + #region Comment Preservation Methods + + /// + /// Sets the token stream for comment tracking. + /// Call this before visiting the root node when PreserveComments is enabled. + /// + /// The token stream from the parsed script. + protected void SetTokenStreamForComments(IList tokenStream) + { + _currentTokenStream = tokenStream; + _lastProcessedTokenIndex = -1; + } + + /// + /// Gets leading comments that appear between the last processed token and the current fragment. + /// + /// The current fragment being visited. + /// List of comment information for leading comments. + protected List GetLeadingComments(TSqlFragment fragment) + { + var comments = new List(); + + if (_currentTokenStream == null || fragment == null || !_options.PreserveComments) + { + return comments; + } + + int startIndex = _lastProcessedTokenIndex + 1; + int endIndex = fragment.FirstTokenIndex; + + // Scan for comments between last processed and current fragment + for (int i = startIndex; i < endIndex && i < _currentTokenStream.Count; i++) + { + var token = _currentTokenStream[i]; + if (IsCommentToken(token)) + { + comments.Add(new CommentInfo(token, CommentPosition.Leading, fragment.FirstTokenIndex)); + } + } + + return comments; + } + + /// + /// Gets trailing comments that appear on the same line after the fragment. + /// + /// The current fragment being visited. + /// List of comment information for trailing comments. + protected List GetTrailingComments(TSqlFragment fragment) + { + var comments = new List(); + + if (_currentTokenStream == null || fragment == null || !_options.PreserveComments) + { + return comments; + } + + int lastTokenIndex = fragment.LastTokenIndex; + if (lastTokenIndex < 0 || lastTokenIndex >= _currentTokenStream.Count) + { + return comments; + } + + var lastToken = _currentTokenStream[lastTokenIndex]; + int lastTokenLine = lastToken.Line; + + // Scan for comments after the last token on the same line + for (int i = lastTokenIndex + 1; i < _currentTokenStream.Count; i++) + { + var token = _currentTokenStream[i]; + + // Stop if we've gone past the same line (unless it's whitespace with no newline) + if (token.Line > lastTokenLine) + { + break; + } + + // Found a comment on the same line + if (IsCommentToken(token)) + { + comments.Add(new CommentInfo(token, CommentPosition.Trailing, lastTokenIndex)); + + // For single-line comments, there can't be anything else after on this line + if (token.TokenType == TSqlTokenType.SingleLineComment) + { + break; + } + } + } + + return comments; + } + + /// + /// Emits a comment using the script writer with current indentation. + /// + /// The comment to emit. + protected void EmitComment(CommentInfo commentInfo) + { + if (commentInfo == null || commentInfo.Token == null) + { + return; + } + + var text = commentInfo.Text; + if (string.IsNullOrEmpty(text)) + { + return; + } + + if (commentInfo.IsSingleLineComment) + { + EmitSingleLineComment(text, commentInfo.Position); + } + else if (commentInfo.IsMultiLineComment) + { + EmitMultiLineComment(text, commentInfo.Position); + } + } + + /// + /// Emits a single-line comment. + /// + private void EmitSingleLineComment(string text, CommentPosition position) + { + if (position == CommentPosition.Trailing) + { + // Trailing: add space before comment, keep on same line + _writer.AddToken(ScriptGeneratorSupporter.CreateWhitespaceToken(1)); + } + else + { + // Leading: comment goes on its own line + // Indentation is already applied by current context + } + + // Write the comment as-is (preserving the -- prefix) + _writer.AddToken(new TSqlParserToken(TSqlTokenType.SingleLineComment, text)); + + if (position == CommentPosition.Leading) + { + // After a leading comment, we need a newline + _writer.NewLine(); + } + } + + /// + /// Emits a multi-line comment, preserving internal structure. + /// + private void EmitMultiLineComment(string text, CommentPosition position) + { + if (position == CommentPosition.Trailing) + { + // Trailing: add space before comment + _writer.AddToken(ScriptGeneratorSupporter.CreateWhitespaceToken(1)); + } + + // For multi-line comments, we preserve the content as-is + // The comment includes /* and */ delimiters + _writer.AddToken(new TSqlParserToken(TSqlTokenType.MultilineComment, text)); + + if (position == CommentPosition.Leading) + { + // After a leading multi-line comment, add newline + _writer.NewLine(); + } + } + + /// + /// Called before visiting a fragment to emit any leading comments. + /// + /// The fragment about to be visited. + protected void BeforeVisitFragment(TSqlFragment fragment) + { + if (!_options.PreserveComments || _currentTokenStream == null || fragment == null) + { + return; + } + + var leadingComments = GetLeadingComments(fragment); + foreach (var comment in leadingComments) + { + EmitComment(comment); + } + } + + /// + /// Called after visiting a fragment to emit any trailing comments and update tracking. + /// + /// The fragment that was just visited. + protected void AfterVisitFragment(TSqlFragment fragment) + { + if (!_options.PreserveComments || _currentTokenStream == null || fragment == null) + { + return; + } + + var trailingComments = GetTrailingComments(fragment); + foreach (var comment in trailingComments) + { + EmitComment(comment); + } + + // Update the last processed token index + if (fragment.LastTokenIndex >= 0) + { + // Account for any trailing comments we just emitted + int newLastIndex = fragment.LastTokenIndex; + foreach (var comment in trailingComments) + { + // Find the index of this comment token + for (int i = newLastIndex + 1; i < _currentTokenStream.Count; i++) + { + if (_currentTokenStream[i] == comment.Token) + { + newLastIndex = i; + break; + } + } + } + _lastProcessedTokenIndex = newLastIndex; + } + } + + /// + /// Checks if a token is a comment token. + /// + private static bool IsCommentToken(TSqlParserToken token) + { + return token != null && + (token.TokenType == TSqlTokenType.SingleLineComment || + token.TokenType == TSqlTokenType.MultilineComment); + } + + #endregion + } +} diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlBatch.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlBatch.cs index e40318a..c7540d4 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlBatch.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlBatch.cs @@ -11,18 +11,30 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(TSqlBatch node) { + // Emit leading comments before the batch + BeforeVisitFragment(node); + foreach (TSqlStatement statement in node.Statements) { + // Emit leading comments before each statement + BeforeVisitFragment(statement); + GenerateFragmentIfNotNull(statement); GenerateSemiColonWhenNecessary(statement); + // Emit trailing comments after each statement + AfterVisitFragment(statement); + if (statement is TSqlStatementSnippet == false) { NewLine(); NewLine(); } } + + // Emit trailing comments after the batch + AfterVisitFragment(node); } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs index f7f51bb..33ea433 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs @@ -12,6 +12,15 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(TSqlScript node) { + // Initialize token stream for comment preservation + if (_options.PreserveComments && node.ScriptTokenStream != null) + { + SetTokenStreamForComments(node.ScriptTokenStream); + } + + // Emit leading comments before the script + BeforeVisitFragment(node); + Boolean firstItem = true; foreach (var item in node.Batches) { @@ -28,6 +37,9 @@ public override void ExplicitVisit(TSqlScript node) GenerateFragmentIfNotNull(item); } + + // Emit trailing comments after the script + AfterVisitFragment(node); } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml b/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml index 551033a..4485388 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml +++ b/SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml @@ -43,6 +43,9 @@ Gets or sets the number of newlines to include after each statement + + Gets or sets a boolean indicating if comments from the original script should be preserved in the generated output + diff --git a/Test/SqlDom/Baselines170/MultiLineCommentTests170.sql b/Test/SqlDom/Baselines170/MultiLineCommentTests170.sql new file mode 100644 index 0000000..663908d --- /dev/null +++ b/Test/SqlDom/Baselines170/MultiLineCommentTests170.sql @@ -0,0 +1,9 @@ +SELECT 1; + +SELECT 2; + +SELECT 3; + +SELECT 4; + +SELECT 5; diff --git a/Test/SqlDom/Baselines170/SingleLineCommentTests170.sql b/Test/SqlDom/Baselines170/SingleLineCommentTests170.sql new file mode 100644 index 0000000..badb4f9 --- /dev/null +++ b/Test/SqlDom/Baselines170/SingleLineCommentTests170.sql @@ -0,0 +1,10 @@ +SELECT 1; + +SELECT * +FROM dbo.MyTable; + +SELECT 2; + +SELECT 3; + +SELECT 4; diff --git a/Test/SqlDom/Only170SyntaxTests.cs b/Test/SqlDom/Only170SyntaxTests.cs index 5039493..b4c1062 100644 --- a/Test/SqlDom/Only170SyntaxTests.cs +++ b/Test/SqlDom/Only170SyntaxTests.cs @@ -33,7 +33,10 @@ public partial class SqlDomTests new ParserTest170("OptimizedLockingTests170.sql", nErrors80: 2, nErrors90: 2, nErrors100: 2, nErrors110: 2, nErrors120: 2, nErrors130: 2, nErrors140: 2, nErrors150: 2, nErrors160: 2), new ParserTest170("CreateEventSessionNotLikePredicate.sql", nErrors80: 2, nErrors90: 1, nErrors100: 1, nErrors110: 1, nErrors120: 1, nErrors130: 0, nErrors140: 0, nErrors150: 0, nErrors160: 0), // Complex query with VECTOR types - parses syntactically in all versions (optimization fix), but VECTOR type only valid in TSql170 - new ParserTest170("ComplexQueryTests170.sql") + new ParserTest170("ComplexQueryTests170.sql"), + // Comment preservation tests - basic SQL syntax works in all versions + new ParserTest170("SingleLineCommentTests170.sql"), + new ParserTest170("MultiLineCommentTests170.sql") }; private static readonly ParserTest[] SqlAzure170_TestInfos = diff --git a/Test/SqlDom/ScriptGeneratorTests.cs b/Test/SqlDom/ScriptGeneratorTests.cs index 9a92ce3..f96abe1 100644 --- a/Test/SqlDom/ScriptGeneratorTests.cs +++ b/Test/SqlDom/ScriptGeneratorTests.cs @@ -334,5 +334,170 @@ void ParseAndAssertEquality(string sqlText, SqlScriptGeneratorOptions generatorO Assert.AreEqual(sqlText, generatedSqlText); } + + #region Comment Preservation Tests + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsDefault() + { + // Verify default is false + var options = new SqlScriptGeneratorOptions(); + Assert.AreEqual(false, options.PreserveComments); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsDisabled() + { + // When PreserveComments is false (default), comments should be stripped + var sqlWithComments = "-- This is a leading comment\nSELECT 1; -- trailing comment"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = false // default + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // Without PreserveComments, comments should not appear + Assert.IsFalse(generatedSql.Contains("--"), "Comments should be stripped when PreserveComments is false"); + Assert.IsFalse(generatedSql.Contains("leading comment"), "Comment text should not appear"); + Assert.IsFalse(generatedSql.Contains("trailing comment"), "Trailing comment should not appear"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_SingleLineLeading() + { + // When PreserveComments is true, leading single-line comments should be preserved + var sqlWithComments = "-- This is a leading comment\nSELECT 1;"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // With PreserveComments, the comment should appear in output + Assert.IsTrue(generatedSql.Contains("-- This is a leading comment"), + "Leading comment should be preserved when PreserveComments is true"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_SingleLineTrailing() + { + // When PreserveComments is true, trailing single-line comments should be preserved + var sqlWithComments = "SELECT 1; -- trailing comment"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // With PreserveComments, the trailing comment should appear + Assert.IsTrue(generatedSql.Contains("-- trailing comment"), + "Trailing comment should be preserved when PreserveComments is true"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_MultipleStatements() + { + // Test comments between multiple statements + var sqlWithComments = @"-- First statement +SELECT 1; +-- Comment between statements +SELECT 2;"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // Both comments should be preserved + Assert.IsTrue(generatedSql.Contains("-- First statement"), + "First comment should be preserved"); + Assert.IsTrue(generatedSql.Contains("-- Comment between statements"), + "Comment between statements should be preserved"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_MultiLineComment() + { + // When PreserveComments is true, multi-line comments should be preserved + var sqlWithComments = "/* This is a multi-line comment */\nSELECT 1;"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // With PreserveComments, the multi-line comment should appear + Assert.IsTrue(generatedSql.Contains("/* This is a multi-line comment */"), + "Multi-line comment should be preserved when PreserveComments is true"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_MultiLineBlockComment() + { + // Test that decorative patterns are preserved + var sqlWithComments = @"/***** Header Comment *****/ +SELECT 1;"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // Decorative pattern should be preserved exactly + Assert.IsTrue(generatedSql.Contains("/***** Header Comment *****/"), + "Decorative comment pattern should be preserved exactly"); + } + + #endregion } } diff --git a/Test/SqlDom/TestScripts/MultiLineCommentTests170.sql b/Test/SqlDom/TestScripts/MultiLineCommentTests170.sql new file mode 100644 index 0000000..016dd73 --- /dev/null +++ b/Test/SqlDom/TestScripts/MultiLineCommentTests170.sql @@ -0,0 +1,16 @@ +/* Leading multi-line comment */ +SELECT 1; + +/* + * Multi-line block comment + * with multiple lines + */ +SELECT 2; + +SELECT 3; /* Trailing multi-line comment */ + +/***** Decorative header *****/ +SELECT 4; + +/* Empty multi-line comment */ +SELECT 5; diff --git a/Test/SqlDom/TestScripts/SingleLineCommentTests170.sql b/Test/SqlDom/TestScripts/SingleLineCommentTests170.sql new file mode 100644 index 0000000..66e077d --- /dev/null +++ b/Test/SqlDom/TestScripts/SingleLineCommentTests170.sql @@ -0,0 +1,14 @@ +-- Leading comment before SELECT +SELECT 1; -- Trailing comment after SELECT + +-- Comment before table reference +SELECT * FROM dbo.MyTable; -- End of line comment + +-- Multiple leading comments +-- Second leading comment +SELECT 2; + +-- Comment between statements +SELECT 3; +-- Another comment +SELECT 4; From db61757a34a916ca5bb28e2d3a4e438191883cd1 Mon Sep 17 00:00:00 2001 From: Drew Skwiers-Koballa Date: Thu, 29 Jan 2026 14:10:29 -0800 Subject: [PATCH 2/5] expanded tests --- Test/SqlDom/ScriptGeneratorTests.cs | 245 ++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) diff --git a/Test/SqlDom/ScriptGeneratorTests.cs b/Test/SqlDom/ScriptGeneratorTests.cs index f96abe1..73a41ae 100644 --- a/Test/SqlDom/ScriptGeneratorTests.cs +++ b/Test/SqlDom/ScriptGeneratorTests.cs @@ -394,6 +394,12 @@ public void TestPreserveCommentsEnabled_SingleLineLeading() // With PreserveComments, the comment should appear in output Assert.IsTrue(generatedSql.Contains("-- This is a leading comment"), "Leading comment should be preserved when PreserveComments is true"); + + // Verify comment appears BEFORE the SELECT keyword (correct positioning) + int commentIndex = generatedSql.IndexOf("-- This is a leading comment"); + int selectIndex = generatedSql.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase); + Assert.IsTrue(commentIndex < selectIndex, + $"Leading comment should appear before SELECT. Comment at {commentIndex}, SELECT at {selectIndex}"); } [TestMethod] @@ -418,6 +424,12 @@ public void TestPreserveCommentsEnabled_SingleLineTrailing() // With PreserveComments, the trailing comment should appear Assert.IsTrue(generatedSql.Contains("-- trailing comment"), "Trailing comment should be preserved when PreserveComments is true"); + + // Verify trailing comment appears AFTER the SELECT (correct positioning) + int selectIndex = generatedSql.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase); + int commentIndex = generatedSql.IndexOf("-- trailing comment"); + Assert.IsTrue(commentIndex > selectIndex, + $"Trailing comment should appear after SELECT. SELECT at {selectIndex}, comment at {commentIndex}"); } [TestMethod] @@ -447,6 +459,19 @@ public void TestPreserveCommentsEnabled_MultipleStatements() "First comment should be preserved"); Assert.IsTrue(generatedSql.Contains("-- Comment between statements"), "Comment between statements should be preserved"); + + // Verify ordering: first comment -> SELECT 1 -> between comment -> SELECT 2 + int firstCommentIndex = generatedSql.IndexOf("-- First statement"); + int firstSelectIndex = generatedSql.IndexOf("SELECT 1", StringComparison.OrdinalIgnoreCase); + int betweenCommentIndex = generatedSql.IndexOf("-- Comment between statements"); + int secondSelectIndex = generatedSql.IndexOf("SELECT 2", StringComparison.OrdinalIgnoreCase); + + Assert.IsTrue(firstCommentIndex < firstSelectIndex, + "First comment should appear before first SELECT"); + Assert.IsTrue(firstSelectIndex < betweenCommentIndex, + "Between comment should appear after first SELECT"); + Assert.IsTrue(betweenCommentIndex < secondSelectIndex, + "Between comment should appear before second SELECT"); } [TestMethod] @@ -471,6 +496,12 @@ public void TestPreserveCommentsEnabled_MultiLineComment() // With PreserveComments, the multi-line comment should appear Assert.IsTrue(generatedSql.Contains("/* This is a multi-line comment */"), "Multi-line comment should be preserved when PreserveComments is true"); + + // Verify comment appears BEFORE the SELECT keyword (correct positioning) + int commentIndex = generatedSql.IndexOf("/* This is a multi-line comment */"); + int selectIndex = generatedSql.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase); + Assert.IsTrue(commentIndex < selectIndex, + $"Multi-line comment should appear before SELECT. Comment at {commentIndex}, SELECT at {selectIndex}"); } [TestMethod] @@ -498,6 +529,220 @@ public void TestPreserveCommentsEnabled_MultiLineBlockComment() "Decorative comment pattern should be preserved exactly"); } + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_Subquery() + { + // Test comments with subqueries + var sqlWithComments = @"-- Outer query comment +SELECT * FROM ( + -- Inner subquery comment + SELECT id, name FROM users +) AS subq;"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // Outer comment should be preserved + Assert.IsTrue(generatedSql.Contains("-- Outer query comment"), + "Outer query comment should be preserved"); + + // Verify comment appears BEFORE the SELECT keyword (correct positioning) + int commentIndex = generatedSql.IndexOf("-- Outer query comment"); + int selectIndex = generatedSql.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase); + Assert.IsTrue(commentIndex < selectIndex, + $"Outer query comment should appear before SELECT. Comment at {commentIndex}, SELECT at {selectIndex}"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_CTE() + { + // Test comments with Common Table Expressions + var sqlWithComments = @"-- CTE definition comment +WITH cte AS ( + SELECT id FROM users +) +-- Main query comment +SELECT * FROM cte;"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // CTE comment should be preserved AND appear before the WITH keyword + Assert.IsTrue(generatedSql.Contains("-- CTE definition comment"), + "CTE definition comment should be preserved"); + + // Verify comment appears BEFORE the WITH keyword (correct positioning) + int commentIndex = generatedSql.IndexOf("-- CTE definition comment"); + int withIndex = generatedSql.IndexOf("WITH", StringComparison.OrdinalIgnoreCase); + Assert.IsTrue(commentIndex < withIndex, + $"CTE comment should appear before WITH keyword. Comment at {commentIndex}, WITH at {withIndex}"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_InsertSelect() + { + // Test comments with INSERT...SELECT statements + var sqlWithComments = @"-- Insert with select comment +INSERT INTO target_table (col1, col2) +-- Select portion comment +SELECT a, b FROM source_table;"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // Insert comment should be preserved + Assert.IsTrue(generatedSql.Contains("-- Insert with select comment"), + "Insert statement comment should be preserved"); + + // Verify comment appears BEFORE the INSERT keyword (correct positioning) + int commentIndex = generatedSql.IndexOf("-- Insert with select comment"); + int insertIndex = generatedSql.IndexOf("INSERT", StringComparison.OrdinalIgnoreCase); + Assert.IsTrue(commentIndex < insertIndex, + $"Insert comment should appear before INSERT. Comment at {commentIndex}, INSERT at {insertIndex}"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_StoredProcedure() + { + // Test comments within stored procedure body + var sqlWithComments = @"-- Procedure header comment +CREATE PROCEDURE TestProc +AS +BEGIN + -- First statement in proc + SELECT 1; + -- Second statement in proc + SELECT 2; +END;"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // Procedure header comment should be preserved + Assert.IsTrue(generatedSql.Contains("-- Procedure header comment"), + "Procedure header comment should be preserved"); + + // Verify comment appears BEFORE the CREATE keyword (correct positioning) + int commentIndex = generatedSql.IndexOf("-- Procedure header comment"); + int createIndex = generatedSql.IndexOf("CREATE", StringComparison.OrdinalIgnoreCase); + Assert.IsTrue(commentIndex < createIndex, + $"Procedure comment should appear before CREATE. Comment at {commentIndex}, CREATE at {createIndex}"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_MixedCommentStyles() + { + // Test mixing single-line and multi-line comments + var sqlWithComments = @"/* Block comment at start */ +-- Single line after block +SELECT 1; /* inline block */ -- trailing single"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // Both comment styles should be preserved + Assert.IsTrue(generatedSql.Contains("/* Block comment at start */"), + "Block comment should be preserved"); + Assert.IsTrue(generatedSql.Contains("-- Single line after block"), + "Single line comment should be preserved"); + + // Verify ordering: block comment -> single line comment -> SELECT + int blockCommentIndex = generatedSql.IndexOf("/* Block comment at start */"); + int singleLineIndex = generatedSql.IndexOf("-- Single line after block"); + int selectIndex = generatedSql.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase); + + Assert.IsTrue(blockCommentIndex < singleLineIndex, + "Block comment should appear before single-line comment"); + Assert.IsTrue(singleLineIndex < selectIndex, + "Single-line comment should appear before SELECT"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_CreateTable() + { + // Test comments with CREATE TABLE statement + var sqlWithComments = @"-- Table creation comment +CREATE TABLE TestTable ( + -- Primary key column + Id INT PRIMARY KEY, + -- Name column + Name NVARCHAR(100) +);"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // Table creation comment should be preserved + Assert.IsTrue(generatedSql.Contains("-- Table creation comment"), + "Table creation comment should be preserved"); + + // Verify comment appears BEFORE the CREATE keyword (correct positioning) + int commentIndex = generatedSql.IndexOf("-- Table creation comment"); + int createIndex = generatedSql.IndexOf("CREATE", StringComparison.OrdinalIgnoreCase); + Assert.IsTrue(commentIndex < createIndex, + $"Table comment should appear before CREATE. Comment at {commentIndex}, CREATE at {createIndex}"); + } + #endregion } } From e134349cbe66fdef1865fd6a514cc3115c2a57f1 Mon Sep 17 00:00:00 2001 From: Drew Skwiers-Koballa Date: Fri, 30 Jan 2026 06:43:33 -0800 Subject: [PATCH 3/5] hybridized approach with v1 --- .../SqlScriptGeneratorVisitor.Comments.cs | 264 ++++----- .../SqlScriptGeneratorVisitor.TSqlBatch.cs | 13 +- .../SqlScriptGeneratorVisitor.TSqlScript.cs | 8 +- .../SqlScriptGeneratorVisitor.Utils.cs | 7 + Test/SqlDom/ScriptGeneratorTests.cs | 513 ++++++++++++++++++ 5 files changed, 666 insertions(+), 139 deletions(-) diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Comments.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Comments.cs index 365b3bc..4a8122f 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Comments.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Comments.cs @@ -23,6 +23,16 @@ internal abstract partial class SqlScriptGeneratorVisitor /// private IList _currentTokenStream; + /// + /// Tracks which comment tokens have already been emitted to avoid duplicates. + /// + private readonly HashSet _emittedComments = new HashSet(); + + /// + /// Tracks whether leading (file-level) comments have been emitted. + /// + private bool _leadingCommentsEmitted = false; + #endregion #region Comment Preservation Methods @@ -36,216 +46,226 @@ protected void SetTokenStreamForComments(IList tokenStream) { _currentTokenStream = tokenStream; _lastProcessedTokenIndex = -1; + _emittedComments.Clear(); + _leadingCommentsEmitted = false; } /// - /// Gets leading comments that appear between the last processed token and the current fragment. + /// Emits comments that appear before the first fragment in the script (file-level leading comments). + /// Called once when generating the first fragment. /// - /// The current fragment being visited. - /// List of comment information for leading comments. - protected List GetLeadingComments(TSqlFragment fragment) + /// The first fragment being generated. + protected void EmitLeadingComments(TSqlFragment fragment) { - var comments = new List(); - - if (_currentTokenStream == null || fragment == null || !_options.PreserveComments) + if (!_options.PreserveComments || _currentTokenStream == null || fragment == null) { - return comments; + return; } - int startIndex = _lastProcessedTokenIndex + 1; - int endIndex = fragment.FirstTokenIndex; + if (fragment.FirstTokenIndex <= 0) + { + return; + } - // Scan for comments between last processed and current fragment - for (int i = startIndex; i < endIndex && i < _currentTokenStream.Count; i++) + for (int i = 0; i < fragment.FirstTokenIndex && i < _currentTokenStream.Count; i++) { var token = _currentTokenStream[i]; - if (IsCommentToken(token)) + if (IsCommentToken(token) && !_emittedComments.Contains(token)) { - comments.Add(new CommentInfo(token, CommentPosition.Leading, fragment.FirstTokenIndex)); + EmitCommentToken(token, isLeading: true); + _emittedComments.Add(token); } } - - return comments; } /// - /// Gets trailing comments that appear on the same line after the fragment. + /// Emits comments that appear in the gap between the last emitted token and the current fragment. + /// This captures comments embedded within sub-expressions. /// - /// The current fragment being visited. - /// List of comment information for trailing comments. - protected List GetTrailingComments(TSqlFragment fragment) + /// The fragment about to be generated. + protected void EmitGapComments(TSqlFragment fragment) { - var comments = new List(); - - if (_currentTokenStream == null || fragment == null || !_options.PreserveComments) + if (!_options.PreserveComments || _currentTokenStream == null || fragment == null) { - return comments; + return; } - int lastTokenIndex = fragment.LastTokenIndex; - if (lastTokenIndex < 0 || lastTokenIndex >= _currentTokenStream.Count) + int startIndex = _lastProcessedTokenIndex + 1; + int endIndex = fragment.FirstTokenIndex; + + if (endIndex <= startIndex) { - return comments; + return; } - var lastToken = _currentTokenStream[lastTokenIndex]; - int lastTokenLine = lastToken.Line; - - // Scan for comments after the last token on the same line - for (int i = lastTokenIndex + 1; i < _currentTokenStream.Count; i++) + for (int i = startIndex; i < endIndex && i < _currentTokenStream.Count; i++) { var token = _currentTokenStream[i]; - - // Stop if we've gone past the same line (unless it's whitespace with no newline) - if (token.Line > lastTokenLine) - { - break; - } - - // Found a comment on the same line - if (IsCommentToken(token)) + if (IsCommentToken(token) && !_emittedComments.Contains(token)) { - comments.Add(new CommentInfo(token, CommentPosition.Trailing, lastTokenIndex)); - - // For single-line comments, there can't be anything else after on this line - if (token.TokenType == TSqlTokenType.SingleLineComment) - { - break; - } + EmitCommentToken(token, isLeading: true); + _emittedComments.Add(token); + _lastProcessedTokenIndex = i; } } - - return comments; } /// - /// Emits a comment using the script writer with current indentation. + /// Emits trailing comments that appear immediately after the fragment. /// - /// The comment to emit. - protected void EmitComment(CommentInfo commentInfo) + /// The fragment that was just generated. + protected void EmitTrailingComments(TSqlFragment fragment) { - if (commentInfo == null || commentInfo.Token == null) + if (!_options.PreserveComments || _currentTokenStream == null || fragment == null) { return; } - var text = commentInfo.Text; - if (string.IsNullOrEmpty(text)) + int lastTokenIndex = fragment.LastTokenIndex; + if (lastTokenIndex < 0 || lastTokenIndex >= _currentTokenStream.Count) { return; } - if (commentInfo.IsSingleLineComment) - { - EmitSingleLineComment(text, commentInfo.Position); - } - else if (commentInfo.IsMultiLineComment) + // Scan for comments immediately following the fragment + for (int i = lastTokenIndex + 1; i < _currentTokenStream.Count; i++) { - EmitMultiLineComment(text, commentInfo.Position); + var token = _currentTokenStream[i]; + + if (IsCommentToken(token) && !_emittedComments.Contains(token)) + { + EmitCommentToken(token, isLeading: false); + _emittedComments.Add(token); + _lastProcessedTokenIndex = i; + } + else if (token.TokenType != TSqlTokenType.WhiteSpace) + { + // Stop at next non-whitespace, non-comment token + break; + } } } /// - /// Emits a single-line comment. + /// Updates tracking after generating a fragment. /// - private void EmitSingleLineComment(string text, CommentPosition position) + /// The fragment that was just generated. + protected void UpdateLastProcessedIndex(TSqlFragment fragment) { - if (position == CommentPosition.Trailing) + if (fragment != null && fragment.LastTokenIndex > _lastProcessedTokenIndex) { - // Trailing: add space before comment, keep on same line - _writer.AddToken(ScriptGeneratorSupporter.CreateWhitespaceToken(1)); + _lastProcessedTokenIndex = fragment.LastTokenIndex; } - else + } + + /// + /// Called from GenerateFragmentIfNotNull to handle comments before generating a fragment. + /// This is the key integration point that enables comments within sub-expressions. + /// + /// The fragment about to be generated. + protected void BeforeGenerateFragment(TSqlFragment fragment) + { + if (!_options.PreserveComments || _currentTokenStream == null || fragment == null) { - // Leading: comment goes on its own line - // Indentation is already applied by current context + return; } - // Write the comment as-is (preserving the -- prefix) - _writer.AddToken(new TSqlParserToken(TSqlTokenType.SingleLineComment, text)); - - if (position == CommentPosition.Leading) + // Emit file-level leading comments once + if (!_leadingCommentsEmitted) { - // After a leading comment, we need a newline - _writer.NewLine(); + EmitLeadingComments(fragment); + _leadingCommentsEmitted = true; } + + // Emit any comments in the gap between last processed token and this fragment + EmitGapComments(fragment); } /// - /// Emits a multi-line comment, preserving internal structure. + /// Called from GenerateFragmentIfNotNull to handle comments after generating a fragment. /// - private void EmitMultiLineComment(string text, CommentPosition position) + /// The fragment that was just generated. + protected void AfterGenerateFragment(TSqlFragment fragment) { - if (position == CommentPosition.Trailing) + if (!_options.PreserveComments || _currentTokenStream == null || fragment == null) { - // Trailing: add space before comment - _writer.AddToken(ScriptGeneratorSupporter.CreateWhitespaceToken(1)); + return; } - // For multi-line comments, we preserve the content as-is - // The comment includes /* and */ delimiters - _writer.AddToken(new TSqlParserToken(TSqlTokenType.MultilineComment, text)); - - if (position == CommentPosition.Leading) - { - // After a leading multi-line comment, add newline - _writer.NewLine(); - } + // Emit trailing comments and update tracking + EmitTrailingComments(fragment); + UpdateLastProcessedIndex(fragment); } /// - /// Called before visiting a fragment to emit any leading comments. + /// Emits a comment token to the output. /// - /// The fragment about to be visited. - protected void BeforeVisitFragment(TSqlFragment fragment) + /// The comment token. + /// True if this is a leading comment, false for trailing. + private void EmitCommentToken(TSqlParserToken token, bool isLeading) { - if (!_options.PreserveComments || _currentTokenStream == null || fragment == null) + if (token == null) { return; } - var leadingComments = GetLeadingComments(fragment); - foreach (var comment in leadingComments) + if (token.TokenType == TSqlTokenType.SingleLineComment) + { + if (!isLeading) + { + // Trailing: add space before comment + _writer.AddToken(ScriptGeneratorSupporter.CreateWhitespaceToken(1)); + } + + _writer.AddToken(new TSqlParserToken(TSqlTokenType.SingleLineComment, token.Text)); + + if (isLeading) + { + // After a leading comment, add newline + _writer.NewLine(); + } + } + else if (token.TokenType == TSqlTokenType.MultilineComment) { - EmitComment(comment); + if (!isLeading) + { + // Trailing: add space before comment + _writer.AddToken(ScriptGeneratorSupporter.CreateWhitespaceToken(1)); + } + + _writer.AddToken(new TSqlParserToken(TSqlTokenType.MultilineComment, token.Text)); + + if (isLeading) + { + // After a leading multi-line comment, add newline + _writer.NewLine(); + } } } /// - /// Called after visiting a fragment to emit any trailing comments and update tracking. + /// Emits any remaining comments at the end of the token stream. + /// Call this after visiting the root fragment to capture comments that appear + /// after the last statement (end-of-script comments). /// - /// The fragment that was just visited. - protected void AfterVisitFragment(TSqlFragment fragment) + protected void EmitRemainingComments() { - if (!_options.PreserveComments || _currentTokenStream == null || fragment == null) + if (!_options.PreserveComments || _currentTokenStream == null) { return; } - var trailingComments = GetTrailingComments(fragment); - foreach (var comment in trailingComments) + // Scan from the last processed token to the end of the token stream + for (int i = _lastProcessedTokenIndex + 1; i < _currentTokenStream.Count; i++) { - EmitComment(comment); - } - - // Update the last processed token index - if (fragment.LastTokenIndex >= 0) - { - // Account for any trailing comments we just emitted - int newLastIndex = fragment.LastTokenIndex; - foreach (var comment in trailingComments) + var token = _currentTokenStream[i]; + if (IsCommentToken(token) && !_emittedComments.Contains(token)) { - // Find the index of this comment token - for (int i = newLastIndex + 1; i < _currentTokenStream.Count; i++) - { - if (_currentTokenStream[i] == comment.Token) - { - newLastIndex = i; - break; - } - } + // End-of-script comments: add newline before, emit comment + _writer.NewLine(); + _writer.AddToken(new TSqlParserToken(token.TokenType, token.Text)); + _emittedComments.Add(token); } - _lastProcessedTokenIndex = newLastIndex; } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlBatch.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlBatch.cs index c7540d4..13d31f9 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlBatch.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlBatch.cs @@ -11,30 +11,19 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(TSqlBatch node) { - // Emit leading comments before the batch - BeforeVisitFragment(node); - + // Comments are now handled automatically by GenerateFragmentIfNotNull foreach (TSqlStatement statement in node.Statements) { - // Emit leading comments before each statement - BeforeVisitFragment(statement); - GenerateFragmentIfNotNull(statement); GenerateSemiColonWhenNecessary(statement); - // Emit trailing comments after each statement - AfterVisitFragment(statement); - if (statement is TSqlStatementSnippet == false) { NewLine(); NewLine(); } } - - // Emit trailing comments after the batch - AfterVisitFragment(node); } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs index 33ea433..dbc0bea 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs @@ -18,9 +18,6 @@ public override void ExplicitVisit(TSqlScript node) SetTokenStreamForComments(node.ScriptTokenStream); } - // Emit leading comments before the script - BeforeVisitFragment(node); - Boolean firstItem = true; foreach (var item in node.Batches) { @@ -35,11 +32,12 @@ public override void ExplicitVisit(TSqlScript node) NewLine(); } + // GenerateFragmentIfNotNull now handles comments automatically GenerateFragmentIfNotNull(item); } - // Emit trailing comments after the script - AfterVisitFragment(node); + // Emit any remaining comments at end of script (after the last statement) + EmitRemainingComments(); } } } diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Utils.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Utils.cs index f646523..08c6dc0 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Utils.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Utils.cs @@ -171,7 +171,14 @@ protected void GenerateFragmentIfNotNull(TSqlFragment fragment) { if (fragment != null) { + // Handle comments before generating the fragment + // This is the key integration point that enables comments within sub-expressions + BeforeGenerateFragment(fragment); + fragment.Accept(this); + + // Handle comments after generating the fragment + AfterGenerateFragment(fragment); } } diff --git a/Test/SqlDom/ScriptGeneratorTests.cs b/Test/SqlDom/ScriptGeneratorTests.cs index 73a41ae..5a21409 100644 --- a/Test/SqlDom/ScriptGeneratorTests.cs +++ b/Test/SqlDom/ScriptGeneratorTests.cs @@ -743,6 +743,519 @@ Name NVARCHAR(100) $"Table comment should appear before CREATE. Comment at {commentIndex}, CREATE at {createIndex}"); } + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_EndOfScriptComment() + { + // Test comments at the very end of the script (after the last statement) + // This is an edge case: there's no "next fragment" to capture these as leading comments + var sqlWithComments = @"SELECT 1; +-- End of script comment"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // End-of-script comment should be preserved + Assert.IsTrue(generatedSql.Contains("-- End of script comment"), + "End-of-script comment should be preserved. Actual output: " + generatedSql); + + // Verify comment appears AFTER the SELECT (correct positioning) + int selectIndex = generatedSql.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase); + int commentIndex = generatedSql.IndexOf("-- End of script comment"); + Assert.IsTrue(commentIndex > selectIndex, + $"End-of-script comment should appear after SELECT. SELECT at {selectIndex}, comment at {commentIndex}"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_EndOfScriptMultiLineComment() + { + // Test multi-line comments at the very end of the script + var sqlWithComments = @"SELECT 1; +/* End of script + multi-line comment */"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // End-of-script multi-line comment should be preserved + Assert.IsTrue(generatedSql.Contains("/* End of script"), + "End-of-script multi-line comment should be preserved. Actual output: " + generatedSql); + Assert.IsTrue(generatedSql.Contains("multi-line comment */"), + "End-of-script multi-line comment should be complete"); + + // Verify comment appears AFTER the SELECT + int selectIndex = generatedSql.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase); + int commentIndex = generatedSql.IndexOf("/* End of script"); + Assert.IsTrue(commentIndex > selectIndex, + $"End-of-script comment should appear after SELECT. SELECT at {selectIndex}, comment at {commentIndex}"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_CommentInWhereClause() + { + // Test comments within WHERE clause expressions + // This tests the improved centralized comment handling in GenerateFragmentIfNotNull + var sqlWithComments = @"SELECT id, name +FROM users +WHERE /* filter active users */ status = 'active';"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // Comment in WHERE clause should be preserved + Assert.IsTrue(generatedSql.Contains("/* filter active users */"), + "Comment in WHERE clause should be preserved. Actual output: " + generatedSql); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_CommentInSelectList() + { + // Test comments within SELECT list (between columns) + var sqlWithComments = @"SELECT + id, -- primary key + name, -- user name + email -- contact info +FROM users;"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // At least one of the column comments should be preserved + Assert.IsTrue( + generatedSql.Contains("-- primary key") || + generatedSql.Contains("-- user name") || + generatedSql.Contains("-- contact info"), + "At least one column comment should be preserved. Actual output: " + generatedSql); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_CommentInColumnDefinitions() + { + // Test comments inside column definitions in CREATE TABLE + var sqlWithComments = @"CREATE TABLE TestTable ( + -- Primary key column + Id INT NOT NULL, + -- User's full name + FullName NVARCHAR(100), + /* Email address for notifications */ + Email NVARCHAR(255), + -- Timestamp for auditing + CreatedDate DATETIME DEFAULT GETDATE() +);"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // Column definition comments should be preserved and in correct positions + Assert.IsTrue(generatedSql.Contains("-- Primary key column"), + "Primary key column comment should be preserved. Actual: " + generatedSql); + Assert.IsTrue(generatedSql.Contains("-- User's full name"), + "FullName column comment should be preserved. Actual: " + generatedSql); + Assert.IsTrue(generatedSql.Contains("/* Email address for notifications */"), + "Email column block comment should be preserved. Actual: " + generatedSql); + Assert.IsTrue(generatedSql.Contains("-- Timestamp for auditing"), + "CreatedDate column comment should be preserved. Actual: " + generatedSql); + + // Verify position: comments should appear before their respective columns + int pkCommentIdx = generatedSql.IndexOf("-- Primary key column"); + int idColumnIdx = generatedSql.IndexOf("Id", StringComparison.OrdinalIgnoreCase); + int nameCommentIdx = generatedSql.IndexOf("-- User's full name"); + int fullNameColumnIdx = generatedSql.IndexOf("FullName", StringComparison.OrdinalIgnoreCase); + int emailCommentIdx = generatedSql.IndexOf("/* Email address for notifications */"); + int emailColumnIdx = generatedSql.IndexOf("Email", StringComparison.OrdinalIgnoreCase); + + Assert.IsTrue(pkCommentIdx < idColumnIdx, + $"Primary key comment should appear before Id column. Comment at {pkCommentIdx}, Id at {idColumnIdx}"); + Assert.IsTrue(nameCommentIdx < fullNameColumnIdx, + $"FullName comment should appear before FullName column. Comment at {nameCommentIdx}, FullName at {fullNameColumnIdx}"); + Assert.IsTrue(emailCommentIdx < emailColumnIdx, + $"Email comment should appear before Email column. Comment at {emailCommentIdx}, Email at {emailColumnIdx}"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_CommentInCaseExpression() + { + // Test comments within CASE expressions + var sqlWithComments = @"SELECT + CASE + -- Check for high priority + WHEN priority = 1 THEN 'High' + /* Medium priority items */ + WHEN priority = 2 THEN 'Medium' + -- Default to low priority + ELSE 'Low' + END AS PriorityLevel +FROM tasks;"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // CASE expression comments should be preserved + Assert.IsTrue(generatedSql.Contains("-- Check for high priority"), + "High priority WHEN comment should be preserved. Actual: " + generatedSql); + Assert.IsTrue(generatedSql.Contains("/* Medium priority items */"), + "Medium priority block comment should be preserved. Actual: " + generatedSql); + Assert.IsTrue(generatedSql.Contains("-- Default to low priority"), + "ELSE comment should be preserved. Actual: " + generatedSql); + + // Verify position: comments should appear before their respective WHEN/ELSE clauses + int highPriorityCommentIdx = generatedSql.IndexOf("-- Check for high priority"); + int firstWhenIdx = generatedSql.IndexOf("WHEN", StringComparison.OrdinalIgnoreCase); + int mediumCommentIdx = generatedSql.IndexOf("/* Medium priority items */"); + int secondWhenIdx = generatedSql.IndexOf("WHEN", firstWhenIdx + 1, StringComparison.OrdinalIgnoreCase); + int elseCommentIdx = generatedSql.IndexOf("-- Default to low priority"); + int elseIdx = generatedSql.IndexOf("ELSE", StringComparison.OrdinalIgnoreCase); + + Assert.IsTrue(highPriorityCommentIdx < firstWhenIdx, + $"High priority comment should appear before first WHEN. Comment at {highPriorityCommentIdx}, WHEN at {firstWhenIdx}"); + Assert.IsTrue(mediumCommentIdx < secondWhenIdx, + $"Medium priority comment should appear before second WHEN. Comment at {mediumCommentIdx}, WHEN at {secondWhenIdx}"); + Assert.IsTrue(elseCommentIdx < elseIdx, + $"ELSE comment should appear before ELSE. Comment at {elseCommentIdx}, ELSE at {elseIdx}"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_CommentInJoinClauses() + { + // Test comments in JOIN clauses + var sqlWithComments = @"SELECT u.name, o.order_date +FROM users u +-- Join to get user orders +INNER JOIN orders o ON u.id = o.user_id +/* Left join for optional address */ +LEFT JOIN addresses a ON u.id = a.user_id +-- Cross join for all combinations +CROSS JOIN products p;"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // JOIN clause comments should be preserved + Assert.IsTrue(generatedSql.Contains("-- Join to get user orders"), + "INNER JOIN comment should be preserved. Actual: " + generatedSql); + Assert.IsTrue(generatedSql.Contains("/* Left join for optional address */"), + "LEFT JOIN block comment should be preserved. Actual: " + generatedSql); + Assert.IsTrue(generatedSql.Contains("-- Cross join for all combinations"), + "CROSS JOIN comment should be preserved. Actual: " + generatedSql); + + // Verify position: comments should appear before their respective JOIN clauses + 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); + int crossJoinCommentIdx = generatedSql.IndexOf("-- Cross join for all combinations"); + int crossJoinIdx = generatedSql.IndexOf("CROSS JOIN", StringComparison.OrdinalIgnoreCase); + + Assert.IsTrue(innerJoinCommentIdx < innerJoinIdx, + $"INNER JOIN comment should appear before INNER JOIN. Comment at {innerJoinCommentIdx}, JOIN at {innerJoinIdx}"); + Assert.IsTrue(leftJoinCommentIdx < leftJoinIdx, + $"LEFT JOIN comment should appear before LEFT JOIN. Comment at {leftJoinCommentIdx}, JOIN at {leftJoinIdx}"); + Assert.IsTrue(crossJoinCommentIdx < crossJoinIdx, + $"CROSS JOIN comment should appear before CROSS JOIN. Comment at {crossJoinCommentIdx}, JOIN at {crossJoinIdx}"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_CommentInWherePredicates() + { + // Test comments within WHERE clause predicates + var sqlWithComments = @"SELECT * FROM orders +WHERE + -- Filter by active status + status = 'active' + /* Date range filter */ + AND order_date >= '2024-01-01' + -- Exclude test orders + AND is_test = 0 + -- Amount threshold + AND amount > 100;"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // WHERE predicate comments should be preserved + Assert.IsTrue(generatedSql.Contains("-- Filter by active status"), + "Status filter comment should be preserved. Actual: " + generatedSql); + Assert.IsTrue(generatedSql.Contains("/* Date range filter */"), + "Date range block comment should be preserved. Actual: " + generatedSql); + Assert.IsTrue(generatedSql.Contains("-- Exclude test orders"), + "Test orders exclusion comment should be preserved. Actual: " + generatedSql); + Assert.IsTrue(generatedSql.Contains("-- Amount threshold"), + "Amount threshold comment should be preserved. Actual: " + generatedSql); + + // Verify position: comments should appear before their respective predicates + int statusCommentIdx = generatedSql.IndexOf("-- Filter by active status"); + int statusPredicateIdx = generatedSql.IndexOf("status", StringComparison.OrdinalIgnoreCase); + int dateCommentIdx = generatedSql.IndexOf("/* Date range filter */"); + int datePredicateIdx = generatedSql.IndexOf("order_date", StringComparison.OrdinalIgnoreCase); + int testCommentIdx = generatedSql.IndexOf("-- Exclude test orders"); + int testPredicateIdx = generatedSql.IndexOf("is_test", StringComparison.OrdinalIgnoreCase); + int amountCommentIdx = generatedSql.IndexOf("-- Amount threshold"); + int amountPredicateIdx = generatedSql.IndexOf("amount", StringComparison.OrdinalIgnoreCase); + + Assert.IsTrue(statusCommentIdx < statusPredicateIdx, + $"Status comment should appear before status predicate. Comment at {statusCommentIdx}, predicate at {statusPredicateIdx}"); + Assert.IsTrue(dateCommentIdx < datePredicateIdx, + $"Date comment should appear before order_date predicate. Comment at {dateCommentIdx}, predicate at {datePredicateIdx}"); + Assert.IsTrue(testCommentIdx < testPredicateIdx, + $"Test orders comment should appear before is_test predicate. Comment at {testCommentIdx}, predicate at {testPredicateIdx}"); + Assert.IsTrue(amountCommentIdx < amountPredicateIdx, + $"Amount comment should appear before amount predicate. Comment at {amountCommentIdx}, predicate at {amountPredicateIdx}"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_CommentInGroupByHaving() + { + // Test comments in GROUP BY and HAVING clauses + var sqlWithComments = @"SELECT department, COUNT(*) as emp_count +FROM employees +-- Group by department +GROUP BY department +/* Filter groups with more than 5 employees */ +HAVING COUNT(*) > 5;"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // GROUP BY and HAVING comments should be preserved + Assert.IsTrue(generatedSql.Contains("-- Group by department"), + "GROUP BY comment should be preserved. Actual: " + generatedSql); + Assert.IsTrue(generatedSql.Contains("/* Filter groups with more than 5 employees */"), + "HAVING block comment should be preserved. Actual: " + generatedSql); + + // Verify position: comments should appear before their respective clauses + int groupByCommentIdx = generatedSql.IndexOf("-- Group by department"); + int groupByIdx = generatedSql.IndexOf("GROUP BY", StringComparison.OrdinalIgnoreCase); + int havingCommentIdx = generatedSql.IndexOf("/* Filter groups with more than 5 employees */"); + int havingIdx = generatedSql.IndexOf("HAVING", StringComparison.OrdinalIgnoreCase); + + Assert.IsTrue(groupByCommentIdx < groupByIdx, + $"GROUP BY comment should appear before GROUP BY. Comment at {groupByCommentIdx}, GROUP BY at {groupByIdx}"); + Assert.IsTrue(havingCommentIdx < havingIdx, + $"HAVING comment should appear before HAVING. Comment at {havingCommentIdx}, HAVING at {havingIdx}"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_CommentInOrderBy() + { + // Test comments in ORDER BY clause + var sqlWithComments = @"SELECT name, created_date, priority +FROM tasks +ORDER BY + -- Primary sort: highest priority first + priority ASC, + /* Secondary sort: newest first */ + created_date DESC;"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // ORDER BY comments should be preserved + Assert.IsTrue(generatedSql.Contains("-- Primary sort: highest priority first"), + "Primary sort comment should be preserved. Actual: " + generatedSql); + Assert.IsTrue(generatedSql.Contains("/* Secondary sort: newest first */"), + "Secondary sort block comment should be preserved. Actual: " + generatedSql); + + // Verify position: comments should appear before their respective sort columns + int primarySortCommentIdx = generatedSql.IndexOf("-- Primary sort: highest priority first"); + int orderByIdx = generatedSql.IndexOf("ORDER BY", StringComparison.OrdinalIgnoreCase); + // Find priority AFTER ORDER BY (since it also appears in SELECT list) + int priorityInOrderByIdx = generatedSql.IndexOf("priority", orderByIdx, StringComparison.OrdinalIgnoreCase); + int secondarySortCommentIdx = generatedSql.IndexOf("/* Secondary sort: newest first */"); + // Find created_date AFTER ORDER BY (since it also appears in SELECT list) + int createdDateInOrderByIdx = generatedSql.IndexOf("created_date", orderByIdx, StringComparison.OrdinalIgnoreCase); + + Assert.IsTrue(primarySortCommentIdx > orderByIdx && primarySortCommentIdx < priorityInOrderByIdx, + $"Primary sort comment should appear between ORDER BY and priority. ORDER BY at {orderByIdx}, Comment at {primarySortCommentIdx}, priority at {priorityInOrderByIdx}"); + Assert.IsTrue(secondarySortCommentIdx < createdDateInOrderByIdx, + $"Secondary sort comment should appear before created_date column. Comment at {secondarySortCommentIdx}, created_date at {createdDateInOrderByIdx}"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_CommentInUnionQueries() + { + // Test comments between UNION queries + var sqlWithComments = @"-- First query: active users +SELECT id, name FROM users WHERE status = 'active' +/* Combine with archived users */ +UNION ALL +-- Second query: archived users +SELECT id, name FROM archived_users;"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // UNION query comments should be preserved + Assert.IsTrue(generatedSql.Contains("-- First query: active users"), + "First query comment should be preserved. Actual: " + generatedSql); + 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 + 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 < firstSelectIdx, + $"First query comment should appear before first SELECT. Comment at {firstQueryCommentIdx}, SELECT at {firstSelectIdx}"); + Assert.IsTrue(unionCommentIdx < unionIdx, + $"UNION comment should appear before UNION. Comment at {unionCommentIdx}, UNION at {unionIdx}"); + } + + [TestMethod] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void TestPreserveCommentsEnabled_CommentInNestedSubquery() + { + // Test comments in deeply nested subqueries + var sqlWithComments = @"SELECT * FROM ( + -- Outer subquery + SELECT * FROM ( + /* Inner subquery */ + SELECT id, name FROM users + ) AS inner_q +) AS outer_q;"; + var parser = new TSql170Parser(true); + var fragment = parser.Parse(new StringReader(sqlWithComments), out var errors); + + Assert.AreEqual(0, errors.Count); + + var generatorOptions = new SqlScriptGeneratorOptions + { + PreserveComments = true + }; + var generator = new Sql170ScriptGenerator(generatorOptions); + generator.GenerateScript(fragment, out var generatedSql); + + // Nested subquery comments should be preserved + Assert.IsTrue(generatedSql.Contains("-- Outer subquery"), + "Outer subquery comment should be preserved. Actual: " + generatedSql); + Assert.IsTrue(generatedSql.Contains("/* Inner subquery */"), + "Inner subquery block comment should be preserved. Actual: " + generatedSql); + + // Verify position: outer comment before inner comment (based on nesting order) + int outerCommentIdx = generatedSql.IndexOf("-- Outer subquery"); + int innerCommentIdx = generatedSql.IndexOf("/* Inner subquery */"); + int innerQAliasIdx = generatedSql.IndexOf("inner_q", StringComparison.OrdinalIgnoreCase); + int outerQAliasIdx = generatedSql.IndexOf("outer_q", StringComparison.OrdinalIgnoreCase); + + Assert.IsTrue(outerCommentIdx < innerCommentIdx, + $"Outer subquery comment should appear before inner subquery comment. Outer at {outerCommentIdx}, inner at {innerCommentIdx}"); + Assert.IsTrue(innerCommentIdx < innerQAliasIdx, + $"Inner subquery comment should appear before inner_q alias. Comment at {innerCommentIdx}, alias at {innerQAliasIdx}"); + Assert.IsTrue(innerQAliasIdx < outerQAliasIdx, + $"inner_q alias should appear before outer_q alias. inner_q at {innerQAliasIdx}, outer_q at {outerQAliasIdx}"); + } + #endregion } } From 6c81cbb02045e429e0b0d48d61bea2cf4371bded Mon Sep 17 00:00:00 2001 From: Drew Skwiers-Koballa Date: Fri, 30 Jan 2026 07:22:18 -0800 Subject: [PATCH 4/5] cleanup unused code --- .../SqlServer/ScriptGenerator/CommentInfo.cs | 66 ------------------- .../ScriptGenerator/CommentPosition.cs | 29 -------- 2 files changed, 95 deletions(-) delete mode 100644 SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/CommentInfo.cs delete mode 100644 SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/CommentPosition.cs diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/CommentInfo.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/CommentInfo.cs deleted file mode 100644 index b9b2f58..0000000 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/CommentInfo.cs +++ /dev/null @@ -1,66 +0,0 @@ -//------------------------------------------------------------------------------ -// -// Copyright (c) Microsoft Corporation. All rights reserved. -// -//------------------------------------------------------------------------------ - -namespace Microsoft.SqlServer.TransactSql.ScriptDom.ScriptGenerator -{ - /// - /// Intermediate structure for processing comments during script generation. - /// - internal sealed class CommentInfo - { - /// - /// Gets or sets the original comment token from the token stream. - /// - public TSqlParserToken Token { get; set; } - - /// - /// Gets or sets the position of the comment relative to code. - /// - public CommentPosition Position { get; set; } - - /// - /// Gets or sets the token index of the associated fragment. - /// - public int AssociatedFragmentIndex { get; set; } - - /// - /// Gets whether this is a single-line comment (-- style). - /// - public bool IsSingleLineComment - { - get { return Token != null && Token.TokenType == TSqlTokenType.SingleLineComment; } - } - - /// - /// Gets whether this is a multi-line comment (/* */ style). - /// - public bool IsMultiLineComment - { - get { return Token != null && Token.TokenType == TSqlTokenType.MultilineComment; } - } - - /// - /// Gets the text content of the comment. - /// - public string Text - { - get { return Token?.Text; } - } - - /// - /// Creates a new CommentInfo instance. - /// - /// The comment token. - /// The position relative to code. - /// The associated fragment token index. - public CommentInfo(TSqlParserToken token, CommentPosition position, int fragmentIndex) - { - Token = token; - Position = position; - AssociatedFragmentIndex = fragmentIndex; - } - } -} diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/CommentPosition.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/CommentPosition.cs deleted file mode 100644 index f58c6be..0000000 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/CommentPosition.cs +++ /dev/null @@ -1,29 +0,0 @@ -//------------------------------------------------------------------------------ -// -// Copyright (c) Microsoft Corporation. All rights reserved. -// -//------------------------------------------------------------------------------ - -namespace Microsoft.SqlServer.TransactSql.ScriptDom.ScriptGenerator -{ - /// - /// Categorizes comment placement relative to code during script generation. - /// - internal enum CommentPosition - { - /// - /// Comment appears before the associated code on previous line(s). - /// - Leading, - - /// - /// Comment appears after code on the same line. - /// - Trailing, - - /// - /// Comment not directly associated with code (e.g., end of file, standalone block). - /// - Standalone - } -} From b79835993f5210100c85796a21f97086c228a38c Mon Sep 17 00:00:00 2001 From: Drew Skwiers-Koballa Date: Fri, 30 Jan 2026 07:22:33 -0800 Subject: [PATCH 5/5] excessive comment cleanup --- .../ScriptGenerator/SqlScriptGeneratorVisitor.TSqlBatch.cs | 1 - .../ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlBatch.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlBatch.cs index 13d31f9..e40318a 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlBatch.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlBatch.cs @@ -11,7 +11,6 @@ partial class SqlScriptGeneratorVisitor { public override void ExplicitVisit(TSqlBatch node) { - // Comments are now handled automatically by GenerateFragmentIfNotNull foreach (TSqlStatement statement in node.Statements) { GenerateFragmentIfNotNull(statement); diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs index dbc0bea..5f56850 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs @@ -32,7 +32,6 @@ public override void ExplicitVisit(TSqlScript node) NewLine(); } - // GenerateFragmentIfNotNull now handles comments automatically GenerateFragmentIfNotNull(item); }