Files
sql-utilities/src/Strata.SqlTools.SqlServer/Breakdowns/QueryBreakdown.cs
T
Thom LambandClaude Opus 4.7 f577c06558
SonarQube Analysis / sonarqube (pull_request) Successful in 4m5s
refactor(dedup): cross-dialect QueryBreakdown + StatementParser helpers (Cluster E)
Two shared scaffolds for blocks Sonar flagged across the SqlServer,
Snowflake, and PostgreSQL dialects:

1. **`AppendToClause` on `SqlBreakdownBase`** — collapses the "if
   clause is empty set it, else append `{operation} {sql}`; then merge
   comment with same rule" pattern that was repeated three times in
   each of SqlServer/Snowflake `QueryBreakdown`. The matching
   `AddWhereExpression` / `AddHavingExpression` / `AddWhereClause(string)`
   sites in both files now delegate to a single `protected static`
   helper. Operates against `ISqlClause`, so it works for both the
   `WhereClause` and `HavingClause` properties.

2. **`HandleDoubleQuoteAsIdentifier` on `SqlServer.StatementParser`** —
   PostgreSQL and Snowflake both override SqlServer's
   `HandleDoubleQuote` (which produces a string-literal token) to
   instead produce a `ColumnIdentifier` token. The two overrides had
   identical 14-line bodies. The shared logic now lives once, and
   each dialect's override is a one-liner that calls the helper.

Deliberately *not* refactored in this commit:
- The CTE WITH-clause SQL generation in SqlServer/Snowflake QueryBreakdown
  (lines ~537-560 / ~579-601 Sonar flagged) — the surrounding logic
  differs enough between the two that an extraction would obscure
  rather than clarify.
- The PG/Snowflake QueryBreakdown constructor pair (lines 40-58 /
  43-61) — only ~10 lines × 2; extracting requires either a new
  shared helper for ~20 lines of savings or moving up the inheritance
  chain, neither pays for itself.

All 1180 tests stay green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:39:36 -05:00

1350 lines
50 KiB
C#

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;
/// <summary>
/// Represents a SELECT query breakdown with all clauses (SELECT, FROM, WHERE, GROUP BY, HAVING, ORDER BY).
/// </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 QueryBreakdown : SqlBreakdownBase, IQueryBreakdown
{
private const string ExpressionNullErrorMessage = "Expression cannot be null.";
private List<IQueryParam> _parameterList;
private List<IWithClause> _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
/// <summary>
/// Initializes a new instance of the <see cref="QueryBreakdown"/> class.
/// </summary>
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;
}
/// <summary>
/// Initializes a new instance of the <see cref="QueryBreakdown"/> class with SELECT and FROM clauses.
/// </summary>
/// <param name="selectClause">The SELECT clause.</param>
/// <param name="fromClause">The FROM clause.</param>
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;
}
/// <summary>
/// Initializes a new instance of the <see cref="QueryBreakdown"/> class with SELECT, FROM, and WHERE clauses.
/// </summary>
/// <param name="selectClause">The SELECT clause.</param>
/// <param name="fromClause">The FROM clause.</param>
/// <param name="whereClause">The WHERE clause.</param>
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;
}
}
/// <summary>
/// Initializes a new instance of the <see cref="QueryBreakdown"/> class with SELECT, FROM, WHERE, and ORDER BY clauses.
/// </summary>
/// <param name="selectClause">The SELECT clause.</param>
/// <param name="fromClause">The FROM clause.</param>
/// <param name="whereClause">The WHERE clause.</param>
/// <param name="orderByClause">The ORDER BY clause.</param>
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
/// <summary>
/// Gets the list of query parameters.
/// </summary>
public IEnumerable<IQueryParam> ParameterList => _parameterList;
/// <summary>
/// Gets the parameter dictionary containing parameter names and their values.
/// Parameter values can be set/updated after parsing.
/// </summary>
public Dictionary<string, object> Parameters { get; } = [];
/// <summary>
/// Gets the WITH clauses (Common Table Expressions) as an ordered list.
/// </summary>
public IReadOnlyList<IWithClause> WithClauses => _withClauses.AsReadOnly();
/// <summary>
/// Gets or sets the WITH clause string for backward compatibility with parsing.
/// </summary>
protected internal string? WithClause { get; set; }
/// <summary>
/// Gets the WITH clause value (for derived classes to access).
/// </summary>
public string? GetWithClauseValue() => WithClause;
/// <summary>
/// Sets the WITH clause value (for derived classes to set).
/// </summary>
public void SetWithClauseValue(string? value) => WithClause = value;
/// <summary>
/// Gets a value indicating whether WITH clauses are being used.
/// </summary>
public bool IsUsingWithClause => _withClauses.Count > 0 || !string.IsNullOrEmpty(WithClause);
/// <summary>
/// Gets or sets the SELECT clause with optional comment.
/// </summary>
public ISqlExpressionClause SelectClause
{
get => _selectClause;
set
{
_selectClause = value;
InvalidateClausesCache();
}
}
/// <summary>
/// Gets a value indicating whether a FROM clause is being used.
/// </summary>
public bool IsUsingFromClause => !string.IsNullOrEmpty(FromClause.Clause);
/// <summary>
/// Gets or sets the FROM clause with optional comment.
/// </summary>
public ISqlClause FromClause
{
get => _fromClause;
set
{
_fromClause = value;
InvalidateClausesCache();
}
}
/// <summary>
/// Gets a value indicating whether a GROUP BY clause is being used.
/// </summary>
public bool IsUsingGroupByClause => !string.IsNullOrEmpty(GroupByClause.Clause);
/// <summary>
/// Gets or sets the GROUP BY clause with optional comment.
/// </summary>
public ISqlExpressionClause GroupByClause
{
get => _groupByClause;
set
{
_groupByClause = value;
InvalidateClausesCache();
}
}
/// <summary>
/// Gets a value indicating whether a WHERE clause is being used.
/// </summary>
public bool IsUsingWhereClause => !string.IsNullOrEmpty(WhereClause.Clause);
/// <summary>
/// Gets or sets the WHERE clause with optional comment.
/// </summary>
public ISqlExpressionClause WhereClause
{
get => _whereClause;
set
{
_whereClause = value;
InvalidateClausesCache();
}
}
/// <summary>
/// Gets or sets the ORDER BY clause with optional comment.
/// </summary>
public ISqlExpressionClause OrderByClause
{
get => _orderByClause;
set
{
_orderByClause = value;
InvalidateClausesCache();
}
}
/// <summary>
/// Gets a value indicating whether an ORDER BY clause is being used.
/// </summary>
public bool IsUsingOrderByClause => !string.IsNullOrEmpty(OrderByClause.Clause);
/// <summary>
/// Gets or sets the HAVING clause with optional comment.
/// </summary>
public ISqlExpressionClause HavingClause
{
get => _havingClause;
set
{
_havingClause = value;
InvalidateClausesCache();
}
}
/// <summary>
/// Gets a value indicating whether a HAVING clause is being used.
/// </summary>
public bool IsUsingHavingClause => !string.IsNullOrEmpty(HavingClause.Clause);
#endregion
#region Methods
/// <summary>
/// Adds a parameter to the query.
/// </summary>
/// <param name="parameterName">The parameter name.</param>
/// <param name="value">The parameter value.</param>
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;
}
}
/// <summary>
/// Adds multiple parameters to the query.
/// </summary>
/// <param name="queryParams">The query parameters to add.</param>
public void AddParameter(IEnumerable<IQueryParam> 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;
}
}
/// <summary>
/// Sets the value of a parameter in the Parameters dictionary.
/// If the parameter doesn't exist, it will be added.
/// </summary>
/// <param name="parameterName">The parameter name (with or without @).</param>
/// <param name="value">The parameter value.</param>
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));
}
/// <summary>
/// Gets the value of a parameter from the Parameters dictionary.
/// </summary>
/// <param name="parameterName">The parameter name (with or without @).</param>
/// <returns>The parameter value, or null if not found.</returns>
public object? GetParameterValue(string parameterName)
{
var name = parameterName.StartsWith('@') ? parameterName : $"@{parameterName}";
return Parameters.TryGetValue(name, out var value) ? value : null;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="parameterName">The parameter name (with or without @ or :).</param>
/// <param name="value">The parameter value.</param>
/// <exception cref="InvalidOperationException">Thrown when trying to set a parameter to a different type than its existing value.</exception>
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!;
}
}
/// <summary>
/// Extracts parameters from a SQL clause and adds them to the Parameters dictionary.
/// </summary>
/// <param name="sql">The SQL clause to extract parameters from.</param>
protected void ExtractAndAddParameters(string sql)
{
if (string.IsNullOrWhiteSpace(sql))
{
return;
}
// Create a temporary dictionary to extract parameters
var tempParams = new Dictionary<string, object>();
Parser.ExtractParameters(tempParams, sql);
// Add each parameter using the managed add method
foreach (var kvp in tempParams)
{
AddOrUpdateParameter(kvp.Key, kvp.Value);
}
}
/// <summary>
/// Invalidates the GetClauses() cache, forcing a fresh SqlClauses object on the next call.
/// Called automatically whenever any clause property is modified.
/// </summary>
private void InvalidateClausesCache()
{
_clausesCacheDirty = true;
_cachedClauses = null;
}
/// <summary>
/// Gets the SQL clauses from this query breakdown.
/// </summary>
/// <returns>A SqlClauses object containing the current clause properties.</returns>
/// <remarks>
/// This method uses caching to improve performance. The cached result is invalidated
/// whenever any clause property is modified.
/// </remarks>
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;
}
/// <summary>
/// Applies SQL clauses from a SqlClauses object to this query breakdown.
/// Only non-null clauses are applied.
/// </summary>
/// <param name="clauses">The SQL clauses to apply.</param>
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;
}
}
/// <summary>
/// Collects all parameters from the CTE hierarchy recursively.
/// This method traverses all WITH clauses and their nested queries to collect parameters.
/// </summary>
/// <param name="allCollectedParams">Dictionary to collect parameters (passed recursively).</param>
/// <remarks>
/// Parameters from nested CTEs are collected using TryAdd, so existing parameters in the main query
/// take precedence over CTE parameters with the same name.
/// </remarks>
protected virtual void CollectCteParameters(Dictionary<string, object> 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);
}
}
}
/// <summary>
/// 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.
/// </summary>
private static void CollectFromCteQuery(IQueryBreakdown? query, Dictionary<string, object> 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);
}
}
/// <summary>
/// Gets all parameters including those from the CTE hierarchy.
/// The main query's parameters take precedence over CTE parameters with the same name.
/// </summary>
/// <returns>A dictionary containing all parameters merged from the CTE hierarchy and main query.</returns>
public virtual Dictionary<string, object> GetMergedParameters()
{
var mergedParams = new Dictionary<string, object>();
// 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
/// <summary>
/// Gets the SQL breakdown as a string (query-specific implementation).
/// </summary>
/// <returns>The SELECT SQL statement.</returns>
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();
}
/// <summary>
/// Gets the complete SQL query including optional setup and finish clauses (for backward compatibility).
/// </summary>
/// <param name="includeSetupFinish">Whether to include setup and finish clauses.</param>
/// <returns>The complete SQL query string.</returns>
public new virtual string GetSql(bool includeSetupFinish = true)
{
return base.GetSql(includeSetupFinish);
}
/// <summary>
/// Creates a deep clone of this query breakdown.
/// </summary>
/// <returns>A cloned instance.</returns>
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<IQueryParam>(_parameterList.Select(p => new QueryParam(p.Name, p.Value)));
// Deep copy with clauses (recursive cloning for nested queries)
clone._withClauses = new List<IWithClause>(
_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;
}
/// <summary>
/// Gets a LINQ to SQL query of the specified type based on this breakdown.
/// </summary>
/// <typeparam name="T">The entity type for the query.</typeparam>
/// <returns>An empty queryable by default, as QueryBreakdown operates on SQL. Override in derived classes to provide LINQ query reconstruction.</returns>
/// <remarks>
/// 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.
/// </remarks>
public override IQueryable<T> GetQuery<T>() where T : class => Enumerable.Empty<T>().AsQueryable();
/// <summary>
/// Gets the complete SQL query string (interface implementation).
/// </summary>
/// <param name="includeSetupFinish">Whether to include setup and finish clauses.</param>
/// <returns>The SQL query string.</returns>
public string GetSQL(bool includeSetupFinish = true) => GetSql(includeSetupFinish);
/// <summary>
/// Merges another query breakdown into this one.
/// </summary>
/// <param name="query">The query to merge.</param>
#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);
}
}
/// <summary>
/// Adds a WHERE clause condition.
/// </summary>
/// <param name="sql">The SQL condition to add.</param>
public void AddWhereClause(string sql)
{
if (string.IsNullOrWhiteSpace(sql))
{
return;
}
AddWhereClause(sql, "and");
}
/// <summary>
/// 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.
/// </summary>
/// <param name="sql">The SQL condition to add.</param>
/// <param name="operation">The logical operation ("and" or "or").</param>
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);
}
/// <summary>
/// Adds an expression to the SELECT clause.
/// </summary>
/// <param name="expression">The expression to add.</param>
/// <param name="comment">Optional comment to add with the expression.</param>
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}";
}
}
}
/// <summary>
/// Adds an expression to the WHERE clause.
/// </summary>
/// <param name="expression">The expression to add.</param>
/// <param name="comment">Optional comment to add with the expression.</param>
/// <param name="operation">The logical operation ("and" or "or"). Defaults to "and".</param>
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);
}
/// <summary>
/// Adds an expression to the GROUP BY clause.
/// </summary>
/// <param name="expression">The expression to add.</param>
/// <param name="comment">Optional comment to add with the expression.</param>
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}";
}
}
}
/// <summary>
/// Adds an expression to the ORDER BY clause.
/// </summary>
/// <param name="expression">The expression to add.</param>
/// <param name="comment">Optional comment to add with the expression.</param>
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}";
}
}
}
/// <summary>
/// Adds an expression to the HAVING clause.
/// </summary>
/// <param name="expression">The expression to add.</param>
/// <param name="comment">Optional comment to add with the expression.</param>
/// <param name="operation">The logical operation ("and" or "or"). Defaults to "and".</param>
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);
}
/// <summary>
/// Adds a Common Table Expression (CTE) to the WITH clause.
/// </summary>
/// <param name="withTableName">The table name for the WITH clause.</param>
/// <param name="withTableQuery">The query breakdown representing the WITH table.</param>
/// <exception cref="CteValidationException">Thrown when CTE validation fails.</exception>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Duplicate CTE names (case-insensitive) are not allowed within the same query.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// var cteQuery = new QueryBreakdown()
/// .Select("id, name")
/// .From("users")
/// .Where("active = 1");
/// mainQuery.AddWithClause("active_users", cteQuery);
/// </code>
/// </example>
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);
}
/// <summary>
/// Adds a Common Table Expression (CTE) with full WithClause configuration to the WITH clause.
/// This overload allows setting IsRecursive, RecursiveQuery, and ColumnList properties.
/// </summary>
/// <param name="withClause">The WithClause object containing the CTE configuration.</param>
/// <exception cref="CteValidationException">Thrown when CTE validation fails.</exception>
/// <remarks>
/// <para>
/// Use this overload when you need advanced CTE features:
/// <list type="bullet">
/// <item><description>Recursive CTEs (set IsRecursive = true and provide RecursiveQuery)</description></item>
/// <item><description>Explicit column lists (set ColumnList to define output column names)</description></item>
/// <item><description>Complex CTE configurations requiring fine-grained control</description></item>
/// </list>
/// </para>
/// <para>
/// Parameter inheritance follows the same rules as other AddWithClause overloads: main query parameters
/// take precedence over CTE parameters with the same name.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// var withClause = new WithClause("active_users", cteQuery)
/// {
/// ColumnList = new List&lt;string&gt; { "id", "name", "email" },
/// IsRecursive = false
/// };
/// mainQuery.AddWithClause(withClause);
/// </code>
/// </example>
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);
}
/// <summary>
/// Adds a Common Table Expression (CTE) to the WITH clause using a raw SQL string.
/// The SQL will be parsed into a QueryBreakdown object.
/// </summary>
/// <param name="withTableName">The table name for the WITH clause.</param>
/// <param name="withTableSql">The SQL query for the WITH table.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules. Defaults to true.</param>
/// <exception cref="CteValidationException">Thrown when CTE validation fails.</exception>
/// <exception cref="SqlParseException">Thrown when SQL parsing fails.</exception>
/// <remarks>
/// <para>
/// This is a convenience overload for adding CTEs when you have a SQL string. The SQL is parsed
/// automatically using <see cref="Parse(string)"/>. If parsing fails, a <see cref="SqlParseException"/>
/// is thrown with position information and context to help diagnose the issue.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// mainQuery.AddWithClause("active_users", "SELECT id, name FROM users WHERE active = 1");
/// </code>
/// </example>
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);
}
/// <summary>
/// Parses the SELECT clause into a collection of Expression objects.
/// Each column/expression in the SELECT list is parsed separately.
/// </summary>
/// <returns>An enumerable collection of parsed Expression objects from the SELECT clause.</returns>
/// <exception cref="FormatException">Thrown when the SELECT clause cannot be parsed.</exception>
public virtual IEnumerable<Expression> GetSelectExpressions()
=> SelectClause.GetExpressions(CreateExpressionParser());
/// <summary>
/// Parses the WHERE clause into an Expression object.
/// </summary>
/// <returns>A parsed Expression object from the WHERE clause, or null if WHERE clause is empty.</returns>
/// <exception cref="FormatException">Thrown when the WHERE clause cannot be parsed.</exception>
public virtual Expression? GetWhereExpression()
=> WhereClause.GetExpressions(CreateExpressionParser()).FirstOrDefault();
/// <summary>
/// Parses the HAVING clause into an Expression object.
/// </summary>
/// <returns>A parsed Expression object from the HAVING clause, or null if HAVING clause is empty.</returns>
/// <exception cref="FormatException">Thrown when the HAVING clause cannot be parsed.</exception>
public virtual Expression? GetHavingExpression()
=> HavingClause.GetExpressions(CreateExpressionParser()).FirstOrDefault();
/// <summary>
/// Creates the appropriate statement expression parser for this query type.
/// Override in derived classes to provide dialect-specific parsers.
/// </summary>
/// <returns>An IStatementExpressionParser instance.</returns>
protected virtual IStatementExpressionParser CreateExpressionParser()
=> new StatementExpressionParser();
#endregion
#region Parsing Methods
/// <summary>
/// Parses a T-SQL SELECT statement into a QueryBreakdown object.
/// </summary>
/// <param name="sql">The T-SQL SELECT statement to parse.</param>
/// <returns>A QueryBreakdown object representing the parsed query.</returns>
/// <exception cref="ArgumentNullException">Thrown when sql is null or empty.</exception>
/// <exception cref="SqlParseException">Thrown when the SQL statement cannot be parsed.</exception>
/// <example>
/// <code>
/// var query = QueryBreakdown.Parse("SELECT id, name FROM users WHERE active = 1");
/// Console.WriteLine(query.GetSql());
/// </code>
/// </example>
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;
}
/// <summary>
/// Attempts to parse a T-SQL SELECT statement into a QueryBreakdown object.
/// </summary>
/// <param name="sql">The T-SQL SELECT statement to parse.</param>
/// <param name="result">When this method returns, contains the parsed QueryBreakdown 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 QueryBreakdown result)
=> TryParse(sql, out result, out _);
/// <summary>
/// Attempts to parse a T-SQL SELECT statement into a QueryBreakdown object.
/// </summary>
/// <param name="sql">The T-SQL SELECT statement to parse.</param>
/// <param name="result">When this method returns, contains the parsed QueryBreakdown 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 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<string>();
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