From d4b66838b529f33507ab49546e503d729775a89d Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Wed, 27 May 2026 16:22:45 -0500 Subject: [PATCH 1/4] refactor(dedup): extract self-duplicated helpers in three src/ files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tackles the in-file copy-paste duplications SonarQube flagged on `sql-utilities`, narrowing the dedup target to the cases where the extraction is a clear readability win. - `LinqToSql.Converters.ReverseConverterExtensions`: the three `ToLinqQueryBreakdown` overloads (SqlServer / PostgreSql / Snowflake) had identical 26-line bodies. Routes all three through a single `BuildLinqBreakdownFrom(QueryBreakdown)` private helper — works because Snowflake/PostgreSql `QueryBreakdown` derive from the SqlServer one, so the parameter type accepts all three. Public API preserved. - `Markdown.Expressions.ExpressionGenerator`: `VisitInExpression` and `VisitNotInExpression` had identical 18-line bodies differing only in the "IN"/"NOT IN" label. Both now delegate to a new private `RenderInList(label, searchExpression, values)`. - `PostgreSql.Statements.StatementExpressionParser`: the qualified- column-name building loop and the column-id switch were duplicated across `HandleStringToken` (qualified-column branch) and `GrabColumnExpression`. Extracted to a shared `BuildQualifiedColumnExpression(seededBuilder, reader)` private helper. Deliberately *not* refactored: `PostgreSql.Statements.StatementReader`'s `<` / `>` operator handlers, which Sonar also flags as duplicate. The shared pattern there is a structural sequence of "MovePosition; character check; emit Token; return" repeated across single-/two-char operator variants; folding it into a helper would replace four short, self-explanatory inline checks with `TryMatchTwoCharOperator('=', ...)` indirection that obscures what each branch actually emits. The dedup isn't worth the readability tax. All 1180 tests stay green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Converters/ReverseConverterExtensions.cs | 54 +++---------------- .../Expressions/ExpressionGenerator.cs | 28 +++------- .../Statements/StatementExpressionParser.cs | 45 +++------------- 3 files changed, 23 insertions(+), 104 deletions(-) diff --git a/src/Strata.SqlTools.LinqToSql/Converters/ReverseConverterExtensions.cs b/src/Strata.SqlTools.LinqToSql/Converters/ReverseConverterExtensions.cs index 9f5cc55..dc3a214 100644 --- a/src/Strata.SqlTools.LinqToSql/Converters/ReverseConverterExtensions.cs +++ b/src/Strata.SqlTools.LinqToSql/Converters/ReverseConverterExtensions.cs @@ -19,29 +19,7 @@ public static class ReverseConverterExtensions public static LinqQueryBreakdown ToLinqQueryBreakdown(this QueryBreakdown breakdown) { ArgumentNullException.ThrowIfNull(breakdown); - - var linq = new LinqQueryBreakdown( - breakdown.SelectClause?.Clause ?? "*", - breakdown.FromClause?.Clause ?? string.Empty, - breakdown.WhereClause?.Clause ?? string.Empty - ); - - if (!string.IsNullOrWhiteSpace(breakdown.GroupByClause?.Clause)) - { - linq.GroupByClause.Clause = breakdown.GroupByClause.Clause; - } - - if (!string.IsNullOrWhiteSpace(breakdown.HavingClause?.Clause)) - { - linq.HavingClause.Clause = breakdown.HavingClause.Clause; - } - - if (!string.IsNullOrWhiteSpace(breakdown.OrderByClause?.Clause)) - { - linq.OrderByClause.Clause = breakdown.OrderByClause.Clause; - } - - return linq; + return BuildLinqBreakdownFrom(breakdown); } /// @@ -52,29 +30,7 @@ public static class ReverseConverterExtensions public static LinqQueryBreakdown ToLinqQueryBreakdown(this PostgreSqlBreakdown breakdown) { ArgumentNullException.ThrowIfNull(breakdown); - - var linq = new LinqQueryBreakdown( - breakdown.SelectClause?.Clause ?? "*", - breakdown.FromClause?.Clause ?? string.Empty, - breakdown.WhereClause?.Clause ?? string.Empty - ); - - if (!string.IsNullOrWhiteSpace(breakdown.GroupByClause?.Clause)) - { - linq.GroupByClause.Clause = breakdown.GroupByClause.Clause; - } - - if (!string.IsNullOrWhiteSpace(breakdown.HavingClause?.Clause)) - { - linq.HavingClause.Clause = breakdown.HavingClause.Clause; - } - - if (!string.IsNullOrWhiteSpace(breakdown.OrderByClause?.Clause)) - { - linq.OrderByClause.Clause = breakdown.OrderByClause.Clause; - } - - return linq; + return BuildLinqBreakdownFrom(breakdown); } /// @@ -85,7 +41,13 @@ public static class ReverseConverterExtensions public static LinqQueryBreakdown ToLinqQueryBreakdown(this SnowflakeBreakdown breakdown) { ArgumentNullException.ThrowIfNull(breakdown); + return BuildLinqBreakdownFrom(breakdown); + } + // Shared body — PostgreSql/Snowflake QueryBreakdown derive from SqlServer.QueryBreakdown, + // so all three public overloads can flow through this single helper. + private static LinqQueryBreakdown BuildLinqBreakdownFrom(QueryBreakdown breakdown) + { var linq = new LinqQueryBreakdown( breakdown.SelectClause?.Clause ?? "*", breakdown.FromClause?.Clause ?? string.Empty, diff --git a/src/Strata.SqlTools.Markdown/Expressions/ExpressionGenerator.cs b/src/Strata.SqlTools.Markdown/Expressions/ExpressionGenerator.cs index 8f19153..99b384a 100644 --- a/src/Strata.SqlTools.Markdown/Expressions/ExpressionGenerator.cs +++ b/src/Strata.SqlTools.Markdown/Expressions/ExpressionGenerator.cs @@ -485,37 +485,23 @@ public class ExpressionGenerator : IVisitor } public string VisitInExpression(InExpression inExpression) - { - var sb = new StringBuilder(); - sb.AppendLine($"{Indent()}IN:"); - _indentLevel++; - sb.AppendLine($"{Indent()}Search Expression:"); - _indentLevel++; - sb.AppendLine(inExpression.SearchExpression.Accept(this)); - _indentLevel--; - sb.AppendLine($"{Indent()}Values:"); - _indentLevel++; - foreach (var value in inExpression.ValuesToCompare) - { - sb.AppendLine(value.Accept(this)); - } - _indentLevel--; - _indentLevel--; - return sb.ToString(); - } + => RenderInList("IN", inExpression.SearchExpression, inExpression.ValuesToCompare); public string VisitNotInExpression(NotInExpression inExpression) + => RenderInList("NOT IN", inExpression.SearchExpression, inExpression.ValuesToCompare); + + private string RenderInList(string label, Expression searchExpression, IEnumerable values) { var sb = new StringBuilder(); - sb.AppendLine($"{Indent()}NOT IN:"); + sb.AppendLine($"{Indent()}{label}:"); _indentLevel++; sb.AppendLine($"{Indent()}Search Expression:"); _indentLevel++; - sb.AppendLine(inExpression.SearchExpression.Accept(this)); + sb.AppendLine(searchExpression.Accept(this)); _indentLevel--; sb.AppendLine($"{Indent()}Values:"); _indentLevel++; - foreach (var value in inExpression.ValuesToCompare) + foreach (var value in values) { sb.AppendLine(value.Accept(this)); } diff --git a/src/Strata.SqlTools.PostgreSql/Statements/StatementExpressionParser.cs b/src/Strata.SqlTools.PostgreSql/Statements/StatementExpressionParser.cs index fc3cf9e..d55642f 100644 --- a/src/Strata.SqlTools.PostgreSql/Statements/StatementExpressionParser.cs +++ b/src/Strata.SqlTools.PostgreSql/Statements/StatementExpressionParser.cs @@ -121,39 +121,7 @@ public class StatementExpressionParser : SqlServerStatementExpressionParser // Check if this is a qualified column name (e.g., users.id) if (reader.TokenType == TokenType.Operator && reader.TokenValue == ".") { - // Build a qualified column expression using StringBuilder for performance - var columnBuilder = new System.Text.StringBuilder(startingToken); - while (reader.TokenType == TokenType.Operator && reader.TokenValue == ".") - { - reader.Read(); // Skip the dot - - if (reader.TokenType == TokenType.String || reader.TokenType == TokenType.ColumnIdentifier) - { - columnBuilder.Append('.').Append(reader.TokenValue); - reader.Read(); - } - else - { - throw new InvalidSyntaxException( - $"Invalid syntax at position {reader.Position}. Expected column identifier after dot."); - } - } - - var columnToken = columnBuilder.ToString(); - - // Return a column expression for the qualified name - var dataColumnId = GetColumnIdFromToken(columnToken); - var tableSource = new RegisteredTableSource(1001, "FW", "DEPARTMENT", "DEPT"); - return dataColumnId switch - { - 1 => new RegisteredTableColumnExpression(dataColumnId, "DEPARTMENT_ID", tableSource), - 2 => new RegisteredTableColumnExpression(dataColumnId, "NAME", tableSource), - 3 => new RegisteredTableColumnExpression(dataColumnId, "REVENUE", tableSource), - 4 => new RegisteredTableColumnExpression(dataColumnId, "DISCHARGE_DATE", tableSource), - 586883 => new RegisteredTableColumnExpression(dataColumnId, "FIXED_COST", tableSource), - 586664 => new RegisteredTableColumnExpression(dataColumnId, "VARIABLE_COST", tableSource), - _ => new RegisteredTableColumnExpression(dataColumnId, GetDefaultColumnName(columnToken), tableSource) - }; + return BuildQualifiedColumnExpression(new System.Text.StringBuilder(startingToken), reader); } // Not a qualified column, treat as a string expression @@ -182,9 +150,14 @@ public class StatementExpressionParser : SqlServerStatementExpressionParser { var columnBuilder = new System.Text.StringBuilder(reader.TokenValue); reader.Read(); + return BuildQualifiedColumnExpression(columnBuilder, reader); + } - // Handle qualified names: table.column, "Table"."Column", etc. - // Keep reading while we see dot-separated identifiers + // Consumes dot-separated identifier segments from the reader, appending each to the seeded builder, + // then maps the resulting qualified name to a RegisteredTableColumnExpression. Shared between + // HandleStringToken (qualified column path) and GrabColumnExpression (entry-point path). + private RegisteredTableColumnExpression BuildQualifiedColumnExpression(System.Text.StringBuilder columnBuilder, IStatementReader reader) + { while (reader.TokenType == TokenType.Operator && reader.TokenValue == ".") { reader.Read(); // Skip the dot @@ -202,8 +175,6 @@ public class StatementExpressionParser : SqlServerStatementExpressionParser } var columnToken = columnBuilder.ToString(); - - // Use base implementation to get the column expression var dataColumnId = GetColumnIdFromToken(columnToken); var tableSource = new RegisteredTableSource(1001, "FW", "DEPARTMENT", "DEPT"); return dataColumnId switch From 4b5348c53a0ced33986b70bd92d98227fc29baa6 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Wed, 27 May 2026 16:24:51 -0500 Subject: [PATCH 2/4] refactor(dedup): share TruncateText across Markdown generators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TruncateText` was copy-pasted verbatim in three Markdown generators (`SqlServer.QueryBreakdownGenerator`, `SqlServer.SqlStatementGenerator`, `Expressions.ExpressionGenerator`). Pulled out to a new `Strata.SqlTools.Markdown.Internal.MarkdownTextHelpers` static class (internal — no public-API change). Each call site keeps its own one-line private wrapper for source readability so existing `TruncateText(...)` calls in the generators need no edits. Deliberately *not* unified across the same three files: - `EscapeMermaidText` (QueryBreakdownGenerator) vs `EscapeMermaidText` (SqlStatementGenerator) — the QBG version intentionally escapes `[]{}()` for Mermaid node syntax; the SSG version only escapes quotes/newlines because it writes into `Note right of DB: ...` contexts where brackets render fine. - `EscapeMarkdown` (ExpressionGenerator) — a different escape set again, targeting Markdown rather than Mermaid. Also deliberately *not* refactored: `SqlServer.UpdateBreakdown.TryParse` ≡ `SqlServer.ProcedureBreakdown.TryParse` prelude (empty-check + parser + prefix regex + extract setup/finish clauses). The 30-line duplication is real, but every extraction shape (tuple return, `out`-flavored helper, context type) is measurably worse than the duplicated original. Leaving it. All 1180 tests stay green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Expressions/ExpressionGenerator.cs | 8 +------ .../Internal/MarkdownTextHelpers.cs | 21 +++++++++++++++++++ .../SqlServer/QueryBreakdownGenerator.cs | 12 +---------- .../SqlServer/SqlStatementGenerator.cs | 12 +---------- 4 files changed, 24 insertions(+), 29 deletions(-) create mode 100644 src/Strata.SqlTools.Markdown/Internal/MarkdownTextHelpers.cs diff --git a/src/Strata.SqlTools.Markdown/Expressions/ExpressionGenerator.cs b/src/Strata.SqlTools.Markdown/Expressions/ExpressionGenerator.cs index 99b384a..67f4de5 100644 --- a/src/Strata.SqlTools.Markdown/Expressions/ExpressionGenerator.cs +++ b/src/Strata.SqlTools.Markdown/Expressions/ExpressionGenerator.cs @@ -361,13 +361,7 @@ public class ExpressionGenerator : IVisitor } private static string TruncateText(string text, int maxLength) - { - if (string.IsNullOrWhiteSpace(text) || text.Length <= maxLength) - { - return text; - } - return string.Concat(text.AsSpan(0, maxLength), "..."); - } + => Internal.MarkdownTextHelpers.TruncateText(text, maxLength); #region IVisitor Implementation diff --git a/src/Strata.SqlTools.Markdown/Internal/MarkdownTextHelpers.cs b/src/Strata.SqlTools.Markdown/Internal/MarkdownTextHelpers.cs new file mode 100644 index 0000000..f6f97b1 --- /dev/null +++ b/src/Strata.SqlTools.Markdown/Internal/MarkdownTextHelpers.cs @@ -0,0 +1,21 @@ +namespace Strata.SqlTools.Markdown.Internal; + +/// +/// Small text utilities shared by the Markdown generators. +/// Kept internal so they don't widen the public surface of this assembly. +/// +internal static class MarkdownTextHelpers +{ + /// + /// Truncates a string to characters, appending an ellipsis when truncated. + /// Returns the input unchanged when null/whitespace or already short enough. + /// + public static string TruncateText(string text, int maxLength) + { + if (string.IsNullOrWhiteSpace(text) || text.Length <= maxLength) + { + return text; + } + return string.Concat(text.AsSpan(0, maxLength), "..."); + } +} diff --git a/src/Strata.SqlTools.Markdown/SqlServer/QueryBreakdownGenerator.cs b/src/Strata.SqlTools.Markdown/SqlServer/QueryBreakdownGenerator.cs index 051cc8a..dcb44a6 100644 --- a/src/Strata.SqlTools.Markdown/SqlServer/QueryBreakdownGenerator.cs +++ b/src/Strata.SqlTools.Markdown/SqlServer/QueryBreakdownGenerator.cs @@ -191,17 +191,7 @@ public static class QueryBreakdownGenerator .Replace(">", ">"); } - /// - /// Truncates text to a maximum length and adds ellipsis if needed. - /// private static string TruncateText(string text, int maxLength) - { - if (string.IsNullOrWhiteSpace(text) || text.Length <= maxLength) - { - return text; - } - - return string.Concat(text.AsSpan(0, maxLength), "..."); - } + => Internal.MarkdownTextHelpers.TruncateText(text, maxLength); } diff --git a/src/Strata.SqlTools.Markdown/SqlServer/SqlStatementGenerator.cs b/src/Strata.SqlTools.Markdown/SqlServer/SqlStatementGenerator.cs index 49bc4da..f3dc210 100644 --- a/src/Strata.SqlTools.Markdown/SqlServer/SqlStatementGenerator.cs +++ b/src/Strata.SqlTools.Markdown/SqlServer/SqlStatementGenerator.cs @@ -112,18 +112,8 @@ public static class SqlStatementGenerator .Replace("\r", ""); } - /// - /// Truncates text to a maximum length. - /// private static string TruncateText(string text, int maxLength) - { - if (string.IsNullOrWhiteSpace(text) || text.Length <= maxLength) - { - return text; - } - - return string.Concat(text.AsSpan(0, maxLength), "..."); - } + => Internal.MarkdownTextHelpers.TruncateText(text, maxLength); /// /// Cleans table name for use in Mermaid diagrams. From c121dfa611359c7aec21e0432cbca62ede8f64e0 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Wed, 27 May 2026 16:33:43 -0500 Subject: [PATCH 3/4] refactor(dedup): share TryParse prelude across breakdown families (Cluster D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standard `TryParse(...)` prelude — null/empty check, parser construction, comment-preserving normalize, statement-prefix regex validation, setup/finish clause extraction — was copy-pasted in **eight** breakdown classes across the SqlServer and Snowflake dialects. Sonar flagged it as a six-way duplicate cluster on the shorter (~17-line) common block, and as additional pairwise duplicates on the longer (~30-line) version. Introduces `Strata.SqlTools.Statements.SqlServer.ParsePreparation` with a single `TryRunPrelude(sql, parser, prefixRegex, prefixDescription, out ...)` method. Each `TryParse` now calls it once and proceeds straight to dialect-specific match logic. Touched callers: - `SqlServer.InsertBreakdown`, `SqlServer.DeleteBreakdown`, `SqlServer.UpdateBreakdown`, `SqlServer.ProcedureBreakdown` - `Snowflake.InsertBreakdown`, `Snowflake.DeleteBreakdown`, `Snowflake.UpdateBreakdown`, `Snowflake.ProcedureBreakdown` The Microsoft-SQL fallback path in the Snowflake breakdowns (which delegates to the SqlServer breakdown's TryParse before the prelude even runs) is preserved unchanged. `ParsePreparation` is `public` because it sits in the SqlServer assembly and is consumed cross-assembly by Snowflake/PostgreSql. This is a new public type but it's deliberately a thin scaffold — external consumers should still be calling the breakdown classes' own `TryParse` methods. All 1180 tests stay green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Breakdowns/DeleteBreakdown.cs | 24 +------ .../Breakdowns/InsertBreakdown.cs | 24 +------ .../Breakdowns/ProcedureBreakdown.cs | 24 +------ .../Breakdowns/UpdateBreakdown.cs | 23 +------ .../Breakdowns/DeleteBreakdown.cs | 23 +------ .../Breakdowns/InsertBreakdown.cs | 24 +------ .../Breakdowns/ProcedureBreakdown.cs | 24 +------ .../Breakdowns/UpdateBreakdown.cs | 23 +------ .../Statements/ParsePreparation.cs | 69 +++++++++++++++++++ 9 files changed, 93 insertions(+), 165 deletions(-) create mode 100644 src/Strata.SqlTools.SqlServer/Statements/ParsePreparation.cs diff --git a/src/Strata.SqlTools.Snowflake/Breakdowns/DeleteBreakdown.cs b/src/Strata.SqlTools.Snowflake/Breakdowns/DeleteBreakdown.cs index 9e2cfeb..b663d80 100644 --- a/src/Strata.SqlTools.Snowflake/Breakdowns/DeleteBreakdown.cs +++ b/src/Strata.SqlTools.Snowflake/Breakdowns/DeleteBreakdown.cs @@ -111,12 +111,6 @@ public class DeleteBreakdown : SqlServerDeleteBreakdown try { - if (string.IsNullOrWhiteSpace(sql)) - { - errorMessage = "SQL statement cannot be null or empty."; - return false; - } - // If Microsoft SQL mode, delegate to base class if (isMicrosoftSql) { @@ -138,25 +132,13 @@ public class DeleteBreakdown : SqlServerDeleteBreakdown return true; } - var parser = SnowflakeParserInstance; - sql = parser.NormalizeSqlPreservingComments(sql); - - // Check if it's a DELETE statement - var sqlTrimmed = sql.TrimStart(); - if (!System.Text.RegularExpressions.Regex.IsMatch(sqlTrimmed, @"^\s*DELETE\b", - System.Text.RegularExpressions.RegexOptions.IgnoreCase, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout)) + if (!Strata.SqlTools.Statements.SqlServer.ParsePreparation.TryRunPrelude( + sql, SnowflakeParserInstance, @"^\s*DELETE\b", "DELETE", + out sql, out var setupClauses, out var finishClauses, out errorMessage)) { - errorMessage = "SQL statement must start with DELETE."; return false; } - // Extract setup and finish clauses - var setupClauses = new List(); - sql = parser.ExtractSetupClauses(sql, setupClauses); - - var finishClauses = new ArrayList(); - sql = parser.ExtractFinishClauses(sql, finishClauses); - // Snowflake uses simpler DELETE syntax: DELETE FROM table WHERE condition var deleteMatch = System.Text.RegularExpressions.Regex.Match(sql, @"DELETE\s+FROM\s+(.*?)(?:\s+WHERE\s+(.*))?$", diff --git a/src/Strata.SqlTools.Snowflake/Breakdowns/InsertBreakdown.cs b/src/Strata.SqlTools.Snowflake/Breakdowns/InsertBreakdown.cs index d719ad9..89f3d06 100644 --- a/src/Strata.SqlTools.Snowflake/Breakdowns/InsertBreakdown.cs +++ b/src/Strata.SqlTools.Snowflake/Breakdowns/InsertBreakdown.cs @@ -115,12 +115,6 @@ public class InsertBreakdown : SqlServerInsertBreakdown try { - if (string.IsNullOrWhiteSpace(sql)) - { - errorMessage = "SQL statement cannot be null or empty."; - return false; - } - // If Microsoft SQL mode, delegate to base class if (isMicrosoftSql) { @@ -142,25 +136,13 @@ public class InsertBreakdown : SqlServerInsertBreakdown return true; } - var parser = SnowflakeParserInstance; - sql = parser.NormalizeSqlPreservingComments(sql); - - // Check if it's an INSERT statement - var sqlTrimmed = sql.TrimStart(); - if (!System.Text.RegularExpressions.Regex.IsMatch(sqlTrimmed, @"^\s*INSERT\s+INTO\b", - System.Text.RegularExpressions.RegexOptions.IgnoreCase, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout)) + if (!Strata.SqlTools.Statements.SqlServer.ParsePreparation.TryRunPrelude( + sql, SnowflakeParserInstance, @"^\s*INSERT\s+INTO\b", "INSERT INTO", + out sql, out var setupClauses, out var finishClauses, out errorMessage)) { - errorMessage = "SQL statement must start with INSERT INTO."; return false; } - // Extract setup and finish clauses - var setupClauses = new List(); - sql = parser.ExtractSetupClauses(sql, setupClauses); - - var finishClauses = new ArrayList(); - sql = parser.ExtractFinishClauses(sql, finishClauses); - // Parse INSERT statement using regex var insertMatch = System.Text.RegularExpressions.Regex.Match(sql, @"INSERT\s+INTO\s+([^\(\s]+)\s*\(([^\)]*)\)\s*VALUES\s*\(([^\)]*)\)", diff --git a/src/Strata.SqlTools.Snowflake/Breakdowns/ProcedureBreakdown.cs b/src/Strata.SqlTools.Snowflake/Breakdowns/ProcedureBreakdown.cs index 74d0a21..9edc117 100644 --- a/src/Strata.SqlTools.Snowflake/Breakdowns/ProcedureBreakdown.cs +++ b/src/Strata.SqlTools.Snowflake/Breakdowns/ProcedureBreakdown.cs @@ -138,37 +138,19 @@ public class ProcedureBreakdown : SqlServerProcedureBreakdown result = null!; errorMessage = null!; - if (string.IsNullOrWhiteSpace(sql)) - { - errorMessage = "SQL statement cannot be null or empty."; - return false; - } - // If Microsoft SQL mode, delegate to base class if (isMicrosoftSql) { return TryParseMicrosoftSql(sql, out result, out errorMessage); } - var parser = SnowflakeParserInstance; - sql = parser.NormalizeSqlPreservingComments(sql); - - // Check if it's a CALL statement (Snowflake syntax) or EXEC (for compatibility) - var sqlTrimmed = sql.TrimStart(); - if (!System.Text.RegularExpressions.Regex.IsMatch(sqlTrimmed, @"^\s*(CALL|EXEC|EXECUTE)\b", - System.Text.RegularExpressions.RegexOptions.IgnoreCase, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout)) + if (!Strata.SqlTools.Statements.SqlServer.ParsePreparation.TryRunPrelude( + sql, SnowflakeParserInstance, @"^\s*(CALL|EXEC|EXECUTE)\b", "CALL, EXEC, or EXECUTE", + out sql, out var setupClauses, out var finishClauses, out errorMessage)) { - errorMessage = "SQL statement must start with CALL, EXEC, or EXECUTE."; return false; } - // Extract setup and finish clauses - var setupClauses = new List(); - sql = parser.ExtractSetupClauses(sql, setupClauses); - - var finishClauses = new ArrayList(); - sql = parser.ExtractFinishClauses(sql, finishClauses); - // Parse CALL statement - match procedure name and parameters // Pattern: CALL procedureName(param => value, ...) var callMatch = System.Text.RegularExpressions.Regex.Match(sql, diff --git a/src/Strata.SqlTools.Snowflake/Breakdowns/UpdateBreakdown.cs b/src/Strata.SqlTools.Snowflake/Breakdowns/UpdateBreakdown.cs index f0fcc00..5895dc1 100644 --- a/src/Strata.SqlTools.Snowflake/Breakdowns/UpdateBreakdown.cs +++ b/src/Strata.SqlTools.Snowflake/Breakdowns/UpdateBreakdown.cs @@ -125,12 +125,6 @@ public class UpdateBreakdown : SqlServerUpdateBreakdown try { - if (string.IsNullOrWhiteSpace(sql)) - { - errorMessage = "SQL statement cannot be null or empty."; - return false; - } - // If Microsoft SQL mode, delegate to base class if (isMicrosoftSql) { @@ -154,24 +148,13 @@ public class UpdateBreakdown : SqlServerUpdateBreakdown } var parser = SnowflakeParserInstance; - sql = parser.NormalizeSqlPreservingComments(sql); - - // Check if it's an UPDATE statement - var sqlTrimmed = sql.TrimStart(); - if (!System.Text.RegularExpressions.Regex.IsMatch(sqlTrimmed, @"^\s*UPDATE\b", - System.Text.RegularExpressions.RegexOptions.IgnoreCase, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout)) + if (!Strata.SqlTools.Statements.SqlServer.ParsePreparation.TryRunPrelude( + sql, parser, @"^\s*UPDATE\b", "UPDATE", + out sql, out var setupClauses, out var finishClauses, out errorMessage)) { - errorMessage = "SQL statement must start with UPDATE."; return false; } - // Extract setup and finish clauses - var setupClauses = new List(); - sql = parser.ExtractSetupClauses(sql, setupClauses); - - var finishClauses = new ArrayList(); - sql = parser.ExtractFinishClauses(sql, finishClauses); - // Parse UPDATE statement - handle both with and without FROM clause var updateMatch = System.Text.RegularExpressions.Regex.Match(sql, @"UPDATE\s+([^\s]+)\s+SET\s+(.*?)(?:\s+FROM\s+(.*?))?(?:\s+WHERE\s+(.*))?$", diff --git a/src/Strata.SqlTools.SqlServer/Breakdowns/DeleteBreakdown.cs b/src/Strata.SqlTools.SqlServer/Breakdowns/DeleteBreakdown.cs index b8433b9..f7d2271 100644 --- a/src/Strata.SqlTools.SqlServer/Breakdowns/DeleteBreakdown.cs +++ b/src/Strata.SqlTools.SqlServer/Breakdowns/DeleteBreakdown.cs @@ -146,31 +146,14 @@ public class DeleteBreakdown : SqlBreakdownBase result = null!; errorMessage = null!; - if (string.IsNullOrWhiteSpace(sql)) - { - errorMessage = "SQL statement cannot be null or empty."; - return false; - } - var parser = new StatementParser(); - sql = parser.NormalizeSqlPreservingComments(sql); - - // Check if it's a DELETE statement - var sqlTrimmed = sql.TrimStart(); - if (!System.Text.RegularExpressions.Regex.IsMatch(sqlTrimmed, @"^\s*DELETE\b", - System.Text.RegularExpressions.RegexOptions.IgnoreCase, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout)) + if (!Strata.SqlTools.Statements.SqlServer.ParsePreparation.TryRunPrelude( + sql, parser, @"^\s*DELETE\b", "DELETE", + out sql, out var setupClauses, out var finishClauses, out errorMessage)) { - errorMessage = "SQL statement must start with DELETE."; return false; } - // Extract setup and finish clauses - var setupClauses = new List(); - sql = parser.ExtractSetupClauses(sql, setupClauses); - - var finishClauses = new ArrayList(); - sql = parser.ExtractFinishClauses(sql, finishClauses); - // Parse DELETE statement using regex // Pattern: DELETE [table_alias] FROM table WHERE condition var deleteMatch = System.Text.RegularExpressions.Regex.Match(sql, diff --git a/src/Strata.SqlTools.SqlServer/Breakdowns/InsertBreakdown.cs b/src/Strata.SqlTools.SqlServer/Breakdowns/InsertBreakdown.cs index 5176880..dd77b77 100644 --- a/src/Strata.SqlTools.SqlServer/Breakdowns/InsertBreakdown.cs +++ b/src/Strata.SqlTools.SqlServer/Breakdowns/InsertBreakdown.cs @@ -145,31 +145,13 @@ public class InsertBreakdown : SqlBreakdownBase try { - if (string.IsNullOrWhiteSpace(sql)) + if (!Strata.SqlTools.Statements.SqlServer.ParsePreparation.TryRunPrelude( + sql, new StatementParser(), @"^\s*INSERT\s+INTO\b", "INSERT INTO", + out sql, out var setupClauses, out var finishClauses, out errorMessage)) { - errorMessage = "SQL statement cannot be null or empty."; return false; } - var parser = new StatementParser(); - sql = parser.NormalizeSqlPreservingComments(sql); - - // Check if it's an INSERT statement - var sqlTrimmed = sql.TrimStart(); - if (!System.Text.RegularExpressions.Regex.IsMatch(sqlTrimmed, @"^\s*INSERT\s+INTO\b", - System.Text.RegularExpressions.RegexOptions.IgnoreCase, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout)) - { - errorMessage = "SQL statement must start with INSERT INTO."; - return false; - } - - // Extract setup and finish clauses - var setupClauses = new List(); - sql = parser.ExtractSetupClauses(sql, setupClauses); - - var finishClauses = new ArrayList(); - sql = parser.ExtractFinishClauses(sql, finishClauses); - // Parse INSERT statement using regex // Pattern: INSERT INTO table (columns) VALUES (values) var insertMatch = System.Text.RegularExpressions.Regex.Match(sql, diff --git a/src/Strata.SqlTools.SqlServer/Breakdowns/ProcedureBreakdown.cs b/src/Strata.SqlTools.SqlServer/Breakdowns/ProcedureBreakdown.cs index a114cd5..bc6fb9f 100644 --- a/src/Strata.SqlTools.SqlServer/Breakdowns/ProcedureBreakdown.cs +++ b/src/Strata.SqlTools.SqlServer/Breakdowns/ProcedureBreakdown.cs @@ -156,31 +156,13 @@ public class ProcedureBreakdown : SqlBreakdownBase try { - if (string.IsNullOrWhiteSpace(sql)) + if (!ParsePreparation.TryRunPrelude( + sql, new StatementParser(), @"^\s*(EXEC|EXECUTE)\b", "EXEC or EXECUTE", + out sql, out var setupClauses, out var finishClauses, out errorMessage)) { - errorMessage = "SQL statement cannot be null or empty."; return false; } - var parser = new StatementParser(); - sql = parser.NormalizeSqlPreservingComments(sql); - - // Check if it's an EXEC or EXECUTE statement - var sqlTrimmed = sql.TrimStart(); - if (!Regex.IsMatch(sqlTrimmed, @"^\s*(EXEC|EXECUTE)\b", - RegexOptions.IgnoreCase, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout)) - { - errorMessage = "SQL statement must start with EXEC or EXECUTE."; - return false; - } - - // Extract setup and finish clauses - var setupClauses = new List(); - sql = parser.ExtractSetupClauses(sql, setupClauses); - - var finishClauses = new ArrayList(); - sql = parser.ExtractFinishClauses(sql, finishClauses); - // Parse EXEC statement - match procedure name and parameters // Pattern: EXEC[UTE] procedureName [@param = value, ...] var execMatch = Regex.Match(sql, diff --git a/src/Strata.SqlTools.SqlServer/Breakdowns/UpdateBreakdown.cs b/src/Strata.SqlTools.SqlServer/Breakdowns/UpdateBreakdown.cs index b280f81..2d5a894 100644 --- a/src/Strata.SqlTools.SqlServer/Breakdowns/UpdateBreakdown.cs +++ b/src/Strata.SqlTools.SqlServer/Breakdowns/UpdateBreakdown.cs @@ -152,31 +152,14 @@ public class UpdateBreakdown : SqlBreakdownBase try { - if (string.IsNullOrWhiteSpace(sql)) - { - errorMessage = "SQL statement cannot be null or empty."; - return false; - } - var parser = new StatementParser(); - sql = parser.NormalizeSqlPreservingComments(sql); - - // Check if it's an UPDATE statement - var sqlTrimmed = sql.TrimStart(); - if (!Regex.IsMatch(sqlTrimmed, @"^\s*UPDATE\b", - RegexOptions.IgnoreCase, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout)) + if (!ParsePreparation.TryRunPrelude( + sql, parser, @"^\s*UPDATE\b", "UPDATE", + out sql, out var setupClauses, out var finishClauses, out errorMessage)) { - errorMessage = "SQL statement must start with UPDATE."; return false; } - // Extract setup and finish clauses - var setupClauses = new List(); - sql = parser.ExtractSetupClauses(sql, setupClauses); - - var finishClauses = new ArrayList(); - sql = parser.ExtractFinishClauses(sql, finishClauses); - // Parse UPDATE statement - handle both with and without FROM clause // Pattern: UPDATE table SET column=value [FROM table] [WHERE condition] var updateMatch = Regex.Match(sql, diff --git a/src/Strata.SqlTools.SqlServer/Statements/ParsePreparation.cs b/src/Strata.SqlTools.SqlServer/Statements/ParsePreparation.cs new file mode 100644 index 0000000..489a0e7 --- /dev/null +++ b/src/Strata.SqlTools.SqlServer/Statements/ParsePreparation.cs @@ -0,0 +1,69 @@ +using System.Collections; +using System.Text.RegularExpressions; +using Strata.SqlTools.SqlBreakdown.Utilities; + +namespace Strata.SqlTools.Statements.SqlServer; + +/// +/// Shared parse-prelude scaffolding for TryParse implementations on +/// dialect-specific breakdown classes (Insert/Update/Delete/Procedure). +/// Each breakdown's prelude was historically 17–30 lines of identical code: +/// null check, comment-preserving normalize, statement-prefix regex check, +/// then setup/finish clause extraction. This helper performs all of it. +/// +public static class ParsePreparation +{ + /// + /// Runs the standard TryParse prelude: + /// validates that is non-empty, normalizes it with + /// , requires the + /// trimmed statement to match , and pulls out + /// the setup/finish clauses. + /// + /// The raw SQL being parsed. + /// The dialect's (or subclass). + /// A regex anchored at the start that the trimmed SQL must match (e.g. @"^\s*INSERT\s+INTO\b"). + /// Human-readable name of the expected prefix used in the error message (e.g. "INSERT INTO"). + /// On success, the SQL with comments preserved and setup/finish clauses removed. + /// On success, the list of setup-clause strings extracted from the front of the SQL. + /// On success, the finish clauses extracted from the back of the SQL. + /// On failure, a human-readable explanation suitable for surfacing through the caller's out string errorMessage. + /// true if the prelude completed and is ready for dialect-specific matching; false otherwise. + public static bool TryRunPrelude( + string sql, + StatementParser parser, + string prefixRegex, + string prefixDescription, + out string normalizedSql, + out List setupClauses, + out ArrayList finishClauses, + out string errorMessage) + { + normalizedSql = null!; + setupClauses = null!; + finishClauses = null!; + errorMessage = null!; + + if (string.IsNullOrWhiteSpace(sql)) + { + errorMessage = "SQL statement cannot be null or empty."; + return false; + } + + normalizedSql = parser.NormalizeSqlPreservingComments(sql); + + if (!Regex.IsMatch(normalizedSql.TrimStart(), prefixRegex, RegexOptions.IgnoreCase, RegexDefaults.MatchTimeout)) + { + errorMessage = $"SQL statement must start with {prefixDescription}."; + return false; + } + + setupClauses = new List(); + normalizedSql = parser.ExtractSetupClauses(normalizedSql, setupClauses); + + finishClauses = new ArrayList(); + normalizedSql = parser.ExtractFinishClauses(normalizedSql, finishClauses); + + return true; + } +} From f577c065586bccd2cb915c2db49561b704fefda7 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Wed, 27 May 2026 16:39:36 -0500 Subject: [PATCH 4/4] refactor(dedup): cross-dialect QueryBreakdown + StatementParser helpers (Cluster E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two shared scaffolds for blocks Sonar flagged across the SqlServer, Snowflake, and PostgreSQL dialects: 1. **`AppendToClause` on `SqlBreakdownBase`** — collapses the "if clause is empty set it, else append `{operation} {sql}`; then merge comment with same rule" pattern that was repeated three times in each of SqlServer/Snowflake `QueryBreakdown`. The matching `AddWhereExpression` / `AddHavingExpression` / `AddWhereClause(string)` sites in both files now delegate to a single `protected static` helper. Operates against `ISqlClause`, so it works for both the `WhereClause` and `HavingClause` properties. 2. **`HandleDoubleQuoteAsIdentifier` on `SqlServer.StatementParser`** — PostgreSQL and Snowflake both override SqlServer's `HandleDoubleQuote` (which produces a string-literal token) to instead produce a `ColumnIdentifier` token. The two overrides had identical 14-line bodies. The shared logic now lives once, and each dialect's override is a one-liner that calls the helper. Deliberately *not* refactored in this commit: - The CTE WITH-clause SQL generation in SqlServer/Snowflake QueryBreakdown (lines ~537-560 / ~579-601 Sonar flagged) — the surrounding logic differs enough between the two that an extraction would obscure rather than clarify. - The PG/Snowflake QueryBreakdown constructor pair (lines 40-58 / 43-61) — only ~10 lines × 2; extracting requires either a new shared helper for ~20 lines of savings or moving up the inheritance chain, neither pays for itself. All 1180 tests stay green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Statements/StatementParser.cs | 18 +---- .../Breakdowns/QueryBreakdown.cs | 70 ++----------------- .../Statements/StatementParser.cs | 18 +---- .../Classes/SqlBreakdownBase.cs | 36 ++++++++++ .../Breakdowns/QueryBreakdown.cs | 70 ++----------------- .../Statements/StatementParser.cs | 26 +++++++ 6 files changed, 72 insertions(+), 166 deletions(-) diff --git a/src/Strata.SqlTools.PostgreSql/Statements/StatementParser.cs b/src/Strata.SqlTools.PostgreSql/Statements/StatementParser.cs index 444f2be..6f5e258 100644 --- a/src/Strata.SqlTools.PostgreSql/Statements/StatementParser.cs +++ b/src/Strata.SqlTools.PostgreSql/Statements/StatementParser.cs @@ -84,23 +84,7 @@ public class StatementParser : SqlServerStatementParser /// Current position in the SQL string. /// Token and new position after the token. protected override ((TokenType type, string value, int position) token, int newPosition) HandleDoubleQuote(string sql, int position) - { - // PostgreSQL: double-quote is an identifier (like [brackets] in T-SQL) - int start = position; - position++; // Skip opening quote - var identifier = new StringBuilder(); - while (position < sql.Length && sql[position] != '"') - { - identifier.Append(sql[position]); - position++; - } - if (position < sql.Length) - { - position++; // Skip closing quote - } - - return ((TokenType.ColumnIdentifier, identifier.ToString(), start), position); - } + => HandleDoubleQuoteAsIdentifier(sql, position); /// /// Post-processes extracted clauses to handle PostgreSQL-specific LIMIT and OFFSET clauses. diff --git a/src/Strata.SqlTools.Snowflake/Breakdowns/QueryBreakdown.cs b/src/Strata.SqlTools.Snowflake/Breakdowns/QueryBreakdown.cs index fd5ba06..5eb2c9a 100644 --- a/src/Strata.SqlTools.Snowflake/Breakdowns/QueryBreakdown.cs +++ b/src/Strata.SqlTools.Snowflake/Breakdowns/QueryBreakdown.cs @@ -231,28 +231,7 @@ public class QueryBreakdown : SqlServerQueryBreakdown var visitor = isMicrosoftSql ? (IVisitor)new SqlServerCommandVisitor() : new CommandVisitor(); - var sql = expression.Accept(visitor); - - if (string.IsNullOrWhiteSpace(WhereClause.Clause)) - { - WhereClause.Clause = sql; - } - else - { - WhereClause.Clause = $"{WhereClause.Clause} {operation} {sql}"; - } - - if (!string.IsNullOrWhiteSpace(comment)) - { - if (string.IsNullOrWhiteSpace(WhereClause.Comment)) - { - WhereClause.Comment = comment; - } - else - { - WhereClause.Comment = $"{WhereClause.Comment} {comment}"; - } - } + AppendToClause(WhereClause, expression.Accept(visitor), operation, comment); } /// @@ -297,29 +276,9 @@ public class QueryBreakdown : SqlServerQueryBreakdown // Extract comments from the incoming SQL var cleanSql = parser.ExtractSqlComments(sql, out var comments); + var commentText = comments.Count > 0 ? string.Join(" ", comments) : null; - if (string.IsNullOrWhiteSpace(WhereClause.Clause)) - { - WhereClause.Clause = cleanSql.Trim(); - } - else - { - WhereClause.Clause = $"{WhereClause.Clause} {operation} {cleanSql.Trim()}"; - } - - // Merge comments - if (comments.Count > 0) - { - var newComment = string.Join(" ", comments); - if (string.IsNullOrWhiteSpace(WhereClause.Comment)) - { - WhereClause.Comment = newComment; - } - else - { - WhereClause.Comment = $"{WhereClause.Comment} {newComment}"; - } - } + AppendToClause(WhereClause, cleanSql.Trim(), operation, commentText); // Extract and add parameters from the WHERE clause using appropriate parser ExtractAndAddParametersWithParser(cleanSql, parser); @@ -476,28 +435,7 @@ public class QueryBreakdown : SqlServerQueryBreakdown var visitor = isMicrosoftSql ? (IVisitor)new SqlServerCommandVisitor() : new CommandVisitor(); - var sql = expression.Accept(visitor); - - if (string.IsNullOrWhiteSpace(HavingClause.Clause)) - { - HavingClause.Clause = sql; - } - else - { - HavingClause.Clause = $"{HavingClause.Clause} {operation} {sql}"; - } - - if (!string.IsNullOrWhiteSpace(comment)) - { - if (string.IsNullOrWhiteSpace(HavingClause.Comment)) - { - HavingClause.Comment = comment; - } - else - { - HavingClause.Comment = $"{HavingClause.Comment} {comment}"; - } - } + AppendToClause(HavingClause, expression.Accept(visitor), operation, comment); } /// diff --git a/src/Strata.SqlTools.Snowflake/Statements/StatementParser.cs b/src/Strata.SqlTools.Snowflake/Statements/StatementParser.cs index def7770..22f0467 100644 --- a/src/Strata.SqlTools.Snowflake/Statements/StatementParser.cs +++ b/src/Strata.SqlTools.Snowflake/Statements/StatementParser.cs @@ -91,23 +91,7 @@ public class StatementParser : SqlServerStatementParser /// Current position in the SQL string. /// Token and new position after the token. protected override ((TokenType type, string value, int position) token, int newPosition) HandleDoubleQuote(string sql, int position) - { - // Snowflake: double-quote is an identifier (like [brackets]) - int start = position; - position++; // Skip opening quote - var identifier = new StringBuilder(); - while (position < sql.Length && sql[position] != '"') - { - identifier.Append(sql[position]); - position++; - } - if (position < sql.Length) - { - position++; // Skip closing quote - } - - return ((TokenType.ColumnIdentifier, identifier.ToString(), start), position); - } + => HandleDoubleQuoteAsIdentifier(sql, position); /// /// Post-processes extracted clauses to handle Snowflake-specific LIMIT clause. diff --git a/src/Strata.SqlTools.SqlBreakdown/Classes/SqlBreakdownBase.cs b/src/Strata.SqlTools.SqlBreakdown/Classes/SqlBreakdownBase.cs index 4663657..241d772 100644 --- a/src/Strata.SqlTools.SqlBreakdown/Classes/SqlBreakdownBase.cs +++ b/src/Strata.SqlTools.SqlBreakdown/Classes/SqlBreakdownBase.cs @@ -53,6 +53,42 @@ public abstract class SqlBreakdownBase : ISqlBreakdown /// The SQL query string. protected abstract string GetSqlBreakdown(); + /// + /// Appends a SQL fragment to an with the given logical operation + /// (e.g. "and" / "or"), and optionally merges an associated comment. + /// If the target clause is empty, the fragment is set as the clause; otherwise it is + /// joined with the operation. + /// + /// The clause being built up (e.g. WhereClause, HavingClause). + /// The already-generated SQL fragment to append. + /// The logical operation used to join with the existing content. + /// Optional comment to merge into clause.Comment. + protected static void AppendToClause(ISqlClause clause, string sql, string operation, string? comment) + { + if (string.IsNullOrWhiteSpace(clause.Clause)) + { + clause.Clause = sql; + } + else + { + clause.Clause = $"{clause.Clause} {operation} {sql}"; + } + + if (string.IsNullOrWhiteSpace(comment)) + { + return; + } + + if (string.IsNullOrWhiteSpace(clause.Comment)) + { + clause.Comment = comment; + } + else + { + clause.Comment = $"{clause.Comment} {comment}"; + } + } + /// /// Gets a LINQ to SQL query of the specified type based on this breakdown. /// diff --git a/src/Strata.SqlTools.SqlServer/Breakdowns/QueryBreakdown.cs b/src/Strata.SqlTools.SqlServer/Breakdowns/QueryBreakdown.cs index d99f887..9d8187c 100644 --- a/src/Strata.SqlTools.SqlServer/Breakdowns/QueryBreakdown.cs +++ b/src/Strata.SqlTools.SqlServer/Breakdowns/QueryBreakdown.cs @@ -822,29 +822,9 @@ public class QueryBreakdown : SqlBreakdownBase, IQueryBreakdown // Extract comments from the incoming SQL var cleanSql = Parser.ExtractSqlComments(sql, out var comments); + var commentText = comments.Count > 0 ? string.Join(" ", comments) : null; - if (string.IsNullOrWhiteSpace(WhereClause.Clause)) - { - WhereClause.Clause = cleanSql.Trim(); - } - else - { - WhereClause.Clause = $"{WhereClause.Clause} {operation} {cleanSql.Trim()}"; - } - - // Merge comments - if (comments.Count > 0) - { - var newComment = string.Join(" ", comments); - if (string.IsNullOrWhiteSpace(WhereClause.Comment)) - { - WhereClause.Comment = newComment; - } - else - { - WhereClause.Comment = $"{WhereClause.Comment} {newComment}"; - } - } + AppendToClause(WhereClause, cleanSql.Trim(), operation, commentText); // Extract and add parameters from the WHERE clause ExtractAndAddParameters(cleanSql); @@ -901,28 +881,7 @@ public class QueryBreakdown : SqlBreakdownBase, IQueryBreakdown } var visitor = new CommandVisitor(); - var sql = expression.Accept(visitor); - - if (string.IsNullOrWhiteSpace(WhereClause.Clause)) - { - WhereClause.Clause = sql; - } - else - { - WhereClause.Clause = $"{WhereClause.Clause} {operation} {sql}"; - } - - if (!string.IsNullOrWhiteSpace(comment)) - { - if (string.IsNullOrWhiteSpace(WhereClause.Comment)) - { - WhereClause.Comment = comment; - } - else - { - WhereClause.Comment = $"{WhereClause.Comment} {comment}"; - } - } + AppendToClause(WhereClause, expression.Accept(visitor), operation, comment); } /// @@ -1013,28 +972,7 @@ public class QueryBreakdown : SqlBreakdownBase, IQueryBreakdown } var visitor = new CommandVisitor(); - var sql = expression.Accept(visitor); - - if (string.IsNullOrWhiteSpace(HavingClause.Clause)) - { - HavingClause.Clause = sql; - } - else - { - HavingClause.Clause = $"{HavingClause.Clause} {operation} {sql}"; - } - - if (!string.IsNullOrWhiteSpace(comment)) - { - if (string.IsNullOrWhiteSpace(HavingClause.Comment)) - { - HavingClause.Comment = comment; - } - else - { - HavingClause.Comment = $"{HavingClause.Comment} {comment}"; - } - } + AppendToClause(HavingClause, expression.Accept(visitor), operation, comment); } /// diff --git a/src/Strata.SqlTools.SqlServer/Statements/StatementParser.cs b/src/Strata.SqlTools.SqlServer/Statements/StatementParser.cs index 0d1551a..6b751c2 100644 --- a/src/Strata.SqlTools.SqlServer/Statements/StatementParser.cs +++ b/src/Strata.SqlTools.SqlServer/Statements/StatementParser.cs @@ -649,6 +649,32 @@ public class StatementParser return ((TokenType.String, str.ToString(), start), position); } + /// + /// Helper used by dialects (PostgreSQL, Snowflake) where double-quoted text is an + /// identifier rather than a string literal. Reads from the opening quote at + /// and returns the inner text as a + /// token. + /// + /// The SQL statement being tokenized. + /// The current position in the SQL string (must point at the opening "). + /// The identifier token and the new position past the closing ". + protected static ((TokenType type, string value, int position) token, int newPosition) HandleDoubleQuoteAsIdentifier(string sql, int position) + { + int start = position; + position++; // Skip opening quote + var identifier = new StringBuilder(); + while (position < sql.Length && sql[position] != '"') + { + identifier.Append(sql[position]); + position++; + } + if (position < sql.Length) + { + position++; // Skip closing quote + } + return ((TokenType.ColumnIdentifier, identifier.ToString(), start), position); + } + /// /// Processes a list of tokens to find SQL keywords at the top level (outside parentheses). ///