From 012e693fe1b815013903377f4e2e9ac5dba20258 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Fri, 22 May 2026 17:13:31 -0500 Subject: [PATCH] chore: refactor for sonarqube issues --- .../Services/DbContextExtensions.cs | 7 +- .../Services/QueryBreakdownMapper.cs | 105 ++++----- .../Services/QueryBreakdownRepository.cs | 14 +- .../Visitors/LinqExpressionVisitor.cs | 11 - .../Common/CollectionReportWriter.cs | 40 ++-- .../LinqToSql/SqlStatementGenerator.cs | 5 +- src/Strata.SqlTools.Query/Value.cs | 3 +- .../ExpressionVisitor.cs | 24 -- src/Strata.SqlTools.Rules/RuleSetEngine.cs | 18 -- .../Breakdowns/ProcedureBreakdown.cs | 219 ++++++++++-------- .../Classes/SqlExpressionClause.cs | 88 ++++--- .../Literals/DateTimeLiteralExpression.cs | 2 - .../Extensions/StringExtensions.cs | 4 +- .../Utilities/SqlSchemaHelpers.cs | 6 - .../Breakdowns/DeleteBreakdown.cs | 166 +++++++------ .../Breakdowns/QueryBreakdown.cs | 53 +++-- .../Exceptions/SqlParseException.cs | 4 +- .../Statements/StatementParser.cs | 98 +++++--- .../Statements/StatementReader.cs | 81 ++++--- .../SqlServer/QueryMarkdownGenerationTests.cs | 8 +- .../PostgreSql/StatementReaderTests.cs | 1 - .../Performance/GetClausesCachingTests.cs | 26 +-- 22 files changed, 522 insertions(+), 461 deletions(-) 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]