diff --git a/CLAUDE.md b/CLAUDE.md index 1de5bf4..0626124 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,7 @@ The entire specification is defined through comprehensive test cases in `test/fi - **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`) +- **Lenient Mode**: opt-in neutral semantics for missing attributes via `NewEngineWithOptions(WithLenientMode())` (proprietary extension; not part of `nikunjy/rules`) ### Rule Syntax Examples @@ -120,6 +121,20 @@ All functionality is validated through the comprehensive test suite in `test/fix - **String Comparisons**: Lexicographic ordering for string relational operations - **Membership Operations**: Use strict type checking (no cross-type matching) - **Large Integer Support**: Preserve precision for integers > 2^53 using dual storage +- **Missing Attributes**: In strict (default) mode, any comparison involving a + missing attribute returns `false`. In lenient mode + (`NewEngineWithOptions(WithLenientMode())`), a comparison predicate + involving a missing attribute returns `true` (neutral) — the identity + element for `and`-chains, the dominant pattern in betting lifecycle rules, + so a missing optional field drops out of the conjunction instead of + failing it. Neutrality is uniform across all comparison operators + (`eq`, `ne`, `lt`, `gt`, `le`, `ge`, `co`, `sw`, `ew`, `in`, `not in`, + datetime). Presence (`pr`), logical (`and`/`or`/`not`) and quantifier + operators are unaffected. Neutrality is only neutral inside an `and`-chain: + `not (x eq 10)` -> `false`, `or` short-circuits to `true`, `none` sees + `true`. Opt-in only; `NewEngine()` is strict. Only missing attributes + (key absent from the map) trigger the neutral path; explicit `nil` is + considered present and dispatched to the normal comparison path. ### List Operations - **Array Length**: Access `.length` on array properties (e.g., `items.length gt 3`) diff --git a/README.md b/README.md index 34529a8..fced879 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,11 @@ A **blazingly fast**, **zero-allocation** rule engine for Go that evaluates logi 2. [📚 API](#-api) 3. [🎯 Context](#-context) 4. [🔤 Rule Language](#-rule-language) -5. [💾 Query Caching](#-query-caching) -6. [⚡ Benchmarks](#-benchmarks) -7. [🤝 Contributing](#-contributing) -8. [📄 License](#-license) +5. [🟢 Lenient Mode (Neutral Semantics)](#-lenient-mode-neutral-semantics) +6. [💾 Query Caching](#-query-caching) +7. [⚡ Benchmarks](#-benchmarks) +8. [🤝 Contributing](#-contributing) +9. [📄 License](#-license) --- @@ -81,6 +82,19 @@ Creates a new rule engine instance. Each engine maintains its own query cache fo engine := rule.NewEngine() ``` +#### `NewEngineWithOptions(opts ...Option) *Engine` +Creates a new rule engine configured with functional options. `NewEngine()` is equivalent to `NewEngineWithOptions()` with no options (strict mode). + +```go +// Strict (default, same as NewEngine()): +engine := rule.NewEngineWithOptions() + +// Lenient (neutral semantics) mode: +engine := rule.NewEngineWithOptions(rule.WithLenientMode()) +``` + +See [Lenient Mode (Neutral Semantics)](#-lenient-mode-neutral-semantics) for details. + #### `Evaluate(query string, context rule.D) (bool, error)` Evaluates a rule expression against the provided context. Returns `true`/`false` and any parsing/evaluation errors. @@ -610,6 +624,87 @@ engine.Evaluate(`selections where (odd ge 1.4) all (is_live eq true)`, context) --- +## 🟢 Lenient Mode (Neutral Semantics) + +By default the engine runs in **strict mode**: when an attribute is missing +from the context, *every* comparison involving it returns `false`. This is +fast and predictable but unsuitable for rule chains where a missing field +should mean "no constraint" rather than "the rule fails". + +**Lenient mode** is an opt-in alternative where a comparison predicate +involving a missing attribute returns **`true`** (neutral). This is the +identity element for `and`-chains — the dominant pattern in betting +lifecycle rules — so a missing optional field simply drops out of the +conjunction instead of failing it. + +| Operator | missing operand (either side) | +|------------------------|--------------------------------| +| `eq` / `==` | `true` | +| `ne` / `!=` | `true` | +| `lt`, `gt`, `le`, `ge` | `true` | +| `co`, `sw`, `ew` | `true` | +| `in` | `true` | +| `not in` | `true` | +| datetime (`dq`…`dg`) | `true` | +| `pr` | unchanged (`false` if missing / `true` if present, incl. `nil`) | + +`and`, `or`, `not` and the list quantifiers (`any`/`all`/`none`) are +**unaffected** by lenient mode — they keep their normal truthiness +behavior, which leads to intentional composition consequences: + +- `not (x eq 10)` with `x` absent → `not true` → `false` +- `x eq 10 or y eq 20` with `x` absent → `or` short-circuits to `true` +- `selections none (r gt 0)` per element where `r` is absent → `none` sees + `true` → returns `false` + +These are deliberate: neutrality is neutral only inside an `and`-chain. +For negation/quantifier-heavy rules, prefer strict mode or keep the +relevant fields present. + +### Enabling lenient mode + +```go +engine := rule.NewEngineWithOptions(rule.WithLenientMode()) +``` + +`NewEngine()` and `NewEngineWithOptions()` with no options keep the original +strict behavior, so existing code is unchanged. + +### Example + +```go +strict := rule.NewEngine() // default +lenient := rule.NewEngineWithOptions(rule.WithLenientMode()) + +ctx := rule.D{} // "age" is absent + +strict.Evaluate(`age eq 18`, ctx) // -> false +lenient.Evaluate(`age eq 18`, ctx) // -> true (neutral) +lenient.Evaluate(`age ne 18`, ctx) // -> true (neutral) +lenient.Evaluate(`a eq b`, ctx) // -> true (both missing) +lenient.Evaluate(`role not in ["admin","user"]`, ctx) // -> true + +// Real constraints still apply: a present field that is false wins. +lenient.Evaluate(`age eq 18 and status eq "settled"`, rule.D{"status": "pending"}) +// -> false (age eq 18 is neutral/true, but status eq "settled" is false) +``` + +### Notes + +- Only **missing attributes** (key absent from the context map) trigger the + neutral path. An explicit `nil` value is still considered present + (`IsValid=true`) and is dispatched to the normal comparison path; it does + not go through the lenient branch. +- Lenient mode has **no allocation cost** (a single bool flag on the + evaluator) and does not affect strict-mode performance. +- Lenient mode is a proprietary extension, not part of `nikunjy/rules`. +- The neutral behavior is uniform across all comparison operators. A + per-operator SQL-ish variant (`null eq value -> false`, `null ne value -> + true`, …) is intentionally not provided; it can be added as a separate + option later if needed, without changing this mode. + +--- + ## 💾 Query Caching The rule engine is smart about performance! 🧠 Here's how caching works: @@ -832,7 +927,7 @@ This section provides a comprehensive compatibility analysis between NSXBet/rule |--------|-------|-------| | **Core Functionality Compatibility** | **77.3%** (17/22 scenarios) | Identical behavior for standard use cases | | **Migration Code Compatibility** | **90%+** | Percentage of existing code that works without changes | -| **Enhanced Features** | **6 major extensions** | New capabilities not available in nikunjy/rules | +| **Enhanced Features** | **7 major extensions** | New capabilities not available in nikunjy/rules | | **Breaking Changes** | **1 significant** | Unquoted strings not supported | **Bottom Line**: NSXBet/rule is highly compatible for standard use cases, provides significant performance improvements, and adds powerful new features while maintaining the same API structure. @@ -869,6 +964,7 @@ This section provides a comprehensive compatibility analysis between NSXBet/rule | **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`.** | +| **Lenient Mode** | Opt-in neutral semantics: missing-attribute comparisons return `true` | `NewEngineWithOptions(rule.WithLenientMode())` | `and`-chains where an optional field should impose no constraint | | **rule.D Type Alias** | Cleaner syntax | `rule.D{"key": "value"}` | Developer experience | ### 🔧 Migration Assessment diff --git a/engine.go b/engine.go index 57978d7..923d9d3 100644 --- a/engine.go +++ b/engine.go @@ -25,6 +25,50 @@ func NewEngine() *Engine { } } +// Option configures an Engine created with NewEngineWithOptions. +type Option func(*Engine) + +// WithLenientMode enables lenient (neutral) evaluation semantics. +// +// In lenient mode, a comparison predicate involving a missing attribute +// returns true (neutral), imposing no constraint. This is the identity +// element for AND-chains (the dominant pattern in betting lifecycle rules), +// so a missing optional field drops out of the conjunction instead of +// failing it: +// +// x eq 10 (x absent) -> true x ne 10 (x absent) -> true +// x lt 10 (x absent) -> true x in [...] (x absent) -> true +// x co "a" (x absent) -> true x dl 30 (x absent) -> true +// +// Composition is intentional: neutral only inside an AND-chain. Under +// negation or quantifiers neutrality becomes decisive, e.g. +// `not (x eq 10)` -> false and `selections none (r gt 0)` sees true per +// element where r is absent and returns false. For such rules prefer strict +// mode (the default). +// +// Presence (pr), logical (and/or/not) and quantifier operators are +// unaffected. The default (strict) mode is unchanged. Opt-in only. +func WithLenientMode() Option { + return func(e *Engine) { + e.evaluator.lenient = true + } +} + +// NewEngineWithOptions creates a new rule engine configured with the given +// options. NewEngine() is equivalent to NewEngineWithOptions() with no +// options (strict mode). +func NewEngineWithOptions(opts ...Option) *Engine { + e := NewEngine() + + for _, opt := range opts { + if opt != nil { + opt(e) + } + } + + return e +} + func (e *Engine) AddQuery(rule string) error { if _, exists := e.compiledRules.Load(rule); exists { return nil // Already compiled diff --git a/evaluator.go b/evaluator.go index f9852e1..69b78ed 100644 --- a/evaluator.go +++ b/evaluator.go @@ -23,7 +23,12 @@ type EvalResult struct { } // Evaluator is an optimized evaluator that avoids allocations during evaluation. -type Evaluator struct{} +// +// When lenient is true, the evaluator applies SQL-ish null-aware semantics +// for comparisons involving missing attributes (see WithLenientMode). +type Evaluator struct { + lenient bool +} func NewEvaluator() *Evaluator { return &Evaluator{} @@ -600,7 +605,19 @@ func (e *Evaluator) evaluateComparisonOperator(node *ASTNode, context D, result // If either operand is invalid (missing attribute), comparison is false if !leftResult.IsValid || !rightResult.IsValid { - result.Bool = false + if e.lenient { + // Neutral semantics: a comparison predicate over a missing attribute + // returns true, imposing no constraint. This is the identity element + // for AND-chains (the dominant pattern in betting lifecycle rules). + // Neutrality only holds inside an AND-chain: under `not`, `or`, or + // list quantifiers it becomes decisive (e.g. `not (x eq 10)` -> false, + // `or` short-circuits to true, `none` sees true per element). For + // those rules prefer strict mode. + result.Bool = true + } else { + result.Bool = false + } + return nil } diff --git a/example_test.go b/example_test.go index 540c85f..64a2f3d 100644 --- a/example_test.go +++ b/example_test.go @@ -701,3 +701,49 @@ func Example_ecommerce() { // Free shipping eligible: true // Weekend discount eligible: true } + +// Example_lenientMode demonstrates the opt-in lenient (neutral) evaluation +// mode. A comparison predicate involving a missing attribute returns true +// (neutral), imposing no constraint — the identity element for AND-chains. +func Example_lenientMode() { + // Default engine: strict mode (missing attribute -> any comparison is false). + strict := rule.NewEngine() + + // Opt-in lenient mode via functional options. + lenient := rule.NewEngineWithOptions(rule.WithLenientMode()) + + // Context with no "age" key. + ctx := rule.D{} + + // In strict mode, "age eq 18" is false because age is missing. + rStrict, _ := strict.Evaluate("age eq 18", ctx) + + // In lenient mode, a missing attribute makes the comparison neutral (true). + rLenientEq, _ := lenient.Evaluate("age eq 18", ctx) + + // Neutrality holds for every comparison operator, so "age ne 18" is also true. + rLenientNe, _ := lenient.Evaluate("age ne 18", ctx) + + // And for both-sides-missing: "a eq b" is neutral -> true. + rLenientBothMissing, _ := lenient.Evaluate("a eq b", ctx) + + // And for membership: "role not in [...]" is neutral -> true. + rLenientNotIn, _ := lenient.Evaluate(`role not in ["admin","user"]`, ctx) + + // AND-chains keep real constraints: a present field that is false still wins. + rAndChain, _ := lenient.Evaluate(`age eq 18 and status eq "settled"`, rule.D{"status": "pending"}) + + fmt.Printf("strict age eq 18: %t\n", rStrict) + fmt.Printf("lenient age eq 18: %t\n", rLenientEq) + fmt.Printf("lenient age ne 18: %t\n", rLenientNe) + fmt.Printf("lenient a eq b (both missing): %t\n", rLenientBothMissing) + fmt.Printf("lenient role not in [...]: %t\n", rLenientNotIn) + fmt.Printf("lenient age eq 18 and status eq settled (status=pending): %t", rAndChain) + // Output: + // strict age eq 18: false + // lenient age eq 18: true + // lenient age ne 18: true + // lenient a eq b (both missing): true + // lenient role not in [...]: true + // lenient age eq 18 and status eq settled (status=pending): false +} diff --git a/test/lenient_fixtures.go b/test/lenient_fixtures.go new file mode 100644 index 0000000..88c71b4 --- /dev/null +++ b/test/lenient_fixtures.go @@ -0,0 +1,175 @@ +package test + +import "github.com/NSXBet/rule" + +/* ---------- Lenient Mode (neutral semantics) ---------- + * + * These fixtures run under NewEngineWithOptions(WithLenientMode()). + * "missing" means the key is absent from the context map. + * + * Neutral semantics: a comparison predicate involving a missing attribute + * returns true, so it imposes no constraint. This is the identity element + * for AND-chains (the dominant pattern in betting lifecycle rules). + * + * x eq 10 (x absent) -> true x lt 10 (x absent) -> true + * x ne 10 (x absent) -> true x co "a" (x absent) -> true + * x in [...] (x absent) -> true x not in [...] -> true + * datetime ops with missing operand -> true + * x eq y (both absent) -> true + * + * Composition consequences (documented, intentional): + * not (x eq 10) -> not true -> false + * x eq 10 or y eq 20 -> OR short-circuits to true + * selections none (r gt 0) -> sees true per element where r is absent -> false + * + * Presence (pr), logical (and/or/not) and quantifier operators themselves + * are unaffected by lenient mode and keep their strict behavior. + */ + +//nolint:gochecknoglobals // Test data +var LenientEqualityTests = []Case{ + // eq with missing left -> neutral true + {"lenient_eq_missing_left_value", "x eq 10", rule.D{}, true}, + {"lenient_eq_missing_left_string", `x eq "abc"`, rule.D{}, true}, + {"lenient_eq_missing_left_bool", "x eq true", rule.D{}, true}, + // eq with missing right (property on the right) -> true + {"lenient_eq_missing_right_value", "10 eq y", rule.D{}, true}, + {"lenient_eq_missing_right_string", `"abc" eq y`, rule.D{}, true}, + // eq with both missing -> true + {"lenient_eq_both_missing", "x eq y", rule.D{}, true}, + {"lenient_eq_both_missing_nested", "a.b eq c.d", rule.D{}, true}, + // eq with present values still works (regular path) + {"lenient_eq_present_true", "x eq 10", rule.D{"x": 10}, true}, + {"lenient_eq_present_false", "x eq 10", rule.D{"x": 11}, false}, + // == alias + {"lenient_equals_alias_missing", "x == y", rule.D{}, true}, + {"lenient_equals_alias_missing_vs_value", "x == 10", rule.D{}, true}, +} + +//nolint:gochecknoglobals // Test data +var LenientInequalityTests = []Case{ + // ne with missing left -> neutral true + {"lenient_ne_missing_left_value", "x ne 10", rule.D{}, true}, + {"lenient_ne_missing_left_string", `x ne "abc"`, rule.D{}, true}, + {"lenient_ne_missing_left_bool", "x ne true", rule.D{}, true}, + // ne with missing right -> true + {"lenient_ne_missing_right_value", "10 ne y", rule.D{}, true}, + // ne with both missing -> true + {"lenient_ne_both_missing", "x ne y", rule.D{}, true}, + // ne with present values still works (regular path) + {"lenient_ne_present_true", "x ne 10", rule.D{"x": 11}, true}, + {"lenient_ne_present_false", "x ne 10", rule.D{"x": 10}, false}, + // != alias + {"lenient_not_equals_alias_missing", "x != 10", rule.D{}, true}, + {"lenient_not_equals_alias_both_missing", "x != y", rule.D{}, true}, +} + +//nolint:gochecknoglobals // Test data +var LenientRelationalTests = []Case{ + // ordering ops against null -> neutral true + {"lenient_lt_missing", "x lt 10", rule.D{}, true}, + {"lenient_gt_missing", "x gt 10", rule.D{}, true}, + {"lenient_le_missing", "x le 10", rule.D{}, true}, + {"lenient_ge_missing", "x ge 10", rule.D{}, true}, + // both missing -> true + {"lenient_lt_both_missing", "x lt y", rule.D{}, true}, + {"lenient_gt_both_missing", "x gt y", rule.D{}, true}, + // missing on the right + {"lenient_lt_missing_right", "10 lt y", rule.D{}, true}, + // present still works (regular path) + {"lenient_lt_present_true", "x lt 10", rule.D{"x": 5}, true}, + {"lenient_lt_present_false", "x lt 10", rule.D{"x": 15}, false}, +} + +//nolint:gochecknoglobals // Test data +var LenientStringOpTests = []Case{ + {"lenient_co_missing", `x co "abc"`, rule.D{}, true}, + {"lenient_sw_missing", `x sw "abc"`, rule.D{}, true}, + {"lenient_ew_missing", `x ew "abc"`, rule.D{}, true}, + {"lenient_co_both_missing", `x co y`, rule.D{}, true}, + // present still works (regular path) + {"lenient_co_present_true", `x co "York"`, rule.D{"x": "New York"}, true}, + {"lenient_co_present_false", `x co "York"`, rule.D{"x": "Boston"}, false}, +} + +//nolint:gochecknoglobals // Test data +var LenientMembershipTests = []Case{ + // in with missing operand -> neutral true + {"lenient_in_missing", "x in [1,2,3]", rule.D{}, true}, + {"lenient_in_missing_string", `x in ["a","b"]`, rule.D{}, true}, + {"lenient_in_missing_right", "v in y", rule.D{"v": 2}, true}, + {"lenient_in_both_missing", "x in y", rule.D{}, true}, + // present still works (regular path) + {"lenient_in_present_true", "x in [1,2,3]", rule.D{"x": 2}, true}, + {"lenient_in_present_false", "x in [1,2,3]", rule.D{"x": 4}, false}, + // not in with missing operand -> neutral true + {"lenient_not_in_missing", "x not in [1,2,3]", rule.D{}, true}, + {"lenient_not_in_missing_string", `x not in ["a","b"]`, rule.D{}, true}, + {"lenient_not_in_missing_right", "v not in y", rule.D{"v": 2}, true}, + {"lenient_not_in_both_missing", "x not in y", rule.D{}, true}, + // present still works (regular path) + {"lenient_not_in_present_true", "x not in [1,2,3]", rule.D{"x": 4}, true}, + {"lenient_not_in_present_false", "x not in [1,2,3]", rule.D{"x": 2}, false}, +} + +//nolint:gochecknoglobals // Test data +var LenientLogicalTests = []Case{ + // and: neutral-then-false -> false (real constraint still applies) + {"lenient_and_neutral_then_false", "(x eq 10) and (y eq 5)", rule.D{"y": 9}, false}, + // and: true-then-neutral -> true + {"lenient_and_true_then_neutral", "(y eq 5) and (x eq 10)", rule.D{"y": 5}, true}, + // and: false-then-neutral (short-circuit) -> false + {"lenient_and_false_then_neutral_sc", "(y eq 5) and (x eq 10)", rule.D{"y": 9}, false}, + // or: neutral short-circuits to true + {"lenient_or_neutral_short_circuit", "(x eq 10) or (y eq 5)", rule.D{"y": 9}, true}, + // or: true-then-irrelevant -> true + {"lenient_or_true_then_neutral", "(y eq 5) or (x eq 10)", rule.D{"y": 5}, true}, + // not: not (neutral) -> not true -> false + {"lenient_not_neutral", "not (x eq 10)", rule.D{}, false}, +} + +//nolint:gochecknoglobals // Test data +var LenientNestedTests = []Case{ + {"lenient_nested_eq_missing_chain", "a.b.c.d eq 10", rule.D{}, true}, + {"lenient_nested_ne_missing_chain", "a.b.c.d ne 10", rule.D{}, true}, + {"lenient_nested_eq_partial", "a.b eq c.d", rule.D{"a": rule.D{"b": 1}}, true}, + {"lenient_nested_eq_partial_ne", "a.b ne c.d", rule.D{"a": rule.D{"b": 1}}, true}, + {"lenient_nested_both_missing_eq", "a.b eq c.d", rule.D{}, true}, + {"lenient_nested_both_missing_ne", "a.b ne c.d", rule.D{}, true}, +} + +//nolint:gochecknoglobals // Test data +var LenientDateTimeTests = []Case{ + // datetime ops vs null -> neutral true + {"lenient_dq_missing", `created_at dq "2024-01-01T00:00:00Z"`, rule.D{}, true}, + {"lenient_dn_missing", `created_at dn "2024-01-01T00:00:00Z"`, rule.D{}, true}, + {"lenient_be_missing", `created_at be "2024-01-01T00:00:00Z"`, rule.D{}, true}, + {"lenient_bq_missing", `created_at bq "2024-01-01T00:00:00Z"`, rule.D{}, true}, + {"lenient_af_missing", `created_at af "2024-01-01T00:00:00Z"`, rule.D{}, true}, + {"lenient_aq_missing", `created_at aq "2024-01-01T00:00:00Z"`, rule.D{}, true}, + {"lenient_dl_missing", "created_at dl 30", rule.D{}, true}, + {"lenient_dg_missing", "created_at dg 30", rule.D{}, true}, + // both missing -> true + {"lenient_dq_both_missing", "a dq b", rule.D{}, true}, + // present still works (regular path) + {"lenient_af_present", `created_at af "2024-01-01T00:00:00Z"`, rule.D{ + "created_at": "2024-06-01T00:00:00Z", + }, true}, + {"lenient_bq_present_false", `created_at bq "2024-01-01T00:00:00Z"`, rule.D{ + "created_at": "2024-06-01T00:00:00Z", + }, false}, +} + +//nolint:gochecknoglobals // Test data +var LenientPresenceTests = []Case{ + // pr is unaffected by lenient mode: missing -> false, nil -> true, present -> true + {"lenient_pr_missing", "x pr", rule.D{}, false}, + {"lenient_pr_present", "x pr", rule.D{"x": 1}, true}, + {"lenient_pr_nil_explicit", "x pr", rule.D{"x": nil}, true}, + {"lenient_pr_nested_missing", "a.b.c pr", rule.D{}, false}, + {"lenient_pr_nested_present", "a.b pr", rule.D{"a": rule.D{"b": 1}}, true}, + // explicit nil (present) vs absent: only the absent operand routes through + // lenientCompare, so the comparison is neutral -> true. + {"lenient_nil_vs_absent_eq", "x eq y", rule.D{"x": nil}, true}, + {"lenient_nil_vs_absent_ne", "x ne y", rule.D{"x": nil}, true}, +} diff --git a/test/rule_engine_test.go b/test/rule_engine_test.go index 713725b..753a22d 100644 --- a/test/rule_engine_test.go +++ b/test/rule_engine_test.go @@ -83,3 +83,32 @@ func TestRulesRound1(t *testing.T) { } } } + +// TestRulesLenient runs the lenient-mode fixtures against an engine created +// with NewEngineWithOptions(WithLenientMode()). It verifies SQL-ish null +// semantics for missing attributes. +func TestRulesLenient(t *testing.T) { + all := [][]Case{ + LenientEqualityTests, + LenientInequalityTests, + LenientRelationalTests, + LenientStringOpTests, + LenientMembershipTests, + LenientLogicalTests, + LenientNestedTests, + LenientDateTimeTests, + LenientPresenceTests, + } + + for _, group := range all { + engine := rule.NewEngineWithOptions(rule.WithLenientMode()) + + for _, tc := range group { + t.Run(tc.Name, func(t *testing.T) { + got, err := engine.Evaluate(tc.Query, tc.Ctx) + require.NoError(t, err, "query=%q", tc.Query) + require.Equal(t, tc.Result, got, "query=%q", tc.Query) + }) + } + } +}