chore: initial git load of code space
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user