SonarQube Analysis / sonarqube (pull_request) Successful in 4m5s
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>
1350 lines
50 KiB
C#
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<string> { "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
|
|
|