SonarQube Analysis / sonarqube (pull_request) Successful in 4m5s
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>
951 lines
35 KiB
C#
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
|
|
}
|