Merge pull request 'Chore/sonarqube src dedup' (#20) from chore/sonarqube-src-dedup into main
SonarQube Analysis / sonarqube (push) Successful in 4m47s

Reviewed-on: #20
This commit was merged in pull request #20.
This commit is contained in:
2026-05-27 16:41:28 -05:00
21 changed files with 212 additions and 464 deletions
@@ -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);
}
/// <summary>
@@ -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);
}
/// <summary>
@@ -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,
@@ -361,13 +361,7 @@ public class ExpressionGenerator : IVisitor<string>
}
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
@@ -485,37 +479,23 @@ public class ExpressionGenerator : IVisitor<string>
}
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<Expression> 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));
}
@@ -0,0 +1,21 @@
namespace Strata.SqlTools.Markdown.Internal;
/// <summary>
/// Small text utilities shared by the Markdown generators.
/// Kept <c>internal</c> so they don't widen the public surface of this assembly.
/// </summary>
internal static class MarkdownTextHelpers
{
/// <summary>
/// Truncates a string to <paramref name="maxLength"/> characters, appending an ellipsis when truncated.
/// Returns the input unchanged when null/whitespace or already short enough.
/// </summary>
public static string TruncateText(string text, int maxLength)
{
if (string.IsNullOrWhiteSpace(text) || text.Length <= maxLength)
{
return text;
}
return string.Concat(text.AsSpan(0, maxLength), "...");
}
}
@@ -191,17 +191,7 @@ public static class QueryBreakdownGenerator
.Replace(">", "&gt;");
}
/// <summary>
/// Truncates text to a maximum length and adds ellipsis if needed.
/// </summary>
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);
}
@@ -112,18 +112,8 @@ public static class SqlStatementGenerator
.Replace("\r", "");
}
/// <summary>
/// Truncates text to a maximum length.
/// </summary>
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);
/// <summary>
/// Cleans table name for use in Mermaid diagrams.
@@ -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
@@ -84,23 +84,7 @@ public class StatementParser : SqlServerStatementParser
/// <param name="position">Current position in the SQL string.</param>
/// <returns>Token and new position after the token.</returns>
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);
/// <summary>
/// Post-processes extracted clauses to handle PostgreSQL-specific LIMIT and OFFSET clauses.
@@ -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<string>();
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+(.*))?$",
@@ -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<string>();
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*\(([^\)]*)\)",
@@ -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<string>();
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,
@@ -231,28 +231,7 @@ public class QueryBreakdown : SqlServerQueryBreakdown
var visitor = isMicrosoftSql
? (IVisitor<string>)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);
}
/// <summary>
@@ -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<string>)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);
}
/// <summary>
@@ -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<string>();
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+(.*))?$",
@@ -91,23 +91,7 @@ public class StatementParser : SqlServerStatementParser
/// <param name="position">Current position in the SQL string.</param>
/// <returns>Token and new position after the token.</returns>
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);
/// <summary>
/// Post-processes extracted clauses to handle Snowflake-specific LIMIT clause.
@@ -53,6 +53,42 @@ public abstract class SqlBreakdownBase : ISqlBreakdown
/// <returns>The SQL query string.</returns>
protected abstract string GetSqlBreakdown();
/// <summary>
/// Appends a SQL fragment to an <see cref="ISqlClause"/> with the given logical operation
/// (e.g. <c>"and"</c> / <c>"or"</c>), 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.
/// </summary>
/// <param name="clause">The clause being built up (e.g. <c>WhereClause</c>, <c>HavingClause</c>).</param>
/// <param name="sql">The already-generated SQL fragment to append.</param>
/// <param name="operation">The logical operation used to join with the existing content.</param>
/// <param name="comment">Optional comment to merge into <c>clause.Comment</c>.</param>
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}";
}
}
/// <summary>
/// Gets a LINQ to SQL query of the specified type based on this breakdown.
/// </summary>
@@ -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<string>();
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,
@@ -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<string>();
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,
@@ -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<string>();
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,
@@ -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);
}
/// <summary>
@@ -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);
}
/// <summary>
@@ -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<string>();
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,
@@ -0,0 +1,69 @@
using System.Collections;
using System.Text.RegularExpressions;
using Strata.SqlTools.SqlBreakdown.Utilities;
namespace Strata.SqlTools.Statements.SqlServer;
/// <summary>
/// Shared parse-prelude scaffolding for <c>TryParse</c> implementations on
/// dialect-specific breakdown classes (Insert/Update/Delete/Procedure).
/// Each breakdown's prelude was historically 1730 lines of identical code:
/// null check, comment-preserving normalize, statement-prefix regex check,
/// then setup/finish clause extraction. This helper performs all of it.
/// </summary>
public static class ParsePreparation
{
/// <summary>
/// Runs the standard <c>TryParse</c> prelude:
/// validates that <paramref name="sql"/> is non-empty, normalizes it with
/// <see cref="StatementParser.NormalizeSqlPreservingComments"/>, requires the
/// trimmed statement to match <paramref name="prefixRegex"/>, and pulls out
/// the setup/finish clauses.
/// </summary>
/// <param name="sql">The raw SQL being parsed.</param>
/// <param name="parser">The dialect's <see cref="StatementParser"/> (or subclass).</param>
/// <param name="prefixRegex">A regex anchored at the start that the trimmed SQL must match (e.g. <c>@"^\s*INSERT\s+INTO\b"</c>).</param>
/// <param name="prefixDescription">Human-readable name of the expected prefix used in the error message (e.g. <c>"INSERT INTO"</c>).</param>
/// <param name="normalizedSql">On success, the SQL with comments preserved and setup/finish clauses removed.</param>
/// <param name="setupClauses">On success, the list of setup-clause strings extracted from the front of the SQL.</param>
/// <param name="finishClauses">On success, the finish clauses extracted from the back of the SQL.</param>
/// <param name="errorMessage">On failure, a human-readable explanation suitable for surfacing through the caller's <c>out string errorMessage</c>.</param>
/// <returns><c>true</c> if the prelude completed and <paramref name="normalizedSql"/> is ready for dialect-specific matching; <c>false</c> otherwise.</returns>
public static bool TryRunPrelude(
string sql,
StatementParser parser,
string prefixRegex,
string prefixDescription,
out string normalizedSql,
out List<string> 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<string>();
normalizedSql = parser.ExtractSetupClauses(normalizedSql, setupClauses);
finishClauses = new ArrayList();
normalizedSql = parser.ExtractFinishClauses(normalizedSql, finishClauses);
return true;
}
}
@@ -649,6 +649,32 @@ public class StatementParser
return ((TokenType.String, str.ToString(), start), position);
}
/// <summary>
/// Helper used by dialects (PostgreSQL, Snowflake) where double-quoted text is an
/// identifier rather than a string literal. Reads from the opening quote at
/// <paramref name="position"/> and returns the inner text as a
/// <see cref="TokenType.ColumnIdentifier"/> token.
/// </summary>
/// <param name="sql">The SQL statement being tokenized.</param>
/// <param name="position">The current position in the SQL string (must point at the opening <c>"</c>).</param>
/// <returns>The identifier token and the new position past the closing <c>"</c>.</returns>
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);
}
/// <summary>
/// Processes a list of tokens to find SQL keywords at the top level (outside parentheses).
/// </summary>