Merge pull request 'chore: refactor for sonarqube issues' (#10) from fix/sonarqube-critical-major-debt into main
SonarQube Analysis / sonarqube (push) Successful in 5m46s
SonarQube Analysis / sonarqube (push) Successful in 5m46s
Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 => 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]
|
||||
|
||||
Reference in New Issue
Block a user