diff --git a/src/Strata.SqlTools.EFCore/Services/DbContextExtensions.cs b/src/Strata.SqlTools.EFCore/Services/DbContextExtensions.cs
index 8a78900..16946f4 100644
--- a/src/Strata.SqlTools.EFCore/Services/DbContextExtensions.cs
+++ b/src/Strata.SqlTools.EFCore/Services/DbContextExtensions.cs
@@ -64,10 +64,15 @@ public static class DbContextExtensions
/// The DbContext instance.
/// The ID of the QueryBreakdownEntity to retrieve.
/// The QueryBreakdownEntity with related entities included, or null if not found.
- public static async Task GetQueryBreakdownWithRelatedDataAsync(this DbContext context, int id)
+ public static Task GetQueryBreakdownWithRelatedDataAsync(this DbContext context, int id)
{
ArgumentNullException.ThrowIfNull(context);
+ return GetQueryBreakdownWithRelatedDataCoreAsync(context, id);
+ }
+
+ private static async Task GetQueryBreakdownWithRelatedDataCoreAsync(DbContext context, int id)
+ {
return await context.Set()
.FirstOrDefaultAsync(q => q.Id == id);
}
diff --git a/src/Strata.SqlTools.EFCore/Services/QueryBreakdownMapper.cs b/src/Strata.SqlTools.EFCore/Services/QueryBreakdownMapper.cs
index 616581e..f68abe0 100644
--- a/src/Strata.SqlTools.EFCore/Services/QueryBreakdownMapper.cs
+++ b/src/Strata.SqlTools.EFCore/Services/QueryBreakdownMapper.cs
@@ -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;
}
+ ///
+ /// Applies a stored clause value and comment to a target clause, leaving it untouched when the value is empty.
+ ///
+ private static void ApplyClause(ISqlClause target, string? value, string? comment)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return;
+ }
+
+ target.Clause = value;
+ target.Comment = comment;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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 list)
diff --git a/src/Strata.SqlTools.EFCore/Services/QueryBreakdownRepository.cs b/src/Strata.SqlTools.EFCore/Services/QueryBreakdownRepository.cs
index e73e968..e11ec4e 100644
--- a/src/Strata.SqlTools.EFCore/Services/QueryBreakdownRepository.cs
+++ b/src/Strata.SqlTools.EFCore/Services/QueryBreakdownRepository.cs
@@ -90,10 +90,15 @@ public class QueryBreakdownRepository : IQueryBreakdownRepository
///
/// Adds a new QueryBreakdown to the repository and saves changes.
///
- public async Task AddAsync(QueryBreakdown queryBreakdown)
+ public Task AddAsync(QueryBreakdown queryBreakdown)
{
ArgumentNullException.ThrowIfNull(queryBreakdown);
+ return AddCoreAsync(queryBreakdown);
+ }
+
+ private async Task AddCoreAsync(QueryBreakdown queryBreakdown)
+ {
var (entity, parameters, withClauses) = _mapper.MapToEntityWithRelations(queryBreakdown);
// Add the main entity
@@ -159,10 +164,15 @@ public class QueryBreakdownRepository : IQueryBreakdownRepository
///
/// Updates an existing QueryBreakdown and saves changes.
///
- 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().FirstOrDefaultAsync(e => e.Id == id);
if (entity == null)
{
diff --git a/src/Strata.SqlTools.LinqToSql/Visitors/LinqExpressionVisitor.cs b/src/Strata.SqlTools.LinqToSql/Visitors/LinqExpressionVisitor.cs
index c2706ef..fc1e324 100644
--- a/src/Strata.SqlTools.LinqToSql/Visitors/LinqExpressionVisitor.cs
+++ b/src/Strata.SqlTools.LinqToSql/Visitors/LinqExpressionVisitor.cs
@@ -13,12 +13,7 @@ public class LinqExpressionVisitor : ExpressionVisitor
private readonly StringBuilder _orderByBuilder = new();
private readonly List _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
///
/// 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))
{
diff --git a/src/Strata.SqlTools.Markdown/Common/CollectionReportWriter.cs b/src/Strata.SqlTools.Markdown/Common/CollectionReportWriter.cs
index 0223dff..d869f7e 100644
--- a/src/Strata.SqlTools.Markdown/Common/CollectionReportWriter.cs
+++ b/src/Strata.SqlTools.Markdown/Common/CollectionReportWriter.cs
@@ -166,14 +166,7 @@ internal static class CollectionReportWriter
var allParamNames = new HashSet(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(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(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();
}
+ ///
+ /// Yields every parameter name referenced by a query, combining parsed parameters
+ /// () and manually added ones (Parameters dictionary).
+ ///
+ private static IEnumerable GetAllParameterNames(QueryBreakdown query)
+ {
+ foreach (var param in query.ParameterList)
+ {
+ yield return param.Name;
+ }
+
+ foreach (var paramName in query.Parameters.Keys)
+ {
+ yield return paramName;
+ }
+ }
+
///
/// Renders the per-query composition report. The optional hook lets a
/// dialect append additional per-query sections (for example, Snowflake feature notes).
diff --git a/src/Strata.SqlTools.Markdown/LinqToSql/SqlStatementGenerator.cs b/src/Strata.SqlTools.Markdown/LinqToSql/SqlStatementGenerator.cs
index d4d3ed2..3bb9583 100644
--- a/src/Strata.SqlTools.Markdown/LinqToSql/SqlStatementGenerator.cs
+++ b/src/Strata.SqlTools.Markdown/LinqToSql/SqlStatementGenerator.cs
@@ -43,9 +43,10 @@ public class SqlStatementGenerator
var queryBreakdown = sqlBreakdown as IQueryBreakdown;
var tableNames = new List();
- 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);
diff --git a/src/Strata.SqlTools.Query/Value.cs b/src/Strata.SqlTools.Query/Value.cs
index 514c024..c84325a 100644
--- a/src/Strata.SqlTools.Query/Value.cs
+++ b/src/Strata.SqlTools.Query/Value.cs
@@ -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();
}
[JsonConstructor]
@@ -22,7 +21,7 @@ public class Value
ColumnAlias = columnAlias;
Calculation = calculation;
CalculationDataColumnIds = calculationDataColumnIds ?? Array.Empty();
- AliasedIds = aliasedIds ?? aliasedIds ?? Array.Empty();
+ AliasedIds = aliasedIds ?? Array.Empty();
FilterGroups = filterGroups?.Where(x => x.IsValid()).ToList() ?? new List();
}
}
diff --git a/src/Strata.SqlTools.Rules/ExpressionVisitor.cs b/src/Strata.SqlTools.Rules/ExpressionVisitor.cs
index 2c46eea..32a2b3e 100644
--- a/src/Strata.SqlTools.Rules/ExpressionVisitor.cs
+++ b/src/Strata.SqlTools.Rules/ExpressionVisitor.cs
@@ -102,30 +102,6 @@ public class LocalVisitor : IVisitor
// 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}";
diff --git a/src/Strata.SqlTools.Rules/RuleSetEngine.cs b/src/Strata.SqlTools.Rules/RuleSetEngine.cs
index 133f299..814ac2c 100644
--- a/src/Strata.SqlTools.Rules/RuleSetEngine.cs
+++ b/src/Strata.SqlTools.Rules/RuleSetEngine.cs
@@ -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;
}
-
- ///
- /// Converts an object to an ExpandoObject by copying all public properties.
- ///
- /// The object to convert.
- /// An ExpandoObject containing all properties from the source object.
- private static ExpandoObject ConvertObjectToExpando(object obj)
- {
- var expando = new ExpandoObject();
- var dictionary = expando as IDictionary;
- foreach (var property in obj.GetType().GetProperties())
- {
- dictionary.Add(property.Name, property.GetValue(obj));
- }
-
- return expando;
- }
}
///
diff --git a/src/Strata.SqlTools.Snowflake/Breakdowns/ProcedureBreakdown.cs b/src/Strata.SqlTools.Snowflake/Breakdowns/ProcedureBreakdown.cs
index e445b9a..8d6d27a 100644
--- a/src/Strata.SqlTools.Snowflake/Breakdowns/ProcedureBreakdown.cs
+++ b/src/Strata.SqlTools.Snowflake/Breakdowns/ProcedureBreakdown.cs
@@ -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();
- 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();
-
- 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();
+ 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;
+ }
+
+ ///
+ /// Parses Microsoft T-SQL CALL/EXEC syntax by delegating to the SQL Server breakdown,
+ /// then projecting the result onto a Snowflake .
+ ///
+ 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;
+ }
+
+ ///
+ /// Parses the parameter list of a Snowflake CALL statement. Supports named (param => value)
+ /// syntax, falling back to positional values when no named parameters are present.
+ ///
+ private static Dictionary ParseCallParameters(string parametersText)
+ {
+ var parameters = new Dictionary();
+
+ 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
}
diff --git a/src/Strata.SqlTools.SqlBreakdown/Classes/SqlExpressionClause.cs b/src/Strata.SqlTools.SqlBreakdown/Classes/SqlExpressionClause.cs
index d52f0c1..e1bffdc 100644
--- a/src/Strata.SqlTools.SqlBreakdown/Classes/SqlExpressionClause.cs
+++ b/src/Strata.SqlTools.SqlBreakdown/Classes/SqlExpressionClause.cs
@@ -89,10 +89,7 @@ public class SqlExpressionClause : SqlClause, ISqlExpressionClause
{
var items = new List();
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;
}
+
+ ///
+ /// 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.
+ ///
+ private struct SplitScanState
+ {
+ public int ParenDepth;
+ public bool InSingleQuote;
+ public bool InDoubleQuote;
+ public bool InBracket;
+
+ /// Gets whether the scanner is currently inside a quoted string or bracketed identifier.
+ public readonly bool InsideDelimiter => InSingleQuote || InDoubleQuote || InBracket;
+
+ /// Updates the quote/bracket states and parenthesis depth for the given character.
+ 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--;
+ }
+ }
+ }
+ }
}
diff --git a/src/Strata.SqlTools.SqlBreakdown/Expressions/Literals/DateTimeLiteralExpression.cs b/src/Strata.SqlTools.SqlBreakdown/Expressions/Literals/DateTimeLiteralExpression.cs
index 91b1a9d..8c01132 100644
--- a/src/Strata.SqlTools.SqlBreakdown/Expressions/Literals/DateTimeLiteralExpression.cs
+++ b/src/Strata.SqlTools.SqlBreakdown/Expressions/Literals/DateTimeLiteralExpression.cs
@@ -1,7 +1,5 @@
using Strata.SqlTools.SqlBreakdown.Interfaces.Core;
-using Strata.SqlTools.SqlBreakdown.Interfaces.Core;
-
namespace Strata.SqlTools.SqlBreakdown.Expressions.Literals;
///
diff --git a/src/Strata.SqlTools.SqlBreakdown/Extensions/StringExtensions.cs b/src/Strata.SqlTools.SqlBreakdown/Extensions/StringExtensions.cs
index 602273a..694f360 100644
--- a/src/Strata.SqlTools.SqlBreakdown/Extensions/StringExtensions.cs
+++ b/src/Strata.SqlTools.SqlBreakdown/Extensions/StringExtensions.cs
@@ -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)
diff --git a/src/Strata.SqlTools.SqlBreakdown/Utilities/SqlSchemaHelpers.cs b/src/Strata.SqlTools.SqlBreakdown/Utilities/SqlSchemaHelpers.cs
index 003224a..4b51a0d 100644
--- a/src/Strata.SqlTools.SqlBreakdown/Utilities/SqlSchemaHelpers.cs
+++ b/src/Strata.SqlTools.SqlBreakdown/Utilities/SqlSchemaHelpers.cs
@@ -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();
diff --git a/src/Strata.SqlTools.SqlServer/Breakdowns/DeleteBreakdown.cs b/src/Strata.SqlTools.SqlServer/Breakdowns/DeleteBreakdown.cs
index 03b2f46..b8433b9 100644
--- a/src/Strata.SqlTools.SqlServer/Breakdowns/DeleteBreakdown.cs
+++ b/src/Strata.SqlTools.SqlServer/Breakdowns/DeleteBreakdown.cs
@@ -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();
- 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();
+ 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;
+ }
+
+ ///
+ /// Extracts inline comments from the DELETE clause text and applies the cleaned value and
+ /// combined comment to . No-op when the clause is blank.
+ ///
+ 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
}
diff --git a/src/Strata.SqlTools.SqlServer/Breakdowns/QueryBreakdown.cs b/src/Strata.SqlTools.SqlServer/Breakdowns/QueryBreakdown.cs
index 7cb4b8d..494c6e5 100644
--- a/src/Strata.SqlTools.SqlServer/Breakdowns/QueryBreakdown.cs
+++ b/src/Strata.SqlTools.SqlServer/Breakdowns/QueryBreakdown.cs
@@ -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);
}
}
}
+ ///
+ /// Recursively collects parameters from a single CTE query and its nested CTEs.
+ /// Parameters are added with an '@' prefix via TryAdd, so existing entries take precedence.
+ ///
+ private static void CollectFromCteQuery(IQueryBreakdown? query, Dictionary allCollectedParams)
+ {
+ if (query == null)
+ {
+ return;
+ }
+
+ // Recursively collect parameters from nested CTEs in this query
+ if (query is QueryBreakdown nestedQueryBreakdown)
+ {
+ nestedQueryBreakdown.CollectCteParameters(allCollectedParams);
+ }
+
+ // Add the query's own parameters (TryAdd means existing params take precedence)
+ foreach (var param in query.ParameterList)
+ {
+ var paramName = param.Name.StartsWith('@') ? param.Name : $"@{param.Name}";
+ allCollectedParams.TryAdd(paramName, param.Value);
+ }
+ }
+
///
/// Gets all parameters including those from the CTE hierarchy.
/// The main query's parameters take precedence over CTE parameters with the same name.
diff --git a/src/Strata.SqlTools.SqlServer/Exceptions/SqlParseException.cs b/src/Strata.SqlTools.SqlServer/Exceptions/SqlParseException.cs
index 875668c..ce55470 100644
--- a/src/Strata.SqlTools.SqlServer/Exceptions/SqlParseException.cs
+++ b/src/Strata.SqlTools.SqlServer/Exceptions/SqlParseException.cs
@@ -48,7 +48,7 @@ public class SqlParseException : Exception
{
Position = position;
Sql = sql ?? string.Empty;
- NearText = ExtractNearText(sql, position);
+ NearText = ExtractNearText(Sql, position);
}
///
@@ -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)
diff --git a/src/Strata.SqlTools.SqlServer/Statements/StatementParser.cs b/src/Strata.SqlTools.SqlServer/Statements/StatementParser.cs
index c2b7d5c..7bf7db3 100644
--- a/src/Strata.SqlTools.SqlServer/Statements/StatementParser.cs
+++ b/src/Strata.SqlTools.SqlServer/Statements/StatementParser.cs
@@ -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);
}
}
}
+ ///
+ /// Records the keyword at the given token (single-word or multi-word) into .
+ ///
+ /// True if a multi-word keyword (GROUP BY / ORDER BY) was matched and the following token should be skipped.
+ private static bool TryRecordKeywordAtToken(
+ List<(TokenType type, string value, int position)> tokens,
+ int tokenIndex,
+ string value,
+ int position,
+ string[] keywords,
+ HashSet keywordSet,
+ Dictionary 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;
+ }
+
+ ///
+ /// Records a two-word keyword (e.g., "GROUP BY") when the token at matches
+ /// and is immediately followed by "BY".
+ ///
+ /// True if the multi-word keyword pattern matched.
+ private static bool TryRecordMultiWordKeyword(
+ List<(TokenType type, string value, int position)> tokens,
+ int tokenIndex,
+ string firstWord,
+ string canonicalKeyword,
+ Dictionary 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 clausePositions)
{
var clauses = new SqlClauses
diff --git a/src/Strata.SqlTools.SqlServer/Statements/StatementReader.cs b/src/Strata.SqlTools.SqlServer/Statements/StatementReader.cs
index be61a0b..8a00263 100644
--- a/src/Strata.SqlTools.SqlServer/Statements/StatementReader.cs
+++ b/src/Strata.SqlTools.SqlServer/Statements/StatementReader.cs
@@ -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}.");
}
+ ///
+ /// 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.
+ ///
+ 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));
+ }
+
+ ///
+ /// Reads a token that begins with a letter, producing a function-start token when immediately
+ /// followed by '(', or a string identifier token otherwise.
+ ///
+ 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);
+ }
+
///
/// Allows derived classes to handle additional characters not covered by the base implementation.
/// For example, Snowflake uses double-quotes (") for delimited identifiers.
diff --git a/tests/Strata.SqlTools.Markdown.Tests/SqlServer/QueryMarkdownGenerationTests.cs b/tests/Strata.SqlTools.Markdown.Tests/SqlServer/QueryMarkdownGenerationTests.cs
index ea9b3c6..5ffd0aa 100644
--- a/tests/Strata.SqlTools.Markdown.Tests/SqlServer/QueryMarkdownGenerationTests.cs
+++ b/tests/Strata.SqlTools.Markdown.Tests/SqlServer/QueryMarkdownGenerationTests.cs
@@ -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)");
}
}
diff --git a/tests/Strata.SqlTools.PostgreSql.Tests/PostgreSql/StatementReaderTests.cs b/tests/Strata.SqlTools.PostgreSql.Tests/PostgreSql/StatementReaderTests.cs
index 4022a0c..d3ccaf6 100644
--- a/tests/Strata.SqlTools.PostgreSql.Tests/PostgreSql/StatementReaderTests.cs
+++ b/tests/Strata.SqlTools.PostgreSql.Tests/PostgreSql/StatementReaderTests.cs
@@ -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;
diff --git a/tests/Strata.SqlTools.SqlServer.Tests/Performance/GetClausesCachingTests.cs b/tests/Strata.SqlTools.SqlServer.Tests/Performance/GetClausesCachingTests.cs
index 62da350..1eb62d3 100644
--- a/tests/Strata.SqlTools.SqlServer.Tests/Performance/GetClausesCachingTests.cs
+++ b/tests/Strata.SqlTools.SqlServer.Tests/Performance/GetClausesCachingTests.cs
@@ -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]