Files
sql-utilities/src/Strata.SqlTools.SqlServer/Statements/StatementParser.cs
T
Thom LambandClaude Opus 4.7 f577c06558
SonarQube Analysis / sonarqube (pull_request) Successful in 4m5s
refactor(dedup): cross-dialect QueryBreakdown + StatementParser helpers (Cluster E)
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) <noreply@anthropic.com>
2026-05-27 16:39:36 -05:00

951 lines
35 KiB
C#

using System.Collections;
using System.Text;
using System.Text.RegularExpressions;
using Strata.SqlTools.SqlBreakdown.Classes;
using Strata.SqlTools.SqlBreakdown.Enums.SQL;
using Strata.SqlTools.SqlBreakdown.Utilities;
namespace Strata.SqlTools.Statements.SqlServer;
/// <summary>
/// Provides shared SQL parsing utilities for normalizing and cleaning SQL statements.
/// </summary>
public class StatementParser
{
#region Constants
public const string KeywordWith = "WITH";
public const string KeywordSelect = "SELECT";
public const string KeywordFrom = "FROM";
public const string KeywordWhere = "WHERE";
public const string KeywordGroupBy = "GROUP BY";
public const string KeywordHaving = "HAVING";
public const string KeywordOrderBy = "ORDER BY";
private static readonly char[] separator = new[] { '\r', '\n' };
private static readonly char[] semicolonSeparator = new[] { ';' };
#endregion
#region Normalization Methods
/// <summary>
/// Normalizes SQL by removing comments and extra whitespace.
/// </summary>
/// <param name="sql">The SQL statement to normalize.</param>
/// <returns>The normalized SQL statement.</returns>
public virtual string NormalizeSql(string sql)
{
// Remove SQL comments before processing
sql = RemoveSqlComments(sql);
// Replace multiple whitespace/newlines with single space
sql = Regex.Replace(sql, @"\s+", " ", RegexOptions.None, RegexDefaults.MatchTimeout);
return sql.Trim();
}
/// <summary>
/// Normalizes SQL whitespace while preserving comments.
/// </summary>
/// <param name="sql">The SQL statement to normalize.</param>
/// <returns>The normalized SQL statement with comments preserved.</returns>
public virtual string NormalizeSqlPreservingComments(string sql)
{
// Replace multiple spaces/tabs with single space, but preserve newlines for comment handling
sql = Regex.Replace(sql, @"[ \t]+", " ", RegexOptions.None, RegexDefaults.MatchTimeout);
// Remove leading/trailing whitespace from each line
var lines = sql.Split(separator, StringSplitOptions.None);
sql = string.Join("\n", lines.Select(line => line.Trim()));
return sql.Trim();
}
/// <summary>
/// Removes single-line (--) and multi-line (/* */) SQL comments from the SQL statement.
/// </summary>
/// <param name="sql">The SQL statement containing comments.</param>
/// <returns>The SQL statement with comments removed.</returns>
#pragma warning disable S3776 // Cognitive Complexity of methods should not be too high
#pragma warning disable S127 // "for" loop stop conditions should be invariant
public virtual string RemoveSqlComments(string sql)
{
var result = new StringBuilder();
var inString = false;
char stringChar = '\0';
var inMultiLineComment = false;
for (int i = 0; i < sql.Length; i++)
{
if (inMultiLineComment)
{
if (i + 1 < sql.Length && sql[i] == '*' && sql[i + 1] == '/')
{
inMultiLineComment = false;
i++; // Skip the '/'
}
continue;
}
if (!inString)
{
// Check for single-line comment
if (i + 1 < sql.Length && sql[i] == '-' && sql[i + 1] == '-')
{
i++; // Skip the second '-'
// Skip until end of line
while (i < sql.Length && sql[i] != '\n' && sql[i] != '\r')
{
i++;
}
if (i < sql.Length)
{
result.Append(sql[i]); // Keep the newline
}
continue;
}
// Check for multi-line comment
if (i + 1 < sql.Length && sql[i] == '/' && sql[i + 1] == '*')
{
inMultiLineComment = true;
i++; // Skip the second character '*', loop increment will advance past it
continue;
}
// Check for string start
if (sql[i] == '\'' || sql[i] == '"')
{
inString = true;
stringChar = sql[i];
}
}
else
{
// Check for string end
if (sql[i] == stringChar && (i == 0 || sql[i - 1] != '\\'))
{
inString = false;
}
}
result.Append(sql[i]);
}
return result.ToString();
}
#pragma warning restore S127
#pragma warning restore S3776
/// <summary>
/// Extracts single-line (--) and multi-line (/* */) SQL comments from the SQL statement.
/// Returns both the SQL without comments and the extracted comments.
/// </summary>
/// <param name="sql">The SQL statement containing comments.</param>
/// <param name="comments">The extracted comments as a list of strings.</param>
/// <returns>The SQL statement with comments removed.</returns>
#pragma warning disable S3776 // Cognitive Complexity of methods should not be too high
#pragma warning disable S127 // "for" loop stop conditions should be invariant
public virtual string ExtractSqlComments(string sql, out List<string> comments)
{
comments = [];
var result = new StringBuilder();
var inString = false;
char stringChar = '\0';
var inMultiLineComment = false;
var currentComment = new StringBuilder();
for (int i = 0; i < sql.Length; i++)
{
if (inMultiLineComment)
{
currentComment.Append(sql[i]);
if (i + 1 < sql.Length && sql[i] == '*' && sql[i + 1] == '/')
{
currentComment.Append(sql[i + 1]); // Add '/'
comments.Add(currentComment.ToString());
currentComment.Clear();
inMultiLineComment = false;
i++; // Skip the '/'
}
continue;
}
if (!inString)
{
// Check for single-line comment
if (i + 1 < sql.Length && sql[i] == '-' && sql[i + 1] == '-')
{
currentComment.Append(sql[i]);
i++; // Skip the second '-'
currentComment.Append(sql[i]);
// Collect until end of line
while (i + 1 < sql.Length && sql[i + 1] != '\n' && sql[i + 1] != '\r')
{
i++;
currentComment.Append(sql[i]);
}
comments.Add(currentComment.ToString());
currentComment.Clear();
// The newline will be handled by the normal loop flow
continue;
}
// Check for multi-line comment
if (i + 1 < sql.Length && sql[i] == '/' && sql[i + 1] == '*')
{
inMultiLineComment = true;
currentComment.Append(sql[i]);
i++; // Skip the second character '*'
currentComment.Append(sql[i]);
continue;
}
// Check for string start
if (sql[i] == '\'' || sql[i] == '"')
{
inString = true;
stringChar = sql[i];
}
}
else
{
// Check for string end
if (sql[i] == stringChar && (i == 0 || sql[i - 1] != '\\'))
{
inString = false;
}
}
result.Append(sql[i]);
}
return result.ToString();
}
#pragma warning restore S127
#pragma warning restore S3776
#endregion
#region Clause Extraction Methods
/// <summary>
/// Gets the setup keywords to look for before the main SELECT statement.
/// </summary>
/// <returns>Array of setup keywords.</returns>
protected virtual string[] GetSetupKeywords()
{
return new[] { "CREATE", "DECLARE", "SET" };
}
public virtual string ExtractSetupClauses(string sql, List<string> setupClauses)
{
// Look for common setup patterns like CREATE TABLE, DECLARE, etc.
var setupKeywords = GetSetupKeywords();
// Simple extraction: look for statements before the main SELECT
var selectIndex = Regex.Match(
sql, @"\bSELECT\b", RegexOptions.IgnoreCase, RegexDefaults.MatchTimeout).Index;
if (selectIndex > 0)
{
var beforeSelect = sql.Substring(0, selectIndex).Trim();
var matchingKeyword = setupKeywords.FirstOrDefault(keyword =>
beforeSelect.StartsWith(keyword, StringComparison.OrdinalIgnoreCase));
if (matchingKeyword != null)
{
// Extract setup clauses (simplified - would need more robust parsing for production)
var statements = beforeSelect.Split(semicolonSeparator, StringSplitOptions.RemoveEmptyEntries)
.Select(stmt => stmt.Trim())
.Where(trimmed => !string.IsNullOrEmpty(trimmed));
setupClauses.AddRange(statements);
return sql.Substring(selectIndex);
}
}
return sql;
}
/// <summary>
/// Gets the regex pattern for finish clauses (DROP statements, etc.).
/// </summary>
/// <returns>Regex pattern string.</returns>
protected virtual string GetFinishClausePattern()
{
return @";\s*(DROP\s+TABLE|DROP\s+PROCEDURE)";
}
public virtual string ExtractFinishClauses(string sql, ArrayList finishClauses)
{
// Look for DROP TABLE or other cleanup statements after the main query
var finishPattern = GetFinishClausePattern();
var match = Regex.Match(
sql, finishPattern, RegexOptions.IgnoreCase, RegexDefaults.MatchTimeout);
if (match.Success)
{
var finishSql = sql.Substring(match.Index + 1).Trim();
var statements = finishSql.Split(semicolonSeparator, StringSplitOptions.RemoveEmptyEntries);
foreach (var stmt in statements)
{
var trimmed = stmt.Trim();
if (!string.IsNullOrEmpty(trimmed))
{
finishClauses.Add(trimmed);
}
}
return sql.Substring(0, match.Index);
}
return sql;
}
public virtual bool TryParseWithClause(string sql, out string? withClause, out string mainQuery)
{
withClause = null;
mainQuery = sql;
// Check if SQL starts with WITH
var withMatch = Regex.Match(
sql, @"^\s*WITH\b", RegexOptions.IgnoreCase, RegexDefaults.MatchTimeout);
if (!withMatch.Success)
{
return false;
}
// Find the main SELECT that follows the WITH clause
// We need to find the top-level SELECT (not one inside a CTE)
int depth = 0;
int withStart = withMatch.Index + withMatch.Length;
int selectIndex = -1;
for (int i = withStart; i < sql.Length; i++)
{
if (sql[i] == '(')
{
depth++;
}
else if (sql[i] == ')')
{
depth--;
}
else if (depth == 0 && i + 6 <= sql.Length)
{
// Check if we're at a SELECT keyword at top level
var selectMatch = Regex.Match(
sql.Substring(i), @"^\s*SELECT\b",
RegexOptions.IgnoreCase, RegexDefaults.MatchTimeout);
if (selectMatch.Success && IsTopLevelKeyword(sql, i + selectMatch.Index))
{
selectIndex = i + selectMatch.Index;
break;
}
}
}
if (selectIndex > withStart)
{
withClause = sql.Substring(withStart, selectIndex - withStart).Trim();
mainQuery = sql.Substring(selectIndex).Trim();
return true;
}
return false;
}
#endregion
#region SELECT Statement Parsing
/// <summary>
/// Gets the array of SQL keywords to search for in the statement.
/// </summary>
/// <returns>Array of keywords to find.</returns>
protected virtual string[] GetKeywordsToFind()
=> [KeywordWith, KeywordSelect, KeywordFrom, KeywordWhere, KeywordGroupBy, KeywordHaving, KeywordOrderBy];
public virtual bool TryParseSelectStatement(string sql, out SqlClauses? clauses, out string errorMessage)
{
clauses = null;
errorMessage = null!;
try
{
// Check if it starts with WITH or SELECT (skip leading comments)
var sqlTrimmed = sql.TrimStart();
// Skip leading comments
while (sqlTrimmed.StartsWith("--") || sqlTrimmed.StartsWith("/*"))
{
if (sqlTrimmed.StartsWith("--"))
{
// Skip to end of line
var newlineIdx = sqlTrimmed.IndexOf('\n');
if (newlineIdx < 0)
{
break; // No newline found, can't continue
}
sqlTrimmed = sqlTrimmed.Substring(newlineIdx + 1).TrimStart();
}
else if (sqlTrimmed.StartsWith("/*"))
{
// Skip to end of multiline comment
var endIdx = sqlTrimmed.IndexOf("*/");
if (endIdx < 0)
{
break; // No end found, can't continue
}
sqlTrimmed = sqlTrimmed.Substring(endIdx + 2).TrimStart();
}
}
if (!Regex.IsMatch(sqlTrimmed, @"^\s*(WITH|SELECT)\b",
RegexOptions.IgnoreCase, RegexDefaults.MatchTimeout))
{
errorMessage = "SQL statement must start with WITH or SELECT.";
clauses = null;
return false;
}
var clausePositions = FindClausePositions(sql);
if (!clausePositions.ContainsKey(KeywordSelect))
{
errorMessage = "No SELECT clause found.";
clauses = null;
return false;
}
clauses = ExtractAllClauses(sql, clausePositions);
return true;
}
catch (Exception ex)
{
errorMessage = $"Error parsing SELECT statement: {ex.Message}";
clauses = null;
return false;
}
}
#pragma warning disable S3776 // Cognitive Complexity of methods should not be too high
public virtual Dictionary<string, int> FindClausePositions(string sql)
{
var clausePositions = new Dictionary<string, int>();
var keywords = GetKeywordsToFind();
var keywordSet = new HashSet<string>(keywords, StringComparer.OrdinalIgnoreCase);
// Tokenize the SQL using dialect-specific rules
var tokens = TokenizeSql(sql);
// Now find keywords at appropriate depth levels
ProcessTokensForKeywords(tokens, keywords, keywordSet, clausePositions);
return clausePositions;
}
#pragma warning restore S127
#pragma warning restore S3776
/// <summary>
/// Tokenizes SQL statement into a list of tokens for keyword extraction.
/// SQL Server-specific: Treats both single and double quotes as string literals.
/// Handles comments during tokenization.
/// </summary>
/// <param name="sql">The SQL statement to tokenize.</param>
/// <returns>List of tokens with type, value, and position.</returns>
#pragma warning disable S3776 // Cognitive Complexity of methods should not be too high
#pragma warning disable S127 // "for" loop stop conditions should be invariant
protected virtual List<(TokenType type, string value, int position)> TokenizeSql(string sql)
{
var tokens = new List<(TokenType type, string value, int position)>();
// Manual tokenization that respects SQL syntax
int i = 0;
bool inSingleLineComment = false;
bool inMultiLineComment = false;
while (i < sql.Length)
{
char c = sql[i];
// Handle comments - skip them during tokenization (if enabled)
if (ShouldHandleComments())
{
if (inSingleLineComment)
{
if (c == '\n' || c == '\r')
{
inSingleLineComment = false;
}
i++;
continue;
}
if (inMultiLineComment)
{
if (i + 1 < sql.Length && c == '*' && sql[i + 1] == '/')
{
inMultiLineComment = false;
i += 2; // Skip */
continue;
}
i++;
continue;
}
// Check for start of single-line comment
if (i + 1 < sql.Length && c == '-' && sql[i + 1] == '-')
{
inSingleLineComment = true;
i += 2;
continue;
}
// Check for start of multi-line comment
if (i + 1 < sql.Length && c == '/' && sql[i + 1] == '*')
{
inMultiLineComment = true;
i += 2;
continue;
}
}
// Skip whitespace but track position
if (char.IsWhiteSpace(c))
{
i++;
continue;
}
// Handle parentheses
if (c == '(')
{
tokens.Add((TokenType.LeftParenthesis, "(", i));
i++;
continue;
}
if (c == ')')
{
tokens.Add((TokenType.RightParenthesis, ")", i));
i++;
continue;
}
// Handle double-quote (dialect-specific: string literal or identifier)
if (c == '"')
{
var (token, newPosition) = HandleDoubleQuote(sql, i);
tokens.Add(token);
i = newPosition;
continue;
}
// Handle bracketed identifiers [ColumnName]
if (c == '[')
{
int start = i;
i++; // Skip opening bracket
var identifier = new StringBuilder();
while (i < sql.Length && sql[i] != ']')
{
identifier.Append(sql[i]);
i++;
}
if (i < sql.Length)
{
i++; // Skip closing bracket
}
tokens.Add((TokenType.ColumnIdentifier, identifier.ToString(), start));
continue;
}
// Handle single-quoted string literals
if (c == '\'')
{
char quote = c;
int start = i;
var str = new StringBuilder();
str.Append(c);
i++;
while (i < sql.Length && sql[i] != quote)
{
str.Append(sql[i]);
i++;
}
if (i < sql.Length)
{
str.Append(sql[i]); // Include closing quote
i++;
}
tokens.Add((TokenType.String, str.ToString(), start));
continue;
}
// Handle words (potential keywords or identifiers)
if (IsWordStartCharacter(c))
{
int start = i;
var word = new StringBuilder();
while (i < sql.Length && (char.IsLetterOrDigit(sql[i]) || sql[i] == '_'))
{
word.Append(sql[i]);
i++;
}
tokens.Add((TokenType.String, word.ToString(), start));
continue;
}
// Skip other characters (operators, commas, etc.)
i++;
}
return tokens;
}
#pragma warning restore S127
#pragma warning restore S3776
/// <summary>
/// Determines whether comments should be handled during tokenization.
/// SQL Server: true (handles -- and /* */ comments).
/// </summary>
/// <returns>True if comments should be handled during tokenization.</returns>
protected virtual bool ShouldHandleComments() => true;
/// <summary>
/// Determines whether a character can start a word (keyword or identifier).
/// SQL Server: Only letters can start words.
/// </summary>
/// <param name="c">The character to check.</param>
/// <returns>True if the character can start a word.</returns>
protected virtual bool IsWordStartCharacter(char c) => char.IsLetter(c);
/// <summary>
/// Handles double-quote character during tokenization.
/// SQL Server: Treats double-quote as string literal (same as single quote).
/// </summary>
/// <param name="sql">The SQL statement being tokenized.</param>
/// <param name="position">Current position in the SQL string.</param>
/// <returns>Token and new position after the token.</returns>
protected virtual ((TokenType type, string value, int position) token, int newPosition) HandleDoubleQuote(string sql, int position)
{
// SQL Server: double-quote is a string literal (same as single quote)
char quote = '"';
int start = position;
var str = new StringBuilder();
str.Append(quote);
position++;
while (position < sql.Length && sql[position] != quote)
{
str.Append(sql[position]);
position++;
}
if (position < sql.Length)
{
str.Append(sql[position]); // Include closing quote
position++;
}
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>
/// <param name="tokens">The list of parsed tokens.</param>
/// <param name="keywords">Array of keywords to search for.</param>
/// <param name="keywordSet">HashSet for efficient keyword lookup.</param>
/// <param name="clausePositions">Dictionary to populate with found keyword positions.</param>
protected virtual void ProcessTokensForKeywords(
List<(TokenType type, string value, int position)> tokens,
string[] keywords,
HashSet<string> keywordSet,
Dictionary<string, int> clausePositions)
{
int parenthesisDepth = 0;
bool skipNextToken = false;
for (int tokenIndex = 0; tokenIndex < tokens.Count; tokenIndex++)
{
// Handle skipping of consumed tokens (e.g., "BY" after "GROUP" or "ORDER")
if (skipNextToken)
{
skipNextToken = false;
continue;
}
var (type, value, position) = tokens[tokenIndex];
if (type == TokenType.LeftParenthesis)
{
parenthesisDepth++;
continue;
}
if (type == TokenType.RightParenthesis)
{
parenthesisDepth--;
continue;
}
// Only process keywords at top level (outside parentheses)
if (parenthesisDepth == 0 && type == TokenType.String)
{
skipNextToken = TryRecordKeywordAtToken(tokens, tokenIndex, value, position, keywords, keywordSet, clausePositions);
}
}
}
/// <summary>
/// Records the keyword at the given token (single-word or multi-word) into <paramref name="clausePositions"/>.
/// </summary>
/// <returns>True if a multi-word keyword (GROUP BY / ORDER BY) was matched and the following token should be skipped.</returns>
private static bool TryRecordKeywordAtToken(
List<(TokenType type, string value, int position)> tokens,
int tokenIndex,
string value,
int position,
string[] keywords,
HashSet<string> keywordSet,
Dictionary<string, int> clausePositions)
{
// Check for multi-word keywords (GROUP BY, ORDER BY); they consume the following BY token.
if (TryRecordMultiWordKeyword(tokens, tokenIndex, "GROUP", KeywordGroupBy, clausePositions) ||
TryRecordMultiWordKeyword(tokens, tokenIndex, "ORDER", KeywordOrderBy, clausePositions))
{
return true;
}
if (keywordSet.Contains(value))
{
var matchedKeyword = keywords.FirstOrDefault(k =>
string.Equals(k, value, StringComparison.OrdinalIgnoreCase));
if (matchedKeyword != null && !clausePositions.ContainsKey(matchedKeyword))
{
clausePositions[matchedKeyword] = position;
}
}
return false;
}
/// <summary>
/// Records a two-word keyword (e.g., "GROUP BY") when the token at <paramref name="tokenIndex"/> matches
/// <paramref name="firstWord"/> and is immediately followed by "BY".
/// </summary>
/// <returns>True if the multi-word keyword pattern matched.</returns>
private static bool TryRecordMultiWordKeyword(
List<(TokenType type, string value, int position)> tokens,
int tokenIndex,
string firstWord,
string canonicalKeyword,
Dictionary<string, int> clausePositions)
{
var (_, value, position) = tokens[tokenIndex];
if (!string.Equals(value, firstWord, StringComparison.OrdinalIgnoreCase) ||
tokenIndex + 1 >= tokens.Count ||
tokens[tokenIndex + 1].type != TokenType.String ||
!string.Equals(tokens[tokenIndex + 1].value, "BY", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!clausePositions.ContainsKey(canonicalKeyword))
{
clausePositions[canonicalKeyword] = position;
}
return true;
}
public virtual SqlClauses ExtractAllClauses(string sql, Dictionary<string, int> clausePositions)
{
var clauses = new SqlClauses
{
SelectClause = ExtractExpressionClauseWithComments(sql, clausePositions, KeywordSelect, KeywordSelect.Length,
GetNextClausePosition(clausePositions, sql.Length, KeywordFrom), splitOnComma: true),
FromClause = clausePositions.ContainsKey(KeywordFrom) ?
ExtractClauseWithComments(sql, clausePositions, KeywordFrom, KeywordFrom.Length,
GetNextClausePosition(clausePositions, sql.Length, KeywordWhere, KeywordGroupBy, KeywordOrderBy)) : null,
WhereClause = clausePositions.ContainsKey(KeywordWhere) ?
ExtractExpressionClauseWithComments(sql, clausePositions, KeywordWhere, KeywordWhere.Length,
GetNextClausePosition(clausePositions, sql.Length, KeywordGroupBy, KeywordOrderBy), splitOnComma: false) : null,
GroupByClause = clausePositions.ContainsKey(KeywordGroupBy) ?
ExtractExpressionClauseWithComments(sql, clausePositions, KeywordGroupBy, KeywordGroupBy.Length,
GetNextClausePosition(clausePositions, sql.Length, KeywordHaving, KeywordOrderBy), splitOnComma: true) : null,
HavingClause = clausePositions.ContainsKey(KeywordHaving) ?
ExtractExpressionClauseWithComments(sql, clausePositions, KeywordHaving, KeywordHaving.Length,
GetNextClausePosition(clausePositions, sql.Length, KeywordOrderBy), splitOnComma: false) : null,
OrderByClause = clausePositions.ContainsKey(KeywordOrderBy) ?
ExtractExpressionClauseWithComments(sql, clausePositions, KeywordOrderBy, KeywordOrderBy.Length, sql.Length, splitOnComma: true) : null
};
// Allow derived classes to post-process clauses for dialect-specific pagination (LIMIT, OFFSET, TOP, etc.)
PostProcessClauses(clauses, sql, clausePositions);
return clauses;
}
/// <summary>
/// Post-processes extracted clauses to handle dialect-specific pagination syntax (LIMIT, OFFSET, TOP, etc.).
/// Base implementation does nothing - override in derived classes for dialect-specific behavior.
/// </summary>
/// <param name="clauses">The extracted clauses to post-process.</param>
/// <param name="sql">The original SQL statement.</param>
/// <param name="clausePositions">Dictionary of keyword positions.</param>
protected virtual void PostProcessClauses(SqlClauses clauses, string sql, Dictionary<string, int> clausePositions)
{
// Base implementation: no post-processing needed for standard SQL Server
}
public virtual SqlClause ExtractClauseWithComments(string sql, Dictionary<string, int> positions, string keyword, int keywordLength, int endPosition)
{
var startPosition = positions[keyword] + keywordLength;
var clauseText = sql.Substring(startPosition, endPosition - startPosition);
// Extract comments from this clause
var clauseWithoutComments = ExtractSqlComments(clauseText, out var comments);
return new SqlClause
{
Clause = clauseWithoutComments.Trim(),
Comment = comments.Count > 0 ? string.Join(" ", comments) : null
};
}
/// <summary>
/// Extracts a SQL expression clause (SELECT, WHERE, HAVING) with comments.
/// </summary>
public virtual SqlExpressionClause ExtractExpressionClauseWithComments(string sql, Dictionary<string, int> positions, string keyword, int keywordLength, int endPosition, bool splitOnComma)
{
var startPosition = positions[keyword] + keywordLength;
var clauseText = sql.Substring(startPosition, endPosition - startPosition);
// Extract comments from this clause
var clauseWithoutComments = ExtractSqlComments(clauseText, out var comments);
return new SqlExpressionClause(splitOnComma)
{
Clause = clauseWithoutComments.Trim(),
Comment = comments.Count > 0 ? string.Join(" ", comments) : null
};
}
public virtual string ExtractClause(string sql, Dictionary<string, int> positions, string keyword, int keywordLength, int endPosition)
{
var startPosition = positions[keyword] + keywordLength;
return sql.Substring(startPosition, endPosition - startPosition).Trim();
}
public virtual int GetNextClausePosition(Dictionary<string, int> clausePositions, int defaultEnd, params string[] keywords)
{
return keywords.Where(clausePositions.ContainsKey)
.Select(keyword => clausePositions[keyword])
.DefaultIfEmpty(defaultEnd)
.First();
}
#endregion
#region Parameter Extraction
public virtual void ExtractParameters(Dictionary<string, object> parameters, string sql)
{
ExtractParameters(parameters, sql, @"@([a-zA-Z_][a-zA-Z0-9_]*)");
}
/// <summary>
/// Extracts parameters from SQL using the specified pattern.
/// </summary>
/// <param name="parameters">The parameter dictionary to populate.</param>
/// <param name="sql">The SQL statement to extract parameters from.</param>
/// <param name="paramPattern">The regex pattern to match parameter names.</param>
protected virtual void ExtractParameters(Dictionary<string, object> parameters, string sql, string paramPattern)
{
var matches = Regex.Matches(sql, paramPattern, RegexOptions.None, RegexDefaults.MatchTimeout);
var paramNames = matches.Cast<Match>()
.Select(match => match.Value)
.Distinct();
foreach (var paramName in paramNames)
{
// Initialize parameter with null value
// User can set actual values later using SetParameterValue
parameters[paramName] = null!;
}
}
#endregion
#region Helper Methods
#pragma warning disable S3776 // Cognitive Complexity of methods should not be too high
public virtual bool IsTopLevelKeyword(string sql, int position)
#pragma warning restore S3776
{
// Check if the keyword at 'position' is at the top level (not in a subquery or string)
int parenthesisDepth = 0;
bool inString = false;
char stringChar = '\0';
for (int i = 0; i < position; i++)
{
if (!inString)
{
if (sql[i] == '(' && (i == 0 || sql[i - 1] != '\\'))
{
parenthesisDepth++;
}
else if (sql[i] == ')' && (i == 0 || sql[i - 1] != '\\'))
{
parenthesisDepth--;
}
else if ((sql[i] == '\'' || sql[i] == '"') && (i == 0 || sql[i - 1] != '\\'))
{
inString = true;
stringChar = sql[i];
}
}
else
{
if (sql[i] == stringChar && (i == 0 || sql[i - 1] != '\\'))
{
inString = false;
}
}
}
return parenthesisDepth == 0 && !inString;
}
#endregion
}