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,313 @@
using Strata.SqlTools.SqlBreakdown.Classes;
using Strata.SqlTools.SqlBreakdown.Enums.SQL;
using Strata.SqlTools.SqlBreakdown.Exceptions;
using Strata.SqlTools.SqlBreakdown.Expressions;
using Strata.SqlTools.SqlBreakdown.Expressions.Functions.Aggregate;
using Strata.SqlTools.SqlBreakdown.Expressions.Literals;
using Strata.SqlTools.SqlBreakdown.Interfaces;
namespace Strata.SqlTools.Statements.SqlServer;
/// <summary>
/// Takes in a string representation of a sql statement and returns it as an <see cref="Expression"/>
/// </summary>
public class StatementExpressionParser : IStatementExpressionParser
{
/// <summary>
/// Parses a SQL statement string into an Expression tree.
/// </summary>
/// <param name="sqlStatement">The SQL statement string to parse.</param>
/// <returns>An <see cref="Expression"/> representing the parsed SQL statement.</returns>
/// <exception cref="ArgumentNullException">Thrown when sqlStatement is null or empty.</exception>
/// <exception cref="FormatException">Thrown when the SQL statement cannot be parsed.</exception>
public Expression Parse(string sqlStatement)
{
if (string.IsNullOrWhiteSpace(sqlStatement))
{
throw new ArgumentNullException(nameof(sqlStatement), "SQL statement cannot be null or empty.");
}
if (!TryParse(sqlStatement, out var result, out var error))
{
throw new FormatException($"Failed to parse SQL statement: {error}");
}
return result;
}
/// <summary>
/// Attempts to parse a SQL statement string into an Expression tree.
/// </summary>
/// <param name="sqlStatement">The SQL statement string to parse.</param>
/// <param name="result">When this method returns, contains the parsed Expression if successful, or null if parsing failed.</param>
/// <returns>true if the SQL was successfully parsed; otherwise, false.</returns>
public bool TryParse(string sqlStatement, out Expression result)
=> TryParse(sqlStatement, out result, out _);
/// <summary>
/// Attempts to parse a SQL statement string into an Expression tree.
/// </summary>
/// <param name="sqlStatement">The SQL statement string to parse.</param>
/// <param name="result">When this method returns, contains the parsed Expression if successful, or null if parsing failed.</param>
/// <param name="errorMessage">When this method returns false, contains a message describing why parsing failed.</param>
/// <returns>true if the SQL was successfully parsed; otherwise, false.</returns>
public bool TryParse(string sqlStatement, out Expression result, out string errorMessage)
{
result = null!;
errorMessage = null!;
try
{
if (string.IsNullOrWhiteSpace(sqlStatement))
{
errorMessage = "SQL statement cannot be null or empty.";
return false;
}
// Normalize the SQL: remove comments and extra whitespace
sqlStatement = NormalizeSql(sqlStatement);
var reader = CreateStatementReader(sqlStatement);
reader.Read();
result = GrabExpression(reader);
// Verify all tokens have been consumed
if (reader.TokenType != TokenType.None)
{
errorMessage = $"Invalid syntax at position {reader.Position}. Unexpected token: {reader.TokenValue}";
return false;
}
return true;
}
catch (InvalidSyntaxException ex)
{
errorMessage = ex.Message;
return false;
}
catch (NotSupportedException ex)
{
errorMessage = ex.Message;
return false;
}
catch (Exception ex)
{
errorMessage = $"Unexpected error during parsing: {ex.Message}";
return false;
}
}
/// <summary>
/// Creates the appropriate statement reader for tokenizing SQL.
/// Override in derived classes to provide dialect-specific readers.
/// </summary>
/// <param name="sqlStatement">The SQL statement to tokenize.</param>
/// <returns>A StatementReader instance.</returns>
protected virtual IStatementReader CreateStatementReader(string sqlStatement)
{
return new StatementReader(sqlStatement);
}
/// <summary>
/// Parses an expression handling addition and subtraction operations.
/// </summary>
/// <param name="reader">The SQL statement reader positioned at the start of the expression.</param>
/// <returns>An <see cref="Expression"/> representing the parsed expression with addition/subtraction operations.</returns>
protected Expression GrabExpression(IStatementReader reader)
{
var left = GrabTerm(reader);
while (reader.TokenType is TokenType.Plus or TokenType.Minus)
{
var operation = reader.TokenType;
reader.Read();
var right = GrabTerm(reader);
switch (operation)
{
case TokenType.Plus:
left += right;
break;
case TokenType.Minus:
left -= right;
break;
}
}
return left;
}
/// <summary>
/// Parses a term handling multiplication and division operations.
/// </summary>
/// <param name="reader">The SQL statement reader positioned at the start of the term.</param>
/// <returns>An <see cref="Expression"/> representing the parsed term with multiplication/division operations.</returns>
protected Expression GrabTerm(IStatementReader reader)
{
var left = GrabFactor(reader);
while (reader.TokenType is TokenType.Multiply or TokenType.Divide)
{
var operation = reader.TokenType;
reader.Read();
var right = GrabFactor(reader);
switch (operation)
{
case TokenType.Multiply:
left *= right;
break;
case TokenType.Divide:
left /= right;
break;
}
}
return left;
}
/// <summary>
/// Parses a factor (basic expression element) such as a number, column, function, or parenthesized expression.
/// </summary>
/// <param name="reader">The SQL statement reader positioned at the start of the factor.</param>
/// <returns>An <see cref="Expression"/> representing the parsed factor.</returns>
/// <exception cref="NotSupportedException">Thrown when the token type is not supported.</exception>
protected virtual Expression GrabFactor(IStatementReader reader)
{
return reader.TokenType switch
{
TokenType.LeftParenthesis => GrabParenthesisExpression(reader),
TokenType.FunctionStart => GrabFunctionExpression(reader),
TokenType.Number => GrabNumberExpression(reader),
TokenType.ColumnIdentifier => GrabColumnExpression(reader),
_ => throw new NotSupportedException($"not expecting token of type {reader.TokenType}")
};
}
/// <summary>
/// Parses an expression enclosed in parentheses.
/// </summary>
/// <param name="reader">The SQL statement reader positioned at the left parenthesis.</param>
/// <returns>An <see cref="Expression"/> representing the parsed expression within the parentheses.</returns>
/// <exception cref="InvalidSyntaxException">Thrown when expected parentheses are not found.</exception>
protected Expression GrabParenthesisExpression(IStatementReader reader)
{
if (reader.TokenType != TokenType.LeftParenthesis)
{
throw new InvalidSyntaxException($"Invalid syntax at position {reader.Position}. Expected {TokenType.LeftParenthesis} but {reader.TokenType} is given.");
}
reader.Read();
var node = GrabExpression(reader);
if (reader.TokenType != TokenType.RightParenthesis)
{
throw new InvalidSyntaxException($"Invalid syntax at position {reader.Position}. Expected {TokenType.RightParenthesis} but {reader.TokenType} is given.");
}
reader.Read();
return node;
}
/// <summary>
/// Parses a SQL function expression (e.g., SUM, AVG) with its arguments.
/// </summary>
/// <param name="reader">The SQL statement reader positioned at the function start.</param>
/// <returns>An <see cref="Expression"/> representing the parsed function.</returns>
/// <exception cref="NotSupportedException">Thrown when the function name is not recognized.</exception>
protected virtual Expression GrabFunctionExpression(IStatementReader reader)
{
var functionName = reader.TokenValue;
var functionArguments = new List<Expression>();
reader.Read();
while (reader.TokenType != TokenType.FunctionEnd)
{
var arg = GrabExpression(reader);
functionArguments.Add(arg);
}
reader.Read();
return functionName.ToUpper() switch
{
"SUM" => new SumFunction(functionArguments[0]),
"AVG" => new AverageFunction(functionArguments[0]),
_ => throw new NotSupportedException($"function with name {functionName} not recognized")
};
}
/// <summary>
/// Parses a numeric literal expression.
/// </summary>
/// <param name="reader">The SQL statement reader positioned at the numeric token.</param>
/// <returns>A <see cref="LiteralExpression"/> representing the parsed number.</returns>
protected virtual LiteralExpression GrabNumberExpression(IStatementReader reader)
{
var numberValue = decimal.Parse(reader.TokenValue);
reader.Read();
return new NumberLiteralExpression(numberValue);
}
/// <summary>
/// Parses a column identifier expression.
/// Note: This is a mock implementation for testing purposes.
/// </summary>
/// <param name="reader">The SQL statement reader positioned at the column identifier token.</param>
/// <returns>A <see cref="RegisteredTableColumnExpression"/> representing the parsed column.</returns>
protected virtual RegisteredTableColumnExpression GrabColumnExpression(IStatementReader reader)
{
var columnToken = reader.TokenValue;
var dataColumnId = GetColumnIdFromToken(columnToken);
var tableSource = new RegisteredTableSource(1001, "FW", "DEPARTMENT", "DEPT");
reader.Read();
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)
};
}
/// <summary>
/// Extracts the column ID from a token string.
/// Default implementation assumes tokens start with a numeric ID followed by underscore.
/// </summary>
/// <param name="columnToken">The column token string.</param>
/// <returns>The extracted column ID.</returns>
protected virtual int GetColumnIdFromToken(string columnToken)
{
return int.Parse(columnToken.Split('_')[0]);
}
/// <summary>
/// Gets the default column name for unknown column IDs.
/// </summary>
/// <param name="columnToken">The column token string.</param>
/// <returns>The default column name.</returns>
protected virtual string GetDefaultColumnName(string columnToken)
{
return "FOOBAR";
}
#region Helper 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>
private static string NormalizeSql(string sql)
{
var parser = new StatementParser();
return parser.NormalizeSql(sql);
}
#endregion
}
@@ -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
}
@@ -0,0 +1,250 @@
using System.Globalization;
using System.Text;
using Strata.SqlTools.SqlBreakdown.Classes;
using Strata.SqlTools.SqlBreakdown.Enums.SQL;
using Strata.SqlTools.SqlBreakdown.Exceptions;
using Strata.SqlTools.SqlBreakdown.Interfaces;
namespace Strata.SqlTools.Statements.SqlServer;
/// <summary>
/// Tokenizer class that reads a string representation of a sql statement and parses out each part as a token
/// </summary>
#pragma warning disable S2325 // Methods and properties that don't access instance data should be static - False positive: These members access instance fields
public class StatementReader : IStatementReader
{
public int Position { get; private set; } = 0;
public int Length => _sqlStatement.Length;
public char CurrentCharacter => _sqlStatement[Position];
public TokenType TokenType => _currentToken.Type;
public string TokenValue => _currentToken.Value;
protected Token _currentToken = Token.None();
protected bool _inFunctionBlock = false;
protected readonly string _sqlStatement;
public StatementReader(string sqlStatement)
{
if (string.IsNullOrWhiteSpace(sqlStatement))
{
throw new ArgumentNullException(nameof(sqlStatement));
}
_sqlStatement = sqlStatement + char.MinValue;
}
public bool Read()
{
NextToken();
return TokenType != TokenType.None;
}
protected virtual void NextToken()
{
if (CurrentCharacter == char.MinValue)
{
_currentToken = Token.None();
return;
}
// skip spaces to next viable character
if (!TrySkip(c => char.IsWhiteSpace(c) || c == ','))
{
_currentToken = Token.None();
return;
}
switch (CurrentCharacter)
{
case char.MinValue:
_currentToken = Token.None();
return;
case '+':
_currentToken = new Token(TokenType.Plus, CurrentCharacter.ToString());
MovePosition();
return;
case '-':
_currentToken = new Token(TokenType.Minus, CurrentCharacter.ToString());
MovePosition();
return;
case '*':
_currentToken = new Token(TokenType.Multiply, CurrentCharacter.ToString());
MovePosition();
return;
case '/':
_currentToken = new Token(TokenType.Divide, CurrentCharacter.ToString());
MovePosition();
return;
case '(':
_currentToken = new Token(TokenType.LeftParenthesis, CurrentCharacter.ToString());
MovePosition();
return;
case ')' when _inFunctionBlock:
MovePosition();
_inFunctionBlock = false;
_currentToken = new Token(TokenType.FunctionEnd, ")");
return;
case ')':
_currentToken = new Token(TokenType.RightParenthesis, CurrentCharacter.ToString());
MovePosition();
return;
case '[':
MovePosition();
var stringValue = GrabStringValue();
_currentToken = new Token(TokenType.ColumnIdentifier, stringValue);
if (CurrentCharacter != ']')
{
throw new InvalidSyntaxException(
$"Invalid syntax at position {Position}. Unexpected symbol {CurrentCharacter}.");
}
MovePosition();
return;
}
// Allow derived classes to handle additional characters (e.g., double-quotes in Snowflake)
if (TryHandleAdditionalCharacter())
{
return;
}
// Allow derived classes to handle special identifier prefixes (e.g., underscores in Snowflake)
if (TryHandleIdentifierPrefix())
{
return;
}
if (char.IsDigit(CurrentCharacter))
{
// Check if this is a column identifier starting with a digit (e.g., "3_REVENUE")
// by looking ahead to see if there's an underscore after the digit(s)
var tempPos = Position;
while (tempPos < Length - 1 && char.IsDigit(_sqlStatement[tempPos]))
{
tempPos++;
}
// If we found an underscore after digit(s), treat as a column identifier
if (tempPos < Length - 1 && _sqlStatement[tempPos] == '_')
{
var stringValue = GrabStringValue();
_currentToken = new Token(TokenType.ColumnIdentifier, stringValue);
return;
}
// Otherwise, it's just a number
var number = GrabNumberValue();
_currentToken = new Token(TokenType.Number, number.ToString(CultureInfo.InvariantCulture));
return;
}
if (char.IsLetter(CurrentCharacter))
{
var stringValue = GrabStringValue();
// if next char is opening paren then its a function
if (CurrentCharacter == '(')
{
MovePosition();
_inFunctionBlock = true;
_currentToken = new Token(TokenType.FunctionStart, stringValue);
return;
}
_currentToken = new Token(TokenType.String, stringValue);
return;
}
throw new InvalidSyntaxException($"Invalid syntax at position {Position}. Unexpected symbol {CurrentCharacter}.");
}
/// <summary>
/// Allows derived classes to handle additional characters not covered by the base implementation.
/// For example, Snowflake uses double-quotes (") for delimited identifiers.
/// </summary>
/// <returns>True if the character was handled; false otherwise.</returns>
protected virtual bool TryHandleAdditionalCharacter() => false;
/// <summary>
/// Allows derived classes to handle special identifier prefix characters.
/// For example, Snowflake allows underscores (_) to start identifiers.
/// </summary>
/// <returns>True if the character was handled; false otherwise.</returns>
protected virtual bool TryHandleIdentifierPrefix() => false;
protected decimal GrabNumberValue()
{
var numberString = new StringBuilder();
while (char.IsDigit(CurrentCharacter))
{
numberString.Append(CurrentCharacter);
MovePosition();
}
if (CurrentCharacter != '.')
{
return int.Parse(numberString.ToString());
}
numberString.Append(CurrentCharacter);
MovePosition();
if (!char.IsDigit(CurrentCharacter))
{
throw new InvalidSyntaxException(
$"Invalid syntax at position {Position}. Unexpected symbol {CurrentCharacter}.");
}
while (char.IsDigit(CurrentCharacter))
{
numberString.Append(CurrentCharacter);
MovePosition();
}
return decimal.Parse(numberString.ToString());
}
protected string GrabStringValue(string prefix = "")
{
var stringValue = new StringBuilder(prefix);
while (char.IsLetterOrDigit(CurrentCharacter) || CurrentCharacter == '_')
{
stringValue.Append(CurrentCharacter);
MovePosition();
}
return stringValue.ToString();
}
// move position until the current character does not satisfy given condition
protected bool TrySkip(Func<char, bool> shouldSkipCharFunc)
{
if (!shouldSkipCharFunc(CurrentCharacter))
{
return true;
}
// skip spaces to next viable character
while (shouldSkipCharFunc(CurrentCharacter))
{
MovePosition();
if (CurrentCharacter == char.MinValue)
{
return false;
}
}
return true;
}
protected void MovePosition()
{
if (Position < Length - 1)
{
Position++;
}
}
}
#pragma warning restore S2325