From a7e3907ad37df519d78118f3846edffafbeab041 Mon Sep 17 00:00:00 2001 From: Lucas Nakano Perez Date: Sun, 15 Feb 2026 19:44:11 -0300 Subject: [PATCH 1/2] feat: add list operations with array length accessor and any/all/none quantifiers --- .github/workflows/test.yml | 2 +- CLAUDE.md | 34 +- benchmark_optimized_test.go | 167 +++++ constants.go | 6 + errors.go | 11 + evaluator.go | 167 ++++- example_test.go | 161 +++++ parser.go | 53 +- race_condition_test.go | 14 +- test/additional_edge_cases.go | 11 +- test/edge_case_fixtures.go | 1 + test/list_fixtures.go | 1287 +++++++++++++++++++++++++++++++++ test/rule_engine_test.go | 8 + token.go | 11 + validator.go | 16 +- 15 files changed, 1917 insertions(+), 32 deletions(-) create mode 100644 test/list_fixtures.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0bc93dd..e0045f0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,7 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v7 with: - version: v2.2.1 + version: v2.9.0 test: name: Unit Tests runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 224ca5f..c54a724 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,18 +35,25 @@ The entire specification is defined through comprehensive test cases in `test/fi - **Logical**: `not`, `and`, `or` with proper nesting - **Attribute Comparisons**: Both flat and nested property comparisons - **Nested Attributes**: Deep object navigation with dot notation +- **Array Length**: `.length` accessor for array size checks +- **List Quantifiers**: `any`, `all`, `none` operators for element-level conditions ### Rule Syntax Examples ``` -x eq 10 // equality -score gt 100 and level lt 5 // logical operations -city co "York" // string contains -color in ["red","green","blue"] // membership -user.profile.age ge 18 // nested attributes -not (status eq "inactive") // negation -created_at dl 30 // within last 30 days -updated_at dg 365 // older than 365 days +x eq 10 // equality +score gt 100 and level lt 5 // logical operations +city co "York" // string contains +color in ["red","green","blue"] // membership +user.profile.age ge 18 // nested attributes +not (status eq "inactive") // negation +created_at dl 30 // within last 30 days +updated_at dg 365 // older than 365 days +items.length gt 3 // array length +selections any (status eq "cancelled") // any element matches +selections all (is_valid eq true) // all elements match +selections none (is_fraud eq true) // no element matches +items any (price gt 100 and in_stock eq true) // compound sub-expression ``` ## Development Commands @@ -111,11 +118,20 @@ All functionality is validated through the comprehensive test suite in `test/fix - **Membership Operations**: Use strict type checking (no cross-type matching) - **Large Integer Support**: Preserve precision for integers > 2^53 using dual storage +### List Operations +- **Array Length**: Access `.length` on array properties (e.g., `items.length gt 3`) +- **Quantifier `any`**: True if any element matches the sub-expression (short-circuits on first match) +- **Quantifier `all`**: True if all elements match (vacuous truth for empty arrays, short-circuits on first non-match) +- **Quantifier `none`**: True if no element matches (true for empty arrays, short-circuits on first match) +- **Sub-expressions**: Quantifiers support full expressions including `and`, `or`, `not`, all comparison operators +- **Nested access**: Works with nested properties (e.g., `data.items any (status eq "active")`) +- **Reserved words**: `any`, `all`, `none` are reserved keywords (cannot be used as field names) + ### Zero-Allocation Implementation - **EvalResult Structure**: Pre-allocated typed result structure to avoid interface boxing - **Memory Reuse**: Single evaluator instance with reusable result buffer - **AST Caching**: Pre-compiled rules stored in lock-free concurrent map -- **Allocation Verification**: All benchmarks must show 0 allocs/op +- **Allocation Verification**: All benchmarks must show 0 allocs/op (array operations may have 1 alloc from Go runtime `[]any` handling) ### Performance Benchmarking - **Baseline Comparison**: Must outperform nikunjy/rules by 100x minimum diff --git a/benchmark_optimized_test.go b/benchmark_optimized_test.go index 3614b26..dc80741 100644 --- a/benchmark_optimized_test.go +++ b/benchmark_optimized_test.go @@ -127,6 +127,173 @@ func BenchmarkOptimizedStandalone(b *testing.B) { } } +// Benchmark array length evaluation for zero allocations. +func BenchmarkOptimizedEngineArrayLength(b *testing.B) { + engine := NewEngine() + ctx := D{ + "selections": []any{ + D{"id": "1"}, + D{"id": "2"}, + D{"id": "3"}, + }, + } + rule := "selections.length gt 2" + + // Pre-compile rule + engine.AddQuery(rule) + + b.ResetTimer() + + for range b.N { + result, err := engine.Evaluate(rule, ctx) + if err != nil || !result { + b.Fatalf("Expected true result, got %v, %v", result, err) + } + } +} + +// Benchmark any quantifier evaluation for zero allocations. +func BenchmarkOptimizedEngineQuantifierAny(b *testing.B) { + engine := NewEngine() + ctx := D{ + "selections": []any{ + D{"event_status": "EVENT_STATUS_IN_PROGRESS", "is_live": true}, + D{"event_status": "EVENT_STATUS_CANCELLED", "is_live": false}, + D{"event_status": "EVENT_STATUS_FINISHED", "is_live": false}, + }, + } + rule := `selections any (event_status eq "EVENT_STATUS_CANCELLED")` + + // Pre-compile rule + engine.AddQuery(rule) + + b.ResetTimer() + + for range b.N { + result, err := engine.Evaluate(rule, ctx) + if err != nil || !result { + b.Fatalf("Expected true result, got %v, %v", result, err) + } + } +} + +// Benchmark all quantifier evaluation for zero allocations. +func BenchmarkOptimizedEngineQuantifierAll(b *testing.B) { + engine := NewEngine() + ctx := D{ + "items": []any{ + D{"is_valid": true}, + D{"is_valid": true}, + D{"is_valid": true}, + }, + } + rule := "items all (is_valid eq true)" + + // Pre-compile rule + engine.AddQuery(rule) + + b.ResetTimer() + + for range b.N { + result, err := engine.Evaluate(rule, ctx) + if err != nil || !result { + b.Fatalf("Expected true result, got %v, %v", result, err) + } + } +} + +// Benchmark none quantifier evaluation for zero allocations. +func BenchmarkOptimizedEngineQuantifierNone(b *testing.B) { + engine := NewEngine() + ctx := D{ + "items": []any{ + D{"status": "active"}, + D{"status": "pending"}, + D{"status": "completed"}, + }, + } + rule := `items none (status eq "error")` + + // Pre-compile rule + engine.AddQuery(rule) + + b.ResetTimer() + + for range b.N { + result, err := engine.Evaluate(rule, ctx) + if err != nil || !result { + b.Fatalf("Expected true result, got %v, %v", result, err) + } + } +} + +// Benchmark complex real-world betting rule with length + quantifiers. +func BenchmarkOptimizedEngineComplexBetting(b *testing.B) { + engine := NewEngine() + ctx := D{ + "bet_type": "BET_TYPE_MULTIPLE", + "customer_data": D{ + "is_vip": true, + }, + "selections": []any{ + D{ + "event_status": "EVENT_STATUS_IN_PROGRESS", + "is_live": true, + "odd": 2.5, + "provider": "PROVIDER_SPORTRADAR", + }, + D{"event_status": "EVENT_STATUS_NOT_STARTED", "is_live": false, "odd": 1.8, "provider": "PROVIDER_RAMP"}, + D{ + "event_status": "EVENT_STATUS_IN_PROGRESS", + "is_live": true, + "odd": 3.1, + "provider": "PROVIDER_SPORTRADAR", + }, + }, + } + rule := `bet_type eq "BET_TYPE_MULTIPLE" and selections.length ge 2 and selections none (event_status eq "EVENT_STATUS_CANCELLED") and selections any (is_live eq true and odd gt 2.0)` + + // Pre-compile rule + engine.AddQuery(rule) + + b.ResetTimer() + + for range b.N { + result, err := engine.Evaluate(rule, ctx) + if err != nil || !result { + b.Fatalf("Expected true result, got %v, %v", result, err) + } + } +} + +// Benchmark quantifier with short-circuit (first element matches). +func BenchmarkOptimizedEngineQuantifierAnyShortCircuit(b *testing.B) { + engine := NewEngine() + + // Create array with 100 elements, first one matches + items := make([]any, 100) + + items[0] = D{"status": "target"} + for i := 1; i < 100; i++ { + items[i] = D{"status": "other"} + } + + ctx := D{"items": items} + rule := `items any (status eq "target")` + + // Pre-compile rule + engine.AddQuery(rule) + + b.ResetTimer() + + for range b.N { + result, err := engine.Evaluate(rule, ctx) + if err != nil || !result { + b.Fatalf("Expected true result, got %v, %v", result, err) + } + } +} + // Direct evaluator benchmarks. func BenchmarkZeroAllocEvaluatorDirect(b *testing.B) { evaluator := NewEvaluator() diff --git a/constants.go b/constants.go index 4062325..c6a5427 100644 --- a/constants.go +++ b/constants.go @@ -27,3 +27,9 @@ const ( // trueString represents the string "true". trueString = "true" ) + +// Property accessor constants. +const ( + // lengthProperty is the special property name for array length access. + lengthProperty = "length" +) diff --git a/errors.go b/errors.go index 5427c91..779e9c5 100644 --- a/errors.go +++ b/errors.go @@ -46,4 +46,15 @@ var ( ErrEmptyParentheses = &EngineError{"EMPTY_PARENTHESES", "Empty parentheses are not allowed"} ErrUnbalancedParens = &EngineError{"UNBALANCED_PARENTHESES", "Unbalanced parentheses"} ErrTrailingTokens = &EngineError{"TRAILING_TOKENS", "Unexpected tokens after complete expression"} + + // ErrQuantifierRequiresParens indicates a quantifier operator used without parenthesized sub-expression. + ErrQuantifierRequiresParens = &EngineError{ + "QUANTIFIER_REQUIRES_PARENS", + "Quantifier operators (any/all/none) require a parenthesized sub-expression", + } + // ErrInvalidQuantifierTarget indicates a quantifier used on a non-identifier/property operand. + ErrInvalidQuantifierTarget = &EngineError{ + "INVALID_QUANTIFIER_TARGET", + "Quantifier operators (any/all/none) can only be used with identifiers or properties", + } ) diff --git a/evaluator.go b/evaluator.go index e7ac982..e040af9 100644 --- a/evaluator.go +++ b/evaluator.go @@ -111,12 +111,13 @@ func (e *Evaluator) evaluateIdentifier(node *ASTNode, context D, result *EvalRes func (e *Evaluator) evaluateProperty(node *ASTNode, context D, result *EvalResult) error { current := context + lastIdx := len(node.Children) - 1 - for _, child := range node.Children { + for i, child := range node.Children { key := child.Value.StrValue // Navigate to the next level - currentMap, ok := current[key] + currentValue, ok := current[key] if !ok { // For missing nested attributes, return invalid result result.IsValid = false @@ -126,17 +127,30 @@ func (e *Evaluator) evaluateProperty(node *ASTNode, context D, result *EvalResul } // If this is the last segment, return the value - if child == node.Children[len(node.Children)-1] { + if i == lastIdx { result.IsValid = true - e.setResultFromAny(result, currentMap) + e.setResultFromAny(result, currentValue) return nil } // Otherwise, continue navigation - if nextMap, isMap := currentMap.(map[string]any); isMap { - current = nextMap - } else { + switch v := currentValue.(type) { + case map[string]any: + current = v + case []any: + // Array found - check if next segment is "length" and it's the last segment + if i+1 == lastIdx && node.Children[i+1].Value.StrValue == lengthProperty { + e.setArrayLengthResult(result, len(v)) + return nil + } + + // Invalid - trying to access non-length property on array + result.IsValid = false + result.Type = ValueString + + return nil + default: // For invalid nested access, return invalid result result.IsValid = false result.Type = ValueString // Default type for missing @@ -152,6 +166,15 @@ func (e *Evaluator) evaluateProperty(node *ASTNode, context D, result *EvalResul return nil } +// setArrayLengthResult sets the result to the length of an array. +func (e *Evaluator) setArrayLengthResult(result *EvalResult, length int) { + result.Type = ValueNumber + result.Num = float64(length) + result.IntValue = int64(length) + result.IsInt = true + result.IsValid = true +} + func (e *Evaluator) evaluateUnaryOp(node *ASTNode, context D, result *EvalResult) error { switch node.Operator { case NOT: @@ -192,6 +215,9 @@ func (e *Evaluator) evaluateUnaryOp(node *ASTNode, context D, result *EvalResult AQ, AND, OR, + ANY, + ALL, + NONE, EQUALS, NOT_EQUALS: return ErrInvalidOperator // These are not unary operators @@ -206,6 +232,8 @@ func (e *Evaluator) evaluateBinaryOp(node *ASTNode, context D, result *EvalResul return e.evaluateLogicalAnd(node, context, result) case OR: return e.evaluateLogicalOr(node, context, result) + case ANY, ALL, NONE: + return e.evaluateQuantifier(node, context, result) case EQ, NE, LT, GT, LE, GE, CO, SW, EW, IN, NOT_IN, EQUALS, NOT_EQUALS, DQ, DN, BE, BQ, AF, AQ, DL, DG: return e.evaluateComparisonOperator(node, context, result) case EOF, @@ -367,6 +395,126 @@ func (e *Evaluator) evaluateLogicalOr(node *ASTNode, context D, result *EvalResu return nil } +// evaluateQuantifier handles the ANY, ALL, and NONE list quantifier operators. +// It iterates over array elements, evaluating the sub-expression against each element's context. +// Uses short-circuit evaluation: ANY stops on first true, ALL stops on first false. +func (e *Evaluator) evaluateQuantifier(node *ASTNode, context D, result *EvalResult) error { + // Resolve the left operand (must be an array from context) + var leftResult EvalResult + + err := e.evaluateNode(node.Left, context, &leftResult) + if err != nil { + return err + } + + result.Type = ValueBoolean + result.IsValid = true + + // Must be a valid array + if !leftResult.IsValid || leftResult.Type != ValueArray { + result.Bool = false + return nil + } + + arr, ok := leftResult.OriginalValue.([]any) + if !ok { + result.Bool = false + return nil + } + + switch node.Operator { //nolint:exhaustive // only quantifier operators are valid here + case ANY: + result.Bool = e.evaluateQuantifierAny(node.Right, arr) + case ALL: + result.Bool = e.evaluateQuantifierAll(node.Right, arr) + case NONE: + result.Bool = e.evaluateQuantifierNone(node.Right, arr) + default: + result.IsValid = false + return ErrInvalidOperator + } + + return nil +} + +// evaluateQuantifierAny returns true if any element in the array satisfies the sub-expression. +// Short-circuits on first match. +func (e *Evaluator) evaluateQuantifierAny(subExpr *ASTNode, arr []any) bool { + var subResult EvalResult + + for _, elem := range arr { + elemMap, isMap := elem.(map[string]any) + if !isMap { + continue + } + + subResult.IsValid = false + subResult.OriginalValue = nil + + if err := e.evaluateNode(subExpr, elemMap, &subResult); err != nil { + continue + } + + if e.toBool(&subResult) { + return true + } + } + + return false +} + +// evaluateQuantifierAll returns true if all elements in the array satisfy the sub-expression. +// Returns true for empty arrays (vacuous truth). Short-circuits on first non-match. +func (e *Evaluator) evaluateQuantifierAll(subExpr *ASTNode, arr []any) bool { + var subResult EvalResult + + for _, elem := range arr { + elemMap, isMap := elem.(map[string]any) + if !isMap { + return false + } + + subResult.IsValid = false + subResult.OriginalValue = nil + + if err := e.evaluateNode(subExpr, elemMap, &subResult); err != nil { + return false + } + + if !e.toBool(&subResult) { + return false + } + } + + return true +} + +// evaluateQuantifierNone returns true if no element in the array satisfies the sub-expression. +// Returns true for empty arrays. Short-circuits on first match. +func (e *Evaluator) evaluateQuantifierNone(subExpr *ASTNode, arr []any) bool { + var subResult EvalResult + + for _, elem := range arr { + elemMap, isMap := elem.(map[string]any) + if !isMap { + continue + } + + subResult.IsValid = false + subResult.OriginalValue = nil + + if err := e.evaluateNode(subExpr, elemMap, &subResult); err != nil { + continue + } + + if e.toBool(&subResult) { + return false + } + } + + return true +} + // evaluateComparisonOperator handles all comparison operators. func (e *Evaluator) evaluateComparisonOperator(node *ASTNode, context D, result *EvalResult) error { var leftResult, rightResult EvalResult @@ -476,7 +624,10 @@ func (e *Evaluator) performComparison( PR, AND, OR, - NOT: + NOT, + ANY, + ALL, + NONE: result.IsValid = false return ErrInvalidOperator default: diff --git a/example_test.go b/example_test.go index 4e66333..24aa6ae 100644 --- a/example_test.go +++ b/example_test.go @@ -362,6 +362,167 @@ func Example_daysOperatorsUseCase() { // ✅ Established user: true } +// Example_arrayLength demonstrates array length operations. +func Example_arrayLength() { + engine := rule.NewEngine() + + context := rule.D{ + "bet_type": "BET_TYPE_MULTIPLE", + "selections": []any{ + rule.D{"sport": "Football", "odd": 1.5}, + rule.D{"sport": "Basketball", "odd": 2.3}, + rule.D{"sport": "Tennis", "odd": 3.1}, + }, + } + + rules := []string{ + `selections.length eq 3`, + `selections.length gt 1`, + `selections.length le 5`, + `bet_type eq "BET_TYPE_MULTIPLE" and selections.length ge 2`, + } + + for _, r := range rules { + result, err := engine.Evaluate(r, context) + if err != nil { + fmt.Printf("%s -> error: %v\n", r, err) + continue + } + + fmt.Printf("%s -> %t\n", r, result) + } + // Output: + // selections.length eq 3 -> true + // selections.length gt 1 -> true + // selections.length le 5 -> true + // bet_type eq "BET_TYPE_MULTIPLE" and selections.length ge 2 -> true +} + +// Example_quantifierAny demonstrates the "any" list quantifier. +func Example_quantifierAny() { + engine := rule.NewEngine() + + context := rule.D{ + "selections": []any{ + rule.D{"event_status": "EVENT_STATUS_IN_PROGRESS", "is_live": true, "odd": 1.5}, + rule.D{"event_status": "EVENT_STATUS_CANCELLED", "is_live": false, "odd": 2.3}, + rule.D{"event_status": "EVENT_STATUS_FINISHED", "is_live": false, "odd": 3.1}, + }, + } + + rules := []string{ + `selections any (event_status eq "EVENT_STATUS_CANCELLED")`, + `selections any (is_live eq true)`, + `selections any (odd gt 3.0)`, + `selections any (is_live eq true and odd gt 2.0)`, + } + + for _, r := range rules { + result, err := engine.Evaluate(r, context) + if err != nil { + fmt.Printf("%s -> error: %v\n", r, err) + continue + } + + fmt.Printf("%s -> %t\n", r, result) + } + // Output: + // selections any (event_status eq "EVENT_STATUS_CANCELLED") -> true + // selections any (is_live eq true) -> true + // selections any (odd gt 3.0) -> true + // selections any (is_live eq true and odd gt 2.0) -> false +} + +// Example_quantifierAllNone demonstrates "all" and "none" quantifiers. +func Example_quantifierAllNone() { + engine := rule.NewEngine() + + context := rule.D{ + "selections": []any{ + rule.D{"sport_name": "Football", "status": "SELECTION_STATUS_WIN"}, + rule.D{"sport_name": "Football", "status": "SELECTION_STATUS_WIN"}, + rule.D{"sport_name": "Football", "status": "SELECTION_STATUS_IN_PROGRESS"}, + }, + } + + rules := []string{ + `selections all (sport_name eq "Football")`, + `selections all (status eq "SELECTION_STATUS_WIN")`, + `selections none (status eq "SELECTION_STATUS_LOSS")`, + `selections none (status eq "SELECTION_STATUS_IN_PROGRESS")`, + } + + for _, r := range rules { + result, err := engine.Evaluate(r, context) + if err != nil { + fmt.Printf("%s -> error: %v\n", r, err) + continue + } + + fmt.Printf("%s -> %t\n", r, result) + } + // Output: + // selections all (sport_name eq "Football") -> true + // selections all (status eq "SELECTION_STATUS_WIN") -> false + // selections none (status eq "SELECTION_STATUS_LOSS") -> true + // selections none (status eq "SELECTION_STATUS_IN_PROGRESS") -> false +} + +// Example_bettingRules demonstrates real-world betting validation rules. +func Example_bettingRules() { + engine := rule.NewEngine() + + bet := rule.D{ + "bet_type": "BET_TYPE_MULTIPLE", + "is_freebet": false, + "customer_data": rule.D{ + "is_vip": true, + "nationality": "BR", + }, + "selections": []any{ + rule.D{ + "event_status": "EVENT_STATUS_IN_PROGRESS", + "is_live": true, + "odd": 2.5, + "sport_name": "Football", + "provider": "PROVIDER_SPORTRADAR", + }, + rule.D{ + "event_status": "EVENT_STATUS_NOT_STARTED", + "is_live": false, + "odd": 1.8, + "sport_name": "Football", + "provider": "PROVIDER_RAMP", + }, + }, + } + + validations := []struct { + name string + rule string + }{ + {"Min selections", `bet_type eq "BET_TYPE_MULTIPLE" and selections.length ge 2`}, + {"No cancelled events", `selections none (event_status eq "EVENT_STATUS_CANCELLED")`}, + {"VIP with live bet", `customer_data.is_vip eq true and selections any (is_live eq true)`}, + {"All Football", `selections all (sport_name eq "Football")`}, + } + + for _, v := range validations { + result, err := engine.Evaluate(v.rule, bet) + if err != nil { + fmt.Printf("%s: error: %v\n", v.name, err) + continue + } + + fmt.Printf("%s: %t\n", v.name, result) + } + // Output: + // Min selections: true + // No cancelled events: true + // VIP with live bet: true + // All Football: true +} + // Example_compatibility shows compatibility with nikunjy/rules. func Example_compatibility() { // Context that works with both libraries diff --git a/parser.go b/parser.go index 181a4e3..d55491a 100644 --- a/parser.go +++ b/parser.go @@ -163,6 +163,11 @@ func (p *Parser) parseComparisonExpression() (*ASTNode, error) { return NewBinaryOpNode(op, left, right), nil } + // Check for quantifier operators (any/all/none) + if p.isQuantifierOperator(p.curToken.Type) { + return p.parseQuantifierExpression(left) + } + // Check for missing operator - if we have another value without an operator, that's an error if p.isValue(p.curToken.Type) { return nil, ErrMissingOperator @@ -249,6 +254,9 @@ func (p *Parser) parsePrimaryExpression() (*ASTNode, error) { AND, OR, NOT, + ANY, + ALL, + NONE, EQUALS, NOT_EQUALS: return nil, fmt.Errorf("unexpected token %s at position %d", p.curToken.Type, p.current) @@ -335,6 +343,9 @@ func (p *Parser) parseArray() (*ASTNode, error) { AND, OR, NOT, + ANY, + ALL, + NONE, EQUALS, NOT_EQUALS: return nil, fmt.Errorf("unexpected token in array: %s", p.curToken.Type) @@ -379,6 +390,41 @@ func (p *Parser) parseIdentifierOrProperty() (*ASTNode, error) { return NewPropertyNode(path), nil } +func (p *Parser) parseQuantifierExpression(left *ASTNode) (*ASTNode, error) { + op := p.curToken.Type + p.advance() + + if p.curToken.Type != PAREN_OPEN { + return nil, ErrQuantifierRequiresParens + } + + p.advance() + + if p.curToken.Type == PAREN_CLOSE { + return nil, ErrEmptyParentheses + } + + subExpr, err := p.parseExpression() + if err != nil { + return nil, err + } + + if expectErr := p.expect(PAREN_CLOSE); expectErr != nil { + return nil, expectErr + } + + return NewBinaryOpNode(op, left, subExpr), nil +} + +func (p *Parser) isQuantifierOperator(tokenType TokenType) bool { + switch tokenType { //nolint:exhaustive // only checking quantifier tokens + case ANY, ALL, NONE: + return true + default: + return false + } +} + func (p *Parser) isComparisonOperator(tokenType TokenType) bool { switch tokenType { case EQ, NE, LT, GT, LE, GE, CO, SW, EW, IN, NOT_IN, PR, DQ, DN, BE, BQ, AF, AQ, DL, DG, EQUALS, NOT_EQUALS: @@ -396,7 +442,10 @@ func (p *Parser) isComparisonOperator(tokenType TokenType) bool { COMMA, AND, OR, - NOT: + NOT, + ANY, + ALL, + NONE: return false default: return false @@ -409,7 +458,7 @@ func (p *Parser) isValue(tokenType TokenType) bool { return true case EOF, ARRAY_END, PAREN_OPEN, PAREN_CLOSE, DOT, COMMA, EQ, NE, LT, GT, LE, GE, CO, SW, EW, IN, NOT_IN, PR, - DQ, DN, BE, BQ, AF, AQ, DL, DG, AND, OR, NOT, EQUALS, NOT_EQUALS: + DQ, DN, BE, BQ, AF, AQ, DL, DG, AND, OR, NOT, ANY, ALL, NONE, EQUALS, NOT_EQUALS: return false default: return false diff --git a/race_condition_test.go b/race_condition_test.go index 07b5546..d4116e6 100644 --- a/race_condition_test.go +++ b/race_condition_test.go @@ -182,7 +182,10 @@ func TestEngine_RaceCondition_DifferentQueries(t *testing.T) { if errorCount > 0 { t.Errorf("❌ RACE CONDITION DETECTED: %d evaluations returned incorrect results", errorCount) } else { - t.Logf("✅ No race conditions detected in %d concurrent mixed query evaluations", numGoroutines*iterationsPerGoroutine) + t.Logf( + "✅ No race conditions detected in %d concurrent mixed query evaluations", + numGoroutines*iterationsPerGoroutine, + ) } require.Equal(t, 0, errorCount, "All evaluations should return correct results") @@ -252,19 +255,14 @@ func TestEngine_RaceCondition_SameQueryDifferentContexts(t *testing.T) { errorCount++ } - require.Positive( - t, - errorCount, - "❌ RACE CONDITION DETECTED: %d evaluations returned incorrect results", - errorCount, + require.Equal(t, 0, errorCount, + "❌ RACE CONDITION DETECTED: %d evaluations returned incorrect results", errorCount, ) t.Logf( "✅ No race conditions detected in %d concurrent evaluations with different contexts", numGoroutines*iterationsPerGoroutine, ) - - require.Equal(t, 0, errorCount, "All evaluations should return correct results") } // Helper functions diff --git a/test/additional_edge_cases.go b/test/additional_edge_cases.go index 213b12c..a7689d8 100644 --- a/test/additional_edge_cases.go +++ b/test/additional_edge_cases.go @@ -40,9 +40,14 @@ var AdditionalEdgeCaseTests = []Case{ }, true}, // Performance edge cases - {"very_long_string", `text co "needle"`, rule.D{ - "text": "This is a very long string that contains the word needle somewhere in the middle of all this text that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on", - }, true}, + { + "very_long_string", + `text co "needle"`, + rule.D{ + "text": "This is a very long string that contains the word needle somewhere in the middle of all this text that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on", + }, + true, + }, // Empty string edge cases {"empty_string_contains_empty", `x co ""`, rule.D{"x": "hello"}, true}, diff --git a/test/edge_case_fixtures.go b/test/edge_case_fixtures.go index 5bcc810..3dfeda0 100644 --- a/test/edge_case_fixtures.go +++ b/test/edge_case_fixtures.go @@ -224,6 +224,7 @@ var ExtremeValueTests = []Case{ for i := range 1000 { arr[i] = i } + return arr }(), }, true}, diff --git a/test/list_fixtures.go b/test/list_fixtures.go new file mode 100644 index 0000000..117970c --- /dev/null +++ b/test/list_fixtures.go @@ -0,0 +1,1287 @@ +package test + +import "github.com/NSXBet/rule" + +/* ---------- Array length ---------- */ + +//nolint:gochecknoglobals // Test data +var ArrayLengthTests = []Case{ + // Basic length checks + { + "length_eq_true", + "items.length eq 3", + rule.D{"items": []any{1, 2, 3}}, + true, + }, + { + "length_eq_false", + "items.length eq 5", + rule.D{"items": []any{1, 2, 3}}, + false, + }, + { + "length_gt_true", + "items.length gt 2", + rule.D{"items": []any{"a", "b", "c"}}, + true, + }, + { + "length_gt_false", + "items.length gt 5", + rule.D{"items": []any{"a", "b", "c"}}, + false, + }, + { + "length_lt_true", + "items.length lt 5", + rule.D{"items": []any{1, 2}}, + true, + }, + { + "length_ge_true", + "items.length ge 3", + rule.D{"items": []any{1, 2, 3}}, + true, + }, + { + "length_le_true", + "items.length le 3", + rule.D{"items": []any{1, 2, 3}}, + true, + }, + { + "length_ne_true", + "items.length ne 0", + rule.D{"items": []any{1}}, + true, + }, + // Empty array + { + "length_empty_eq_zero", + "items.length eq 0", + rule.D{"items": []any{}}, + true, + }, + { + "length_empty_gt_zero", + "items.length gt 0", + rule.D{"items": []any{}}, + false, + }, + // Nested property path with length + { + "length_nested_property", + "data.selections.length gt 1", + rule.D{ + "data": rule.D{ + "selections": []any{ + rule.D{"id": "1"}, + rule.D{"id": "2"}, + rule.D{"id": "3"}, + }, + }, + }, + true, + }, + { + "length_deep_nested", + "a.b.c.length eq 2", + rule.D{ + "a": rule.D{ + "b": rule.D{ + "c": []any{10, 20}, + }, + }, + }, + true, + }, + // Combined with other conditions + { + "length_combined_and", + `items.length gt 2 and status eq "active"`, + rule.D{ + "items": []any{1, 2, 3}, + "status": "active", + }, + true, + }, + { + "length_combined_and_false", + `items.length gt 5 and status eq "active"`, + rule.D{ + "items": []any{1, 2, 3}, + "status": "active", + }, + false, + }, + { + "length_combined_or", + `items.length eq 0 or fallback eq true`, + rule.D{ + "items": []any{1}, + "fallback": true, + }, + true, + }, + // Missing array property + { + "length_missing_property", + "missing.length gt 0", + rule.D{}, + false, + }, + // Non-array property with length + { + "length_on_map_with_length_key", + "obj.length eq 42", + rule.D{ + "obj": rule.D{"length": 42}, + }, + true, + }, + // Array of objects + { + "length_array_of_objects", + "selections.length eq 3", + rule.D{ + "selections": []any{ + rule.D{"event_status": "EVENT_STATUS_IN_PROGRESS"}, + rule.D{"event_status": "EVENT_STATUS_FINISHED"}, + rule.D{"event_status": "EVENT_STATUS_CANCELLED"}, + }, + }, + true, + }, +} + +/* ---------- ANY quantifier ---------- */ + +//nolint:gochecknoglobals // Test data +var QuantifierAnyTests = []Case{ + // Basic any - match found + { + "any_basic_match", + `items any (status eq "active")`, + rule.D{ + "items": []any{ + rule.D{"status": "inactive"}, + rule.D{"status": "active"}, + rule.D{"status": "inactive"}, + }, + }, + true, + }, + // Basic any - no match + { + "any_basic_no_match", + `items any (status eq "active")`, + rule.D{ + "items": []any{ + rule.D{"status": "inactive"}, + rule.D{"status": "disabled"}, + }, + }, + false, + }, + // Any with empty array + { + "any_empty_array", + `items any (status eq "active")`, + rule.D{"items": []any{}}, + false, + }, + // Any with numeric comparison + { + "any_numeric", + "scores any (value gt 90)", + rule.D{ + "scores": []any{ + rule.D{"value": 50}, + rule.D{"value": 75}, + rule.D{"value": 95}, + }, + }, + true, + }, + { + "any_numeric_no_match", + "scores any (value gt 100)", + rule.D{ + "scores": []any{ + rule.D{"value": 50}, + rule.D{"value": 75}, + }, + }, + false, + }, + // Any with boolean + { + "any_boolean", + "items any (is_active eq true)", + rule.D{ + "items": []any{ + rule.D{"is_active": false}, + rule.D{"is_active": true}, + }, + }, + true, + }, + // Any with compound sub-expression + { + "any_compound_and", + `items any (status eq "active" and priority gt 5)`, + rule.D{ + "items": []any{ + rule.D{"status": "active", "priority": 3}, + rule.D{"status": "active", "priority": 8}, + rule.D{"status": "inactive", "priority": 9}, + }, + }, + true, + }, + { + "any_compound_and_no_match", + `items any (status eq "active" and priority gt 10)`, + rule.D{ + "items": []any{ + rule.D{"status": "active", "priority": 3}, + rule.D{"status": "active", "priority": 8}, + }, + }, + false, + }, + // Any with OR sub-expression + { + "any_compound_or", + `items any (status eq "cancelled" or status eq "refunded")`, + rule.D{ + "items": []any{ + rule.D{"status": "active"}, + rule.D{"status": "refunded"}, + }, + }, + true, + }, + // Any with string operations + { + "any_string_contains", + `items any (name co "John")`, + rule.D{ + "items": []any{ + rule.D{"name": "Alice Smith"}, + rule.D{"name": "John Doe"}, + }, + }, + true, + }, + { + "any_string_starts_with", + `items any (code sw "PRE_")`, + rule.D{ + "items": []any{ + rule.D{"code": "POST_123"}, + rule.D{"code": "PRE_456"}, + }, + }, + true, + }, + // Any with IN operator + { + "any_in_operator", + `items any (color in ["red", "blue"])`, + rule.D{ + "items": []any{ + rule.D{"color": "green"}, + rule.D{"color": "blue"}, + }, + }, + true, + }, + // Any with presence operator + { + "any_presence", + "items any (optional_field pr)", + rule.D{ + "items": []any{ + rule.D{"name": "a"}, + rule.D{"name": "b", "optional_field": "value"}, + }, + }, + true, + }, + // Any combined with outer conditions + { + "any_combined_outer", + `type eq "order" and items any (status eq "shipped")`, + rule.D{ + "type": "order", + "items": []any{ + rule.D{"status": "pending"}, + rule.D{"status": "shipped"}, + }, + }, + true, + }, + { + "any_combined_outer_false", + `type eq "invoice" and items any (status eq "shipped")`, + rule.D{ + "type": "order", + "items": []any{ + rule.D{"status": "shipped"}, + }, + }, + false, + }, + // Any on nested property + { + "any_nested_property", + `data.items any (value gt 0)`, + rule.D{ + "data": rule.D{ + "items": []any{ + rule.D{"value": -1}, + rule.D{"value": 5}, + }, + }, + }, + true, + }, + // Any with missing property in elements + { + "any_missing_property_in_element", + `items any (missing_field eq "value")`, + rule.D{ + "items": []any{ + rule.D{"name": "a"}, + rule.D{"name": "b"}, + }, + }, + false, + }, + // Any with NOT sub-expression + { + "any_not_sub_expression", + `items any (not (status eq "cancelled"))`, + rule.D{ + "items": []any{ + rule.D{"status": "cancelled"}, + rule.D{"status": "active"}, + }, + }, + true, + }, + // Any on non-existent array + { + "any_missing_array", + `missing_items any (status eq "active")`, + rule.D{}, + false, + }, + // Any on non-array value + { + "any_non_array_value", + `name any (status eq "active")`, + rule.D{"name": "not_an_array"}, + false, + }, +} + +/* ---------- ALL quantifier ---------- */ + +//nolint:gochecknoglobals // Test data +var QuantifierAllTests = []Case{ + // All match + { + "all_basic_match", + `items all (status eq "active")`, + rule.D{ + "items": []any{ + rule.D{"status": "active"}, + rule.D{"status": "active"}, + rule.D{"status": "active"}, + }, + }, + true, + }, + // Not all match + { + "all_basic_no_match", + `items all (status eq "active")`, + rule.D{ + "items": []any{ + rule.D{"status": "active"}, + rule.D{"status": "inactive"}, + }, + }, + false, + }, + // All with empty array (vacuous truth) + { + "all_empty_array", + `items all (status eq "active")`, + rule.D{"items": []any{}}, + true, + }, + // All with numeric + { + "all_numeric", + "scores all (value ge 50)", + rule.D{ + "scores": []any{ + rule.D{"value": 60}, + rule.D{"value": 75}, + rule.D{"value": 90}, + }, + }, + true, + }, + { + "all_numeric_false", + "scores all (value ge 50)", + rule.D{ + "scores": []any{ + rule.D{"value": 30}, + rule.D{"value": 75}, + }, + }, + false, + }, + // All with compound expression + { + "all_compound", + `items all (status eq "active" and enabled eq true)`, + rule.D{ + "items": []any{ + rule.D{"status": "active", "enabled": true}, + rule.D{"status": "active", "enabled": true}, + }, + }, + true, + }, + { + "all_compound_false", + `items all (status eq "active" and enabled eq true)`, + rule.D{ + "items": []any{ + rule.D{"status": "active", "enabled": true}, + rule.D{"status": "active", "enabled": false}, + }, + }, + false, + }, + // All combined with outer conditions + { + "all_combined_outer", + `category eq "premium" and items all (quality ge 8)`, + rule.D{ + "category": "premium", + "items": []any{ + rule.D{"quality": 9}, + rule.D{"quality": 8}, + }, + }, + true, + }, + // All on nested property + { + "all_nested_property", + `data.items all (is_valid eq true)`, + rule.D{ + "data": rule.D{ + "items": []any{ + rule.D{"is_valid": true}, + rule.D{"is_valid": true}, + }, + }, + }, + true, + }, + // All on missing array + { + "all_missing_array", + `missing all (status eq "active")`, + rule.D{}, + false, + }, + // All on non-array + { + "all_non_array", + `name all (status eq "active")`, + rule.D{"name": "string_value"}, + false, + }, + // Single element all + { + "all_single_element", + `items all (value gt 0)`, + rule.D{ + "items": []any{ + rule.D{"value": 5}, + }, + }, + true, + }, +} + +/* ---------- NONE quantifier ---------- */ + +//nolint:gochecknoglobals // Test data +var QuantifierNoneTests = []Case{ + // None match (true) + { + "none_basic_match", + `items none (status eq "cancelled")`, + rule.D{ + "items": []any{ + rule.D{"status": "active"}, + rule.D{"status": "pending"}, + }, + }, + true, + }, + // Some match (false) + { + "none_basic_has_match", + `items none (status eq "cancelled")`, + rule.D{ + "items": []any{ + rule.D{"status": "active"}, + rule.D{"status": "cancelled"}, + }, + }, + false, + }, + // None with empty array (true) + { + "none_empty_array", + `items none (status eq "cancelled")`, + rule.D{"items": []any{}}, + true, + }, + // None with numeric + { + "none_numeric", + "scores none (value lt 0)", + rule.D{ + "scores": []any{ + rule.D{"value": 10}, + rule.D{"value": 20}, + }, + }, + true, + }, + { + "none_numeric_false", + "scores none (value lt 0)", + rule.D{ + "scores": []any{ + rule.D{"value": 10}, + rule.D{"value": -5}, + }, + }, + false, + }, + // None with compound expression + { + "none_compound", + `items none (status eq "error" and severity eq "critical")`, + rule.D{ + "items": []any{ + rule.D{"status": "error", "severity": "low"}, + rule.D{"status": "ok", "severity": "critical"}, + }, + }, + true, + }, + { + "none_compound_false", + `items none (status eq "error" and severity eq "critical")`, + rule.D{ + "items": []any{ + rule.D{"status": "error", "severity": "critical"}, + rule.D{"status": "ok", "severity": "low"}, + }, + }, + false, + }, + // None combined with outer conditions + { + "none_combined_outer", + `is_verified eq true and items none (is_fraud eq true)`, + rule.D{ + "is_verified": true, + "items": []any{ + rule.D{"is_fraud": false}, + rule.D{"is_fraud": false}, + }, + }, + true, + }, + // None on nested property + { + "none_nested_property", + `data.items none (status eq "deleted")`, + rule.D{ + "data": rule.D{ + "items": []any{ + rule.D{"status": "active"}, + rule.D{"status": "archived"}, + }, + }, + }, + true, + }, + // None on missing array + { + "none_missing_array", + `missing none (status eq "active")`, + rule.D{}, + false, + }, + // None on non-array + { + "none_non_array", + `name none (status eq "active")`, + rule.D{"name": "string_value"}, + false, + }, +} + +/* ---------- Real-world betting scenarios ---------- */ + +//nolint:gochecknoglobals // Test data +var ListRealWorldTests = []Case{ + // Betting: check if any selection has a cancelled event + { + "betting_any_cancelled", + `selections any (event_status eq "EVENT_STATUS_CANCELLED")`, + rule.D{ + "selections": []any{ + rule.D{"event_status": "EVENT_STATUS_IN_PROGRESS", "sport_name": "Football"}, + rule.D{"event_status": "EVENT_STATUS_CANCELLED", "sport_name": "Basketball"}, + rule.D{"event_status": "EVENT_STATUS_FINISHED", "sport_name": "Tennis"}, + }, + }, + true, + }, + { + "betting_no_cancelled", + `selections any (event_status eq "EVENT_STATUS_CANCELLED")`, + rule.D{ + "selections": []any{ + rule.D{"event_status": "EVENT_STATUS_IN_PROGRESS"}, + rule.D{"event_status": "EVENT_STATUS_FINISHED"}, + }, + }, + false, + }, + // Betting: all selections are live + { + "betting_all_live", + "selections all (is_live eq true)", + rule.D{ + "selections": []any{ + rule.D{"is_live": true, "sport_name": "Football"}, + rule.D{"is_live": true, "sport_name": "Basketball"}, + }, + }, + true, + }, + { + "betting_not_all_live", + "selections all (is_live eq true)", + rule.D{ + "selections": []any{ + rule.D{"is_live": true}, + rule.D{"is_live": false}, + }, + }, + false, + }, + // Betting: no selection is a loss + { + "betting_none_loss", + `selections none (status eq "SELECTION_STATUS_LOSS")`, + rule.D{ + "selections": []any{ + rule.D{"status": "SELECTION_STATUS_WIN"}, + rule.D{"status": "SELECTION_STATUS_IN_PROGRESS"}, + }, + }, + true, + }, + // Betting: multiple bet with minimum selections + { + "betting_multiple_min_selections", + `bet_type eq "BET_TYPE_MULTIPLE" and selections.length ge 3`, + rule.D{ + "bet_type": "BET_TYPE_MULTIPLE", + "selections": []any{ + rule.D{"id": "1"}, + rule.D{"id": "2"}, + rule.D{"id": "3"}, + }, + }, + true, + }, + { + "betting_multiple_insufficient_selections", + `bet_type eq "BET_TYPE_MULTIPLE" and selections.length ge 3`, + rule.D{ + "bet_type": "BET_TYPE_MULTIPLE", + "selections": []any{ + rule.D{"id": "1"}, + rule.D{"id": "2"}, + }, + }, + false, + }, + // Betting: VIP customer with high-odd live selections + { + "betting_vip_high_odd_live", + `customer_data.is_vip eq true and selections any (is_live eq true and odd gt 3.0)`, + rule.D{ + "customer_data": rule.D{"is_vip": true}, + "selections": []any{ + rule.D{"is_live": false, "odd": 1.5}, + rule.D{"is_live": true, "odd": 4.2}, + }, + }, + true, + }, + { + "betting_vip_no_high_odd_live", + `customer_data.is_vip eq true and selections any (is_live eq true and odd gt 3.0)`, + rule.D{ + "customer_data": rule.D{"is_vip": true}, + "selections": []any{ + rule.D{"is_live": true, "odd": 1.5}, + rule.D{"is_live": false, "odd": 4.2}, + }, + }, + false, + }, + // Betting: complex rule - multiple type, enough selections, no cancelled events, from specific provider + { + "betting_complex_validation", + `bet_type eq "BET_TYPE_MULTIPLE" and selections.length ge 2 and selections none (event_status eq "EVENT_STATUS_CANCELLED") and selections any (provider eq "PROVIDER_SPORTRADAR")`, + rule.D{ + "bet_type": "BET_TYPE_MULTIPLE", + "selections": []any{ + rule.D{ + "event_status": "EVENT_STATUS_IN_PROGRESS", + "provider": "PROVIDER_SPORTRADAR", + "is_live": true, + }, + rule.D{ + "event_status": "EVENT_STATUS_NOT_STARTED", + "provider": "PROVIDER_RAMP", + "is_live": false, + }, + }, + }, + true, + }, + { + "betting_complex_validation_cancelled", + `bet_type eq "BET_TYPE_MULTIPLE" and selections.length ge 2 and selections none (event_status eq "EVENT_STATUS_CANCELLED")`, + rule.D{ + "bet_type": "BET_TYPE_MULTIPLE", + "selections": []any{ + rule.D{"event_status": "EVENT_STATUS_IN_PROGRESS"}, + rule.D{"event_status": "EVENT_STATUS_CANCELLED"}, + }, + }, + false, + }, + // Betting: any selection is super odd + { + "betting_any_super_odd", + "selections any (is_super_odd eq true)", + rule.D{ + "selections": []any{ + rule.D{"is_super_odd": false, "odd": 1.5}, + rule.D{"is_super_odd": true, "odd": 5.0}, + }, + }, + true, + }, + // Betting: all selections from the same sport + { + "betting_all_same_sport", + `selections all (sport_name eq "Football")`, + rule.D{ + "selections": []any{ + rule.D{"sport_name": "Football"}, + rule.D{"sport_name": "Football"}, + rule.D{"sport_name": "Football"}, + }, + }, + true, + }, + { + "betting_not_all_same_sport", + `selections all (sport_name eq "Football")`, + rule.D{ + "selections": []any{ + rule.D{"sport_name": "Football"}, + rule.D{"sport_name": "Basketball"}, + }, + }, + false, + }, + // Betting: any selection with recommendation + { + "betting_any_recommendation", + "selections any (is_recommendation eq true)", + rule.D{ + "selections": []any{ + rule.D{"is_recommendation": false}, + rule.D{"is_recommendation": true}, + }, + }, + true, + }, + // Betting: length combined with quantifier + { + "betting_length_and_quantifier", + `selections.length gt 1 and selections all (odd gt 1.0)`, + rule.D{ + "selections": []any{ + rule.D{"odd": 1.5}, + rule.D{"odd": 2.3}, + }, + }, + true, + }, + // Betting: freebet with single selection constraint + { + "betting_freebet_single", + `is_freebet eq true and selections.length eq 1`, + rule.D{ + "is_freebet": true, + "selections": []any{ + rule.D{"id": "sel_1"}, + }, + }, + true, + }, + { + "betting_freebet_multiple_invalid", + `is_freebet eq true and selections.length eq 1`, + rule.D{ + "is_freebet": true, + "selections": []any{ + rule.D{"id": "sel_1"}, + rule.D{"id": "sel_2"}, + }, + }, + false, + }, + // Selection with string operation inside quantifier + { + "betting_any_event_name_contains", + `selections any (event_name co "Barcelona")`, + rule.D{ + "selections": []any{ + rule.D{"event_name": "Real Madrid vs Atletico"}, + rule.D{"event_name": "FC Barcelona vs PSG"}, + }, + }, + true, + }, + // Quantifier with NOT outer + { + "betting_not_any_cancelled", + `not (selections any (event_status eq "EVENT_STATUS_CANCELLED"))`, + rule.D{ + "selections": []any{ + rule.D{"event_status": "EVENT_STATUS_IN_PROGRESS"}, + rule.D{"event_status": "EVENT_STATUS_FINISHED"}, + }, + }, + true, + }, + { + "betting_not_any_cancelled_false", + `not (selections any (event_status eq "EVENT_STATUS_CANCELLED"))`, + rule.D{ + "selections": []any{ + rule.D{"event_status": "EVENT_STATUS_CANCELLED"}, + }, + }, + false, + }, +} + +/* ---------- Nested list quantifiers (list inside list) ---------- */ + +//nolint:gochecknoglobals // Test data +var NestedListTests = []Case{ + // any inside any - match found + { + "nested_any_any_match", + `groups any (items any (value gt 10))`, + rule.D{ + "groups": []any{ + rule.D{"items": []any{ + rule.D{"value": 1}, + rule.D{"value": 2}, + }}, + rule.D{"items": []any{ + rule.D{"value": 5}, + rule.D{"value": 15}, + }}, + }, + }, + true, + }, + // any inside any - no match + { + "nested_any_any_no_match", + `groups any (items any (value gt 100))`, + rule.D{ + "groups": []any{ + rule.D{"items": []any{ + rule.D{"value": 1}, + rule.D{"value": 2}, + }}, + }, + }, + false, + }, + // all inside all + { + "nested_all_all_match", + `groups all (items all (is_valid eq true))`, + rule.D{ + "groups": []any{ + rule.D{"items": []any{ + rule.D{"is_valid": true}, + rule.D{"is_valid": true}, + }}, + rule.D{"items": []any{ + rule.D{"is_valid": true}, + }}, + }, + }, + true, + }, + { + "nested_all_all_no_match", + `groups all (items all (is_valid eq true))`, + rule.D{ + "groups": []any{ + rule.D{"items": []any{ + rule.D{"is_valid": true}, + }}, + rule.D{"items": []any{ + rule.D{"is_valid": false}, + }}, + }, + }, + false, + }, + // none inside any + { + "nested_any_none", + `groups any (items none (status eq "error"))`, + rule.D{ + "groups": []any{ + rule.D{"items": []any{ + rule.D{"status": "error"}, + }}, + rule.D{"items": []any{ + rule.D{"status": "ok"}, + rule.D{"status": "ok"}, + }}, + }, + }, + true, + }, + // any inside all + { + "nested_all_any_match", + `groups all (items any (priority gt 0))`, + rule.D{ + "groups": []any{ + rule.D{"items": []any{ + rule.D{"priority": 0}, + rule.D{"priority": 5}, + }}, + rule.D{"items": []any{ + rule.D{"priority": 3}, + }}, + }, + }, + true, + }, + { + "nested_all_any_no_match", + `groups all (items any (priority gt 0))`, + rule.D{ + "groups": []any{ + rule.D{"items": []any{ + rule.D{"priority": 5}, + }}, + rule.D{"items": []any{ + rule.D{"priority": 0}, + rule.D{"priority": 0}, + }}, + }, + }, + false, + }, + // Nested length + { + "nested_length_inside_any", + "groups any (items.length gt 2)", + rule.D{ + "groups": []any{ + rule.D{"items": []any{rule.D{"x": 1}}}, + rule.D{"items": []any{rule.D{"x": 1}, rule.D{"x": 2}, rule.D{"x": 3}}}, + }, + }, + true, + }, + { + "nested_length_inside_all", + "groups all (items.length ge 1)", + rule.D{ + "groups": []any{ + rule.D{"items": []any{rule.D{"x": 1}}}, + rule.D{"items": []any{rule.D{"x": 1}, rule.D{"x": 2}}}, + }, + }, + true, + }, + // Condition on parent + nested quantifier + { + "nested_parent_condition_and_inner_quantifier", + `groups any (name eq "vip" and items any (score gt 90))`, + rule.D{ + "groups": []any{ + rule.D{ + "name": "regular", + "items": []any{rule.D{"score": 95}}, + }, + rule.D{ + "name": "vip", + "items": []any{rule.D{"score": 50}, rule.D{"score": 99}}, + }, + }, + }, + true, + }, + { + "nested_parent_condition_and_inner_quantifier_no_match", + `groups any (name eq "vip" and items any (score gt 100))`, + rule.D{ + "groups": []any{ + rule.D{ + "name": "vip", + "items": []any{rule.D{"score": 50}, rule.D{"score": 99}}, + }, + }, + }, + false, + }, + // Empty inner lists + { + "nested_any_empty_inner", + `groups any (items any (x eq 1))`, + rule.D{ + "groups": []any{ + rule.D{"items": []any{}}, + }, + }, + false, + }, + { + "nested_all_empty_inner", + `groups all (items all (x eq 1))`, + rule.D{ + "groups": []any{ + rule.D{"items": []any{}}, + }, + }, + true, // vacuous truth + }, + { + "nested_none_empty_inner", + `groups all (items none (x eq 1))`, + rule.D{ + "groups": []any{ + rule.D{"items": []any{}}, + }, + }, + true, // no elements to match + }, + // Betting: selections with bet_builder_selections (real-world) + { + "betting_nested_bb_any_market", + `selections any (bet_builder_selections any (market_name eq "Goals"))`, + rule.D{ + "selections": []any{ + rule.D{ + "sport_name": "Football", + "bet_builder_selections": []any{ + rule.D{"market_name": "Goals", "odd": 1.5}, + rule.D{"market_name": "Cards", "odd": 2.3}, + }, + }, + rule.D{ + "sport_name": "Basketball", + "bet_builder_selections": []any{ + rule.D{"market_name": "Points", "odd": 3.1}, + }, + }, + }, + }, + true, + }, + { + "betting_nested_bb_no_match", + `selections any (bet_builder_selections any (market_name eq "Corners"))`, + rule.D{ + "selections": []any{ + rule.D{ + "bet_builder_selections": []any{ + rule.D{"market_name": "Goals"}, + rule.D{"market_name": "Cards"}, + }, + }, + }, + }, + false, + }, + // Betting: all bet_builder_selections have odd > 1.0 + { + "betting_nested_all_bb_odds", + `selections all (bet_builder_selections all (odd gt 1.0))`, + rule.D{ + "selections": []any{ + rule.D{ + "bet_builder_selections": []any{ + rule.D{"odd": 1.5}, + rule.D{"odd": 2.0}, + }, + }, + rule.D{ + "bet_builder_selections": []any{ + rule.D{"odd": 1.2}, + }, + }, + }, + }, + true, + }, + // Betting: sport + bb combined + { + "betting_nested_sport_and_bb", + `selections any (sport_name eq "Football" and bet_builder_selections any (odd gt 2.0))`, + rule.D{ + "selections": []any{ + rule.D{ + "sport_name": "Football", + "bet_builder_selections": []any{ + rule.D{"odd": 1.5}, + rule.D{"odd": 2.5}, + }, + }, + }, + }, + true, + }, + { + "betting_nested_sport_and_bb_no_match", + `selections any (sport_name eq "Football" and bet_builder_selections any (odd gt 3.0))`, + rule.D{ + "selections": []any{ + rule.D{ + "sport_name": "Football", + "bet_builder_selections": []any{ + rule.D{"odd": 1.5}, + rule.D{"odd": 2.5}, + }, + }, + }, + }, + false, + }, + // Betting: none of the bet_builders have errors + { + "betting_nested_none_bb_errors", + `selections none (bet_builder_selections any (status eq "error"))`, + rule.D{ + "selections": []any{ + rule.D{ + "bet_builder_selections": []any{ + rule.D{"status": "ok"}, + rule.D{"status": "ok"}, + }, + }, + rule.D{ + "bet_builder_selections": []any{ + rule.D{"status": "ok"}, + }, + }, + }, + }, + true, + }, + { + "betting_nested_none_bb_errors_found", + `selections none (bet_builder_selections any (status eq "error"))`, + rule.D{ + "selections": []any{ + rule.D{ + "bet_builder_selections": []any{ + rule.D{"status": "ok"}, + }, + }, + rule.D{ + "bet_builder_selections": []any{ + rule.D{"status": "error"}, + }, + }, + }, + }, + false, + }, + // Betting: bb length inside quantifier + { + "betting_nested_bb_length", + `selections any (is_bet_builder eq true and bet_builder_selections.length gt 1)`, + rule.D{ + "selections": []any{ + rule.D{ + "is_bet_builder": true, + "bet_builder_selections": []any{rule.D{"a": 1}, rule.D{"a": 2}}, + }, + }, + }, + true, + }, + // Complex: outer length + inner quantifier + { + "nested_complex_length_and_quantifiers", + `selections.length ge 2 and selections all (bet_builder_selections.length ge 1) and selections any (bet_builder_selections any (odd gt 2.0))`, + rule.D{ + "selections": []any{ + rule.D{ + "bet_builder_selections": []any{ + rule.D{"odd": 1.5}, + }, + }, + rule.D{ + "bet_builder_selections": []any{ + rule.D{"odd": 2.5}, + }, + }, + }, + }, + true, + }, +} diff --git a/test/rule_engine_test.go b/test/rule_engine_test.go index fc09f73..cea4603 100644 --- a/test/rule_engine_test.go +++ b/test/rule_engine_test.go @@ -58,6 +58,14 @@ func TestRulesRound1(t *testing.T) { // Comprehensive datetime tests DateTimeComprehensiveTests, + + // List operation tests (length, any, all, none) + ArrayLengthTests, + QuantifierAnyTests, + QuantifierAllTests, + QuantifierNoneTests, + ListRealWorldTests, + NestedListTests, } for _, group := range all { diff --git a/token.go b/token.go index 4b35a4c..c672a49 100644 --- a/token.go +++ b/token.go @@ -44,6 +44,11 @@ const ( OR NOT + // ANY represents the "any" list quantifier operator. + ANY // any element matches + ALL // all elements match + NONE // no element matches + // EQUALS is an alias for the equality operator. EQUALS // == NOT_EQUALS //nolint:revive,staticcheck // Token constants use ALL_CAPS convention @@ -82,6 +87,9 @@ var keywordMap = map[string]TokenType{ "and": AND, "or": OR, "not": NOT, + "any": ANY, + "all": ALL, + "none": NONE, trueString: BOOLEAN, "false": BOOLEAN, } @@ -122,6 +130,9 @@ var tokenStringMap = map[TokenType]string{ AND: "and", OR: "or", NOT: "not", + ANY: "any", + ALL: "all", + NONE: "none", EQUALS: "==", NOT_EQUALS: "!=", } diff --git a/validator.go b/validator.go index 4b63045..de93d44 100644 --- a/validator.go +++ b/validator.go @@ -51,6 +51,8 @@ func validateBinaryOperation(node *ASTNode) error { return validateInOperation(node) case CO, SW, EW: return validateStringOperation(node) + case ANY, ALL, NONE: + return validateQuantifierOperation(node) case EOF, IDENTIFIER, STRING, NUMBER, BOOLEAN, ARRAY_START, ARRAY_END, PAREN_OPEN, PAREN_CLOSE, DOT, COMMA, EQ, NE, LT, GT, LE, GE, PR, DQ, DN, BE, BQ, AF, AQ, DL, DG, AND, OR, NOT, EQUALS, NOT_EQUALS: @@ -71,7 +73,8 @@ func validateUnaryOperation(node *ASTNode) error { return validatePresenceOperation(node) case EOF, IDENTIFIER, STRING, NUMBER, BOOLEAN, ARRAY_START, ARRAY_END, PAREN_OPEN, PAREN_CLOSE, DOT, COMMA, EQ, NE, LT, GT, LE, GE, - CO, SW, EW, IN, NOT_IN, DQ, DN, BE, BQ, AF, AQ, DL, DG, AND, OR, NOT, EQUALS, NOT_EQUALS: + CO, SW, EW, IN, NOT_IN, DQ, DN, BE, BQ, AF, AQ, DL, DG, + AND, OR, NOT, ANY, ALL, NONE, EQUALS, NOT_EQUALS: // Other operators don't apply to unary operations return nil } @@ -121,6 +124,17 @@ func validateStringOperation(node *ASTNode) error { return nil } +func validateQuantifierOperation(node *ASTNode) error { + // Quantifier operators (any/all/none) should only work on identifiers or properties + operand := node.Left + if operand.Type != NodeIdentifier && operand.Type != NodeProperty { + return ErrInvalidQuantifierTarget + } + + // Right operand (sub-expression) is validated recursively by the caller + return nil +} + func validatePresenceOperation(node *ASTNode) error { // Presence operator should only work on identifiers or properties operand := node.Left From 0e3f61f01c7fc684b4d3645821992256eae45457 Mon Sep 17 00:00:00 2001 From: Lucas Nakano Perez Date: Sun, 15 Feb 2026 23:36:38 -0300 Subject: [PATCH 2/2] fix: coderabbit comments --- CLAUDE.md | 2 +- benchmark_optimized_test.go | 116 +++++++++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c54a724..dec7c1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ The entire specification is defined through comprehensive test cases in `test/fi ### Rule Syntax Examples -``` +```text x eq 10 // equality score gt 100 and level lt 5 // logical operations city co "York" // string contains diff --git a/benchmark_optimized_test.go b/benchmark_optimized_test.go index dc80741..7934f81 100644 --- a/benchmark_optimized_test.go +++ b/benchmark_optimized_test.go @@ -13,6 +13,14 @@ func BenchmarkOptimizedEngineSimple(b *testing.B) { // Pre-compile rule engine.AddQuery(rule) + b.ReportAllocs() + + if allocs := testing.AllocsPerRun(1, func() { + _, _ = engine.Evaluate(rule, ctx) + }); allocs != 0 { + b.Fatalf("expected 0 allocs/op, got %f", allocs) + } + b.ResetTimer() for range b.N { @@ -37,6 +45,14 @@ func BenchmarkOptimizedEngineComplex(b *testing.B) { // Pre-compile rule engine.AddQuery(rule) + b.ReportAllocs() + + if allocs := testing.AllocsPerRun(1, func() { + _, _ = engine.Evaluate(rule, ctx) + }); allocs != 0 { + b.Fatalf("expected 0 allocs/op, got %f", allocs) + } + b.ResetTimer() for range b.N { @@ -55,6 +71,14 @@ func BenchmarkOptimizedEngineStringOps(b *testing.B) { // Pre-compile rule engine.AddQuery(rule) + b.ReportAllocs() + + if allocs := testing.AllocsPerRun(1, func() { + _, _ = engine.Evaluate(rule, ctx) + }); allocs != 0 { + b.Fatalf("expected 0 allocs/op, got %f", allocs) + } + b.ResetTimer() for range b.N { @@ -76,6 +100,15 @@ func BenchmarkOptimizedEngineInOperator(b *testing.B) { // Pre-compile rule engine.AddQuery(rule) + b.ReportAllocs() + + // 1 alloc (24 B) from []any interface boxing in Go runtime. + if allocs := testing.AllocsPerRun(1, func() { + _, _ = engine.Evaluate(rule, ctx) + }); allocs > 1 { + b.Fatalf("expected <= 1 allocs/op, got %f", allocs) + } + b.ResetTimer() for range b.N { @@ -102,6 +135,14 @@ func BenchmarkOptimizedEngineNestedProps(b *testing.B) { // Pre-compile rule engine.AddQuery(rule) + b.ReportAllocs() + + if allocs := testing.AllocsPerRun(1, func() { + _, _ = engine.Evaluate(rule, ctx) + }); allocs != 0 { + b.Fatalf("expected 0 allocs/op, got %f", allocs) + } + b.ResetTimer() for range b.N { @@ -117,6 +158,14 @@ func BenchmarkOptimizedStandalone(b *testing.B) { ctx := D{"x": 10} rule := "x eq 10" + b.ReportAllocs() + + if allocs := testing.AllocsPerRun(1, func() { + _, _ = engine.Evaluate(rule, ctx) + }); allocs != 0 { + b.Fatalf("expected 0 allocs/op, got %f", allocs) + } + b.ResetTimer() for range b.N { @@ -142,6 +191,14 @@ func BenchmarkOptimizedEngineArrayLength(b *testing.B) { // Pre-compile rule engine.AddQuery(rule) + b.ReportAllocs() + + if allocs := testing.AllocsPerRun(1, func() { + _, _ = engine.Evaluate(rule, ctx) + }); allocs != 0 { + b.Fatalf("expected 0 allocs/op, got %f", allocs) + } + b.ResetTimer() for range b.N { @@ -152,7 +209,7 @@ func BenchmarkOptimizedEngineArrayLength(b *testing.B) { } } -// Benchmark any quantifier evaluation for zero allocations. +// Benchmark any quantifier evaluation (1 alloc, 24 B from []any interface boxing). func BenchmarkOptimizedEngineQuantifierAny(b *testing.B) { engine := NewEngine() ctx := D{ @@ -167,6 +224,15 @@ func BenchmarkOptimizedEngineQuantifierAny(b *testing.B) { // Pre-compile rule engine.AddQuery(rule) + b.ReportAllocs() + + // 1 alloc (24 B) from []any interface boxing in Go runtime. + if allocs := testing.AllocsPerRun(1, func() { + _, _ = engine.Evaluate(rule, ctx) + }); allocs > 1 { + b.Fatalf("expected <= 1 allocs/op, got %f", allocs) + } + b.ResetTimer() for range b.N { @@ -177,7 +243,7 @@ func BenchmarkOptimizedEngineQuantifierAny(b *testing.B) { } } -// Benchmark all quantifier evaluation for zero allocations. +// Benchmark all quantifier evaluation (1 alloc, 24 B from []any interface boxing). func BenchmarkOptimizedEngineQuantifierAll(b *testing.B) { engine := NewEngine() ctx := D{ @@ -192,6 +258,15 @@ func BenchmarkOptimizedEngineQuantifierAll(b *testing.B) { // Pre-compile rule engine.AddQuery(rule) + b.ReportAllocs() + + // 1 alloc (24 B) from []any interface boxing in Go runtime. + if allocs := testing.AllocsPerRun(1, func() { + _, _ = engine.Evaluate(rule, ctx) + }); allocs > 1 { + b.Fatalf("expected <= 1 allocs/op, got %f", allocs) + } + b.ResetTimer() for range b.N { @@ -202,7 +277,7 @@ func BenchmarkOptimizedEngineQuantifierAll(b *testing.B) { } } -// Benchmark none quantifier evaluation for zero allocations. +// Benchmark none quantifier evaluation (1 alloc, 24 B from []any interface boxing). func BenchmarkOptimizedEngineQuantifierNone(b *testing.B) { engine := NewEngine() ctx := D{ @@ -217,6 +292,15 @@ func BenchmarkOptimizedEngineQuantifierNone(b *testing.B) { // Pre-compile rule engine.AddQuery(rule) + b.ReportAllocs() + + // 1 alloc (24 B) from []any interface boxing in Go runtime. + if allocs := testing.AllocsPerRun(1, func() { + _, _ = engine.Evaluate(rule, ctx) + }); allocs > 1 { + b.Fatalf("expected <= 1 allocs/op, got %f", allocs) + } + b.ResetTimer() for range b.N { @@ -256,6 +340,15 @@ func BenchmarkOptimizedEngineComplexBetting(b *testing.B) { // Pre-compile rule engine.AddQuery(rule) + b.ReportAllocs() + + // Multiple allocs expected from []any interface boxing (one per quantifier). + if allocs := testing.AllocsPerRun(1, func() { + _, _ = engine.Evaluate(rule, ctx) + }); allocs > 2 { + b.Fatalf("expected <= 2 allocs/op, got %f", allocs) + } + b.ResetTimer() for range b.N { @@ -284,6 +377,15 @@ func BenchmarkOptimizedEngineQuantifierAnyShortCircuit(b *testing.B) { // Pre-compile rule engine.AddQuery(rule) + b.ReportAllocs() + + // 1 alloc (24 B) from []any interface boxing in Go runtime. + if allocs := testing.AllocsPerRun(1, func() { + _, _ = engine.Evaluate(rule, ctx) + }); allocs > 1 { + b.Fatalf("expected <= 1 allocs/op, got %f", allocs) + } + b.ResetTimer() for range b.N { @@ -302,6 +404,14 @@ func BenchmarkZeroAllocEvaluatorDirect(b *testing.B) { NewNumberLiteralNode(10)) ctx := D{"x": 10} + b.ReportAllocs() + + if allocs := testing.AllocsPerRun(1, func() { + _, _ = evaluator.Evaluate(ast, ctx) + }); allocs != 0 { + b.Fatalf("expected 0 allocs/op, got %f", allocs) + } + b.ResetTimer() for range b.N {