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