Merge pull request 'chore: refactor for sonarqube issues' (#10) from fix/sonarqube-critical-major-debt into main
SonarQube Analysis / sonarqube (push) Successful in 5m46s

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
2026-05-22 17:15:42 -05:00
22 changed files with 522 additions and 461 deletions
@@ -64,10 +64,15 @@ public static class DbContextExtensions
/// <param name="context">The DbContext instance.</param>
/// <param name="id">The ID of the QueryBreakdownEntity to retrieve.</param>
/// <returns>The QueryBreakdownEntity with related entities included, or null if not found.</returns>
public static async Task<QueryBreakdownEntity?> GetQueryBreakdownWithRelatedDataAsync(this DbContext context, int id)
public static Task<QueryBreakdownEntity?> GetQueryBreakdownWithRelatedDataAsync(this DbContext context, int id)
{
ArgumentNullException.ThrowIfNull(context);
return GetQueryBreakdownWithRelatedDataCoreAsync(context, id);
}
private static async Task<QueryBreakdownEntity?> GetQueryBreakdownWithRelatedDataCoreAsync(DbContext context, int id)
{
return await context.Set<QueryBreakdownEntity>()
.FirstOrDefaultAsync(q => q.Id == id);
}
@@ -3,6 +3,7 @@ using System.Text.Json;
using Strata.SqlTools.Breakdowns.SqlServer;
using Strata.SqlTools.EFCore.Abstractions;
using Strata.SqlTools.EFCore.Models;
using Strata.SqlTools.SqlBreakdown.Classes;
namespace Strata.SqlTools.EFCore.Services;
@@ -54,41 +55,12 @@ public class QueryBreakdownMapper : IQueryBreakdownMapper
var queryBreakdown = new QueryBreakdown();
// Set clause properties
if (!string.IsNullOrEmpty(entity.SelectClause))
{
queryBreakdown.SelectClause.Clause = entity.SelectClause;
queryBreakdown.SelectClause.Comment = entity.SelectClauseComment;
}
if (!string.IsNullOrEmpty(entity.FromClause))
{
queryBreakdown.FromClause.Clause = entity.FromClause;
queryBreakdown.FromClause.Comment = entity.FromClauseComment;
}
if (!string.IsNullOrEmpty(entity.WhereClause))
{
queryBreakdown.WhereClause.Clause = entity.WhereClause;
queryBreakdown.WhereClause.Comment = entity.WhereClauseComment;
}
if (!string.IsNullOrEmpty(entity.GroupByClause))
{
queryBreakdown.GroupByClause.Clause = entity.GroupByClause;
queryBreakdown.GroupByClause.Comment = entity.GroupByClauseComment;
}
if (!string.IsNullOrEmpty(entity.HavingClause))
{
queryBreakdown.HavingClause.Clause = entity.HavingClause;
queryBreakdown.HavingClause.Comment = entity.HavingClauseComment;
}
if (!string.IsNullOrEmpty(entity.OrderByClause))
{
queryBreakdown.OrderByClause.Clause = entity.OrderByClause;
queryBreakdown.OrderByClause.Comment = entity.OrderByClauseComment;
}
ApplyClause(queryBreakdown.SelectClause, entity.SelectClause, entity.SelectClauseComment);
ApplyClause(queryBreakdown.FromClause, entity.FromClause, entity.FromClauseComment);
ApplyClause(queryBreakdown.WhereClause, entity.WhereClause, entity.WhereClauseComment);
ApplyClause(queryBreakdown.GroupByClause, entity.GroupByClause, entity.GroupByClauseComment);
ApplyClause(queryBreakdown.HavingClause, entity.HavingClause, entity.HavingClauseComment);
ApplyClause(queryBreakdown.OrderByClause, entity.OrderByClause, entity.OrderByClauseComment);
if (!string.IsNullOrEmpty(entity.WithClause))
{
@@ -194,32 +166,55 @@ public class QueryBreakdownMapper : IQueryBreakdownMapper
? paramEntity.ParameterName
: $"@{paramEntity.ParameterName}";
// Deserialize value if type information is available
object? value = paramEntity.ParameterValue;
if (!string.IsNullOrEmpty(paramEntity.ParameterTypeName) && !string.IsNullOrEmpty(paramEntity.ParameterValue))
{
var type = Type.GetType(paramEntity.ParameterTypeName);
if (type != null)
{
try
{
value = Convert.ChangeType(paramEntity.ParameterValue, type);
}
catch
{
// If conversion fails, use string value
value = paramEntity.ParameterValue;
}
}
}
queryBreakdown.Parameters[key] = value;
queryBreakdown.Parameters[key] = DeserializeParameterValue(paramEntity);
}
}
return queryBreakdown;
}
/// <summary>
/// Applies a stored clause value and comment to a target clause, leaving it untouched when the value is empty.
/// </summary>
private static void ApplyClause(ISqlClause target, string? value, string? comment)
{
if (string.IsNullOrEmpty(value))
{
return;
}
target.Clause = value;
target.Comment = comment;
}
/// <summary>
/// Reconstructs a parameter value from its stored string and type name, falling back to the raw
/// string when no type information is available or the conversion fails.
/// </summary>
private static object? DeserializeParameterValue(QueryParameterEntity paramEntity)
{
if (string.IsNullOrEmpty(paramEntity.ParameterTypeName) || string.IsNullOrEmpty(paramEntity.ParameterValue))
{
return paramEntity.ParameterValue;
}
var type = Type.GetType(paramEntity.ParameterTypeName);
if (type == null)
{
return paramEntity.ParameterValue;
}
try
{
return Convert.ChangeType(paramEntity.ParameterValue, type);
}
catch
{
// If conversion fails, use string value
return paramEntity.ParameterValue;
}
}
#region Helper Methods
internal static string SerializeList(List<string> list)
@@ -90,10 +90,15 @@ public class QueryBreakdownRepository : IQueryBreakdownRepository
/// <summary>
/// Adds a new QueryBreakdown to the repository and saves changes.
/// </summary>
public async Task<int> AddAsync(QueryBreakdown queryBreakdown)
public Task<int> AddAsync(QueryBreakdown queryBreakdown)
{
ArgumentNullException.ThrowIfNull(queryBreakdown);
return AddCoreAsync(queryBreakdown);
}
private async Task<int> AddCoreAsync(QueryBreakdown queryBreakdown)
{
var (entity, parameters, withClauses) = _mapper.MapToEntityWithRelations(queryBreakdown);
// Add the main entity
@@ -159,10 +164,15 @@ public class QueryBreakdownRepository : IQueryBreakdownRepository
/// <summary>
/// Updates an existing QueryBreakdown and saves changes.
/// </summary>
public async Task UpdateAsync(int id, QueryBreakdown queryBreakdown)
public Task UpdateAsync(int id, QueryBreakdown queryBreakdown)
{
ArgumentNullException.ThrowIfNull(queryBreakdown);
return UpdateCoreAsync(id, queryBreakdown);
}
private async Task UpdateCoreAsync(int id, QueryBreakdown queryBreakdown)
{
var entity = await _context.Set<QueryBreakdownEntity>().FirstOrDefaultAsync(e => e.Id == id);
if (entity == null)
{
@@ -13,12 +13,7 @@ public class LinqExpressionVisitor : ExpressionVisitor
private readonly StringBuilder _orderByBuilder = new();
private readonly List<string> _methodCalls = new();
private bool _isInWhereClause;
#pragma warning disable IDE0052, S4487
private bool _isInSelectClause;
private bool _isInOrderByClause;
private bool _isInGroupByClause;
private string? _tableName;
#pragma warning restore IDE0052, S4487
/// <summary>
/// Gets the SELECT clause extracted from the expression.
@@ -167,9 +162,7 @@ public class LinqExpressionVisitor : ExpressionVisitor
var lambda = StripQuotes(node.Arguments[1]) as LambdaExpression;
if (lambda != null)
{
_isInSelectClause = true;
var selectExpression = ExtractSelectExpression(lambda.Body);
_isInSelectClause = false;
if (!string.IsNullOrEmpty(selectExpression))
{
@@ -190,9 +183,7 @@ public class LinqExpressionVisitor : ExpressionVisitor
var lambda = StripQuotes(node.Arguments[1]) as LambdaExpression;
if (lambda != null)
{
_isInOrderByClause = true;
var orderByExpression = ExtractMemberName(lambda.Body);
_isInOrderByClause = false;
if (!string.IsNullOrEmpty(orderByExpression))
{
@@ -220,9 +211,7 @@ public class LinqExpressionVisitor : ExpressionVisitor
var lambda = StripQuotes(node.Arguments[1]) as LambdaExpression;
if (lambda != null)
{
_isInGroupByClause = true;
var groupByExpression = ExtractMemberName(lambda.Body);
_isInGroupByClause = false;
if (!string.IsNullOrEmpty(groupByExpression))
{
@@ -166,14 +166,7 @@ internal static class CollectionReportWriter
var allParamNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var query in queryBreakdowns)
{
foreach (var param in query.ParameterList)
{
allParamNames.Add(param.Name);
}
foreach (var paramName in query.Parameters.Keys)
{
allParamNames.Add(paramName);
}
allParamNames.UnionWith(GetAllParameterNames(query));
}
var parameters = allParamNames.OrderBy(p => p).ToList();
@@ -199,19 +192,7 @@ internal static class CollectionReportWriter
sb.AppendLine($" style {queryNode} fill:{format.QueryNodeFill}");
// Collect all parameter names used by this query
var queryParamNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Add from ParameterList (parsed parameters)
foreach (var param in query.ParameterList)
{
queryParamNames.Add(param.Name);
}
// Add from Parameters dictionary (manually added parameters)
foreach (var paramName in query.Parameters.Keys)
{
queryParamNames.Add(paramName);
}
var queryParamNames = new HashSet<string>(GetAllParameterNames(query), StringComparer.OrdinalIgnoreCase);
// Connect parameters to this query
foreach (var paramName in queryParamNames)
@@ -233,6 +214,23 @@ internal static class CollectionReportWriter
return sb.ToString();
}
/// <summary>
/// Yields every parameter name referenced by a query, combining parsed parameters
/// (<see cref="QueryBreakdown.ParameterList"/>) and manually added ones (<c>Parameters</c> dictionary).
/// </summary>
private static IEnumerable<string> GetAllParameterNames(QueryBreakdown query)
{
foreach (var param in query.ParameterList)
{
yield return param.Name;
}
foreach (var paramName in query.Parameters.Keys)
{
yield return paramName;
}
}
/// <summary>
/// Renders the per-query composition report. The optional <paramref name="appendExtra"/> hook lets a
/// dialect append additional per-query sections (for example, Snowflake feature notes).
@@ -43,9 +43,10 @@ public class SqlStatementGenerator
var queryBreakdown = sqlBreakdown as IQueryBreakdown;
var tableNames = new List<string>();
if (queryBreakdown != null && !string.IsNullOrWhiteSpace(queryBreakdown.FromClause?.ToString()))
var fromClauseText = queryBreakdown?.FromClause?.ToString();
if (!string.IsNullOrWhiteSpace(fromClauseText))
{
tableNames.Add(queryBreakdown.FromClause.ToString());
tableNames.Add(fromClauseText);
}
return _baseGenerator.GenerateEntityRelationshipDiagram(tableNames, title);
+1 -2
View File
@@ -13,7 +13,6 @@ public class Value
public Value() : this(string.Empty, string.Empty, new int[0], new string[0], new CalculationFilterGroup[0])
{
// FilterGroups = new List<CalculationFilterGroup>();
}
[JsonConstructor]
@@ -22,7 +21,7 @@ public class Value
ColumnAlias = columnAlias;
Calculation = calculation;
CalculationDataColumnIds = calculationDataColumnIds ?? Array.Empty<int>();
AliasedIds = aliasedIds ?? aliasedIds ?? Array.Empty<string>();
AliasedIds = aliasedIds ?? Array.Empty<string>();
FilterGroups = filterGroups?.Where(x => x.IsValid()).ToList() ?? new List<CalculationFilterGroup>();
}
}
@@ -102,30 +102,6 @@ public class LocalVisitor : IVisitor<string>
// just converting it to an AND expression for now
var and = new And(With.Left, With.Right);
return and.Accept(this);
//throw new NotImplementedException("not sure what to do with 'WITH' expressions yet");
}
private bool TryGetCollectionItemProperty(Expression Expression, out Property? property)
{
property = null;
if (Expression is not IBinary binary)
{
return false;
}
if (binary.Left is not Property Property)
{
return false;
}
if (Property.Expression is not CollectionProperty collection)
{
return false;
}
property = Property;
return true;
}
public virtual string VisitParameter(Parameter parameter) => $"{parameter.ParameterName}";
@@ -1,4 +1,3 @@
using System.Dynamic;
using System.Runtime.CompilerServices;
using RulesEngine.Interfaces;
using RulesEngine.Models;
@@ -58,23 +57,6 @@ internal class RuleSetEngine
return success;
}
/// <summary>
/// Converts an object to an ExpandoObject by copying all public properties.
/// </summary>
/// <param name="obj">The object to convert.</param>
/// <returns>An ExpandoObject containing all properties from the source object.</returns>
private static ExpandoObject ConvertObjectToExpando(object obj)
{
var expando = new ExpandoObject();
var dictionary = expando as IDictionary<string, object?>;
foreach (var property in obj.GetType().GetProperties())
{
dictionary.Add(property.Name, property.GetValue(obj));
}
return expando;
}
}
/// <summary>
@@ -124,101 +124,7 @@ public class ProcedureBreakdown : SqlServerProcedureBreakdown
try
{
if (string.IsNullOrWhiteSpace(sql))
{
errorMessage = "SQL statement cannot be null or empty.";
return false;
}
// If Microsoft SQL mode, delegate to base class
if (isMicrosoftSql)
{
if (!SqlServerProcedureBreakdown.TryParse(sql, out var baseResult, out errorMessage))
{
return false;
}
// Convert to Snowflake ProcedureBreakdown
result = new ProcedureBreakdown
{
ProcedureName = baseResult.ProcedureName,
Parameters = baseResult.Parameters,
SetupClauses = baseResult.SetupClauses,
FinishClauses = baseResult.FinishClauses
};
return true;
}
var parser = SnowflakeParserInstance;
sql = parser.NormalizeSqlPreservingComments(sql);
// Check if it's a CALL statement (Snowflake syntax) or EXEC (for compatibility)
var sqlTrimmed = sql.TrimStart();
if (!System.Text.RegularExpressions.Regex.IsMatch(sqlTrimmed, @"^\s*(CALL|EXEC|EXECUTE)\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout))
{
errorMessage = "SQL statement must start with CALL, EXEC, or EXECUTE.";
return false;
}
// Extract setup and finish clauses
var setupClauses = new List<string>();
sql = parser.ExtractSetupClauses(sql, setupClauses);
var finishClauses = new ArrayList();
sql = parser.ExtractFinishClauses(sql, finishClauses);
// Parse CALL statement - match procedure name and parameters
// Pattern: CALL procedureName(param => value, ...)
var callMatch = System.Text.RegularExpressions.Regex.Match(sql,
@"(?:CALL|EXEC|EXECUTE)\s+([^\s\(]+)(?:\s*\((.*?)\))?$",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout);
if (!callMatch.Success)
{
errorMessage = "Could not parse CALL statement. Expected format: CALL procedureName(param => value, ...)";
return false;
}
var procedureName = callMatch.Groups[1].Value.Trim();
var parametersText = callMatch.Groups.Count > 2 ? callMatch.Groups[2].Value.Trim() : string.Empty;
var parameters = new Dictionary<string, string>();
if (!string.IsNullOrWhiteSpace(parametersText))
{
// Parse parameters - Snowflake uses param => value syntax
var paramMatches = System.Text.RegularExpressions.Regex.Matches(parametersText,
@"(\w+)\s*=>\s*([^,]+)(?:,|$)",
System.Text.RegularExpressions.RegexOptions.IgnoreCase, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout);
foreach (System.Text.RegularExpressions.Match paramMatch in paramMatches)
{
var paramName = paramMatch.Groups[1].Value.Trim();
var paramValue = paramMatch.Groups[2].Value.Trim();
// Store with @ prefix for consistency with SQL Server
parameters["@" + paramName] = paramValue;
}
// If no named parameters found, try positional parameters (just values)
if (parameters.Count == 0 && !string.IsNullOrWhiteSpace(parametersText))
{
var positionalParams = parametersText.Split(',');
for (int i = 0; i < positionalParams.Length; i++)
{
parameters[$"@param{i + 1}"] = positionalParams[i].Trim();
}
}
}
result = new ProcedureBreakdown(procedureName, parameters, isMicrosoftSql: false)
{
SetupClauses = setupClauses,
FinishClauses = finishClauses
};
return true;
return TryParseCore(sql, out result, out errorMessage, isMicrosoftSql);
}
catch (Exception ex)
{
@@ -227,5 +133,128 @@ public class ProcedureBreakdown : SqlServerProcedureBreakdown
}
}
private static bool TryParseCore(string sql, out ProcedureBreakdown result, out string errorMessage, bool isMicrosoftSql)
{
result = null!;
errorMessage = null!;
if (string.IsNullOrWhiteSpace(sql))
{
errorMessage = "SQL statement cannot be null or empty.";
return false;
}
// If Microsoft SQL mode, delegate to base class
if (isMicrosoftSql)
{
return TryParseMicrosoftSql(sql, out result, out errorMessage);
}
var parser = SnowflakeParserInstance;
sql = parser.NormalizeSqlPreservingComments(sql);
// Check if it's a CALL statement (Snowflake syntax) or EXEC (for compatibility)
var sqlTrimmed = sql.TrimStart();
if (!System.Text.RegularExpressions.Regex.IsMatch(sqlTrimmed, @"^\s*(CALL|EXEC|EXECUTE)\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout))
{
errorMessage = "SQL statement must start with CALL, EXEC, or EXECUTE.";
return false;
}
// Extract setup and finish clauses
var setupClauses = new List<string>();
sql = parser.ExtractSetupClauses(sql, setupClauses);
var finishClauses = new ArrayList();
sql = parser.ExtractFinishClauses(sql, finishClauses);
// Parse CALL statement - match procedure name and parameters
// Pattern: CALL procedureName(param => value, ...)
var callMatch = System.Text.RegularExpressions.Regex.Match(sql,
@"(?:CALL|EXEC|EXECUTE)\s+([^\s\(]+)(?:\s*\((.*?)\))?$",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout);
if (!callMatch.Success)
{
errorMessage = "Could not parse CALL statement. Expected format: CALL procedureName(param => value, ...)";
return false;
}
var procedureName = callMatch.Groups[1].Value.Trim();
var parametersText = callMatch.Groups.Count > 2 ? callMatch.Groups[2].Value.Trim() : string.Empty;
result = new ProcedureBreakdown(procedureName, ParseCallParameters(parametersText), isMicrosoftSql: false)
{
SetupClauses = setupClauses,
FinishClauses = finishClauses
};
return true;
}
/// <summary>
/// Parses Microsoft T-SQL CALL/EXEC syntax by delegating to the SQL Server breakdown,
/// then projecting the result onto a Snowflake <see cref="ProcedureBreakdown"/>.
/// </summary>
private static bool TryParseMicrosoftSql(string sql, out ProcedureBreakdown result, out string errorMessage)
{
result = null!;
if (!SqlServerProcedureBreakdown.TryParse(sql, out var baseResult, out errorMessage))
{
return false;
}
result = new ProcedureBreakdown
{
ProcedureName = baseResult.ProcedureName,
Parameters = baseResult.Parameters,
SetupClauses = baseResult.SetupClauses,
FinishClauses = baseResult.FinishClauses
};
return true;
}
/// <summary>
/// Parses the parameter list of a Snowflake CALL statement. Supports named (<c>param =&gt; value</c>)
/// syntax, falling back to positional values when no named parameters are present.
/// </summary>
private static Dictionary<string, string> ParseCallParameters(string parametersText)
{
var parameters = new Dictionary<string, string>();
if (string.IsNullOrWhiteSpace(parametersText))
{
return parameters;
}
// Parse parameters - Snowflake uses param => value syntax
var paramMatches = System.Text.RegularExpressions.Regex.Matches(parametersText,
@"(\w+)\s*=>\s*([^,]+)(?:,|$)",
System.Text.RegularExpressions.RegexOptions.IgnoreCase, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout);
foreach (System.Text.RegularExpressions.Match paramMatch in paramMatches)
{
var paramName = paramMatch.Groups[1].Value.Trim();
var paramValue = paramMatch.Groups[2].Value.Trim();
// Store with @ prefix for consistency with SQL Server
parameters["@" + paramName] = paramValue;
}
// If no named parameters found, try positional parameters (just values)
if (parameters.Count == 0)
{
var positionalParams = parametersText.Split(',');
for (int i = 0; i < positionalParams.Length; i++)
{
parameters[$"@param{i + 1}"] = positionalParams[i].Trim();
}
}
return parameters;
}
#endregion
}
@@ -89,10 +89,7 @@ public class SqlExpressionClause : SqlClause, ISqlExpressionClause
{
var items = new List<string>();
var current = new StringBuilder();
var parenDepth = 0;
var inSingleQuote = false;
var inDoubleQuote = false;
var inBracket = false;
var state = new SplitScanState();
for (int i = 0; i < clause.Length; i++)
{
@@ -106,39 +103,10 @@ public class SqlExpressionClause : SqlClause, ISqlExpressionClause
continue;
}
// Toggle quote states
if (ch == '\'' && !inDoubleQuote && !inBracket)
{
inSingleQuote = !inSingleQuote;
}
else if (ch == '"' && !inSingleQuote && !inBracket)
{
inDoubleQuote = !inDoubleQuote;
}
else if (ch == '[' && !inSingleQuote && !inDoubleQuote)
{
inBracket = true;
}
else if (ch == ']' && inBracket && !inSingleQuote && !inDoubleQuote)
{
inBracket = false;
}
// Track parenthesis depth
if (!inSingleQuote && !inDoubleQuote && !inBracket)
{
if (ch == '(')
{
parenDepth++;
}
else if (ch == ')')
{
parenDepth--;
}
}
state.UpdateForCharacter(ch);
// Split on comma only when not inside quotes, brackets, or parentheses
if (ch == ',' && !inSingleQuote && !inDoubleQuote && !inBracket && parenDepth == 0)
if (ch == ',' && !state.InsideDelimiter && state.ParenDepth == 0)
{
items.Add(current.ToString());
current.Clear();
@@ -157,5 +125,55 @@ public class SqlExpressionClause : SqlClause, ISqlExpressionClause
return items;
}
/// <summary>
/// Tracks quote, bracket, and parenthesis nesting while scanning a clause character by character,
/// so the splitter knows when a comma is at the top level.
/// </summary>
private struct SplitScanState
{
public int ParenDepth;
public bool InSingleQuote;
public bool InDoubleQuote;
public bool InBracket;
/// <summary>Gets whether the scanner is currently inside a quoted string or bracketed identifier.</summary>
public readonly bool InsideDelimiter => InSingleQuote || InDoubleQuote || InBracket;
/// <summary>Updates the quote/bracket states and parenthesis depth for the given character.</summary>
public void UpdateForCharacter(char ch)
{
// Toggle quote/bracket states
if (ch == '\'' && !InDoubleQuote && !InBracket)
{
InSingleQuote = !InSingleQuote;
}
else if (ch == '"' && !InSingleQuote && !InBracket)
{
InDoubleQuote = !InDoubleQuote;
}
else if (ch == '[' && !InSingleQuote && !InDoubleQuote)
{
InBracket = true;
}
else if (ch == ']' && InBracket && !InSingleQuote && !InDoubleQuote)
{
InBracket = false;
}
// Track parenthesis depth only when outside quotes/brackets
if (!InsideDelimiter)
{
if (ch == '(')
{
ParenDepth++;
}
else if (ch == ')')
{
ParenDepth--;
}
}
}
}
}
@@ -1,7 +1,5 @@
using Strata.SqlTools.SqlBreakdown.Interfaces.Core;
using Strata.SqlTools.SqlBreakdown.Interfaces.Core;
namespace Strata.SqlTools.SqlBreakdown.Expressions.Literals;
/// <summary>
@@ -48,7 +48,7 @@ public static class StringExtensions
if (aCharCount < 0)
{
throw new ArgumentException();
throw new ArgumentException("Character count cannot be negative.", nameof(aCharCount));
}
if (aCharCount == 0)
@@ -75,7 +75,7 @@ public static class StringExtensions
if (aCharCount < 0)
{
throw new ArgumentException();
throw new ArgumentException("Character count cannot be negative.", nameof(aCharCount));
}
if (aCharCount == 0)
@@ -44,12 +44,6 @@ public static partial class SqlUtils
try
{
// Would need to execute SQL here - skipping for this conversion
// var cmd = new StrataSimpleSQLRunner.SimpleSQLCommand("select * from sys.schemas where principal_id=1");
// var dt = StrataSimpleSQLRunner.ExecuteDatatable(cmd);
// foreach (DataRow row in dt.Rows)
// {
// list.Add(row[0].ToString());
// }
// Remove system schemas
return list.Except(GetSystemSchemas()).ToList();
@@ -132,80 +132,7 @@ public class DeleteBreakdown : SqlBreakdownBase
try
{
if (string.IsNullOrWhiteSpace(sql))
{
errorMessage = "SQL statement cannot be null or empty.";
return false;
}
var parser = new StatementParser();
sql = parser.NormalizeSqlPreservingComments(sql);
// Check if it's a DELETE statement
var sqlTrimmed = sql.TrimStart();
if (!System.Text.RegularExpressions.Regex.IsMatch(sqlTrimmed, @"^\s*DELETE\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout))
{
errorMessage = "SQL statement must start with DELETE.";
return false;
}
// Extract setup and finish clauses
var setupClauses = new List<string>();
sql = parser.ExtractSetupClauses(sql, setupClauses);
var finishClauses = new ArrayList();
sql = parser.ExtractFinishClauses(sql, finishClauses);
// Parse DELETE statement using regex
// Pattern: DELETE [table_alias] FROM table WHERE condition
var deleteMatch = System.Text.RegularExpressions.Regex.Match(sql,
@"DELETE\s+(.*?)\s+FROM\s+(.*?)(?:\s+WHERE\s+(.*))?$",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout);
if (!deleteMatch.Success)
{
// Try simpler pattern: DELETE FROM table WHERE condition
deleteMatch = System.Text.RegularExpressions.Regex.Match(sql,
@"DELETE\s+FROM\s+(.*?)(?:\s+WHERE\s+(.*))?$",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout);
if (!deleteMatch.Success)
{
errorMessage = "Could not parse DELETE statement. Expected format: DELETE [alias] FROM table [WHERE condition]";
return false;
}
var fromClause = deleteMatch.Groups[1].Value.Trim();
var whereClause = deleteMatch.Groups.Count > 2 ? deleteMatch.Groups[2].Value.Trim() : string.Empty;
result = new DeleteBreakdown(fromClause, whereClause)
{
SetupClauses = setupClauses,
FinishClauses = finishClauses
};
}
else
{
var deleteClause = deleteMatch.Groups[1].Value.Trim();
var fromClause = deleteMatch.Groups[2].Value.Trim();
var whereClause = deleteMatch.Groups.Count > 3 ? deleteMatch.Groups[3].Value.Trim() : string.Empty;
result = new DeleteBreakdown(fromClause, whereClause)
{
SetupClauses = setupClauses,
FinishClauses = finishClauses
};
if (!string.IsNullOrWhiteSpace(deleteClause))
{
var cleanDelete = parser.ExtractSqlComments(deleteClause, out var deleteComments);
result.DeleteClause.Clause = cleanDelete.Trim();
result.DeleteClause.Comment = deleteComments.Count > 0 ? string.Join(" ", deleteComments) : null;
}
}
return true;
return TryParseCore(sql, out result, out errorMessage);
}
catch (Exception ex)
{
@@ -214,5 +141,96 @@ public class DeleteBreakdown : SqlBreakdownBase
}
}
private static bool TryParseCore(string sql, out DeleteBreakdown result, out string errorMessage)
{
result = null!;
errorMessage = null!;
if (string.IsNullOrWhiteSpace(sql))
{
errorMessage = "SQL statement cannot be null or empty.";
return false;
}
var parser = new StatementParser();
sql = parser.NormalizeSqlPreservingComments(sql);
// Check if it's a DELETE statement
var sqlTrimmed = sql.TrimStart();
if (!System.Text.RegularExpressions.Regex.IsMatch(sqlTrimmed, @"^\s*DELETE\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout))
{
errorMessage = "SQL statement must start with DELETE.";
return false;
}
// Extract setup and finish clauses
var setupClauses = new List<string>();
sql = parser.ExtractSetupClauses(sql, setupClauses);
var finishClauses = new ArrayList();
sql = parser.ExtractFinishClauses(sql, finishClauses);
// Parse DELETE statement using regex
// Pattern: DELETE [table_alias] FROM table WHERE condition
var deleteMatch = System.Text.RegularExpressions.Regex.Match(sql,
@"DELETE\s+(.*?)\s+FROM\s+(.*?)(?:\s+WHERE\s+(.*))?$",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout);
if (deleteMatch.Success)
{
var deleteClause = deleteMatch.Groups[1].Value.Trim();
var fromClause = deleteMatch.Groups[2].Value.Trim();
var whereClause = deleteMatch.Groups.Count > 3 ? deleteMatch.Groups[3].Value.Trim() : string.Empty;
result = new DeleteBreakdown(fromClause, whereClause)
{
SetupClauses = setupClauses,
FinishClauses = finishClauses
};
ApplyDeleteClauseComments(result, parser, deleteClause);
return true;
}
// Try simpler pattern: DELETE FROM table WHERE condition
deleteMatch = System.Text.RegularExpressions.Regex.Match(sql,
@"DELETE\s+FROM\s+(.*?)(?:\s+WHERE\s+(.*))?$",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline, Strata.SqlTools.SqlBreakdown.Utilities.RegexDefaults.MatchTimeout);
if (!deleteMatch.Success)
{
errorMessage = "Could not parse DELETE statement. Expected format: DELETE [alias] FROM table [WHERE condition]";
return false;
}
var simpleFromClause = deleteMatch.Groups[1].Value.Trim();
var simpleWhereClause = deleteMatch.Groups.Count > 2 ? deleteMatch.Groups[2].Value.Trim() : string.Empty;
result = new DeleteBreakdown(simpleFromClause, simpleWhereClause)
{
SetupClauses = setupClauses,
FinishClauses = finishClauses
};
return true;
}
/// <summary>
/// Extracts inline comments from the DELETE clause text and applies the cleaned value and
/// combined comment to <paramref name="result"/>. No-op when the clause is blank.
/// </summary>
private static void ApplyDeleteClauseComments(DeleteBreakdown result, StatementParser parser, string deleteClause)
{
if (string.IsNullOrWhiteSpace(deleteClause))
{
return;
}
var cleanDelete = parser.ExtractSqlComments(deleteClause, out var deleteComments);
result.DeleteClause.Clause = cleanDelete.Trim();
result.DeleteClause.Comment = deleteComments.Count > 0 ? string.Join(" ", deleteComments) : null;
}
#endregion
}
@@ -491,38 +491,41 @@ public class QueryBreakdown : SqlBreakdownBase, IQueryBreakdown
continue;
}
// Recursively collect parameters from nested CTEs in the CTE's query
if (withClause.Query is QueryBreakdown cteQueryBreakdown)
{
cteQueryBreakdown.CollectCteParameters(allCollectedParams);
}
// Add the CTE's own parameters (TryAdd means main query params take precedence)
foreach (var param in withClause.Query.ParameterList)
{
var paramName = param.Name.StartsWith('@') ? param.Name : $"@{param.Name}";
allCollectedParams.TryAdd(paramName, param.Value);
}
CollectFromCteQuery(withClause.Query, allCollectedParams);
// For recursive CTEs, also collect parameters from the recursive query
if (withClause.IsRecursive && withClause.RecursiveQuery != null)
if (withClause.IsRecursive)
{
// Recursively collect parameters from nested CTEs in the recursive query
if (withClause.RecursiveQuery is QueryBreakdown recursiveQueryBreakdown)
{
recursiveQueryBreakdown.CollectCteParameters(allCollectedParams);
}
// Add the recursive query's own parameters
foreach (var param in withClause.RecursiveQuery.ParameterList)
{
var paramName = param.Name.StartsWith('@') ? param.Name : $"@{param.Name}";
allCollectedParams.TryAdd(paramName, param.Value);
}
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.
@@ -48,7 +48,7 @@ public class SqlParseException : Exception
{
Position = position;
Sql = sql ?? string.Empty;
NearText = ExtractNearText(sql, position);
NearText = ExtractNearText(Sql, position);
}
/// <summary>
@@ -63,7 +63,7 @@ public class SqlParseException : Exception
{
Position = position;
Sql = sql ?? string.Empty;
NearText = ExtractNearText(sql, position);
NearText = ExtractNearText(Sql, position);
}
private static string FormatMessage(string message, string sql, int position)
@@ -689,44 +689,74 @@ public class StatementParser
// Only process keywords at top level (outside parentheses)
if (parenthesisDepth == 0 && type == TokenType.String)
{
// Check for multi-word keywords (GROUP BY, ORDER BY)
if (string.Equals(value, "GROUP", StringComparison.OrdinalIgnoreCase) &&
tokenIndex + 1 < tokens.Count &&
tokens[tokenIndex + 1].type == TokenType.String &&
string.Equals(tokens[tokenIndex + 1].value, "BY", StringComparison.OrdinalIgnoreCase))
{
if (!clausePositions.ContainsKey(KeywordGroupBy))
{
clausePositions[KeywordGroupBy] = position;
}
skipNextToken = true; // Skip BY in next iteration
}
else if (string.Equals(value, "ORDER", StringComparison.OrdinalIgnoreCase) &&
tokenIndex + 1 < tokens.Count &&
tokens[tokenIndex + 1].type == TokenType.String &&
string.Equals(tokens[tokenIndex + 1].value, "BY", StringComparison.OrdinalIgnoreCase))
{
if (!clausePositions.ContainsKey(KeywordOrderBy))
{
clausePositions[KeywordOrderBy] = position;
}
skipNextToken = true; // Skip BY in next iteration
}
else if (keywordSet.Contains(value))
{
var matchedKeyword = keywords.FirstOrDefault(k =>
string.Equals(k, value, StringComparison.OrdinalIgnoreCase));
if (matchedKeyword != null && !clausePositions.ContainsKey(matchedKeyword))
{
clausePositions[matchedKeyword] = position;
}
}
skipNextToken = TryRecordKeywordAtToken(tokens, tokenIndex, value, position, keywords, keywordSet, clausePositions);
}
}
}
/// <summary>
/// Records the keyword at the given token (single-word or multi-word) into <paramref name="clausePositions"/>.
/// </summary>
/// <returns>True if a multi-word keyword (GROUP BY / ORDER BY) was matched and the following token should be skipped.</returns>
private static bool TryRecordKeywordAtToken(
List<(TokenType type, string value, int position)> tokens,
int tokenIndex,
string value,
int position,
string[] keywords,
HashSet<string> keywordSet,
Dictionary<string, int> clausePositions)
{
// Check for multi-word keywords (GROUP BY, ORDER BY); they consume the following BY token.
if (TryRecordMultiWordKeyword(tokens, tokenIndex, "GROUP", KeywordGroupBy, clausePositions) ||
TryRecordMultiWordKeyword(tokens, tokenIndex, "ORDER", KeywordOrderBy, clausePositions))
{
return true;
}
if (keywordSet.Contains(value))
{
var matchedKeyword = keywords.FirstOrDefault(k =>
string.Equals(k, value, StringComparison.OrdinalIgnoreCase));
if (matchedKeyword != null && !clausePositions.ContainsKey(matchedKeyword))
{
clausePositions[matchedKeyword] = position;
}
}
return false;
}
/// <summary>
/// Records a two-word keyword (e.g., "GROUP BY") when the token at <paramref name="tokenIndex"/> matches
/// <paramref name="firstWord"/> and is immediately followed by "BY".
/// </summary>
/// <returns>True if the multi-word keyword pattern matched.</returns>
private static bool TryRecordMultiWordKeyword(
List<(TokenType type, string value, int position)> tokens,
int tokenIndex,
string firstWord,
string canonicalKeyword,
Dictionary<string, int> clausePositions)
{
var (_, value, position) = tokens[tokenIndex];
if (!string.Equals(value, firstWord, StringComparison.OrdinalIgnoreCase) ||
tokenIndex + 1 >= tokens.Count ||
tokens[tokenIndex + 1].type != TokenType.String ||
!string.Equals(tokens[tokenIndex + 1].value, "BY", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!clausePositions.ContainsKey(canonicalKeyword))
{
clausePositions[canonicalKeyword] = position;
}
return true;
}
public virtual SqlClauses ExtractAllClauses(string sql, Dictionary<string, int> clausePositions)
{
var clauses = new SqlClauses
@@ -116,49 +116,66 @@ public class StatementReader : IStatementReader
if (char.IsDigit(CurrentCharacter))
{
// Check if this is a column identifier starting with a digit (e.g., "3_REVENUE")
// by looking ahead to see if there's an underscore after the digit(s)
var tempPos = Position;
while (tempPos < Length - 1 && char.IsDigit(_sqlStatement[tempPos]))
{
tempPos++;
}
// If we found an underscore after digit(s), treat as a column identifier
if (tempPos < Length - 1 && _sqlStatement[tempPos] == '_')
{
var stringValue = GrabStringValue();
_currentToken = new Token(TokenType.ColumnIdentifier, stringValue);
return;
}
// Otherwise, it's just a number
var number = GrabNumberValue();
_currentToken = new Token(TokenType.Number, number.ToString(CultureInfo.InvariantCulture));
ReadDigitToken();
return;
}
if (char.IsLetter(CurrentCharacter))
{
var stringValue = GrabStringValue();
// if next char is opening paren then its a function
if (CurrentCharacter == '(')
{
MovePosition();
_inFunctionBlock = true;
_currentToken = new Token(TokenType.FunctionStart, stringValue);
return;
}
_currentToken = new Token(TokenType.String, stringValue);
ReadLetterToken();
return;
}
throw new InvalidSyntaxException($"Invalid syntax at position {Position}. Unexpected symbol {CurrentCharacter}.");
}
/// <summary>
/// Reads a token that begins with a digit, producing a column identifier when the digits are
/// followed by an underscore (e.g., "3_REVENUE"), or a numeric token otherwise.
/// </summary>
private void ReadDigitToken()
{
// Check if this is a column identifier starting with a digit (e.g., "3_REVENUE")
// by looking ahead to see if there's an underscore after the digit(s)
var tempPos = Position;
while (tempPos < Length - 1 && char.IsDigit(_sqlStatement[tempPos]))
{
tempPos++;
}
// If we found an underscore after digit(s), treat as a column identifier
if (tempPos < Length - 1 && _sqlStatement[tempPos] == '_')
{
var stringValue = GrabStringValue();
_currentToken = new Token(TokenType.ColumnIdentifier, stringValue);
return;
}
// Otherwise, it's just a number
var number = GrabNumberValue();
_currentToken = new Token(TokenType.Number, number.ToString(CultureInfo.InvariantCulture));
}
/// <summary>
/// Reads a token that begins with a letter, producing a function-start token when immediately
/// followed by '(', or a string identifier token otherwise.
/// </summary>
private void ReadLetterToken()
{
var stringValue = GrabStringValue();
// if next char is opening paren then its a function
if (CurrentCharacter == '(')
{
MovePosition();
_inFunctionBlock = true;
_currentToken = new Token(TokenType.FunctionStart, stringValue);
return;
}
_currentToken = new Token(TokenType.String, stringValue);
}
/// <summary>
/// Allows derived classes to handle additional characters not covered by the base implementation.
/// For example, Snowflake uses double-quotes (") for delimited identifiers.
@@ -74,18 +74,18 @@ public class QueryMarkdownGenerationTests
Assert.That(File.Exists(outputFile), Is.True,
$"Markdown file was not created: {outputFile}");
TestContext.WriteLine($"Generated: {fileName}.md");
TestContext.Out.WriteLine($"Generated: {fileName}.md");
successCount++;
}
catch (FormatException ex)
{
// Skip files that can't be parsed (e.g., partial queries with only WITH clauses)
TestContext.WriteLine($"Skipped: {fileName}.sql - {ex.Message}");
TestContext.Out.WriteLine($"Skipped: {fileName}.sql - {ex.Message}");
skippedCount++;
}
}
TestContext.WriteLine($"Generated {successCount} markdown files, skipped {skippedCount} files in {_markdownOutputPath}");
TestContext.Out.WriteLine($"Generated {successCount} markdown files, skipped {skippedCount} files in {_markdownOutputPath}");
Assert.That(successCount, Is.GreaterThan(0), "At least one markdown file should be generated");
}
@@ -126,7 +126,7 @@ public class QueryMarkdownGenerationTests
catch (FormatException)
{
// Skip files that can't be parsed
TestContext.WriteLine($"Skipped: {fileName}.sql (could not parse)");
TestContext.Out.WriteLine($"Skipped: {fileName}.sql (could not parse)");
}
}
@@ -1,6 +1,5 @@
using Strata.SqlTools.SqlBreakdown.Enums.SQL;
using Strata.SqlTools.SqlBreakdown.Exceptions;
using Strata.SqlTools.SqlBreakdown.Exceptions;
using StatementReader = Strata.SqlTools.Statements.PostgreSql.StatementReader;
namespace Strata.SqlTools.SqlBreakdown.Tests.PostgreSql;
@@ -34,7 +34,7 @@ public class GetClausesCachingTests
// Assert
Assert.That(clauses2, Is.Not.SameAs(clauses1), "GetClauses should return a new instance after SelectClause changes");
Assert.That(clauses2.SelectClause.Clause, Is.EqualTo("id, name, email"));
Assert.That(clauses2.SelectClause?.Clause, Is.EqualTo("id, name, email"));
}
[Test]
@@ -50,7 +50,7 @@ public class GetClausesCachingTests
// Assert
Assert.That(clauses2, Is.Not.SameAs(clauses1), "GetClauses should return a new instance after FromClause changes");
Assert.That(clauses2.FromClause.Clause, Is.EqualTo("customers"));
Assert.That(clauses2.FromClause?.Clause, Is.EqualTo("customers"));
}
[Test]
@@ -66,7 +66,7 @@ public class GetClausesCachingTests
// Assert
Assert.That(clauses2, Is.Not.SameAs(clauses1), "GetClauses should return a new instance after WhereClause changes");
Assert.That(clauses2.WhereClause.Clause, Is.EqualTo("active = 1 AND deleted = 0"));
Assert.That(clauses2.WhereClause?.Clause, Is.EqualTo("active = 1 AND deleted = 0"));
}
[Test]
@@ -83,7 +83,7 @@ public class GetClausesCachingTests
// Assert
Assert.That(clauses2, Is.Not.SameAs(clauses1), "GetClauses should return a new instance after GroupByClause changes");
Assert.That(clauses2.GroupByClause.Clause, Is.EqualTo("status, type"));
Assert.That(clauses2.GroupByClause?.Clause, Is.EqualTo("status, type"));
}
[Test]
@@ -100,7 +100,7 @@ public class GetClausesCachingTests
// Assert
Assert.That(clauses2, Is.Not.SameAs(clauses1), "GetClauses should return a new instance after HavingClause changes");
Assert.That(clauses2.HavingClause.Clause, Is.EqualTo("COUNT(*) > 100"));
Assert.That(clauses2.HavingClause?.Clause, Is.EqualTo("COUNT(*) > 100"));
}
[Test]
@@ -117,7 +117,7 @@ public class GetClausesCachingTests
// Assert
Assert.That(clauses2, Is.Not.SameAs(clauses1), "GetClauses should return a new instance after OrderByClause changes");
Assert.That(clauses2.OrderByClause.Clause, Is.EqualTo("name DESC"));
Assert.That(clauses2.OrderByClause?.Clause, Is.EqualTo("name DESC"));
}
[Test]
@@ -159,7 +159,7 @@ public class GetClausesCachingTests
// Assert
Assert.That(clauses2, Is.Not.SameAs(clauses1), "GetClauses should return a new instance when clause object is replaced");
Assert.That(clauses2.SelectClause.Clause, Is.EqualTo("id, name, email"));
Assert.That(clauses2.SelectClause?.Clause, Is.EqualTo("id, name, email"));
}
[Test]
@@ -181,8 +181,8 @@ public class GetClausesCachingTests
// Assert
Assert.That(clauses2, Is.Not.SameAs(clauses1), "GetClauses should return a new instance after ApplyClauses");
Assert.That(clauses2.SelectClause.Clause, Is.EqualTo("id, name"));
Assert.That(clauses2.FromClause.Clause, Is.EqualTo("customers"));
Assert.That(clauses2.SelectClause?.Clause, Is.EqualTo("id, name"));
Assert.That(clauses2.FromClause?.Clause, Is.EqualTo("customers"));
}
[Test]
@@ -197,10 +197,10 @@ public class GetClausesCachingTests
// Assert
Assert.That(clauses, Is.Not.Null);
Assert.That(clauses.SelectClause.Clause, Is.EqualTo("id, name"));
Assert.That(clauses.FromClause.Clause, Is.EqualTo("users"));
Assert.That(clauses.WhereClause.Clause, Is.EqualTo("active = 1"));
Assert.That(clauses.OrderByClause.Clause, Is.EqualTo("name ASC"));
Assert.That(clauses.SelectClause?.Clause, Is.EqualTo("id, name"));
Assert.That(clauses.FromClause?.Clause, Is.EqualTo("users"));
Assert.That(clauses.WhereClause?.Clause, Is.EqualTo("active = 1"));
Assert.That(clauses.OrderByClause?.Clause, Is.EqualTo("name ASC"));
}
[Test]