Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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`**: `<source> where (<predicate>).length` counts elements matching predicate; `<source> where (<predicate>) any|all|none (<sub>)` 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
Expand Down
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

- `<source> where (<predicate>).length <op> <number>` — counts elements where
predicate is true. Zero-allocation: the engine never materializes the
filtered array.
- `<source> where (<predicate>) any|all|none (<sub>)` — 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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const (
NodeLiteral
NodeArray
NodeProperty
NodeWhereCount
)

type ASTNode struct {
Expand Down Expand Up @@ -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
}
Expand Down
72 changes: 72 additions & 0 deletions benchmark_optimized_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
13 changes: 13 additions & 0 deletions concept.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
)
75 changes: 71 additions & 4 deletions evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -333,6 +338,67 @@ func (e *Evaluator) setPresenceResult(result *EvalResult, exists bool) {
result.IsValid = true
}

// evaluateWhereCount evaluates "<source> where (<predicate>).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
Expand Down Expand Up @@ -627,7 +693,8 @@ func (e *Evaluator) performComparison(
NOT,
ANY,
ALL,
NONE:
NONE,
WHERE:
result.IsValid = false
return ErrInvalidOperator
default:
Expand Down
Loading