chore: initial git load of code space
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
using System.Collections;
|
||||
using System.Text;
|
||||
using Strata.SqlTools.SqlBreakdown.Classes;
|
||||
using Strata.SqlTools.Statements.SqlServer;
|
||||
|
||||
namespace Strata.SqlTools.Breakdowns.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a DELETE SQL statement breakdown with FROM and WHERE clauses for SQL Server.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class DeleteBreakdown : SqlBreakdownBase
|
||||
{
|
||||
protected readonly StatementParser Parser;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeleteBreakdown"/> class.
|
||||
/// </summary>
|
||||
public DeleteBreakdown()
|
||||
{
|
||||
Parser = new StatementParser();
|
||||
FromClause = new SqlClause();
|
||||
WhereClause = new SqlClause();
|
||||
DeleteClause = new SqlClause();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeleteBreakdown"/> class.
|
||||
/// </summary>
|
||||
/// <param name="fromClause">The FROM clause.</param>
|
||||
/// <param name="whereClause">The WHERE clause.</param>
|
||||
public DeleteBreakdown(string fromClause, string whereClause) : this()
|
||||
{
|
||||
var cleanFrom = Parser.ExtractSqlComments(fromClause, out var fromComments);
|
||||
FromClause.Clause = cleanFrom.Trim();
|
||||
FromClause.Comment = fromComments.Count > 0 ? string.Join(" ", fromComments) : null;
|
||||
|
||||
var cleanWhere = Parser.ExtractSqlComments(whereClause, out var whereComments);
|
||||
WhereClause.Clause = cleanWhere.Trim();
|
||||
WhereClause.Comment = whereComments.Count > 0 ? string.Join(" ", whereComments) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the FROM clause.
|
||||
/// </summary>
|
||||
public SqlClause FromClause { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the DELETE clause (optional, for DELETE with alias).
|
||||
/// </summary>
|
||||
public SqlClause DeleteClause { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether a WHERE clause is being used.
|
||||
/// </summary>
|
||||
public bool IsUsingWhereClause => !string.IsNullOrWhiteSpace(WhereClause.Clause);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the WHERE clause.
|
||||
/// </summary>
|
||||
public SqlClause WhereClause { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SQL breakdown as a string.
|
||||
/// </summary>
|
||||
/// <returns>The DELETE SQL statement.</returns>
|
||||
protected override string GetSqlBreakdown()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("DELETE ");
|
||||
if (!string.IsNullOrWhiteSpace(DeleteClause.Clause))
|
||||
{
|
||||
sb.AppendLine($" {DeleteClause.Clause}");
|
||||
}
|
||||
|
||||
sb.AppendLine("FROM ");
|
||||
sb.AppendLine($" {FromClause.Clause}");
|
||||
|
||||
if (IsUsingWhereClause)
|
||||
{
|
||||
sb.AppendLine("WHERE ");
|
||||
sb.AppendLine($" {WhereClause.Clause}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
#region Parse Methods
|
||||
|
||||
/// <summary>
|
||||
/// Parses a DELETE SQL statement into a DeleteBreakdown object.
|
||||
/// </summary>
|
||||
/// <param name="sql">The DELETE SQL statement to parse.</param>
|
||||
/// <returns>A DeleteBreakdown object representing the parsed statement.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when sql is null or empty.</exception>
|
||||
/// <exception cref="FormatException">Thrown when the SQL statement cannot be parsed.</exception>
|
||||
public static DeleteBreakdown Parse(string sql)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(sql), "SQL statement cannot be null or empty.");
|
||||
}
|
||||
|
||||
if (!TryParse(sql, out var result, out var error))
|
||||
{
|
||||
throw new FormatException($"Failed to parse DELETE statement: {error}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse a DELETE SQL statement into a DeleteBreakdown object.
|
||||
/// </summary>
|
||||
/// <param name="sql">The DELETE SQL statement to parse.</param>
|
||||
/// <param name="result">When this method returns, contains the parsed DeleteBreakdown if successful, or null if parsing failed.</param>
|
||||
/// <returns>true if the SQL was successfully parsed; otherwise, false.</returns>
|
||||
public static bool TryParse(string sql, out DeleteBreakdown result)
|
||||
=> TryParse(sql, out result, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse a DELETE SQL statement into a DeleteBreakdown object.
|
||||
/// </summary>
|
||||
/// <param name="sql">The DELETE SQL statement to parse.</param>
|
||||
/// <param name="result">When this method returns, contains the parsed DeleteBreakdown 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 static bool TryParse(string sql, out DeleteBreakdown result, out string errorMessage)
|
||||
{
|
||||
result = null!;
|
||||
errorMessage = null!;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
{
|
||||
errorMessage = "SQL statement cannot be null or empty.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var parser = new StatementParser();
|
||||
sql = parser.NormalizeSqlPreservingComments(sql);
|
||||
|
||||
// Check if it's a DELETE statement
|
||||
var sqlTrimmed = sql.TrimStart();
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(sqlTrimmed, @"^\s*DELETE\b",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase))
|
||||
{
|
||||
errorMessage = "SQL statement must start with DELETE.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract setup and finish clauses
|
||||
var setupClauses = new List<string>();
|
||||
sql = parser.ExtractSetupClauses(sql, setupClauses);
|
||||
|
||||
var finishClauses = new ArrayList();
|
||||
sql = parser.ExtractFinishClauses(sql, finishClauses);
|
||||
|
||||
// Parse DELETE statement using regex
|
||||
// Pattern: DELETE [table_alias] FROM table WHERE condition
|
||||
var deleteMatch = System.Text.RegularExpressions.Regex.Match(sql,
|
||||
@"DELETE\s+(.*?)\s+FROM\s+(.*?)(?:\s+WHERE\s+(.*))?$",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||
|
||||
if (!deleteMatch.Success)
|
||||
{
|
||||
// Try simpler pattern: DELETE FROM table WHERE condition
|
||||
deleteMatch = System.Text.RegularExpressions.Regex.Match(sql,
|
||||
@"DELETE\s+FROM\s+(.*?)(?:\s+WHERE\s+(.*))?$",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||
|
||||
if (!deleteMatch.Success)
|
||||
{
|
||||
errorMessage = "Could not parse DELETE statement. Expected format: DELETE [alias] FROM table [WHERE condition]";
|
||||
return false;
|
||||
}
|
||||
|
||||
var fromClause = deleteMatch.Groups[1].Value.Trim();
|
||||
var whereClause = deleteMatch.Groups.Count > 2 ? deleteMatch.Groups[2].Value.Trim() : string.Empty;
|
||||
|
||||
result = new DeleteBreakdown(fromClause, whereClause)
|
||||
{
|
||||
SetupClauses = setupClauses,
|
||||
FinishClauses = finishClauses
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
var deleteClause = deleteMatch.Groups[1].Value.Trim();
|
||||
var fromClause = deleteMatch.Groups[2].Value.Trim();
|
||||
var whereClause = deleteMatch.Groups.Count > 3 ? deleteMatch.Groups[3].Value.Trim() : string.Empty;
|
||||
|
||||
result = new DeleteBreakdown(fromClause, whereClause)
|
||||
{
|
||||
SetupClauses = setupClauses,
|
||||
FinishClauses = finishClauses
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(deleteClause))
|
||||
{
|
||||
var cleanDelete = parser.ExtractSqlComments(deleteClause, out var deleteComments);
|
||||
result.DeleteClause.Clause = cleanDelete.Trim();
|
||||
result.DeleteClause.Comment = deleteComments.Count > 0 ? string.Join(" ", deleteComments) : null;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Unexpected error during parsing: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Collections;
|
||||
using System.Text;
|
||||
using Strata.SqlTools.SqlBreakdown.Classes;
|
||||
using Strata.SqlTools.Statements.SqlServer;
|
||||
using Strata.SqlTools.SqlBreakdown.Utilities;
|
||||
|
||||
namespace Strata.SqlTools.Breakdowns.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an INSERT SQL statement breakdown with column and value clauses for SQL Server.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class InsertBreakdown : SqlBreakdownBase
|
||||
{
|
||||
protected readonly StatementParser Parser;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InsertBreakdown"/> class.
|
||||
/// </summary>
|
||||
public InsertBreakdown()
|
||||
{
|
||||
Parser = new StatementParser();
|
||||
TableName = new SqlClause();
|
||||
InsertIntoClause = new SqlClause();
|
||||
ValuesClause = new SqlClause();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InsertBreakdown"/> class.
|
||||
/// </summary>
|
||||
/// <param name="tableName">The table name.</param>
|
||||
/// <param name="insertIntoClause">The column list for the INSERT.</param>
|
||||
/// <param name="valuesClause">The values clause.</param>
|
||||
public InsertBreakdown(string tableName, string insertIntoClause, string valuesClause) : this()
|
||||
{
|
||||
var cleanTable = Parser.ExtractSqlComments(tableName, out var tableComments);
|
||||
TableName.Clause = cleanTable.Trim();
|
||||
TableName.Comment = tableComments.Count > 0 ? string.Join(" ", tableComments) : null;
|
||||
|
||||
var cleanInsert = Parser.ExtractSqlComments(insertIntoClause, out var insertComments);
|
||||
InsertIntoClause.Clause = cleanInsert.Trim();
|
||||
InsertIntoClause.Comment = insertComments.Count > 0 ? string.Join(" ", insertComments) : null;
|
||||
|
||||
var cleanValues = Parser.ExtractSqlComments(valuesClause, out var valuesComments);
|
||||
ValuesClause.Clause = cleanValues.Trim();
|
||||
ValuesClause.Comment = valuesComments.Count > 0 ? string.Join(" ", valuesComments) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InsertBreakdown"/> class from a list of column names.
|
||||
/// </summary>
|
||||
/// <param name="tableName">The table name.</param>
|
||||
/// <param name="columnNames">The list of column names.</param>
|
||||
public InsertBreakdown(string tableName, List<string> columnNames) : this()
|
||||
{
|
||||
TableName.Clause = tableName;
|
||||
InsertIntoClause.Clause = SqlUtils.GetSqlSafeColumnList(columnNames);
|
||||
|
||||
// Generate parameter names for values (SQL Server uses @parameter syntax)
|
||||
var valuesList = new List<string>();
|
||||
foreach (string item in columnNames)
|
||||
{
|
||||
valuesList.Add($"@{item}");
|
||||
}
|
||||
ValuesClause.Clause = string.Join(",", valuesList);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the INSERT INTO clause (column list).
|
||||
/// </summary>
|
||||
public SqlClause InsertIntoClause { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the VALUES clause.
|
||||
/// </summary>
|
||||
public SqlClause ValuesClause { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the table name.
|
||||
/// </summary>
|
||||
public SqlClause TableName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SQL breakdown as a string.
|
||||
/// </summary>
|
||||
/// <returns>The INSERT SQL statement.</returns>
|
||||
protected override string GetSqlBreakdown()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("INSERT INTO ");
|
||||
sb.Append($" {TableName.Clause} (");
|
||||
sb.Append(InsertIntoClause.Clause);
|
||||
sb.AppendLine(")");
|
||||
sb.Append(" VALUES (");
|
||||
sb.Append(ValuesClause.Clause);
|
||||
sb.AppendLine(")");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
#region Parse Methods
|
||||
|
||||
/// <summary>
|
||||
/// Parses an INSERT SQL statement into an InsertBreakdown object.
|
||||
/// </summary>
|
||||
/// <param name="sql">The INSERT SQL statement to parse.</param>
|
||||
/// <returns>An InsertBreakdown object representing the parsed statement.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when sql is null or empty.</exception>
|
||||
/// <exception cref="FormatException">Thrown when the SQL statement cannot be parsed.</exception>
|
||||
public static InsertBreakdown Parse(string sql)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(sql), "SQL statement cannot be null or empty.");
|
||||
}
|
||||
|
||||
if (!TryParse(sql, out var result, out var error))
|
||||
{
|
||||
throw new FormatException($"Failed to parse INSERT statement: {error}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse an INSERT SQL statement into an InsertBreakdown object.
|
||||
/// </summary>
|
||||
/// <param name="sql">The INSERT SQL statement to parse.</param>
|
||||
/// <param name="result">When this method returns, contains the parsed InsertBreakdown if successful, or null if parsing failed.</param>
|
||||
/// <returns>true if the SQL was successfully parsed; otherwise, false.</returns>
|
||||
public static bool TryParse(string sql, out InsertBreakdown result)
|
||||
=> TryParse(sql, out result, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse an INSERT SQL statement into an InsertBreakdown object.
|
||||
/// </summary>
|
||||
/// <param name="sql">The INSERT SQL statement to parse.</param>
|
||||
/// <param name="result">When this method returns, contains the parsed InsertBreakdown 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 static bool TryParse(string sql, out InsertBreakdown result, out string errorMessage)
|
||||
{
|
||||
result = null!;
|
||||
errorMessage = null!;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
{
|
||||
errorMessage = "SQL statement cannot be null or empty.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var parser = new StatementParser();
|
||||
sql = parser.NormalizeSqlPreservingComments(sql);
|
||||
|
||||
// Check if it's an INSERT statement
|
||||
var sqlTrimmed = sql.TrimStart();
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(sqlTrimmed, @"^\s*INSERT\s+INTO\b",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase))
|
||||
{
|
||||
errorMessage = "SQL statement must start with INSERT INTO.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract setup and finish clauses
|
||||
var setupClauses = new List<string>();
|
||||
sql = parser.ExtractSetupClauses(sql, setupClauses);
|
||||
|
||||
var finishClauses = new ArrayList();
|
||||
sql = parser.ExtractFinishClauses(sql, finishClauses);
|
||||
|
||||
// Parse INSERT statement using regex
|
||||
// Pattern: INSERT INTO table (columns) VALUES (values)
|
||||
var insertMatch = System.Text.RegularExpressions.Regex.Match(sql,
|
||||
@"INSERT\s+INTO\s+([^\(\s]+)\s*\(([^\)]*)\)\s*VALUES\s*\(([^\)]*)\)",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||
|
||||
if (!insertMatch.Success)
|
||||
{
|
||||
errorMessage = "Could not parse INSERT statement. Expected format: INSERT INTO table (columns) VALUES (values)";
|
||||
return false;
|
||||
}
|
||||
|
||||
var tableName = insertMatch.Groups[1].Value.Trim();
|
||||
var columnsClause = insertMatch.Groups[2].Value.Trim();
|
||||
var valuesClause = insertMatch.Groups[3].Value.Trim();
|
||||
|
||||
result = new InsertBreakdown(tableName, columnsClause, valuesClause)
|
||||
{
|
||||
SetupClauses = setupClauses,
|
||||
FinishClauses = finishClauses
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Unexpected error during parsing: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Collections;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Strata.SqlTools.SqlBreakdown.Classes;
|
||||
using Strata.SqlTools.Statements.SqlServer;
|
||||
|
||||
namespace Strata.SqlTools.Breakdowns.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SQL Server stored procedure call breakdown with procedure name and parameters.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class ProcedureBreakdown : SqlBreakdownBase
|
||||
{
|
||||
protected readonly StatementParser Parser;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProcedureBreakdown"/> class.
|
||||
/// </summary>
|
||||
public ProcedureBreakdown()
|
||||
{
|
||||
Parser = new StatementParser();
|
||||
ProcedureName = new SqlClause();
|
||||
Parameters = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProcedureBreakdown"/> class.
|
||||
/// </summary>
|
||||
/// <param name="procedureName">The stored procedure name.</param>
|
||||
public ProcedureBreakdown(string procedureName) : this()
|
||||
{
|
||||
var cleanName = Parser.ExtractSqlComments(procedureName, out var nameComments);
|
||||
ProcedureName.Clause = cleanName.Trim();
|
||||
ProcedureName.Comment = nameComments.Count > 0 ? string.Join(" ", nameComments) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProcedureBreakdown"/> class.
|
||||
/// </summary>
|
||||
/// <param name="procedureName">The stored procedure name.</param>
|
||||
/// <param name="parameters">The parameters dictionary (parameter name -> value expression).</param>
|
||||
public ProcedureBreakdown(string procedureName, Dictionary<string, string> parameters) : this(procedureName)
|
||||
{
|
||||
Parameters = parameters ?? new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the stored procedure name.
|
||||
/// </summary>
|
||||
public SqlClause ProcedureName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the parameters dictionary (parameter name -> value expression).
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Parameters { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether parameters are being used.
|
||||
/// </summary>
|
||||
#pragma warning disable S2325 // Methods and properties that don't access instance data should be static
|
||||
public bool IsUsingParameters => Parameters.Count > 0;
|
||||
#pragma warning restore S2325
|
||||
|
||||
/// <summary>
|
||||
/// Adds a parameter to the stored procedure call.
|
||||
/// </summary>
|
||||
/// <param name="parameterName">The parameter name (with or without @).</param>
|
||||
/// <param name="valueExpression">The value expression or literal.</param>
|
||||
#pragma warning disable S2325 // Methods and properties that don't access instance data should be static
|
||||
public void AddParameter(string parameterName, string valueExpression)
|
||||
#pragma warning restore S2325
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parameterName))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(parameterName));
|
||||
}
|
||||
|
||||
// Ensure parameter name starts with @
|
||||
if (!parameterName.StartsWith('@'))
|
||||
{
|
||||
parameterName = "@" + parameterName;
|
||||
}
|
||||
|
||||
Parameters[parameterName] = valueExpression;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SQL breakdown as a string.
|
||||
/// </summary>
|
||||
/// <returns>The EXECUTE/EXEC SQL statement.</returns>
|
||||
protected override string GetSqlBreakdown()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.Append("EXEC ");
|
||||
sb.Append(ProcedureName.Clause);
|
||||
|
||||
if (IsUsingParameters)
|
||||
{
|
||||
sb.AppendLine();
|
||||
var paramList = new List<string>();
|
||||
foreach (var param in Parameters)
|
||||
{
|
||||
paramList.Add($" {param.Key} = {param.Value}");
|
||||
}
|
||||
sb.Append(string.Join($",{Environment.NewLine}", paramList));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
#region Parse Methods
|
||||
|
||||
/// <summary>
|
||||
/// Parses an EXEC/EXECUTE SQL statement into a ProcedureBreakdown object.
|
||||
/// </summary>
|
||||
/// <param name="sql">The EXEC/EXECUTE SQL statement to parse.</param>
|
||||
/// <returns>A ProcedureBreakdown object representing the parsed statement.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when sql is null or empty.</exception>
|
||||
/// <exception cref="FormatException">Thrown when the SQL statement cannot be parsed.</exception>
|
||||
public static ProcedureBreakdown Parse(string sql)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(sql), "SQL statement cannot be null or empty.");
|
||||
}
|
||||
|
||||
if (!TryParse(sql, out var result, out var error))
|
||||
{
|
||||
throw new FormatException($"Failed to parse EXEC statement: {error}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse an EXEC/EXECUTE SQL statement into a ProcedureBreakdown object.
|
||||
/// </summary>
|
||||
/// <param name="sql">The EXEC/EXECUTE SQL statement to parse.</param>
|
||||
/// <param name="result">When this method returns, contains the parsed ProcedureBreakdown if successful, or null if parsing failed.</param>
|
||||
/// <returns>true if the SQL was successfully parsed; otherwise, false.</returns>
|
||||
public static bool TryParse(string sql, out ProcedureBreakdown result)
|
||||
=> TryParse(sql, out result, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse an EXEC/EXECUTE SQL statement into a ProcedureBreakdown object.
|
||||
/// </summary>
|
||||
/// <param name="sql">The EXEC/EXECUTE SQL statement to parse.</param>
|
||||
/// <param name="result">When this method returns, contains the parsed ProcedureBreakdown 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 static bool TryParse(string sql, out ProcedureBreakdown result, out string errorMessage)
|
||||
{
|
||||
result = null!;
|
||||
errorMessage = null!;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
{
|
||||
errorMessage = "SQL statement cannot be null or empty.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var parser = new StatementParser();
|
||||
sql = parser.NormalizeSqlPreservingComments(sql);
|
||||
|
||||
// Check if it's an EXEC or EXECUTE statement
|
||||
var sqlTrimmed = sql.TrimStart();
|
||||
if (!Regex.IsMatch(sqlTrimmed, @"^\s*(EXEC|EXECUTE)\b",
|
||||
RegexOptions.IgnoreCase))
|
||||
{
|
||||
errorMessage = "SQL statement must start with EXEC or EXECUTE.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract setup and finish clauses
|
||||
var setupClauses = new List<string>();
|
||||
sql = parser.ExtractSetupClauses(sql, setupClauses);
|
||||
|
||||
var finishClauses = new ArrayList();
|
||||
sql = parser.ExtractFinishClauses(sql, finishClauses);
|
||||
|
||||
// Parse EXEC statement - match procedure name and parameters
|
||||
// Pattern: EXEC[UTE] procedureName [@param = value, ...]
|
||||
var execMatch = Regex.Match(sql,
|
||||
@"(?:EXEC|EXECUTE)\s+([^\s@,]+)(?:\s+(.*))?$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
if (!execMatch.Success)
|
||||
{
|
||||
errorMessage = "Could not parse EXEC statement. Expected format: EXEC procedureName [@param = value, ...]";
|
||||
return false;
|
||||
}
|
||||
|
||||
var procedureName = execMatch.Groups[1].Value.Trim();
|
||||
var parametersText = execMatch.Groups.Count > 2 ? execMatch.Groups[2].Value.Trim() : string.Empty;
|
||||
|
||||
var parameters = new Dictionary<string, string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(parametersText))
|
||||
{
|
||||
// Parse parameters - handle both @param = value and positional parameters
|
||||
var paramMatches = Regex.Matches(parametersText,
|
||||
@"(@\w+)\s*=\s*([^,]+)(?:,|$)",
|
||||
RegexOptions.IgnoreCase);
|
||||
|
||||
parameters = paramMatches
|
||||
.Cast<System.Text.RegularExpressions.Match>()
|
||||
.ToDictionary(
|
||||
paramMatch => paramMatch.Groups[1].Value.Trim(),
|
||||
paramMatch => paramMatch.Groups[2].Value.Trim()
|
||||
);
|
||||
}
|
||||
|
||||
result = new ProcedureBreakdown(procedureName, parameters)
|
||||
{
|
||||
SetupClauses = setupClauses,
|
||||
FinishClauses = finishClauses
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Unexpected error during parsing: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,698 @@
|
||||
using System.Text;
|
||||
using Strata.SqlTools.SqlBreakdown.Classes;
|
||||
using Strata.SqlTools.SqlBreakdown.Interfaces;
|
||||
|
||||
namespace Strata.SqlTools.Breakdowns.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// SQL Server-specific collection for managing multiple QueryBreakdown objects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This class extends SqlBreakdownCollection with SQL Server-specific functionality,
|
||||
/// including support for T-SQL features like batches (GO), temporary tables, stored procedures, and CTEs.
|
||||
/// </remarks>
|
||||
[Serializable]
|
||||
public class QueryBreakdownCollection : SqlBreakdownCollection
|
||||
{
|
||||
private readonly List<QueryBreakdown> _queryBreakdowns;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="QueryBreakdownCollection"/> class.
|
||||
/// </summary>
|
||||
public QueryBreakdownCollection() : base()
|
||||
{
|
||||
_queryBreakdowns = new List<QueryBreakdown>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="QueryBreakdownCollection"/> class with initial query breakdowns.
|
||||
/// </summary>
|
||||
/// <param name="queryBreakdowns">The initial collection of query breakdowns.</param>
|
||||
public QueryBreakdownCollection(IEnumerable<QueryBreakdown> queryBreakdowns) : base(queryBreakdowns?.Cast<ISqlBreakdown>() ?? Enumerable.Empty<ISqlBreakdown>())
|
||||
{
|
||||
_queryBreakdowns = new List<QueryBreakdown>(queryBreakdowns ?? Enumerable.Empty<QueryBreakdown>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of QueryBreakdown objects.
|
||||
/// </summary>
|
||||
public IReadOnlyList<QueryBreakdown> QueryBreakdowns => _queryBreakdowns.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// Adds a QueryBreakdown to the collection.
|
||||
/// </summary>
|
||||
/// <param name="queryBreakdown">The query breakdown to add.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when queryBreakdown is null.</exception>
|
||||
public void Add(QueryBreakdown queryBreakdown)
|
||||
{
|
||||
if (queryBreakdown == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(queryBreakdown));
|
||||
}
|
||||
|
||||
_queryBreakdowns.Add(queryBreakdown);
|
||||
base.Add(queryBreakdown);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds multiple QueryBreakdowns to the collection.
|
||||
/// </summary>
|
||||
/// <param name="queryBreakdowns">The query breakdowns to add.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when queryBreakdowns is null.</exception>
|
||||
public void AddRange(IEnumerable<QueryBreakdown> queryBreakdowns)
|
||||
{
|
||||
if (queryBreakdowns == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(queryBreakdowns));
|
||||
}
|
||||
|
||||
foreach (var breakdown in queryBreakdowns)
|
||||
{
|
||||
Add(breakdown);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a QueryBreakdown from the collection.
|
||||
/// </summary>
|
||||
/// <param name="queryBreakdown">The query breakdown to remove.</param>
|
||||
/// <returns>True if removed; otherwise, false.</returns>
|
||||
public bool Remove(QueryBreakdown queryBreakdown)
|
||||
{
|
||||
var removed = _queryBreakdowns.Remove(queryBreakdown);
|
||||
if (removed)
|
||||
{
|
||||
base.Remove(queryBreakdown);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all query breakdowns from the collection.
|
||||
/// </summary>
|
||||
public new void Clear()
|
||||
{
|
||||
_queryBreakdowns.Clear();
|
||||
base.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SQL Server T-SQL batch representation with proper batch handling.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Generates T-SQL with proper GO separators and optional transaction support.
|
||||
/// </remarks>
|
||||
/// <param name="includeSetupFinish">Whether to include setup and finish clauses.</param>
|
||||
/// <param name="includeTransaction">Whether to wrap in BEGIN TRANSACTION / COMMIT.</param>
|
||||
/// <returns>The formatted T-SQL batch.</returns>
|
||||
public string GetSqlServerBatch(bool includeSetupFinish = true, bool includeTransaction = false)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Add transaction wrapper if requested
|
||||
if (includeTransaction)
|
||||
{
|
||||
sb.AppendLine("BEGIN TRANSACTION;");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Add all queries with GO separators
|
||||
if (_queryBreakdowns.Count > 0)
|
||||
{
|
||||
for (int i = 0; i < _queryBreakdowns.Count; i++)
|
||||
{
|
||||
var query = _queryBreakdowns[i];
|
||||
sb.Append(query.GetSql(includeSetupFinish));
|
||||
|
||||
// Add GO separator between queries (not after last)
|
||||
if (i < _queryBreakdowns.Count - 1)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("GO");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close transaction if opened
|
||||
if (includeTransaction)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("COMMIT TRANSACTION;");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters query breakdowns where the SELECT clause contains specific text.
|
||||
/// </summary>
|
||||
/// <param name="selectContains">The text to find in the SELECT clause.</param>
|
||||
/// <returns>Filtered query breakdowns.</returns>
|
||||
public IEnumerable<QueryBreakdown> WhereSelectContains(string selectContains)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selectContains))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(selectContains));
|
||||
}
|
||||
|
||||
return _queryBreakdowns.Where(q =>
|
||||
q.SelectClause?.Clause?.Contains(selectContains, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters query breakdowns where the FROM clause contains specific text.
|
||||
/// </summary>
|
||||
/// <param name="tableNameContains">The table name or pattern to find.</param>
|
||||
/// <returns>Filtered query breakdowns.</returns>
|
||||
public IEnumerable<QueryBreakdown> WhereTableContains(string tableNameContains)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tableNameContains))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(tableNameContains));
|
||||
}
|
||||
|
||||
return _queryBreakdowns.Where(q =>
|
||||
q.FromClause?.Clause?.Contains(tableNameContains, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters query breakdowns that have a WHERE clause.
|
||||
/// </summary>
|
||||
/// <returns>Query breakdowns with WHERE clauses.</returns>
|
||||
public IEnumerable<QueryBreakdown> WhereHaveWhereClause()
|
||||
{
|
||||
return _queryBreakdowns.Where(q =>
|
||||
!string.IsNullOrWhiteSpace(q.WhereClause?.Clause));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters query breakdowns that do NOT have a WHERE clause.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is useful for identifying potentially risky queries that affect all rows.
|
||||
/// </remarks>
|
||||
/// <returns>Query breakdowns without WHERE clauses.</returns>
|
||||
public IEnumerable<QueryBreakdown> WhereHaveNoWhereClause()
|
||||
{
|
||||
return _queryBreakdowns.Where(q =>
|
||||
string.IsNullOrWhiteSpace(q.WhereClause?.Clause));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters query breakdowns that have a GROUP BY clause.
|
||||
/// </summary>
|
||||
/// <returns>Query breakdowns with GROUP BY clauses.</returns>
|
||||
public IEnumerable<QueryBreakdown> WhereHaveGroupByClause()
|
||||
{
|
||||
return _queryBreakdowns.Where(q =>
|
||||
!string.IsNullOrWhiteSpace(q.GroupByClause?.Clause));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters query breakdowns that have an ORDER BY clause.
|
||||
/// </summary>
|
||||
/// <returns>Query breakdowns with ORDER BY clauses.</returns>
|
||||
public IEnumerable<QueryBreakdown> WhereHaveOrderByClause()
|
||||
{
|
||||
return _queryBreakdowns.Where(q =>
|
||||
!string.IsNullOrWhiteSpace(q.OrderByClause?.Clause));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters query breakdowns that have WITH clauses (CTEs).
|
||||
/// </summary>
|
||||
/// <returns>Query breakdowns with CTE definitions.</returns>
|
||||
public IEnumerable<QueryBreakdown> WhereHaveCommonTableExpressions()
|
||||
{
|
||||
return _queryBreakdowns.Where(q => q.WithClauses.Count > 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters query breakdowns that reference JOIN operations.
|
||||
/// </summary>
|
||||
/// <returns>Query breakdowns with JOINs.</returns>
|
||||
public IEnumerable<QueryBreakdown> WhereHaveJoins()
|
||||
{
|
||||
return _queryBreakdowns.Where(q => q.GetSql().Contains("JOIN", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters query breakdowns by parameter usage.
|
||||
/// </summary>
|
||||
/// <param name="parameterName">The parameter name to search for.</param>
|
||||
/// <returns>Query breakdowns using the specified parameter.</returns>
|
||||
public IEnumerable<QueryBreakdown> WhereUseParameter(string parameterName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parameterName))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(parameterName));
|
||||
}
|
||||
|
||||
return _queryBreakdowns.Where(q =>
|
||||
q.ParameterList.Any(p => p.Name == parameterName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of columns selected across all queries.
|
||||
/// </summary>
|
||||
/// <returns>Total column count.</returns>
|
||||
public int GetTotalSelectedColumns()
|
||||
{
|
||||
return _queryBreakdowns.Sum(q =>
|
||||
!string.IsNullOrWhiteSpace(q.SelectClause?.Clause)
|
||||
? q.SelectClause.Clause.Split(',').Length
|
||||
: 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unique table names referenced across all queries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This provides a quick overview of which tables are being queried.
|
||||
/// Note: This is a best-effort extraction and may not capture all table references,
|
||||
/// especially in complex subqueries or with aliasing.
|
||||
/// </remarks>
|
||||
/// <returns>List of unique table names.</returns>
|
||||
public IEnumerable<string> GetUniqueTableReferences()
|
||||
{
|
||||
var tables = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var tableNames = _queryBreakdowns
|
||||
.Where(q => !string.IsNullOrWhiteSpace(q.FromClause?.Clause))
|
||||
.SelectMany(q => ExtractTableNames(q.FromClause!.Clause!));
|
||||
|
||||
foreach (var table in tableNames)
|
||||
{
|
||||
tables.Add(table);
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a summary of all queries including their types and basic composition.
|
||||
/// </summary>
|
||||
/// <returns>Summary information for each query.</returns>
|
||||
public IEnumerable<QuerySummary> GetQuerySummaries()
|
||||
{
|
||||
return _queryBreakdowns.Select((q, index) => new QuerySummary
|
||||
{
|
||||
Index = index,
|
||||
HasSelectClause = !string.IsNullOrWhiteSpace(q.SelectClause?.Clause),
|
||||
HasFromClause = !string.IsNullOrWhiteSpace(q.FromClause?.Clause),
|
||||
HasWhereClause = !string.IsNullOrWhiteSpace(q.WhereClause?.Clause),
|
||||
HasGroupByClause = !string.IsNullOrWhiteSpace(q.GroupByClause?.Clause),
|
||||
HasHavingClause = !string.IsNullOrWhiteSpace(q.HavingClause?.Clause),
|
||||
HasOrderByClause = !string.IsNullOrWhiteSpace(q.OrderByClause?.Clause),
|
||||
HasJoins = false,
|
||||
HasCTE = q.WithClauses.Count > 0,
|
||||
ColumnCount = !string.IsNullOrWhiteSpace(q.SelectClause?.Clause) ? q.SelectClause.Clause.Split(',').Length : 0,
|
||||
ParameterCount = q.ParameterList.Count(),
|
||||
JoinCount = 0
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to extract table names from a FROM clause.
|
||||
/// </summary>
|
||||
private static IEnumerable<string> ExtractTableNames(string fromClause)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fromClause))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Simple extraction: split by comma and clean up aliases
|
||||
var parts = fromClause.Split(',');
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var trimmed = part.Trim();
|
||||
|
||||
// Remove alias (assuming format: table AS alias or table alias)
|
||||
var tokens = trimmed.Split(new[] { " AS ", " " }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (tokens.Length > 0)
|
||||
{
|
||||
var tableName = tokens[0].Trim();
|
||||
if (!string.IsNullOrWhiteSpace(tableName))
|
||||
{
|
||||
yield return tableName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronizes parameters across all queries in the collection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This ensures all queries share the same parameter values based on parameter name.
|
||||
/// Later parameter values override earlier ones if there are conflicts.
|
||||
/// Only synchronizes parameters that the query already defines to avoid adding unused parameters.
|
||||
/// </remarks>
|
||||
public void SynchronizeParameters()
|
||||
{
|
||||
// Get all unique parameter names across all queries
|
||||
var allParameterNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var query in _queryBreakdowns)
|
||||
{
|
||||
foreach (var paramName in query.Parameters.Keys)
|
||||
{
|
||||
allParameterNames.Add(paramName);
|
||||
}
|
||||
}
|
||||
|
||||
// For each parameter, use the last query's value and sync to all queries that have it
|
||||
foreach (var paramName in allParameterNames)
|
||||
{
|
||||
object? lastValue = null;
|
||||
bool parameterFound = false;
|
||||
|
||||
// Find the last query that has this parameter and get its value
|
||||
for (int i = _queryBreakdowns.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (_queryBreakdowns[i].Parameters.ContainsKey(paramName))
|
||||
{
|
||||
lastValue = _queryBreakdowns[i].Parameters[paramName];
|
||||
parameterFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronize the parameter value to all queries that have it
|
||||
if (parameterFound)
|
||||
{
|
||||
foreach (var query in _queryBreakdowns.Where(q => q.Parameters.ContainsKey(paramName)))
|
||||
{
|
||||
query.Parameters[paramName] = lastValue!;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a parameter to all queries in the collection.
|
||||
/// </summary>
|
||||
/// <param name="parameterName">The parameter name.</param>
|
||||
/// <param name="value">The parameter value.</param>
|
||||
public void AddParameterToAll(string parameterName, object? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parameterName))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(parameterName));
|
||||
}
|
||||
|
||||
foreach (var query in _queryBreakdowns)
|
||||
{
|
||||
query.Parameters[parameterName] = value!;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unique parameters from all queries in the collection as a combined dictionary.
|
||||
/// </summary>
|
||||
/// <returns>A dictionary containing all unique parameters across all queries.</returns>
|
||||
protected Dictionary<string, object> GetCombinedParameterDictionary()
|
||||
{
|
||||
var combinedParams = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var query in _queryBreakdowns)
|
||||
{
|
||||
// Add from ParameterList (parsed parameters)
|
||||
foreach (var param in query.ParameterList)
|
||||
{
|
||||
combinedParams[param.Name] = param.Value;
|
||||
}
|
||||
|
||||
// Add/override from Parameters dictionary (manually added parameters)
|
||||
foreach (var param in query.Parameters)
|
||||
{
|
||||
combinedParams[param.Key] = param.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return combinedParams;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unique parameters from all queries in the collection.
|
||||
/// </summary>
|
||||
/// <returns>A collection of unique QueryParam objects.</returns>
|
||||
|
||||
/// <summary>
|
||||
/// Gets all T-SQL parameters as a formatted string suitable for SQL Server.
|
||||
/// </summary>
|
||||
/// <param name="includeDataTypes">Whether to include estimated data types (uses generic approach).</param>
|
||||
/// <returns>A formatted string of parameters.</returns>
|
||||
public string GetParametersAsString(bool includeDataTypes = false)
|
||||
{
|
||||
var parameters = GetCombinedParameterDictionary();
|
||||
if (parameters.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
bool first = true;
|
||||
|
||||
foreach (var param in parameters)
|
||||
{
|
||||
if (!first)
|
||||
{
|
||||
sb.Append(", ");
|
||||
}
|
||||
|
||||
sb.Append($"@{param.Key}");
|
||||
|
||||
if (includeDataTypes)
|
||||
{
|
||||
var dataType = GetSqlDataType(param.Value);
|
||||
sb.Append($" {dataType}");
|
||||
}
|
||||
|
||||
sb.Append($" = {FormatParameterValue(param.Value)}");
|
||||
first = false;
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a report of parameter usage across all queries.
|
||||
/// </summary>
|
||||
/// <returns>Parameter usage information.</returns>
|
||||
public IEnumerable<ParameterUsageReport> GetParameterUsageReport()
|
||||
{
|
||||
// Collect all unique parameter names from both ParameterList and Parameters dictionary
|
||||
var allParamNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var query in _queryBreakdowns)
|
||||
{
|
||||
// Add from ParameterList (parsed parameters)
|
||||
foreach (var param in query.ParameterList)
|
||||
{
|
||||
allParamNames.Add(param.Name);
|
||||
}
|
||||
|
||||
// Add from Parameters dictionary (manually added parameters)
|
||||
foreach (var paramName in query.Parameters.Keys)
|
||||
{
|
||||
allParamNames.Add(paramName);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var paramName in allParamNames)
|
||||
{
|
||||
var queriesUsing = 0;
|
||||
object? lastValue = null;
|
||||
|
||||
foreach (var query in _queryBreakdowns)
|
||||
{
|
||||
// Check ParameterList first (parsed)
|
||||
var param = query.ParameterList.FirstOrDefault(p => p.Name.Equals(paramName, StringComparison.OrdinalIgnoreCase));
|
||||
if (param != null)
|
||||
{
|
||||
queriesUsing++;
|
||||
lastValue = param.Value;
|
||||
}
|
||||
// Also check Parameters dictionary (manually added)
|
||||
else if (query.Parameters.TryGetValue(paramName, out var dictValue))
|
||||
{
|
||||
queriesUsing++;
|
||||
lastValue = dictValue;
|
||||
}
|
||||
}
|
||||
|
||||
yield return new ParameterUsageReport
|
||||
{
|
||||
ParameterName = paramName,
|
||||
Value = lastValue,
|
||||
UsedInQueryCount = queriesUsing,
|
||||
TotalQueries = _queryBreakdowns.Count
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to get SQL Server data type from a .NET object.
|
||||
/// </summary>
|
||||
private static string GetSqlDataType(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => "SQL_VARIANT",
|
||||
bool => "BIT",
|
||||
byte => "TINYINT",
|
||||
short => "SMALLINT",
|
||||
int => "INT",
|
||||
long => "BIGINT",
|
||||
float => "REAL",
|
||||
double => "FLOAT",
|
||||
decimal => "DECIMAL(18, 2)",
|
||||
string => "NVARCHAR(MAX)",
|
||||
DateTime => "DATETIME2",
|
||||
_ => "SQL_VARIANT"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to format a parameter value for SQL output.
|
||||
/// </summary>
|
||||
private static string FormatParameterValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => "NULL",
|
||||
bool b => b ? "1" : "0",
|
||||
string s => $"'{s.Replace("'", "''")}'",
|
||||
DateTime dt => $"'{dt:yyyy-MM-dd HH:mm:ss}'",
|
||||
byte or short or int or long or float or double or decimal => value.ToString() ?? "NULL",
|
||||
_ => throw new ArgumentException($"Unsupported parameter type: {value.GetType().Name}. Only primitive types, strings, and DateTime are supported.")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary information about a query for quick analysis.
|
||||
/// </summary>
|
||||
public class QuerySummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the index of the query in the collection.
|
||||
/// </summary>
|
||||
public int Index { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the query has a SELECT clause.
|
||||
/// </summary>
|
||||
public bool HasSelectClause { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the query has a FROM clause.
|
||||
/// </summary>
|
||||
public bool HasFromClause { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the query has a WHERE clause.
|
||||
/// </summary>
|
||||
public bool HasWhereClause { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the query has a GROUP BY clause.
|
||||
/// </summary>
|
||||
public bool HasGroupByClause { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the query has a HAVING clause.
|
||||
/// </summary>
|
||||
public bool HasHavingClause { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the query has an ORDER BY clause.
|
||||
/// </summary>
|
||||
public bool HasOrderByClause { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the query has JOIN clauses.
|
||||
/// </summary>
|
||||
public bool HasJoins { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the query has Common Table Expressions (CTEs).
|
||||
/// </summary>
|
||||
public bool HasCTE { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of columns in the SELECT clause.
|
||||
/// </summary>
|
||||
public int ColumnCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of parameters used.
|
||||
/// </summary>
|
||||
public int ParameterCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of JOIN clauses.
|
||||
/// </summary>
|
||||
public int JoinCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns a string representation of the query summary.
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Query #{Index}");
|
||||
sb.AppendLine($" SELECT: {(HasSelectClause ? "Yes" : "No")} ({ColumnCount} columns)");
|
||||
sb.AppendLine($" FROM: {(HasFromClause ? "Yes" : "No")}");
|
||||
sb.AppendLine($" WHERE: {(HasWhereClause ? "Yes" : "No")}");
|
||||
sb.AppendLine($" GROUP BY: {(HasGroupByClause ? "Yes" : "No")}");
|
||||
sb.AppendLine($" HAVING: {(HasHavingClause ? "Yes" : "No")}");
|
||||
sb.AppendLine($" ORDER BY: {(HasOrderByClause ? "Yes" : "No")}");
|
||||
sb.AppendLine($" JOINs: {(HasJoins ? "Yes" : "No")} ({JoinCount} joins)");
|
||||
sb.AppendLine($" CTEs: {(HasCTE ? "Yes" : "No")}");
|
||||
sb.Append($" Parameters: {ParameterCount}");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Report of parameter usage across queries in a collection.
|
||||
/// </summary>
|
||||
public class ParameterUsageReport
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the parameter name.
|
||||
/// </summary>
|
||||
public string ParameterName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the parameter value.
|
||||
/// </summary>
|
||||
public object? Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of queries using this parameter.
|
||||
/// </summary>
|
||||
public int UsedInQueryCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of queries in the collection.
|
||||
/// </summary>
|
||||
public int TotalQueries { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the parameter is used in all queries.
|
||||
/// </summary>
|
||||
public bool IsUsedInAllQueries => UsedInQueryCount == TotalQueries;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a string representation of the parameter usage report.
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
var usagePercentage = TotalQueries > 0 ? (UsedInQueryCount / (decimal)TotalQueries * 100) : 0;
|
||||
return $"@{ParameterName}: {UsedInQueryCount}/{TotalQueries} queries ({usagePercentage:F1}%) - Value: {Value?.ToString() ?? "NULL"}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Data;
|
||||
using System.Data.SqlClient;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Strata.SqlTools.Breakdowns.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// A trace listener that writes trace messages to a SQL Server database.
|
||||
/// </summary>
|
||||
public class TraceListener : System.Diagnostics.TraceListener
|
||||
{
|
||||
private readonly string _serverName;
|
||||
private readonly string _traceDbConnectionString;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TraceListener"/> class.
|
||||
/// </summary>
|
||||
/// <param name="serverName">The server name for logging.</param>
|
||||
/// <param name="traceDbConnectionString">The connection string to the trace database.</param>
|
||||
public TraceListener(string serverName, string traceDbConnectionString)
|
||||
{
|
||||
_serverName = serverName;
|
||||
_traceDbConnectionString = traceDbConnectionString;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a message to the trace database.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to write.</param>
|
||||
public override void Write(string? message)
|
||||
{
|
||||
WriteTrace(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a message followed by a line terminator to the trace database.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to write.</param>
|
||||
public override void WriteLine(string? message)
|
||||
{
|
||||
WriteTrace(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a trace message to the database.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to write.</param>
|
||||
private void WriteTrace(string? message)
|
||||
{
|
||||
using var sqlConnection = new SqlConnection();
|
||||
|
||||
try
|
||||
{
|
||||
sqlConnection.ConnectionString = _traceDbConnectionString;
|
||||
sqlConnection.Open();
|
||||
|
||||
using var command = sqlConnection.CreateCommand();
|
||||
command.CommandType = CommandType.Text;
|
||||
command.CommandText = "INSERT INTO Trace ([SERVER], [MESSAGE]) VALUES(@SERVER, @MESSAGE)";
|
||||
command.Parameters.Add(new SqlParameter("@SERVER", _serverName));
|
||||
command.Parameters.Add(new SqlParameter("@MESSAGE", message));
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
catch (SqlException)
|
||||
{
|
||||
if (Debugger.IsAttached)
|
||||
{
|
||||
Debugger.Break();
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
if (Debugger.IsAttached)
|
||||
{
|
||||
Debugger.Break();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Collections;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Strata.SqlTools.SqlBreakdown.Classes;
|
||||
using Strata.SqlTools.Statements.SqlServer;
|
||||
|
||||
namespace Strata.SqlTools.Breakdowns.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an UPDATE SQL statement breakdown with SET, FROM, and WHERE clauses for SQL Server.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class UpdateBreakdown : SqlBreakdownBase
|
||||
{
|
||||
protected readonly StatementParser Parser;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UpdateBreakdown"/> class.
|
||||
/// </summary>
|
||||
public UpdateBreakdown()
|
||||
{
|
||||
Parser = new StatementParser();
|
||||
TableName = new SqlClause();
|
||||
SetClause = new SqlClause();
|
||||
FromClause = new SqlClause();
|
||||
WhereClause = new SqlClause();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UpdateBreakdown"/> class.
|
||||
/// </summary>
|
||||
/// <param name="tableName">The table name.</param>
|
||||
/// <param name="setClause">The SET clause.</param>
|
||||
/// <param name="whereClause">The WHERE clause.</param>
|
||||
public UpdateBreakdown(string tableName, string setClause, string whereClause) : this()
|
||||
{
|
||||
var cleanTable = Parser.ExtractSqlComments(tableName, out var tableComments);
|
||||
TableName.Clause = cleanTable.Trim();
|
||||
TableName.Comment = tableComments.Count > 0 ? string.Join(" ", tableComments) : null;
|
||||
|
||||
var cleanSet = Parser.ExtractSqlComments(setClause, out var setComments);
|
||||
SetClause.Clause = cleanSet.Trim();
|
||||
SetClause.Comment = setComments.Count > 0 ? string.Join(" ", setComments) : null;
|
||||
|
||||
var cleanWhere = Parser.ExtractSqlComments(whereClause, out var whereComments);
|
||||
WhereClause.Clause = cleanWhere.Trim();
|
||||
WhereClause.Comment = whereComments.Count > 0 ? string.Join(" ", whereComments) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SET clause.
|
||||
/// </summary>
|
||||
public SqlClause SetClause { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the table name.
|
||||
/// </summary>
|
||||
public SqlClause TableName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether a FROM clause is being used.
|
||||
/// </summary>
|
||||
public bool IsUsingFromClause => !string.IsNullOrWhiteSpace(FromClause.Clause);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the FROM clause (for UPDATE with JOIN).
|
||||
/// </summary>
|
||||
public SqlClause FromClause { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether a WHERE clause is being used.
|
||||
/// </summary>
|
||||
public bool IsUsingWhereClause => !string.IsNullOrWhiteSpace(WhereClause.Clause);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the WHERE clause.
|
||||
/// </summary>
|
||||
public SqlClause WhereClause { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SQL breakdown as a string.
|
||||
/// </summary>
|
||||
/// <returns>The UPDATE SQL statement.</returns>
|
||||
protected override string GetSqlBreakdown()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("UPDATE ");
|
||||
sb.AppendLine($" {TableName.Clause}");
|
||||
|
||||
sb.AppendLine("SET ");
|
||||
sb.AppendLine($" {SetClause.Clause}");
|
||||
|
||||
if (IsUsingFromClause)
|
||||
{
|
||||
sb.AppendLine("FROM ");
|
||||
sb.AppendLine($" {FromClause.Clause}");
|
||||
}
|
||||
|
||||
if (IsUsingWhereClause)
|
||||
{
|
||||
sb.AppendLine("WHERE ");
|
||||
sb.AppendLine($" {WhereClause.Clause}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
#region Parse Methods
|
||||
|
||||
/// <summary>
|
||||
/// Parses an UPDATE SQL statement into an UpdateBreakdown object.
|
||||
/// </summary>
|
||||
/// <param name="sql">The UPDATE SQL statement to parse.</param>
|
||||
/// <returns>An UpdateBreakdown object representing the parsed statement.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when sql is null or empty.</exception>
|
||||
/// <exception cref="FormatException">Thrown when the SQL statement cannot be parsed.</exception>
|
||||
public static UpdateBreakdown Parse(string sql)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(sql), "SQL statement cannot be null or empty.");
|
||||
}
|
||||
|
||||
if (!TryParse(sql, out var result, out var error))
|
||||
{
|
||||
throw new FormatException($"Failed to parse UPDATE statement: {error}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse an UPDATE SQL statement into an UpdateBreakdown object.
|
||||
/// </summary>
|
||||
/// <param name="sql">The UPDATE SQL statement to parse.</param>
|
||||
/// <param name="result">When this method returns, contains the parsed UpdateBreakdown if successful, or null if parsing failed.</param>
|
||||
/// <returns>true if the SQL was successfully parsed; otherwise, false.</returns>
|
||||
public static bool TryParse(string sql, out UpdateBreakdown result)
|
||||
=> TryParse(sql, out result, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse an UPDATE SQL statement into an UpdateBreakdown object.
|
||||
/// </summary>
|
||||
/// <param name="sql">The UPDATE SQL statement to parse.</param>
|
||||
/// <param name="result">When this method returns, contains the parsed UpdateBreakdown 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 static bool TryParse(string sql, out UpdateBreakdown result, out string errorMessage)
|
||||
{
|
||||
result = null!;
|
||||
errorMessage = null!;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
{
|
||||
errorMessage = "SQL statement cannot be null or empty.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var parser = new StatementParser();
|
||||
sql = parser.NormalizeSqlPreservingComments(sql);
|
||||
|
||||
// Check if it's an UPDATE statement
|
||||
var sqlTrimmed = sql.TrimStart();
|
||||
if (!Regex.IsMatch(sqlTrimmed, @"^\s*UPDATE\b",
|
||||
RegexOptions.IgnoreCase))
|
||||
{
|
||||
errorMessage = "SQL statement must start with UPDATE.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract setup and finish clauses
|
||||
var setupClauses = new List<string>();
|
||||
sql = parser.ExtractSetupClauses(sql, setupClauses);
|
||||
|
||||
var finishClauses = new ArrayList();
|
||||
sql = parser.ExtractFinishClauses(sql, finishClauses);
|
||||
|
||||
// Parse UPDATE statement - handle both with and without FROM clause
|
||||
// Pattern: UPDATE table SET column=value [FROM table] [WHERE condition]
|
||||
var updateMatch = Regex.Match(sql,
|
||||
@"UPDATE\s+([^\s]+)\s+SET\s+(.*?)(?:\s+FROM\s+(.*?))?(?:\s+WHERE\s+(.*))?$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
if (!updateMatch.Success)
|
||||
{
|
||||
errorMessage = "Could not parse UPDATE statement. Expected format: UPDATE table SET column=value [FROM table] [WHERE condition]";
|
||||
return false;
|
||||
}
|
||||
|
||||
var tableName = updateMatch.Groups[1].Value.Trim();
|
||||
var setClause = updateMatch.Groups[2].Value.Trim();
|
||||
var fromClause = updateMatch.Groups.Count > 3 ? updateMatch.Groups[3].Value.Trim() : string.Empty;
|
||||
var whereClause = updateMatch.Groups.Count > 4 ? updateMatch.Groups[4].Value.Trim() : string.Empty;
|
||||
|
||||
result = new UpdateBreakdown(tableName, setClause, whereClause)
|
||||
{
|
||||
SetupClauses = setupClauses,
|
||||
FinishClauses = finishClauses
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fromClause))
|
||||
{
|
||||
var cleanFrom = parser.ExtractSqlComments(fromClause, out var fromComments);
|
||||
result.FromClause.Clause = cleanFrom.Trim();
|
||||
result.FromClause.Comment = fromComments.Count > 0 ? string.Join(" ", fromComments) : null;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Unexpected error during parsing: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
namespace Strata.SqlTools.SqlServer.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when Common Table Expression (CTE) validation fails.
|
||||
/// Provides context about what CTE validation rule was violated.
|
||||
/// </summary>
|
||||
public class CteValidationException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the name of the CTE that failed validation.
|
||||
/// </summary>
|
||||
public string? CteName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the validation rule that was violated.
|
||||
/// </summary>
|
||||
public string ValidationRule { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CteValidationException"/> class.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message describing the validation failure.</param>
|
||||
/// <param name="cteName">The name of the CTE that failed validation (optional).</param>
|
||||
/// <param name="validationRule">The validation rule that was violated.</param>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// throw new CteValidationException(
|
||||
/// "CTE table name cannot be empty",
|
||||
/// null,
|
||||
/// "TableNameRequired"
|
||||
/// );
|
||||
/// </code>
|
||||
/// </example>
|
||||
public CteValidationException(string message, string? cteName, string validationRule)
|
||||
: base(FormatMessage(message, cteName, validationRule))
|
||||
{
|
||||
CteName = cteName;
|
||||
ValidationRule = validationRule ?? "UnknownRule";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CteValidationException"/> class with an inner exception.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message describing the validation failure.</param>
|
||||
/// <param name="cteName">The name of the CTE that failed validation (optional).</param>
|
||||
/// <param name="validationRule">The validation rule that was violated.</param>
|
||||
/// <param name="innerException">The exception that caused this validation failure.</param>
|
||||
public CteValidationException(string message, string? cteName, string validationRule, Exception innerException)
|
||||
: base(FormatMessage(message, cteName, validationRule), innerException)
|
||||
{
|
||||
CteName = cteName;
|
||||
ValidationRule = validationRule ?? "UnknownRule";
|
||||
}
|
||||
|
||||
private static string FormatMessage(string message, string? cteName, string validationRule)
|
||||
{
|
||||
var formatted = message;
|
||||
|
||||
if (!string.IsNullOrEmpty(cteName))
|
||||
{
|
||||
formatted = $"{message} (CTE: '{cteName}')";
|
||||
}
|
||||
|
||||
formatted += $"\nValidation Rule: {validationRule}";
|
||||
|
||||
// Add helpful hints based on common validation rules
|
||||
formatted += GetValidationHint(validationRule);
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
private static string GetValidationHint(string validationRule)
|
||||
{
|
||||
return validationRule switch
|
||||
{
|
||||
"TableNameRequired" => "\nHint: Every CTE must have a non-empty table name.",
|
||||
"QueryRequired" => "\nHint: CTE must have either a Query or Sql property set.",
|
||||
"DuplicateCteName" => "\nHint: Each CTE name must be unique within a query.",
|
||||
"CircularReference" => "\nHint: CTEs cannot reference themselves (except in recursive CTEs).",
|
||||
"ColumnCountMismatch" => "\nHint: CTE column list count must match SELECT column count.",
|
||||
"RecursiveWithoutFlag" => "\nHint: Set IsRecursive = true for recursive CTEs.",
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Strata.SqlTools.SqlServer.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when SQL parsing fails.
|
||||
/// Provides detailed context about the parse failure including position and surrounding text.
|
||||
/// </summary>
|
||||
public class SqlParseException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the position in the SQL string where the parse error occurred.
|
||||
/// </summary>
|
||||
public int Position { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SQL statement that failed to parse.
|
||||
/// </summary>
|
||||
public string Sql { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the text near the error position (up to 40 characters).
|
||||
/// </summary>
|
||||
public string NearText { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SqlParseException"/> class.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message describing the parse failure.</param>
|
||||
/// <param name="sql">The SQL statement that failed to parse.</param>
|
||||
/// <param name="position">The position in the SQL where the error occurred.</param>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// throw new SqlParseException(
|
||||
/// "Unexpected token 'FROM'",
|
||||
/// "SELECT * FRM users",
|
||||
/// 10
|
||||
/// );
|
||||
/// // Message will include:
|
||||
/// // - Error description
|
||||
/// // - Position: 10
|
||||
/// // - Near: "* FRM users"
|
||||
/// // - Full SQL statement
|
||||
/// </code>
|
||||
/// </example>
|
||||
public SqlParseException(string message, string sql, int position)
|
||||
: base(FormatMessage(message, sql, position))
|
||||
{
|
||||
Position = position;
|
||||
Sql = sql ?? string.Empty;
|
||||
NearText = ExtractNearText(sql, position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SqlParseException"/> class with an inner exception.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message describing the parse failure.</param>
|
||||
/// <param name="sql">The SQL statement that failed to parse.</param>
|
||||
/// <param name="position">The position in the SQL where the error occurred.</param>
|
||||
/// <param name="innerException">The exception that caused this parse failure.</param>
|
||||
public SqlParseException(string message, string sql, int position, Exception innerException)
|
||||
: base(FormatMessage(message, sql, position), innerException)
|
||||
{
|
||||
Position = position;
|
||||
Sql = sql ?? string.Empty;
|
||||
NearText = ExtractNearText(sql, position);
|
||||
}
|
||||
|
||||
private static string FormatMessage(string message, string sql, int position)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sql))
|
||||
{
|
||||
return $"{message}\nSQL statement is empty or null.";
|
||||
}
|
||||
|
||||
var nearText = ExtractNearText(sql, position);
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine(message);
|
||||
sb.AppendLine($"Position: {position}");
|
||||
sb.AppendLine($"Near: '{nearText}'");
|
||||
|
||||
// Show full SQL for short statements, truncated for long ones
|
||||
if (sql.Length <= 200)
|
||||
{
|
||||
sb.AppendLine($"Full SQL: {sql}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"SQL (truncated): {sql.Substring(0, 197)}...");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ExtractNearText(string sql, int position)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sql))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Clamp position to valid range
|
||||
position = Math.Max(0, Math.Min(position, sql.Length));
|
||||
|
||||
// Extract up to 20 chars before and 20 chars after the position
|
||||
var start = Math.Max(0, position - 20);
|
||||
var length = Math.Min(40, sql.Length - start);
|
||||
|
||||
var nearText = sql.Substring(start, length);
|
||||
|
||||
// Add ellipsis if truncated
|
||||
if (start > 0)
|
||||
{
|
||||
nearText = "..." + nearText;
|
||||
}
|
||||
if (start + length < sql.Length)
|
||||
{
|
||||
nearText = nearText + "...";
|
||||
}
|
||||
|
||||
return nearText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
using System.Globalization;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Conditional;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Conditional.Comparisons;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Functions.Conditional;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Functions.DateTime;
|
||||
using Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Factory class for creating boolean expressions and SQL filter conditions from Filter objects.
|
||||
/// Supports various filter types including list filters, date filters, calendar ranges, and timeframes.
|
||||
/// </summary>
|
||||
public abstract class ExpressionFactory
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ExpressionFactory"/> class with the default system time provider.
|
||||
/// </summary>
|
||||
protected ExpressionFactory() : this(TimeProvider.System)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ExpressionFactory"/> class with the specified time provider.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">The time provider implementation for date/time operations.</param>
|
||||
protected ExpressionFactory(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the column expression for the specified data column ID.
|
||||
/// Derived classes must implement this method to provide column lookup logic.
|
||||
/// </summary>
|
||||
/// <param name="dataColumnId">The data column identifier.</param>
|
||||
/// <returns>A registered table column expression for the specified column ID.</returns>
|
||||
protected abstract RegisteredTableColumnExpression GetColumnExpression(int dataColumnId);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a boolean expression from the specified filter object.
|
||||
/// Supports list filters, date-based filters, calendar ranges, and relative timeframe filters.
|
||||
/// </summary>
|
||||
/// <param name="filter">The filter configuration containing filter type, values, and date part specifications.</param>
|
||||
/// <returns>A boolean expression representing the filter condition for SQL generation.</returns>
|
||||
/// <exception cref="NotSupportedException">Thrown when the filter type is not supported.</exception>
|
||||
#pragma warning disable S2325 // Methods and properties that don't access instance data should be static
|
||||
protected BooleanExpression CreateBooleanExpression(Filter filter)
|
||||
{
|
||||
var columnExpr = GetColumnExpression(filter.DataColumnId);
|
||||
|
||||
return filter.FilterType switch
|
||||
{
|
||||
FilterType.List when filter.DatePart == DatePart.Continuous => GetListFilter(columnExpr, filter.Values, filter.ListUseNotIn),
|
||||
FilterType.List when filter.DatePart != DatePart.Continuous => GetDateListFilter(columnExpr, filter.DatePart, filter.Values),
|
||||
FilterType.Timeframe => GetTimeFrameFilter(columnExpr, filter.DatePart, filter.DateTimeFrameOffset, filter.DateTimeFrameCount),
|
||||
FilterType.Calendar => GetFilterForDateRange(columnExpr, DateTime.Parse(filter.Values.ElementAt(0).ToString()!, CultureInfo.InvariantCulture), DateTime.Parse(filter.Values.ElementAt(1).ToString()!, CultureInfo.InvariantCulture)),
|
||||
_ => throw new NotSupportedException($"FilterType {filter.FilterType} is not supported")
|
||||
};
|
||||
}
|
||||
#pragma warning restore S2325
|
||||
|
||||
/// <summary>
|
||||
/// Creates a list filter expression using IN or equality operators.
|
||||
/// For multiple values, generates an IN clause; for a single value, generates an equality comparison.
|
||||
/// </summary>
|
||||
/// <param name="columnExpr">The column expression to filter on.</param>
|
||||
/// <param name="values">The collection of values to include in the filter.</param>
|
||||
/// <param name="negateOperation">If true, negates the filter condition (NOT IN or !=).</param>
|
||||
/// <returns>A boolean expression representing the list filter condition.</returns>
|
||||
protected static BooleanExpression GetListFilter(Expression columnExpr, IEnumerable<object> values, bool negateOperation)
|
||||
{
|
||||
var exprList = values.Select(Expression.FromObject).ToArray();
|
||||
BooleanExpression listFilterExpr = exprList.Length > 1
|
||||
? new InExpression(columnExpr, exprList)
|
||||
: new EqualToExpression(columnExpr, exprList[0]);
|
||||
|
||||
if (negateOperation)
|
||||
{
|
||||
listFilterExpr = !listFilterExpr;
|
||||
}
|
||||
|
||||
return listFilterExpr;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a date list filter by converting each value to a date range and combining them with OR logic.
|
||||
/// Supports fiscal year, fiscal quarter, year, and month date parts.
|
||||
/// </summary>
|
||||
/// <param name="columnExpr">The date column expression to filter on.</param>
|
||||
/// <param name="datePart">The date part granularity (FiscalYear, FiscalQuarter, Year, Month).</param>
|
||||
/// <param name="values">The collection of date values to include in the filter.</param>
|
||||
/// <returns>A boolean expression representing the date list filter with OR'd date ranges.</returns>
|
||||
#pragma warning disable S2325 // Methods and properties that don't access instance data should be static
|
||||
protected BooleanExpression GetDateListFilter(Expression columnExpr, DatePart datePart, IEnumerable<object> values)
|
||||
{
|
||||
// or chains together all of the date ranges into a single condition
|
||||
var condition = values
|
||||
.Select(v => GetDateTimeRangeFromValue(datePart, v.ToString()!))
|
||||
.Select(range => GetFilterForDateRange(columnExpr, range.start, range.end))
|
||||
.Aggregate((current, next) => current | next);
|
||||
|
||||
return condition;
|
||||
}
|
||||
#pragma warning restore S2325
|
||||
|
||||
/// <summary>
|
||||
/// Creates a timeframe filter for relative date ranges based on the current date.
|
||||
/// Calculates the start and end dates using the specified offset and count.
|
||||
/// </summary>
|
||||
/// <param name="columnExpr">The date column expression to filter on.</param>
|
||||
/// <param name="datePart">The date part granularity (Day, Month, Year, FiscalYear).</param>
|
||||
/// <param name="frameOffset">The offset from the current date (can be negative for past dates).</param>
|
||||
/// <param name="frameCount">The number of date parts to include in the range.</param>
|
||||
/// <returns>A boolean expression representing the timeframe filter condition.</returns>
|
||||
protected BooleanExpression GetTimeFrameFilter(Expression columnExpr, DatePart datePart, int frameOffset, int frameCount)
|
||||
{
|
||||
var (start, end) = GetOffsetDateRange(datePart, frameOffset, frameCount);
|
||||
|
||||
return GetFilterForDateRange(columnExpr, start, end);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the offset date range from the current date using the specified frame offset and count.
|
||||
/// Handles day, month, year, and fiscal year date parts. Ensures the start date is always before the end date.
|
||||
/// </summary>
|
||||
/// <param name="datePart">The date part granularity for the calculation.</param>
|
||||
/// <param name="frameOffset">The offset from the current date (positive for future, negative for past).</param>
|
||||
/// <param name="frameCount">The number of date parts to include in the range.</param>
|
||||
/// <returns>A tuple containing the start and end dates of the range.</returns>
|
||||
/// <exception cref="NotImplementedException">Thrown for fiscal quarter date part (not yet implemented).</exception>
|
||||
/// <exception cref="NotSupportedException">Thrown for unsupported date parts.</exception>
|
||||
protected (DateTime start, DateTime end) GetOffsetDateRange(DatePart datePart, int frameOffset, int frameCount)
|
||||
{
|
||||
var today = _timeProvider.GetUtcNow().Date;
|
||||
DateTime start;
|
||||
DateTime end;
|
||||
switch (datePart)
|
||||
{
|
||||
case DatePart.Day:
|
||||
start = today.AddDays(frameOffset);
|
||||
end = start.AddDays(frameCount);
|
||||
break;
|
||||
case DatePart.Month:
|
||||
start = new DateTime(today.Year, today.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(frameOffset);
|
||||
end = start.AddMonths(frameCount);
|
||||
break;
|
||||
case DatePart.Year:
|
||||
start = new DateTime(today.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddYears(frameOffset);
|
||||
end = start.AddYears(frameCount);
|
||||
break;
|
||||
case DatePart.FiscalYear:
|
||||
start = GetCurrentFiscalYearStart().AddYears(frameOffset);
|
||||
end = start.AddYears(frameCount);
|
||||
break;
|
||||
case DatePart.FiscalQuarter:
|
||||
// need to implement quarter still
|
||||
throw new NotImplementedException("need to do");
|
||||
case DatePart.Quarter:
|
||||
case DatePart.Week:
|
||||
case DatePart.Continuous:
|
||||
default:
|
||||
throw new NotSupportedException($"datePart {datePart} is not supported.");
|
||||
}
|
||||
|
||||
return start > end
|
||||
? (end, start)
|
||||
: (start, end);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a date value string into a date range based on the specified date part.
|
||||
/// Supports fiscal year (FYxxxx), fiscal quarter (FYxxxx-Qx), year (yyyy), and month (MM-yyyy) formats.
|
||||
/// </summary>
|
||||
/// <param name="datePart">The date part granularity that determines the parsing format.</param>
|
||||
/// <param name="value">The date value string to parse.</param>
|
||||
/// <returns>A tuple containing the start and end dates of the parsed range.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the value cannot be parsed with the given date part.</exception>
|
||||
protected (DateTime start, DateTime end) GetDateTimeRangeFromValue(DatePart datePart, string value)
|
||||
{
|
||||
switch (datePart)
|
||||
{
|
||||
case DatePart.FiscalYear:
|
||||
{
|
||||
var currentFiscalYearStart = GetCurrentFiscalYearStart();
|
||||
var year = int.Parse(value.Substring(2, 4)) - 1;
|
||||
var startDate = new DateTime(year, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(currentFiscalYearStart.Month - 1).AddDays(currentFiscalYearStart.Day - 1);
|
||||
return (startDate, startDate.AddYears(1));
|
||||
}
|
||||
case DatePart.FiscalQuarter:
|
||||
{
|
||||
var currentFiscalYearStart = GetCurrentFiscalYearStart();
|
||||
var year = int.Parse(value.Substring(2, 4)) - 1;
|
||||
var fiscalYearStartDate = new DateTime(year, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(currentFiscalYearStart.Month - 1).AddDays(currentFiscalYearStart.Day - 1);
|
||||
var quarter = int.Parse(value.Substring(8, 1));
|
||||
var fiscalQuarterStart = fiscalYearStartDate.AddMonths((quarter - 1) * 3);
|
||||
return (fiscalQuarterStart, fiscalQuarterStart.AddMonths(3));
|
||||
}
|
||||
case DatePart.Year:
|
||||
{
|
||||
var start = DateTime.ParseExact(value, "yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None);
|
||||
return (start, start.AddYears(1));
|
||||
}
|
||||
case DatePart.Month:
|
||||
{
|
||||
var start = DateTime.ParseExact(value, "MM-yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None);
|
||||
return (start, start.AddMonths(1));
|
||||
}
|
||||
default:
|
||||
// error parsing input date
|
||||
throw new InvalidOperationException($"could not parse value {value} with datePart {datePart} into DateTime");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a boolean expression for a date range filter.
|
||||
/// Generates a condition where the column is greater than or equal to start and less than end.
|
||||
/// </summary>
|
||||
/// <param name="columnExpr">The date column expression to filter on.</param>
|
||||
/// <param name="start">The inclusive start date of the range.</param>
|
||||
/// <param name="end">The exclusive end date of the range.</param>
|
||||
/// <returns>A boolean expression representing the date range condition (column >= start AND column < end).</returns>
|
||||
protected static BooleanExpression GetFilterForDateRange(Expression columnExpr, DateTime start, DateTime end)
|
||||
{
|
||||
#pragma warning disable S2178 // Short-circuit logic should be used in boolean contexts
|
||||
return columnExpr >= start & columnExpr < end;
|
||||
#pragma warning restore S2178
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an expression that calculates the fiscal year month for a given date column.
|
||||
/// Adjusts the date based on the fiscal year start month and day, then truncates to month precision.
|
||||
/// </summary>
|
||||
/// <param name="dateColumnExpr">The date column expression to convert to fiscal year month.</param>
|
||||
/// <param name="fiscalYearStartMonth">The month (1-12) when the fiscal year begins.</param>
|
||||
/// <param name="fiscalYearStartDay">The day of the month when the fiscal year begins.</param>
|
||||
/// <returns>An expression that represents the truncated fiscal year month.</returns>
|
||||
protected static Expression GetFiscalYearMonthExpression(Expression dateColumnExpr, int fiscalYearStartMonth, int fiscalYearStartDay)
|
||||
{
|
||||
Expression monthsToAdd = 13 - fiscalYearStartMonth;
|
||||
if (fiscalYearStartDay > 1)
|
||||
{
|
||||
var middleOfMonthTransitionExpr = new IfThenElseExpression(new DatePartFunction(dateColumnExpr, "day") < fiscalYearStartDay, 1, 0);
|
||||
monthsToAdd -= middleOfMonthTransitionExpr;
|
||||
}
|
||||
|
||||
var fiscalDate = new DateAddFunction(dateColumnExpr, "month", monthsToAdd);
|
||||
return new TruncateDateFunction(fiscalDate, "month");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current fiscal year start date.
|
||||
/// Must be implemented by derived classes to provide organization-specific fiscal year configuration.
|
||||
/// </summary>
|
||||
/// <returns>The fiscal year start date in UTC.</returns>
|
||||
protected abstract DateTime GetCurrentFiscalYearStart();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public enum AggregationType
|
||||
{
|
||||
Sum = 0,
|
||||
Count = 1,
|
||||
CountDistinct = 2,
|
||||
Avg = 3,
|
||||
Median = 4,
|
||||
Min = 5,
|
||||
Max = 6
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public class CalculationFilter : Filter
|
||||
{
|
||||
public IEnumerable<string> AliasedDataColumnIds { get; }
|
||||
|
||||
public CalculationFilter(int dataColumnId, IEnumerable<string> aliasedDataColumnIds, IEnumerable<object> values, IEnumerable<FilterCondition> conditions)
|
||||
: base(dataColumnId, FilterType.Conditions, values, conditions, DatePart.Continuous, false, 0, 0)
|
||||
{
|
||||
AliasedDataColumnIds = aliasedDataColumnIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public class CalculationFilterGroup
|
||||
{
|
||||
[JsonIgnore]
|
||||
private IEnumerable<CalculationFilter> _filters;
|
||||
|
||||
// Hereditary logical operation applied to all Filters
|
||||
public LogicalOperator LogicalOperator { get; set; }
|
||||
|
||||
public IEnumerable<CalculationFilter> Filters
|
||||
{
|
||||
get => _filters?.Where(x => x.IsValid()).ToList() ?? new List<CalculationFilter>();
|
||||
set => _filters = value;
|
||||
}
|
||||
|
||||
public CalculationFilterGroup()
|
||||
{
|
||||
LogicalOperator = LogicalOperator.And;
|
||||
_filters = new List<CalculationFilter>();
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public CalculationFilterGroup(IEnumerable<CalculationFilter> filters, LogicalOperator logicalOperator)
|
||||
{
|
||||
_filters = filters;
|
||||
LogicalOperator = logicalOperator;
|
||||
}
|
||||
|
||||
public bool IsValid()
|
||||
{
|
||||
return Filters != null && Filters.Any();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public class ColumnQueryConfig
|
||||
{
|
||||
public int DataColumnId { get; set; }
|
||||
|
||||
public DatePart DatePart { get; set; }
|
||||
|
||||
public Filter? Filter { get; set; }
|
||||
|
||||
public int RowLimit { get; set; }
|
||||
|
||||
[JsonConstructor]
|
||||
public ColumnQueryConfig(int dataColumnId, DatePart datePart, Filter? filter, int rowLimit)
|
||||
{
|
||||
DataColumnId = dataColumnId;
|
||||
DatePart = datePart;
|
||||
Filter = filter != null && filter.IsValid() ? filter : null;
|
||||
RowLimit = rowLimit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public enum DatePart
|
||||
{
|
||||
Continuous = 0,
|
||||
Year,
|
||||
Quarter,
|
||||
Month,
|
||||
Week,
|
||||
Day,
|
||||
FiscalYear,
|
||||
FiscalQuarter
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public class Field
|
||||
{
|
||||
public string ColumnAlias { get; set; } = string.Empty;
|
||||
public int DataColumnId { get; set; }
|
||||
public DatePart DatePart { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a filter criteria for querying data with support for various filter types including lists, date ranges, and timeframes.
|
||||
/// Filters can be applied to specific data columns and support different date granularities.
|
||||
/// </summary>
|
||||
public class Filter
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the identifier of the data column to which this filter applies.
|
||||
/// </summary>
|
||||
public int DataColumnId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of filter being applied (e.g., List, Calendar, Timeframe).
|
||||
/// </summary>
|
||||
public FilterType FilterType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of values to filter by. The interpretation depends on the <see cref="FilterType"/>.
|
||||
/// </summary>
|
||||
public IEnumerable<object> Values { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of filter conditions that define complex filtering logic.
|
||||
/// Only valid conditions are retained.
|
||||
/// </summary>
|
||||
public IEnumerable<FilterCondition> Conditions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the date granularity part for date-based filtering (e.g., Year, Month, Day, FiscalYear).
|
||||
/// </summary>
|
||||
public DatePart DatePart { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether to use NOT IN instead of IN for list-type filters.
|
||||
/// Only applies when <see cref="FilterType"/> is List.
|
||||
/// </summary>
|
||||
public bool ListUseNotIn { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the offset from the current time for timeframe-based filters.
|
||||
/// Used in conjunction with <see cref="DateTimeFrameCount"/> to define relative time periods.
|
||||
/// </summary>
|
||||
public int DateTimeFrameOffset { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the zero-based number of time increments from the offset.
|
||||
/// A value of 0 means current period, -1 means one period backward, and 1 means one period forward.
|
||||
/// The unit (year, month, day, etc.) is determined by the <see cref="DatePart"/> property.
|
||||
/// </summary>
|
||||
public int DateTimeFrameCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Filter"/> class with the specified filter criteria.
|
||||
/// </summary>
|
||||
/// <param name="dataColumnId">The identifier of the data column to filter.</param>
|
||||
/// <param name="filterType">The type of filter to apply.</param>
|
||||
/// <param name="values">The collection of values for the filter.</param>
|
||||
/// <param name="conditions">The collection of filter conditions (invalid conditions are automatically removed).</param>
|
||||
/// <param name="datePart">The date granularity for date-based filtering.</param>
|
||||
/// <param name="listUseNotIn">Whether to use NOT IN for list filters; false to use IN.</param>
|
||||
/// <param name="dateTimeFrameOffset">The offset from current time for timeframe filters.</param>
|
||||
/// <param name="dateTimeFrameCount">The number of time increments from the offset (0 = current, negative = past, positive = future).</param>
|
||||
[JsonConstructor]
|
||||
public Filter(int dataColumnId, FilterType filterType, IEnumerable<object> values, IEnumerable<FilterCondition> conditions, DatePart datePart, bool listUseNotIn, int dateTimeFrameOffset, int dateTimeFrameCount)
|
||||
{
|
||||
DataColumnId = dataColumnId;
|
||||
FilterType = filterType;
|
||||
Values = values;
|
||||
Conditions = conditions.Where(x => x.IsValid()).ToList();
|
||||
DatePart = datePart;
|
||||
ListUseNotIn = listUseNotIn;
|
||||
DateTimeFrameOffset = dateTimeFrameOffset;
|
||||
DateTimeFrameCount = dateTimeFrameCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether this filter has valid criteria that can be applied.
|
||||
/// A filter is valid if it has values, conditions, or non-default timeframe settings.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the filter has values, conditions, or timeframe settings; otherwise, <c>false</c>.
|
||||
/// </returns>
|
||||
public bool IsValid()
|
||||
{
|
||||
return (Values != null && Values.Any()) || (Conditions != null && Conditions.Any()) || (DateTimeFrameCount != default || DateTimeFrameOffset != default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public class FilterCondition
|
||||
{
|
||||
public FilterOperator Operator { get; set; }
|
||||
|
||||
public IEnumerable<object>? Values { get; set; }
|
||||
|
||||
// This is not hereditary to Values; it is used for combination with the next FilterCondition in the set
|
||||
// todo: That could be indexed to ensure accuracy
|
||||
public LogicalOperator LogicalOperator { get; set; }
|
||||
|
||||
public bool IsValid()
|
||||
{
|
||||
return Values != null && Values.Any();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public class FilterGroup
|
||||
{
|
||||
// Hereditary logical operation applied to all Filters
|
||||
public LogicalOperator LogicalOperator { get; set; }
|
||||
|
||||
public IEnumerable<Filter> Filters { get; }
|
||||
|
||||
public FilterGroup()
|
||||
{
|
||||
LogicalOperator = LogicalOperator.And;
|
||||
Filters = new List<Filter>();
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public FilterGroup(IEnumerable<Filter> filters, LogicalOperator logicalOperator)
|
||||
{
|
||||
Filters = filters.Where(x => x.IsValid()).ToList();
|
||||
LogicalOperator = logicalOperator;
|
||||
}
|
||||
|
||||
public bool IsValid()
|
||||
{
|
||||
return Filters != null && Filters.Any();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public enum FilterOperator
|
||||
{
|
||||
Equals = 0,
|
||||
NotEquals = 1,
|
||||
LessThan = 2,
|
||||
LessThanOrEqualTo = 3,
|
||||
GreaterThan = 4,
|
||||
GreaterThanOrEqualTo = 5,
|
||||
Between = 6, // this is a function, not a comparison - x BETWEEN a AND b is the same as: x >= a AND x <= z
|
||||
Contains = 7,
|
||||
StartsWith = 8,
|
||||
EndsWith = 9
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public enum FilterType
|
||||
{
|
||||
List = 0,
|
||||
Conditions = 1,
|
||||
Calendar = 2,
|
||||
Timeframe = 3
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public enum LogicalOperator
|
||||
{
|
||||
[Display(Name = "and")]
|
||||
And,
|
||||
[Display(Name = "or")]
|
||||
Or
|
||||
}
|
||||
|
||||
public static class LogicalOperatorExtensions
|
||||
{
|
||||
public static string ToSql(this LogicalOperator logicalOperator, bool withSpaces = true)
|
||||
{
|
||||
var sql = "";
|
||||
switch (logicalOperator)
|
||||
{
|
||||
case LogicalOperator.And:
|
||||
sql = "and";
|
||||
break;
|
||||
case LogicalOperator.Or:
|
||||
sql = "or";
|
||||
break;
|
||||
}
|
||||
|
||||
return withSpaces ? $" {sql} " : sql;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public class QueryConfig
|
||||
{
|
||||
public IEnumerable<Row> Rows { get; set; }
|
||||
|
||||
public IEnumerable<Value> Values { get; set; }
|
||||
|
||||
public IEnumerable<FilterGroup> FilterGroups { get; }
|
||||
|
||||
public bool WithTotals { get; set; }
|
||||
|
||||
public int RowLimit { get; set; }
|
||||
|
||||
public QueryConfig()
|
||||
{
|
||||
Rows = new List<Row>();
|
||||
Values = new List<Value>();
|
||||
FilterGroups = new List<FilterGroup>();
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public QueryConfig(IEnumerable<FilterGroup> filterGroups, IEnumerable<Row> rows, IEnumerable<Value> values, bool withTotals, int rowLimit)
|
||||
{
|
||||
FilterGroups = filterGroups.Where(x => x.IsValid()).ToList();
|
||||
Rows = rows;
|
||||
Values = values;
|
||||
WithTotals = withTotals;
|
||||
RowLimit = rowLimit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public static class QueryConfigExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all column ids referenced by this <see cref="QueryConfig"/>
|
||||
/// </summary>
|
||||
/// <param name="queryConfig"></param>
|
||||
/// <returns></returns>
|
||||
public static int[] GetAllColumnIds(this QueryConfig queryConfig)
|
||||
{
|
||||
return queryConfig.Values.SelectMany(value => value.CalculationDataColumnIds)
|
||||
.Union(queryConfig.Values.SelectMany(x => x.FilterGroups.SelectMany(y => y.Filters.Select(f => f.DataColumnId))))
|
||||
.Union(queryConfig.Rows.Select(row => row.DataColumnId))
|
||||
.Union(queryConfig.FilterGroups.SelectMany(filterGroup => filterGroup.Filters.Select(filter => filter.DataColumnId)))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public class Row : Field
|
||||
{
|
||||
public SortDirection sortDirection { get; set; }
|
||||
|
||||
public Row()
|
||||
{
|
||||
sortDirection = SortDirection.Asc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public enum SortDirection
|
||||
{
|
||||
Asc = 0,
|
||||
Desc = 1
|
||||
}
|
||||
|
||||
public static class SortDirectionExtensions
|
||||
{
|
||||
public static string ToSql(this SortDirection sortDirection)
|
||||
{
|
||||
return sortDirection.ToString().ToUpper();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public class SqlResponse
|
||||
{
|
||||
public string SqlQuery { get; }
|
||||
public IEnumerable<KeyValuePair<string, object>> Parameters { get; }
|
||||
|
||||
public SqlResponse(string sqlQuery, IEnumerable<KeyValuePair<string, object>> parameters)
|
||||
{
|
||||
SqlQuery = sqlQuery;
|
||||
Parameters = parameters;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public class Value
|
||||
{
|
||||
public string ColumnAlias { get; }
|
||||
public string Calculation { get; }
|
||||
public IEnumerable<int> CalculationDataColumnIds { get; }
|
||||
public IEnumerable<string> AliasedIds { get; }
|
||||
|
||||
public IEnumerable<CalculationFilterGroup> FilterGroups { get; }
|
||||
|
||||
public Value() : this(string.Empty, string.Empty, new int[0], new string[0], new CalculationFilterGroup[0])
|
||||
{
|
||||
// FilterGroups = new List<CalculationFilterGroup>();
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public Value(string columnAlias, string calculation, IEnumerable<int> calculationDataColumnIds, IEnumerable<string> aliasedIds, IEnumerable<CalculationFilterGroup> filterGroups)
|
||||
{
|
||||
ColumnAlias = columnAlias;
|
||||
Calculation = calculation;
|
||||
CalculationDataColumnIds = calculationDataColumnIds ?? Array.Empty<int>();
|
||||
AliasedIds = aliasedIds ?? aliasedIds ?? Array.Empty<string>();
|
||||
FilterGroups = filterGroups?.Where(x => x.IsValid()).ToList() ?? new List<CalculationFilterGroup>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Strata.SqlTools.SqlServer.ExpressionFactory.Query;
|
||||
|
||||
public class ValueFilter
|
||||
{
|
||||
public int DataColumnId { get; set; }
|
||||
|
||||
public object? FilterValue { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
using Strata.SqlTools.Breakdowns.SqlServer;
|
||||
using Strata.SqlTools.SqlBreakdown.Classes;
|
||||
using Strata.SqlTools.SqlBreakdown.Interfaces.QueryEngine;
|
||||
|
||||
namespace Strata.SqlTools.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods providing a fluent API for building SQL queries with WITH clauses (CTEs).
|
||||
/// Enables method chaining for intuitive query construction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>This extension class provides a fluent, chainable API for building QueryBreakdown objects.
|
||||
/// Instead of setting properties individually, you can use these methods to build queries in a
|
||||
/// more intuitive, method-chaining style.</para>
|
||||
///
|
||||
/// <para>Example - Traditional approach:</para>
|
||||
/// <code>
|
||||
/// var cteQuery = new QueryBreakdown();
|
||||
/// cteQuery.SelectClause.Clause = "id, name";
|
||||
/// cteQuery.FromClause.Clause = "users";
|
||||
/// cteQuery.WhereClause.Clause = "active = 1";
|
||||
/// var mainQuery = new QueryBreakdown();
|
||||
/// mainQuery.AddWithClause("active_users", cteQuery);
|
||||
/// mainQuery.SelectClause.Clause = "*";
|
||||
/// mainQuery.FromClause.Clause = "active_users";
|
||||
/// var sql = mainQuery.GetSql();
|
||||
/// </code>
|
||||
///
|
||||
/// <para>Example - Fluent approach (using these extensions):</para>
|
||||
/// <code>
|
||||
/// var sql = new QueryBreakdown()
|
||||
/// .WithCte("active_users", cte => cte
|
||||
/// .Select("id, name")
|
||||
/// .From("users")
|
||||
/// .Where("active = 1"))
|
||||
/// .Select("*")
|
||||
/// .From("active_users")
|
||||
/// .GetSql();
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public static class QueryBreakdownExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets the SELECT clause and returns the query for method chaining.
|
||||
/// </summary>
|
||||
/// <param name="query">The query to configure.</param>
|
||||
/// <param name="selectClause">The SELECT clause SQL text (e.g., "id, name, email").</param>
|
||||
/// <returns>The same query object for method chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when query is null.</exception>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var query = new QueryBreakdown()
|
||||
/// .Select("id, name")
|
||||
/// .From("users");
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static QueryBreakdown Select(this QueryBreakdown query, string selectClause)
|
||||
{
|
||||
if (query == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(query), "Query cannot be null.");
|
||||
}
|
||||
|
||||
query.SelectClause.Clause = selectClause;
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the FROM clause and returns the query for method chaining.
|
||||
/// </summary>
|
||||
/// <param name="query">The query to configure.</param>
|
||||
/// <param name="fromClause">The FROM clause SQL text (e.g., "users" or "users u JOIN orders o").</param>
|
||||
/// <returns>The same query object for method chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when query is null.</exception>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var query = new QueryBreakdown()
|
||||
/// .Select("*")
|
||||
/// .From("users u JOIN orders o ON u.id = o.user_id");
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static QueryBreakdown From(this QueryBreakdown query, string fromClause)
|
||||
{
|
||||
if (query == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(query), "Query cannot be null.");
|
||||
}
|
||||
|
||||
query.FromClause.Clause = fromClause;
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the WHERE clause and returns the query for method chaining.
|
||||
/// </summary>
|
||||
/// <param name="query">The query to configure.</param>
|
||||
/// <param name="whereClause">The WHERE clause SQL text (e.g., "active = 1 AND age > 18").</param>
|
||||
/// <returns>The same query object for method chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when query is null.</exception>
|
||||
/// <remarks>
|
||||
/// This method replaces any existing WHERE clause. To add conditions to an existing WHERE clause,
|
||||
/// use <see cref="AddWhere(QueryBreakdown, string)"/> instead.
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var query = new QueryBreakdown()
|
||||
/// .Select("*")
|
||||
/// .From("users")
|
||||
/// .Where("active = 1");
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static QueryBreakdown Where(this QueryBreakdown query, string whereClause)
|
||||
{
|
||||
if (query == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(query), "Query cannot be null.");
|
||||
}
|
||||
|
||||
query.WhereClause.Clause = whereClause;
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a condition to the existing WHERE clause and returns the query for method chaining.
|
||||
/// </summary>
|
||||
/// <param name="query">The query to configure.</param>
|
||||
/// <param name="whereCondition">The WHERE condition to append (e.g., "AND active = 1").</param>
|
||||
/// <returns>The same query object for method chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when query is null.</exception>
|
||||
/// <remarks>
|
||||
/// This method appends to the existing WHERE clause. If you want to replace the WHERE clause entirely,
|
||||
/// use <see cref="Where(QueryBreakdown, string)"/> instead.
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var query = new QueryBreakdown()
|
||||
/// .Select("*")
|
||||
/// .From("users")
|
||||
/// .Where("active = 1")
|
||||
/// .AddWhere("AND age > 18");
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static QueryBreakdown AddWhere(this QueryBreakdown query, string whereCondition)
|
||||
{
|
||||
if (query == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(query), "Query cannot be null.");
|
||||
}
|
||||
|
||||
query.AddWhereClause(whereCondition);
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the GROUP BY clause and returns the query for method chaining.
|
||||
/// </summary>
|
||||
/// <param name="query">The query to configure.</param>
|
||||
/// <param name="groupByClause">The GROUP BY clause SQL text (e.g., "department, year").</param>
|
||||
/// <returns>The same query object for method chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when query is null.</exception>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var query = new QueryBreakdown()
|
||||
/// .Select("department, COUNT(*) as count")
|
||||
/// .From("employees")
|
||||
/// .GroupBy("department");
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static QueryBreakdown GroupBy(this QueryBreakdown query, string groupByClause)
|
||||
{
|
||||
if (query == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(query), "Query cannot be null.");
|
||||
}
|
||||
|
||||
query.GroupByClause.Clause = groupByClause;
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the HAVING clause and returns the query for method chaining.
|
||||
/// </summary>
|
||||
/// <param name="query">The query to configure.</param>
|
||||
/// <param name="havingClause">The HAVING clause SQL text (e.g., "COUNT(*) > 5").</param>
|
||||
/// <returns>The same query object for method chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when query is null.</exception>
|
||||
/// <remarks>
|
||||
/// The HAVING clause filters groups after GROUP BY has been applied. Typically used with aggregate functions.
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var query = new QueryBreakdown()
|
||||
/// .Select("department, COUNT(*) as count")
|
||||
/// .From("employees")
|
||||
/// .GroupBy("department")
|
||||
/// .Having("COUNT(*) > 5");
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static QueryBreakdown Having(this QueryBreakdown query, string havingClause)
|
||||
{
|
||||
if (query == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(query), "Query cannot be null.");
|
||||
}
|
||||
|
||||
query.HavingClause.Clause = havingClause;
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the ORDER BY clause and returns the query for method chaining.
|
||||
/// </summary>
|
||||
/// <param name="query">The query to configure.</param>
|
||||
/// <param name="orderByClause">The ORDER BY clause SQL text (e.g., "name ASC, created_date DESC").</param>
|
||||
/// <returns>The same query object for method chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when query is null.</exception>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var query = new QueryBreakdown()
|
||||
/// .Select("*")
|
||||
/// .From("employees")
|
||||
/// .OrderBy("last_name ASC, first_name ASC");
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static QueryBreakdown OrderBy(this QueryBreakdown query, string orderByClause)
|
||||
{
|
||||
if (query == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(query), "Query cannot be null.");
|
||||
}
|
||||
|
||||
query.OrderByClause.Clause = orderByClause;
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a Common Table Expression (CTE) to this query with fluent configuration.
|
||||
/// </summary>
|
||||
/// <param name="query">The query to add the CTE to.</param>
|
||||
/// <param name="tableName">The name of the CTE (used in the WITH clause).</param>
|
||||
/// <param name="configureAction">An action that configures the CTE query using fluent methods.</param>
|
||||
/// <returns>The same query object for method chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when query or configureAction is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when tableName is null, empty, or whitespace.</exception>
|
||||
/// <remarks>
|
||||
/// <para>This method creates a new QueryBreakdown for the CTE and allows you to configure it
|
||||
/// using the fluent API within a lambda expression.</para>
|
||||
///
|
||||
/// <para>Parameters defined in the CTE query are automatically merged into the parent query's
|
||||
/// parameter collection. If a parameter name conflict occurs, the parent query's parameter
|
||||
/// takes precedence.</para>
|
||||
///
|
||||
/// <para>For recursive CTEs, configure the IsRecursive flag and RecursiveQuery property on the
|
||||
/// CTE within the configureAction.</para>
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// // Simple CTE
|
||||
/// var sql = new QueryBreakdown()
|
||||
/// .WithCte("active_users", cte => cte
|
||||
/// .Select("id, name, email")
|
||||
/// .From("users")
|
||||
/// .Where("active = 1"))
|
||||
/// .Select("*")
|
||||
/// .From("active_users")
|
||||
/// .GetSql();
|
||||
/// </code>
|
||||
/// </example>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// // Multiple CTEs
|
||||
/// var sql = new QueryBreakdown()
|
||||
/// .WithCte("active_users", cte => cte
|
||||
/// .Select("id, name")
|
||||
/// .From("users")
|
||||
/// .Where("active = 1"))
|
||||
/// .WithCte("active_orders", cte => cte
|
||||
/// .Select("order_id, user_id, amount")
|
||||
/// .From("orders")
|
||||
/// .Where("status = 'completed'"))
|
||||
/// .Select("u.name, COUNT(o.order_id) as order_count")
|
||||
/// .From("active_users u")
|
||||
/// .From("LEFT JOIN active_orders o ON u.id = o.user_id")
|
||||
/// .GroupBy("u.id, u.name")
|
||||
/// .GetSql();
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static QueryBreakdown WithCte(
|
||||
this QueryBreakdown query,
|
||||
string tableName,
|
||||
Action<QueryBreakdown> configureAction)
|
||||
{
|
||||
if (query == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(query), "Query cannot be null.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tableName))
|
||||
{
|
||||
throw new ArgumentException("CTE table name cannot be null, empty, or whitespace.", nameof(tableName));
|
||||
}
|
||||
|
||||
if (configureAction == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(configureAction), "Configuration action cannot be null.");
|
||||
}
|
||||
|
||||
// Create a new QueryBreakdown for the CTE
|
||||
var cteQuery = new QueryBreakdown();
|
||||
|
||||
// Configure the CTE using the provided action
|
||||
configureAction(cteQuery);
|
||||
|
||||
// Add the CTE to the main query
|
||||
query.AddWithClause(tableName, cteQuery);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a Common Table Expression (CTE) defined by a column list.
|
||||
/// </summary>
|
||||
/// <param name="query">The query to add the CTE to.</param>
|
||||
/// <param name="tableName">The name of the CTE.</param>
|
||||
/// <param name="columns">The list of column names for the CTE.</param>
|
||||
/// <param name="configureAction">An action that configures the CTE query.</param>
|
||||
/// <returns>The same query object for method chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when query, configureAction, or columns is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when tableName is null, empty, or whitespace, or when columns is empty.</exception>
|
||||
/// <remarks>
|
||||
/// <para>Allows explicit specification of CTE column names using the syntax:
|
||||
/// WITH cte_name (col1, col2, col3) AS (query)</para>
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var sql = new QueryBreakdown()
|
||||
/// .WithCte("active_users", new[] { "id", "name", "email" }, cte => cte
|
||||
/// .Select("user_id, user_name, user_email")
|
||||
/// .From("users")
|
||||
/// .Where("status = 'active'"))
|
||||
/// .Select("*")
|
||||
/// .From("active_users")
|
||||
/// .GetSql();
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static QueryBreakdown WithCte(
|
||||
this QueryBreakdown query,
|
||||
string tableName,
|
||||
string[] columns,
|
||||
Action<QueryBreakdown> configureAction)
|
||||
{
|
||||
if (query == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(query), "Query cannot be null.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tableName))
|
||||
{
|
||||
throw new ArgumentException("CTE table name cannot be null, empty, or whitespace.", nameof(tableName));
|
||||
}
|
||||
|
||||
if (columns == null || columns.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Columns cannot be null or empty.", nameof(columns));
|
||||
}
|
||||
|
||||
if (configureAction == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(configureAction), "Configuration action cannot be null.");
|
||||
}
|
||||
|
||||
// Create a new QueryBreakdown for the CTE with column list
|
||||
var cteQuery = new QueryBreakdown();
|
||||
|
||||
// Configure the CTE using the provided action
|
||||
configureAction(cteQuery);
|
||||
|
||||
// Create a WithClause with column list
|
||||
var withClause = new WithClause(tableName, cteQuery)
|
||||
{
|
||||
ColumnList = new List<string>(columns)
|
||||
};
|
||||
|
||||
// Add the CTE to the main query
|
||||
query.AddWithClause(withClause);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a Common Table Expression (CTE) defined with an IQueryBreakdown instance.
|
||||
/// </summary>
|
||||
/// <param name="query">The query to add the CTE to.</param>
|
||||
/// <param name="tableName">The name of the CTE.</param>
|
||||
/// <param name="cteQuery">The query that defines the CTE.</param>
|
||||
/// <returns>The same query object for method chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when query or cteQuery is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when tableName is null, empty, or whitespace.</exception>
|
||||
/// <remarks>
|
||||
/// This is the base method used by the other WithCte overloads. Use when you have an
|
||||
/// already-configured query to add as a CTE.
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var cteQuery = new QueryBreakdown()
|
||||
/// .Select("id, name")
|
||||
/// .From("users")
|
||||
/// .Where("active = 1");
|
||||
///
|
||||
/// var sql = new QueryBreakdown()
|
||||
/// .WithCte("active_users", cteQuery)
|
||||
/// .Select("*")
|
||||
/// .From("active_users")
|
||||
/// .GetSql();
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static QueryBreakdown WithCte(
|
||||
this QueryBreakdown query,
|
||||
string tableName,
|
||||
IQueryBreakdown cteQuery)
|
||||
{
|
||||
if (query == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(query), "Query cannot be null.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tableName))
|
||||
{
|
||||
throw new ArgumentException("CTE table name cannot be null, empty, or whitespace.", nameof(tableName));
|
||||
}
|
||||
|
||||
if (cteQuery == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(cteQuery), "CTE query cannot be null.");
|
||||
}
|
||||
|
||||
query.AddWithClause(tableName, cteQuery);
|
||||
return query;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,50 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
|
||||
<!-- NuGet Package Metadata -->
|
||||
<PackageId>Strata.SqlTools.SqlServer</PackageId>
|
||||
<Version>1.0.0</Version>
|
||||
<Authors>Strata Decision Technology</Authors>
|
||||
<Company>Strata Decision Technology</Company>
|
||||
<Product>Strata SQL Utilities - SQL Server</Product>
|
||||
<Description>Microsoft SQL Server T-SQL specific implementations for Strata.SqlTools, including query breakdown, statement parsing, and SQL generation for T-SQL dialect.</Description>
|
||||
<PackageTags>sql;tsql;sql-server;query-builder;sql-parser;database;t-sql</PackageTags>
|
||||
<PackageProjectUrl>https://github.com/stratadecision/sql-builder</PackageProjectUrl>
|
||||
<RepositoryUrl>https://github.com/stratadecision/sql-builder</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<PackageReleaseNotes>Initial release with SQL Server T-SQL query parsing, generation, and breakdown support.</PackageReleaseNotes>
|
||||
<Copyright>Copyright © Strata Decision Technology 2024-2026</Copyright>
|
||||
|
||||
<!-- Build Configuration -->
|
||||
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
|
||||
<IncludeSymbols>true</IncludeSymbols>
|
||||
<SymbolPackageFormat>symbols.nupkg</SymbolPackageFormat>
|
||||
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
||||
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
|
||||
|
||||
<!-- Code Analysis -->
|
||||
<EnableNETAnalyzers>true</EnableNETAnalyzers>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Strata.SqlTools.SqlBreakdown\Strata.SqlTools.SqlBreakdown.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Data.SqlClient" Version="4.8.6" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.Text;
|
||||
using Strata.SqlTools.Breakdowns.SqlServer;
|
||||
using Strata.SqlTools.SqlBreakdown.Interfaces.QueryEngine;
|
||||
|
||||
namespace Strata.SqlTools.SqlServer.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// SQL Server-specific paging helper methods for generating paged query results.
|
||||
/// </summary>
|
||||
public static class SqlServerPagingUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates SQL for paged result sets.
|
||||
/// </summary>
|
||||
/// <param name="queryBreakdown">The query breakdown defining the SQL query.</param>
|
||||
/// <param name="pageIndex">The zero-based page index.</param>
|
||||
/// <param name="pageSize">The number of rows per page.</param>
|
||||
/// <param name="rowNumOrderBy">The ORDER BY clause for row numbering.</param>
|
||||
/// <returns>A SQL string that implements paging.</returns>
|
||||
public static string GetPagingSql(IQueryBreakdown queryBreakdown, int pageIndex, int pageSize, string rowNumOrderBy)
|
||||
{
|
||||
bool isPaged = pageIndex != -1;
|
||||
var sb = new StringBuilder();
|
||||
string finalTable = "#FINALTABLE" + Guid.NewGuid().ToString().Replace("-", string.Empty);
|
||||
|
||||
// Write out any setup clauses
|
||||
sb.AppendLine("--create setup clauses");
|
||||
foreach (string setup in queryBreakdown.SetupClauses)
|
||||
{
|
||||
sb.AppendLine(setup);
|
||||
}
|
||||
|
||||
// Get the data
|
||||
sb.AppendLine("--get the data");
|
||||
if (isPaged)
|
||||
{
|
||||
sb.Append($"SELECT Row_Number() Over (Order By {rowNumOrderBy}) as ROWNUM,");
|
||||
sb.AppendLine();
|
||||
sb.Append($"{queryBreakdown.SelectClause} INTO {finalTable} FROM {queryBreakdown.FromClause}");
|
||||
sb.AppendLine();
|
||||
|
||||
if (queryBreakdown.IsUsingWhereClause)
|
||||
{
|
||||
sb.Append($"WHERE {queryBreakdown.WhereClause}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("SELECT");
|
||||
sb.Append($"{queryBreakdown.SelectClause} INTO {finalTable} FROM {queryBreakdown.FromClause}");
|
||||
sb.AppendLine();
|
||||
|
||||
if (queryBreakdown.IsUsingWhereClause)
|
||||
{
|
||||
sb.Append($"WHERE {queryBreakdown.WhereClause}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
if (queryBreakdown.IsUsingGroupByClause)
|
||||
{
|
||||
sb.Append(" GROUP BY ");
|
||||
sb.AppendLine();
|
||||
sb.Append($" {queryBreakdown.GroupByClause} ");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (queryBreakdown.IsUsingOrderByClause)
|
||||
{
|
||||
sb.Append($"ORDER BY {queryBreakdown.OrderByClause}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (isPaged)
|
||||
{
|
||||
sb.Append($"SELECT * FROM {finalTable} WHERE ROWNUM BETWEEN ({pageIndex} * {pageSize} + 1) and ({pageSize} * ({pageIndex} + 1))");
|
||||
sb.AppendLine();
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($"SELECT * FROM {finalTable}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.Append($"SELECT COUNT(*) FROM {finalTable}");
|
||||
sb.AppendLine();
|
||||
|
||||
// Drop temp tables
|
||||
sb.AppendLine("--drop temp tables");
|
||||
sb.Append($"drop table {finalTable}");
|
||||
sb.AppendLine();
|
||||
|
||||
// Write out any finish clauses
|
||||
sb.AppendLine("--create finish clauses");
|
||||
foreach (string finish in queryBreakdown.FinishClauses)
|
||||
{
|
||||
sb.AppendLine(finish);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate SQL to do paging. TotalCount will be returned as first column in the result set.
|
||||
/// Uses Common Table Expression (CTE) for efficient paging with ROW_NUMBER() and COUNT(*) OVER().
|
||||
/// </summary>
|
||||
/// <param name="queryBreakdown">The query breakdown defining the SQL query.</param>
|
||||
/// <param name="start">The starting row number (1-based).</param>
|
||||
/// <param name="limit">The maximum number of rows to return.</param>
|
||||
/// <param name="sort">The ORDER BY expression for sorting.</param>
|
||||
/// <returns>A SQL string that implements paging with total count.</returns>
|
||||
public static string GetPagingSqlByStart(IQueryBreakdown queryBreakdown, int start, int limit, string sort)
|
||||
{
|
||||
const string rowNumAlias = "RowNum";
|
||||
const string totalCountAlias = "TotalCount";
|
||||
|
||||
// Setup innerQuery
|
||||
const string innerQueryAlias = "Query";
|
||||
var innerQuery = (IQueryBreakdown)((ICloneable)queryBreakdown).Clone();
|
||||
|
||||
var innerSelectSb = new StringBuilder();
|
||||
innerSelectSb.Append($"ROW_NUMBER() OVER(ORDER BY {sort}) AS {rowNumAlias}, ");
|
||||
innerSelectSb.AppendLine();
|
||||
innerSelectSb.Append($"COUNT(*) OVER () AS {totalCountAlias}, ");
|
||||
innerSelectSb.AppendLine(((QueryBreakdown)queryBreakdown).SelectClause.Clause);
|
||||
((QueryBreakdown)innerQuery).SelectClause.Clause = innerSelectSb.ToString();
|
||||
|
||||
// Setup pagingQuery
|
||||
var pagingQuery = new QueryBreakdown();
|
||||
pagingQuery.SelectClause.Clause = "*";
|
||||
pagingQuery.FromClause.Clause = innerQueryAlias;
|
||||
pagingQuery.WhereClause.Clause = $"{rowNumAlias} BETWEEN {start} AND ({start} + {limit}) - 1";
|
||||
|
||||
// Setup finalQuery with CTE
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($";WITH {innerQueryAlias} AS ");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("( ");
|
||||
sb.AppendLine(innerQuery.GetSql());
|
||||
sb.AppendLine(") ");
|
||||
sb.AppendLine(pagingQuery.GetSql(false));
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
using System.Text;
|
||||
using Strata.SqlTools.SqlBreakdown.Classes;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Arithmetic;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Conditional;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Conditional.Comparisons;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Conditional.Logical;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Functions;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Functions.Aggregate;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Functions.Conditional;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Literals;
|
||||
using Strata.SqlTools.SqlBreakdown.Interfaces.Core;
|
||||
|
||||
namespace Strata.SqlTools.Visitors.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Implements the visitor pattern to convert SQL expression objects into T-SQL (Microsoft SQL Server) compatible SQL command strings.
|
||||
/// This class traverses the expression tree and generates appropriate SQL syntax for SQL Server database.
|
||||
/// Can be inherited to support other SQL dialects by overriding dialect-specific formatting methods.
|
||||
/// </summary>
|
||||
public class CommandVisitor : IVisitor<string>
|
||||
{
|
||||
#region Dialect-Specific Formatting (Template Method Pattern)
|
||||
|
||||
/// <summary>
|
||||
/// Formats an identifier (table name, column name, alias) according to the SQL dialect.
|
||||
/// SQL Server uses square brackets. Override for other dialects.
|
||||
/// </summary>
|
||||
/// <param name="identifier">The identifier to format.</param>
|
||||
/// <returns>The formatted identifier.</returns>
|
||||
protected virtual string FormatIdentifier(string identifier) => $"[{identifier}]";
|
||||
|
||||
/// <summary>
|
||||
/// Formats a parameter name according to the SQL dialect.
|
||||
/// SQL Server uses @ prefix. Override for other dialects (e.g., : for Oracle/Snowflake).
|
||||
/// </summary>
|
||||
/// <param name="parameterName">The parameter name to format.</param>
|
||||
/// <returns>The formatted parameter reference.</returns>
|
||||
protected virtual string FormatParameterName(string parameterName) => $"@{parameterName}";
|
||||
|
||||
/// <summary>
|
||||
/// Formats a boolean literal according to the SQL dialect.
|
||||
/// SQL Server uses bit values (1/0). Override for dialects with TRUE/FALSE keywords.
|
||||
/// </summary>
|
||||
/// <param name="value">The boolean value to format.</param>
|
||||
/// <returns>The formatted boolean literal.</returns>
|
||||
protected virtual string FormatBooleanLiteral(bool value) => value ? "1" : "0";
|
||||
|
||||
/// <summary>
|
||||
/// Formats a string literal according to the SQL dialect, including escaping.
|
||||
/// SQL Server escapes single quotes by doubling them. Override for other escaping rules.
|
||||
/// </summary>
|
||||
/// <param name="value">The string value to format.</param>
|
||||
/// <returns>The formatted string literal with quotes.</returns>
|
||||
protected virtual string FormatStringLiteral(string value) => $"'{value.Replace("'", "''")}'";
|
||||
|
||||
/// <summary>
|
||||
/// Formats a case-insensitive LIKE expression according to the SQL dialect.
|
||||
/// SQL Server uses UPPER() wrapper. Override for dialects with ILIKE or other mechanisms.
|
||||
/// </summary>
|
||||
/// <param name="likeExpression">The LIKE expression to format.</param>
|
||||
/// <returns>The formatted case-insensitive LIKE expression.</returns>
|
||||
protected virtual string FormatCaseInsensitiveLike(LikeExpression likeExpression)
|
||||
{
|
||||
return $"UPPER({likeExpression.Subject.Accept(this)}) LIKE UPPER({likeExpression.Pattern.Accept(this)})";
|
||||
}
|
||||
|
||||
#endregion
|
||||
/// <summary>
|
||||
/// Visits a table source expression and generates the appropriate SQL identifier.
|
||||
/// Returns the alias if present, otherwise returns the fully qualified table name ([schema].[table]) or just the table name.
|
||||
/// </summary>
|
||||
/// <param name="tableSource">The table source expression to convert.</param>
|
||||
/// <returns>A SQL string representing the table identifier with SQL Server bracket notation.</returns>
|
||||
public virtual string VisitTableSource(TableSource tableSource)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tableSource.Alias))
|
||||
{
|
||||
return FormatIdentifier(tableSource.Alias);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tableSource.Schema))
|
||||
{
|
||||
return $"{FormatIdentifier(tableSource.Schema)}.{FormatIdentifier(tableSource.TableName)}";
|
||||
}
|
||||
|
||||
return FormatIdentifier(tableSource.TableName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits a column expression and generates a fully qualified column reference.
|
||||
/// </summary>
|
||||
/// <typeparam name="TSource">The type of the source (e.g., TableSource).</typeparam>
|
||||
/// <param name="column">The column expression to convert.</param>
|
||||
/// <returns>A SQL string in the format "[source].[columnName]".</returns>
|
||||
public virtual string VisitColumnExpression<TSource>(ColumnExpression<TSource> column) where TSource : SelectSource
|
||||
{
|
||||
var sourceName = column.Source.Accept(this);
|
||||
return $"{sourceName}.{FormatIdentifier(column.ColumnName)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits a SELECT clause column and generates the column expression with optional alias.
|
||||
/// </summary>
|
||||
/// <param name="selectClauseColumn">The SELECT clause column to convert.</param>
|
||||
/// <returns>A SQL string representing the column expression, with "AS [alias]" appended if an alias is specified.</returns>
|
||||
public virtual string VisitSelectClauseColumn(SelectClauseColumn selectClauseColumn)
|
||||
{
|
||||
var expr = selectClauseColumn.Expression.Accept(this);
|
||||
return !string.IsNullOrWhiteSpace(selectClauseColumn.Alias)
|
||||
? $"{expr} AS {FormatIdentifier(selectClauseColumn.Alias)}"
|
||||
: expr;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits a parameter expression and generates a T-SQL parameter reference.
|
||||
/// </summary>
|
||||
/// <param name="parameterExpression">The parameter expression to convert.</param>
|
||||
/// <returns>A SQL string in the format "@parameterName".</returns>
|
||||
public virtual string VisitParameterExpression(ParameterExpression parameterExpression)
|
||||
{
|
||||
return FormatParameterName(parameterExpression.ParameterName);
|
||||
}
|
||||
|
||||
#region Literal Expressions
|
||||
|
||||
/// <summary>
|
||||
/// Visits a numeric literal expression and converts it to a SQL number literal.
|
||||
/// </summary>
|
||||
/// <param name="numberLiteral">The number literal expression to convert.</param>
|
||||
/// <returns>A SQL string representing the numeric value.</returns>
|
||||
public string VisitNumberLiteralExpression(NumberLiteralExpression numberLiteral) =>
|
||||
$"{numberLiteral.Value}";
|
||||
|
||||
/// <summary>
|
||||
/// Visits a string literal expression and converts it to a SQL string literal with single quotes.
|
||||
/// Escapes single quotes within the string by doubling them.
|
||||
/// </summary>
|
||||
/// <param name="stringLiteral">The string literal expression to convert.</param>
|
||||
/// <returns>A SQL string literal enclosed in single quotes with escaped quotes.</returns>
|
||||
public virtual string VisitStringLiteralExpression(StringLiteralExpression stringLiteral) =>
|
||||
FormatStringLiteral(stringLiteral.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a DateTime literal expression and converts it to a SQL date or datetime literal.
|
||||
/// If the time component is zero, only the date is included (yyyy-MM-dd).
|
||||
/// Otherwise, the full datetime with milliseconds is included (yyyy-MM-dd HH:mm:ss.fff).
|
||||
/// </summary>
|
||||
/// <param name="dateTimeLiteral">The DateTime literal expression to convert.</param>
|
||||
/// <returns>A SQL datetime literal string enclosed in single quotes.</returns>
|
||||
public string VisitDateTimeLiteralExpression(DateTimeLiteralExpression dateTimeLiteral)
|
||||
{
|
||||
return dateTimeLiteral.Value.TimeOfDay == TimeSpan.Zero
|
||||
? $"'{dateTimeLiteral.Value:yyyy-MM-dd}'"
|
||||
: $"'{dateTimeLiteral.Value:yyyy-MM-dd HH:mm:ss.fff}'";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits a NULL literal expression and returns the SQL NULL keyword.
|
||||
/// </summary>
|
||||
/// <param name="nullLiteral">The NULL literal expression to convert.</param>
|
||||
/// <returns>The string "NULL".</returns>
|
||||
public string VisitNullLiteralExpression(NullLiteralExpression nullLiteral)
|
||||
=> "NULL";
|
||||
|
||||
/// <summary>
|
||||
/// Visits a boolean literal expression and converts it to T-SQL boolean representation (1 or 0).
|
||||
/// SQL Server does not have a native BOOLEAN type, so bit values are used.
|
||||
/// </summary>
|
||||
/// <param name="booleanLiteral">The boolean literal expression to convert.</param>
|
||||
/// <returns>The string "1" for true or "0" for false.</returns>
|
||||
public virtual string VisitBooleanLiteralExpression(BooleanLiteralExpression booleanLiteral) =>
|
||||
FormatBooleanLiteral(booleanLiteral.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a parameter literal expression and returns the parameter placeholder as-is.
|
||||
/// Supports positional ($1, $2), named with @, and named with : format.
|
||||
/// </summary>
|
||||
/// <param name="parameterLiteral">The parameter literal expression to convert.</param>
|
||||
/// <returns>The parameter placeholder string (e.g., "$1", "@userId", ":userId").</returns>
|
||||
public virtual string VisitParameterLiteralExpression(ParameterLiteralExpression parameterLiteral) =>
|
||||
parameterLiteral.Value;
|
||||
|
||||
|
||||
#pragma warning disable CS1570 // XML comment has badly formed XML
|
||||
/// <summary>
|
||||
/// Visits a symbol literal expression and returns the symbolic operator as-is.
|
||||
/// Used for database-specific operators like PostgreSQL's >=, &pipe;&pipe;, etc.
|
||||
/// </summary>
|
||||
/// <param name="symbolLiteral">The symbol literal expression to convert.</param>
|
||||
/// <returns>
|
||||
/// The symbolic operator string (e.g., ">=", "&pipe;&pipe;", "..").
|
||||
/// </returns>
|
||||
public virtual string VisitSymbolLiteralExpression(SymbolLiteralExpression symbolLiteral) =>
|
||||
symbolLiteral.Value;
|
||||
#pragma warning restore CS1570 // XML comment has badly formed XML
|
||||
|
||||
#endregion
|
||||
|
||||
#region Boolean Expressions
|
||||
|
||||
/// <summary>
|
||||
/// Visits a comparison expression and generates SQL comparison syntax (e.g., =, !=, >, <, >=, <=).
|
||||
/// </summary>
|
||||
/// <param name="comparison">The comparison expression to convert.</param>
|
||||
/// <returns>A SQL string in the format "expressionA operator expressionB".</returns>
|
||||
public string VisitComparisonExpression(ComparisonOperatorExpression comparison)
|
||||
{
|
||||
return $"{comparison.ExpressionA.Accept(this)} {comparison.Operator} {comparison.ExpressionB.Accept(this)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits an AND logical expression and generates SQL AND syntax.
|
||||
/// Automatically wraps OR and NOT expressions in parentheses for correct precedence.
|
||||
/// </summary>
|
||||
/// <param name="logical">The AND expression to convert.</param>
|
||||
/// <returns>A SQL string in the format "expressionA AND expressionB" with appropriate parentheses.</returns>
|
||||
public string VisitAndExpression(AndExpression logical)
|
||||
{
|
||||
var aExpSql = WrapInParenthesis(logical.ExpressionA, exp => exp is OrExpression or NotExpression);
|
||||
var bExpSql = WrapInParenthesis(logical.ExpressionB, exp => exp is OrExpression or NotExpression);
|
||||
|
||||
return $"{aExpSql} AND {bExpSql}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits an OR logical expression and generates SQL OR syntax.
|
||||
/// Automatically wraps AND and NOT expressions in parentheses for correct precedence.
|
||||
/// The second expression is placed on a new line for readability.
|
||||
/// </summary>
|
||||
/// <param name="logical">The OR expression to convert.</param>
|
||||
/// <returns>A SQL string in the format "expressionA OR \nexpressionB" with appropriate parentheses.</returns>
|
||||
public string VisitOrExpression(OrExpression logical)
|
||||
{
|
||||
var aExpSql = WrapInParenthesis(logical.ExpressionA, exp => exp is AndExpression or NotExpression);
|
||||
var bExpSql = WrapInParenthesis(logical.ExpressionB, exp => exp is AndExpression or NotExpression);
|
||||
|
||||
return $"{aExpSql} OR \n{bExpSql}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits a NOT logical expression and generates SQL NOT syntax.
|
||||
/// Automatically wraps AND and OR expressions in parentheses for correct precedence.
|
||||
/// </summary>
|
||||
/// <param name="logical">The NOT expression to convert.</param>
|
||||
/// <returns>A SQL string in the format "NOT expression" with appropriate parentheses.</returns>
|
||||
public string VisitNotExpression(NotExpression logical)
|
||||
{
|
||||
var aExpSql = WrapInParenthesis(logical.ExpressionA, exp => exp is AndExpression or OrExpression);
|
||||
|
||||
return $"NOT {aExpSql}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits an IN expression and generates SQL IN syntax for testing membership in a set of values.
|
||||
/// </summary>
|
||||
/// <param name="inExpression">The IN expression to convert.</param>
|
||||
/// <returns>A SQL string in the format "expression IN (value1, value2, ...)".</returns>
|
||||
public string VisitInExpression(InExpression inExpression)
|
||||
{
|
||||
return $"{inExpression.SearchExpression.Accept(this)} IN ({string.Join(", ", inExpression.ValuesToCompare.Select(v => v.Accept(this)))})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits a NOT IN expression and generates SQL NOT IN syntax for testing non-membership in a set of values.
|
||||
/// </summary>
|
||||
/// <param name="inExpression">The NOT IN expression to convert.</param>
|
||||
/// <returns>A SQL string in the format "expression NOT IN (value1, value2, ...)".</returns>
|
||||
public string VisitNotInExpression(NotInExpression inExpression)
|
||||
{
|
||||
return $"{inExpression.SearchExpression.Accept(this)} NOT IN ({string.Join(", ", inExpression.ValuesToCompare.Select(v => v.Accept(this)))})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits a LIKE expression and generates SQL LIKE syntax for pattern matching.
|
||||
/// For case-insensitive matching, wraps both the subject and pattern in UPPER() function calls.
|
||||
/// </summary>
|
||||
/// <param name="likeExpression">The LIKE expression to convert.</param>
|
||||
/// <returns>A SQL string in the format "expression LIKE pattern" or "UPPER(expression) LIKE UPPER(pattern)".</returns>
|
||||
public virtual string VisitLikeExpression(LikeExpression likeExpression)
|
||||
{
|
||||
if (likeExpression.CaseInsensitive)
|
||||
{
|
||||
return FormatCaseInsensitiveLike(likeExpression);
|
||||
}
|
||||
|
||||
return $"{likeExpression.Subject.Accept(this)} LIKE {likeExpression.Pattern.Accept(this)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits a NOT LIKE expression and generates SQL NOT LIKE syntax.
|
||||
/// </summary>
|
||||
/// <param name="notLikeExpression">The NOT LIKE expression to convert.</param>
|
||||
/// <returns>A SQL string in the format "NOT (expression LIKE pattern)" or "NOT (UPPER(expression) LIKE UPPER(pattern))".</returns>
|
||||
public string VisitNotLikeExpression(NotLikeExpression notLikeExpression) =>
|
||||
$"NOT ({VisitLikeExpression(notLikeExpression)})";
|
||||
|
||||
/// <summary>
|
||||
/// Visits a BETWEEN expression and generates SQL BETWEEN syntax for range testing.
|
||||
/// </summary>
|
||||
/// <param name="betweenExpression">The BETWEEN expression to convert.</param>
|
||||
/// <returns>A SQL string in the format "expression BETWEEN lowerBound AND upperBound".</returns>
|
||||
public string VisitBetweenExpression(BetweenExpression betweenExpression)
|
||||
{
|
||||
return $"{betweenExpression.Expression.Accept(this)} BETWEEN {betweenExpression.LowerBound.Accept(this)} AND {betweenExpression.UpperBound.Accept(this)}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Function Expressions
|
||||
|
||||
/// <summary>
|
||||
/// Visits an aggregate function expression (e.g., SUM, COUNT, AVG) and generates SQL aggregate function syntax.
|
||||
/// </summary>
|
||||
/// <param name="aggregateFunction">The aggregate function expression to convert.</param>
|
||||
/// <returns>A SQL string representing the aggregate function call.</returns>
|
||||
public string VisitAggregateFunctionExpression(AggregateFunctionExpression aggregateFunction) =>
|
||||
VisitFunctionExpression(aggregateFunction);
|
||||
|
||||
/// <summary>
|
||||
/// Visits a CASE expression and generates SQL CASE statement syntax with WHEN/THEN/ELSE clauses.
|
||||
/// Each condition-result pair is placed on a new line for readability.
|
||||
/// </summary>
|
||||
/// <param name="caseFunction">The CASE expression to convert.</param>
|
||||
/// <returns>A multi-line SQL string representing the CASE statement.</returns>
|
||||
public string VisitCaseFunctionExpression(CaseExpression caseFunction)
|
||||
{
|
||||
var sb = new StringBuilder("CASE\n");
|
||||
sb.AppendJoin("\n", caseFunction.ConditionResultPairs.Select(p => $" WHEN {p.condition.Accept(this)} THEN {p.result.Accept(this)}"));
|
||||
|
||||
if (caseFunction.ElseResultExpression is not null)
|
||||
{
|
||||
sb.Append($"\n ELSE {caseFunction.ElseResultExpression.Accept(this)}");
|
||||
}
|
||||
|
||||
sb.Append("\nEND");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits a generic function expression and generates SQL function call syntax.
|
||||
/// This is the base implementation for all function expressions.
|
||||
/// </summary>
|
||||
/// <param name="function">The function expression to convert.</param>
|
||||
/// <returns>A SQL string in the format "functionName(arg1, arg2, ...)".</returns>
|
||||
public virtual string VisitFunctionExpression(FunctionExpression function)
|
||||
{
|
||||
return $"{function.FunctionName}({string.Join(", ", function.Arguments.Select(e => e.Accept(this)))})";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Visits an arithmetic expression and generates SQL arithmetic operation syntax (+, -, *, /).
|
||||
/// Automatically wraps sub-expressions in parentheses when needed to maintain correct operator precedence.
|
||||
/// </summary>
|
||||
/// <param name="arithmeticExpression">The arithmetic expression to convert.</param>
|
||||
/// <returns>A SQL string representing the arithmetic operation with appropriate parentheses.</returns>
|
||||
public string VisitArithmeticExpression(ArithmeticExpression arithmeticExpression)
|
||||
{
|
||||
var aExpSql = WrapInParenthesis(arithmeticExpression.ExpressionA, expr => ShouldWrapArithmetic(arithmeticExpression, expr));
|
||||
var bExpSql = WrapInParenthesis(arithmeticExpression.ExpressionB, expr => ShouldWrapArithmetic(arithmeticExpression, expr));
|
||||
|
||||
return $"{aExpSql} {arithmeticExpression.ArithmeticOperator} {bExpSql}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visits an input property expression. This method is not implemented as input properties
|
||||
/// are typically not directly converted to SQL.
|
||||
/// </summary>
|
||||
/// <param name="inputPropertyExpression">The input property expression.</param>
|
||||
/// <returns>Throws NotImplementedException.</returns>
|
||||
/// <exception cref="NotImplementedException">This method is not implemented.</exception>
|
||||
public string VisitInputPropertyExpression(InputPropertyExpression inputPropertyExpression)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an arithmetic sub-expression should be wrapped in parentheses
|
||||
/// to maintain correct operator precedence (multiplication/division have higher precedence than addition/subtraction).
|
||||
/// </summary>
|
||||
/// <param name="arithmeticExpression">The parent arithmetic expression.</param>
|
||||
/// <param name="other">The sub-expression to evaluate.</param>
|
||||
/// <returns>True if the sub-expression should be wrapped in parentheses; otherwise, false.</returns>
|
||||
private static bool ShouldWrapArithmetic(ArithmeticExpression arithmeticExpression, Expression other)
|
||||
{
|
||||
return other switch
|
||||
{
|
||||
AdditionExpression or SubtractionExpression => arithmeticExpression is not (AdditionExpression or SubtractionExpression),
|
||||
MultiplicationExpression or DivisionExpression => arithmeticExpression is not (MultiplicationExpression or DivisionExpression),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an expression in parentheses if the provided predicate returns true.
|
||||
/// This is used to ensure correct operator precedence in generated SQL.
|
||||
/// </summary>
|
||||
/// <param name="expression">The expression to potentially wrap.</param>
|
||||
/// <param name="shouldWrap">A predicate function that determines if wrapping is needed.</param>
|
||||
/// <returns>The expression SQL with or without parentheses.</returns>
|
||||
private string WrapInParenthesis(Expression expression, Func<Expression, bool> shouldWrap)
|
||||
{
|
||||
return shouldWrap(expression)
|
||||
? $"({expression.Accept(this)})"
|
||||
: $"{expression.Accept(this)}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user