From 2c481a283da4c295ffa1f93b322faa7aa3be2fc1 Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Sat, 18 Jul 2026 14:18:42 +0800 Subject: [PATCH 01/10] refactor: introduce HAVING expression tree models and AST nodes --- .../Ast/FqlHavingConditionNode.cs | 15 +++++++++++++++ .../Ast/FqlHavingGroupNode.cs | 8 ++++++++ .../Ast/FqlHavingLogicalNode.cs | 10 ++++++++++ .../Extensions/LogicalOperatorExtensions.cs | 13 +++++++++++++ .../Models/Aggregates/Aggregate.cs | 16 ++++++++++++++++ .../Models/Aggregates/HavingConditionNode.cs | 19 +++++++++++++++++++ .../Models/Aggregates/HavingGroupNode.cs | 10 ++++++++++ .../Models/Aggregates/HavingLogicalNode.cs | 15 +++++++++++++++ .../Models/Aggregates/HavingNode.cs | 6 ++++++ src/FlexQuery.NET/Models/QueryOptions.cs | 4 ++-- 10 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 src/FlexQuery.NET.Parsers.Fql/Ast/FqlHavingConditionNode.cs create mode 100644 src/FlexQuery.NET.Parsers.Fql/Ast/FqlHavingGroupNode.cs create mode 100644 src/FlexQuery.NET.Parsers.Fql/Ast/FqlHavingLogicalNode.cs create mode 100644 src/FlexQuery.NET/Extensions/LogicalOperatorExtensions.cs create mode 100644 src/FlexQuery.NET/Models/Aggregates/Aggregate.cs create mode 100644 src/FlexQuery.NET/Models/Aggregates/HavingConditionNode.cs create mode 100644 src/FlexQuery.NET/Models/Aggregates/HavingGroupNode.cs create mode 100644 src/FlexQuery.NET/Models/Aggregates/HavingLogicalNode.cs create mode 100644 src/FlexQuery.NET/Models/Aggregates/HavingNode.cs diff --git a/src/FlexQuery.NET.Parsers.Fql/Ast/FqlHavingConditionNode.cs b/src/FlexQuery.NET.Parsers.Fql/Ast/FqlHavingConditionNode.cs new file mode 100644 index 0000000..8820910 --- /dev/null +++ b/src/FlexQuery.NET.Parsers.Fql/Ast/FqlHavingConditionNode.cs @@ -0,0 +1,15 @@ +using FlexQuery.NET.Models.Aggregates; +using FlexQuery.NET.Parsers; + +namespace FlexQuery.NET.Parsers.Fql; + +internal sealed class FqlHavingConditionNode(AggregateFunction function, string field, string @operator, string value) + : FqlAstNode +{ + public AggregateFunction Function { get; } = function; + public string Field { get; } = field; + public string Operator { get; } = @operator; + public string Value { get; } = value; + + public override string ToString() => $"{Function.ToKeyword().ToUpperInvariant()}({Field}) {Operator} {Value}"; +} diff --git a/src/FlexQuery.NET.Parsers.Fql/Ast/FqlHavingGroupNode.cs b/src/FlexQuery.NET.Parsers.Fql/Ast/FqlHavingGroupNode.cs new file mode 100644 index 0000000..bc1a138 --- /dev/null +++ b/src/FlexQuery.NET.Parsers.Fql/Ast/FqlHavingGroupNode.cs @@ -0,0 +1,8 @@ +namespace FlexQuery.NET.Parsers.Fql; + +internal sealed class FqlHavingGroupNode(FqlAstNode inner) : FqlAstNode +{ + public FqlAstNode Inner { get; } = inner; + + public override string ToString() => $"({Inner})"; +} diff --git a/src/FlexQuery.NET.Parsers.Fql/Ast/FqlHavingLogicalNode.cs b/src/FlexQuery.NET.Parsers.Fql/Ast/FqlHavingLogicalNode.cs new file mode 100644 index 0000000..ca15c38 --- /dev/null +++ b/src/FlexQuery.NET.Parsers.Fql/Ast/FqlHavingLogicalNode.cs @@ -0,0 +1,10 @@ +namespace FlexQuery.NET.Parsers.Fql; +using FlexQuery.NET.Models.Filters; + +internal sealed class FqlHavingLogicalNode(LogicOperator logic, IReadOnlyList children) : FqlAstNode +{ + public LogicOperator Logic { get; } = logic; + public IReadOnlyList Children { get; } = children; + + public override string ToString() => $"{Logic.ToKeyword()}({string.Join(", ", Children)})"; +} diff --git a/src/FlexQuery.NET/Extensions/LogicalOperatorExtensions.cs b/src/FlexQuery.NET/Extensions/LogicalOperatorExtensions.cs new file mode 100644 index 0000000..d91d467 --- /dev/null +++ b/src/FlexQuery.NET/Extensions/LogicalOperatorExtensions.cs @@ -0,0 +1,13 @@ +using FlexQuery.NET.Models.Filters; + +namespace FlexQuery.NET; + +internal static class LogicalOperatorExtensions +{ + public static string ToKeyword(this LogicOperator logicOperator) => logicOperator switch + { + LogicOperator.And => "AND", + LogicOperator.Or => "OR", + _ => throw new ArgumentOutOfRangeException(nameof(logicOperator), logicOperator, null) + }; +} \ No newline at end of file diff --git a/src/FlexQuery.NET/Models/Aggregates/Aggregate.cs b/src/FlexQuery.NET/Models/Aggregates/Aggregate.cs new file mode 100644 index 0000000..865feb1 --- /dev/null +++ b/src/FlexQuery.NET/Models/Aggregates/Aggregate.cs @@ -0,0 +1,16 @@ +namespace FlexQuery.NET.Models.Aggregates; + +/// +/// Represents an aggregate projection expression (sum/count/avg). +/// +public sealed class Aggregate +{ + /// Aggregate function. + public AggregateFunction Function { get; set; } + + /// Field path to aggregate. Optional for count. + public string? Field { get; set; } + + /// Output property name in the projected shape. + public string Alias { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/FlexQuery.NET/Models/Aggregates/HavingConditionNode.cs b/src/FlexQuery.NET/Models/Aggregates/HavingConditionNode.cs new file mode 100644 index 0000000..38a6359 --- /dev/null +++ b/src/FlexQuery.NET/Models/Aggregates/HavingConditionNode.cs @@ -0,0 +1,19 @@ +namespace FlexQuery.NET.Models.Aggregates; + +/// +/// Leaf HAVING condition: aggregate-function:field:operator:value. +/// +public sealed class HavingConditionNode : HavingNode +{ + /// Aggregate function. + public AggregateFunction Function { get; set; } + + /// Aggregate field path. Optional for count. + public string? Field { get; set; } + + /// Comparison operator (eq, gt, gte, lt, lte, neq). + public string Operator { get; set; } = "eq"; + + /// Raw comparison value. + public string? Value { get; set; } +} diff --git a/src/FlexQuery.NET/Models/Aggregates/HavingGroupNode.cs b/src/FlexQuery.NET/Models/Aggregates/HavingGroupNode.cs new file mode 100644 index 0000000..039f387 --- /dev/null +++ b/src/FlexQuery.NET/Models/Aggregates/HavingGroupNode.cs @@ -0,0 +1,10 @@ +namespace FlexQuery.NET.Models.Aggregates; + +/// +/// Parenthesized HAVING group. +/// +public sealed class HavingGroupNode : HavingNode +{ + /// Inner expression. + public HavingNode Inner { get; set; } = null!; +} diff --git a/src/FlexQuery.NET/Models/Aggregates/HavingLogicalNode.cs b/src/FlexQuery.NET/Models/Aggregates/HavingLogicalNode.cs new file mode 100644 index 0000000..b6687cb --- /dev/null +++ b/src/FlexQuery.NET/Models/Aggregates/HavingLogicalNode.cs @@ -0,0 +1,15 @@ +using FlexQuery.NET.Models.Filters; + +namespace FlexQuery.NET.Models.Aggregates; + +/// +/// Logical combination of HAVING expressions (AND / OR). +/// +public sealed class HavingLogicalNode : HavingNode +{ + /// Logic operator: "and" or "or". + public LogicOperator Logic { get; set; } = LogicOperator.And; + + /// Child expressions. + public List Children { get; set; } = []; +} diff --git a/src/FlexQuery.NET/Models/Aggregates/HavingNode.cs b/src/FlexQuery.NET/Models/Aggregates/HavingNode.cs new file mode 100644 index 0000000..c5b0e51 --- /dev/null +++ b/src/FlexQuery.NET/Models/Aggregates/HavingNode.cs @@ -0,0 +1,6 @@ +namespace FlexQuery.NET.Models.Aggregates; + +/// +/// Abstract base for HAVING expression tree nodes. +/// +public abstract class HavingNode; diff --git a/src/FlexQuery.NET/Models/QueryOptions.cs b/src/FlexQuery.NET/Models/QueryOptions.cs index f01e95b..167e918 100644 --- a/src/FlexQuery.NET/Models/QueryOptions.cs +++ b/src/FlexQuery.NET/Models/QueryOptions.cs @@ -37,10 +37,10 @@ public sealed class QueryOptions public List? GroupBy { get; set; } /// Aggregate projection expressions (sum, count, avg). - public List Aggregates { get; set; } = new(); + public List Aggregates { get; set; } = new(); /// HAVING condition against aggregate projections. - public HavingCondition? Having { get; set; } + public HavingNode? Having { get; set; } /// If true, applies Distinct() to the query. public bool? Distinct { get; set; } From 8935653a9dac4b480ddd0adaf12c12f639b8bf78 Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Sat, 18 Jul 2026 14:18:53 +0800 Subject: [PATCH 02/10] feat: add recursive-descent HAVING parsers for DSL and FQL with AND/OR/parentheses --- .../Parsing/FqlHavingAstParser.cs | 229 ++++++++++++++ .../Parsing/FqlHavingParser.cs | 228 ++------------ src/FlexQuery.NET/Parsers/DslHavingParser.cs | 289 +++++++++++++++--- 3 files changed, 503 insertions(+), 243 deletions(-) create mode 100644 src/FlexQuery.NET.Parsers.Fql/Parsing/FqlHavingAstParser.cs diff --git a/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlHavingAstParser.cs b/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlHavingAstParser.cs new file mode 100644 index 0000000..add131d --- /dev/null +++ b/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlHavingAstParser.cs @@ -0,0 +1,229 @@ +using FlexQuery.NET.Models.Aggregates; +using FlexQuery.NET.Models.Filters; + +namespace FlexQuery.NET.Parsers.Fql; + +/// +/// Recursive-descent parser for FQL HAVING expressions. +/// Grammar: +/// expression ::= or +/// or ::= and ('OR' and)* +/// and ::= factor ('AND' factor)* +/// factor ::= '(' expression ')' | aggregateCondition +/// aggregateCondition ::= Function '(' Field? ')' Operator Value +/// +internal sealed class FqlHavingAstParser(IReadOnlyList tokens) +{ + private int _position; + + public static FqlAstNode Parse(string source) + { + var tokens = new FqlTokenizer(source).Tokenize(); + var parser = new FqlHavingAstParser(tokens); + var result = parser.ParseExpression(); + + if (parser.Current.Kind != FqlTokenType.End) + throw new FqlParseException($"Unexpected token at position {parser.Current.Position}."); + + return result; + } + + private FqlToken Current => tokens[_position]; + + private FqlAstNode ParseExpression() + { + var left = ParseOr(); + + while (true) + { + if (Match(FqlTokenType.And)) + { + var right = ParseOr(); + left = new FqlHavingLogicalNode(LogicOperator.And, Flatten("and", [left, right])); + } + else if (Match(FqlTokenType.Or)) + { + var right = ParseOr(); + left = new FqlHavingLogicalNode(LogicOperator.Or, Flatten("or", [left, right])); + } + else + { + break; + } + } + + return left; + } + + private FqlAstNode ParseOr() + { + var left = ParseAnd(); + + while (Match(FqlTokenType.Or)) + { + var right = ParseAnd(); + left = new FqlHavingLogicalNode(LogicOperator.And, Flatten("or", [left, right])); + } + + return left; + } + + private FqlAstNode ParseAnd() + { + var left = ParseFactor(); + + while (Match(FqlTokenType.And)) + { + var right = ParseFactor(); + left = new FqlHavingLogicalNode(LogicOperator.And, Flatten("and", [left, right])); + } + + return left; + } + + private FqlAstNode ParseFactor() + { + if (!Match(FqlTokenType.OpenParen)) return ParseAggregateCondition(); + + var inner = ParseExpression(); + Expect(FqlTokenType.CloseParen); + return new FqlHavingGroupNode(inner); + + } + + private FqlAstNode ParseAggregateCondition() + { + if (_position >= tokens.Count) + throw new FqlParseException("Expected aggregate condition but reached end of HAVING expression."); + + var (fn, fnValue) = ExpectFunctionToken(); + + if (!Match(FqlTokenType.OpenParen)) + throw new FqlParseException($"Expected '(' after aggregate function '{fnValue}'."); + + string? field = null; + if (Current.Kind != FqlTokenType.CloseParen) + { + if (Match(FqlTokenType.Star)) + { + throw new FqlParseException($"Expected field name inside aggregate function '{fnValue}'."); + } + + var fieldParts = new List(); + while (true) + { + if (Current.Kind == FqlTokenType.Identifier) + { + fieldParts.Add(Current.Value); + _position++; + } + else if (Match(FqlTokenType.Dot)) + { + if (Current.Kind == FqlTokenType.Identifier) + { + fieldParts.Add(Current.Value); + _position++; + } + else + { + throw new FqlParseException($"Expected identifier after '.' in field path at position {Current.Position}."); + } + } + else + { + break; + } + } + + if (fieldParts.Count == 0) + throw new FqlParseException($"Expected field name inside aggregate function '{fnValue}'."); + + field = string.Join('.', fieldParts); + + if (!ParserUtilities.IsValidPropertyPath(field.AsSpan())) + throw new FqlParseException($"Invalid field '{field}' in HAVING expression."); + } + + Expect(FqlTokenType.CloseParen); + + if (Current.Kind is FqlTokenType.And or FqlTokenType.Or) + throw new FqlParseException($"Missing operator after aggregate condition '{fnValue}({field})'."); + + var op = ParseComparisonOperator(); + + if (Current.Kind is FqlTokenType.And or FqlTokenType.Or or FqlTokenType.End or FqlTokenType.CloseParen or FqlTokenType.CloseBracket) + throw new FqlParseException($"Missing value after operator in HAVING expression."); + + var value = ParseValue(); + + return new FqlHavingConditionNode(fn, field ?? string.Empty, op, value); + } + + private (AggregateFunction Function, string Value) ExpectFunctionToken() + { + var token = Current; + if (token.Kind is FqlTokenType.Sum or FqlTokenType.Avg or FqlTokenType.Min or FqlTokenType.Max or FqlTokenType.Count) + { + _position++; + var fn = AggregateFunctionConverter.Parse(token.Value.ToLowerInvariant()); + return (fn, token.Value); + } + + throw new FqlParseException($"Expected aggregate function (SUM, COUNT, AVG, MIN, MAX) but found '{token.Value}' at position {token.Position}."); + } + + private string ParseComparisonOperator() + { + if (Match(FqlTokenType.Gte)) return "gte"; + if (Match(FqlTokenType.Lte)) return "lte"; + if (Match(FqlTokenType.Neq)) return "neq"; + if (Match(FqlTokenType.Gt)) return "gt"; + if (Match(FqlTokenType.Lt)) return "lt"; + if (Match(FqlTokenType.Eq)) return "eq"; + + throw new FqlParseException($"Expected comparison operator (>, >=, <, <=, =, <>) at position {Current.Position}."); + } + + private string ParseValue() + { + if (_position >= tokens.Count) + throw new FqlParseException("Expected value but reached end of HAVING expression."); + + var token = Current; + _position++; + + return token.Value; + } + + private bool Match(FqlTokenType kind) + { + if (Current.Kind != kind) return false; + _position++; + return true; + } + + private FqlToken Expect(FqlTokenType kind) + { + if (Current.Kind != kind) + throw new FqlParseException($"Expected {kind} at position {Current.Position}, but found {Current.Kind}."); + + return tokens[_position++]; + } + + private static IReadOnlyList Flatten(string logic, IReadOnlyList children) + { + var result = new List(); + foreach (var child in children) + { + if (child is FqlHavingLogicalNode logical && string.Equals(logical.Logic.ToKeyword(), logic, StringComparison.OrdinalIgnoreCase)) + { + result.AddRange(logical.Children); + continue; + } + + result.Add(child); + } + + return result; + } +} diff --git a/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlHavingParser.cs b/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlHavingParser.cs index 16ac213..1f8c16f 100644 --- a/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlHavingParser.cs +++ b/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlHavingParser.cs @@ -1,229 +1,43 @@ using FlexQuery.NET.Constants; using FlexQuery.NET.Models.Aggregates; +using FlexQuery.NET.Parsers; namespace FlexQuery.NET.Parsers.Fql; internal static class FqlHavingParser { - public static HavingCondition? Parse(string? rawHaving) + public static HavingNode? Parse(string? rawHaving) { if (string.IsNullOrWhiteSpace(rawHaving)) return null; var trimmed = rawHaving.Trim(); - var parenIndex = trimmed.IndexOf('('); - if (parenIndex <= 0) - throw new FqlParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - $"Expected format: FUNCTION(Field) OPERATOR value. " + - $"Missing function call."); + if (trimmed.Length == 0) return null; - AggregateFunction function; - try - { - function = AggregateFunctionConverter.Parse(trimmed[..parenIndex].Trim()); - } - catch - { - throw new FqlParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - $"Expected format: FUNCTION(Field) OPERATOR value. " + - $"Unrecognized aggregate function."); - } - - var closeParen = trimmed.IndexOf(')', parenIndex); - if (closeParen < 0) - throw new FqlParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - $"Missing closing parenthesis."); - - var fieldRaw = trimmed[(parenIndex + 1)..closeParen].Trim(); - var rest = trimmed[(closeParen + 1)..].Trim(); - - if (rest.Length == 0) - throw new FqlParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - $"Expected format: FUNCTION(Field) OPERATOR value. " + - $"Missing operator and value after function call."); - - if (function == AggregateFunction.Count && fieldRaw == "*") - { - throw new FqlParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - $"COUNT(*) is not supported. Use COUNT() or another aggregate over a property instead."); - } - - var op = ParseOperator(rest, rawHaving); - var value = ParseValue(rest, op, rawHaving); - - var field = fieldRaw.Length == 0 || fieldRaw == "*" - ? null - : fieldRaw; - - if (field is not null && !ParserUtilities.IsValidPropertyPath(field.AsSpan())) - throw new FqlParseException( - $"Invalid field '{field}' in HAVING expression '{rawHaving}'. " + - "Field must be a valid property path."); - - return new HavingCondition - { - Function = function, - Field = field, - Operator = op, - Value = value - }; + var ast = FqlHavingAstParser.Parse(trimmed); + return Convert(ast); } - private static string ParseOperator(string rest, string rawHaving) - { - if (IsWord(rest, "IS NOT NULL")) - return FilterOperators.IsNotNull; - if (IsWord(rest, "IS NULL")) - return FilterOperators.IsNull; - if (IsWord(rest, "NOT IN")) - return FilterOperators.NotIn; - if (IsWord(rest, "BETWEEN")) - return FilterOperators.Between; - if (IsWord(rest, "LIKE")) - return FilterOperators.Like; - if (IsWord(rest, "IN")) - return FilterOperators.In; - - if (rest.StartsWith(">=", StringComparison.Ordinal)) - return FilterOperators.GreaterThanOrEq; - if (rest.StartsWith("<=", StringComparison.Ordinal)) - return FilterOperators.LessThanOrEq; - - if (rest.StartsWith("!=", StringComparison.Ordinal) || rest.StartsWith("<>", StringComparison.Ordinal)) - return FilterOperators.NotEqual; - - if (rest.StartsWith(">", StringComparison.Ordinal)) - return FilterOperators.GreaterThan; - if (rest.StartsWith("<", StringComparison.Ordinal)) - return FilterOperators.LessThan; - if (rest.StartsWith("=", StringComparison.Ordinal)) - return FilterOperators.Equal; - - throw new FqlParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - $"Expected format: FUNCTION(Field) OPERATOR value. " + - $"Unrecognized operator in '{rest}'."); - } - - private static string? ParseValue(string rest, string op, string rawHaving) - { - if (op is FilterOperators.IsNull or FilterOperators.IsNotNull) + private static HavingNode Convert(FqlAstNode node) + => node switch { - var opStr = op == FilterOperators.IsNull ? "IS NULL" : "IS NOT NULL"; - var afterOp = rest[opStr.Length..].Trim(); - if (afterOp.Length > 0) + FqlHavingConditionNode c => new HavingConditionNode { - throw new FqlParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - $"Unexpected content after '{opStr}'."); - } - - return null; - } - - if (op == FilterOperators.Between) - { - var afterBetween = rest["BETWEEN".Length..].Trim(); - var andIndex = afterBetween.IndexOf("AND", StringComparison.OrdinalIgnoreCase); - if (andIndex < 0) + Function = c.Function, + Field = string.IsNullOrWhiteSpace(c.Field) ? null : c.Field, + Operator = FilterOperators.Normalize(c.Operator), + Value = c.Value + }, + FqlHavingLogicalNode logicalNode => new HavingLogicalNode { - throw new FqlParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - $"BETWEEN requires two values separated by AND."); - } - - var val1 = afterBetween[..andIndex].Trim().Trim('\'', '"'); - var val2 = afterBetween[(andIndex + 3)..].Trim().Trim('\'', '"'); - - if (val1.Length == 0) + Logic = logicalNode.Logic, + Children = logicalNode.Children.Select(Convert).ToList() + }, + FqlHavingGroupNode g => new HavingGroupNode { - throw new FqlParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - $"Missing first value in BETWEEN."); - } - - if (val2.Length == 0) - { - throw new FqlParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - $"Missing second value in BETWEEN."); - } - - return $"{val1},{val2}"; - } - - if (op is FilterOperators.In or FilterOperators.NotIn) - { - var notIn = op == FilterOperators.In ? "IN" : "NOT IN"; - var afterIn = rest[notIn.Length..].Trim(); - - if (!afterIn.StartsWith("(")) - { - throw new FqlParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - $"IN requires a parenthesized list of values."); - } - - var closeParen = afterIn.IndexOf(')'); - if (closeParen < 0) - { - throw new FqlParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - $"Missing closing parenthesis in IN list."); - } - - var valuesStr = afterIn[1..closeParen].Trim(); - var values = valuesStr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(v => v.Trim('\'', '"')) - .ToArray(); - - if (values.Length == 0) - { - throw new FqlParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - $"IN list cannot be empty."); - } - - return string.Join(",", values); - } - - var opLen = op switch - { - FilterOperators.GreaterThanOrEq or FilterOperators.LessThanOrEq or FilterOperators.NotEqual => 2, - FilterOperators.GreaterThan or FilterOperators.LessThan or FilterOperators.Equal => 1, - FilterOperators.Like => "LIKE".Length, - _ => throw new FqlParseException($"Unable to parse HAVING expression '{rawHaving}'. " + - $"Unsupported operator '{op}'.") + Inner = Convert(g.Inner) + }, + _ => throw new FqlParseException($"Unsupported HAVING AST node type: {node.GetType().Name}.") }; - - var value = rest[opLen..].Trim().Trim('\'', '"'); - - if (string.IsNullOrEmpty(value)) - { - throw new FqlParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - $"Missing value after operator."); - } - - return value; - } - - private static bool IsWord(string rest, string word) - { - if (!rest.StartsWith(word, StringComparison.OrdinalIgnoreCase)) - return false; - - var idx = word.Length; - if (idx >= rest.Length) - return true; - - var next = rest[idx]; - return !char.IsLetterOrDigit(next); - } } diff --git a/src/FlexQuery.NET/Parsers/DslHavingParser.cs b/src/FlexQuery.NET/Parsers/DslHavingParser.cs index c153513..6142b5a 100644 --- a/src/FlexQuery.NET/Parsers/DslHavingParser.cs +++ b/src/FlexQuery.NET/Parsers/DslHavingParser.cs @@ -1,64 +1,281 @@ using FlexQuery.NET.Models.Aggregates; using FlexQuery.NET.Constants; using FlexQuery.NET.Parsers.Dsl; +using System.Text; +using FlexQuery.NET.Models.Filters; namespace FlexQuery.NET.Parsers; /// /// Parses HAVING clause expressions for grouped queries using native DSL syntax. -/// Format: function:field:operator:value +/// Supports AND, OR, and parentheses for grouping. +/// Canonical DSL syntax: aggregate-function:field:operator:value +/// Example: sum:total:gt:100 AND count:id:gte:5 /// internal static class DslHavingParser { /// - /// Parses a HAVING clause string into a . + /// Parses a HAVING clause string into a tree. /// Throws if the input is malformed. /// - public static HavingCondition? Parse(string? rawHaving) + public static HavingNode? Parse(string? rawHaving) { - if (string.IsNullOrWhiteSpace(rawHaving)) - return null; + if (string.IsNullOrWhiteSpace(rawHaving)) return null; var trimmed = rawHaving.Trim(); - var parts = trimmed.Split(new[] { ':' }, 4); + if (trimmed.Length == 0) return null; - if (parts.Length != 4) + var tokens = Tokenize(trimmed); + var parser = new HavingTokenParser(tokens); + var result = parser.ParseExpression(); + + if (parser.CurrentToken != null) throw new DslParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - "Expected format: FUNCTION:Field:OPERATOR:value. " + - "For example: sum:total:gt:100"); + $"Unable to parse HAVING expression '{rawHaving}'. Unexpected token at position {parser.CurrentToken.Position}."); - var fnRaw = parts[0].Trim(); - var field = parts[1].Trim(); - var rawOp = parts[2].Trim(); - var value = parts[3].Trim(); + return result; + } - if (fnRaw.Length == 0) - throw new DslParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - "Expected format: FUNCTION:Field:OPERATOR:value. " + - "For example: sum:total:gt:100"); + private record HavingToken(string Value, int Position); - if (value.Length == 0) - throw new DslParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - "Missing value after operator."); + private static List Tokenize(string input) + { + var tokens = new List(); + var sb = new StringBuilder(); + var pos = 0; - var aggregateRef = AggregateGrammar.ParseFunctionField($"{fnRaw}:{field}"); + while (pos < input.Length) + { + var ch = input[pos]; - var normalizedOp = FilterOperators.Normalize(rawOp); - if (!FilterOperators.IsSupported(normalizedOp)) - throw new DslParseException( - $"Unable to parse HAVING expression '{rawHaving}'. " + - $"Unsupported operator '{rawOp}'. " + - $"Expected one of: eq, neq, gt, gte, lt, lte, contains, startswith, endswith, like, isnull, isnotnull, in, notin, between, any, all, count."); + if (char.IsWhiteSpace(ch)) + { + if (sb.Length > 0) + { + tokens.Add(new HavingToken(sb.ToString(), pos - sb.Length)); + sb.Clear(); + } + pos++; + continue; + } + + switch (ch) + { + case '(' or ')': + { + if (sb.Length > 0) + { + tokens.Add(new HavingToken(sb.ToString(), pos - sb.Length)); + sb.Clear(); + } + tokens.Add(new HavingToken(ch.ToString(), pos)); + pos++; + continue; + } + case '\'' or '"': + { + if (sb.Length > 0) + { + tokens.Add(new HavingToken(sb.ToString(), pos - sb.Length)); + sb.Clear(); + } + + var quote = ch; + pos++; + var valueStart = pos; + while (pos < input.Length && input[pos] != quote) + pos++; + + var value = input[valueStart..pos]; + if (pos < input.Length) pos++; // skip closing quote + + tokens.Add(new HavingToken(value, valueStart)); + continue; + } + default: + sb.Append(ch); + pos++; + break; + } + } + + if (sb.Length > 0) + { + tokens.Add(new HavingToken(sb.ToString(), pos - sb.Length)); + } + + return tokens; + } + + private sealed class HavingTokenParser(List tokens) + { + private int _position; + public HavingToken? CurrentToken => _position < tokens.Count ? tokens[_position] : null; + + private HavingToken Expect(string expected) + { + if (_position >= tokens.Count) + throw new DslParseException($"Expected '{expected}' but reached end of HAVING expression."); + + var token = tokens[_position]; + if (!string.Equals(token.Value, expected, StringComparison.OrdinalIgnoreCase)) + throw new DslParseException($"Expected '{expected}' but found '{token.Value}' at position {token.Position}."); + + _position++; + return token; + } + + private bool Match(string value) + { + if (_position >= tokens.Count || + !string.Equals(tokens[_position].Value, value, StringComparison.OrdinalIgnoreCase)) return false; + _position++; + return true; + } + + private HavingToken Consume() + { + return _position >= tokens.Count ? throw new DslParseException($"Unexpected end of HAVING expression.") : tokens[_position++]; + } + + public HavingNode ParseExpression() + { + return ParseOr(); + } + + private HavingNode ParseOr() + { + var left = ParseAnd(); + + while (Match("OR")) + { + var right = ParseAnd(); + left = new HavingLogicalNode { Logic = LogicOperator.Or, Children = [left, right] }; + } + + return left; + } + + private HavingNode ParseAnd() + { + var left = ParseFactor(); + + while (Match("AND")) + { + var right = ParseFactor(); + left = new HavingLogicalNode { Logic = LogicOperator.And, Children = [left, right] }; + } + + return left; + } + + private HavingNode ParseFactor() + { + if (!Match("(")) return ParseCondition(); + var inner = ParseExpression(); + Expect(")"); + return new HavingGroupNode { Inner = inner }; + + } + + private HavingNode ParseCondition() + { + if (_position >= tokens.Count) + throw new DslParseException("Expected HAVING condition but reached end of expression."); + + var conditionParts = new List(); + var startPos = tokens[_position].Position; + + while (_position < tokens.Count && tokens[_position].Value is not ("AND" or "OR" or "(" or ")")) + { + conditionParts.Add(tokens[_position].Value); + _position++; + } - return new HavingCondition + if (conditionParts.Count == 0) + throw new DslParseException("Expected HAVING condition but found logical operator."); + + var conditionToken = string.Join(':', conditionParts); + var parts = conditionToken.Split(':', StringSplitOptions.TrimEntries); + + if (parts.Length < 4) + throw new DslParseException( + $"Unable to parse HAVING condition '{conditionToken}'. " + + "Expected format: FUNCTION:Field:Operator:value. " + + "For example: sum:total:gt:100"); + + var fnRaw = parts[0].ToLowerInvariant(); + if (fnRaw == "average") fnRaw = "avg"; + + if (string.IsNullOrWhiteSpace(fnRaw)) + throw new DslParseException( + $"Unable to parse HAVING condition '{conditionToken}'. " + + "Expected format: FUNCTION:Field:Operator:value. " + + "For example: sum:total:gt:100"); + + if (!Enum.TryParse(fnRaw, true, out var function)) + throw new DslParseException( + $"Unsupported aggregate function '{parts[0]}' in HAVING condition '{conditionToken}'. " + + "Supported functions: sum, count, avg, min, max."); + + var field = parts[1]; + if (string.IsNullOrWhiteSpace(field)) + throw new DslParseException( + $"Missing field in HAVING condition '{conditionToken}'. " + + "Expected format: FUNCTION:Field:Operator:value."); + + if (field == "*") + throw new DslParseException( + $"Invalid field '{field}' in HAVING condition '{conditionToken}'. " + + "COUNT(*) is not supported. Use COUNT() instead."); + + if (!IsValidPropertyPath(field.AsSpan())) + throw new DslParseException( + $"Invalid field '{field}' in HAVING condition '{conditionToken}'. " + + "Field must be a valid property path."); + + var opRaw = parts[2]; + var normalizedOp = FilterOperators.Normalize(opRaw); + var allowedOps = new HashSet(StringComparer.OrdinalIgnoreCase) { "eq", "neq", "gt", "gte", "lt", "lte" }; + if (!allowedOps.Contains(normalizedOp)) + throw new DslParseException( + $"Unsupported operator '{opRaw}' in HAVING condition '{conditionToken}'. " + + "Supported operators: eq, neq, gt, gte, lt, lte."); + + var value = string.Join(':', parts[3..]); + + if (string.IsNullOrWhiteSpace(value)) + throw new DslParseException( + $"Missing value after operator in HAVING condition '{conditionToken}'."); + + return new HavingConditionNode + { + Function = function, + Field = string.IsNullOrWhiteSpace(field) ? null : field, + Operator = normalizedOp, + Value = value + }; + } + + private static bool IsValidPropertyPath(ReadOnlySpan path) { - Function = aggregateRef.Function, - Field = aggregateRef.Field, - Operator = normalizedOp, - Value = value - }; + if (path.IsEmpty) return true; + if (path[0] == '.') return false; + if (path[^1] == '.') return false; + + var segmentStart = 0; + for (var i = 0; i <= path.Length; i++) + { + if (i != path.Length && path[i] != '.') continue; + if (i == segmentStart) return false; + if (!char.IsLetter(path[segmentStart]) && path[segmentStart] != '_') return false; + for (var j = segmentStart + 1; j < i; j++) + { + if (!char.IsLetterOrDigit(path[j]) && path[j] != '_') return false; + } + segmentStart = i + 1; + } + return true; + } } } From b05cc397b894fa92816e963974f6c3e437a5dc50 Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Sat, 18 Jul 2026 14:19:05 +0800 Subject: [PATCH 03/10] feat: add HAVING validation rules and update validator pipeline --- .../Validation/QueryValidator.cs | 5 +- .../Rules/FieldAccessValidationRule.cs | 27 +++++- .../Rules/HavingAggregateExistenceRule.cs | 62 ++++++++++++ .../Rules/HavingAliasIntegrityRule.cs | 33 ------- .../Rules/HavingFieldExistenceRule.cs | 66 +++++++++++++ .../Rules/HavingOperatorValidityRule.cs | 53 ++++++++++ .../Rules/HavingTypeCompatibilityRule.cs | 96 +++++++++++++++++++ 7 files changed, 305 insertions(+), 37 deletions(-) create mode 100644 src/FlexQuery.NET/Validation/Rules/HavingAggregateExistenceRule.cs delete mode 100644 src/FlexQuery.NET/Validation/Rules/HavingAliasIntegrityRule.cs create mode 100644 src/FlexQuery.NET/Validation/Rules/HavingFieldExistenceRule.cs create mode 100644 src/FlexQuery.NET/Validation/Rules/HavingOperatorValidityRule.cs create mode 100644 src/FlexQuery.NET/Validation/Rules/HavingTypeCompatibilityRule.cs diff --git a/src/FlexQuery.NET/Validation/QueryValidator.cs b/src/FlexQuery.NET/Validation/QueryValidator.cs index 5bff2e4..9e2dcfc 100644 --- a/src/FlexQuery.NET/Validation/QueryValidator.cs +++ b/src/FlexQuery.NET/Validation/QueryValidator.cs @@ -26,7 +26,10 @@ public QueryValidator() _rules.Add(new OperatorValidityRule()); _rules.Add(new TypeCompatibilityRule()); _rules.Add(new HavingWithoutGroupByRule()); - _rules.Add(new HavingAliasIntegrityRule()); + _rules.Add(new HavingAggregateExistenceRule()); + _rules.Add(new HavingOperatorValidityRule()); + _rules.Add(new HavingFieldExistenceRule()); + _rules.Add(new HavingTypeCompatibilityRule()); _rules.Add(new AggregateValidationRule()); _rules.Add(new AggregateSortValidationRule()); _rules.Add(new GroupByIncludeConflictRule()); diff --git a/src/FlexQuery.NET/Validation/Rules/FieldAccessValidationRule.cs b/src/FlexQuery.NET/Validation/Rules/FieldAccessValidationRule.cs index f63392b..5293ff9 100644 --- a/src/FlexQuery.NET/Validation/Rules/FieldAccessValidationRule.cs +++ b/src/FlexQuery.NET/Validation/Rules/FieldAccessValidationRule.cs @@ -1,5 +1,6 @@ using FlexQuery.NET.Caching; using FlexQuery.NET.Models; +using FlexQuery.NET.Models.Aggregates; using FlexQuery.NET.Models.Filters; using FlexQuery.NET.Security; using FlexQuery.NET.Exceptions; @@ -111,10 +112,10 @@ public void Validate(QueryOptions options, QueryContext context, ValidationResul } } - // 8. Process Having - check field if present - if (options.Having != null && !string.IsNullOrWhiteSpace(options.Having.Field)) + // 8. Process Having - check fields recursively + if (options.Having != null) { - CheckAccess(options.Having.Field, QueryOperation.Having, context, result); + ValidateHavingAccess(options.Having, context, result, execOptions); } } @@ -545,4 +546,24 @@ private string NormalizeField(string rawField, QueryGovernanceOptions options) return _normalizationCache.GetOrAdd(rawField, f => f.Trim()); } + + private void ValidateHavingAccess(HavingNode having, QueryContext context, ValidationResult result, QueryGovernanceOptions execOptions) + { + switch (having) + { + case HavingConditionNode c: + if (!string.IsNullOrWhiteSpace(c.Field)) + { + CheckAccess(c.Field, QueryOperation.Having, context, result); + } + break; + case HavingLogicalNode l: + foreach (var child in l.Children) + ValidateHavingAccess(child, context, result, execOptions); + break; + case HavingGroupNode g: + ValidateHavingAccess(g.Inner, context, result, execOptions); + break; + } + } } diff --git a/src/FlexQuery.NET/Validation/Rules/HavingAggregateExistenceRule.cs b/src/FlexQuery.NET/Validation/Rules/HavingAggregateExistenceRule.cs new file mode 100644 index 0000000..d7c1a93 --- /dev/null +++ b/src/FlexQuery.NET/Validation/Rules/HavingAggregateExistenceRule.cs @@ -0,0 +1,62 @@ +using FlexQuery.NET.Constants; +using FlexQuery.NET.Execution; +using FlexQuery.NET.Models; +using FlexQuery.NET.Models.Aggregates; +using FlexQuery.NET.Parsers; + +namespace FlexQuery.NET.Validation.Rules; + +/// +/// Validates that every aggregate referenced in the HAVING expression +/// is declared in the Aggregates collection. Matches by function AND field (case-insensitive). +/// +internal sealed class HavingAggregateExistenceRule : IValidationRule +{ + /// + public void Validate(QueryOptions options, QueryContext context, ValidationResult result) + { + if (options.Having == null) return; + + var declared = options.Aggregates + .Select(a => (a.Function, a.Field)) + .ToList(); + + var missing = new List(); + + CollectMissing(options.Having, declared, missing); + + foreach (var reference in missing) + { + result.Errors.Add(new ValidationError( + $"HAVING references aggregate '{reference}' which is not declared in the aggregate clause.", + ValidationErrorCodes.HavingAliasMismatch, + reference)); + } + } + + private static void CollectMissing(HavingNode node, List<(AggregateFunction Function, string? Field)> declared, List missing) + { + switch (node) + { + case HavingConditionNode c: + { + var key = (c.Function, c.Field); + if (!declared.Any(d => d.Function == key.Function && string.Equals(d.Field, key.Field, StringComparison.OrdinalIgnoreCase))) + { + var fn = c.Function.ToKeyword().ToUpperInvariant(); + var refStr = c.Field is null ? fn : $"{fn}({c.Field})"; + missing.Add(refStr); + } + + break; + } + case HavingLogicalNode l: + foreach (var child in l.Children) + CollectMissing(child, declared, missing); + break; + case HavingGroupNode g: + CollectMissing(g.Inner, declared, missing); + break; + } + } +} diff --git a/src/FlexQuery.NET/Validation/Rules/HavingAliasIntegrityRule.cs b/src/FlexQuery.NET/Validation/Rules/HavingAliasIntegrityRule.cs deleted file mode 100644 index 7edef47..0000000 --- a/src/FlexQuery.NET/Validation/Rules/HavingAliasIntegrityRule.cs +++ /dev/null @@ -1,33 +0,0 @@ -using FlexQuery.NET.Constants; -using FlexQuery.NET.Execution; -using FlexQuery.NET.Models; -using FlexQuery.NET.Parsers; - -namespace FlexQuery.NET.Validation.Rules; - -/// -/// Validates that the HAVING condition references an aggregate that is -/// explicitly declared in the Aggregates collection. This prevents silent -/// mismatches when the HAVING aggregate does not correspond to any computed -/// aggregate, which would cause a runtime column-not-found error. -/// -internal sealed class HavingAliasIntegrityRule : IValidationRule -{ - /// - public void Validate(QueryOptions options, QueryContext context, ValidationResult result) - { - if (options.Having == null) return; - - var hasMatchingAggregate = options.Aggregates.Any(a => - a.Function == options.Having.Function && - string.Equals(a.Field, options.Having.Field, StringComparison.OrdinalIgnoreCase)); - - if (!hasMatchingAggregate) - { - result.Errors.Add(new ValidationError( - $"Aggregate expression '{options.Having.Function.ToKeyword()}:{options.Having.Field}' " + - "is not declared in the aggregate parameter.", - ValidationErrorCodes.AggregateNotDeclared)); - } - } -} diff --git a/src/FlexQuery.NET/Validation/Rules/HavingFieldExistenceRule.cs b/src/FlexQuery.NET/Validation/Rules/HavingFieldExistenceRule.cs new file mode 100644 index 0000000..2fc18c5 --- /dev/null +++ b/src/FlexQuery.NET/Validation/Rules/HavingFieldExistenceRule.cs @@ -0,0 +1,66 @@ +using FlexQuery.NET.Constants; +using FlexQuery.NET.Execution; +using FlexQuery.NET.Helpers; +using FlexQuery.NET.Models; +using FlexQuery.NET.Models.Aggregates; +using FlexQuery.NET.Security; + +namespace FlexQuery.NET.Validation.Rules; + +/// +/// Validates that fields referenced in HAVING expressions exist on the target entity type +/// and are scalar properties (not navigation properties). +/// +internal sealed class HavingFieldExistenceRule : IValidationRule +{ + /// + public void Validate(QueryOptions options, QueryContext context, ValidationResult result) + { + if (options.Having == null) return; + if (context.TargetType == null) return; + + var errors = new List(); + CollectErrors(options.Having, context.TargetType, errors); + + foreach (var error in errors) + { + result.Errors.Add(new ValidationError(error, ValidationErrorCodes.FieldNotFound)); + } + + return; + + static void CollectErrors(HavingNode node, Type entityType, List errors) + { + while (true) + { + switch (node) + { + case HavingConditionNode c: + if (string.IsNullOrWhiteSpace(c.Field)) break; + + if (!SafePropertyResolver.TryResolveChain(entityType, c.Field, out var chain) || chain.Count == 0) + { + errors.Add($"Field '{c.Field}' referenced in HAVING does not exist on type '{entityType.Name}'."); + break; + } + + var lastProp = chain[^1]; + if (TypeHelper.IsNavigationProperty(lastProp.PropertyType)) + { + errors.Add($"Field '{c.Field}' referenced in HAVING is a navigation property. Only scalar properties can be aggregated."); + } + + break; + case HavingLogicalNode l: + foreach (var child in l.Children) CollectErrors(child, entityType, errors); + break; + case HavingGroupNode g: + node = g.Inner; + continue; + } + + break; + } + } + } +} diff --git a/src/FlexQuery.NET/Validation/Rules/HavingOperatorValidityRule.cs b/src/FlexQuery.NET/Validation/Rules/HavingOperatorValidityRule.cs new file mode 100644 index 0000000..b2931bd --- /dev/null +++ b/src/FlexQuery.NET/Validation/Rules/HavingOperatorValidityRule.cs @@ -0,0 +1,53 @@ +using FlexQuery.NET.Constants; +using FlexQuery.NET.Execution; +using FlexQuery.NET.Models; +using FlexQuery.NET.Models.Aggregates; + +namespace FlexQuery.NET.Validation.Rules; + +/// +/// Validates that operators used in HAVING expressions are comparison operators only. +/// Only eq, neq, gt, gte, lt, lte are allowed. +/// +internal sealed class HavingOperatorValidityRule : IValidationRule +{ + private static readonly HashSet AllowedOperators = new(StringComparer.OrdinalIgnoreCase) + { + "eq", "neq", "gt", "gte", "lt", "lte" + }; + + /// + public void Validate(QueryOptions options, QueryContext context, ValidationResult result) + { + if (options.Having == null) return; + + var invalid = new List(); + CollectInvalid(options.Having, invalid); + + foreach (var op in invalid) + { + result.Errors.Add(new ValidationError( + $"Operator '{op}' is not supported in HAVING expressions. Only eq, neq, gt, gte, lt, lte are allowed.", + ValidationErrorCodes.InvalidOperator, + op)); + } + } + + private static void CollectInvalid(HavingNode node, List invalid) + { + switch (node) + { + case HavingConditionNode c: + if (!AllowedOperators.Contains(c.Operator)) + invalid.Add(c.Operator); + break; + case HavingLogicalNode l: + foreach (var child in l.Children) + CollectInvalid(child, invalid); + break; + case HavingGroupNode g: + CollectInvalid(g.Inner, invalid); + break; + } + } +} diff --git a/src/FlexQuery.NET/Validation/Rules/HavingTypeCompatibilityRule.cs b/src/FlexQuery.NET/Validation/Rules/HavingTypeCompatibilityRule.cs new file mode 100644 index 0000000..6509636 --- /dev/null +++ b/src/FlexQuery.NET/Validation/Rules/HavingTypeCompatibilityRule.cs @@ -0,0 +1,96 @@ +using FlexQuery.NET.Constants; +using FlexQuery.NET.Execution; +using FlexQuery.NET.Helpers; +using FlexQuery.NET.Models; +using FlexQuery.NET.Models.Aggregates; +using FlexQuery.NET.Parsers; +using FlexQuery.NET.Security; + +namespace FlexQuery.NET.Validation.Rules; + +/// +/// Validates that comparison values in HAVING expressions are compatible with the +/// aggregate result type. SUM/AVG/MIN/MAX require numeric values; COUNT requires numeric values. +/// +internal sealed class HavingTypeCompatibilityRule : IValidationRule +{ + /// + public void Validate(QueryOptions options, QueryContext context, ValidationResult result) + { + if (options.Having == null) return; + if (context.TargetType == null) return; + + var errors = new List(); + CollectErrors(options.Having, context.TargetType, options.Aggregates, errors); + + foreach (var error in errors) + { + result.Errors.Add(new ValidationError(error, ValidationErrorCodes.TypeMismatch)); + } + } + + private static void CollectErrors(HavingNode node, Type entityType, List aggregates, List errors) + { + switch (node) + { + case HavingConditionNode c: + ValidateCondition(c, entityType, aggregates, errors); + break; + case HavingLogicalNode l: + foreach (var child in l.Children) + CollectErrors(child, entityType, aggregates, errors); + break; + case HavingGroupNode g: + CollectErrors(g.Inner, entityType, aggregates, errors); + break; + } + } + + private static void ValidateCondition(HavingConditionNode condition, Type entityType, List aggregates, List errors) + { + if (string.IsNullOrWhiteSpace(condition.Value)) return; + + Type? targetType = null; + + if (!string.IsNullOrWhiteSpace(condition.Field)) + { + if (SafePropertyResolver.TryResolveChain(entityType, condition.Field, out var chain) && chain is { Count: > 0 }) + { + targetType = chain[^1].PropertyType; + } + } + else + { + var declared = aggregates.FirstOrDefault(a => + a.Function == condition.Function && + string.Equals(a.Field, condition.Field, StringComparison.OrdinalIgnoreCase)); + if (declared is not null && !string.IsNullOrWhiteSpace(declared.Field)) + { + if (SafePropertyResolver.TryResolveChain(entityType, declared.Field, out var chain) && chain is { Count: > 0 }) + { + targetType = chain[^1].PropertyType; + } + } + } + + if (targetType is null) return; + + var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; + + var isNumericFunction = condition.Function is AggregateFunction.Sum or AggregateFunction.Avg or AggregateFunction.Min or AggregateFunction.Max; + var isCountFunction = condition.Function == AggregateFunction.Count; + + if (isNumericFunction && !TypeHelper.IsNumeric(underlying)) + { + errors.Add($"Value '{condition.Value}' is not compatible with aggregate function '{condition.Function.ToKeyword()}' on non-numeric field '{condition.Field}'."); + return; + } + + if (!isCountFunction) return; + + if (!long.TryParse(condition.Value, out _) && !int.TryParse(condition.Value, out _)) + { + errors.Add($"Value '{condition.Value}' is not numeric. COUNT comparisons require numeric values."); + } + } +} From bdfa1260562113e7da4578908498e016182b8763 Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Sat, 18 Jul 2026 14:19:14 +0800 Subject: [PATCH 04/10] refactor: replace AggregateModel with Aggregate and update aggregate parsers --- .../Parsing/FqlAggregateParser.cs | 13 ++++++++++--- .../Models/Aggregates/AggregateModel.cs | 16 ---------------- src/FlexQuery.NET/Parsers/Dsl/DslSelectParser.cs | 4 ++-- src/FlexQuery.NET/Parsers/DslAggregateParser.cs | 8 ++++---- 4 files changed, 16 insertions(+), 25 deletions(-) delete mode 100644 src/FlexQuery.NET/Models/Aggregates/AggregateModel.cs diff --git a/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlAggregateParser.cs b/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlAggregateParser.cs index a75b4fb..130481d 100644 --- a/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlAggregateParser.cs +++ b/src/FlexQuery.NET.Parsers.Fql/Parsing/FqlAggregateParser.cs @@ -5,12 +5,12 @@ namespace FlexQuery.NET.Parsers.Fql; internal static class FqlAggregateParser { - public static List Parse(string? rawSelect) + public static List Parse(string? rawSelect) { if (string.IsNullOrWhiteSpace(rawSelect)) return []; - var result = new List(); + var result = new List(); var segments = SplitTopLevel(rawSelect); foreach (var segment in segments) @@ -66,6 +66,13 @@ public static List Parse(string? rawSelect) $"COUNT(*) is not supported. Use COUNT() or another aggregate over a property instead."); } + if (function == AggregateFunction.Count && fieldRaw == "*") + { + throw new FqlParseException( + $"Unable to parse aggregate expression '{rawSelect}'. " + + $"COUNT(*) is not supported. Use COUNT() or another aggregate over a property instead."); + } + if (fieldRaw != "*" && !ParserUtilities.IsValidPropertyPath(fieldRaw.AsSpan())) throw new FqlParseException( $"Invalid field '{fieldRaw}' in aggregate expression '{rawSelect}'. " + @@ -127,7 +134,7 @@ public static List Parse(string? rawSelect) "Aliases must be valid identifiers (e.g. 'TotalSales')."); } - result.Add(new AggregateModel + result.Add(new Aggregate { Function = function, Field = field, diff --git a/src/FlexQuery.NET/Models/Aggregates/AggregateModel.cs b/src/FlexQuery.NET/Models/Aggregates/AggregateModel.cs deleted file mode 100644 index 4b71966..0000000 --- a/src/FlexQuery.NET/Models/Aggregates/AggregateModel.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace FlexQuery.NET.Models.Aggregates; - -/// -/// Represents an aggregate projection expression (sum/count/avg). -/// -public sealed class AggregateModel -{ - /// Aggregate function. - public AggregateFunction Function { get; set; } - - /// Field path to aggregate. Optional for count. - public string? Field { get; set; } - - /// Output property name in the projected shape. - public string Alias { get; set; } = string.Empty; -} \ No newline at end of file diff --git a/src/FlexQuery.NET/Parsers/Dsl/DslSelectParser.cs b/src/FlexQuery.NET/Parsers/Dsl/DslSelectParser.cs index 60236b6..a1d920c 100644 --- a/src/FlexQuery.NET/Parsers/Dsl/DslSelectParser.cs +++ b/src/FlexQuery.NET/Parsers/Dsl/DslSelectParser.cs @@ -215,7 +215,7 @@ private static void ParseSelectionList(ReadOnlySpan span, List throw new DslParseException( "Root wildcard cannot be combined with other selections."); - string alias = null; + string? alias = null; while (i < span.Length && char.IsWhiteSpace(span[i])) i++; @@ -224,7 +224,7 @@ private static void ParseSelectionList(ReadOnlySpan span, List { i++; - int aliasStart = i; + var aliasStart = i; while (i < span.Length && (char.IsLetterOrDigit(span[i]) || span[i] == '_')) i++; diff --git a/src/FlexQuery.NET/Parsers/DslAggregateParser.cs b/src/FlexQuery.NET/Parsers/DslAggregateParser.cs index 07d2ce7..ce95b17 100644 --- a/src/FlexQuery.NET/Parsers/DslAggregateParser.cs +++ b/src/FlexQuery.NET/Parsers/DslAggregateParser.cs @@ -5,12 +5,12 @@ namespace FlexQuery.NET.Parsers; internal static class DslAggregateParser { - public static List Parse(string? rawAggregates) + public static List Parse(string? rawAggregates) { if (string.IsNullOrWhiteSpace(rawAggregates)) return []; - var result = new List(); + var result = new List(); var items = rawAggregates.Split(',', StringSplitOptions.TrimEntries); foreach (var item in items) @@ -60,7 +60,7 @@ public static List Parse(string? rawAggregates) $"Invalid alias '{aliasPart}' in aggregate expression. " + "Aliases must be valid identifiers (e.g. 'TotalSales')."); - result.Add(new AggregateModel + result.Add(new Aggregate { Function = aggregateRef.Function, Field = aggregateRef.Field, @@ -69,7 +69,7 @@ public static List Parse(string? rawAggregates) } else { - result.Add(new AggregateModel + result.Add(new Aggregate { Function = aggregateRef.Function, Field = aggregateRef.Field, From c79439bf289a82e4d30262b9eaccefb724b1798a Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Sat, 18 Jul 2026 14:19:23 +0800 Subject: [PATCH 05/10] feat: update HAVING expression builder and SQL generation for nested conditions --- .../SQL/Builders/SqlHavingBuilder.cs | 60 +++++++++++---- src/FlexQuery.NET/Builders/GroupByBuilder.cs | 14 +--- .../Expressions/HavingExpressionBuilder.cs | 77 +++++++++++++++++-- 3 files changed, 120 insertions(+), 31 deletions(-) diff --git a/src/FlexQuery.NET.Dapper/SQL/Builders/SqlHavingBuilder.cs b/src/FlexQuery.NET.Dapper/SQL/Builders/SqlHavingBuilder.cs index 94b829b..9a18a4e 100644 --- a/src/FlexQuery.NET.Dapper/SQL/Builders/SqlHavingBuilder.cs +++ b/src/FlexQuery.NET.Dapper/SQL/Builders/SqlHavingBuilder.cs @@ -1,3 +1,4 @@ +using System.Text; using FlexQuery.NET.Dapper.Dialects; using FlexQuery.NET.Dapper.Mapping; using FlexQuery.NET.Dapper.Sql.Converters; @@ -14,7 +15,7 @@ internal static class SqlHavingBuilder { public static string Build( ISqlDialect dialect, - HavingCondition? having, + HavingNode? having, IEntityMapping mapping, SqlParameterContext parameters) { @@ -23,6 +24,47 @@ public static string Build( return string.Empty; } + var sb = new StringBuilder(); + sb.Append("HAVING "); + AppendExpression(sb, having, dialect, mapping, parameters); + return sb.ToString(); + } + + private static void AppendExpression(StringBuilder sb, HavingNode node, ISqlDialect dialect, IEntityMapping mapping, SqlParameterContext parameters) + { + switch (node) + { + case HavingConditionNode c: + sb.Append(BuildCondition(c, dialect, mapping, parameters)); + break; + case HavingLogicalNode l: + { + var parts = new List(); + foreach (var child in l.Children) + { + var childSb = new StringBuilder(); + AppendExpression(childSb, child, dialect, mapping, parameters); + parts.Add(childSb.ToString()); + } + + var op = l.Logic.ToKeyword(); + sb.Append('('); + sb.Append(string.Join($" {op} ", parts)); + sb.Append(')'); + break; + } + case HavingGroupNode g: + { + sb.Append('('); + AppendExpression(sb, g.Inner, dialect, mapping, parameters); + sb.Append(')'); + break; + } + } + } + + private static string BuildCondition(HavingConditionNode having, ISqlDialect dialect, IEntityMapping mapping, SqlParameterContext parameters) + { var isCountStar = having.Function == AggregateFunction.Count && string.IsNullOrWhiteSpace(having.Field); @@ -44,22 +86,14 @@ public static string Build( $"{having.Function.ToKeyword().ToUpperInvariant()}({column})"; } - var value = ConvertValue( - dialect, - having, - mapping); - + var value = ConvertValue(dialect, having, mapping); var parameterName = parameters.Add(value); - var sqlOperator = NormalizeOperator(having.Operator); - return $"HAVING {aggregateExpression} {sqlOperator} {parameterName}"; + return $"{aggregateExpression} {sqlOperator} {parameterName}"; } - private static object? ConvertValue( - ISqlDialect dialect, - HavingCondition having, - IEntityMapping mapping) + private static object? ConvertValue(ISqlDialect dialect, HavingConditionNode having, IEntityMapping mapping) { var value = having.Value?.Trim('"'); @@ -122,4 +156,4 @@ private static string NormalizeOperator(string op) _ => op }; } -} \ No newline at end of file +} diff --git a/src/FlexQuery.NET/Builders/GroupByBuilder.cs b/src/FlexQuery.NET/Builders/GroupByBuilder.cs index 312930c..8fc65ef 100644 --- a/src/FlexQuery.NET/Builders/GroupByBuilder.cs +++ b/src/FlexQuery.NET/Builders/GroupByBuilder.cs @@ -66,15 +66,7 @@ internal static IQueryable ApplyUntyped(IQueryable query, QueryOptions opt if (options.Having is not null) { - var havingFunction = AggregateFunctionConverter.ToKeyword(options.Having.Function); - - var havingAlias = ParserUtilities.BuildAggregateAlias(havingFunction, options.Having.Field); - var matchingAggregate = options.Aggregates.FirstOrDefault(a => - a.Function == options.Having.Function && - string.Equals(a.Field, options.Having.Field, StringComparison.OrdinalIgnoreCase)); - if (matchingAggregate?.Alias != null) - havingAlias = matchingAggregate.Alias; - var havingLambda = HavingExpressionBuilder.Build(projectionType, options.Having, havingAlias, options.CaseInsensitive); + var havingLambda = HavingExpressionBuilder.Build(projectionType, options.Having, options.Aggregates, options.CaseInsensitive); if (havingLambda is not null) { finalCall = Expression.Call( @@ -143,7 +135,7 @@ private static List BuildSelectedFieldList(QueryOptions options, List selectedFields, - List aggregates, + List aggregates, QueryOptions options, out Type? projectionType) { @@ -188,7 +180,7 @@ private static List BuildSelectedFieldList(QueryOptions options, List +/// Builds a LINQ predicate from a tree. +/// internal static class HavingExpressionBuilder { - public static LambdaExpression? Build(Type projectionType, HavingCondition? having, string aggregateAlias, bool caseInsensitive = true) + public static LambdaExpression? Build(Type projectionType, HavingNode? having, List aggregates, bool caseInsensitive = true) { - if (having is null || string.IsNullOrWhiteSpace(aggregateAlias)) - return null; + if (having is null) return null; var parameter = Expression.Parameter(projectionType, "g"); + var body = BuildExpression(having, parameter, projectionType, aggregates, caseInsensitive); + if (body is null) return null; + + var delegateType = typeof(Func<,>).MakeGenericType(projectionType, typeof(bool)); + return Expression.Lambda(delegateType, body, parameter); + } + + private static Expression? BuildExpression(HavingNode node, ParameterExpression parameter, Type projectionType, List aggregates, bool caseInsensitive) + { + switch (node) + { + case HavingConditionNode c: + return BuildCondition(c, parameter, projectionType, aggregates, caseInsensitive); + case HavingLogicalNode l: + { + var parts = new List(); + foreach (var child in l.Children) + { + var childExpr = BuildExpression(child, parameter, projectionType, aggregates, caseInsensitive); + if (childExpr is not null) parts.Add(childExpr); + } + + switch (parts.Count) + { + case 0: + return null; + case 1: + return parts[0]; + default: + { + var logic = l.Logic.ToKeyword(); + var result = parts.Aggregate((left, right) => + logic.Equals("or", StringComparison.OrdinalIgnoreCase) + ? Expression.OrElse(left, right) + : Expression.AndAlso(left, right)); + + return result; + } + } + } + case HavingGroupNode g: + return BuildExpression(g.Inner, parameter, projectionType, aggregates, caseInsensitive); + default: + return null; + } + } + + private static Expression? BuildCondition(HavingConditionNode condition, ParameterExpression parameter, Type projectionType, List aggregates, bool caseInsensitive) + { + var aggregateAlias = ResolveAggregateAlias(condition, aggregates); + if (aggregateAlias is null) return null; + var member = projectionType.GetProperty(aggregateAlias); if (member is null) return null; var memberExpr = Expression.Property(parameter, member); - var predicate = FilterExpressionBuilder.Build(memberExpr, having.Operator, having.Value, caseInsensitive); - if (predicate is null) return null; + var predicate = FilterExpressionBuilder.Build(memberExpr, condition.Operator, condition.Value, caseInsensitive); + return predicate; + } - var delegateType = typeof(Func<,>).MakeGenericType(projectionType, typeof(bool)); - return Expression.Lambda(delegateType, predicate, parameter); + private static string? ResolveAggregateAlias(HavingConditionNode condition, List aggregates) + { + var matching = aggregates.FirstOrDefault(a => + a.Function == condition.Function && + string.Equals(a.Field, condition.Field, StringComparison.OrdinalIgnoreCase)); + + return matching?.Alias; } } From 415a1caecda604242fe5f1f6ccb740ba317bf803 Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Sat, 18 Jul 2026 14:19:34 +0800 Subject: [PATCH 06/10] refactor: update fluent builders and extensions for new Aggregate model --- .../Builders/AggregateResultBuilder.cs | 2 +- .../Builders/Fluent/AggregateBuilder.cs | 14 ++++---- .../Builders/Fluent/FluentQueryBuilder.cs | 2 +- .../Caching/QueryCacheKeyBuilder.cs | 17 ++++++++-- .../Extensions/QueryOptionsExtensions.cs | 33 ++++++++++++------- 5 files changed, 45 insertions(+), 23 deletions(-) diff --git a/src/FlexQuery.NET/Builders/AggregateResultBuilder.cs b/src/FlexQuery.NET/Builders/AggregateResultBuilder.cs index 1a562f8..931012f 100644 --- a/src/FlexQuery.NET/Builders/AggregateResultBuilder.cs +++ b/src/FlexQuery.NET/Builders/AggregateResultBuilder.cs @@ -8,7 +8,7 @@ internal static class AggregateResultBuilder { public static Dictionary>? Build( object? aggregateRow, - IReadOnlyCollection aggregates) + IReadOnlyCollection aggregates) { if (aggregateRow == null) { diff --git a/src/FlexQuery.NET/Builders/Fluent/AggregateBuilder.cs b/src/FlexQuery.NET/Builders/Fluent/AggregateBuilder.cs index 44b9b04..fc482f7 100644 --- a/src/FlexQuery.NET/Builders/Fluent/AggregateBuilder.cs +++ b/src/FlexQuery.NET/Builders/Fluent/AggregateBuilder.cs @@ -6,14 +6,14 @@ namespace FlexQuery.NET.Builders.Fluent; /// Builds a list of AggregateModel entries for use with QueryBuilder.Aggregate. public sealed class AggregateBuilder { - private readonly List _aggregates = new(); + private readonly List _aggregates = new(); - internal List Build() => _aggregates; + internal List Build() => _aggregates; /// Adds a SUM(field) aggregate with an optional alias. public AggregateBuilder Sum(string field, string? alias) { - _aggregates.Add(new AggregateModel + _aggregates.Add(new Aggregate { Function = AggregateFunction.Sum, Field = field, @@ -25,7 +25,7 @@ public AggregateBuilder Sum(string field, string? alias) /// Adds a COUNT(field) aggregate with an optional alias. public AggregateBuilder Count(string field, string? alias) { - _aggregates.Add(new AggregateModel + _aggregates.Add(new Aggregate { Function = AggregateFunction.Count, Field = field, @@ -37,7 +37,7 @@ public AggregateBuilder Count(string field, string? alias) /// Adds an AVG(field) aggregate with an optional alias. public AggregateBuilder Avg(string field, string? alias) { - _aggregates.Add(new AggregateModel + _aggregates.Add(new Aggregate { Function = AggregateFunction.Avg, Field = field, @@ -49,7 +49,7 @@ public AggregateBuilder Avg(string field, string? alias) /// Adds a MIN(field) aggregate with an optional alias. public AggregateBuilder Min(string field, string? alias) { - _aggregates.Add(new AggregateModel + _aggregates.Add(new Aggregate { Function = AggregateFunction.Min, Field = field, @@ -61,7 +61,7 @@ public AggregateBuilder Min(string field, string? alias) /// Adds a MAX(field) aggregate with an optional alias. public AggregateBuilder Max(string field, string? alias) { - _aggregates.Add(new AggregateModel + _aggregates.Add(new Aggregate { Function = AggregateFunction.Max, Field = field, diff --git a/src/FlexQuery.NET/Builders/Fluent/FluentQueryBuilder.cs b/src/FlexQuery.NET/Builders/Fluent/FluentQueryBuilder.cs index 784fb02..ee1482f 100644 --- a/src/FlexQuery.NET/Builders/Fluent/FluentQueryBuilder.cs +++ b/src/FlexQuery.NET/Builders/Fluent/FluentQueryBuilder.cs @@ -94,7 +94,7 @@ public FluentQueryBuilder Aggregate(Action configure) /// Sets the HAVING condition for aggregate filtering. public FluentQueryBuilder Having(string function, string? field, string op, string? value) { - _options.Having = new HavingCondition + _options.Having = new HavingConditionNode { Function = AggregateFunctionConverter.Parse(function), Field = field, diff --git a/src/FlexQuery.NET/Caching/QueryCacheKeyBuilder.cs b/src/FlexQuery.NET/Caching/QueryCacheKeyBuilder.cs index d66b373..5819167 100644 --- a/src/FlexQuery.NET/Caching/QueryCacheKeyBuilder.cs +++ b/src/FlexQuery.NET/Caching/QueryCacheKeyBuilder.cs @@ -80,16 +80,27 @@ private static string SortKey(IEnumerable? sorts) : string.Join(",", sorts.Select(s => $"{Escape(s.Field)}:{s.Descending}:{Escape(s.Aggregate.HasValue ? s.Aggregate.Value.ToKeyword() : null)}:{Escape(s.AggregateField)}")); - private static string AggregateKey(IEnumerable? aggregates) + private static string AggregateKey(IEnumerable? aggregates) => aggregates is null ? string.Empty : string.Join(",", aggregates.Select(a => $"{Escape(a.Function.ToKeyword())}:{Escape(a.Field)}:{Escape(a.Alias)}")); - private static string HavingKey(HavingCondition? having) + private static string HavingKey(HavingNode? having) => having is null ? string.Empty - : $"{Escape(having.Function.ToKeyword())}:{Escape(having.Field)}:{Escape(having.Operator)}:{Escape(having.Value)}"; + : BuildHavingKey(having); + + private static string BuildHavingKey(HavingNode having) + { + return having switch + { + HavingConditionNode c => $"{Escape(c.Function.ToKeyword())}:{Escape(c.Field)}:{Escape(c.Operator)}:{Escape(c.Value)}", + HavingLogicalNode l => $"({string.Join(l.Logic.ToKeyword().ToLowerInvariant() == "or" ? "|" : "&", l.Children.Select(BuildHavingKey))})", + HavingGroupNode g => $"({BuildHavingKey(g.Inner)})", + _ => string.Empty + }; + } private static string IncludeKey(IEnumerable? includes) => includes is null diff --git a/src/FlexQuery.NET/Extensions/QueryOptionsExtensions.cs b/src/FlexQuery.NET/Extensions/QueryOptionsExtensions.cs index 95d826a..8de48a8 100644 --- a/src/FlexQuery.NET/Extensions/QueryOptionsExtensions.cs +++ b/src/FlexQuery.NET/Extensions/QueryOptionsExtensions.cs @@ -192,7 +192,7 @@ internal static QueryOptions CopyQueryOptions(this QueryOptions source) ProjectionMode = source.ProjectionMode, GroupBy = source.GroupBy?.ToList(), Aggregates = source.Aggregates.Select(CloneAggregateModel).ToList(), - Having = CloneHavingCondition(source.Having), + Having = CloneHavingExpression(source.Having), Distinct = source.Distinct, SelectTree = source.SelectTree, Paging = new PagingOptions { Page = source.Paging.Page, PageSize = source.Paging.PageSize, Disabled = source.Paging.Disabled }, @@ -249,7 +249,7 @@ private static IncludeNode CloneIncludeNode(IncludeNode include) Children = include.Children.Select(CloneIncludeNode).ToList() }; - private static AggregateModel CloneAggregateModel(AggregateModel aggregate) + private static Aggregate CloneAggregateModel(Aggregate aggregate) => new() { Function = aggregate.Function, @@ -257,16 +257,27 @@ private static AggregateModel CloneAggregateModel(AggregateModel aggregate) Alias = aggregate.Alias }; - private static HavingCondition? CloneHavingCondition(HavingCondition? having) - => having is null - ? null - : new HavingCondition + private static HavingNode? CloneHavingExpression(HavingNode? having) + => having switch + { + HavingConditionNode c => new HavingConditionNode + { + Function = c.Function, + Field = c.Field, + Operator = c.Operator, + Value = c.Value + }, + HavingLogicalNode l => new HavingLogicalNode { - Function = having.Function, - Field = having.Field, - Operator = having.Operator, - Value = having.Value - }; + Logic = l.Logic, + Children = l.Children.Select(CloneHavingExpression).Where(x => x is not null).Cast().ToList() + }, + HavingGroupNode g => new HavingGroupNode + { + Inner = CloneHavingExpression(g.Inner)! + }, + _ => null + }; /// /// Generates a stable cache key for the query execution pipeline. From 8f6f3db588d754dc2528e29c96cb7b04d2ae8d73 Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Sat, 18 Jul 2026 14:19:44 +0800 Subject: [PATCH 07/10] fix: update validation rules and request model for aggregate refactor --- src/FlexQuery.NET/Models/FlexQueryRequest.cs | 4 ++-- .../Validation/GroupedSortValidator.cs | 2 +- .../Rules/AggregateSortValidationRule.cs | 4 ++-- .../Validation/Rules/AggregateValidationRule.cs | 16 ++++++++-------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/FlexQuery.NET/Models/FlexQueryRequest.cs b/src/FlexQuery.NET/Models/FlexQueryRequest.cs index d5eb731..9337742 100644 --- a/src/FlexQuery.NET/Models/FlexQueryRequest.cs +++ b/src/FlexQuery.NET/Models/FlexQueryRequest.cs @@ -34,10 +34,10 @@ public sealed class FlexQueryRequest public List? GroupBy { get; set; } /// Aggregate projection expressions (sum, count, avg). - public List Aggregate { get; set; } = []; + public List Aggregate { get; set; } = []; /// HAVING condition against aggregate projections. - public HavingCondition? Having { get; set; } + public HavingNode? Having { get; set; } /// If true, applies Distinct() to the query. public bool? Distinct { get; set; } diff --git a/src/FlexQuery.NET/Validation/GroupedSortValidator.cs b/src/FlexQuery.NET/Validation/GroupedSortValidator.cs index d2a1ee3..f7f0453 100644 --- a/src/FlexQuery.NET/Validation/GroupedSortValidator.cs +++ b/src/FlexQuery.NET/Validation/GroupedSortValidator.cs @@ -41,7 +41,7 @@ internal static class GroupedSortValidator public static List Validate( IReadOnlyList sorts, IReadOnlyList groupByFields, - IReadOnlyList aggregates) + IReadOnlyList aggregates) { var validFields = new HashSet(StringComparer.OrdinalIgnoreCase); var fieldToAlias = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/src/FlexQuery.NET/Validation/Rules/AggregateSortValidationRule.cs b/src/FlexQuery.NET/Validation/Rules/AggregateSortValidationRule.cs index 16fcc28..852af6e 100644 --- a/src/FlexQuery.NET/Validation/Rules/AggregateSortValidationRule.cs +++ b/src/FlexQuery.NET/Validation/Rules/AggregateSortValidationRule.cs @@ -90,9 +90,9 @@ private static void ValidateNoAggregates(QueryOptions options, ValidationResult } } - private static Dictionary BuildLookup(IReadOnlyCollection aggregates) + private static Dictionary BuildLookup(IReadOnlyCollection aggregates) { - var dict = new Dictionary(); + var dict = new Dictionary(); foreach (var aggregate in aggregates) { var key = new AggregateKey(aggregate.Function, aggregate.Field); diff --git a/src/FlexQuery.NET/Validation/Rules/AggregateValidationRule.cs b/src/FlexQuery.NET/Validation/Rules/AggregateValidationRule.cs index 0a62ecf..ecbacae 100644 --- a/src/FlexQuery.NET/Validation/Rules/AggregateValidationRule.cs +++ b/src/FlexQuery.NET/Validation/Rules/AggregateValidationRule.cs @@ -28,7 +28,7 @@ public void Validate(QueryOptions options, QueryContext context, ValidationResul ValidateAggregateTargets(options.Aggregates, result); } - private static void ValidateInvalidIdentifier(AggregateModel aggregate, ValidationResult result) + private static void ValidateInvalidIdentifier(Aggregate aggregate, ValidationResult result) { if (aggregate.Alias.Length > 0 && !ParserUtilities.IsValidIdentifier(aggregate.Alias.AsSpan())) { @@ -39,7 +39,7 @@ private static void ValidateInvalidIdentifier(AggregateModel aggregate, Validati } } - private static void ValidateReservedKeyword(AggregateModel aggregate, ValidationResult result) + private static void ValidateReservedKeyword(Aggregate aggregate, ValidationResult result) { if (ReservedKeywordHelper.IsReserved(aggregate.Alias)) { @@ -49,9 +49,9 @@ private static void ValidateReservedKeyword(AggregateModel aggregate, Validation } } - private static void ValidateDuplicateAliases(IReadOnlyCollection aggregates, ValidationResult result) + private static void ValidateDuplicateAliases(IReadOnlyCollection aggregates, ValidationResult result) { - var seen = new Dictionary(StringComparer.OrdinalIgnoreCase); + var seen = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var aggregate in aggregates) { if (seen.TryGetValue(aggregate.Alias, out var existing)) @@ -68,9 +68,9 @@ private static void ValidateDuplicateAliases(IReadOnlyCollection } } - private static void ValidateDuplicateDefinitions(IReadOnlyCollection aggregates, ValidationResult result) + private static void ValidateDuplicateDefinitions(IReadOnlyCollection aggregates, ValidationResult result) { - var seen = new Dictionary(); + var seen = new Dictionary(); foreach (var aggregate in aggregates) { var key = new AggregateKey(aggregate.Function, aggregate.Field); @@ -88,7 +88,7 @@ private static void ValidateDuplicateDefinitions(IReadOnlyCollection aggregates, ValidationResult result) + private static void ValidateAggregateTargets(IReadOnlyCollection aggregates, ValidationResult result) { foreach (var aggregate in aggregates) { @@ -108,7 +108,7 @@ private static void ValidateAggregateTargets(IReadOnlyCollection } } - private static string FormatAggregate(AggregateModel aggregate) + private static string FormatAggregate(Aggregate aggregate) { var function = aggregate.Function.ToKeyword().ToUpperInvariant(); var field = aggregate.Field ?? "*"; From cabe4f29e5ae818c4ba83260d3f97ae0bd5caef7 Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Sat, 18 Jul 2026 14:19:54 +0800 Subject: [PATCH 08/10] docs: update OpenAPI documentation, adapters, and sample diagnostics --- .../Controllers/DiagnosticsHelper.cs | 33 ++++++++++++++++--- .../Parsers/AgGridQueryOptionsParser.cs | 2 +- .../Parsers/KendoQueryOptionsParser.cs | 2 +- .../Documentation/ExampleProvider.cs | 8 ++--- .../FlexQueryDocumentationRegistry.cs | 4 +-- .../Documentation/SchemaDocumentation.cs | 3 ++ 6 files changed, 40 insertions(+), 12 deletions(-) diff --git a/samples/FlexQuery.NET.Samples.WebApi/Controllers/DiagnosticsHelper.cs b/samples/FlexQuery.NET.Samples.WebApi/Controllers/DiagnosticsHelper.cs index 433eb1d..a0865f2 100644 --- a/samples/FlexQuery.NET.Samples.WebApi/Controllers/DiagnosticsHelper.cs +++ b/samples/FlexQuery.NET.Samples.WebApi/Controllers/DiagnosticsHelper.cs @@ -1,5 +1,6 @@ using FlexQuery.NET.Diagnostics; using FlexQuery.NET.Models; +using FlexQuery.NET.Models.Aggregates; using FlexQuery.NET.Models.Filters; namespace FlexQuery.NET.Samples.WebApi.Controllers; @@ -77,10 +78,7 @@ public static object BuildRichDiagnostics( }).ToList(), having = options.Having is not null ? new { - function = options.Having.Function, - field = options.Having.Field, - op = options.Having.Operator, - value = options.Having.Value + expression = SerializeHaving(options.Having) } : null, paging = new { @@ -329,4 +327,31 @@ public static object BuildManualShape( } : null, timeline = (object[]?)null }; + + private static object? SerializeHaving(HavingNode having) + { + return having switch + { + HavingConditionNode c => new + { + type = "condition", + function = c.Function.ToString(), + field = c.Field, + @operator = c.Operator, + value = c.Value + }, + HavingLogicalNode l => new + { + type = "logical", + logic = l.Logic, + children = l.Children.Select(SerializeHaving).ToList() + }, + HavingGroupNode g => new + { + type = "group", + inner = SerializeHaving(g.Inner) + }, + _ => null + }; + } } diff --git a/src/FlexQuery.NET.Adapters.AgGrid/Parsers/AgGridQueryOptionsParser.cs b/src/FlexQuery.NET.Adapters.AgGrid/Parsers/AgGridQueryOptionsParser.cs index c72836c..47c1c07 100644 --- a/src/FlexQuery.NET.Adapters.AgGrid/Parsers/AgGridQueryOptionsParser.cs +++ b/src/FlexQuery.NET.Adapters.AgGrid/Parsers/AgGridQueryOptionsParser.cs @@ -78,7 +78,7 @@ public static QueryOptions Parse(AgGridRequest request) var fn = AggregateFunctionConverter.Parse(col.AggFunc); var alias = ParserUtilities.BuildAggregateAlias(fn.ToString().ToLowerInvariant(), col.Field); - result.Aggregates.Add(new AggregateModel + result.Aggregates.Add(new Aggregate { Field = col.Field, Function = fn, diff --git a/src/FlexQuery.NET.Adapters.Kendo/Parsers/KendoQueryOptionsParser.cs b/src/FlexQuery.NET.Adapters.Kendo/Parsers/KendoQueryOptionsParser.cs index b210948..99d0381 100644 --- a/src/FlexQuery.NET.Adapters.Kendo/Parsers/KendoQueryOptionsParser.cs +++ b/src/FlexQuery.NET.Adapters.Kendo/Parsers/KendoQueryOptionsParser.cs @@ -164,7 +164,7 @@ private static void AddAggregate(QueryOptions result, string? field, string? agg var fn = NormalizeAggregateFunction(aggregateFunction); var alias = BuildAggregateAlias(fn, field); - result.Aggregates.Add(new AggregateModel + result.Aggregates.Add(new Aggregate { Field = field, Function = fn, diff --git a/src/FlexQuery.NET.OpenApi/Documentation/ExampleProvider.cs b/src/FlexQuery.NET.OpenApi/Documentation/ExampleProvider.cs index 848d105..7a76bc7 100644 --- a/src/FlexQuery.NET.OpenApi/Documentation/ExampleProvider.cs +++ b/src/FlexQuery.NET.OpenApi/Documentation/ExampleProvider.cs @@ -55,12 +55,12 @@ internal static class ExampleProvider ], Aggregate = [ - new AggregateModel { Function = AggregateFunction.Sum, Field = "TotalAmount", Alias = "TotalRevenue" }, - new AggregateModel { Function = AggregateFunction.Count, Alias = "OrderCount" }, - new AggregateModel { Function = AggregateFunction.Avg, Field = "Rating", Alias = "AvgRating" } + new Aggregate { Function = AggregateFunction.Sum, Field = "TotalAmount", Alias = "TotalRevenue" }, + new Aggregate { Function = AggregateFunction.Count, Alias = "OrderCount" }, + new Aggregate { Function = AggregateFunction.Avg, Field = "Rating", Alias = "AvgRating" } ], GroupBy = ["Region", "Category"], - Having = new HavingCondition + Having = new HavingConditionNode { Function = AggregateFunction.Sum, Field = "TotalAmount", diff --git a/src/FlexQuery.NET.OpenApi/Documentation/FlexQueryDocumentationRegistry.cs b/src/FlexQuery.NET.OpenApi/Documentation/FlexQueryDocumentationRegistry.cs index 969c78b..b2c18c5 100644 --- a/src/FlexQuery.NET.OpenApi/Documentation/FlexQueryDocumentationRegistry.cs +++ b/src/FlexQuery.NET.OpenApi/Documentation/FlexQueryDocumentationRegistry.cs @@ -20,8 +20,8 @@ internal static class FlexQueryDocumentationRegistry [typeof(FilterCondition)] = new(SchemaDocumentation.FilterCondition, null), [typeof(SortNode)] = new(SchemaDocumentation.SortNode, null), [typeof(PagingOptions)] = new(SchemaDocumentation.PagingOptions, null), - [typeof(AggregateModel)] = new(SchemaDocumentation.AggregateModel, null), - [typeof(HavingCondition)] = new(SchemaDocumentation.HavingCondition, null), + [typeof(Aggregate)] = new(SchemaDocumentation.AggregateModel, null), + [typeof(HavingNode)] = new(SchemaDocumentation.HavingExpression, null), [typeof(IncludeNode)] = new(SchemaDocumentation.IncludeNode, null), [typeof(ProjectionMode)] = new(SchemaDocumentation.ProjectionMode, null), [typeof(LogicOperator)] = new(SchemaDocumentation.LogicOperator, null), diff --git a/src/FlexQuery.NET.OpenApi/Documentation/SchemaDocumentation.cs b/src/FlexQuery.NET.OpenApi/Documentation/SchemaDocumentation.cs index bb0ef3e..e7bb2a9 100644 --- a/src/FlexQuery.NET.OpenApi/Documentation/SchemaDocumentation.cs +++ b/src/FlexQuery.NET.OpenApi/Documentation/SchemaDocumentation.cs @@ -32,6 +32,9 @@ internal static class SchemaDocumentation internal static readonly string HavingCondition = "A HAVING condition against an aggregate projection."; + internal static readonly string HavingExpression = + "A HAVING expression tree supporting logical combinations of aggregate conditions."; + internal static readonly string IncludeNode = "A filtered navigation include path with optional child expansions."; From da3e3fdc479ce4f44d2a0ade5ba9e1ef26b24c2f Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Sat, 18 Jul 2026 14:20:02 +0800 Subject: [PATCH 09/10] test: update tests for HAVING expression tree, parsers, validators, and SQL generation --- .../DocumentationContentTests.cs | 2 +- .../FlexQueryDocumentationRegistryTests.cs | 2 +- .../Adapters/AgGridQueryParserTests.cs | 4 +- .../Builders/AggregateResultBuilderTests.cs | 6 +- .../Builders/FluentQueryBuilderTests.cs | 21 +- .../Builders/GroupByTests.cs | 60 ++-- .../Caching/QueryCacheKeyBuilderTests.cs | 2 +- .../Dapper/Dialects/DialectTests.cs | 6 +- .../Integration/GroupedQueryExecutionTests.cs | 14 +- ...ecurityGovernanceDapperIntegrationTests.cs | 22 +- .../Dapper/Security/SqlInjectionTests.cs | 8 +- .../Translation/SqlHavingBuilderTests.cs | 48 +-- .../Translation/SqlSelectBuilderTests.cs | 24 +- .../Translation/SqlTranslatorGroupedTests.cs | 6 +- .../Dapper/Translation/SqlTranslatorTests.cs | 14 +- .../Integration/FlexQueryBaseTests.cs | 7 +- .../Integration/GroupedQueryBehaviorTests.cs | 14 +- .../Integration/GroupedQueryDapperTests.cs | 4 +- .../Integration/QueryNormalizationTests.cs | 2 +- .../Integration/ResultCountTests.cs | 6 +- ...ecurityGovernanceEfCoreIntegrationTests.cs | 8 +- .../Parsers/Dsl/DslAggregateParserTests.cs | 10 +- .../Parsers/Dsl/DslHavingParserTests.cs | 93 ++--- .../Parsers/DslQueryParserTests.cs | 277 ++++++++++++--- .../Parsers/Fql/FqlAggregateParserTests.cs | 10 +- .../Parsers/Fql/FqlHavingParserTests.cs | 209 ++++-------- .../Parsers/FqlQueryParserTests.cs | 322 ++++++++++++++++-- .../Security/FieldSecurityTests.cs | 28 +- .../AggregateAliasValidationRuleTests.cs | 64 ++-- .../AggregateSortValidationRuleTests.cs | 32 +- .../FieldAccessValidationRuleTests.cs | 10 +- .../HavingAliasIntegrityRuleTests.cs | 38 +-- .../Validation/ValidationEdgeCaseTests.cs | 8 +- .../Validation/ValidationTests.cs | 20 +- 34 files changed, 878 insertions(+), 523 deletions(-) diff --git a/tests/FlexQuery.NET.OpenApi.Tests/Documentation/DocumentationContentTests.cs b/tests/FlexQuery.NET.OpenApi.Tests/Documentation/DocumentationContentTests.cs index 9e198b7..2213d5b 100644 --- a/tests/FlexQuery.NET.OpenApi.Tests/Documentation/DocumentationContentTests.cs +++ b/tests/FlexQuery.NET.OpenApi.Tests/Documentation/DocumentationContentTests.cs @@ -27,7 +27,7 @@ public void SchemaDocumentation_ValuesAreNonEmpty() SchemaDocumentation.SortNode.Should().NotBeNullOrWhiteSpace(); SchemaDocumentation.PagingOptions.Should().NotBeNullOrWhiteSpace(); SchemaDocumentation.AggregateModel.Should().NotBeNullOrWhiteSpace(); - SchemaDocumentation.HavingCondition.Should().NotBeNullOrWhiteSpace(); + SchemaDocumentation.HavingConditionExpression.Should().NotBeNullOrWhiteSpace(); SchemaDocumentation.IncludeNode.Should().NotBeNullOrWhiteSpace(); SchemaDocumentation.ProjectionMode.Should().NotBeNullOrWhiteSpace(); SchemaDocumentation.LogicOperator.Should().NotBeNullOrWhiteSpace(); diff --git a/tests/FlexQuery.NET.OpenApi.Tests/Documentation/FlexQueryDocumentationRegistryTests.cs b/tests/FlexQuery.NET.OpenApi.Tests/Documentation/FlexQueryDocumentationRegistryTests.cs index 24b3a2d..1d049eb 100644 --- a/tests/FlexQuery.NET.OpenApi.Tests/Documentation/FlexQueryDocumentationRegistryTests.cs +++ b/tests/FlexQuery.NET.OpenApi.Tests/Documentation/FlexQueryDocumentationRegistryTests.cs @@ -14,7 +14,7 @@ public class FlexQueryDocumentationRegistryTests [InlineData(typeof(SortNode))] [InlineData(typeof(PagingOptions))] [InlineData(typeof(AggregateModel))] - [InlineData(typeof(HavingCondition))] + [InlineData(typeof(HavingConditionNode))] [InlineData(typeof(IncludeNode))] [InlineData(typeof(ProjectionMode))] [InlineData(typeof(LogicOperator))] diff --git a/tests/FlexQuery.NET.Tests/Adapters/AgGridQueryParserTests.cs b/tests/FlexQuery.NET.Tests/Adapters/AgGridQueryParserTests.cs index 38c3650..3554df0 100644 --- a/tests/FlexQuery.NET.Tests/Adapters/AgGridQueryParserTests.cs +++ b/tests/FlexQuery.NET.Tests/Adapters/AgGridQueryParserTests.cs @@ -517,7 +517,7 @@ public void ApplyAgGridRequest_LeafRequestClearsStaleGroupingAndAggregates() GroupBy = ["category"], Aggregates = [ - new AggregateModel + new Aggregate { Field = "quantity", Function = AggregateFunction.Sum, @@ -552,7 +552,7 @@ public void ApplyAgGridRequest_ReplacesExistingGroupingAndAggregates() GroupBy = ["oldGroup"], Aggregates = [ - new AggregateModel { Field = "oldValue", Function = AggregateFunction.Max, Alias = "oldValueMax" } + new Aggregate { Field = "oldValue", Function = AggregateFunction.Max, Alias = "oldValueMax" } ] }; diff --git a/tests/FlexQuery.NET.Tests/Builders/AggregateResultBuilderTests.cs b/tests/FlexQuery.NET.Tests/Builders/AggregateResultBuilderTests.cs index 1d7aab3..5ceb6cd 100644 --- a/tests/FlexQuery.NET.Tests/Builders/AggregateResultBuilderTests.cs +++ b/tests/FlexQuery.NET.Tests/Builders/AggregateResultBuilderTests.cs @@ -32,7 +32,7 @@ public void Build_EmptyAggregates_ReturnsNull() public void Build_MatchingAggregates_ReturnsGrandTotals() { var row = new AggregateRow { TotalSum = 250.0, IdCount = 10 }; - var aggregates = new List + var aggregates = new List { new() { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }, new() { Function = AggregateFunction.Count, Field = "Id", Alias = "IdCount" } @@ -53,7 +53,7 @@ public void Build_MatchingAggregates_ReturnsGrandTotals() public void Build_PartialMatch_OnlyReturnsMatching() { var row = new AggregateRow { TotalSum = 100, IdCount = 5 }; - var aggregates = new List + var aggregates = new List { new() { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" } }; @@ -69,7 +69,7 @@ public void Build_PartialMatch_OnlyReturnsMatching() public void Build_MissingAlias_ReturnsNullForUnmatchedProperty() { var row = new { OtherField = 42 }; - var aggregates = new List + var aggregates = new List { new() { Function = AggregateFunction.Count, Field = "Id", Alias = "NonExistentProperty" } }; diff --git a/tests/FlexQuery.NET.Tests/Builders/FluentQueryBuilderTests.cs b/tests/FlexQuery.NET.Tests/Builders/FluentQueryBuilderTests.cs index c145e57..fd8b78b 100644 --- a/tests/FlexQuery.NET.Tests/Builders/FluentQueryBuilderTests.cs +++ b/tests/FlexQuery.NET.Tests/Builders/FluentQueryBuilderTests.cs @@ -260,12 +260,12 @@ public void Aggregate_AddsAggregateProjections() .Build(); options.Aggregates.Should().HaveCount(6); - options.Aggregates[0].Should().Match(a => a.Function == AggregateFunction.Sum && a.Field == "Amount" && a.Alias == "TotalAmount"); - options.Aggregates[1].Should().Match(a => a.Function == AggregateFunction.Count && a.Field == "Id" && a.Alias == "OrderCount"); - options.Aggregates[2].Should().Match(a => a.Function == AggregateFunction.Count && a.Field == "TotalCount" && a.Alias == "TotalCount"); - options.Aggregates[3].Should().Match(a => a.Function == AggregateFunction.Avg && a.Field == "Price" && a.Alias == "AvgPrice"); - options.Aggregates[4].Should().Match(a => a.Function == AggregateFunction.Min && a.Field == "Date" && a.Alias == "Earliest"); - options.Aggregates[5].Should().Match(a => a.Function == AggregateFunction.Max && a.Field == "Date" && a.Alias == "Latest"); + options.Aggregates[0].Should().Match(a => a.Function == AggregateFunction.Sum && a.Field == "Amount" && a.Alias == "TotalAmount"); + options.Aggregates[1].Should().Match(a => a.Function == AggregateFunction.Count && a.Field == "Id" && a.Alias == "OrderCount"); + options.Aggregates[2].Should().Match(a => a.Function == AggregateFunction.Count && a.Field == "TotalCount" && a.Alias == "TotalCount"); + options.Aggregates[3].Should().Match(a => a.Function == AggregateFunction.Avg && a.Field == "Price" && a.Alias == "AvgPrice"); + options.Aggregates[4].Should().Match(a => a.Function == AggregateFunction.Min && a.Field == "Date" && a.Alias == "Earliest"); + options.Aggregates[5].Should().Match(a => a.Function == AggregateFunction.Max && a.Field == "Date" && a.Alias == "Latest"); } [Fact] @@ -276,10 +276,11 @@ public void Having_SetsHavingCondition() .Build(); options.Having.Should().NotBeNull(); - options.Having!.Function.Should().Be(AggregateFunction.Count); - options.Having.Field.Should().Be("Id"); - options.Having.Operator.Should().Be("gt"); - options.Having.Value.Should().Be("5"); + var having = (HavingConditionNode)options.Having!; + having.Function.Should().Be(AggregateFunction.Count); + having.Field.Should().Be("Id"); + having.Operator.Should().Be("gt"); + having.Value.Should().Be("5"); } [Fact] diff --git a/tests/FlexQuery.NET.Tests/Builders/GroupByTests.cs b/tests/FlexQuery.NET.Tests/Builders/GroupByTests.cs index 8e083ca..5031eac 100644 --- a/tests/FlexQuery.NET.Tests/Builders/GroupByTests.cs +++ b/tests/FlexQuery.NET.Tests/Builders/GroupByTests.cs @@ -330,8 +330,8 @@ public async Task FlexQueryAsync_GroupedNullKeys_ReturnsNullGroupWithCorrectAggr GroupBy = ["Profile.Bio"], Aggregates = [ - new AggregateModel { Field = "Age", Function = AggregateFunction.Sum, Alias = "ageSum" }, - new AggregateModel { Field = "Id", Function = AggregateFunction.Count, Alias = "idCount" } + new Aggregate { Field = "Age", Function = AggregateFunction.Sum, Alias = "ageSum" }, + new Aggregate { Field = "Id", Function = AggregateFunction.Count, Alias = "idCount" } ], Filter = new FilterGroup { @@ -512,8 +512,8 @@ public async Task FlexQueryAsync_GroupedHaving_Count_FiltersByCount() var options = new QueryOptions { GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Id", Alias = "Count" }], - Having = new HavingCondition + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Id", Alias = "Count" }], + Having = new HavingConditionNode { Field = "Id", Function = AggregateFunction.Count, @@ -535,8 +535,8 @@ public async Task FlexQueryAsync_GroupedHaving_Sum_FiltersBySum() var options = new QueryOptions { GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], - Having = new HavingCondition + Aggregates = [new Aggregate { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], + Having = new HavingConditionNode { Field = "Total", Function = AggregateFunction.Sum, @@ -557,8 +557,8 @@ public async Task FlexQueryAsync_GroupedHaving_Avg_FiltersByAverage() var options = new QueryOptions { GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Field = "Total", Function = AggregateFunction.Avg, Alias = "totalAvg" }], - Having = new HavingCondition + Aggregates = [new Aggregate { Field = "Total", Function = AggregateFunction.Avg, Alias = "totalAvg" }], + Having = new HavingConditionNode { Field = "Total", Function = AggregateFunction.Avg, @@ -578,8 +578,8 @@ public async Task FlexQueryAsync_GroupedHaving_Max_FiltersByMax() var options = new QueryOptions { GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Field = "Total", Function = AggregateFunction.Max, Alias = "totalMax" }], - Having = new HavingCondition + Aggregates = [new Aggregate { Field = "Total", Function = AggregateFunction.Max, Alias = "totalMax" }], + Having = new HavingConditionNode { Field = "Total", Function = AggregateFunction.Max, @@ -599,8 +599,8 @@ public async Task FlexQueryAsync_GroupedHaving_Min_FiltersByMin() var options = new QueryOptions { GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Field = "Total", Function = AggregateFunction.Min, Alias = "totalMin" }], - Having = new HavingCondition + Aggregates = [new Aggregate { Field = "Total", Function = AggregateFunction.Min, Alias = "totalMin" }], + Having = new HavingConditionNode { Field = "Total", Function = AggregateFunction.Min, @@ -623,8 +623,8 @@ await AddOrdersAsync( var options = new QueryOptions { GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Id", Alias = "Count" }], - Having = new HavingCondition + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Id", Alias = "Count" }], + Having = new HavingConditionNode { Field = "Id", Function = AggregateFunction.Count, @@ -650,8 +650,8 @@ await AddOrdersAsync( var options = new QueryOptions { GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Id", Alias = "Count" }], - Having = new HavingCondition + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Id", Alias = "Count" }], + Having = new HavingConditionNode { Field = "Id", Function = AggregateFunction.Count, @@ -672,8 +672,8 @@ public async Task FlexQueryAsync_GroupedHaving_WithProjection_SelectsOnlyProject var options = new QueryOptions { GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Id", Alias = "Count" }], - Having = new HavingCondition + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Id", Alias = "Count" }], + Having = new HavingConditionNode { Field = "Id", Function = AggregateFunction.Count, @@ -697,8 +697,8 @@ public async Task FlexQueryAsync_GroupedHaving_HavingFiltersOutAllRows_ReturnsEm var options = new QueryOptions { GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Id", Alias = "Count" }], - Having = new HavingCondition + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Id", Alias = "Count" }], + Having = new HavingConditionNode { Field = "Id", Function = AggregateFunction.Count, @@ -720,7 +720,7 @@ private static QueryOptions GroupedOptions(int page, int pageSize) GroupBy = ["CustomerId"], Aggregates = [ - new AggregateModel + new Aggregate { Field = "Total", Function = AggregateFunction.Sum, @@ -738,11 +738,11 @@ private static QueryOptions MultiAggregateGroupedOptions(int page, int pageSize) GroupBy = ["CustomerId"], Aggregates = [ - new AggregateModel { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }, - new AggregateModel { Field = "Total", Function = AggregateFunction.Avg, Alias = "totalAvg" }, - new AggregateModel { Field = "Total", Function = AggregateFunction.Min, Alias = "totalMin" }, - new AggregateModel { Field = "Total", Function = AggregateFunction.Max, Alias = "totalMax" }, - new AggregateModel { Field = "Id", Function = AggregateFunction.Count, Alias = "idCount" } + new Aggregate { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }, + new Aggregate { Field = "Total", Function = AggregateFunction.Avg, Alias = "totalAvg" }, + new Aggregate { Field = "Total", Function = AggregateFunction.Min, Alias = "totalMin" }, + new Aggregate { Field = "Total", Function = AggregateFunction.Max, Alias = "totalMax" }, + new Aggregate { Field = "Id", Function = AggregateFunction.Count, Alias = "idCount" } ], Paging = { Page = page, PageSize = pageSize } }; @@ -755,7 +755,7 @@ private static QueryOptions MultiColumnGroupedOptions(int page, int pageSize) GroupBy = ["CustomerId", "OrderDate"], Aggregates = [ - new AggregateModel + new Aggregate { Field = "Total", Function = AggregateFunction.Sum, @@ -769,7 +769,7 @@ private static QueryOptions MultiColumnGroupedOptions(int page, int pageSize) private static QueryOptions GroupedOptionsWithHaving(int page, int pageSize, decimal minimumTotal) { var options = GroupedOptions(page, pageSize); - options.Having = new HavingCondition + options.Having = new HavingConditionNode { Field = "Total", Function = AggregateFunction.Sum, @@ -891,8 +891,8 @@ await AddOrdersWithNumberPrefixAsync( Filters = [new FilterCondition { Field = "Number", Operator = "startswith", Value = "ORDER-" }] }, GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "totalSum" }], - Having = new HavingCondition { Function = AggregateFunction.Sum, Field = "Total", Operator = "gt", Value = "0" }, + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "totalSum" }], + Having = new HavingConditionNode { Function = AggregateFunction.Sum, Field = "Total", Operator = "gt", Value = "0" }, Sort = [new SortNode { Field = "totalSum", Descending = true }], Paging = { Page = 1, PageSize = 10 } }; diff --git a/tests/FlexQuery.NET.Tests/Caching/QueryCacheKeyBuilderTests.cs b/tests/FlexQuery.NET.Tests/Caching/QueryCacheKeyBuilderTests.cs index 2c6a207..946196e 100644 --- a/tests/FlexQuery.NET.Tests/Caching/QueryCacheKeyBuilderTests.cs +++ b/tests/FlexQuery.NET.Tests/Caching/QueryCacheKeyBuilderTests.cs @@ -113,7 +113,7 @@ public void Build_GroupByAndAggregatesIncluded() var options = new QueryOptions { GroupBy = ["Category"], - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Id", Alias = "cnt" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Id", Alias = "cnt" }] }; var key = QueryCacheKeyBuilder.Build(options, typeof(Customer), "query"); diff --git a/tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs index dc7bc65..83611c4 100644 --- a/tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Dialects/DialectTests.cs @@ -735,7 +735,7 @@ public void All_Dialects_Generate_Grand_Total_Aggregates_Select() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Alias = "TotalCount", Field = "*" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Alias = "TotalCount", Field = "*" }] }; options.Items[ContextKeys.EntityType] = typeof(Customer); @@ -1116,7 +1116,7 @@ private static QueryOptions CreateAggregateOptions() var options = new QueryOptions { GroupBy = ["Status"], - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Alias = "TotalCount", Field = "*" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Alias = "TotalCount", Field = "*" }] }; options.Items[ContextKeys.EntityType] = typeof(Customer); return options; @@ -1158,7 +1158,7 @@ private static QueryOptions CreateHavingOptions() var options = new QueryOptions { GroupBy = ["Status"], - Having = new HavingCondition + Having = new HavingConditionNode { Field = "Amount", Operator = "gt", diff --git a/tests/FlexQuery.NET.Tests/Dapper/Integration/GroupedQueryExecutionTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Integration/GroupedQueryExecutionTests.cs index d8d7862..c28398d 100644 --- a/tests/FlexQuery.NET.Tests/Dapper/Integration/GroupedQueryExecutionTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Integration/GroupedQueryExecutionTests.cs @@ -153,8 +153,8 @@ await AddOrdersWithNumberPrefixAsync( GroupBy = ["CustomerId"], Aggregates = [ - new AggregateModel { Field = "Id", Function = AggregateFunction.Count, Alias = "idCount" }, - new AggregateModel { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" } + new Aggregate { Field = "Id", Function = AggregateFunction.Count, Alias = "idCount" }, + new Aggregate { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" } ], Filter = NumberPrefixFilter("COUNT-"), Sort = [new SortNode { Field = "CustomerId" }], @@ -186,7 +186,7 @@ private static QueryOptions GroupedOptions(int page, int pageSize) GroupBy = ["CustomerId"], Aggregates = [ - new AggregateModel + new Aggregate { Field = "Total", Function = AggregateFunction.Sum, @@ -204,7 +204,7 @@ private static QueryOptions MultiColumnGroupedOptions(int page, int pageSize) GroupBy = ["CustomerId", "OrderDate"], Aggregates = [ - new AggregateModel + new Aggregate { Field = "Total", Function = AggregateFunction.Sum, @@ -218,7 +218,7 @@ private static QueryOptions MultiColumnGroupedOptions(int page, int pageSize) private static QueryOptions GroupedOptionsWithHaving(int page, int pageSize, decimal minimumTotal) { var options = GroupedOptions(page, pageSize); - options.Having = new HavingCondition + options.Having = new HavingConditionNode { Field = "Total", Function = AggregateFunction.Sum, @@ -235,14 +235,14 @@ private static QueryOptions CountGroupedOptionsWithHaving(int page, int pageSize GroupBy = ["CustomerId"], Aggregates = [ - new AggregateModel + new Aggregate { Field = "Id", Function = AggregateFunction.Count, Alias = "idCount" } ], - Having = new HavingCondition + Having = new HavingConditionNode { Field = "Id", Function = AggregateFunction.Count, diff --git a/tests/FlexQuery.NET.Tests/Dapper/Security/SecurityGovernanceDapperIntegrationTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Security/SecurityGovernanceDapperIntegrationTests.cs index 4a24a57..71fa6a1 100644 --- a/tests/FlexQuery.NET.Tests/Dapper/Security/SecurityGovernanceDapperIntegrationTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Security/SecurityGovernanceDapperIntegrationTests.cs @@ -104,7 +104,7 @@ public void GroupedQuery_OrderByGroupField_Works() var options = NoPaging(new QueryOptions { GroupBy = new List { "Category" }, - Aggregates = { new AggregateModel { Field = "Price", Function = AggregateFunction.Avg, Alias = "priceAvg" } }, + Aggregates = { new Aggregate { Field = "Price", Function = AggregateFunction.Avg, Alias = "priceAvg" } }, Sort = { new SortNode { Field = "Category", Descending = true } } }); options.Items[ContextKeys.EntityType] = typeof(GovOrder); @@ -139,7 +139,7 @@ public void GroupedQuery_NonGroupedSort_PassesThrough() var options = NoPaging(new QueryOptions { GroupBy = new List { "Category" }, - Aggregates = { new AggregateModel { Field = "Price", Function = AggregateFunction.Sum, Alias = "priceSum" } }, + Aggregates = { new Aggregate { Field = "Price", Function = AggregateFunction.Sum, Alias = "priceSum" } }, Sort = { new SortNode { Field = "Id" } } }); options.Items[ContextKeys.EntityType] = typeof(GovOrder); @@ -172,7 +172,7 @@ public void GroupBy_ShouldThrow_WhenFieldNotGroupable() var options = NoPaging(new QueryOptions { GroupBy = new List { "Salary" }, - Aggregates = { new AggregateModel { Field = "Salary", Function = AggregateFunction.Sum, Alias = "salarySum" } } + Aggregates = { new Aggregate { Field = "Salary", Function = AggregateFunction.Sum, Alias = "salarySum" } } }); options.Items[ContextKeys.EntityType] = typeof(GovEntity); @@ -195,7 +195,7 @@ public void Aggregate_ShouldThrow_WhenFieldNotAggregatable() { var options = NoPaging(new QueryOptions { - Aggregates = { new AggregateModel { Field = "Salary", Function = AggregateFunction.Sum, Alias = "salarySum" } } + Aggregates = { new Aggregate { Field = "Salary", Function = AggregateFunction.Sum, Alias = "salarySum" } } }); options.Items[ContextKeys.EntityType] = typeof(GovEntity); @@ -219,8 +219,8 @@ public void Having_ShouldThrow_WhenFieldNotAggregatable() var options = NoPaging(new QueryOptions { GroupBy = new List { "Name" }, - Aggregates = { new AggregateModel { Field = "Salary", Function = AggregateFunction.Sum, Alias = "salarySum" } }, - Having = new HavingCondition { Field = "Salary", Function = AggregateFunction.Sum, Operator = "gt", Value = "0" } + Aggregates = { new Aggregate { Field = "Salary", Function = AggregateFunction.Sum, Alias = "salarySum" } }, + Having = new HavingConditionNode { Field = "Salary", Function = AggregateFunction.Sum, Operator = "gt", Value = "0" } }); options.Items[ContextKeys.EntityType] = typeof(GovEntity); @@ -481,7 +481,7 @@ public async Task Execute_GroupedQuery_ReturnsAggregateAlias() var options = NoPaging(new QueryOptions { GroupBy = new List { "CustomerId" }, - Aggregates = { new AggregateModel { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" } }, + Aggregates = { new Aggregate { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" } }, Sort = { new SortNode { Field = "Total", Descending = true } } }); var dapperOptions = new DapperQueryOptions @@ -513,7 +513,7 @@ public async Task Execute_GroupByGovernanceViolation_ShouldThrow() var options = NoPaging(new QueryOptions { GroupBy = new List { "Name" }, - Aggregates = { new AggregateModel { Field = "Name", Function = AggregateFunction.Count, Alias = "cnt" } } + Aggregates = { new Aggregate { Field = "Name", Function = AggregateFunction.Count, Alias = "cnt" } } }); var modelBuilder = new DapperModelBuilder(); modelBuilder.Entity().ToTable("Entities"); @@ -540,7 +540,7 @@ public async Task Execute_AggregateGovernanceViolation_ShouldThrow() var options = NoPaging(new QueryOptions { - Aggregates = { new AggregateModel { Field = "Name", Function = AggregateFunction.Count, Alias = "cnt" } } + Aggregates = { new Aggregate { Field = "Name", Function = AggregateFunction.Count, Alias = "cnt" } } }); var dapperOptions = new DapperQueryOptions { @@ -564,8 +564,8 @@ public async Task Execute_HavingGovernanceViolation_ShouldThrow() var options = NoPaging(new QueryOptions { GroupBy = new List { "Name" }, - Aggregates = { new AggregateModel { Field = "Name", Function = AggregateFunction.Count, Alias = "cnt" } }, - Having = new HavingCondition { Field = "Name", Function = AggregateFunction.Count, Operator = "gt", Value = "0" } + Aggregates = { new Aggregate { Field = "Name", Function = AggregateFunction.Count, Alias = "cnt" } }, + Having = new HavingConditionNode { Field = "Name", Function = AggregateFunction.Count, Operator = "gt", Value = "0" } }); var dapperOptions = new DapperQueryOptions { diff --git a/tests/FlexQuery.NET.Tests/Dapper/Security/SqlInjectionTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Security/SqlInjectionTests.cs index 0c9d358..7cf20e1 100644 --- a/tests/FlexQuery.NET.Tests/Dapper/Security/SqlInjectionTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Security/SqlInjectionTests.cs @@ -224,7 +224,7 @@ public void Should_Parameterize_Having_Values() var options = new QueryOptions { GroupBy = new List { "Name" }, - Having = new HavingCondition + Having = new HavingConditionNode { Function = AggregateFunction.Count, Field = "Id", @@ -249,7 +249,7 @@ public void Should_Quote_Aggregate_Fields() var options = new QueryOptions { GroupBy = ["Id"], - Aggregates = { new AggregateModel { Function = AggregateFunction.Sum, Field = "Price", Alias = "Total" } }, + Aggregates = { new Aggregate { Function = AggregateFunction.Sum, Field = "Price", Alias = "Total" } }, Paging = { Disabled = true } }; options.Items[ContextKeys.EntityType] = typeof(Customer); @@ -266,7 +266,7 @@ public void Should_Quote_Aggregate_Alias_To_Prevent_Injection() var options = new QueryOptions { GroupBy = ["Id"], - Aggregates = { new AggregateModel { Function = AggregateFunction.Count, Field = "Id", Alias = "Cnt); DROP TABLE Users;--" } }, + Aggregates = { new Aggregate { Function = AggregateFunction.Count, Field = "Id", Alias = "Cnt); DROP TABLE Users;--" } }, Paging = { Disabled = true } }; options.Items[ContextKeys.EntityType] = typeof(Customer); @@ -282,7 +282,7 @@ public void Should_Quote_Aggregate_Fields_In_TranslateAggregates() { var options = new QueryOptions { - Aggregates = { new AggregateModel { Function = AggregateFunction.Sum, Field = "Price", Alias = "Total" } }, + Aggregates = { new Aggregate { Function = AggregateFunction.Sum, Field = "Price", Alias = "Total" } }, Paging = { Disabled = true } }; options.Items[ContextKeys.EntityType] = typeof(Customer); diff --git a/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlHavingBuilderTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlHavingBuilderTests.cs index 04370c6..1f167d7 100644 --- a/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlHavingBuilderTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlHavingBuilderTests.cs @@ -30,7 +30,7 @@ public void Build_CountStarWithGt_GeneratesHavingCountGt() { var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Count, Field = null, @@ -50,7 +50,7 @@ public void Build_CountStarWithEq_GeneratesHavingCountEq() { var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Count, Field = null, @@ -69,7 +69,7 @@ public void Build_CountStarWithLt_GeneratesHavingCountLt() { var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Count, Field = null, @@ -88,7 +88,7 @@ public void Build_CountStarWithGte_GeneratesHavingCountGte() { var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Count, Field = null, @@ -107,7 +107,7 @@ public void Build_CountStarWithLte_GeneratesHavingCountLte() { var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Count, Field = null, @@ -126,7 +126,7 @@ public void Build_CountStarWithNeq_GeneratesHavingCountNeq() { var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Count, Field = null, @@ -145,7 +145,7 @@ public void Build_CountField_GeneratesHavingCountField() { var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Count, Field = "Status", @@ -164,7 +164,7 @@ public void Build_SumField_GeneratesHavingSum() { var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Sum, Field = "Score", @@ -183,7 +183,7 @@ public void Build_AvgField_GeneratesHavingAvg() { var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Avg, Field = "Score", @@ -202,7 +202,7 @@ public void Build_MinField_GeneratesHavingMin() { var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Min, Field = "Score", @@ -221,7 +221,7 @@ public void Build_MaxField_GeneratesHavingMax() { var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Max, Field = "Score", @@ -241,7 +241,7 @@ public void Build_OperatorAliases_NormalizeCorrectly() var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Count, Field = null, @@ -259,7 +259,7 @@ public void Build_OperatorNeqAliases_NormalizeCorrectly() var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Count, Field = null, @@ -277,7 +277,7 @@ public void Build_OperatorGtAliases_NormalizeCorrectly() var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Count, Field = null, @@ -295,7 +295,7 @@ public void Build_OperatorLtAliases_NormalizeCorrectly() var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Count, Field = null, @@ -313,7 +313,7 @@ public void Build_OperatorGteAliases_NormalizeCorrectly() var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Count, Field = null, @@ -331,7 +331,7 @@ public void Build_OperatorLteAliases_NormalizeCorrectly() var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Count, Field = null, @@ -348,7 +348,7 @@ public void Build_SumWithDecimalValue_ConvertsCorrectly() { var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Sum, Field = "Score", @@ -367,7 +367,7 @@ public void Build_CountStarWithSqlite_ConvertsDecimalToDouble() { var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(new SqliteDialect()); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Count, Field = null, @@ -386,7 +386,7 @@ public void Build_SumWithSqlite_ConvertsDecimalToDouble() { var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(new SqliteDialect()); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Sum, Field = "Score", @@ -405,7 +405,7 @@ public void Build_WithPostgreSql_UsesCorrectQuoting() { var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(new PostgreSqlDialect()); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Sum, Field = "Score", @@ -423,7 +423,7 @@ public void Build_WithMySql_UsesCorrectQuoting() { var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(new MySqlDialect()); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Sum, Field = "Score", @@ -443,7 +443,7 @@ public void Build_WithTableAlias_UsesAliasInColumn() registry.Entity().ToTable("Employees").HasAlias("e"); var mapping = registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Sum, Field = "Score", @@ -461,7 +461,7 @@ public void Build_UnknownOperator_ReturnsRawOperator() { var mapping = _registry.GetMapping(typeof(Employee)); var parameters = new SqlParameterContext(Dialect); - var having = new HavingCondition + var having = new HavingConditionNode { Function = AggregateFunction.Count, Field = null, diff --git a/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlSelectBuilderTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlSelectBuilderTests.cs index a2b81b5..867b596 100644 --- a/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlSelectBuilderTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlSelectBuilderTests.cs @@ -23,7 +23,7 @@ public void BuildAggregateSelectParts_CountStarNullField_ReturnsCount1() var builder = new SqlSelectBuilder(_registry, Dialect); var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = null, Alias = "total" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = null, Alias = "total" }] }; var parts = builder.BuildAggregateSelectParts(options, mapping); @@ -38,7 +38,7 @@ public void BuildAggregateSelectParts_CountStarWildcard_ReturnsCount1() var builder = new SqlSelectBuilder(_registry, Dialect); var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "*", Alias = "total" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "*", Alias = "total" }] }; var parts = builder.BuildAggregateSelectParts(options, mapping); @@ -53,7 +53,7 @@ public void BuildAggregateSelectParts_CountField_GeneratesCountField() var builder = new SqlSelectBuilder(_registry, Dialect); var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Status", Alias = "statusCount" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Status", Alias = "statusCount" }] }; var parts = builder.BuildAggregateSelectParts(options, mapping); @@ -68,7 +68,7 @@ public void BuildAggregateSelectParts_Sum_GeneratesSum() var builder = new SqlSelectBuilder(_registry, Dialect); var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Score", Alias = "totalScore" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Score", Alias = "totalScore" }] }; var parts = builder.BuildAggregateSelectParts(options, mapping); @@ -83,7 +83,7 @@ public void BuildAggregateSelectParts_Avg_GeneratesAvg() var builder = new SqlSelectBuilder(_registry, Dialect); var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Avg, Field = "Score", Alias = "avgScore" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Avg, Field = "Score", Alias = "avgScore" }] }; var parts = builder.BuildAggregateSelectParts(options, mapping); @@ -98,7 +98,7 @@ public void BuildAggregateSelectParts_Min_GeneratesMin() var builder = new SqlSelectBuilder(_registry, Dialect); var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Min, Field = "Score", Alias = "minScore" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Min, Field = "Score", Alias = "minScore" }] }; var parts = builder.BuildAggregateSelectParts(options, mapping); @@ -113,7 +113,7 @@ public void BuildAggregateSelectParts_Max_GeneratesMax() var builder = new SqlSelectBuilder(_registry, Dialect); var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Max, Field = "Score", Alias = "maxScore" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Max, Field = "Score", Alias = "maxScore" }] }; var parts = builder.BuildAggregateSelectParts(options, mapping); @@ -130,9 +130,9 @@ public void BuildAggregateSelectParts_MultipleAggregates_ReturnsAll() { Aggregates = [ - new AggregateModel { Function = AggregateFunction.Count, Field = null, Alias = "total" }, - new AggregateModel { Function = AggregateFunction.Sum, Field = "Score", Alias = "sumScore" }, - new AggregateModel { Function = AggregateFunction.Avg, Field = "Score", Alias = "avgScore" } + new Aggregate { Function = AggregateFunction.Count, Field = null, Alias = "total" }, + new Aggregate { Function = AggregateFunction.Sum, Field = "Score", Alias = "sumScore" }, + new Aggregate { Function = AggregateFunction.Avg, Field = "Score", Alias = "avgScore" } ] }; @@ -153,7 +153,7 @@ public void BuildAggregateSelectParts_WithTableAlias_UsesAlias() var builder = new SqlSelectBuilder(registry, Dialect); var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Score", Alias = "total" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Score", Alias = "total" }] }; var parts = builder.BuildAggregateSelectParts(options, mapping); @@ -219,7 +219,7 @@ public void BuildSelectClause_WithGroupByAndAggregates_IncludesBoth() var options = new QueryOptions { GroupBy = ["Status"], - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = null, Alias = "cnt" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = null, Alias = "cnt" }] }; var tree = new SelectionNode(); diff --git a/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorGroupedTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorGroupedTests.cs index 37de529..0355531 100644 --- a/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorGroupedTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorGroupedTests.cs @@ -61,7 +61,7 @@ public void Dapper_GroupedPaging_GeneratesCorrectClauseOrdering() } ] }; - options.Having = new HavingCondition + options.Having = new HavingConditionNode { Field = "Total", Function = AggregateFunction.Sum, @@ -96,7 +96,7 @@ public void Dapper_GroupedQuery_CountSql_CountsGroupedRowsForFutureResultCount() } ] }; - options.Having = new HavingCondition + options.Having = new HavingConditionNode { Field = "Total", Function = AggregateFunction.Sum, @@ -207,7 +207,7 @@ private static QueryOptions GroupedOptions() GroupBy = ["CustomerId"], Aggregates = [ - new AggregateModel + new Aggregate { Field = "Total", Function = AggregateFunction.Sum, diff --git a/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorTests.cs b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorTests.cs index 04e31a7..53c9fa6 100644 --- a/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorTests.cs +++ b/tests/FlexQuery.NET.Tests/Dapper/Translation/SqlTranslatorTests.cs @@ -149,7 +149,7 @@ public void Translate_Aggregates_GeneratesAggregateSelect() { var options = NoPaging(new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Alias = "TotalCount" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Alias = "TotalCount" }] }); options.Items[ContextKeys.EntityType] = typeof(Customer); @@ -265,7 +265,7 @@ public void Translate_Having_GeneratesHavingClause() var options = NoPaging(new QueryOptions { GroupBy = ["Status"], - Having = new HavingCondition + Having = new HavingConditionNode { Field = "Amount", Operator = "gt", @@ -289,8 +289,8 @@ public void Translate_Having_FieldLessCount_GeneratesCountStar() var options = NoPaging(new QueryOptions { GroupBy = ["Status"], - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Alias = "Count" }], - Having = new HavingCondition + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Alias = "Count" }], + Having = new HavingConditionNode { Function = AggregateFunction.Count, Operator = "gt", @@ -314,8 +314,8 @@ public void Translate_Having_ClauseOrdering_GroupByBeforeHaving() var options = NoPaging(new QueryOptions { GroupBy = ["Status"], - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Alias = "Count" }], - Having = new HavingCondition + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Alias = "Count" }], + Having = new HavingConditionNode { Function = AggregateFunction.Count, Operator = "gt", @@ -340,7 +340,7 @@ public void Translate_Having_SelectBuildsAggregateSelectWithNewAlias() var options = NoPaging(new QueryOptions { GroupBy = ["Status"], - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Alias = "Count" }], + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Alias = "Count" }], }); options.Items[ContextKeys.EntityType] = typeof(Customer); diff --git a/tests/FlexQuery.NET.Tests/Integration/FlexQueryBaseTests.cs b/tests/FlexQuery.NET.Tests/Integration/FlexQueryBaseTests.cs index 6825951..d574536 100644 --- a/tests/FlexQuery.NET.Tests/Integration/FlexQueryBaseTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/FlexQueryBaseTests.cs @@ -22,7 +22,7 @@ public void FlexQueryRequest_PopulatesAllProperties() Select = ["Id", "Name"], Include = ["Orders"], GroupBy = ["Category"], - Having = new HavingCondition { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "5" }, + Having = new HavingConditionNode { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "5" }, Paging = new PagingOptions { Page = 2, PageSize = 25 }, IncludeCount = false, Distinct = true, @@ -36,8 +36,9 @@ public void FlexQueryRequest_PopulatesAllProperties() request.Include.Should().BeEquivalentTo("Orders"); request.GroupBy.Should().BeEquivalentTo("Category"); request.Having.Should().NotBeNull(); - request.Having!.Function.Should().Be(AggregateFunction.Count); - request.Having.Field.Should().Be("Id"); + var having = (HavingConditionNode)request.Having!; + having.Function.Should().Be(AggregateFunction.Count); + having.Field.Should().Be("Id"); request.Paging.Page.Should().Be(2); request.Paging.PageSize.Should().Be(25); request.IncludeCount.Should().BeFalse(); diff --git a/tests/FlexQuery.NET.Tests/Integration/GroupedQueryBehaviorTests.cs b/tests/FlexQuery.NET.Tests/Integration/GroupedQueryBehaviorTests.cs index cd0979e..323f740 100644 --- a/tests/FlexQuery.NET.Tests/Integration/GroupedQueryBehaviorTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/GroupedQueryBehaviorTests.cs @@ -93,7 +93,7 @@ public async Task GroupedQuery_InvalidDetailSort_SortIsIgnored() GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], + Aggregates = [new Aggregate { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], Sort = [new SortNode { Field = "Id" }], @@ -133,7 +133,7 @@ public async Task GroupedQuery_AllInvalidSorts_FallsBackToGroupKey() GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], + Aggregates = [new Aggregate { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], Sort = [ @@ -171,7 +171,7 @@ public async Task GroupedQuery_AggregateAliasSort_SortsByAlias() GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], + Aggregates = [new Aggregate { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], Sort = [new SortNode { Field = "totalSum", Descending = true }], @@ -203,7 +203,7 @@ public async Task GroupedQuery_AggregateFieldSort_ResolvesToAlias() GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], + Aggregates = [new Aggregate { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], Sort = [new SortNode { Field = "Total", Descending = true }], @@ -235,7 +235,7 @@ public async Task GroupedQuery_GroupKeySort_SortsByKey() GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], + Aggregates = [new Aggregate { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], Sort = [new SortNode { Field = "CustomerId", Descending = true }], @@ -271,7 +271,7 @@ public async Task GroupedQuery_PagingWithoutSort_DefaultsToGroupKeyOrder() GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], + Aggregates = [new Aggregate { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], Paging = { Page = 1, PageSize = 2 }, @@ -287,7 +287,7 @@ public async Task GroupedQuery_PagingWithoutSort_DefaultsToGroupKeyOrder() GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], + Aggregates = [new Aggregate { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], Paging = { Page = 2, PageSize = 2 }, diff --git a/tests/FlexQuery.NET.Tests/Integration/GroupedQueryDapperTests.cs b/tests/FlexQuery.NET.Tests/Integration/GroupedQueryDapperTests.cs index c55bf00..d82cd20 100644 --- a/tests/FlexQuery.NET.Tests/Integration/GroupedQueryDapperTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/GroupedQueryDapperTests.cs @@ -58,7 +58,7 @@ private static QueryOptions BaseGroupedOptions() GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], + Aggregates = [new Aggregate { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], Paging = { Disabled = true } @@ -202,7 +202,7 @@ public void Dapper_GroupedQuery_PagingWithoutSort_DefaultsToGroupKeyOrder() GroupBy = ["CustomerId"], - Aggregates = [new AggregateModel { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], + Aggregates = [new Aggregate { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" }], Paging = { Page = 2, PageSize = 10 } diff --git a/tests/FlexQuery.NET.Tests/Integration/QueryNormalizationTests.cs b/tests/FlexQuery.NET.Tests/Integration/QueryNormalizationTests.cs index 079863f..e4ed19d 100644 --- a/tests/FlexQuery.NET.Tests/Integration/QueryNormalizationTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/QueryNormalizationTests.cs @@ -199,7 +199,7 @@ public void HasProjection_ReturnsTrueForAllProjectionShapes() new QueryOptions { Includes = ["Orders"] }.HasProjection().Should().BeTrue(); new QueryOptions { Expand = [new IncludeNode { Path = "Orders" }] }.HasProjection().Should().BeTrue(); new QueryOptions { GroupBy = ["Status"] }.HasProjection().Should().BeTrue(); - new QueryOptions { Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Alias = "Count" }] }.HasProjection().Should().BeTrue(); + new QueryOptions { Aggregates = [new Aggregate { Function = AggregateFunction.Count, Alias = "Count" }] }.HasProjection().Should().BeTrue(); } [Fact] diff --git a/tests/FlexQuery.NET.Tests/Integration/ResultCountTests.cs b/tests/FlexQuery.NET.Tests/Integration/ResultCountTests.cs index cacc595..902cf38 100644 --- a/tests/FlexQuery.NET.Tests/Integration/ResultCountTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/ResultCountTests.cs @@ -112,7 +112,7 @@ private static QueryOptions GroupedOptions(int page, int pageSize) GroupBy = ["CustomerId"], Aggregates = [ - new AggregateModel + new Aggregate { Field = "Total", Function = AggregateFunction.Sum, @@ -132,14 +132,14 @@ private static QueryOptions GroupedHavingOptions(int page, int pageSize) GroupBy = ["CustomerId"], Aggregates = [ - new AggregateModel + new Aggregate { Field = "Total", Function = AggregateFunction.Sum, Alias = "totalSum" } ], - Having = new HavingCondition + Having = new HavingConditionNode { Field = "Total", Function = AggregateFunction.Sum, diff --git a/tests/FlexQuery.NET.Tests/Integration/SecurityGovernanceEfCoreIntegrationTests.cs b/tests/FlexQuery.NET.Tests/Integration/SecurityGovernanceEfCoreIntegrationTests.cs index 9eb9772..ef67ef5 100644 --- a/tests/FlexQuery.NET.Tests/Integration/SecurityGovernanceEfCoreIntegrationTests.cs +++ b/tests/FlexQuery.NET.Tests/Integration/SecurityGovernanceEfCoreIntegrationTests.cs @@ -101,7 +101,7 @@ public async Task GroupBy_ShouldThrow_WhenFieldNotInGroupableFields() var options = new QueryOptions { GroupBy = new List { "Salary" }, - Aggregates = { new AggregateModel { Field = "Salary", Function = AggregateFunction.Sum, Alias = "salarySum" } }, + Aggregates = { new Aggregate { Field = "Salary", Function = AggregateFunction.Sum, Alias = "salarySum" } }, Paging = { Disabled = true } }; var execOptions = new EfCoreQueryOptions @@ -123,7 +123,7 @@ public async Task Aggregate_ShouldThrow_WhenFieldNotInAggregatableFields() { var options = new QueryOptions { - Aggregates = { new AggregateModel { Field = "Salary", Function = AggregateFunction.Sum, Alias = "salarySum" } }, + Aggregates = { new Aggregate { Field = "Salary", Function = AggregateFunction.Sum, Alias = "salarySum" } }, Paging = { Disabled = true } }; var execOptions = new EfCoreQueryOptions @@ -146,8 +146,8 @@ public async Task Having_ShouldThrow_WhenFieldNotInAggregatableFields() var options = new QueryOptions { GroupBy = new List { "Name" }, - Aggregates = { new AggregateModel { Field = "Salary", Function = AggregateFunction.Sum, Alias = "salarySum" } }, - Having = new HavingCondition { Field = "Salary", Function = AggregateFunction.Sum, Operator = "gt", Value = "0" }, + Aggregates = { new Aggregate { Field = "Salary", Function = AggregateFunction.Sum, Alias = "salarySum" } }, + Having = new HavingConditionNode { Field = "Salary", Function = AggregateFunction.Sum, Operator = "gt", Value = "0" }, Paging = { Disabled = true } }; var execOptions = new EfCoreQueryOptions diff --git a/tests/FlexQuery.NET.Tests/Parsers/Dsl/DslAggregateParserTests.cs b/tests/FlexQuery.NET.Tests/Parsers/Dsl/DslAggregateParserTests.cs index 3e93144..260e893 100644 --- a/tests/FlexQuery.NET.Tests/Parsers/Dsl/DslAggregateParserTests.cs +++ b/tests/FlexQuery.NET.Tests/Parsers/Dsl/DslAggregateParserTests.cs @@ -7,7 +7,7 @@ namespace FlexQuery.NET.Tests.Parsers.Dsl; public class DslAggregateParserTests { - private static List Parse(string? raw) => + private static List Parse(string? raw) => DslAggregateParser.Parse(raw); [Fact] @@ -414,4 +414,12 @@ public void Parse_AutoAlias_CasingPreserved() result[0].Field.Should().Be("Amount"); result[0].Alias.Should().Be("AmountSum"); } + + [Fact] + public void Parse_Field_CountStar_ThrowsFqlParseException() + { + var ex = Record.Exception(() => Parse("count:*")); + + ex.Should().BeOfType(); + } } diff --git a/tests/FlexQuery.NET.Tests/Parsers/Dsl/DslHavingParserTests.cs b/tests/FlexQuery.NET.Tests/Parsers/Dsl/DslHavingParserTests.cs index c454f17..0b159c8 100644 --- a/tests/FlexQuery.NET.Tests/Parsers/Dsl/DslHavingParserTests.cs +++ b/tests/FlexQuery.NET.Tests/Parsers/Dsl/DslHavingParserTests.cs @@ -1,4 +1,3 @@ -using FlexQuery.NET.Exceptions; using FlexQuery.NET.Execution; using FlexQuery.NET.Models; using FlexQuery.NET.Models.Aggregates; @@ -6,14 +5,12 @@ using FlexQuery.NET.Parsers.Dsl; using FlexQuery.NET.Validation; using FlexQuery.NET.Validation.Rules; -using Microsoft.AspNetCore.Http; -using Xunit; namespace FlexQuery.NET.Tests.Parsers.Dsl; public class DslHavingParserTests { - private static HavingCondition? Parse(string? raw) => + private static HavingNode? Parse(string? raw) => DslHavingParser.Parse(raw); private static QueryOptions ParseDsl(Dictionary raw) @@ -44,16 +41,21 @@ public void Parse_WhitespaceOnly_ReturnsNull() } [Theory] - [InlineData("sum:field:isnull:1", "isnull")] - [InlineData("sum:field:ISNULL:1", "isnull")] - [InlineData("sum:field:isnotnull:1", "isnotnull")] - [InlineData("sum:field:ISNOTNULL:1", "isnotnull")] + [InlineData("sum:field:eq:1", "eq")] + [InlineData("sum:field:EQ:1", "eq")] + [InlineData("sum:field:neq:1", "neq")] + [InlineData("sum:field:NEQ:1", "neq")] + [InlineData("sum:field:gt:1", "gt")] + [InlineData("sum:field:gte:1", "gte")] + [InlineData("sum:field:lt:1", "lt")] + [InlineData("sum:field:lte:1", "lte")] public void Parse_ValidOperator_ReturnsNormalizedOperator(string input, string expectedOperator) { var result = Parse(input); result.Should().NotBeNull(); - result!.Operator.Should().Be(expectedOperator); + var having = (HavingConditionNode)result!; + having.Operator.Should().Be(expectedOperator); } [Theory] @@ -62,6 +64,12 @@ public void Parse_ValidOperator_ReturnsNormalizedOperator(string input, string e [InlineData("avg:price:nope:50")] [InlineData("count:orders:doesnotexist:1,2,3")] [InlineData("sum:total:xyz:100")] + [InlineData("sum:field:isnull:1")] + [InlineData("sum:field:isnotnull:1")] + [InlineData("sum:total:between:10:20")] + [InlineData("count:orders:in:1,2,3")] + [InlineData("sum:total:contains:abc")] + [InlineData("sum:total:like:abc")] public void Parse_UnsupportedOperator_ThrowsDslParseException(string input) { var act = () => Parse(input); @@ -85,7 +93,7 @@ public void Parse_CountStar_ThrowsDslParseException() var act = () => Parse("count:*:gt:0"); act.Should().Throw() - .WithMessage("*count:* is not supported*"); + .WithMessage("*Invalid field*"); } [Fact] @@ -94,50 +102,11 @@ public void Parse_ValidGtOperator_ReturnsCorrectFunctionAndField() var result = Parse("sum:total:gt:100"); result.Should().NotBeNull(); - result!.Function.Should().Be(AggregateFunction.Sum); - result.Field.Should().Be("total"); - result.Operator.Should().Be("gt"); - result.Value.Should().Be("100"); - } - - [Fact] - public void Parse_ValidBetweenOperator_ReturnsCorrectValue() - { - var result = Parse("sum:total:between:10:20"); - - result.Should().NotBeNull(); - result!.Operator.Should().Be("between"); - result.Value.Should().Be("10:20"); - } - - [Fact] - public void Parse_ValidInOperator_ReturnsCorrectValue() - { - var result = Parse("count:orders:in:1,2,3"); - - result.Should().NotBeNull(); - result!.Operator.Should().Be("in"); - result.Value.Should().Be("1,2,3"); - } - - [Fact] - public void Parse_ValidIsNullOperator_ReturnsCorrectValue() - { - var result = Parse("sum:field:isnull:1"); - - result.Should().NotBeNull(); - result!.Operator.Should().Be("isnull"); - result.Value.Should().Be("1"); - } - - [Fact] - public void Parse_ValidIsNotNullOperator_ReturnsCorrectValue() - { - var result = Parse("sum:field:isnotnull:1"); - - result.Should().NotBeNull(); - result!.Operator.Should().Be("isnotnull"); - result.Value.Should().Be("1"); + var having = (HavingConditionNode)result!; + having.Function.Should().Be(AggregateFunction.Sum); + having.Field.Should().Be("total"); + having.Operator.Should().Be("gt"); + having.Value.Should().Be("100"); } [Fact] @@ -188,7 +157,7 @@ public void Parse_MissingFunction_ThrowsDslParseException() [Fact] public void Parse_InvalidField_ThrowsDslParseException() { - var act = () => Parse("sum:invalid field:gt:0"); + var act = () => Parse("sum:123:gt:0"); act.Should().Throw() .WithMessage("*Invalid field*"); @@ -203,7 +172,7 @@ public void DslQuery_ValidAggregateHaving_PassesValidation() ["having"] = "sum:total:gt:100" }); - var rule = new HavingAliasIntegrityRule(); + var rule = new HavingAggregateExistenceRule(); var result = ValidationResult.Success(); rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); @@ -219,13 +188,13 @@ public void DslQuery_EmptyAggregates_FailsValidation() ["having"] = "sum:total:gt:100" }); - var rule = new HavingAliasIntegrityRule(); + var rule = new HavingAggregateExistenceRule(); var result = ValidationResult.Success(); rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.AggregateNotDeclared); + result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.HavingAliasMismatch); } [Fact] @@ -237,13 +206,13 @@ public void DslQuery_MismatchedFunction_FailsValidation() ["having"] = "sum:total:gt:100" }); - var rule = new HavingAliasIntegrityRule(); + var rule = new HavingAggregateExistenceRule(); var result = ValidationResult.Success(); rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.AggregateNotDeclared); + result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.HavingAliasMismatch); } [Fact] @@ -255,12 +224,12 @@ public void DslQuery_MismatchedField_FailsValidation() ["having"] = "sum:total:gt:100" }); - var rule = new HavingAliasIntegrityRule(); + var rule = new HavingAggregateExistenceRule(); var result = ValidationResult.Success(); rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.AggregateNotDeclared); + result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.HavingAliasMismatch); } } diff --git a/tests/FlexQuery.NET.Tests/Parsers/DslQueryParserTests.cs b/tests/FlexQuery.NET.Tests/Parsers/DslQueryParserTests.cs index af6e095..191a2d1 100644 --- a/tests/FlexQuery.NET.Tests/Parsers/DslQueryParserTests.cs +++ b/tests/FlexQuery.NET.Tests/Parsers/DslQueryParserTests.cs @@ -1,10 +1,13 @@ using FlexQuery.NET.Exceptions; +using FlexQuery.NET.Execution; using FlexQuery.NET.Models; using FlexQuery.NET.Models.Projection; using FlexQuery.NET.Models.Aggregates; using FlexQuery.NET.Models.Filters; using FlexQuery.NET.Parsers; using FlexQuery.NET.Parsers.Dsl; +using FlexQuery.NET.Validation; +using FlexQuery.NET.Validation.Rules; namespace FlexQuery.NET.Tests.Parsers; @@ -473,11 +476,7 @@ public void Select_SingleField_ParsedCorrectly() opts.Select.Should().BeEquivalentTo([new SelectNode { Field = "Name" }]); } - - // ════════════════════════════════════════════════════════════════════ - // Grouping / Aggregates / Having - // ════════════════════════════════════════════════════════════════════ - + [Fact] public void GroupAndAggregateSelect_ParsedCorrectly() { @@ -495,9 +494,10 @@ public void GroupAndAggregateSelect_ParsedCorrectly() opts.Aggregates.Should().Contain(a => a.Function == AggregateFunction.Sum && a.Field == "total"); opts.Aggregates.Should().Contain(a => a.Function == AggregateFunction.Count && a.Field == "id"); opts.Having.Should().NotBeNull(); - opts.Having!.Function.Should().Be(AggregateFunction.Sum); - opts.Having.Operator.Should().Be(FilterOperators.GreaterThan); - opts.Having.Value.Should().Be("10000"); + var having = (HavingConditionNode)opts.Having!; + having.Function.Should().Be(AggregateFunction.Sum); + having.Operator.Should().Be(FilterOperators.GreaterThan); + having.Value.Should().Be("10000"); } [Fact] @@ -534,10 +534,11 @@ public void Having_Count_GreaterThan() }); opts.Having.Should().NotBeNull(); - opts.Having!.Function.Should().Be(AggregateFunction.Count); - opts.Having.Field.Should().Be("Orders"); - opts.Having.Operator.Should().Be(FilterOperators.GreaterThan); - opts.Having.Value.Should().Be("20"); + var having = (HavingConditionNode)opts.Having!; + having.Function.Should().Be(AggregateFunction.Count); + having.Field.Should().Be("Orders"); + having.Operator.Should().Be(FilterOperators.GreaterThan); + having.Value.Should().Be("20"); } [Fact] @@ -552,10 +553,11 @@ public void Having_Sum_WithField() }); opts.Having.Should().NotBeNull(); - opts.Having!.Function.Should().Be(AggregateFunction.Sum); - opts.Having.Field.Should().Be("total"); - opts.Having.Operator.Should().Be(FilterOperators.GreaterThan); - opts.Having.Value.Should().Be("100"); + var having = (HavingConditionNode)opts.Having!; + having.Function.Should().Be(AggregateFunction.Sum); + having.Field.Should().Be("total"); + having.Operator.Should().Be(FilterOperators.GreaterThan); + having.Value.Should().Be("100"); } [Fact] @@ -570,10 +572,11 @@ public void Having_ParenthesisFormat_ParsedCorrectly() }); opts.Having.Should().NotBeNull(); - opts.Having!.Function.Should().Be(AggregateFunction.Sum); - opts.Having.Field.Should().Be("total"); - opts.Having.Operator.Should().Be(FilterOperators.GreaterThan); - opts.Having.Value.Should().Be("100"); + var having = (HavingConditionNode)opts.Having!; + having.Function.Should().Be(AggregateFunction.Sum); + having.Field.Should().Be("total"); + having.Operator.Should().Be(FilterOperators.GreaterThan); + having.Value.Should().Be("100"); } [Fact] @@ -599,16 +602,13 @@ public void DslHaving_NestedField_ParsedCorrectly() }); opts.Having.Should().NotBeNull(); - opts.Having!.Function.Should().Be(AggregateFunction.Sum); - opts.Having.Field.Should().Be("Orders.Total"); - opts.Having.Operator.Should().Be(FilterOperators.GreaterThan); - opts.Having.Value.Should().Be("500"); + var having = (HavingConditionNode)opts.Having!; + having.Function.Should().Be(AggregateFunction.Sum); + having.Field.Should().Be("Orders.Total"); + having.Operator.Should().Be(FilterOperators.GreaterThan); + having.Value.Should().Be("500"); } - - // ════════════════════════════════════════════════════════════════════ - // Aggregates - // ════════════════════════════════════════════════════════════════════ - + [Fact] public void DslAggregate_SingleField_ParsedCorrectly() { @@ -869,11 +869,7 @@ public void DslAggregate_TooManyParts_IsRejected() act.Should().Throw() .Which.ParameterName.Should().Be("aggregate"); } - - // ════════════════════════════════════════════════════════════════════ - // Filter — Additional Operators - // ════════════════════════════════════════════════════════════════════ - + [Fact] public void DslFilter_LikeOperator_ParsedCorrectly() { @@ -912,11 +908,7 @@ public void DslFilter_EndsWith_ParsedCorrectly() opts.Filter!.Filters.Should().ContainSingle(f => f.Field == "email" && f.Operator == FilterOperators.EndsWith && f.Value == ".com"); } - - // ════════════════════════════════════════════════════════════════════ - // Integration — All Parameters - // ════════════════════════════════════════════════════════════════════ - + [Fact] public void DslIntegration_AllParameters_ParsedCorrectly() { @@ -983,10 +975,11 @@ public void DslIntegration_AllParameters_ParsedCorrectly() // Having opts.Having.Should().NotBeNull(); - opts.Having!.Function.Should().Be(AggregateFunction.Sum); - opts.Having.Field.Should().Be("Amount"); - opts.Having.Operator.Should().Be("gt"); - opts.Having.Value.Should().Be("1000"); + var having = (HavingConditionNode)opts.Having!; + having.Function.Should().Be(AggregateFunction.Sum); + having.Field.Should().Be("Amount"); + having.Operator.Should().Be("gt"); + having.Value.Should().Be("1000"); // Distinct opts.Distinct.Should().BeTrue(); @@ -995,11 +988,7 @@ public void DslIntegration_AllParameters_ParsedCorrectly() opts.Paging.Page.Should().Be(2); opts.Paging.PageSize.Should().Be(25); } - - // ════════════════════════════════════════════════════════════════════ - // Empty / Default Input - // ════════════════════════════════════════════════════════════════════ - + [Fact] public void EmptyInput_ReturnsDefaultOptions() { @@ -1011,4 +1000,196 @@ public void EmptyInput_ReturnsDefaultOptions() opts.Paging.Page.Should().Be(1); opts.Paging.PageSize.Should().Be(20); } + + [Fact] + public void DslQuery_ValidAggregateHaving_PassesValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "sum:Total", + Having = "sum:Total:gt:100" + }; + + var options = new DslQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void DslQuery_ValidAggregateHavingWithAnd_PassesValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "sum:Total,count:Id", + Having = "sum:Total:gt:100 AND count:Id:gte:5" + }; + + var options = new DslQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void DslQuery_ValidAggregateHavingWithOr_PassesValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "sum:Total,avg:Price", + Having = "sum:Total:gt:100 OR avg:Price:lt:50" + }; + + var options = new DslQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void DslQuery_ValidNestedAggregateHaving_PassesValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "sum:total, count:Id, avg:price", + Having = "(sum:total:gt:100 OR count:Id:gte:5) AND avg:price:lt:50" + }; + + var options = new DslQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeTrue(); + } + + + + [Fact] + public void DslQuery_InvalidAggregateHaving_FailsValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "sum:Total", + Having = "avg:Price:gt:100" + }; + + var options = new DslQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void DslQuery_InvalidAggregateHavingWithAnd_FailsValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "sum:Total", + Having = "sum:Total:gt:100 AND count:Id:gte:5" + }; + + var options = new DslQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void DslQuery_InvalidAggregateHavingWithOr_FailsValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "sum:Total", + Having = "sum:Total:gt:100 OR avg:price:lt:50" + }; + + var options = new DslQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void DslQuery_InvalidNestedAggregateHaving_FailsValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "sum:Total,count:Id", + + Having = "(sum:Total:gt:100 OR avg:price:lt:50) AND count:Id:gte:5" + }; + + var options = new DslQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void DslQuery_CountStarAggregate_ThrowsParseException() + { + var parameters = new FlexQueryParameters + { + Aggregate = "count:*" + }; + + var action = () => new DslQueryParser().Parse(parameters); + + action.Should().Throw(); + } + + [Fact] + public void DslQuery_CountStarAggregateAndHaving_ThrowsParseException() + { + var parameters = new FlexQueryParameters + { + Aggregate = "count:*", + Having = "count:*" + }; + + var action = () => new DslQueryParser().Parse(parameters); + + action.Should().Throw(); + } + + [Fact] + public void DslQuery_NestedHavingWithDeclaredAggregates_PassesValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "count:id, sum:total", + Having = "(sum:total:gt:500 AND count:id:gte:5)" + }; + + var options = new DslQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeTrue(); + } } \ No newline at end of file diff --git a/tests/FlexQuery.NET.Tests/Parsers/Fql/FqlAggregateParserTests.cs b/tests/FlexQuery.NET.Tests/Parsers/Fql/FqlAggregateParserTests.cs index a0f25df..19c5b7a 100644 --- a/tests/FlexQuery.NET.Tests/Parsers/Fql/FqlAggregateParserTests.cs +++ b/tests/FlexQuery.NET.Tests/Parsers/Fql/FqlAggregateParserTests.cs @@ -7,7 +7,7 @@ namespace FlexQuery.NET.Tests.Parsers.Fql; public class FqlAggregateParserTests { - private static List Parse(string? raw) => + private static List Parse(string? raw) => FqlAggregateParser.Parse(raw); [Fact] @@ -452,6 +452,14 @@ public void Parse_Field_DoubleDot_ThrowsFqlParseException() ex.Should().BeOfType(); ex.Message.Should().Contain("Invalid field"); } + + [Fact] + public void Parse_Field_CountStar_ThrowsFqlParseException() + { + var ex = Record.Exception(() => Parse("COUNT(*)")); + + ex.Should().BeOfType(); + } [Fact] public void Parse_AsWithoutSpaceBeforeAlias_ThrowsFqlParseException() diff --git a/tests/FlexQuery.NET.Tests/Parsers/Fql/FqlHavingParserTests.cs b/tests/FlexQuery.NET.Tests/Parsers/Fql/FqlHavingParserTests.cs index 8473847..9b3ef6e 100644 --- a/tests/FlexQuery.NET.Tests/Parsers/Fql/FqlHavingParserTests.cs +++ b/tests/FlexQuery.NET.Tests/Parsers/Fql/FqlHavingParserTests.cs @@ -11,7 +11,7 @@ namespace FlexQuery.NET.Tests.Parsers.Fql; public class FqlHavingParserTests { - private static HavingCondition? Parse(string? raw) => + private static HavingNode? Parse(string? raw) => FqlHavingParser.Parse(raw); [Fact] @@ -40,46 +40,14 @@ public void Parse_WhitespaceOnly_ReturnsNull() [InlineData("SUM(Total) = 100", "eq", "100")] [InlineData("SUM(Total) != 100", "neq", "100")] [InlineData("SUM(Total) <> 100", "neq", "100")] - [InlineData("AVG(Price) LIKE '%abc%'", "like", "%abc%")] - [InlineData("SUM(Total) IS NULL", "isnull", null)] - [InlineData("SUM(Total) IS NOT NULL", "isnotnull", null)] public void Parse_ValidOperator_ReturnsNormalizedOperator(string input, string expectedOperator, string? expectedValue) { var result = Parse(input); result.Should().NotBeNull(); - result!.Operator.Should().Be(expectedOperator); - result.Value.Should().Be(expectedValue); - } - - [Fact] - public void Parse_ValidBetweenOperator_ReturnsCommaSeparatedValues() - { - var result = Parse("SUM(Total) BETWEEN 10 AND 20"); - - result.Should().NotBeNull(); - result!.Operator.Should().Be("between"); - result.Value.Should().Be("10,20"); - } - - [Fact] - public void Parse_ValidInOperator_ReturnsCommaSeparatedValues() - { - var result = Parse("COUNT(Orders) IN (1, 2, 3)"); - - result.Should().NotBeNull(); - result!.Operator.Should().Be("in"); - result.Value.Should().Be("1,2,3"); - } - - [Fact] - public void Parse_ValidNotInOperator_ReturnsCommaSeparatedValues() - { - var result = Parse("COUNT(Orders) NOT IN (1, 2, 3)"); - - result.Should().NotBeNull(); - result!.Operator.Should().Be("notin"); - result.Value.Should().Be("1,2,3"); + var having = (HavingConditionNode)result!; + having.Operator.Should().Be(expectedOperator); + having.Value.Should().Be(expectedValue); } [Theory] @@ -93,59 +61,15 @@ public void Parse_UnsupportedOperator_ThrowsFqlParseException(string input) act.Should().Throw(); } - [Fact] - public void Parse_UnsupportedOperator_ErrorMessageContainsOperator() - { - var ex = Record.Exception(() => Parse("SUM(Total) FOO 100")); - - ex.Should().BeOfType(); - ex.Message.Should().Contain("FOO"); - ex.Message.Should().Contain("Unrecognized operator"); - } - - [Fact] - public void Parse_Between_MissingAnd_ThrowsFqlParseException() - { - var act = () => Parse("SUM(Total) BETWEEN 10"); - - act.Should().Throw() - .WithMessage("*BETWEEN requires two values*"); - } - - [Fact] - public void Parse_In_MissingOpeningParen_ThrowsFqlParseException() - { - var act = () => Parse("SUM(Total) IN 1, 2, 3"); - - act.Should().Throw() - .WithMessage("*IN requires a parenthesized list*"); - } - - [Fact] - public void Parse_In_MissingClosingParen_ThrowsFqlParseException() - { - var act = () => Parse("SUM(Total) IN (1, 2, 3"); - - act.Should().Throw() - .WithMessage("*Missing closing parenthesis*"); - } - - [Fact] - public void Parse_In_EmptyList_ThrowsFqlParseException() - { - var act = () => Parse("SUM(Total) IN ()"); - - act.Should().Throw() - .WithMessage("*IN list cannot be empty*"); - } - - [Fact] - public void Parse_IsNull_ExtraContent_ThrowsFqlParseException() + [Theory] + [InlineData("SUM(Total) BETWEEN 10 AND 20")] + [InlineData("COUNT(Orders) IN (1, 2, 3)")] + [InlineData("COUNT(Orders) NOT IN (1, 2, 3)")] + public void Parse_UnsupportedHavingOperator_ThrowsFqlParseException(string input) { - var act = () => Parse("SUM(Total) IS NULL EXTRA"); + var act = () => Parse(input); - act.Should().Throw() - .WithMessage("*Unexpected content after*"); + act.Should().Throw(); } [Fact] @@ -154,50 +78,11 @@ public void Parse_ValidGtOperator_ReturnsCorrectFunctionAndField() var result = Parse("SUM(Total) > 100"); result.Should().NotBeNull(); - result!.Function.Should().Be(AggregateFunction.Sum); - result.Field.Should().Be("Total"); - result.Operator.Should().Be("gt"); - result.Value.Should().Be("100"); - } - - [Fact] - public void Parse_ValidBetweenOperator_ReturnsCorrectValue() - { - var result = Parse("SUM(Total) BETWEEN 10 AND 20"); - - result.Should().NotBeNull(); - result!.Operator.Should().Be("between"); - result.Value.Should().Be("10,20"); - } - - [Fact] - public void Parse_ValidInOperator_ReturnsCorrectValue() - { - var result = Parse("COUNT(Orders) IN (1, 2, 3)"); - - result.Should().NotBeNull(); - result!.Operator.Should().Be("in"); - result.Value.Should().Be("1,2,3"); - } - - [Fact] - public void Parse_ValidIsNullOperator_ReturnsCorrectValue() - { - var result = Parse("SUM(Total) IS NULL"); - - result.Should().NotBeNull(); - result!.Operator.Should().Be("isnull"); - result.Value.Should().BeNull(); - } - - [Fact] - public void Parse_ValidIsNotNullOperator_ReturnsCorrectValue() - { - var result = Parse("SUM(Total) IS NOT NULL"); - - result.Should().NotBeNull(); - result!.Operator.Should().Be("isnotnull"); - result.Value.Should().BeNull(); + var having = (HavingConditionNode)result!; + having.Function.Should().Be(AggregateFunction.Sum); + having.Field.Should().Be("Total"); + having.Operator.Should().Be("gt"); + having.Value.Should().Be("100"); } [Fact] @@ -215,13 +100,13 @@ public void Parse_CountStar_ThrowsFqlParseException() var act = () => Parse("COUNT(*) > 0"); act.Should().Throw() - .WithMessage("*COUNT(*) is not supported*"); + .WithMessage("*Expected field name inside aggregate function 'COUNT'*"); } [Fact] public void Parse_InvalidField_ThrowsFqlParseException() { - var act = () => Parse("SUM(Invalid Field) > 0"); + var act = () => Parse("SUM(bad-field) > 0"); act.Should().Throw() .WithMessage("*Invalid field*"); @@ -233,7 +118,7 @@ public void Parse_MissingFunctionCall_ThrowsFqlParseException() var act = () => Parse("Total > 100"); act.Should().Throw() - .WithMessage("*Missing function call*"); + .WithMessage("*Expected aggregate function*"); } [Fact] @@ -242,17 +127,31 @@ public void Parse_MissingClosingParen_ThrowsFqlParseException() var act = () => Parse("SUM(Total > 100"); act.Should().Throw() - .WithMessage("*Missing closing parenthesis*"); + .WithMessage("*Expected CloseParen at position 10, but found Gt*"); } [Fact] public void Parse_QuotedValues_StrippedCorrectly() { - var result = Parse("SUM(Total) BETWEEN '10' AND '20'"); + var result = Parse("SUM(Total) > '100'"); + + result.Should().NotBeNull(); + var having = (HavingConditionNode)result!; + having.Operator.Should().Be("gt"); + having.Value.Should().Be("100"); + } + + [Fact] + public void Parse_DottedField_ReturnsCorrectField() + { + var result = Parse("SUM(Orders.Total) > 100"); result.Should().NotBeNull(); - result!.Operator.Should().Be("between"); - result.Value.Should().Be("10,20"); + var having = (HavingConditionNode)result!; + having.Function.Should().Be(AggregateFunction.Sum); + having.Field.Should().Be("Orders.Total"); + having.Operator.Should().Be("gt"); + having.Value.Should().Be("100"); } [Fact] @@ -265,7 +164,7 @@ public void FqlQuery_ValidAggregateHaving_PassesValidation() }; var options = new FqlQueryParser().Parse(parameters); - var rule = new HavingAliasIntegrityRule(); + var rule = new HavingAggregateExistenceRule(); var result = ValidationResult.Success(); rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); @@ -282,13 +181,13 @@ public void FqlQuery_EmptyAggregates_FailsValidation() }; var options = new FqlQueryParser().Parse(parameters); - var rule = new HavingAliasIntegrityRule(); + var rule = new HavingAggregateExistenceRule(); var result = ValidationResult.Success(); rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.AggregateNotDeclared); + result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.HavingAliasMismatch); } [Fact] @@ -301,13 +200,13 @@ public void FqlQuery_MismatchedFunction_FailsValidation() }; var options = new FqlQueryParser().Parse(parameters); - var rule = new HavingAliasIntegrityRule(); + var rule = new HavingAggregateExistenceRule(); var result = ValidationResult.Success(); rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.AggregateNotDeclared); + result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.HavingAliasMismatch); } [Fact] @@ -320,12 +219,32 @@ public void FqlQuery_MismatchedField_FailsValidation() }; var options = new FqlQueryParser().Parse(parameters); - var rule = new HavingAliasIntegrityRule(); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.HavingAliasMismatch); + } + + + [Fact] + public void FqlQuery_HavingOrContainsMissingAggregate_FailsValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "SUM(Total)", + Having = "SUM(Total) > 100 OR AVG(Cost) < 50" + }; + + var options = new FqlQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); var result = ValidationResult.Success(); rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.AggregateNotDeclared); } + } diff --git a/tests/FlexQuery.NET.Tests/Parsers/FqlQueryParserTests.cs b/tests/FlexQuery.NET.Tests/Parsers/FqlQueryParserTests.cs index eaade00..120450b 100644 --- a/tests/FlexQuery.NET.Tests/Parsers/FqlQueryParserTests.cs +++ b/tests/FlexQuery.NET.Tests/Parsers/FqlQueryParserTests.cs @@ -1,9 +1,12 @@ using FlexQuery.NET.Exceptions; +using FlexQuery.NET.Execution; using FlexQuery.NET.Models; using FlexQuery.NET.Models.Aggregates; using FlexQuery.NET.Models.Projection; using FlexQuery.NET.Models.Filters; using FlexQuery.NET.Parsers.Fql; +using FlexQuery.NET.Validation; +using FlexQuery.NET.Validation.Rules; namespace FlexQuery.NET.Tests.Parsers; @@ -35,6 +38,13 @@ private static QueryOptions FqlParse(string? filter = null, string? sort = null, }); } + private static HavingConditionNode GetHavingCondition(HavingNode? having) + { + having.Should().NotBeNull(); + having.Should().BeOfType(); + return (HavingConditionNode)having!; + } + [Fact] public void Fql_SimpleCondition_ParsedCorrectly() { @@ -391,7 +401,6 @@ public void Fql_FlatConditions_StillWorkAfterScopedSupport() filter.Filters[0].ScopedFilter.Should().BeNull(); } - // ─── Sort Parser Tests ──────────────────────────────────────────── [Fact] public void FqlSort_SingleAsc_ParsedCorrectly() @@ -546,8 +555,6 @@ public void FqlSort_Aggregate_MissingField_Throws() act.Should().Throw(); } - // ─── Aggregate Parser Tests ─────────────────────────────────────── - [Fact] public void FqlAggregate_SumWithAlias_ParsedCorrectly() { @@ -736,7 +743,6 @@ public void FqlAggregate_ExtraContentAfterField_IsRejected() .Which.ParameterName.Should().Be("aggregate"); } - // ─── GroupBy Parser Tests ───────────────────────────────────────── [Fact] public void FqlGroupBy_SingleField_ParsedCorrectly() @@ -765,7 +771,6 @@ public void FqlGroupBy_NestedProperty_ParsedCorrectly() result.GroupBy[0].Should().Be("Customer.Region"); } - // ─── Having Parser Tests ────────────────────────────────────────── [Fact] public void FqlHaving_CountGreaterThan_ParsedCorrectly() @@ -773,10 +778,11 @@ public void FqlHaving_CountGreaterThan_ParsedCorrectly() var result = FqlParse(having: "COUNT(Orders) > 5"); result.Having.Should().NotBeNull(); - result.Having!.Function.Should().Be(AggregateFunction.Count); - result.Having.Field.Should().Be("Orders"); - result.Having.Operator.Should().Be("gt"); - result.Having.Value.Should().Be("5"); + var having = GetHavingCondition(result.Having); + having.Function.Should().Be(AggregateFunction.Count); + having.Field.Should().Be("Orders"); + having.Operator.Should().Be("gt"); + having.Value.Should().Be("5"); } [Fact] @@ -784,11 +790,11 @@ public void FqlHaving_SumGreaterThanValue_ParsedCorrectly() { var result = FqlParse(having: "SUM(Amount) > 1000"); - result.Having.Should().NotBeNull(); - result.Having!.Function.Should().Be(AggregateFunction.Sum); - result.Having.Field.Should().Be("Amount"); - result.Having.Operator.Should().Be("gt"); - result.Having.Value.Should().Be("1000"); + var having = GetHavingCondition(result.Having); + having.Function.Should().Be(AggregateFunction.Sum); + having.Field.Should().Be("Amount"); + having.Operator.Should().Be("gt"); + having.Value.Should().Be("1000"); } [Fact] @@ -805,9 +811,9 @@ public void FqlHaving_AllOperators_ParsedCorrectly() private void AssertHaving(string havingExpr, string expectedOp, string expectedValue) { var result = FqlParse(having: havingExpr); - result.Having.Should().NotBeNull(); - result.Having!.Operator.Should().Be(expectedOp); - result.Having.Value.Should().Be(expectedValue); + var having = GetHavingCondition(result.Having); + having.Operator.Should().Be(expectedOp); + having.Value.Should().Be(expectedValue); } [Fact] @@ -815,11 +821,11 @@ public void FqlHaving_FieldSpecific_ParsedCorrectly() { var result = FqlParse(having: "SUM(Orders.Total) > 500"); - result.Having.Should().NotBeNull(); - result.Having!.Function.Should().Be(AggregateFunction.Sum); - result.Having.Field.Should().Be("Orders.Total"); - result.Having.Operator.Should().Be("gt"); - result.Having.Value.Should().Be("500"); + var having = GetHavingCondition(result.Having); + having.Function.Should().Be(AggregateFunction.Sum); + having.Field.Should().Be("Orders.Total"); + having.Operator.Should().Be("gt"); + having.Value.Should().Be("500"); } [Fact] @@ -830,7 +836,6 @@ public void FqlHaving_EmptyString_ReturnsNull() result.Having.Should().BeNull(); } - // ─── Integration Tests ──────────────────────────────────────────── [Fact] public void FqlIntegration_AllParameters_ParsedCorrectly() @@ -895,10 +900,11 @@ public void FqlIntegration_AllParameters_ParsedCorrectly() // Having result.Having.Should().NotBeNull(); - result.Having!.Function.Should().Be(AggregateFunction.Sum); - result.Having.Field.Should().Be("Amount"); - result.Having.Operator.Should().Be("gt"); - result.Having.Value.Should().Be("1000"); + var having = GetHavingCondition(result.Having); + having.Function.Should().Be(AggregateFunction.Sum); + having.Field.Should().Be("Amount"); + having.Operator.Should().Be("gt"); + having.Value.Should().Be("1000"); // Distinct result.Distinct.Should().BeTrue(); @@ -907,5 +913,267 @@ public void FqlIntegration_AllParameters_ParsedCorrectly() result.Paging.Page.Should().Be(2); result.Paging.PageSize.Should().Be(25); } + + [Fact] + public void FqlQuery_ValidAggregateHaving_PassesValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "SUM(Total)", + Having = "SUM(Total) > 100" + }; + + var options = new FqlQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void FqlQuery_ValidAggregateHavingWithAnd_PassesValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "SUM(Total), COUNT(Id)", + Having = "SUM(Total) > 100 AND COUNT(Id) >= 5" + }; + + var options = new FqlQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void FqlQuery_ValidAggregateHavingWithOr_PassesValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "SUM(Total), AVG(Price)", + Having = "SUM(Total) > 100 OR AVG(Price) < 50" + }; + + var options = new FqlQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void FqlQuery_ValidNestedAggregateHaving_PassesValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "SUM(Total), COUNT(Id), AVG(Price)", + Having = "(SUM(Total) > 100 OR COUNT(Id) >= 5) AND AVG(Price) < 50" + }; + + var options = new FqlQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void FqlQuery_InvalidAggregateHaving_FailsValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "SUM(Total)", + Having = "AVG(Price) > 100" + }; + + var options = new FqlQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void FqlQuery_InvalidAggregateHavingWithAnd_FailsValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "SUM(Total)", + Having = "SUM(Total) > 100 AND COUNT(Id) >= 5" + }; + + var options = new FqlQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void FqlQuery_InvalidAggregateHavingWithOr_FailsValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "SUM(Total)", + Having = "SUM(Total) > 100 OR AVG(Price) < 50" + }; + + var options = new FqlQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void FqlQuery_InvalidNestedAggregateHaving_FailsValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "SUM(Total), COUNT(Id)", + Having = "(SUM(Total) > 100 OR AVG(Price) < 50) AND COUNT(Id) >= 5" + }; + + var options = new FqlQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void FqlQuery_HavingCountFieldNotDefinedInAggregate_FailsValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "COUNT(OrderItems.Id)", + Having = "COUNT(Id) > 10" + }; + + var options = new FqlQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void FqlQuery_HavingCountStarDefinedInAggregate_PassesValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "COUNT(Id)", + Having = "COUNT(Id) > 10" + }; + + var options = new FqlQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void FqlQuery_CountStarAggregate_ThrowsParseException() + { + var parameters = new FlexQueryParameters + { + Aggregate = "COUNT(*)" + }; + + var action = () => new FqlQueryParser().Parse(parameters); + + action.Should().Throw(); + } + + [Fact] + public void FqlQuery_CountStarAggregateAndHaving_ThrowsParseException() + { + var parameters = new FlexQueryParameters + { + Aggregate = "COUNT(*)", + Having = "COUNT(*)" + }; + + var action = () => new FqlQueryParser().Parse(parameters); + + action.Should().Throw(); + } + + [Fact] + public void FqlQuery_NestedHavingContainsMissingAggregate_FailsValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "SUM(Total), COUNT(Id)", + Having = "(SUM(Total) > 100 OR AVG(Price) < 50) AND COUNT(Id) >= 5" + }; + + var options = new FqlQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void FqlQuery_HavingAndContainsMissingAggregate_FailsValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "SUM(Total)", + Having = "SUM(Total) > 100 AND COUNT(Id) >= 5" + }; + + var options = new FqlQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeFalse(); + } + + + [Fact] + public void FqlQuery_NestedHavingWithDeclaredAggregates_PassesValidation() + { + var parameters = new FlexQueryParameters + { + Aggregate = "COUNT(Id), SUM(Total)", + Having = "(SUM(Total) > 500 AND COUNT(Id) >= 5)" + }; + + var options = new FqlQueryParser().Parse(parameters); + var rule = new HavingAggregateExistenceRule(); + var result = ValidationResult.Success(); + + rule.Validate(options, new QueryContext { TargetType = typeof(Order) }, result); + + result.IsValid.Should().BeTrue(); + } } diff --git a/tests/FlexQuery.NET.Tests/Security/FieldSecurityTests.cs b/tests/FlexQuery.NET.Tests/Security/FieldSecurityTests.cs index ee9d7e0..eb79812 100644 --- a/tests/FlexQuery.NET.Tests/Security/FieldSecurityTests.cs +++ b/tests/FlexQuery.NET.Tests/Security/FieldSecurityTests.cs @@ -329,7 +329,7 @@ public void Should_Fail_When_Aggregate_Field_Not_In_AggregatableFields() { var options = new QueryOptions { - Aggregates = new List + Aggregates = new List { new() { Function = AggregateFunction.Sum, Field = "SSN", Alias = "total_ssn" } } @@ -350,7 +350,7 @@ public void NonStrict_ShouldRemove_Unauthorized_Aggregate_Field() { var options = new QueryOptions { - Aggregates = new List + Aggregates = new List { new() { Function = AggregateFunction.Count, Field = "Id", Alias = "cnt" }, new() { Function = AggregateFunction.Sum, Field = "SSN", Alias = "total_ssn" } @@ -374,7 +374,7 @@ public void Should_Succeed_When_Aggregate_Field_Is_In_AggregatableFields() { var options = new QueryOptions { - Aggregates = new List + Aggregates = new List { new() { Function = AggregateFunction.Count, Field = "Id", Alias = "cnt" } } @@ -394,7 +394,7 @@ public void Should_Succeed_With_Wildcard_AggregatableFields() { var options = new QueryOptions { - Aggregates = new List + Aggregates = new List { new() { Function = AggregateFunction.Sum, Field = "Orders.Total", Alias = "total" } } @@ -419,11 +419,11 @@ public void Should_Fail_When_Having_Field_Not_In_AggregatableFields() var options = new QueryOptions { GroupBy = new List { "Name" }, - Aggregates = new List + Aggregates = new List { new() { Function = AggregateFunction.Count, Field = "Id", Alias = "cnt" } }, - Having = new HavingCondition + Having = new HavingConditionNode { Function = AggregateFunction.Count, Field = "SSN", @@ -448,7 +448,7 @@ public void NonStrict_ShouldRecordError_When_Having_Field_Unauthorized() { var options = new QueryOptions { - Having = new HavingCondition + Having = new HavingConditionNode { Function = AggregateFunction.Sum, Field = "SSN", @@ -474,11 +474,11 @@ public void Should_Succeed_When_Having_Field_Is_In_AggregatableFields() var options = new QueryOptions { GroupBy = new List { "Name" }, - Aggregates = new List + Aggregates = new List { new() { Function = AggregateFunction.Count, Field = "Id", Alias = "cnt" } }, - Having = new HavingCondition + Having = new HavingConditionNode { Function = AggregateFunction.Count, Field = "Id", @@ -659,11 +659,11 @@ public void Combined_GroupByAggregateHaving_AllAuthorized_ShouldSucceed() var options = new QueryOptions { GroupBy = new List { "Name" }, - Aggregates = new List + Aggregates = new List { new() { Function = AggregateFunction.Count, Field = "Id", Alias = "cnt" } }, - Having = new HavingCondition { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "0" } + Having = new HavingConditionNode { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "0" } }; var execOptions = new QueryExecutionOptions { @@ -682,12 +682,12 @@ public void NonStrict_Combined_ShouldRemoveUnauthorizedAndKeepAuthorized() var options = new QueryOptions { GroupBy = new List { "Name", "SSN" }, - Aggregates = new List + Aggregates = new List { new() { Function = AggregateFunction.Count, Field = "Id", Alias = "cnt" }, new() { Function = AggregateFunction.Sum, Field = "SSN", Alias = "total_ssn" } }, - Having = new HavingCondition { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "0" } + Having = new HavingConditionNode { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "0" } }; var execOptions = new QueryExecutionOptions { @@ -995,7 +995,7 @@ public void DefaultProjection_ShouldNotApply_WhenAggregatesProvided() { var options = new QueryOptions { - Aggregates = new List + Aggregates = new List { new() { Function = AggregateFunction.Count, Field = "Id", Alias = "cnt" } } diff --git a/tests/FlexQuery.NET.Tests/Validation/AggregateAliasValidationRuleTests.cs b/tests/FlexQuery.NET.Tests/Validation/AggregateAliasValidationRuleTests.cs index 643caf8..8fc621b 100644 --- a/tests/FlexQuery.NET.Tests/Validation/AggregateAliasValidationRuleTests.cs +++ b/tests/FlexQuery.NET.Tests/Validation/AggregateAliasValidationRuleTests.cs @@ -35,7 +35,7 @@ public void NoAlias_AutoGenerated_Passes() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }] }; var result = Validate(options); @@ -47,7 +47,7 @@ public void ExplicitAlias_ValidIdentifier_Passes() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSales" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSales" }] }; var result = Validate(options); @@ -59,7 +59,7 @@ public void ExplicitAlias_StartsWithDigit_FailsWithInvalidAlias() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "123Total" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "123Total" }] }; var result = Validate(options); @@ -72,7 +72,7 @@ public void ExplicitAlias_ContainsHyphen_FailsWithInvalidAlias() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "total-sales" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "total-sales" }] }; var result = Validate(options); @@ -85,7 +85,7 @@ public void ExplicitAlias_ContainsSpace_FailsWithInvalidAlias() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "total sales" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "total sales" }] }; var result = Validate(options); @@ -98,7 +98,7 @@ public void ExplicitAlias_ContainsDot_FailsWithInvalidAlias() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "total.sales" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "total.sales" }] }; var result = Validate(options); @@ -111,7 +111,7 @@ public void ExplicitAlias_ContainsSpecialChar_FailsWithInvalidAlias() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "total!" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "total!" }] }; var result = Validate(options); @@ -148,7 +148,7 @@ public void ReservedKeyword_Alias_FailsWithReservedAlias(string reservedAlias) { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = reservedAlias }] + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = reservedAlias }] }; var result = Validate(options); @@ -163,8 +163,8 @@ public void DuplicateAlias_Explicit_FailsWithDuplicateAlias() { Aggregates = [ - new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "Total" }, - new AggregateModel { Function = AggregateFunction.Avg, Field = "Price", Alias = "Total" } + new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "Total" }, + new Aggregate { Function = AggregateFunction.Avg, Field = "Price", Alias = "Total" } ] }; var result = Validate(options); @@ -180,8 +180,8 @@ public void DuplicateAlias_CaseInsensitive_FailsWithDuplicateAlias() { Aggregates = [ - new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "Total" }, - new AggregateModel { Function = AggregateFunction.Avg, Field = "Price", Alias = "total" } + new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "Total" }, + new Aggregate { Function = AggregateFunction.Avg, Field = "Price", Alias = "total" } ] }; var result = Validate(options); @@ -197,8 +197,8 @@ public void DuplicateAlias_AutoGeneratedVsExplicit_FailsWithDuplicateAlias() { Aggregates = [ - new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }, - new AggregateModel { Function = AggregateFunction.Count, Field = "Id", Alias = "TotalSum" } + new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }, + new Aggregate { Function = AggregateFunction.Count, Field = "Id", Alias = "TotalSum" } ] }; var result = Validate(options); @@ -212,7 +212,7 @@ public void InvalidAggregateTarget_SumWildcard_FailsWithInvalidAggregateTarget() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = null, Alias = "SumAll" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = null, Alias = "SumAll" }] }; var result = Validate(options); @@ -225,7 +225,7 @@ public void InvalidAggregateTarget_AvgWildcard_FailsWithInvalidAggregateTarget() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Avg, Field = null, Alias = "AvgAll" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Avg, Field = null, Alias = "AvgAll" }] }; var result = Validate(options); @@ -238,7 +238,7 @@ public void InvalidAggregateTarget_MinWildcard_FailsWithInvalidAggregateTarget() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Min, Field = null, Alias = "MinAll" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Min, Field = null, Alias = "MinAll" }] }; var result = Validate(options); @@ -251,7 +251,7 @@ public void InvalidAggregateTarget_MaxWildcard_FailsWithInvalidAggregateTarget() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Max, Field = null, Alias = "MaxAll" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Max, Field = null, Alias = "MaxAll" }] }; var result = Validate(options); @@ -264,7 +264,7 @@ public void ValidAggregateTarget_CountWildcard_Rejected() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "*", Alias = "TotalCount" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "*", Alias = "TotalCount" }] }; var result = Validate(options); @@ -277,7 +277,7 @@ public void ValidAggregateTarget_SumProperty_Passes() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }] + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }] }; var result = Validate(options); @@ -291,8 +291,8 @@ public void MultipleErrors_Accumulated() { Aggregates = [ - new AggregateModel { Function = AggregateFunction.Sum, Field = null, Alias = "SELECT" }, - new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "SELECT" } + new Aggregate { Function = AggregateFunction.Sum, Field = null, Alias = "SELECT" }, + new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "SELECT" } ] }; var result = Validate(options); @@ -311,8 +311,8 @@ public void DuplicateAggregateDefinition_SameFunctionAndField_Fails() { Aggregates = [ - new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }, - new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum2" } + new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }, + new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum2" } ] }; var result = Validate(options); @@ -328,8 +328,8 @@ public void DuplicateAggregateDefinition_DifferentAliases_Fails() { Aggregates = [ - new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "Sales" }, - new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "Revenue" } + new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "Sales" }, + new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "Revenue" } ] }; var result = Validate(options); @@ -345,8 +345,8 @@ public void DuplicateAggregateDefinition_DifferentFunction_Passes() { Aggregates = [ - new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }, - new AggregateModel { Function = AggregateFunction.Avg, Field = "Total", Alias = "AvgTotal" } + new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }, + new Aggregate { Function = AggregateFunction.Avg, Field = "Total", Alias = "AvgTotal" } ] }; var result = Validate(options); @@ -361,8 +361,8 @@ public void DuplicateAggregateDefinition_DifferentField_Passes() { Aggregates = [ - new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }, - new AggregateModel { Function = AggregateFunction.Sum, Field = "Price", Alias = "PriceSum" } + new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }, + new Aggregate { Function = AggregateFunction.Sum, Field = "Price", Alias = "PriceSum" } ] }; var result = Validate(options); @@ -377,8 +377,8 @@ public void DuplicateAggregateDefinition_CaseInsensitiveField_Fails() { Aggregates = [ - new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }, - new AggregateModel { Function = AggregateFunction.Sum, Field = "total", Alias = "TotalSum2" } + new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }, + new Aggregate { Function = AggregateFunction.Sum, Field = "total", Alias = "TotalSum2" } ] }; var result = Validate(options); diff --git a/tests/FlexQuery.NET.Tests/Validation/AggregateSortValidationRuleTests.cs b/tests/FlexQuery.NET.Tests/Validation/AggregateSortValidationRuleTests.cs index 518592a..be4381b 100644 --- a/tests/FlexQuery.NET.Tests/Validation/AggregateSortValidationRuleTests.cs +++ b/tests/FlexQuery.NET.Tests/Validation/AggregateSortValidationRuleTests.cs @@ -26,7 +26,7 @@ public void EmptySort_Passes() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }], + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }], Sort = [] }; var result = Validate(options); @@ -39,7 +39,7 @@ public void FieldSort_Passes() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }], + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }], Sort = [new SortNode { Field = "CustomerName", Descending = false }] }; var result = Validate(options); @@ -52,7 +52,7 @@ public void AggregateSort_MatchingFunctionAndField_Passes() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }], + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }], Sort = [new SortNode { Field = "Orders", Aggregate = AggregateFunction.Sum, AggregateField = "Total", Descending = true }] }; var result = Validate(options); @@ -65,7 +65,7 @@ public void AggregateSort_CaseInsensitiveField_Passes() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }], + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }], Sort = [new SortNode { Field = "Orders", Aggregate = AggregateFunction.Sum, AggregateField = "total", Descending = true }] }; var result = Validate(options); @@ -78,7 +78,7 @@ public void AggregateSort_AliasIgnored_Passes() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }], + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }], Sort = [new SortNode { Field = "Orders", Aggregate = AggregateFunction.Sum, AggregateField = "Total", Descending = true }] }; var result = Validate(options); @@ -91,7 +91,7 @@ public void AggregateSort_DifferentFunction_Fails() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }], + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }], Sort = [new SortNode { Field = "Orders", Aggregate = AggregateFunction.Avg, AggregateField = "Total", Descending = true }] }; var result = Validate(options); @@ -105,7 +105,7 @@ public void AggregateSort_DifferentField_Fails() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }], + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }], Sort = [new SortNode { Field = "Orders", Aggregate = AggregateFunction.Sum, AggregateField = "Subtotal", Descending = true }] }; var result = Validate(options); @@ -119,7 +119,7 @@ public void AggregateSort_NotDeclared_Fails() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }], + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }], Sort = [new SortNode { Field = "Orders", Aggregate = AggregateFunction.Sum, AggregateField = "Price", Descending = true }] }; var result = Validate(options); @@ -147,7 +147,7 @@ public void AggregateSort_CountCollection_Passes() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Orders", Alias = "TotalCount" }], + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Orders", Alias = "TotalCount" }], Sort = [new SortNode { Field = "Orders", Aggregate = AggregateFunction.Count, AggregateField = null, Descending = true }] }; var result = Validate(options, typeof(Customer)); @@ -162,8 +162,8 @@ public void AggregateSort_MultipleAggregates_MixedSorts_Passes() { Aggregates = [ - new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }, - new AggregateModel { Function = AggregateFunction.Avg, Field = "Price", Alias = "AvgPrice" } + new Aggregate { Function = AggregateFunction.Sum, Field = "Total", Alias = "TotalSum" }, + new Aggregate { Function = AggregateFunction.Avg, Field = "Price", Alias = "AvgPrice" } ], Sort = [ @@ -182,7 +182,7 @@ public void AggregateSort_Count_CollectionTarget_Valid() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Orders", Alias = "TotalCount" }], + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Orders", Alias = "TotalCount" }], Sort = [new SortNode { Field = "Orders", Aggregate = AggregateFunction.Count, AggregateField = null, Descending = true }] }; var result = Validate(options, typeof(Customer)); @@ -195,7 +195,7 @@ public void AggregateSort_Count_AlternateCollectionTarget_Valid() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Addresses", Alias = "AddressCount" }], + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Addresses", Alias = "AddressCount" }], Sort = [new SortNode { Field = "Addresses", Aggregate = AggregateFunction.Count, AggregateField = null, Descending = false }] }; var result = Validate(options, typeof(Customer)); @@ -208,7 +208,7 @@ public void AggregateSort_Count_ScalarTarget_Fails() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Orders.Total", Alias = "InvalidCount" }], + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Orders.Total", Alias = "InvalidCount" }], Sort = [new SortNode { Field = "Orders.Total", Aggregate = AggregateFunction.Count, AggregateField = null, Descending = true }] }; var result = Validate(options, typeof(Customer)); @@ -222,7 +222,7 @@ public void AggregateSort_Count_ScalarNavigationTarget_Fails() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Profile", Alias = "InvalidCount" }], + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Profile", Alias = "InvalidCount" }], Sort = [new SortNode { Field = "Profile", Aggregate = AggregateFunction.Count, AggregateField = null, Descending = true }] }; var result = Validate(options, typeof(Customer)); @@ -236,7 +236,7 @@ public void AggregateSort_Count_NoTargetType_SkipsValidation() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Orders", Alias = "TotalCount" }], + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Orders", Alias = "TotalCount" }], Sort = [new SortNode { Field = "Orders", Aggregate = AggregateFunction.Count, AggregateField = null, Descending = true }] }; var result = Validate(options, targetType: null); diff --git a/tests/FlexQuery.NET.Tests/Validation/FieldAccessValidationRuleTests.cs b/tests/FlexQuery.NET.Tests/Validation/FieldAccessValidationRuleTests.cs index 71cdc2e..e1711c9 100644 --- a/tests/FlexQuery.NET.Tests/Validation/FieldAccessValidationRuleTests.cs +++ b/tests/FlexQuery.NET.Tests/Validation/FieldAccessValidationRuleTests.cs @@ -259,7 +259,7 @@ public void BlockedFieldInAggregate_Strict_Throws() StrictFieldValidation = true, BlockedFields = ["Name"] }; - var options = new QueryOptions { Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Name", Alias = "cnt" }] }; + var options = new QueryOptions { Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Name", Alias = "cnt" }] }; var rule = new FieldAccessValidationRule(); var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); @@ -275,7 +275,7 @@ public void BlockedFieldInAggregate_NonStrict_Removes() StrictFieldValidation = false, BlockedFields = ["Name"] }; - var options = new QueryOptions { Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Name", Alias = "cnt" }] }; + var options = new QueryOptions { Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Name", Alias = "cnt" }] }; var rule = new FieldAccessValidationRule(); var result = ValidationResult.Success(); @@ -294,7 +294,7 @@ public void BlockedFieldInHaving_Strict_Throws() }; var options = new QueryOptions { - Having = new HavingCondition { Function = AggregateFunction.Count, Field = "Name", Operator = "gt", Value = "5" } + Having = new HavingConditionNode { Function = AggregateFunction.Count, Field = "Name", Operator = "gt", Value = "5" } }; var rule = new FieldAccessValidationRule(); @@ -380,7 +380,7 @@ public void OperationLevel_AggregatableFields_Strict_Throws() StrictFieldValidation = true, AggregatableFields = ["Id"] }; - var options = new QueryOptions { Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Name", Alias = "cnt" }] }; + var options = new QueryOptions { Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Name", Alias = "cnt" }] }; var rule = new FieldAccessValidationRule(); var act = () => rule.Validate(options, Context(execOptions: execOptions), ValidationResult.Success()); @@ -740,7 +740,7 @@ public void SortWithGroupBy_ResolvesAggregateAlias() var options = new QueryOptions { GroupBy = ["Age"], - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Age", Alias = "cnt" }], + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Age", Alias = "cnt" }], Sort = [new SortNode { Field = "cnt" }] }; var rule = new FieldAccessValidationRule(); diff --git a/tests/FlexQuery.NET.Tests/Validation/HavingAliasIntegrityRuleTests.cs b/tests/FlexQuery.NET.Tests/Validation/HavingAliasIntegrityRuleTests.cs index bdb3bbd..3b5a5a9 100644 --- a/tests/FlexQuery.NET.Tests/Validation/HavingAliasIntegrityRuleTests.cs +++ b/tests/FlexQuery.NET.Tests/Validation/HavingAliasIntegrityRuleTests.cs @@ -7,7 +7,7 @@ namespace FlexQuery.NET.Tests.Validation; -public class HavingAliasIntegrityRuleTests +public class HavingAggregateExistenceRuleTests { private static QueryContext Context(Type? targetType = null, QueryGovernanceOptions? execOptions = null) => @@ -17,7 +17,7 @@ private static QueryContext Context(Type? targetType = null, QueryGovernanceOpti public void NullHaving_Passes() { var options = new QueryOptions { Having = null }; - var rule = new HavingAliasIntegrityRule(); + var rule = new HavingAggregateExistenceRule(); var result = ValidationResult.Success(); rule.Validate(options, Context(), result); @@ -30,16 +30,16 @@ public void EmptyAggregates_Fails() { var options = new QueryOptions { - Having = new HavingCondition { Function = AggregateFunction.Sum, Field = "Age", Operator = "gt", Value = "100" }, + Having = new HavingConditionNode { Function = AggregateFunction.Sum, Field = "Age", Operator = "gt", Value = "100" }, Aggregates = [] }; - var rule = new HavingAliasIntegrityRule(); + var rule = new HavingAggregateExistenceRule(); var result = ValidationResult.Success(); rule.Validate(options, Context(), result); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.AggregateNotDeclared); + result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.HavingAliasMismatch); } [Fact] @@ -47,10 +47,10 @@ public void MatchingAggregate_Passes() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Age", Alias = "totalAge" }], - Having = new HavingCondition { Function = AggregateFunction.Sum, Field = "Age", Operator = "gt", Value = "100" } + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Age", Alias = "totalAge" }], + Having = new HavingConditionNode { Function = AggregateFunction.Sum, Field = "Age", Operator = "gt", Value = "100" } }; - var rule = new HavingAliasIntegrityRule(); + var rule = new HavingAggregateExistenceRule(); var result = ValidationResult.Success(); rule.Validate(options, Context(), result); @@ -63,16 +63,16 @@ public void MismatchedFunction_Fails() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Id", Alias = "idCount" }], - Having = new HavingCondition { Function = AggregateFunction.Sum, Field = "Id", Operator = "gt", Value = "100" } + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Id", Alias = "idCount" }], + Having = new HavingConditionNode { Function = AggregateFunction.Sum, Field = "Id", Operator = "gt", Value = "100" } }; - var rule = new HavingAliasIntegrityRule(); + var rule = new HavingAggregateExistenceRule(); var result = ValidationResult.Success(); rule.Validate(options, Context(), result); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.AggregateNotDeclared); + result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.HavingAliasMismatch); } [Fact] @@ -80,16 +80,16 @@ public void MismatchedField_Fails() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Age", Alias = "totalAge" }], - Having = new HavingCondition { Function = AggregateFunction.Sum, Field = "Name", Operator = "gt", Value = "100" } + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Age", Alias = "totalAge" }], + Having = new HavingConditionNode { Function = AggregateFunction.Sum, Field = "Name", Operator = "gt", Value = "100" } }; - var rule = new HavingAliasIntegrityRule(); + var rule = new HavingAggregateExistenceRule(); var result = ValidationResult.Success(); rule.Validate(options, Context(), result); result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.AggregateNotDeclared); + result.Errors.Should().ContainSingle(e => e.Code == ValidationErrorCodes.HavingAliasMismatch); } [Fact] @@ -97,10 +97,10 @@ public void CaseInsensitiveFieldMatch_Passes() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Age", Alias = "totalAge" }], - Having = new HavingCondition { Function = AggregateFunction.Sum, Field = "age", Operator = "gt", Value = "100" } + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Age", Alias = "totalAge" }], + Having = new HavingConditionNode { Function = AggregateFunction.Sum, Field = "age", Operator = "gt", Value = "100" } }; - var rule = new HavingAliasIntegrityRule(); + var rule = new HavingAggregateExistenceRule(); var result = ValidationResult.Success(); rule.Validate(options, Context(), result); diff --git a/tests/FlexQuery.NET.Tests/Validation/ValidationEdgeCaseTests.cs b/tests/FlexQuery.NET.Tests/Validation/ValidationEdgeCaseTests.cs index 6db2c76..f9ee94a 100644 --- a/tests/FlexQuery.NET.Tests/Validation/ValidationEdgeCaseTests.cs +++ b/tests/FlexQuery.NET.Tests/Validation/ValidationEdgeCaseTests.cs @@ -319,7 +319,7 @@ public void FieldExistence_SkipsGroupedAggregateAliasInSort() var options = new QueryOptions { GroupBy = ["City"], - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Id", Alias = "count" }], + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Id", Alias = "count" }], Sort = [new SortNode { Field = "count" }] }; var rule = new FieldExistenceRule(); @@ -382,7 +382,7 @@ public void HavingWithoutGroupBy_AllowsHavingWhenGroupByIsPresent() var options = new QueryOptions { GroupBy = ["City"], - Having = new HavingCondition { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "5" } + Having = new HavingConditionNode { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "5" } }; var rule = new HavingWithoutGroupByRule(); var result = ValidationResult.Success(); @@ -397,8 +397,8 @@ public void HavingWithoutGroupBy_AllowsHavingWhenAggregatesArePresent() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Id", Alias = "cnt" }], - Having = new HavingCondition { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "5" } + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Id", Alias = "cnt" }], + Having = new HavingConditionNode { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "5" } }; var rule = new HavingWithoutGroupByRule(); var result = ValidationResult.Success(); diff --git a/tests/FlexQuery.NET.Tests/Validation/ValidationTests.cs b/tests/FlexQuery.NET.Tests/Validation/ValidationTests.cs index 1650201..9e74a49 100644 --- a/tests/FlexQuery.NET.Tests/Validation/ValidationTests.cs +++ b/tests/FlexQuery.NET.Tests/Validation/ValidationTests.cs @@ -99,7 +99,7 @@ public void HavingWithoutGroupBy_RejectsHavingWithoutGroupByOrAggregates() { var options = new QueryOptions { - Having = new HavingCondition { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "5" } + Having = new HavingConditionNode { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "5" } }; Action act = () => options.ValidateOrThrow(); @@ -114,8 +114,8 @@ public void HavingWithoutGroupBy_AllowsHavingWithGroupBy() var options = new QueryOptions { GroupBy = ["Status"], - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Id", Alias = "idCount" }], - Having = new HavingCondition { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "5" } + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Id", Alias = "idCount" }], + Having = new HavingConditionNode { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "5" } }; Action act = () => options.ValidateOrThrow(); @@ -128,8 +128,8 @@ public void HavingWithoutGroupBy_AllowsHavingWithAggregates() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Count, Field = "Id", Alias = "idCount" }], - Having = new HavingCondition { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "5" } + Aggregates = [new Aggregate { Function = AggregateFunction.Count, Field = "Id", Alias = "idCount" }], + Having = new HavingConditionNode { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "5" } }; Action act = () => options.ValidateOrThrow(); @@ -142,14 +142,14 @@ public void HavingAliasIntegrity_RejectsAliasMismatch() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "totalSum" }], - Having = new HavingCondition { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "5" } + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Age", Alias = "totalAge" }], + Having = new HavingConditionNode { Function = AggregateFunction.Count, Field = "Id", Operator = "gt", Value = "5" } }; Action act = () => options.ValidateOrThrow(); act.Should().Throw() - .Which.Result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.AggregateNotDeclared); + .Which.Result.Errors.Should().Contain(e => e.Code == ValidationErrorCodes.HavingAliasMismatch); } [Fact] @@ -157,8 +157,8 @@ public void HavingAliasIntegrity_AllowsMatchingAlias() { var options = new QueryOptions { - Aggregates = [new AggregateModel { Function = AggregateFunction.Sum, Field = "Total", Alias = "totalSum" }], - Having = new HavingCondition { Function = AggregateFunction.Sum, Field = "Total", Operator = "gt", Value = "100" } + Aggregates = [new Aggregate { Function = AggregateFunction.Sum, Field = "Age", Alias = "totalAge" }], + Having = new HavingConditionNode { Function = AggregateFunction.Sum, Field = "Age", Operator = "gt", Value = "100" } }; Action act = () => options.ValidateOrThrow(); From 5d59aa45a449ac0b3fcf65868180482c02050b9d Mon Sep 17 00:00:00 2001 From: Peter John Casasola Date: Sat, 18 Jul 2026 14:26:53 +0800 Subject: [PATCH 10/10] docs: update OpenAPI documentation registry and schema docs --- .../Documentation/FlexQueryDocumentationRegistry.cs | 4 ++-- .../Documentation/SchemaDocumentation.cs | 6 +++--- .../Documentation/DocumentationContentTests.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/FlexQuery.NET.OpenApi/Documentation/FlexQueryDocumentationRegistry.cs b/src/FlexQuery.NET.OpenApi/Documentation/FlexQueryDocumentationRegistry.cs index b2c18c5..47eb687 100644 --- a/src/FlexQuery.NET.OpenApi/Documentation/FlexQueryDocumentationRegistry.cs +++ b/src/FlexQuery.NET.OpenApi/Documentation/FlexQueryDocumentationRegistry.cs @@ -20,8 +20,8 @@ internal static class FlexQueryDocumentationRegistry [typeof(FilterCondition)] = new(SchemaDocumentation.FilterCondition, null), [typeof(SortNode)] = new(SchemaDocumentation.SortNode, null), [typeof(PagingOptions)] = new(SchemaDocumentation.PagingOptions, null), - [typeof(Aggregate)] = new(SchemaDocumentation.AggregateModel, null), - [typeof(HavingNode)] = new(SchemaDocumentation.HavingExpression, null), + [typeof(Aggregate)] = new(SchemaDocumentation.Aggregate, null), + [typeof(HavingNode)] = new(SchemaDocumentation.HavingNode, null), [typeof(IncludeNode)] = new(SchemaDocumentation.IncludeNode, null), [typeof(ProjectionMode)] = new(SchemaDocumentation.ProjectionMode, null), [typeof(LogicOperator)] = new(SchemaDocumentation.LogicOperator, null), diff --git a/src/FlexQuery.NET.OpenApi/Documentation/SchemaDocumentation.cs b/src/FlexQuery.NET.OpenApi/Documentation/SchemaDocumentation.cs index e7bb2a9..6116351 100644 --- a/src/FlexQuery.NET.OpenApi/Documentation/SchemaDocumentation.cs +++ b/src/FlexQuery.NET.OpenApi/Documentation/SchemaDocumentation.cs @@ -26,13 +26,13 @@ internal static class SchemaDocumentation internal static readonly string PagingOptions = "Pagination parameters: page number, page size, and disable option."; - internal static readonly string AggregateModel = + internal static readonly string Aggregate = "An aggregate projection expression with function, field, and alias."; - internal static readonly string HavingCondition = + internal static readonly string HavingConditionNode = "A HAVING condition against an aggregate projection."; - internal static readonly string HavingExpression = + internal static readonly string HavingNode = "A HAVING expression tree supporting logical combinations of aggregate conditions."; internal static readonly string IncludeNode = diff --git a/tests/FlexQuery.NET.OpenApi.Tests/Documentation/DocumentationContentTests.cs b/tests/FlexQuery.NET.OpenApi.Tests/Documentation/DocumentationContentTests.cs index 2213d5b..98cd867 100644 --- a/tests/FlexQuery.NET.OpenApi.Tests/Documentation/DocumentationContentTests.cs +++ b/tests/FlexQuery.NET.OpenApi.Tests/Documentation/DocumentationContentTests.cs @@ -27,7 +27,7 @@ public void SchemaDocumentation_ValuesAreNonEmpty() SchemaDocumentation.SortNode.Should().NotBeNullOrWhiteSpace(); SchemaDocumentation.PagingOptions.Should().NotBeNullOrWhiteSpace(); SchemaDocumentation.AggregateModel.Should().NotBeNullOrWhiteSpace(); - SchemaDocumentation.HavingConditionExpression.Should().NotBeNullOrWhiteSpace(); + SchemaDocumentation.HavingConditionNode.Should().NotBeNullOrWhiteSpace(); SchemaDocumentation.IncludeNode.Should().NotBeNullOrWhiteSpace(); SchemaDocumentation.ProjectionMode.Should().NotBeNullOrWhiteSpace(); SchemaDocumentation.LogicOperator.Should().NotBeNullOrWhiteSpace();