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="context">The DbContext instance.</param>
/// <param name="id">The ID of the QueryBreakdownEntity to retrieve.</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> /// <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); ArgumentNullException.ThrowIfNull(context);
return GetQueryBreakdownWithRelatedDataCoreAsync(context, id);
}
private static async Task<QueryBreakdownEntity?> GetQueryBreakdownWithRelatedDataCoreAsync(DbContext context, int id)
{
return await context.Set<QueryBreakdownEntity>() return await context.Set<QueryBreakdownEntity>()
.FirstOrDefaultAsync(q => q.Id == id); .FirstOrDefaultAsync(q => q.Id == id);
} }
@@ -3,6 +3,7 @@ using System.Text.Json;
using Strata.SqlTools.Breakdowns.SqlServer; using Strata.SqlTools.Breakdowns.SqlServer;
using Strata.SqlTools.EFCore.Abstractions; using Strata.SqlTools.EFCore.Abstractions;
using Strata.SqlTools.EFCore.Models; using Strata.SqlTools.EFCore.Models;
using Strata.SqlTools.SqlBreakdown.Classes;
namespace Strata.SqlTools.EFCore.Services; namespace Strata.SqlTools.EFCore.Services;
@@ -54,41 +55,12 @@ public class QueryBreakdownMapper : IQueryBreakdownMapper
var queryBreakdown = new QueryBreakdown(); var queryBreakdown = new QueryBreakdown();
// Set clause properties // Set clause properties
if (!string.IsNullOrEmpty(entity.SelectClause)) ApplyClause(queryBreakdown.SelectClause, entity.SelectClause, entity.SelectClauseComment);
{ ApplyClause(queryBreakdown.FromClause, entity.FromClause, entity.FromClauseComment);
queryBreakdown.SelectClause.Clause = entity.SelectClause; ApplyClause(queryBreakdown.WhereClause, entity.WhereClause, entity.WhereClauseComment);
queryBreakdown.SelectClause.Comment = entity.SelectClauseComment; 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.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;
}
if (!string.IsNullOrEmpty(entity.WithClause)) if (!string.IsNullOrEmpty(entity.WithClause))
{ {
@@ -194,32 +166,55 @@ public class QueryBreakdownMapper : IQueryBreakdownMapper
? paramEntity.ParameterName ? paramEntity.ParameterName
: $"@{paramEntity.ParameterName}"; : $"@{paramEntity.ParameterName}";
// Deserialize value if type information is available queryBreakdown.Parameters[key] = DeserializeParameterValue(paramEntity);
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;
} }
} }
return queryBreakdown; 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 #region Helper Methods
internal static string SerializeList(List<string> list) internal static string SerializeList(List<string> list)
@@ -90,10 +90,15 @@ public class QueryBreakdownRepository : IQueryBreakdownRepository
/// <summary> /// <summary>
/// Adds a new QueryBreakdown to the repository and saves changes. /// Adds a new QueryBreakdown to the repository and saves changes.
/// </summary> /// </summary>
public async Task<int> AddAsync(QueryBreakdown queryBreakdown) public Task<int> AddAsync(QueryBreakdown queryBreakdown)
{ {
ArgumentNullException.ThrowIfNull(queryBreakdown); ArgumentNullException.ThrowIfNull(queryBreakdown);
return AddCoreAsync(queryBreakdown);
}
private async Task<int> AddCoreAsync(QueryBreakdown queryBreakdown)
{
var (entity, parameters, withClauses) = _mapper.MapToEntityWithRelations(queryBreakdown); var (entity, parameters, withClauses) = _mapper.MapToEntityWithRelations(queryBreakdown);
// Add the main entity // Add the main entity
@@ -159,10 +164,15 @@ public class QueryBreakdownRepository : IQueryBreakdownRepository
/// <summary> /// <summary>
/// Updates an existing QueryBreakdown and saves changes. /// Updates an existing QueryBreakdown and saves changes.
/// </summary> /// </summary>
public async Task UpdateAsync(int id, QueryBreakdown queryBreakdown) public Task UpdateAsync(int id, QueryBreakdown queryBreakdown)
{ {
ArgumentNullException.ThrowIfNull(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); var entity = await _context.Set<QueryBreakdownEntity>().FirstOrDefaultAsync(e => e.Id == id);
if (entity == null) if (entity == null)
{ {
@@ -13,12 +13,7 @@ public class LinqExpressionVisitor : ExpressionVisitor
private readonly StringBuilder _orderByBuilder = new(); private readonly StringBuilder _orderByBuilder = new();
private readonly List<string> _methodCalls = new(); private readonly List<string> _methodCalls = new();
private bool _isInWhereClause; private bool _isInWhereClause;
#pragma warning disable IDE0052, S4487
private bool _isInSelectClause;
private bool _isInOrderByClause;
private bool _isInGroupByClause;
private string? _tableName; private string? _tableName;
#pragma warning restore IDE0052, S4487
/// <summary> /// <summary>
/// Gets the SELECT clause extracted from the expression. /// Gets the SELECT clause extracted from the expression.
@@ -167,9 +162,7 @@ public class LinqExpressionVisitor : ExpressionVisitor
var lambda = StripQuotes(node.Arguments[1]) as LambdaExpression; var lambda = StripQuotes(node.Arguments[1]) as LambdaExpression;
if (lambda != null) if (lambda != null)
{ {
_isInSelectClause = true;
var selectExpression = ExtractSelectExpression(lambda.Body); var selectExpression = ExtractSelectExpression(lambda.Body);
_isInSelectClause = false;
if (!string.IsNullOrEmpty(selectExpression)) if (!string.IsNullOrEmpty(selectExpression))
{ {
@@ -190,9 +183,7 @@ public class LinqExpressionVisitor : ExpressionVisitor
var lambda = StripQuotes(node.Arguments[1]) as LambdaExpression; var lambda = StripQuotes(node.Arguments[1]) as LambdaExpression;
if (lambda != null) if (lambda != null)
{ {
_isInOrderByClause = true;
var orderByExpression = ExtractMemberName(lambda.Body); var orderByExpression = ExtractMemberName(lambda.Body);
_isInOrderByClause = false;
if (!string.IsNullOrEmpty(orderByExpression)) if (!string.IsNullOrEmpty(orderByExpression))
{ {
@@ -220,9 +211,7 @@ public class LinqExpressionVisitor : ExpressionVisitor
var lambda = StripQuotes(node.Arguments[1]) as LambdaExpression; var lambda = StripQuotes(node.Arguments[1]) as LambdaExpression;
if (lambda != null) if (lambda != null)
{ {
_isInGroupByClause = true;
var groupByExpression = ExtractMemberName(lambda.Body); var groupByExpression = ExtractMemberName(lambda.Body);
_isInGroupByClause = false;
if (!string.IsNullOrEmpty(groupByExpression)) if (!string.IsNullOrEmpty(groupByExpression))
{ {
@@ -166,14 +166,7 @@ internal static class CollectionReportWriter
var allParamNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var allParamNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var query in queryBreakdowns) foreach (var query in queryBreakdowns)
{ {
foreach (var param in query.ParameterList) allParamNames.UnionWith(GetAllParameterNames(query));
{
allParamNames.Add(param.Name);
}
foreach (var paramName in query.Parameters.Keys)
{
allParamNames.Add(paramName);
}
} }
var parameters = allParamNames.OrderBy(p => p).ToList(); var parameters = allParamNames.OrderBy(p => p).ToList();
@@ -199,19 +192,7 @@ internal static class CollectionReportWriter
sb.AppendLine($" style {queryNode} fill:{format.QueryNodeFill}"); sb.AppendLine($" style {queryNode} fill:{format.QueryNodeFill}");
// Collect all parameter names used by this query // Collect all parameter names used by this query
var queryParamNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var queryParamNames = new HashSet<string>(GetAllParameterNames(query), 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);
}
// Connect parameters to this query // Connect parameters to this query
foreach (var paramName in queryParamNames) foreach (var paramName in queryParamNames)
@@ -233,6 +214,23 @@ internal static class CollectionReportWriter
return sb.ToString(); 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> /// <summary>
/// Renders the per-query composition report. The optional <paramref name="appendExtra"/> hook lets a /// 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). /// dialect append additional per-query sections (for example, Snowflake feature notes).
@@ -43,9 +43,10 @@ public class SqlStatementGenerator
var queryBreakdown = sqlBreakdown as IQueryBreakdown; var queryBreakdown = sqlBreakdown as IQueryBreakdown;
var tableNames = new List<string>(); 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); 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]) public Value() : this(string.Empty, string.Empty, new int[0], new string[0], new CalculationFilterGroup[0])
{ {
// FilterGroups = new List<CalculationFilterGroup>();
} }
[JsonConstructor] [JsonConstructor]
@@ -22,7 +21,7 @@ public class Value
ColumnAlias = columnAlias; ColumnAlias = columnAlias;
Calculation = calculation; Calculation = calculation;
CalculationDataColumnIds = calculationDataColumnIds ?? Array.Empty<int>(); 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>(); 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 // just converting it to an AND expression for now
var and = new And(With.Left, With.Right); var and = new And(With.Left, With.Right);
return and.Accept(this); 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}"; public virtual string VisitParameter(Parameter parameter) => $"{parameter.ParameterName}";
@@ -1,4 +1,3 @@
using System.Dynamic;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using RulesEngine.Interfaces; using RulesEngine.Interfaces;
using RulesEngine.Models; using RulesEngine.Models;
@@ -58,23 +57,6 @@ internal class RuleSetEngine
return success; 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> /// <summary>
@@ -124,101 +124,7 @@ public class ProcedureBreakdown : SqlServerProcedureBreakdown
try try
{ {
if (string.IsNullOrWhiteSpace(sql)) return TryParseCore(sql, out result, out errorMessage, isMicrosoftSql);
{
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;
} }
catch (Exception ex) 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 #endregion
} }
@@ -89,10 +89,7 @@ public class SqlExpressionClause : SqlClause, ISqlExpressionClause
{ {
var items = new List<string>(); var items = new List<string>();
var current = new StringBuilder(); var current = new StringBuilder();
var parenDepth = 0; var state = new SplitScanState();
var inSingleQuote = false;
var inDoubleQuote = false;
var inBracket = false;
for (int i = 0; i < clause.Length; i++) for (int i = 0; i < clause.Length; i++)
{ {
@@ -106,39 +103,10 @@ public class SqlExpressionClause : SqlClause, ISqlExpressionClause
continue; continue;
} }
// Toggle quote states state.UpdateForCharacter(ch);
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--;
}
}
// Split on comma only when not inside quotes, brackets, or parentheses // 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()); items.Add(current.ToString());
current.Clear(); current.Clear();
@@ -157,5 +125,55 @@ public class SqlExpressionClause : SqlClause, ISqlExpressionClause
return items; 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;
using Strata.SqlTools.SqlBreakdown.Interfaces.Core;
namespace Strata.SqlTools.SqlBreakdown.Expressions.Literals; namespace Strata.SqlTools.SqlBreakdown.Expressions.Literals;
/// <summary> /// <summary>
@@ -48,7 +48,7 @@ public static class StringExtensions
if (aCharCount < 0) if (aCharCount < 0)
{ {
throw new ArgumentException(); throw new ArgumentException("Character count cannot be negative.", nameof(aCharCount));
} }
if (aCharCount == 0) if (aCharCount == 0)
@@ -75,7 +75,7 @@ public static class StringExtensions
if (aCharCount < 0) if (aCharCount < 0)
{ {
throw new ArgumentException(); throw new ArgumentException("Character count cannot be negative.", nameof(aCharCount));
} }
if (aCharCount == 0) if (aCharCount == 0)
@@ -44,12 +44,6 @@ public static partial class SqlUtils
try try
{ {
// Would need to execute SQL here - skipping for this conversion // 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 // Remove system schemas
return list.Except(GetSystemSchemas()).ToList(); return list.Except(GetSystemSchemas()).ToList();
@@ -132,80 +132,7 @@ public class DeleteBreakdown : SqlBreakdownBase
try try
{ {
if (string.IsNullOrWhiteSpace(sql)) return TryParseCore(sql, out result, out errorMessage);
{
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;
} }
catch (Exception ex) 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 #endregion
} }
@@ -491,38 +491,41 @@ public class QueryBreakdown : SqlBreakdownBase, IQueryBreakdown
continue; continue;
} }
// Recursively collect parameters from nested CTEs in the CTE's query CollectFromCteQuery(withClause.Query, allCollectedParams);
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);
}
// For recursive CTEs, also collect parameters from the recursive query // 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 CollectFromCteQuery(withClause.RecursiveQuery, allCollectedParams);
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);
}
} }
} }
} }
/// <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> /// <summary>
/// Gets all parameters including those from the CTE hierarchy. /// Gets all parameters including those from the CTE hierarchy.
/// The main query's parameters take precedence over CTE parameters with the same name. /// The main query's parameters take precedence over CTE parameters with the same name.
@@ -48,7 +48,7 @@ public class SqlParseException : Exception
{ {
Position = position; Position = position;
Sql = sql ?? string.Empty; Sql = sql ?? string.Empty;
NearText = ExtractNearText(sql, position); NearText = ExtractNearText(Sql, position);
} }
/// <summary> /// <summary>
@@ -63,7 +63,7 @@ public class SqlParseException : Exception
{ {
Position = position; Position = position;
Sql = sql ?? string.Empty; Sql = sql ?? string.Empty;
NearText = ExtractNearText(sql, position); NearText = ExtractNearText(Sql, position);
} }
private static string FormatMessage(string message, string sql, int 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) // Only process keywords at top level (outside parentheses)
if (parenthesisDepth == 0 && type == TokenType.String) if (parenthesisDepth == 0 && type == TokenType.String)
{ {
// Check for multi-word keywords (GROUP BY, ORDER BY) skipNextToken = TryRecordKeywordAtToken(tokens, tokenIndex, value, position, keywords, keywordSet, clausePositions);
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;
}
}
} }
} }
} }
/// <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) public virtual SqlClauses ExtractAllClauses(string sql, Dictionary<string, int> clausePositions)
{ {
var clauses = new SqlClauses var clauses = new SqlClauses
@@ -116,49 +116,66 @@ public class StatementReader : IStatementReader
if (char.IsDigit(CurrentCharacter)) if (char.IsDigit(CurrentCharacter))
{ {
// Check if this is a column identifier starting with a digit (e.g., "3_REVENUE") ReadDigitToken();
// 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));
return; return;
} }
if (char.IsLetter(CurrentCharacter)) if (char.IsLetter(CurrentCharacter))
{ {
var stringValue = GrabStringValue(); ReadLetterToken();
// 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);
return; return;
} }
throw new InvalidSyntaxException($"Invalid syntax at position {Position}. Unexpected symbol {CurrentCharacter}."); 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> /// <summary>
/// Allows derived classes to handle additional characters not covered by the base implementation. /// Allows derived classes to handle additional characters not covered by the base implementation.
/// For example, Snowflake uses double-quotes (") for delimited identifiers. /// For example, Snowflake uses double-quotes (") for delimited identifiers.
@@ -74,18 +74,18 @@ public class QueryMarkdownGenerationTests
Assert.That(File.Exists(outputFile), Is.True, Assert.That(File.Exists(outputFile), Is.True,
$"Markdown file was not created: {outputFile}"); $"Markdown file was not created: {outputFile}");
TestContext.WriteLine($"Generated: {fileName}.md"); TestContext.Out.WriteLine($"Generated: {fileName}.md");
successCount++; successCount++;
} }
catch (FormatException ex) catch (FormatException ex)
{ {
// Skip files that can't be parsed (e.g., partial queries with only WITH clauses) // 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++; 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"); Assert.That(successCount, Is.GreaterThan(0), "At least one markdown file should be generated");
} }
@@ -126,7 +126,7 @@ public class QueryMarkdownGenerationTests
catch (FormatException) catch (FormatException)
{ {
// Skip files that can't be parsed // 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.Enums.SQL;
using Strata.SqlTools.SqlBreakdown.Exceptions; using Strata.SqlTools.SqlBreakdown.Exceptions;
using Strata.SqlTools.SqlBreakdown.Exceptions;
using StatementReader = Strata.SqlTools.Statements.PostgreSql.StatementReader; using StatementReader = Strata.SqlTools.Statements.PostgreSql.StatementReader;
namespace Strata.SqlTools.SqlBreakdown.Tests.PostgreSql; namespace Strata.SqlTools.SqlBreakdown.Tests.PostgreSql;
@@ -34,7 +34,7 @@ public class GetClausesCachingTests
// Assert // Assert
Assert.That(clauses2, Is.Not.SameAs(clauses1), "GetClauses should return a new instance after SelectClause changes"); 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] [Test]
@@ -50,7 +50,7 @@ public class GetClausesCachingTests
// Assert // Assert
Assert.That(clauses2, Is.Not.SameAs(clauses1), "GetClauses should return a new instance after FromClause changes"); 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] [Test]
@@ -66,7 +66,7 @@ public class GetClausesCachingTests
// Assert // Assert
Assert.That(clauses2, Is.Not.SameAs(clauses1), "GetClauses should return a new instance after WhereClause changes"); 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] [Test]
@@ -83,7 +83,7 @@ public class GetClausesCachingTests
// Assert // Assert
Assert.That(clauses2, Is.Not.SameAs(clauses1), "GetClauses should return a new instance after GroupByClause changes"); 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] [Test]
@@ -100,7 +100,7 @@ public class GetClausesCachingTests
// Assert // Assert
Assert.That(clauses2, Is.Not.SameAs(clauses1), "GetClauses should return a new instance after HavingClause changes"); 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] [Test]
@@ -117,7 +117,7 @@ public class GetClausesCachingTests
// Assert // Assert
Assert.That(clauses2, Is.Not.SameAs(clauses1), "GetClauses should return a new instance after OrderByClause changes"); 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] [Test]
@@ -159,7 +159,7 @@ public class GetClausesCachingTests
// Assert // Assert
Assert.That(clauses2, Is.Not.SameAs(clauses1), "GetClauses should return a new instance when clause object is replaced"); 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] [Test]
@@ -181,8 +181,8 @@ public class GetClausesCachingTests
// Assert // Assert
Assert.That(clauses2, Is.Not.SameAs(clauses1), "GetClauses should return a new instance after ApplyClauses"); 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.SelectClause?.Clause, Is.EqualTo("id, name"));
Assert.That(clauses2.FromClause.Clause, Is.EqualTo("customers")); Assert.That(clauses2.FromClause?.Clause, Is.EqualTo("customers"));
} }
[Test] [Test]
@@ -197,10 +197,10 @@ public class GetClausesCachingTests
// Assert // Assert
Assert.That(clauses, Is.Not.Null); Assert.That(clauses, Is.Not.Null);
Assert.That(clauses.SelectClause.Clause, Is.EqualTo("id, name")); Assert.That(clauses.SelectClause?.Clause, Is.EqualTo("id, name"));
Assert.That(clauses.FromClause.Clause, Is.EqualTo("users")); Assert.That(clauses.FromClause?.Clause, Is.EqualTo("users"));
Assert.That(clauses.WhereClause.Clause, Is.EqualTo("active = 1")); Assert.That(clauses.WhereClause?.Clause, Is.EqualTo("active = 1"));
Assert.That(clauses.OrderByClause.Clause, Is.EqualTo("name ASC")); Assert.That(clauses.OrderByClause?.Clause, Is.EqualTo("name ASC"));
} }
[Test] [Test]