diff --git a/CLAUDE.md b/CLAUDE.md index dec7c1d..1de5bf4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,7 @@ The entire specification is defined through comprehensive test cases in `test/fi - **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 +- **List Filtering**: `where (predicate)` operator to filter arrays before applying `.length` or a quantifier (proprietary extension; not part of `nikunjy/rules`) ### Rule Syntax Examples @@ -54,6 +55,8 @@ 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 +selections where (odd ge 1.4).length ge 4 // count filtered elements +selections where (odd ge 1.4) any (provider eq "X") // quantifier on filtered subset ``` ## Development Commands @@ -125,7 +128,8 @@ All functionality is validated through the comprehensive test suite in `test/fix - **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) +- **Filter `where`**: ` where ().length` counts elements matching predicate; ` where () any|all|none ()` applies a quantifier on the filtered subset. Chaining of `where` is not supported. Post-`where` access is restricted to `.length`, `any`, `all`, and `none`. +- **Reserved words**: `any`, `all`, `none`, `where` are reserved keywords (cannot be used as field names) ### Zero-Allocation Implementation - **EvalResult Structure**: Pre-allocated typed result structure to avoid interface boxing diff --git a/README.md b/README.md index 3a34ce7..34529a8 100644 --- a/README.md +++ b/README.md @@ -556,6 +556,58 @@ rule := `(user.age ge 18 and user.status eq "active") and result, _ := engine.Evaluate(rule, context) // true ``` +### List Operators + +Operate over arrays with `.length`, quantifiers (`any`, `all`, `none`) and the +proprietary `where` filter: + +```go +context := rule.D{ + "selections": []any{ + rule.D{"odd": 1.5, "is_live": true, "provider": "X"}, + rule.D{"odd": 2.0, "is_live": false, "provider": "Y"}, + rule.D{"odd": 1.4, "is_live": true, "provider": "X"}, + rule.D{"odd": 1.0, "is_live": true, "provider": "Z"}, + }, +} + +// Array length +engine.Evaluate(`selections.length ge 3`, context) // true + +// Quantifiers over the full array +engine.Evaluate(`selections any (provider eq "X")`, context) // true +engine.Evaluate(`selections all (is_live eq true)`, context) // false +engine.Evaluate(`selections none (provider eq "W")`, context) // true + +// where: count or quantify over a filtered subset +// "How many selections have odd >= 1.4?" +engine.Evaluate(`selections where (odd ge 1.4).length ge 3`, context) // true +// "Among selections with odd >= 1.4, is any from provider X?" +engine.Evaluate(`selections where (odd ge 1.4) any (provider eq "X")`, context) // true +// "Among selections with odd >= 1.4, are all live?" +engine.Evaluate(`selections where (odd ge 1.4) all (is_live eq true)`, context) // false (one has is_live=false) +``` + +#### `where` rules + +- ` where ().length ` — counts elements where + predicate is true. Zero-allocation: the engine never materializes the + filtered array. +- ` where () any|all|none ()` — equivalent to applying + the quantifier on the filtered subset. Internally rewritten as a single + quantifier over the source array with a composed predicate. +- The source must be an identifier or property that resolves to an array of + objects. +- The predicate is a full boolean expression evaluated with each array element + as context. +- Only `.length`, `any`, `all`, and `none` may follow the `where` clause. +- Chained `where` (`where (...) where (...)`) is **not** supported in this + release. +- `where` is a **reserved keyword** — JSON contexts using a top-level field + named `where` will conflict. +- `where` is a **proprietary extension** that is not part of `nikunjy/rules`. + Rules using `where` will not run on that library. + --- ## 💾 Query Caching @@ -814,6 +866,9 @@ This section provides a comprehensive compatibility analysis between NSXBet/rule | **DateTime Operators** | Native datetime comparison | `created_at af "2024-01-01T00:00:00Z"` | Time-based business rules | | **Property-to-Property** | Compare any two properties | `user.age gt limits.minimum` | Dynamic threshold validation | | **Deep Property Comparison** | Multi-level nested comparisons | `config.max eq system.limits.ceiling` | Complex configuration rules | +| **List Quantifiers** | `any`, `all`, `none` over arrays | `selections any (status eq "live")` | Element-level checks on arrays | +| **Array Length** | `.length` accessor on arrays | `items.length ge 3` | Size constraints | +| **`where` Filter** | Filter arrays by predicate before count or quantifier | `selections where (odd ge 1.4).length ge 4` | Counting / quantifying over filtered subsets. **Reserves the keyword `where`.** | | **rule.D Type Alias** | Cleaner syntax | `rule.D{"key": "value"}` | Developer experience | ### 🔧 Migration Assessment diff --git a/ast.go b/ast.go index 72d815d..6670108 100644 --- a/ast.go +++ b/ast.go @@ -9,6 +9,7 @@ const ( NodeLiteral NodeArray NodeProperty + NodeWhereCount ) type ASTNode struct { @@ -135,6 +136,14 @@ func NewArrayLiteralNode(elements []Value) *ASTNode { } } +func NewWhereCountNode(source, predicate *ASTNode) *ASTNode { + return &ASTNode{ + Type: NodeWhereCount, + Left: source, + Children: []*ASTNode{predicate}, + } +} + func (n *ASTNode) IsOperator() bool { return n.Type == NodeBinaryOp || n.Type == NodeUnaryOp } diff --git a/benchmark_optimized_test.go b/benchmark_optimized_test.go index 7934f81..1334547 100644 --- a/benchmark_optimized_test.go +++ b/benchmark_optimized_test.go @@ -421,3 +421,75 @@ func BenchmarkZeroAllocEvaluatorDirect(b *testing.B) { } } } + +// BenchmarkWhereCount validates near-zero allocations for "where (...).length" rules. +// 1 alloc (24 B) from []any interface boxing in Go runtime. +func BenchmarkWhereCount(b *testing.B) { + engine := NewEngine() + ctx := D{ + "selections": []any{ + D{"odd": 1.5}, + D{"odd": 2.0}, + D{"odd": 1.4}, + D{"odd": 1.8}, + D{"odd": 1.0}, + }, + } + query := "selections where (odd ge 1.4).length ge 4" + + if err := engine.AddQuery(query); err != nil { + b.Fatal(err) + } + + b.ReportAllocs() + + if allocs := testing.AllocsPerRun(1, func() { + _, _ = engine.Evaluate(query, ctx) + }); allocs > 1 { + b.Fatalf("expected <= 1 allocs/op, got %f", allocs) + } + + b.ResetTimer() + + for range b.N { + result, err := engine.Evaluate(query, ctx) + if err != nil || !result { + b.Fatalf("Expected true result, got %v, %v", result, err) + } + } +} + +// BenchmarkWhereWithQuantifier validates near-zero allocations for "where (...) any (...)" rules. +// 1 alloc (24 B) from []any interface boxing in Go runtime. +func BenchmarkWhereWithQuantifier(b *testing.B) { + engine := NewEngine() + ctx := D{ + "selections": []any{ + D{"odd": 1.5, "provider": "Y"}, + D{"odd": 2.0, "provider": "X"}, + D{"odd": 1.0, "provider": "X"}, + }, + } + query := `selections where (odd ge 1.4) any (provider eq "X")` + + if err := engine.AddQuery(query); err != nil { + b.Fatal(err) + } + + b.ReportAllocs() + + if allocs := testing.AllocsPerRun(1, func() { + _, _ = engine.Evaluate(query, ctx) + }); allocs > 1 { + b.Fatalf("expected <= 1 allocs/op, got %f", allocs) + } + + b.ResetTimer() + + for range b.N { + result, err := engine.Evaluate(query, ctx) + if err != nil || !result { + b.Fatalf("Expected true result, got %v, %v", result, err) + } + } +} diff --git a/concept.md b/concept.md index 8954d42..43bb076 100644 --- a/concept.md +++ b/concept.md @@ -10,6 +10,19 @@ There are some constraints: 3. Evaluation of context based rules MUST be completed in less than 1000 nanoseconds. 4. The rules that must work are present in the test/fixtures.go file in the form of test tables. +## Supported operators + +- Comparison: `eq`, `ne`, `==`, `!=`, `lt`, `gt`, `le`, `ge` +- Strings: `co` (contains), `sw` (starts with), `ew` (ends with) +- Membership: `in`, `not in` +- Presence: `pr` +- DateTime: `dq`, `dn`, `be`, `bq`, `af`, `aq`, `dl`, `dg` +- Logical: `and`, `or`, `not` +- List: `.length`, `any`, `all`, `none` +- List filtering (proprietary extension): `where (predicate)` followed by `.length` or a quantifier (`any`, `all`, `none`). + Examples: `selections where (odd ge 1.4).length ge 4`, `selections where (odd ge 1.4) any (provider eq "X")`. + Not part of `nikunjy/rules`. + ## Coding guidelines You are a seasoned golang developer. You know what you are doing. You are also a champion of TDD. You will always follow diff --git a/errors.go b/errors.go index 779e9c5..f0b1ce1 100644 --- a/errors.go +++ b/errors.go @@ -57,4 +57,25 @@ var ( "INVALID_QUANTIFIER_TARGET", "Quantifier operators (any/all/none) can only be used with identifiers or properties", } + + // ErrWhereRequiresParens indicates where used without parenthesized predicate. + ErrWhereRequiresParens = &EngineError{ + "WHERE_REQUIRES_PARENS", + "where operator requires a parenthesized predicate", + } + // ErrWhereRequiresLengthOrQuantifier indicates where without .length or quantifier after. + ErrWhereRequiresLengthOrQuantifier = &EngineError{ + "WHERE_REQUIRES_LENGTH_OR_QUANTIFIER", + "where expression must be followed by .length or a quantifier (any/all/none)", + } + // ErrEmptyWherePredicate indicates where with empty predicate. + ErrEmptyWherePredicate = &EngineError{ + "EMPTY_WHERE_PREDICATE", + "where predicate cannot be empty", + } + // ErrWhereRequiresLength indicates where followed by non-length property. + ErrWhereRequiresLength = &EngineError{ + "WHERE_REQUIRES_LENGTH", + "where can only be followed by .length", + } ) diff --git a/evaluator.go b/evaluator.go index e040af9..f9852e1 100644 --- a/evaluator.go +++ b/evaluator.go @@ -62,6 +62,9 @@ func (e *Evaluator) evaluateNode(node *ASTNode, context D, result *EvalResult) e case NodeArray: return ErrInvalidNode // Arrays are not directly evaluatable + case NodeWhereCount: + return e.evaluateWhereCount(node, context, result) + default: return ErrInvalidNode } @@ -219,7 +222,8 @@ func (e *Evaluator) evaluateUnaryOp(node *ASTNode, context D, result *EvalResult ALL, NONE, EQUALS, - NOT_EQUALS: + NOT_EQUALS, + WHERE: return ErrInvalidOperator // These are not unary operators default: return ErrInvalidOperator @@ -248,7 +252,8 @@ func (e *Evaluator) evaluateBinaryOp(node *ASTNode, context D, result *EvalResul DOT, COMMA, PR, - NOT: + NOT, + WHERE: result.IsValid = false return ErrInvalidOperator // These are not binary operators default: @@ -280,7 +285,7 @@ func (e *Evaluator) evaluatePresenceOperator(node *ASTNode, context D, result *E return e.checkIdentifierPresence(node, context, result) case NodeProperty: return e.checkPropertyPresence(node, context, result) - case NodeBinaryOp, NodeUnaryOp, NodeLiteral, NodeArray: + case NodeBinaryOp, NodeUnaryOp, NodeLiteral, NodeArray, NodeWhereCount: return ErrInvalidOperator // Invalid node types for PR operator default: return ErrInvalidOperator @@ -333,6 +338,67 @@ func (e *Evaluator) setPresenceResult(result *EvalResult, exists bool) { result.IsValid = true } +// evaluateWhereCount evaluates " where ().length". +// It counts elements in the source array where the predicate evaluates to true. +// Zero allocation: only uses stack variables, no new slice allocation. +func (e *Evaluator) evaluateWhereCount(node *ASTNode, context D, result *EvalResult) error { + result.IsValid = true + result.Type = ValueNumber + result.IsInt = true + result.Num = 0 + result.IntValue = 0 + result.Bool = false + result.Str = "" + result.Arr = nil + + // Evaluate source to get the array + var sourceResult EvalResult + if err := e.evaluateNode(node.Left, context, &sourceResult); err != nil { + // Source evaluation error: surface as count 0 + return nil //nolint:nilerr // intentionally swallowing source errors + } + + // If source is not valid or not an array, return count 0 + if !sourceResult.IsValid || sourceResult.Type != ValueArray { + return nil + } + + // Get the array from OriginalValue + arr, ok := sourceResult.OriginalValue.([]any) + if !ok { + return nil + } + + // Count elements that satisfy predicate + predicate := node.Children[0] + count := int64(0) + + var predicateResult EvalResult + + for _, elem := range arr { + elemMap, isMap := elem.(map[string]any) + if !isMap { + continue + } + + predicateResult.IsValid = false + predicateResult.OriginalValue = nil + + if predErr := e.evaluateNode(predicate, elemMap, &predicateResult); predErr != nil { + continue // Skip element if predicate evaluation fails + } + + if e.toBool(&predicateResult) { + count++ + } + } + + result.Num = float64(count) + result.IntValue = count + + return nil +} + // evaluateLogicalAnd handles the AND logical operator with short-circuit evaluation. func (e *Evaluator) evaluateLogicalAnd(node *ASTNode, context D, result *EvalResult) error { var leftResult EvalResult @@ -627,7 +693,8 @@ func (e *Evaluator) performComparison( NOT, ANY, ALL, - NONE: + NONE, + WHERE: result.IsValid = false return ErrInvalidOperator default: diff --git a/example_test.go b/example_test.go index 24aa6ae..540c85f 100644 --- a/example_test.go +++ b/example_test.go @@ -468,6 +468,46 @@ func Example_quantifierAllNone() { // selections none (status eq "SELECTION_STATUS_IN_PROGRESS") -> false } +// Example_whereFilter demonstrates the where operator for filtering arrays. +// where (predicate).length counts elements that match the predicate. +// where (predicate) any/all/none (subexpr) applies a quantifier on the filtered subset. +func Example_whereFilter() { + engine := rule.NewEngine() + + context := rule.D{ + "selections": []any{ + rule.D{"odd": 1.5, "is_live": true}, + rule.D{"odd": 2.0, "is_live": false}, + rule.D{"odd": 1.4, "is_live": true}, + rule.D{"odd": 1.8, "is_live": true}, + rule.D{"odd": 1.0, "is_live": false}, + }, + } + + rules := []string{ + // Count selections with odd >= 1.4 — must be at least 4 + "selections where (odd ge 1.4).length ge 4", + // Among selections with odd >= 1.4, all must be live + "selections where (odd ge 1.4) all (is_live eq true)", + // No selection with odd >= 1.4 should be non-live + "selections where (odd ge 1.4) none (is_live eq false)", + } + + 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 where (odd ge 1.4).length ge 4 -> true + // selections where (odd ge 1.4) all (is_live eq true) -> false + // selections where (odd ge 1.4) none (is_live eq false) -> false +} + // Example_bettingRules demonstrates real-world betting validation rules. func Example_bettingRules() { engine := rule.NewEngine() diff --git a/lexer_test.go b/lexer_test.go index afcd633..15144dc 100644 --- a/lexer_test.go +++ b/lexer_test.go @@ -51,6 +51,13 @@ func TestLexerTokenization(t *testing.T) { {"x and y", []TokenType{IDENTIFIER, AND, IDENTIFIER, EOF}}, {"x or y", []TokenType{IDENTIFIER, OR, IDENTIFIER, EOF}}, {"not x", []TokenType{NOT, IDENTIFIER, EOF}}, + { + "items where (x eq 1).length", + []TokenType{ + IDENTIFIER, WHERE, PAREN_OPEN, IDENTIFIER, EQ, NUMBER, PAREN_CLOSE, + DOT, IDENTIFIER, EOF, + }, + }, } for _, test := range tests { diff --git a/parser.go b/parser.go index d55491a..a3e16f5 100644 --- a/parser.go +++ b/parser.go @@ -141,12 +141,123 @@ func (p *Parser) parseNotExpression() (*ASTNode, error) { return p.parseComparisonExpression() } -func (p *Parser) parseComparisonExpression() (*ASTNode, error) { +func (p *Parser) parseWhereExpression() (*ASTNode, error) { left, err := p.parsePrimaryExpression() if err != nil { return nil, err } + // where can only follow an identifier/property (array source) + if p.curToken.Type != WHERE { + return left, nil + } + + if left.Type != NodeIdentifier && left.Type != NodeProperty { + return nil, ErrWhereRequiresParens + } + + p.advance() + + if p.curToken.Type != PAREN_OPEN { + return nil, ErrWhereRequiresParens + } + + p.advance() + + if p.curToken.Type == PAREN_CLOSE { + return nil, ErrEmptyWherePredicate + } + + predicate, err := p.parseExpression() + if err != nil { + return nil, err + } + + if expectErr := p.expect(PAREN_CLOSE); expectErr != nil { + return nil, expectErr + } + + result := NewWhereCountNode(left, predicate) + + switch p.curToken.Type { //nolint:exhaustive // only DOT and quantifiers are valid here + case DOT: + return p.parseWhereWithLength(result) + + case ANY, ALL, NONE: + return p.parseWhereWithQuantifier(result, p.curToken.Type) + + default: + return nil, ErrWhereRequiresLengthOrQuantifier + } +} + +func (p *Parser) parseWhereWithLength(whereNode *ASTNode) (*ASTNode, error) { + // Consume the DOT + if err := p.expect(DOT); err != nil { + return nil, err + } + + // Must be an identifier with value "length" + if p.curToken.Type != IDENTIFIER || p.curToken.Value != lengthProperty { + return nil, ErrWhereRequiresLength + } + + p.advance() + + return whereNode, nil +} + +func (p *Parser) parseWhereWithQuantifier(whereNode *ASTNode, quantifier TokenType) (*ASTNode, error) { + // Consume the quantifier token (any/all/none) + 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 + } + + // Rewrite " where () ()" as + // " ()" without materializing the filtered array. + // - any: source any (predicate and subExpr) + // - all: source all ((not predicate) or subExpr) // vacuous truth for filtered out + // - none: source none (predicate and subExpr) + source := whereNode.Left + predicate := whereNode.Children[0] + + var composed *ASTNode + + switch quantifier { //nolint:exhaustive // only quantifier tokens are valid here + case ANY, NONE: + composed = NewBinaryOpNode(AND, predicate, subExpr) + case ALL: + composed = NewBinaryOpNode(OR, NewUnaryOpNode(NOT, predicate), subExpr) + default: + return nil, ErrInvalidOperator + } + + return NewBinaryOpNode(quantifier, source, composed), nil +} + +func (p *Parser) parseComparisonExpression() (*ASTNode, error) { + left, err := p.parseWhereExpression() + if err != nil { + return nil, err + } + if p.isComparisonOperator(p.curToken.Type) { op := p.curToken.Type p.advance() @@ -258,7 +369,8 @@ func (p *Parser) parsePrimaryExpression() (*ASTNode, error) { ALL, NONE, EQUALS, - NOT_EQUALS: + NOT_EQUALS, + WHERE: return nil, fmt.Errorf("unexpected token %s at position %d", p.curToken.Type, p.current) default: @@ -347,7 +459,8 @@ func (p *Parser) parseArray() (*ASTNode, error) { ALL, NONE, EQUALS, - NOT_EQUALS: + NOT_EQUALS, + WHERE: return nil, fmt.Errorf("unexpected token in array: %s", p.curToken.Type) default: return nil, fmt.Errorf("unexpected token in array: %s", p.curToken.Type) @@ -445,7 +558,8 @@ func (p *Parser) isComparisonOperator(tokenType TokenType) bool { NOT, ANY, ALL, - NONE: + NONE, + WHERE: return false default: return false @@ -458,7 +572,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, ANY, ALL, NONE, EQUALS, NOT_EQUALS: + DQ, DN, BE, BQ, AF, AQ, DL, DG, AND, OR, NOT, ANY, ALL, NONE, EQUALS, NOT_EQUALS, WHERE: return false default: return false diff --git a/parser_test.go b/parser_test.go index 268b194..8151b01 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1,6 +1,7 @@ package rule import ( + "errors" "testing" ) @@ -338,3 +339,124 @@ func TestParserPresenceOperator(t *testing.T) { t.Error("Expected PR operator") } } + +// Test parser with where + length. +func TestParserWhereLength(t *testing.T) { + ast, err := ParseRule("selections where (odd ge 1.4).length ge 4") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Top must be a binary op (ge) + if ast.Type != NodeBinaryOp || ast.Operator != GE { + t.Fatalf("Expected top-level GE binary op, got %v / %v", ast.Type, ast.Operator) + } + + // Left must be a NodeWhereCount + if ast.Left.Type != NodeWhereCount { + t.Errorf("Expected left to be NodeWhereCount, got %v", ast.Left.Type) + } + + // Source of where must be identifier "selections" + if ast.Left.Left.Type != NodeIdentifier || ast.Left.Left.Value.StrValue != "selections" { + t.Error("Expected where source to be identifier 'selections'") + } + + // Predicate must be a binary op + if len(ast.Left.Children) != 1 || ast.Left.Children[0].Type != NodeBinaryOp { + t.Error("Expected where predicate to be a binary op") + } +} + +// Test parser with where + any (rewriting). +func TestParserWhereWithAny(t *testing.T) { + ast, err := ParseRule(`selections where (odd ge 1.4) any (provider eq "X")`) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Top must be ANY quantifier + if ast.Type != NodeBinaryOp || ast.Operator != ANY { + t.Fatalf("Expected top-level ANY, got %v / %v", ast.Type, ast.Operator) + } + + // Left must be the original source identifier + if ast.Left.Type != NodeIdentifier || ast.Left.Value.StrValue != "selections" { + t.Errorf("Expected source to be identifier 'selections', got %v", ast.Left.Type) + } + + // Right must be a composed AND of (predicate, subExpr) + if ast.Right.Type != NodeBinaryOp || ast.Right.Operator != AND { + t.Error("Expected right to be a composed AND expression") + } +} + +// Test parser with where + all (rewriting). +func TestParserWhereWithAll(t *testing.T) { + ast, err := ParseRule(`selections where (odd ge 1.4) all (is_live eq true)`) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Top must be ALL quantifier + if ast.Type != NodeBinaryOp || ast.Operator != ALL { + t.Fatalf("Expected top-level ALL, got %v / %v", ast.Type, ast.Operator) + } + + // Right must be a composed OR of (NOT predicate, subExpr) + if ast.Right.Type != NodeBinaryOp || ast.Right.Operator != OR { + t.Error("Expected right to be a composed OR expression for ALL rewrite") + } + + if ast.Right.Left.Type != NodeUnaryOp || ast.Right.Left.Operator != NOT { + t.Error("Expected first part of OR to be NOT (predicate)") + } +} + +// Test parser with where + none (rewriting). +func TestParserWhereWithNone(t *testing.T) { + ast, err := ParseRule(`selections where (odd ge 1.4) none (is_fraud eq true)`) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Top must be NONE quantifier + if ast.Type != NodeBinaryOp || ast.Operator != NONE { + t.Fatalf("Expected top-level NONE, got %v / %v", ast.Type, ast.Operator) + } + + // Left must be the original source identifier + if ast.Left.Type != NodeIdentifier || ast.Left.Value.StrValue != "selections" { + t.Errorf("Expected source to be identifier 'selections', got %v", ast.Left.Type) + } + + // Right must be a composed AND of (predicate, subExpr) — same shape as ANY rewrite. + if ast.Right.Type != NodeBinaryOp || ast.Right.Operator != AND { + t.Error("Expected right to be a composed AND expression for NONE rewrite") + } +} + +// Test parser where errors. +func TestParserWhereErrors(t *testing.T) { + tests := []struct { + input string + wantErr error + }{ + {"selections where odd ge 1.4", ErrWhereRequiresParens}, + {"selections where ()", ErrEmptyWherePredicate}, + {"selections where (odd ge 1.4)", ErrWhereRequiresLengthOrQuantifier}, + {"selections where (odd ge 1.4).foo", ErrWhereRequiresLength}, + } + + for _, tt := range tests { + _, err := ParseRule(tt.input) + if err == nil { + t.Errorf("Input %q: expected error %v, got nil", tt.input, tt.wantErr) + continue + } + + if !errors.Is(err, tt.wantErr) { + t.Errorf("Input %q: expected error %v, got %v", tt.input, tt.wantErr, err) + } + } +} diff --git a/test/list_fixtures.go b/test/list_fixtures.go index 117970c..0bc9e77 100644 --- a/test/list_fixtures.go +++ b/test/list_fixtures.go @@ -1285,3 +1285,244 @@ var NestedListTests = []Case{ true, }, } + +/* ---------- Where filter operator ---------- */ + +//nolint:gochecknoglobals // Test data +var WhereTests = []Case{ + // Basic where + length + { + "where_length_ge_match", + "selections where (odd ge 1.4).length ge 4", + rule.D{ + "selections": []any{ + rule.D{"odd": 1.5}, + rule.D{"odd": 2.0}, + rule.D{"odd": 1.4}, + rule.D{"odd": 1.8}, + rule.D{"odd": 1.0}, + }, + }, + true, + }, + { + "where_length_ge_no_match", + "selections where (odd ge 1.4).length ge 4", + rule.D{ + "selections": []any{ + rule.D{"odd": 1.5}, + rule.D{"odd": 2.0}, + rule.D{"odd": 1.0}, + }, + }, + false, + }, + { + "where_length_eq_zero", + "selections where (odd ge 5.0).length eq 0", + rule.D{ + "selections": []any{ + rule.D{"odd": 1.5}, + rule.D{"odd": 2.0}, + }, + }, + true, + }, + { + "where_length_gt_zero", + "selections where (odd ge 1.0).length gt 0", + rule.D{ + "selections": []any{ + rule.D{"odd": 1.5}, + rule.D{"odd": 2.0}, + }, + }, + true, + }, + // Where + nested source + { + "where_nested_source", + "data.selections where (odd ge 1.4).length ge 2", + rule.D{ + "data": rule.D{ + "selections": []any{ + rule.D{"odd": 1.5}, + rule.D{"odd": 2.0}, + rule.D{"odd": 1.0}, + }, + }, + }, + true, + }, + // Where with compound predicate + { + "where_compound_and", + `selections where (odd ge 1.4 and is_live eq true).length ge 2`, + rule.D{ + "selections": []any{ + rule.D{"odd": 1.5, "is_live": true}, + rule.D{"odd": 2.0, "is_live": false}, + rule.D{"odd": 1.8, "is_live": true}, + }, + }, + true, + }, + { + "where_compound_or", + `selections where (status eq "live" or status eq "active").length ge 1`, + rule.D{ + "selections": []any{ + rule.D{"status": "cancelled"}, + rule.D{"status": "live"}, + }, + }, + true, + }, + { + "where_with_not_predicate", + `selections where (not (status eq "cancelled")).length ge 1`, + rule.D{ + "selections": []any{ + rule.D{"status": "cancelled"}, + rule.D{"status": "active"}, + }, + }, + true, + }, + // Where + quantifier + { + "where_with_any_match", + `selections where (odd ge 1.4) any (provider eq "X")`, + rule.D{ + "selections": []any{ + rule.D{"odd": 1.5, "provider": "Y"}, + rule.D{"odd": 2.0, "provider": "X"}, + rule.D{"odd": 1.0, "provider": "X"}, + }, + }, + true, + }, + { + "where_with_any_no_match", + `selections where (odd ge 1.4) any (provider eq "Z")`, + rule.D{ + "selections": []any{ + rule.D{"odd": 1.5, "provider": "X"}, + rule.D{"odd": 2.0, "provider": "Y"}, + }, + }, + false, + }, + { + "where_with_all_match", + `selections where (odd ge 1.4) all (is_live eq true)`, + rule.D{ + "selections": []any{ + rule.D{"odd": 1.5, "is_live": true}, + rule.D{"odd": 2.0, "is_live": true}, + rule.D{"odd": 1.0, "is_live": false}, + }, + }, + true, + }, + { + "where_with_all_no_match", + `selections where (odd ge 1.4) all (is_live eq true)`, + rule.D{ + "selections": []any{ + rule.D{"odd": 1.5, "is_live": true}, + rule.D{"odd": 2.0, "is_live": false}, + }, + }, + false, + }, + { + "where_with_none_match", + `selections where (odd ge 1.4) none (is_fraud eq true)`, + rule.D{ + "selections": []any{ + rule.D{"odd": 1.5, "is_fraud": false}, + rule.D{"odd": 2.0, "is_fraud": false}, + rule.D{"odd": 1.0, "is_fraud": true}, + }, + }, + true, + }, + // Where inside quantifier + { + "where_inside_quantifier", + `groups any (items where (active eq true).length gt 2)`, + rule.D{ + "groups": []any{ + rule.D{ + "items": []any{ + rule.D{"active": true}, + rule.D{"active": false}, + }, + }, + rule.D{ + "items": []any{ + rule.D{"active": true}, + rule.D{"active": true}, + rule.D{"active": true}, + }, + }, + }, + }, + true, + }, + // Combination with outer conditions + { + "where_combined_with_and", + "selections where (odd ge 1.4).length ge 2 and is_freebet eq false", + rule.D{ + "is_freebet": false, + "selections": []any{ + rule.D{"odd": 1.5}, + rule.D{"odd": 2.0}, + }, + }, + true, + }, + { + "where_combined_with_not", + "not (selections where (odd ge 5.0).length gt 0)", + rule.D{ + "selections": []any{ + rule.D{"odd": 1.5}, + rule.D{"odd": 2.0}, + }, + }, + true, + }, + // Edge cases + { + "where_missing_array", + "missing where (odd ge 1.4).length eq 0", + rule.D{}, + true, + }, + { + "where_non_array", + "name where (odd ge 1.4).length eq 0", + rule.D{"name": "not_an_array"}, + true, + }, + { + "where_empty_array", + "selections where (odd ge 1.4).length eq 0", + rule.D{"selections": []any{}}, + true, + }, + { + "where_element_missing_field", + "selections where (odd ge 1.4).length eq 1", + rule.D{ + "selections": []any{ + rule.D{"odd": 1.5}, + rule.D{"name": "no_odd"}, + }, + }, + true, + }, +} diff --git a/test/rule_engine_test.go b/test/rule_engine_test.go index cea4603..713725b 100644 --- a/test/rule_engine_test.go +++ b/test/rule_engine_test.go @@ -66,6 +66,9 @@ func TestRulesRound1(t *testing.T) { QuantifierNoneTests, ListRealWorldTests, NestedListTests, + + // Where filter operator tests + WhereTests, } for _, group := range all { diff --git a/token.go b/token.go index c672a49..c0e592d 100644 --- a/token.go +++ b/token.go @@ -52,6 +52,9 @@ const ( // EQUALS is an alias for the equality operator. EQUALS // == NOT_EQUALS //nolint:revive,staticcheck // Token constants use ALL_CAPS convention + + // WHERE represents the list filtering operator. + WHERE ) type Token struct { @@ -90,6 +93,7 @@ var keywordMap = map[string]TokenType{ "any": ANY, "all": ALL, "none": NONE, + "where": WHERE, trueString: BOOLEAN, "false": BOOLEAN, } @@ -133,6 +137,7 @@ var tokenStringMap = map[TokenType]string{ ANY: "any", ALL: "all", NONE: "none", + WHERE: "where", EQUALS: "==", NOT_EQUALS: "!=", } diff --git a/validator.go b/validator.go index de93d44..561b492 100644 --- a/validator.go +++ b/validator.go @@ -36,11 +36,28 @@ func ValidateAST(node *ASTNode) error { case NodeLiteral, NodeIdentifier, NodeProperty, NodeArray: // These are terminal nodes, no further validation needed return nil + case NodeWhereCount: + return validateWhereCountNode(node) } return nil } +// validateWhereCountNode validates a NodeWhereCount and recursively validates +// its source and predicate subtrees. +func validateWhereCountNode(node *ASTNode) error { + if err := validateWhereCountOperation(node); err != nil { + return err + } + + if err := ValidateAST(node.Left); err != nil { + return err + } + + // validateWhereCountOperation guarantees the predicate child exists. + return ValidateAST(node.Children[0]) +} + func validateBinaryOperation(node *ASTNode) error { if node.Left == nil || node.Right == nil { return errors.New("binary operation missing operands") @@ -55,7 +72,7 @@ func validateBinaryOperation(node *ASTNode) error { 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: + DQ, DN, BE, BQ, AF, AQ, DL, DG, AND, OR, NOT, EQUALS, NOT_EQUALS, WHERE: // Other operators don't need special validation return nil } @@ -74,7 +91,7 @@ func validateUnaryOperation(node *ASTNode) error { 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, ANY, ALL, NONE, EQUALS, NOT_EQUALS: + AND, OR, NOT, ANY, ALL, NONE, EQUALS, NOT_EQUALS, WHERE: // Other operators don't apply to unary operations return nil } @@ -94,7 +111,7 @@ func validateInOperation(node *ASTNode) error { case NodeIdentifier, NodeProperty: // Allow identifiers/properties as they might evaluate to arrays at runtime return nil - case NodeBinaryOp, NodeUnaryOp, NodeArray: + case NodeBinaryOp, NodeUnaryOp, NodeArray, NodeWhereCount: return ErrInvalidInOperand } } @@ -144,3 +161,23 @@ func validatePresenceOperation(node *ASTNode) error { return nil } + +func validateWhereCountOperation(node *ASTNode) error { + if node.Left == nil { + return ErrWhereRequiresParens + } + + // Source must be identifier or property + if node.Left.Type != NodeIdentifier && node.Left.Type != NodeProperty { + return ErrWhereRequiresParens + } + + // Predicate (first child) must exist + if len(node.Children) == 0 || node.Children[0] == nil { + return ErrEmptyWherePredicate + } + + // Predicate validation happens recursively by the caller + + return nil +}