using System.Collections;
using System.Text;
using Strata.SqlTools.SqlBreakdown.Classes;
using Strata.SqlTools.SqlBreakdown.Expressions;
using Strata.SqlTools.SqlBreakdown.Interfaces;
using Strata.SqlTools.SqlBreakdown.Interfaces.QueryEngine;
using Strata.SqlTools.SqlServer.Exceptions;
using Strata.SqlTools.Statements.SqlServer;
using Strata.SqlTools.Visitors.SqlServer;
namespace Strata.SqlTools.Breakdowns.SqlServer;
///
/// Represents a SELECT query breakdown with all clauses (SELECT, FROM, WHERE, GROUP BY, HAVING, ORDER BY).
///
#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 QueryBreakdown : SqlBreakdownBase, IQueryBreakdown
{
private const string ExpressionNullErrorMessage = "Expression cannot be null.";
private List _parameterList;
private List _withClauses;
protected readonly StatementParser Parser;
// Caching fields for GetClauses() performance optimization
private SqlClauses? _cachedClauses;
private bool _clausesCacheDirty;
// Backing fields for clause properties to support cache invalidation
#pragma warning disable S3604 // "Fields should not be write-only" - False positive: These fields are used as backing fields for properties that manage cache invalidation
private ISqlExpressionClause _selectClause = new SqlExpressionClause();
private ISqlClause _fromClause = new SqlClause();
private ISqlExpressionClause _whereClause = new SqlExpressionClause();
private ISqlExpressionClause _groupByClause = new SqlExpressionClause();
private ISqlExpressionClause _havingClause = new SqlExpressionClause();
private ISqlExpressionClause _orderByClause = new SqlExpressionClause();
#pragma warning restore S3604
///
/// Initializes a new instance of the class.
///
public QueryBreakdown() : base()
{
Parser = new StatementParser();
_parameterList = [];
_withClauses = [];
// Initialize backing fields directly to avoid triggering cache invalidation
_selectClause = new SqlExpressionClause(splitOnComma: true);
_fromClause = new SqlClause();
_whereClause = new SqlExpressionClause(splitOnComma: false);
_orderByClause = new SqlExpressionClause(splitOnComma: true);
_groupByClause = new SqlExpressionClause(splitOnComma: true);
_havingClause = new SqlExpressionClause(splitOnComma: false);
// Cache is dirty initially (will be built on first GetClauses() call)
_clausesCacheDirty = true;
}
///
/// Initializes a new instance of the class with SELECT and FROM clauses.
///
/// The SELECT clause.
/// The FROM clause.
public QueryBreakdown(string selectClause, string fromClause) : this()
{
var cleanSelect = Parser.ExtractSqlComments(selectClause, out var selectComments);
SelectClause.Clause = cleanSelect.Trim();
SelectClause.Comment = selectComments.Count > 0 ? string.Join(" ", selectComments) : null;
var cleanFrom = Parser.ExtractSqlComments(fromClause, out var fromComments);
FromClause.Clause = cleanFrom.Trim();
FromClause.Comment = fromComments.Count > 0 ? string.Join(" ", fromComments) : null;
}
///
/// Initializes a new instance of the class with SELECT, FROM, and WHERE clauses.
///
/// The SELECT clause.
/// The FROM clause.
/// The WHERE clause.
public QueryBreakdown(string selectClause, string fromClause, string whereClause) : this(selectClause, fromClause)
{
if (!string.IsNullOrEmpty(whereClause))
{
var cleanWhere = Parser.ExtractSqlComments(whereClause, out var whereComments);
WhereClause.Clause = cleanWhere.Trim();
WhereClause.Comment = whereComments.Count > 0 ? string.Join(" ", whereComments) : null;
}
}
///
/// Initializes a new instance of the class with SELECT, FROM, WHERE, and ORDER BY clauses.
///
/// The SELECT clause.
/// The FROM clause.
/// The WHERE clause.
/// The ORDER BY clause.
public QueryBreakdown(string selectClause, string fromClause, string whereClause, string orderByClause)
: this(selectClause, fromClause, whereClause)
{
if (!string.IsNullOrEmpty(orderByClause))
{
var cleanOrderBy = Parser.ExtractSqlComments(orderByClause, out var orderByComments);
OrderByClause.Clause = cleanOrderBy.Trim();
OrderByClause.Comment = orderByComments.Count > 0 ? string.Join(" ", orderByComments) : null;
}
}
#region Properties
///
/// Gets the list of query parameters.
///
public IEnumerable ParameterList => _parameterList;
///
/// Gets the parameter dictionary containing parameter names and their values.
/// Parameter values can be set/updated after parsing.
///
public Dictionary Parameters { get; } = [];
///
/// Gets the WITH clauses (Common Table Expressions) as an ordered list.
///
public IReadOnlyList WithClauses => _withClauses.AsReadOnly();
///
/// Gets or sets the WITH clause string for backward compatibility with parsing.
///
protected internal string? WithClause { get; set; }
///
/// Gets the WITH clause value (for derived classes to access).
///
public string? GetWithClauseValue() => WithClause;
///
/// Sets the WITH clause value (for derived classes to set).
///
public void SetWithClauseValue(string? value) => WithClause = value;
///
/// Gets a value indicating whether WITH clauses are being used.
///
public bool IsUsingWithClause => _withClauses.Count > 0 || !string.IsNullOrEmpty(WithClause);
///
/// Gets or sets the SELECT clause with optional comment.
///
public ISqlExpressionClause SelectClause
{
get => _selectClause;
set
{
_selectClause = value;
InvalidateClausesCache();
}
}
///
/// Gets a value indicating whether a FROM clause is being used.
///
public bool IsUsingFromClause => !string.IsNullOrEmpty(FromClause.Clause);
///
/// Gets or sets the FROM clause with optional comment.
///
public ISqlClause FromClause
{
get => _fromClause;
set
{
_fromClause = value;
InvalidateClausesCache();
}
}
///
/// Gets a value indicating whether a GROUP BY clause is being used.
///
public bool IsUsingGroupByClause => !string.IsNullOrEmpty(GroupByClause.Clause);
///
/// Gets or sets the GROUP BY clause with optional comment.
///
public ISqlExpressionClause GroupByClause
{
get => _groupByClause;
set
{
_groupByClause = value;
InvalidateClausesCache();
}
}
///
/// Gets a value indicating whether a WHERE clause is being used.
///
public bool IsUsingWhereClause => !string.IsNullOrEmpty(WhereClause.Clause);
///
/// Gets or sets the WHERE clause with optional comment.
///
public ISqlExpressionClause WhereClause
{
get => _whereClause;
set
{
_whereClause = value;
InvalidateClausesCache();
}
}
///
/// Gets or sets the ORDER BY clause with optional comment.
///
public ISqlExpressionClause OrderByClause
{
get => _orderByClause;
set
{
_orderByClause = value;
InvalidateClausesCache();
}
}
///
/// Gets a value indicating whether an ORDER BY clause is being used.
///
public bool IsUsingOrderByClause => !string.IsNullOrEmpty(OrderByClause.Clause);
///
/// Gets or sets the HAVING clause with optional comment.
///
public ISqlExpressionClause HavingClause
{
get => _havingClause;
set
{
_havingClause = value;
InvalidateClausesCache();
}
}
///
/// Gets a value indicating whether a HAVING clause is being used.
///
public bool IsUsingHavingClause => !string.IsNullOrEmpty(HavingClause.Clause);
#endregion
#region Methods
///
/// Adds a parameter to the query.
///
/// The parameter name.
/// The parameter value.
public void AddParameter(string parameterName, object value)
{
_parameterList.Add(new QueryParam(parameterName, value));
// Also add/update in dictionary
if (parameterName.StartsWith('@'))
{
Parameters[parameterName] = value;
}
else
{
Parameters[$"@{parameterName}"] = value;
}
}
///
/// Adds multiple parameters to the query.
///
/// The query parameters to add.
public void AddParameter(IEnumerable queryParams)
{
_parameterList.AddRange(queryParams);
// Also add/update in dictionary
foreach (var param in queryParams)
{
var name = param.Name.StartsWith('@') ? param.Name : $"@{param.Name}";
Parameters[name] = param.Value;
}
}
///
/// Sets the value of a parameter in the Parameters dictionary.
/// If the parameter doesn't exist, it will be added.
///
/// The parameter name (with or without @).
/// The parameter value.
public void SetParameterValue(string parameterName, object value)
{
var name = parameterName.StartsWith('@') ? parameterName : $"@{parameterName}";
Parameters[name] = value;
// Update or add to parameter list
var existingParam = _parameterList.FirstOrDefault(p => p.Name == name);
if (existingParam != null)
{
_parameterList.Remove(existingParam);
}
_parameterList.Add(new QueryParam(name, value));
}
///
/// Gets the value of a parameter from the Parameters dictionary.
///
/// The parameter name (with or without @).
/// The parameter value, or null if not found.
public object? GetParameterValue(string parameterName)
{
var name = parameterName.StartsWith('@') ? parameterName : $"@{parameterName}";
return Parameters.TryGetValue(name, out var value) ? value : null;
}
///
/// Adds or updates a parameter with value validation.
/// If the parameter exists with a non-null value and the new value is of a different type, throws an exception.
/// If the parameter exists with a null value, sets the value.
/// If the parameter exists with a non-null value of the same type, keeps the existing value.
/// If the parameter doesn't exist, adds it.
///
/// The parameter name (with or without @ or :).
/// The parameter value.
/// Thrown when trying to set a parameter to a different type than its existing value.
protected void AddOrUpdateParameter(string parameterName, object? value)
{
// Normalize parameter name - keep : or @ prefix if present, otherwise add @
string name;
if (parameterName.StartsWith(':') || parameterName.StartsWith('@'))
{
name = parameterName;
}
else
{
name = $"@{parameterName}";
}
if (Parameters.TryGetValue(name, out var existingValue))
{
// Parameter already exists
if (existingValue != null && value != null)
{
// Both existing and new values are non-null, check types
var existingType = existingValue.GetType();
var newType = value.GetType();
if (existingType != newType)
{
throw new InvalidOperationException(
$"Parameter '{name}' already exists with type '{existingType.Name}' but attempted to set it with type '{newType.Name}'.");
}
// Same type - keep existing value (don't update)
// This is important for AddWhereClause behavior where extracted params are null
}
else if (existingValue == null && value != null)
{
// Existing value is null, new value is not null - set it
Parameters[name] = value;
}
// If new value is null, keep existing value (don't overwrite)
}
else
{
// Parameter doesn't exist, add it
Parameters[name] = value!;
}
}
///
/// Extracts parameters from a SQL clause and adds them to the Parameters dictionary.
///
/// The SQL clause to extract parameters from.
protected void ExtractAndAddParameters(string sql)
{
if (string.IsNullOrWhiteSpace(sql))
{
return;
}
// Create a temporary dictionary to extract parameters
var tempParams = new Dictionary();
Parser.ExtractParameters(tempParams, sql);
// Add each parameter using the managed add method
foreach (var kvp in tempParams)
{
AddOrUpdateParameter(kvp.Key, kvp.Value);
}
}
///
/// Invalidates the GetClauses() cache, forcing a fresh SqlClauses object on the next call.
/// Called automatically whenever any clause property is modified.
///
private void InvalidateClausesCache()
{
_clausesCacheDirty = true;
_cachedClauses = null;
}
///
/// Gets the SQL clauses from this query breakdown.
///
/// A SqlClauses object containing the current clause properties.
///
/// This method uses caching to improve performance. The cached result is invalidated
/// whenever any clause property is modified.
///
public virtual SqlClauses GetClauses()
{
if (_clausesCacheDirty || _cachedClauses == null)
{
_cachedClauses = new SqlClauses
{
SelectClause = SelectClause,
FromClause = FromClause,
WhereClause = WhereClause,
GroupByClause = GroupByClause,
HavingClause = HavingClause,
OrderByClause = OrderByClause
};
_clausesCacheDirty = false;
}
return _cachedClauses;
}
///
/// Applies SQL clauses from a SqlClauses object to this query breakdown.
/// Only non-null clauses are applied.
///
/// The SQL clauses to apply.
public virtual void ApplyClauses(SqlClauses? clauses)
{
if (clauses == null)
{
return;
}
if (clauses.SelectClause != null)
{
SelectClause = clauses.SelectClause;
}
if (clauses.FromClause != null)
{
FromClause = clauses.FromClause;
}
if (clauses.WhereClause != null)
{
WhereClause = clauses.WhereClause;
}
if (clauses.GroupByClause != null)
{
GroupByClause = clauses.GroupByClause;
}
if (clauses.HavingClause != null)
{
HavingClause = clauses.HavingClause;
}
if (clauses.OrderByClause != null)
{
OrderByClause = clauses.OrderByClause;
}
}
///
/// Collects all parameters from the CTE hierarchy recursively.
/// This method traverses all WITH clauses and their nested queries to collect parameters.
///
/// Dictionary to collect parameters (passed recursively).
///
/// Parameters from nested CTEs are collected using TryAdd, so existing parameters in the main query
/// take precedence over CTE parameters with the same name.
///
protected virtual void CollectCteParameters(Dictionary allCollectedParams)
{
// First add CTE parameters (so main query parameters can override them)
foreach (var withClause in _withClauses)
{
if (withClause.Query == null)
{
continue;
}
CollectFromCteQuery(withClause.Query, allCollectedParams);
// For recursive CTEs, also collect parameters from the recursive query
if (withClause.IsRecursive)
{
CollectFromCteQuery(withClause.RecursiveQuery, allCollectedParams);
}
}
}
///
/// Recursively collects parameters from a single CTE query and its nested CTEs.
/// Parameters are added with an '@' prefix via TryAdd, so existing entries take precedence.
///
private static void CollectFromCteQuery(IQueryBreakdown? query, Dictionary allCollectedParams)
{
if (query == null)
{
return;
}
// Recursively collect parameters from nested CTEs in this query
if (query is QueryBreakdown nestedQueryBreakdown)
{
nestedQueryBreakdown.CollectCteParameters(allCollectedParams);
}
// Add the query's own parameters (TryAdd means existing params take precedence)
foreach (var param in query.ParameterList)
{
var paramName = param.Name.StartsWith('@') ? param.Name : $"@{param.Name}";
allCollectedParams.TryAdd(paramName, param.Value);
}
}
///
/// Gets all parameters including those from the CTE hierarchy.
/// The main query's parameters take precedence over CTE parameters with the same name.
///
/// A dictionary containing all parameters merged from the CTE hierarchy and main query.
public virtual Dictionary GetMergedParameters()
{
var mergedParams = new Dictionary();
// First collect CTE parameters
CollectCteParameters(mergedParams);
// Then add/override with main query parameters
foreach (var param in Parameters)
{
mergedParams[param.Key] = param.Value;
}
return mergedParams;
}
#pragma warning disable S3776 // Cognitive Complexity of methods should not be too high
///
/// Gets the SQL breakdown as a string (query-specific implementation).
///
/// The SELECT SQL statement.
protected override string GetSqlBreakdown()
#pragma warning restore S3776
{
var sb = new StringBuilder();
if (IsUsingWithClause)
{
// Check if any CTE is recursive - if so, add RECURSIVE keyword
bool hasRecursive = _withClauses.Any(wc => wc.IsRecursive);
sb.Append("WITH ");
if (hasRecursive)
{
sb.AppendLine("RECURSIVE");
}
else
{
sb.AppendLine();
}
for (int i = 0; i < _withClauses.Count; i++)
{
var withClause = _withClauses[i];
var anchorSql = withClause.Query?.GetSql(includeSetupFinish: false).Trim() ?? string.Empty;
if (i > 0)
{
sb.Append(',');
sb.AppendLine();
}
// Include comment if present
if (!string.IsNullOrWhiteSpace(withClause.Comment))
{
sb.AppendLine($" {withClause.Comment}");
}
// Write CTE name with optional column list
var cteName = withClause.TableName;
if (withClause.ColumnList != null && withClause.ColumnList.Count > 0)
{
var columnList = string.Join(", ", withClause.ColumnList);
cteName = $"{withClause.TableName} ({columnList})";
}
sb.AppendLine($" {cteName} AS (");
if (withClause.IsRecursive && withClause.RecursiveQuery != null)
{
// For recursive CTEs: anchor query UNION ALL recursive query
var recursiveSql = withClause.RecursiveQuery.GetSql(includeSetupFinish: false).Trim();
sb.AppendLine($" {anchorSql}");
sb.AppendLine(" UNION ALL");
sb.AppendLine($" {recursiveSql}");
}
else
{
// For non-recursive CTEs: just the single query
sb.AppendLine($" {anchorSql}");
}
sb.Append(" )");
}
sb.AppendLine();
}
sb.AppendLine("SELECT ");
if (!string.IsNullOrEmpty(SelectClause.Comment))
{
sb.AppendLine($" {SelectClause.Comment}");
}
sb.AppendLine($" {SelectClause.Clause}");
if (IsUsingFromClause)
{
sb.AppendLine("FROM ");
if (!string.IsNullOrEmpty(FromClause.Comment))
{
sb.AppendLine($" {FromClause.Comment}");
}
sb.AppendLine($" {FromClause.Clause}");
}
if (IsUsingWhereClause)
{
sb.AppendLine("WHERE ");
if (!string.IsNullOrEmpty(WhereClause.Comment))
{
sb.AppendLine($" {WhereClause.Comment}");
}
sb.AppendLine($" {WhereClause.Clause}");
}
if (IsUsingGroupByClause)
{
sb.AppendLine("GROUP BY ");
if (!string.IsNullOrEmpty(GroupByClause.Comment))
{
sb.AppendLine($" {GroupByClause.Comment}");
}
sb.AppendLine($" {GroupByClause.Clause}");
}
if (IsUsingHavingClause)
{
sb.AppendLine("HAVING ");
if (!string.IsNullOrEmpty(HavingClause.Comment))
{
sb.AppendLine($" {HavingClause.Comment}");
}
sb.AppendLine($" {HavingClause.Clause}");
}
if (IsUsingOrderByClause)
{
sb.AppendLine("ORDER BY ");
if (!string.IsNullOrEmpty(OrderByClause.Comment))
{
sb.AppendLine($" {OrderByClause.Comment}");
}
sb.AppendLine($" {OrderByClause.Clause}");
}
return sb.ToString();
}
///
/// Gets the complete SQL query including optional setup and finish clauses (for backward compatibility).
///
/// Whether to include setup and finish clauses.
/// The complete SQL query string.
public new virtual string GetSql(bool includeSetupFinish = true)
{
return base.GetSql(includeSetupFinish);
}
///
/// Creates a deep clone of this query breakdown.
///
/// A cloned instance.
public new object Clone()
{
// Start with base clone (handles SetupClauses and FinishClauses)
var clone = (QueryBreakdown)base.Clone();
// Deep copy query-specific properties
clone.SelectClause = new SqlExpressionClause(splitOnComma: true) { Clause = SelectClause.Clause, Comment = SelectClause.Comment };
clone.FromClause = new SqlClause { Clause = FromClause.Clause, Comment = FromClause.Comment };
clone.WhereClause = new SqlExpressionClause(splitOnComma: false) { Clause = WhereClause.Clause, Comment = WhereClause.Comment };
clone.GroupByClause = new SqlExpressionClause(splitOnComma: true) { Clause = GroupByClause.Clause, Comment = GroupByClause.Comment };
clone.HavingClause = new SqlExpressionClause(splitOnComma: false) { Clause = HavingClause.Clause, Comment = HavingClause.Comment };
clone.OrderByClause = new SqlExpressionClause(splitOnComma: true) { Clause = OrderByClause.Clause, Comment = OrderByClause.Comment };
clone.WithClause = WithClause;
// Deep copy parameter list
clone._parameterList = new List(_parameterList.Select(p => new QueryParam(p.Name, p.Value)));
// Deep copy with clauses (recursive cloning for nested queries)
clone._withClauses = new List(
_withClauses.Select(w =>
{
// Clone the query if it implements ICloneable, otherwise use the original reference
var clonedQuery = w.Query is ICloneable cloneable
? (IQueryBreakdown)cloneable.Clone()
: w.Query;
return new WithClause(w.TableName, clonedQuery!) { Clause = w.Clause, Comment = w.Comment, Sql = w.Sql };
}));
// Deep copy parameters dictionary
foreach (var kvp in Parameters)
{
clone.Parameters[kvp.Key] = kvp.Value;
}
return clone;
}
///
/// Gets a LINQ to SQL query of the specified type based on this breakdown.
///
/// The entity type for the query.
/// An empty queryable by default, as QueryBreakdown operates on SQL. Override in derived classes to provide LINQ query reconstruction.
///
/// This base implementation returns an empty queryable since QueryBreakdown represents parsed SQL statements
/// and has no built-in way to create LINQ queries.
/// Derived classes (such as LinqQueryBreakdown) can override this method to reconstruct LINQ queries
/// from the analyzed components.
///
public override IQueryable GetQuery() where T : class => Enumerable.Empty().AsQueryable();
///
/// Gets the complete SQL query string (interface implementation).
///
/// Whether to include setup and finish clauses.
/// The SQL query string.
public string GetSQL(bool includeSetupFinish = true) => GetSql(includeSetupFinish);
///
/// Merges another query breakdown into this one.
///
/// The query to merge.
#pragma warning disable S3776 // Cognitive Complexity of methods should not be too high
public void MergeWith(IQueryBreakdown query)
#pragma warning restore S3776
{
FromClause.Clause = $"{FromClause.Clause} {query.FromClause.Clause}";
if (query.IsUsingWhereClause)
{
// Ensure join clause is present
if (!string.IsNullOrEmpty(WhereClause.Clause) && !string.IsNullOrEmpty(query.WhereClause.Clause))
{
string trimmedWhere = query.WhereClause.Clause.Trim().ToUpperInvariant();
if (!trimmedWhere.Equals("AND") && !trimmedWhere.Equals("OR") &&
!trimmedWhere.StartsWith("AND ") && !trimmedWhere.StartsWith("OR "))
{
string currentWhereTrimmed = WhereClause.Clause.Trim().ToUpperInvariant();
if (!currentWhereTrimmed.EndsWith(" AND") && !currentWhereTrimmed.EndsWith(" OR"))
{
WhereClause.Clause += " AND ";
}
}
}
WhereClause.Clause = $"{WhereClause.Clause} {query.WhereClause.Clause}";
}
if (query.IsUsingSetupClause)
{
SetupClauses.AddRange(query.SetupClauses);
}
if (query.IsUsingFinishClause)
{
FinishClauses.AddRange(query.FinishClauses);
}
foreach (var param in query.ParameterList)
{
AddParameter(param.Name, param.Value);
}
}
///
/// Adds a WHERE clause condition.
///
/// The SQL condition to add.
public void AddWhereClause(string sql)
{
if (string.IsNullOrWhiteSpace(sql))
{
return;
}
AddWhereClause(sql, "and");
}
///
/// Adds a WHERE clause condition with a specific logical operation.
/// Extracts and preserves any SQL comments in the clause.
/// Automatically extracts parameters from the WHERE clause and adds them to the Parameters dictionary.
///
/// The SQL condition to add.
/// The logical operation ("and" or "or").
public virtual void AddWhereClause(string sql, string operation)
{
if (string.IsNullOrWhiteSpace(sql))
{
return;
}
// Extract comments from the incoming SQL
var cleanSql = Parser.ExtractSqlComments(sql, out var comments);
var commentText = comments.Count > 0 ? string.Join(" ", comments) : null;
AppendToClause(WhereClause, cleanSql.Trim(), operation, commentText);
// Extract and add parameters from the WHERE clause
ExtractAndAddParameters(cleanSql);
}
///
/// Adds an expression to the SELECT clause.
///
/// The expression to add.
/// Optional comment to add with the expression.
public void AddSelectExpression(Expression expression, string? comment = null)
{
if (expression is null)
{
throw new ArgumentNullException(nameof(expression), ExpressionNullErrorMessage);
}
var visitor = new CommandVisitor();
var sql = expression.Accept(visitor);
if (string.IsNullOrWhiteSpace(SelectClause.Clause))
{
SelectClause.Clause = sql;
}
else
{
SelectClause.Clause = $"{SelectClause.Clause}, {sql}";
}
if (!string.IsNullOrWhiteSpace(comment))
{
if (string.IsNullOrWhiteSpace(SelectClause.Comment))
{
SelectClause.Comment = comment;
}
else
{
SelectClause.Comment = $"{SelectClause.Comment} {comment}";
}
}
}
///
/// Adds an expression to the WHERE clause.
///
/// The expression to add.
/// Optional comment to add with the expression.
/// The logical operation ("and" or "or"). Defaults to "and".
public void AddWhereExpression(Expression expression, string? comment = null, string operation = "and")
{
if (expression is null)
{
throw new ArgumentNullException(nameof(expression), ExpressionNullErrorMessage);
}
var visitor = new CommandVisitor();
AppendToClause(WhereClause, expression.Accept(visitor), operation, comment);
}
///
/// Adds an expression to the GROUP BY clause.
///
/// The expression to add.
/// Optional comment to add with the expression.
public virtual void AddGroupByExpression(Expression expression, string? comment = null)
{
if (expression is null)
{
throw new ArgumentNullException(nameof(expression), ExpressionNullErrorMessage);
}
var visitor = new CommandVisitor();
var sql = expression.Accept(visitor);
if (string.IsNullOrWhiteSpace(GroupByClause.Clause))
{
GroupByClause.Clause = sql;
}
else
{
GroupByClause.Clause = $"{GroupByClause.Clause}, {sql}";
}
if (!string.IsNullOrWhiteSpace(comment))
{
if (string.IsNullOrWhiteSpace(GroupByClause.Comment))
{
GroupByClause.Comment = comment;
}
else
{
GroupByClause.Comment = $"{GroupByClause.Comment} {comment}";
}
}
}
///
/// Adds an expression to the ORDER BY clause.
///
/// The expression to add.
/// Optional comment to add with the expression.
public virtual void AddOrderByExpression(Expression expression, string? comment = null)
{
if (expression is null)
{
throw new ArgumentNullException(nameof(expression), ExpressionNullErrorMessage);
}
var visitor = new CommandVisitor();
var sql = expression.Accept(visitor);
if (string.IsNullOrWhiteSpace(OrderByClause.Clause))
{
OrderByClause.Clause = sql;
}
else
{
OrderByClause.Clause = $"{OrderByClause.Clause}, {sql}";
}
if (!string.IsNullOrWhiteSpace(comment))
{
if (string.IsNullOrWhiteSpace(OrderByClause.Comment))
{
OrderByClause.Comment = comment;
}
else
{
OrderByClause.Comment = $"{OrderByClause.Comment} {comment}";
}
}
}
///
/// Adds an expression to the HAVING clause.
///
/// The expression to add.
/// Optional comment to add with the expression.
/// The logical operation ("and" or "or"). Defaults to "and".
public virtual void AddHavingExpression(Expression expression, string? comment = null, string operation = "and")
{
if (expression is null)
{
throw new ArgumentNullException(nameof(expression), ExpressionNullErrorMessage);
}
var visitor = new CommandVisitor();
AppendToClause(HavingClause, expression.Accept(visitor), operation, comment);
}
///
/// Adds a Common Table Expression (CTE) to the WITH clause.
///
/// The table name for the WITH clause.
/// The query breakdown representing the WITH table.
/// Thrown when CTE validation fails.
///
///
/// CTEs inherit parameters from their parent query. When the same parameter name exists in both the CTE
/// and the main query, the main query's parameter value takes precedence. This allows CTEs to reference
/// parameters from the outer scope while permitting the main query to override them.
///
///
/// Duplicate CTE names (case-insensitive) are not allowed within the same query.
///
///
///
///
/// var cteQuery = new QueryBreakdown()
/// .Select("id, name")
/// .From("users")
/// .Where("active = 1");
/// mainQuery.AddWithClause("active_users", cteQuery);
///
///
public void AddWithClause(string withTableName, IQueryBreakdown withTableQuery)
{
// Validate table name
if (string.IsNullOrWhiteSpace(withTableName))
{
throw new CteValidationException(
"CTE table name cannot be null, empty, or whitespace.",
withTableName,
"TableNameRequired");
}
// Validate query
if (withTableQuery == null)
{
throw new CteValidationException(
"CTE query cannot be null. Provide a valid IQueryBreakdown instance.",
withTableName,
"QueryRequired");
}
// Check for duplicate CTE names
if (_withClauses.Any(c => c.TableName.Equals(withTableName, StringComparison.OrdinalIgnoreCase)))
{
throw new CteValidationException(
$"A CTE with the name '{withTableName}' already exists in this query. Each CTE name must be unique.",
withTableName,
"DuplicateCteName");
}
// Create and add a WithClause object
var withClause = new WithClause(withTableName, withTableQuery);
_withClauses.Add(withClause);
}
///
/// Adds a Common Table Expression (CTE) with full WithClause configuration to the WITH clause.
/// This overload allows setting IsRecursive, RecursiveQuery, and ColumnList properties.
///
/// The WithClause object containing the CTE configuration.
/// Thrown when CTE validation fails.
///
///
/// Use this overload when you need advanced CTE features:
///
/// - Recursive CTEs (set IsRecursive = true and provide RecursiveQuery)
/// - Explicit column lists (set ColumnList to define output column names)
/// - Complex CTE configurations requiring fine-grained control
///
///
///
/// Parameter inheritance follows the same rules as other AddWithClause overloads: main query parameters
/// take precedence over CTE parameters with the same name.
///
///
///
///
/// var withClause = new WithClause("active_users", cteQuery)
/// {
/// ColumnList = new List<string> { "id", "name", "email" },
/// IsRecursive = false
/// };
/// mainQuery.AddWithClause(withClause);
///
///
public void AddWithClause(IWithClause withClause)
{
if (withClause == null)
{
throw new CteValidationException(
"WithClause cannot be null. Provide a valid IWithClause instance.",
null,
"QueryRequired");
}
if (string.IsNullOrWhiteSpace(withClause.TableName))
{
throw new CteValidationException(
"CTE table name cannot be null, empty, or whitespace.",
withClause.TableName,
"TableNameRequired");
}
// Check for duplicate CTE names
if (_withClauses.Any(c => c.TableName.Equals(withClause.TableName, StringComparison.OrdinalIgnoreCase)))
{
throw new CteValidationException(
$"A CTE with the name '{withClause.TableName}' already exists in this query. Each CTE name must be unique.",
withClause.TableName,
"DuplicateCteName");
}
// Validate that either Query or Sql is set
if (withClause.Query == null && withClause.Sql == null)
{
throw new CteValidationException(
$"CTE '{withClause.TableName}' must have either a Query or Sql property set.",
withClause.TableName,
"QueryRequired");
}
// Add the provided WithClause object directly
_withClauses.Add((WithClause)withClause);
}
///
/// Adds a Common Table Expression (CTE) to the WITH clause using a raw SQL string.
/// The SQL will be parsed into a QueryBreakdown object.
///
/// The table name for the WITH clause.
/// The SQL query for the WITH table.
/// If true, uses Microsoft T-SQL parsing rules. Defaults to true.
/// Thrown when CTE validation fails.
/// Thrown when SQL parsing fails.
///
///
/// This is a convenience overload for adding CTEs when you have a SQL string. The SQL is parsed
/// automatically using . If parsing fails, a
/// is thrown with position information and context to help diagnose the issue.
///
///
/// Parameter inheritance: Parameters defined in the CTE SQL can be referenced by the main query.
/// If the same parameter exists in both the CTE and main query, the main query's value takes precedence.
///
///
///
///
/// mainQuery.AddWithClause("active_users", "SELECT id, name FROM users WHERE active = 1");
///
///
public virtual void AddWithClause(string withTableName, string withTableSql, bool isMicrosoftSql = true)
{
// Validate table name
if (string.IsNullOrWhiteSpace(withTableName))
{
throw new CteValidationException(
"CTE table name cannot be null, empty, or whitespace.",
withTableName,
"TableNameRequired");
}
// Validate SQL
if (string.IsNullOrWhiteSpace(withTableSql))
{
throw new CteValidationException(
$"CTE '{withTableName}' SQL cannot be null, empty, or whitespace.",
withTableName,
"QueryRequired");
}
// Check for duplicate CTE names
if (_withClauses.Any(c => c.TableName.Equals(withTableName, StringComparison.OrdinalIgnoreCase)))
{
throw new CteValidationException(
$"A CTE with the name '{withTableName}' already exists in this query. Each CTE name must be unique.",
withTableName,
"DuplicateCteName");
}
// Parse the SQL string into a QueryBreakdown object
QueryBreakdown parsedQuery;
try
{
parsedQuery = QueryBreakdown.Parse(withTableSql);
}
catch (Exception ex)
{
throw new SqlParseException(
$"Failed to parse SQL for CTE '{withTableName}'.",
withTableSql,
0,
ex);
}
// Create and add the WithClause
var withClause = new WithClause(withTableName, parsedQuery);
_withClauses.Add(withClause);
}
///
/// Parses the SELECT clause into a collection of Expression objects.
/// Each column/expression in the SELECT list is parsed separately.
///
/// An enumerable collection of parsed Expression objects from the SELECT clause.
/// Thrown when the SELECT clause cannot be parsed.
public virtual IEnumerable GetSelectExpressions()
=> SelectClause.GetExpressions(CreateExpressionParser());
///
/// Parses the WHERE clause into an Expression object.
///
/// A parsed Expression object from the WHERE clause, or null if WHERE clause is empty.
/// Thrown when the WHERE clause cannot be parsed.
public virtual Expression? GetWhereExpression()
=> WhereClause.GetExpressions(CreateExpressionParser()).FirstOrDefault();
///
/// Parses the HAVING clause into an Expression object.
///
/// A parsed Expression object from the HAVING clause, or null if HAVING clause is empty.
/// Thrown when the HAVING clause cannot be parsed.
public virtual Expression? GetHavingExpression()
=> HavingClause.GetExpressions(CreateExpressionParser()).FirstOrDefault();
///
/// Creates the appropriate statement expression parser for this query type.
/// Override in derived classes to provide dialect-specific parsers.
///
/// An IStatementExpressionParser instance.
protected virtual IStatementExpressionParser CreateExpressionParser()
=> new StatementExpressionParser();
#endregion
#region Parsing Methods
///
/// Parses a T-SQL SELECT statement into a QueryBreakdown object.
///
/// The T-SQL SELECT statement to parse.
/// A QueryBreakdown object representing the parsed query.
/// Thrown when sql is null or empty.
/// Thrown when the SQL statement cannot be parsed.
///
///
/// var query = QueryBreakdown.Parse("SELECT id, name FROM users WHERE active = 1");
/// Console.WriteLine(query.GetSql());
///
///
public static QueryBreakdown Parse(string sql)
{
if (string.IsNullOrWhiteSpace(sql))
{
throw new ArgumentNullException(nameof(sql), "SQL statement cannot be null, empty, or whitespace.");
}
if (!TryParse(sql, out var result, out var error))
{
// Try to find approximate error position (if available in error message)
var position = 0;
// Look for common parse error patterns that might contain position info
if (error.Contains("position", StringComparison.OrdinalIgnoreCase))
{
// Try to extract position from error message
var match = System.Text.RegularExpressions.Regex.Match(error, @"position[:\s]+(\d+)",
System.Text.RegularExpressions.RegexOptions.IgnoreCase, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout);
if (match.Success && int.TryParse(match.Groups[1].Value, out var parsedPos))
{
position = parsedPos;
}
}
throw new SqlParseException(
$"Failed to parse SQL statement: {error}",
sql,
position);
}
return result;
}
///
/// Attempts to parse a T-SQL SELECT statement into a QueryBreakdown object.
///
/// The T-SQL SELECT statement to parse.
/// When this method returns, contains the parsed QueryBreakdown if successful, or null if parsing failed.
/// true if the SQL was successfully parsed; otherwise, false.
public static bool TryParse(string sql, out QueryBreakdown result)
=> TryParse(sql, out result, out _);
///
/// Attempts to parse a T-SQL SELECT statement into a QueryBreakdown object.
///
/// The T-SQL SELECT statement to parse.
/// When this method returns, contains the parsed QueryBreakdown if successful, or null if parsing failed.
/// When this method returns false, contains a message describing why parsing failed.
/// true if the SQL was successfully parsed; otherwise, false.
public static bool TryParse(string sql, out QueryBreakdown result, out string errorMessage)
{
result = null!;
errorMessage = null!;
try
{
if (string.IsNullOrWhiteSpace(sql))
{
errorMessage = "SQL statement cannot be null or empty.";
return false;
}
// Create parser instance
var parser = new StatementParser();
// Normalize whitespace while preserving comments
sql = parser.NormalizeSqlPreservingComments(sql);
// Extract setup clauses (everything before the main SELECT that's not part of it)
var setupClauses = new List();
sql = parser.ExtractSetupClauses(sql, setupClauses);
// Extract finish clauses (cleanup statements after the main query)
var finishClauses = new ArrayList();
sql = parser.ExtractFinishClauses(sql, finishClauses);
// Parse WITH clause separately if present (before parsing SELECT)
string? withClause = null;
if (parser.TryParseWithClause(sql, out withClause, out var mainQuery))
{
sql = mainQuery; // Continue with the main query after WITH
}
// Parse the main SELECT statement - now with comments preserved
if (!parser.TryParseSelectStatement(sql, out var clauses, out errorMessage))
{
return false;
}
// Create the QueryBreakdown object
result = new QueryBreakdown
{
WithClause = withClause?.Trim(),
SelectClause = clauses!.SelectClause ?? new SqlExpressionClause(splitOnComma: true),
FromClause = clauses.FromClause ?? new SqlClause(),
WhereClause = clauses.WhereClause ?? new SqlExpressionClause(splitOnComma: false),
GroupByClause = clauses.GroupByClause ?? new SqlExpressionClause(splitOnComma: true),
HavingClause = clauses.HavingClause ?? new SqlExpressionClause(splitOnComma: false),
OrderByClause = clauses.OrderByClause ?? new SqlExpressionClause(splitOnComma: true),
SetupClauses = setupClauses,
FinishClauses = finishClauses
};
// Extract parameters from all clauses (use comment-free version for this)
var sqlWithoutComments = parser.RemoveSqlComments(sql);
parser.ExtractParameters(result.Parameters, sqlWithoutComments);
return true;
}
catch (Exception ex)
{
errorMessage = $"Unexpected error during parsing: {ex.Message}";
return false;
}
}
#endregion
}
#pragma warning restore S2325