chore: initial git load of code space

This commit is contained in:
Thom Lamb
2026-05-12 08:52:33 -05:00
parent 9abada692f
commit 5e467bcc9c
384 changed files with 65960 additions and 2 deletions
@@ -0,0 +1,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 &gt;= start AND column &lt; 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 &gt;=, &pipe;&pipe;, etc.
/// </summary>
/// <param name="symbolLiteral">The symbol literal expression to convert.</param>
/// <returns>
/// The symbolic operator string (e.g., "&gt;=", "&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., =, !=, &gt;, &lt;, &gt;=, &lt;=).
/// </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)}";
}
}