chore: initial git load of code space

This commit is contained in:
Thom Lamb
2026-05-12 08:52:33 -05:00
parent 9abada692f
commit 5e467bcc9c
384 changed files with 65960 additions and 2 deletions
@@ -0,0 +1,891 @@
using System.Collections;
using System.Text;
using System.Text.RegularExpressions;
using Strata.SqlTools.SqlBreakdown.Classes;
using Strata.SqlTools.SqlBreakdown.Enums.SQL;
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";
#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+", " ");
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]+", " ");
// Remove leading/trailing whitespace from each line
var lines = sql.Split(new[] { '\r', '\n' }, 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 = new List<string>();
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).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(new[] { ';' }, 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);
if (match.Success)
{
var finishSql = sql.Substring(match.Index + 1).Trim();
var statements = finishSql.Split(new[] { ';' }, 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);
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);
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))
{
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>
/// 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)
{
// Check for multi-word keywords (GROUP BY, ORDER BY)
if (string.Equals(value, "GROUP", StringComparison.OrdinalIgnoreCase) &&
tokenIndex + 1 < tokens.Count &&
tokens[tokenIndex + 1].type == TokenType.String &&
string.Equals(tokens[tokenIndex + 1].value, "BY", StringComparison.OrdinalIgnoreCase))
{
if (!clausePositions.ContainsKey(KeywordGroupBy))
{
clausePositions[KeywordGroupBy] = position;
}
skipNextToken = true; // Skip BY in next iteration
}
else if (string.Equals(value, "ORDER", StringComparison.OrdinalIgnoreCase) &&
tokenIndex + 1 < tokens.Count &&
tokens[tokenIndex + 1].type == TokenType.String &&
string.Equals(tokens[tokenIndex + 1].value, "BY", StringComparison.OrdinalIgnoreCase))
{
if (!clausePositions.ContainsKey(KeywordOrderBy))
{
clausePositions[KeywordOrderBy] = position;
}
skipNextToken = true; // Skip BY in next iteration
}
else if (keywordSet.Contains(value))
{
var matchedKeyword = keywords.FirstOrDefault(k =>
string.Equals(k, value, StringComparison.OrdinalIgnoreCase));
if (matchedKeyword != null && !clausePositions.ContainsKey(matchedKeyword))
{
clausePositions[matchedKeyword] = position;
}
}
}
}
}
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);
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
}