From e2c32ebe46ad517b0799fc276e3bd334a059a6d6 Mon Sep 17 00:00:00 2001 From: Matheus Cstro Date: Tue, 4 Aug 2026 18:37:04 -0300 Subject: [PATCH 01/10] feat: add lenient mode option to Engine and Evaluator Introduce NewEngineWithOptions and WithLenientMode Option. The Evaluator gains a lenient bool field (zero allocation cost) wired from the Engine. NewEngine() behavior is unchanged (strict mode); lenient is opt-in only. --- PLAN.md | 140 +++++++++++++++++++++++++++++++++++++++++++++++++++ engine.go | 35 +++++++++++++ evaluator.go | 7 ++- 3 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..c5f4bf9 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,140 @@ +# Plano de Implementação: Modo Lenient (SQL-ish null semantics) + +## Objetivo + +Adicionar um modo "lenient" ao engine onde campos ausentes (ou `nil`) são +tratados como `null` e propagados com semântica SQL-ish, em vez de virarem +`false` indiscriminadamente em qualquer comparação. + +## Motivação + +No modo strict (atual), quando um campo está ausente do contexto: + +| Query | Resultado atual | Surpresa? | +|---------------------|-----------------|-----------| +| `x eq 10` | `false` | não | +| `x ne 10` | `false` | **SIM** (null ≠ 10 deveria ser true) | +| `not (x eq 10)` | `true` | coerente | +| `x lt 10` | `false` | coerente | +| `x in [...]` | `false` | coerente | +| `x not in [...]` | `false` | **SIM** (null não está no set → true) | + +O modo lenient corrige essas incongruências aplicando semântica null-aware +estilo SQL (2-valued logic simplificada, sem tri-state "unknown"): + +| Operador | null vs valor concreto | null vs null | +|---------------------|------------------------|--------------| +| `eq` / `==` | `false` | `true` | +| `ne` / `!=` | `true` | `false` | +| `lt`, `gt`, `le`, `ge` | `false` | `false` | +| `co`, `sw`, `ew` | `false` | `false` | +| `in` | `false` | `false` | +| `not in` | `true` | `false` | +| `pr` | `false` (ausente) / `true` (nil explícito) | — | +| datetime (`dq,dn,be,bq,af,aq,dl,dg`) | `false` | `false` | + +Regras para `pr` (presence) NÃO mudam: ausência → `false`, `nil` explícito → +`true` (presença é sobre "a chave está no map", não sobre o valor). + +## Design + +### API pública (não quebra nada) + +```go +// Modo default (strict, inalterado): +engine := rule.NewEngine() + +// Modo lenient: +engine := rule.NewEngineWithOptions(rule.WithLenientMode()) +``` + +- `NewEngine()` continua funcionando exatamente igual (modo strict). +- Adiciona `Option` funcional e `WithLenientMode()`. +- `NewEngineWithOptions(opts ...Option) *Engine`. + +### Interno + +- `Evaluator` ganha um campo `lenient bool` (zero custo de alocação). +- Único ponto de mudança semântica: `evaluateComparisonOperator`. + Hoje existe o short-circuit: + ```go + if !leftResult.IsValid || !rightResult.IsValid { + result.Bool = false + return nil + } + ``` + Em modo lenient, substitui por: + ```go + if !leftResult.IsValid || !rightResult.IsValid { + if !e.lenient { + result.Bool = false + return nil + } + result.Bool = e.lenientCompare(node.Operator, &leftResult, &rightResult) + return nil + } + ``` +- Nova função `lenientCompare(operator, left, right) bool` centraliza a tabela + SQL-ish. Não aloca (operadores são constantes, leitura por valor). +- `pr` (presence) não muda — é tratado em `evaluatePresenceOperator`, fora do + caminho de comparação. +- `not`, `and`, `or` não mudam — usam `toBool`, que já retorna `false` para + inválido; `not` inverte → coerente com SQL-ish (NOT null = true via + `not (x eq 10)` → `not false` → `true`). + +### Ausência vs nil explícito + +- **Ausência** (chave não está no map): `IsValid = false` → em modo lenient + tratado como `null`. +- **nil explícito** (`x: nil` no context): hoje cai no `default:` de + `setResultFromAny` → `IsValid = true`, `Type = ValueString`, `Str = ""`. + Isso significa que `nil` explícito hoje se comporta como string vazia para + comparações, mas como presente para `pr`. + - **Decisão**: no escopo deste PR, lenient trata apenas **ausência** como + null. `nil` explícito mantém comportamento atual (não quebra testes + existentes como `nil_value_presence` → `true`). Futuro: nullable tracking + separado, fora do escopo. + +## Passos (commits semânticos) + +1. **`feat: add lenient mode option to Engine and Evaluator`** + - `engine.go`: `Option`, `WithLenientMode()`, `NewEngineWithOptions()`. + - `evaluator.go`: campo `lenient bool` em `Evaluator`, propagado por + `NewEvaluator` (interno) e setter a partir do Engine. + - `NewEngine()` inalterado. + +2. **`feat: implement SQL-ish null comparison in lenient mode`** + - `evaluator.go`: `lenientCompare()` + branch em + `evaluateComparisonOperator`. + - Sem mudança de comportamento em strict. + +3. **`test: add lenient mode null semantics test suite`** + - Novo arquivo `test/lenient_fixtures.go` com tabela de casos. + - Integração em `test/rule_engine_test.go` (novo grupo rodando com + `NewEngineWithOptions(WithLenientMode())`). + - Casos: eq/ne/lt/gt/le/ge/co/sw/ew/in/not_in com ausência, ausência ambos + lados, nil vs concreto, and/or/not, nested missing, datetime. + +4. **`test: add lenient mode example in example_test.go`** + - `Example_lenientMode()` com `// Output:` verificável. + +5. **`docs: document lenient mode in README`** + - Nova seção "## 🟢 Lenient Mode (Null-Aware Semantics)". + - Atualizar tabela de "Exclusive Features". + - Atualizar Quick Example/TOC conforme necessário. + +6. **`docs: update CLAUDE.md with lenient mode spec`** + - Adicionar bullet em "Type System Compliance" e "List Operations" + descrevendo o modo lenient. + +## Restrições do projeto respeitadas + +- **0 allocs/op**: `lenient bool` é campo de struct, lido por valor; nenhum + novo slice/map/interface. +- **Sub-100ns**: um branch predizível a mais no caminho hot. +- **API-compatible**: `NewEngine()` inalterado; modo é opt-in. +- **TDD**: testes definem a spec; implementação segue a tabela. +- **Errors tipados**: nenhum novo erro necessário (lenient não introduz + erros — apenas muda bool de resultado). +- **Sem co-author** nos commits. +- **Scripts de teste em pasta própria**: testes ficam em `test/`. \ No newline at end of file diff --git a/engine.go b/engine.go index 57978d7..025d077 100644 --- a/engine.go +++ b/engine.go @@ -25,6 +25,41 @@ func NewEngine() *Engine { } } +// Option configures an Engine created with NewEngineWithOptions. +type Option func(*Engine) + +// WithLenientMode enables lenient (SQL-ish null-aware) evaluation semantics. +// +// In lenient mode, missing attributes are treated as null and propagate +// through comparison operators with SQL-ish semantics instead of being +// coerced to false unconditionally: +// +// null eq -> false null ne -> true +// null lt/gt/le/ge -> false null not in [...] -> true +// null co/sw/ew -> false null eq null -> true +// +// 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..c0e449a 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{} From 051d4026922d6cf3af6ecaa252de506d47c4751a Mon Sep 17 00:00:00 2001 From: Matheus Cstro Date: Tue, 4 Aug 2026 18:37:54 -0300 Subject: [PATCH 02/10] feat: implement SQL-ish null comparison in lenient mode Add lenientCompare() that applies null-aware semantics when at least one operand is missing: eq null<->value is false (null<->null is true), ne flips accordingly, not in becomes true, and ordering/string/datetime ops stay false. Strict mode is unchanged: the new branch only runs when e.lenient is set. --- evaluator.go | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/evaluator.go b/evaluator.go index c0e449a..0b644e2 100644 --- a/evaluator.go +++ b/evaluator.go @@ -605,13 +605,48 @@ 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 { + result.Bool = false + return nil + } + + result.Bool = e.lenientCompare(node.Operator, &leftResult, &rightResult) return nil } return e.performComparison(node.Operator, &leftResult, &rightResult, result) } +// lenientCompare applies SQL-ish null-aware semantics when at least one +// operand is missing (IsValid == false). It implements a 2-valued logic +// (no tri-state "unknown") so callers get a definitive bool. +// +// null eq -> false null ne -> true +// null lt/gt/le/ge -> false null not in [...] -> true +// null co/sw/ew -> false null eq null -> true +// all datetime operators against null -> false +// +// Presence (pr) is handled separately and is unaffected. Logical and +// quantifier operators also route through toBool and are unaffected. +func (e *Evaluator) lenientCompare(operator TokenType, left, right *EvalResult) bool { + switch operator { + case EQ, EQUALS: + // null eq null -> true; null eq value -> false + return !left.IsValid && !right.IsValid + case NE, NOT_EQUALS: + // null ne null -> false; null ne value -> true + return left.IsValid || right.IsValid + case NOT_IN: + // null is never a member of a concrete set -> not in -> true + return left.IsValid || right.IsValid + case LT, GT, LE, GE, CO, SW, EW, IN, DQ, DN, BE, BQ, AF, AQ, DL, DG: + // Ordering, string, membership and datetime ops are not null-comparable + return false + default: + return false + } +} + // performComparison executes the specific comparison operation. func (e *Evaluator) performComparison( operator TokenType, From 8765c995f499040c946aa478aaf703522fb54ce7 Mon Sep 17 00:00:00 2001 From: Matheus Cstro Date: Tue, 4 Aug 2026 18:38:25 -0300 Subject: [PATCH 03/10] test: add lenient mode null semantics test suite Add Lenient* fixtures covering eq/ne/lt/gt/le/ge/co/sw/ew/in/not_in with missing attributes, both-sides-missing, nil-vs-concrete, and/or/not, nested missing chains, datetime operators and presence (unaffected). Wire a TestRulesLenient runner using NewEngineWithOptions(WithLenientMode). --- test/lenient_fixtures.go | 156 +++++++++++++++++++++++++++++++++++++++ test/rule_engine_test.go | 29 ++++++++ 2 files changed, 185 insertions(+) create mode 100644 test/lenient_fixtures.go diff --git a/test/lenient_fixtures.go b/test/lenient_fixtures.go new file mode 100644 index 0000000..8c2207f --- /dev/null +++ b/test/lenient_fixtures.go @@ -0,0 +1,156 @@ +package test + +import "github.com/NSXBet/rule" + +/* ---------- Lenient Mode (SQL-ish null semantics) ---------- + * + * These fixtures run under NewEngineWithOptions(WithLenientMode()). + * "missing" means the key is absent from the context map. + * + * SQL-ish null semantics (2-valued): + * null eq -> false null ne -> true + * null lt/gt/le/ge value -> false null not in [...] -> true + * null co/sw/ew -> false null eq null -> true + * datetime ops vs null -> false null ne null -> false + * + * Presence (pr), logical (and/or/not) and quantifier operators are + * unaffected by lenient mode and keep their strict behavior. + */ + +//nolint:gochecknoglobals // Test data +var LenientEqualityTests = []Case{ + // eq with missing left + {"lenient_eq_missing_left_value", "x eq 10", rule.D{}, false}, + {"lenient_eq_missing_left_string", `x eq "abc"`, rule.D{}, false}, + {"lenient_eq_missing_left_bool", "x eq true", rule.D{}, false}, + // eq with missing right (property on the right) + {"lenient_eq_missing_right_value", "10 eq y", rule.D{}, false}, + {"lenient_eq_missing_right_string", `"abc" eq y`, rule.D{}, false}, + // eq with both missing -> true (null eq null) + {"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 + {"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{}, false}, +} + +//nolint:gochecknoglobals // Test data +var LenientInequalityTests = []Case{ + // ne with missing left -> true (null ne value) + {"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 -> false (null ne null) + {"lenient_ne_both_missing", "x ne y", rule.D{}, false}, + // ne with present values still works + {"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{}, false}, +} + +//nolint:gochecknoglobals // Test data +var LenientRelationalTests = []Case{ + // ordering ops against null -> false + {"lenient_lt_missing", "x lt 10", rule.D{}, false}, + {"lenient_gt_missing", "x gt 10", rule.D{}, false}, + {"lenient_le_missing", "x le 10", rule.D{}, false}, + {"lenient_ge_missing", "x ge 10", rule.D{}, false}, + // both missing -> still false (not null-comparable) + {"lenient_lt_both_missing", "x lt y", rule.D{}, false}, + {"lenient_gt_both_missing", "x gt y", rule.D{}, false}, + // missing on the right + {"lenient_lt_missing_right", "10 lt y", rule.D{}, false}, + // present still works + {"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{}, false}, + {"lenient_sw_missing", `x sw "abc"`, rule.D{}, false}, + {"lenient_ew_missing", `x ew "abc"`, rule.D{}, false}, + {"lenient_co_both_missing", `x co y`, rule.D{}, false}, + // present still works + {"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: null in set -> false + {"lenient_in_missing", "x in [1,2,3]", rule.D{}, false}, + {"lenient_in_missing_string", `x in ["a","b"]`, rule.D{}, false}, + {"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: null not in set -> 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_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: false && anything -> false (short-circuit, missing not reached) + {"lenient_and_left_false", "(x eq 10) and (y ne 5)", rule.D{"x": 99}, false}, + // and: true && missing-comparison -> depends on lenient result of right + {"lenient_and_right_missing_eq", "(x eq 10) and (y eq 5)", rule.D{"x": 10}, false}, + {"lenient_and_right_missing_ne", "(x eq 10) and (y ne 5)", rule.D{"x": 10}, true}, + // or: true short-circuits + {"lenient_or_left_true", "(x eq 10) or (y eq 5)", rule.D{"x": 10}, true}, + {"lenient_or_left_false_missing_right", "(x eq 10) or (y eq 5)", rule.D{}, false}, + {"lenient_or_left_false_missing_right_ne", "(x eq 10) or (y ne 5)", rule.D{}, true}, + // not: not (missing eq value) -> not false -> true + {"lenient_not_missing_eq", "not (x eq 10)", rule.D{}, true}, + // not: not (missing ne value) -> not true -> false + {"lenient_not_missing_ne", "not (x ne 10)", rule.D{}, false}, + // not (both missing eq) -> not true -> false + {"lenient_not_both_missing_eq", "not (x eq y)", rule.D{}, false}, +} + +//nolint:gochecknoglobals // Test data +var LenientNestedTests = []Case{ + {"lenient_nested_eq_missing_chain", "a.b.c.d eq 10", rule.D{}, false}, + {"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}}, false}, + {"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{}, false}, +} + +//nolint:gochecknoglobals // Test data +var LenientDateTimeTests = []Case{ + // datetime ops vs null -> false + {"lenient_dq_missing", `created_at dq "2024-01-01T00:00:00Z"`, rule.D{}, false}, + {"lenient_dn_missing", `created_at dn "2024-01-01T00:00:00Z"`, rule.D{}, false}, + {"lenient_be_missing", `created_at be "2024-01-01T00:00:00Z"`, rule.D{}, false}, + {"lenient_bq_missing", `created_at bq "2024-01-01T00:00:00Z"`, rule.D{}, false}, + {"lenient_af_missing", `created_at af "2024-01-01T00:00:00Z"`, rule.D{}, false}, + {"lenient_aq_missing", `created_at aq "2024-01-01T00:00:00Z"`, rule.D{}, false}, + {"lenient_dl_missing", "created_at dl 30", rule.D{}, false}, + {"lenient_dg_missing", "created_at dg 30", rule.D{}, false}, + // both missing -> false + {"lenient_dq_both_missing", "a dq b", rule.D{}, false}, + // present still works + {"lenient_af_present", `created_at af "2024-01-01T00:00:00Z"`, rule.D{ + "created_at": "2024-06-01T00:00:00Z", + }, true}, +} + +//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}, +} 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) + }) + } + } +} From 2f5a0ecad4dca447d81163e4f48ff3b42714e1dc Mon Sep 17 00:00:00 2001 From: Matheus Cstro Date: Tue, 4 Aug 2026 18:38:33 -0300 Subject: [PATCH 04/10] test: add lenient mode example in example_test.go Add Example_lenientMode with a verified // Output block that contrasts strict vs lenient behavior for missing attributes, demonstrating ne/not in/eq null. --- example_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/example_test.go b/example_test.go index 540c85f..1ede71f 100644 --- a/example_test.go +++ b/example_test.go @@ -701,3 +701,45 @@ func Example_ecommerce() { // Free shipping eligible: true // Weekend discount eligible: true } + +// Example_lenientMode demonstrates the opt-in lenient (SQL-ish null-aware) +// evaluation mode. Missing attributes are treated as null and propagate +// through comparisons with SQL-ish semantics instead of always returning false. +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, null eq 18 is still false... + rLenientEq, _ := lenient.Evaluate("age eq 18", ctx) + + // ...but null ne 18 is true (null is not 18), unlike strict mode where + // missing attributes make every comparison false. + rLenientNe, _ := lenient.Evaluate("age ne 18", ctx) + + // null eq null is true in lenient mode. + rLenientBothMissing, _ := lenient.Evaluate("a eq b", ctx) + + // null not in [...] is true in lenient mode. + rLenientNotIn, _ := lenient.Evaluate("role not in [\"admin\",\"user\"]", ctx) + + 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", rLenientNotIn) + // Output: + // strict age eq 18: false + // lenient age eq 18: false + // lenient age ne 18: true + // lenient a eq b (both missing): true + // lenient role not in [...]: true +} From 901c5a0dd0660bdc7ddce13b1bc10027bec49c45 Mon Sep 17 00:00:00 2001 From: Matheus Cstro Date: Tue, 4 Aug 2026 18:38:56 -0300 Subject: [PATCH 05/10] docs: document lenient mode in README Add a 'Lenient Mode (Null-Aware Semantics)' section with the SQL-ish operator table, enabling snippet, example and notes. Register NewEngineWithOptions in the API section, add the feature to the Exclusive Features table, and link it from the TOC. --- README.md | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 34529a8..7484cba 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 (Null-Aware Semantics)](#-lenient-mode-null-aware-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 (SQL-ish null-aware) mode: +engine := rule.NewEngineWithOptions(rule.WithLenientMode()) +``` + +See [Lenient Mode (Null-Aware Semantics)](#-lenient-mode-null-aware-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,66 @@ engine.Evaluate(`selections where (odd ge 1.4) all (is_live eq true)`, context) --- +## 🟢 Lenient Mode (Null-Aware 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 can be surprising for cases like `x ne 10`, which +returns `false` when `x` is absent rather than `true`. + +**Lenient mode** is an opt-in alternative where missing attributes are +treated as `null` and propagate through comparison operators with SQL-ish +(2-valued) semantics: + +| Operator | null vs concrete value | null vs null | +|------------------------|------------------------|--------------| +| `eq` / `==` | `false` | `true` | +| `ne` / `!=` | `true` | `false` | +| `lt`, `gt`, `le`, `ge` | `false` | `false` | +| `co`, `sw`, `ew` | `false` | `false` | +| `in` | `false` | `false` | +| `not in` | `true` | `false` | +| datetime (`dq`…`dg`) | `false` | `false` | +| `pr` | `false` if missing / `true` if present (incl. `nil`) | unchanged | + +`and`, `or`, `not` and the list quantifiers (`any`/`all`/`none`) are +**unaffected** by lenient mode — they keep their normal truthiness behavior. + +### 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 ne 18`, ctx) // -> false +lenient.Evaluate(`age ne 18`, ctx) // -> true (null ne 18) +lenient.Evaluate(`age eq 18`, ctx) // -> false (null eq 18) +lenient.Evaluate(`a eq b`, ctx) // -> true (null eq null) +lenient.Evaluate(`role not in ["admin","user"]`, ctx) // -> true +``` + +### Notes + +- Only **missing attributes** are treated as `null`. An explicit `nil` value + in the context is still considered present for `pr` and keeps its existing + value-comparison behavior. +- 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`. + +--- + ## 💾 Query Caching The rule engine is smart about performance! 🧠 Here's how caching works: @@ -869,6 +943,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 SQL-ish null-aware semantics for missing attributes | `NewEngineWithOptions(rule.WithLenientMode())` | Rules where absence should mean `null` instead of `false` | | **rule.D Type Alias** | Cleaner syntax | `rule.D{"key": "value"}` | Developer experience | ### 🔧 Migration Assessment From 8984256c279d67173c7d11c1d0c14e62d7db4a80 Mon Sep 17 00:00:00 2001 From: Matheus Cstro Date: Tue, 4 Aug 2026 18:40:31 -0300 Subject: [PATCH 06/10] docs: update CLAUDE.md with lenient mode spec Add lenient mode bullet to the supported operations list and document SQL-ish null semantics under Type System Compliance. Also fix linter findings (exhaustive nolint, wsl_v5 whitespace) introduced by the new branch. --- CLAUDE.md | 8 ++++++++ engine.go | 1 + evaluator.go | 8 ++++---- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1de5bf4..9378a2f 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 SQL-ish null-aware semantics for missing attributes via `NewEngineWithOptions(WithLenientMode())` (proprietary extension; not part of `nikunjy/rules`) ### Rule Syntax Examples @@ -120,6 +121,13 @@ 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())`), missing attributes are treated + as `null` with SQL-ish 2-valued semantics: `null ne ` is `true`, + `null not in [...]` is `true`, `null eq null` is `true`, ordering/string/ + datetime ops against `null` are `false`. Presence (`pr`), logical and + quantifier operators are unaffected. Opt-in only; `NewEngine()` is strict. ### List Operations - **Array Length**: Access `.length` on array properties (e.g., `items.length gt 3`) diff --git a/engine.go b/engine.go index 025d077..bb6973c 100644 --- a/engine.go +++ b/engine.go @@ -51,6 +51,7 @@ func WithLenientMode() Option { // options (strict mode). func NewEngineWithOptions(opts ...Option) *Engine { e := NewEngine() + for _, opt := range opts { if opt != nil { opt(e) diff --git a/evaluator.go b/evaluator.go index 0b644e2..ca1fc39 100644 --- a/evaluator.go +++ b/evaluator.go @@ -605,12 +605,12 @@ func (e *Evaluator) evaluateComparisonOperator(node *ASTNode, context D, result // If either operand is invalid (missing attribute), comparison is false if !leftResult.IsValid || !rightResult.IsValid { - if !e.lenient { + if e.lenient { + result.Bool = e.lenientCompare(node.Operator, &leftResult, &rightResult) + } else { result.Bool = false - return nil } - result.Bool = e.lenientCompare(node.Operator, &leftResult, &rightResult) return nil } @@ -629,7 +629,7 @@ func (e *Evaluator) evaluateComparisonOperator(node *ASTNode, context D, result // Presence (pr) is handled separately and is unaffected. Logical and // quantifier operators also route through toBool and are unaffected. func (e *Evaluator) lenientCompare(operator TokenType, left, right *EvalResult) bool { - switch operator { + switch operator { //nolint:exhaustive // only comparison operators reach here case EQ, EQUALS: // null eq null -> true; null eq value -> false return !left.IsValid && !right.IsValid From 48d1d168c779d321df9792c299cf8f9e0b60cf57 Mon Sep 17 00:00:00 2001 From: Matheus Cstro Date: Tue, 4 Aug 2026 19:07:39 -0300 Subject: [PATCH 07/10] chore: remove internal planning doc from branch PLAN.md was a working draft of the implementation plan and should not ship in the repository. --- PLAN.md | 140 -------------------------------------------------------- 1 file changed, 140 deletions(-) delete mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index c5f4bf9..0000000 --- a/PLAN.md +++ /dev/null @@ -1,140 +0,0 @@ -# Plano de Implementação: Modo Lenient (SQL-ish null semantics) - -## Objetivo - -Adicionar um modo "lenient" ao engine onde campos ausentes (ou `nil`) são -tratados como `null` e propagados com semântica SQL-ish, em vez de virarem -`false` indiscriminadamente em qualquer comparação. - -## Motivação - -No modo strict (atual), quando um campo está ausente do contexto: - -| Query | Resultado atual | Surpresa? | -|---------------------|-----------------|-----------| -| `x eq 10` | `false` | não | -| `x ne 10` | `false` | **SIM** (null ≠ 10 deveria ser true) | -| `not (x eq 10)` | `true` | coerente | -| `x lt 10` | `false` | coerente | -| `x in [...]` | `false` | coerente | -| `x not in [...]` | `false` | **SIM** (null não está no set → true) | - -O modo lenient corrige essas incongruências aplicando semântica null-aware -estilo SQL (2-valued logic simplificada, sem tri-state "unknown"): - -| Operador | null vs valor concreto | null vs null | -|---------------------|------------------------|--------------| -| `eq` / `==` | `false` | `true` | -| `ne` / `!=` | `true` | `false` | -| `lt`, `gt`, `le`, `ge` | `false` | `false` | -| `co`, `sw`, `ew` | `false` | `false` | -| `in` | `false` | `false` | -| `not in` | `true` | `false` | -| `pr` | `false` (ausente) / `true` (nil explícito) | — | -| datetime (`dq,dn,be,bq,af,aq,dl,dg`) | `false` | `false` | - -Regras para `pr` (presence) NÃO mudam: ausência → `false`, `nil` explícito → -`true` (presença é sobre "a chave está no map", não sobre o valor). - -## Design - -### API pública (não quebra nada) - -```go -// Modo default (strict, inalterado): -engine := rule.NewEngine() - -// Modo lenient: -engine := rule.NewEngineWithOptions(rule.WithLenientMode()) -``` - -- `NewEngine()` continua funcionando exatamente igual (modo strict). -- Adiciona `Option` funcional e `WithLenientMode()`. -- `NewEngineWithOptions(opts ...Option) *Engine`. - -### Interno - -- `Evaluator` ganha um campo `lenient bool` (zero custo de alocação). -- Único ponto de mudança semântica: `evaluateComparisonOperator`. - Hoje existe o short-circuit: - ```go - if !leftResult.IsValid || !rightResult.IsValid { - result.Bool = false - return nil - } - ``` - Em modo lenient, substitui por: - ```go - if !leftResult.IsValid || !rightResult.IsValid { - if !e.lenient { - result.Bool = false - return nil - } - result.Bool = e.lenientCompare(node.Operator, &leftResult, &rightResult) - return nil - } - ``` -- Nova função `lenientCompare(operator, left, right) bool` centraliza a tabela - SQL-ish. Não aloca (operadores são constantes, leitura por valor). -- `pr` (presence) não muda — é tratado em `evaluatePresenceOperator`, fora do - caminho de comparação. -- `not`, `and`, `or` não mudam — usam `toBool`, que já retorna `false` para - inválido; `not` inverte → coerente com SQL-ish (NOT null = true via - `not (x eq 10)` → `not false` → `true`). - -### Ausência vs nil explícito - -- **Ausência** (chave não está no map): `IsValid = false` → em modo lenient - tratado como `null`. -- **nil explícito** (`x: nil` no context): hoje cai no `default:` de - `setResultFromAny` → `IsValid = true`, `Type = ValueString`, `Str = ""`. - Isso significa que `nil` explícito hoje se comporta como string vazia para - comparações, mas como presente para `pr`. - - **Decisão**: no escopo deste PR, lenient trata apenas **ausência** como - null. `nil` explícito mantém comportamento atual (não quebra testes - existentes como `nil_value_presence` → `true`). Futuro: nullable tracking - separado, fora do escopo. - -## Passos (commits semânticos) - -1. **`feat: add lenient mode option to Engine and Evaluator`** - - `engine.go`: `Option`, `WithLenientMode()`, `NewEngineWithOptions()`. - - `evaluator.go`: campo `lenient bool` em `Evaluator`, propagado por - `NewEvaluator` (interno) e setter a partir do Engine. - - `NewEngine()` inalterado. - -2. **`feat: implement SQL-ish null comparison in lenient mode`** - - `evaluator.go`: `lenientCompare()` + branch em - `evaluateComparisonOperator`. - - Sem mudança de comportamento em strict. - -3. **`test: add lenient mode null semantics test suite`** - - Novo arquivo `test/lenient_fixtures.go` com tabela de casos. - - Integração em `test/rule_engine_test.go` (novo grupo rodando com - `NewEngineWithOptions(WithLenientMode())`). - - Casos: eq/ne/lt/gt/le/ge/co/sw/ew/in/not_in com ausência, ausência ambos - lados, nil vs concreto, and/or/not, nested missing, datetime. - -4. **`test: add lenient mode example in example_test.go`** - - `Example_lenientMode()` com `// Output:` verificável. - -5. **`docs: document lenient mode in README`** - - Nova seção "## 🟢 Lenient Mode (Null-Aware Semantics)". - - Atualizar tabela de "Exclusive Features". - - Atualizar Quick Example/TOC conforme necessário. - -6. **`docs: update CLAUDE.md with lenient mode spec`** - - Adicionar bullet em "Type System Compliance" e "List Operations" - descrevendo o modo lenient. - -## Restrições do projeto respeitadas - -- **0 allocs/op**: `lenient bool` é campo de struct, lido por valor; nenhum - novo slice/map/interface. -- **Sub-100ns**: um branch predizível a mais no caminho hot. -- **API-compatible**: `NewEngine()` inalterado; modo é opt-in. -- **TDD**: testes definem a spec; implementação segue a tabela. -- **Errors tipados**: nenhum novo erro necessário (lenient não introduz - erros — apenas muda bool de resultado). -- **Sem co-author** nos commits. -- **Scripts de teste em pasta própria**: testes ficam em `test/`. \ No newline at end of file From 0d1103eb47dc3bf1834a58de6d9590f610f1f192 Mon Sep 17 00:00:00 2001 From: Matheus Cstro Date: Tue, 4 Aug 2026 19:11:50 -0300 Subject: [PATCH 08/10] test: add explicit-nil vs absent lenient comparison cases Address CodeRabbit review: extend LenientPresenceTests with x eq y / x ne y cases using rule.D{"x": nil} (present) vs y absent, expecting false and true respectively. These exercise lenientCompare when only one operand is missing. Also fix the README exclusive-features count (6 -> 7 major extensions) now that Lenient Mode is listed. --- README.md | 2 +- test/lenient_fixtures.go | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7484cba..0e98b48 100644 --- a/README.md +++ b/README.md @@ -906,7 +906,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. diff --git a/test/lenient_fixtures.go b/test/lenient_fixtures.go index 8c2207f..b7c7260 100644 --- a/test/lenient_fixtures.go +++ b/test/lenient_fixtures.go @@ -153,4 +153,8 @@ var LenientPresenceTests = []Case{ {"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 eq -> false and ne -> true. + {"lenient_nil_vs_absent_eq", "x eq y", rule.D{"x": nil}, false}, + {"lenient_nil_vs_absent_ne", "x ne y", rule.D{"x": nil}, true}, } From d0926af259a5dbaf5e681b863457037225f33e4e Mon Sep 17 00:00:00 2001 From: Matheus Cstro Date: Wed, 5 Aug 2026 17:33:15 -0300 Subject: [PATCH 09/10] feat: switch lenient mode from SQL-ish to neutral semantics A comparison predicate over a missing attribute now returns true (neutral) across all comparison operators (eq, ne, lt, gt, le, ge, co, sw, ew, in, not in, datetime). 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. The SQL-ish per-operator logic (null eq value -> false, null ne value -> true, ...) is removed in favor of a single result.Bool = true branch. Neutrality only holds inside an AND-chain: under not, or, or list quantifiers it becomes decisive (documented in code + tests). pr, logical and quantifier operators are unaffected. Tests in test/lenient_fixtures.go and Example_lenientMode are updated to the neutral truth table; the old SQL-ish expectations are replaced. --- CLAUDE.md | 19 +++-- README.md | 81 ++++++++++++------- engine.go | 22 ++++-- evaluator.go | 39 ++-------- example_test.go | 26 ++++--- test/lenient_fixtures.go | 163 +++++++++++++++++++++------------------ 6 files changed, 191 insertions(+), 159 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9378a2f..0626124 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +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 SQL-ish null-aware semantics for missing attributes via `NewEngineWithOptions(WithLenientMode())` (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 @@ -123,11 +123,18 @@ All functionality is validated through the comprehensive test suite in `test/fix - **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())`), missing attributes are treated - as `null` with SQL-ish 2-valued semantics: `null ne ` is `true`, - `null not in [...]` is `true`, `null eq null` is `true`, ordering/string/ - datetime ops against `null` are `false`. Presence (`pr`), logical and - quantifier operators are unaffected. Opt-in only; `NewEngine()` is strict. + (`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 0e98b48..865282e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A **blazingly fast**, **zero-allocation** rule engine for Go that evaluates logi 2. [📚 API](#-api) 3. [🎯 Context](#-context) 4. [🔤 Rule Language](#-rule-language) -5. [🟢 Lenient Mode (Null-Aware Semantics)](#-lenient-mode-null-aware-semantics) +5. [🟢 Lenient Mode (Neutral Semantics)](#-lenient-mode-neutral-semantics) 6. [💾 Query Caching](#-query-caching) 7. [⚡ Benchmarks](#-benchmarks) 8. [🤝 Contributing](#-contributing) @@ -89,11 +89,11 @@ Creates a new rule engine configured with functional options. `NewEngine()` is e // Strict (default, same as NewEngine()): engine := rule.NewEngineWithOptions() -// Lenient (SQL-ish null-aware) mode: +// Lenient (neutral semantics) mode: engine := rule.NewEngineWithOptions(rule.WithLenientMode()) ``` -See [Lenient Mode (Null-Aware Semantics)](#-lenient-mode-null-aware-semantics) for details. +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. @@ -624,30 +624,42 @@ engine.Evaluate(`selections where (odd ge 1.4) all (is_live eq true)`, context) --- -## 🟢 Lenient Mode (Null-Aware Semantics) +## 🟢 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 can be surprising for cases like `x ne 10`, which -returns `false` when `x` is absent rather than `true`. - -**Lenient mode** is an opt-in alternative where missing attributes are -treated as `null` and propagate through comparison operators with SQL-ish -(2-valued) semantics: - -| Operator | null vs concrete value | null vs null | -|------------------------|------------------------|--------------| -| `eq` / `==` | `false` | `true` | -| `ne` / `!=` | `true` | `false` | -| `lt`, `gt`, `le`, `ge` | `false` | `false` | -| `co`, `sw`, `ew` | `false` | `false` | -| `in` | `false` | `false` | -| `not in` | `true` | `false` | -| datetime (`dq`…`dg`) | `false` | `false` | -| `pr` | `false` if missing / `true` if present (incl. `nil`) | unchanged | +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. +**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 @@ -666,21 +678,30 @@ lenient := rule.NewEngineWithOptions(rule.WithLenientMode()) ctx := rule.D{} // "age" is absent -strict.Evaluate(`age ne 18`, ctx) // -> false -lenient.Evaluate(`age ne 18`, ctx) // -> true (null ne 18) -lenient.Evaluate(`age eq 18`, ctx) // -> false (null eq 18) -lenient.Evaluate(`a eq b`, ctx) // -> true (null eq null) +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** are treated as `null`. An explicit `nil` value - in the context is still considered present for `pr` and keeps its existing - value-comparison behavior. +- 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 `lenientCompare`. - 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. --- @@ -943,7 +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 SQL-ish null-aware semantics for missing attributes | `NewEngineWithOptions(rule.WithLenientMode())` | Rules where absence should mean `null` instead of `false` | +| **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 bb6973c..923d9d3 100644 --- a/engine.go +++ b/engine.go @@ -28,15 +28,23 @@ func NewEngine() *Engine { // Option configures an Engine created with NewEngineWithOptions. type Option func(*Engine) -// WithLenientMode enables lenient (SQL-ish null-aware) evaluation semantics. +// WithLenientMode enables lenient (neutral) evaluation semantics. // -// In lenient mode, missing attributes are treated as null and propagate -// through comparison operators with SQL-ish semantics instead of being -// coerced to false unconditionally: +// 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: // -// null eq -> false null ne -> true -// null lt/gt/le/ge -> false null not in [...] -> true -// null co/sw/ew -> false null eq null -> true +// 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. diff --git a/evaluator.go b/evaluator.go index ca1fc39..69b78ed 100644 --- a/evaluator.go +++ b/evaluator.go @@ -606,7 +606,14 @@ func (e *Evaluator) evaluateComparisonOperator(node *ASTNode, context D, result // If either operand is invalid (missing attribute), comparison is false if !leftResult.IsValid || !rightResult.IsValid { if e.lenient { - result.Bool = e.lenientCompare(node.Operator, &leftResult, &rightResult) + // 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 } @@ -617,36 +624,6 @@ func (e *Evaluator) evaluateComparisonOperator(node *ASTNode, context D, result return e.performComparison(node.Operator, &leftResult, &rightResult, result) } -// lenientCompare applies SQL-ish null-aware semantics when at least one -// operand is missing (IsValid == false). It implements a 2-valued logic -// (no tri-state "unknown") so callers get a definitive bool. -// -// null eq -> false null ne -> true -// null lt/gt/le/ge -> false null not in [...] -> true -// null co/sw/ew -> false null eq null -> true -// all datetime operators against null -> false -// -// Presence (pr) is handled separately and is unaffected. Logical and -// quantifier operators also route through toBool and are unaffected. -func (e *Evaluator) lenientCompare(operator TokenType, left, right *EvalResult) bool { - switch operator { //nolint:exhaustive // only comparison operators reach here - case EQ, EQUALS: - // null eq null -> true; null eq value -> false - return !left.IsValid && !right.IsValid - case NE, NOT_EQUALS: - // null ne null -> false; null ne value -> true - return left.IsValid || right.IsValid - case NOT_IN: - // null is never a member of a concrete set -> not in -> true - return left.IsValid || right.IsValid - case LT, GT, LE, GE, CO, SW, EW, IN, DQ, DN, BE, BQ, AF, AQ, DL, DG: - // Ordering, string, membership and datetime ops are not null-comparable - return false - default: - return false - } -} - // performComparison executes the specific comparison operation. func (e *Evaluator) performComparison( operator TokenType, diff --git a/example_test.go b/example_test.go index 1ede71f..64a2f3d 100644 --- a/example_test.go +++ b/example_test.go @@ -702,9 +702,9 @@ func Example_ecommerce() { // Weekend discount eligible: true } -// Example_lenientMode demonstrates the opt-in lenient (SQL-ish null-aware) -// evaluation mode. Missing attributes are treated as null and propagate -// through comparisons with SQL-ish semantics instead of always returning false. +// 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() @@ -718,28 +718,32 @@ func Example_lenientMode() { // In strict mode, "age eq 18" is false because age is missing. rStrict, _ := strict.Evaluate("age eq 18", ctx) - // In lenient mode, null eq 18 is still false... + // In lenient mode, a missing attribute makes the comparison neutral (true). rLenientEq, _ := lenient.Evaluate("age eq 18", ctx) - // ...but null ne 18 is true (null is not 18), unlike strict mode where - // missing attributes make every comparison false. + // Neutrality holds for every comparison operator, so "age ne 18" is also true. rLenientNe, _ := lenient.Evaluate("age ne 18", ctx) - // null eq null is true in lenient mode. + // And for both-sides-missing: "a eq b" is neutral -> true. rLenientBothMissing, _ := lenient.Evaluate("a eq b", ctx) - // null not in [...] is true in lenient mode. - rLenientNotIn, _ := lenient.Evaluate("role not in [\"admin\",\"user\"]", 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", rLenientNotIn) + 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: 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 index b7c7260..88c71b4 100644 --- a/test/lenient_fixtures.go +++ b/test/lenient_fixtures.go @@ -2,147 +2,162 @@ package test import "github.com/NSXBet/rule" -/* ---------- Lenient Mode (SQL-ish null semantics) ---------- +/* ---------- Lenient Mode (neutral semantics) ---------- * * These fixtures run under NewEngineWithOptions(WithLenientMode()). * "missing" means the key is absent from the context map. * - * SQL-ish null semantics (2-valued): - * null eq -> false null ne -> true - * null lt/gt/le/ge value -> false null not in [...] -> true - * null co/sw/ew -> false null eq null -> true - * datetime ops vs null -> false null ne null -> false + * 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). * - * Presence (pr), logical (and/or/not) and quantifier operators are - * unaffected by lenient mode and keep their strict behavior. + * 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 - {"lenient_eq_missing_left_value", "x eq 10", rule.D{}, false}, - {"lenient_eq_missing_left_string", `x eq "abc"`, rule.D{}, false}, - {"lenient_eq_missing_left_bool", "x eq true", rule.D{}, false}, - // eq with missing right (property on the right) - {"lenient_eq_missing_right_value", "10 eq y", rule.D{}, false}, - {"lenient_eq_missing_right_string", `"abc" eq y`, rule.D{}, false}, - // eq with both missing -> true (null eq null) + // 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 + // 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{}, false}, + {"lenient_equals_alias_missing_vs_value", "x == 10", rule.D{}, true}, } //nolint:gochecknoglobals // Test data var LenientInequalityTests = []Case{ - // ne with missing left -> true (null ne value) + // 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 -> false (null ne null) - {"lenient_ne_both_missing", "x ne y", rule.D{}, false}, - // ne with present values still works + // 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{}, false}, + {"lenient_not_equals_alias_both_missing", "x != y", rule.D{}, true}, } //nolint:gochecknoglobals // Test data var LenientRelationalTests = []Case{ - // ordering ops against null -> false - {"lenient_lt_missing", "x lt 10", rule.D{}, false}, - {"lenient_gt_missing", "x gt 10", rule.D{}, false}, - {"lenient_le_missing", "x le 10", rule.D{}, false}, - {"lenient_ge_missing", "x ge 10", rule.D{}, false}, - // both missing -> still false (not null-comparable) - {"lenient_lt_both_missing", "x lt y", rule.D{}, false}, - {"lenient_gt_both_missing", "x gt y", rule.D{}, false}, + // 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{}, false}, - // present still works + {"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{}, false}, - {"lenient_sw_missing", `x sw "abc"`, rule.D{}, false}, - {"lenient_ew_missing", `x ew "abc"`, rule.D{}, false}, - {"lenient_co_both_missing", `x co y`, rule.D{}, false}, - // present still works + {"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: null in set -> false - {"lenient_in_missing", "x in [1,2,3]", rule.D{}, false}, - {"lenient_in_missing_string", `x in ["a","b"]`, rule.D{}, false}, + // 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: null not in set -> true + // 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: false && anything -> false (short-circuit, missing not reached) - {"lenient_and_left_false", "(x eq 10) and (y ne 5)", rule.D{"x": 99}, false}, - // and: true && missing-comparison -> depends on lenient result of right - {"lenient_and_right_missing_eq", "(x eq 10) and (y eq 5)", rule.D{"x": 10}, false}, - {"lenient_and_right_missing_ne", "(x eq 10) and (y ne 5)", rule.D{"x": 10}, true}, - // or: true short-circuits - {"lenient_or_left_true", "(x eq 10) or (y eq 5)", rule.D{"x": 10}, true}, - {"lenient_or_left_false_missing_right", "(x eq 10) or (y eq 5)", rule.D{}, false}, - {"lenient_or_left_false_missing_right_ne", "(x eq 10) or (y ne 5)", rule.D{}, true}, - // not: not (missing eq value) -> not false -> true - {"lenient_not_missing_eq", "not (x eq 10)", rule.D{}, true}, - // not: not (missing ne value) -> not true -> false - {"lenient_not_missing_ne", "not (x ne 10)", rule.D{}, false}, - // not (both missing eq) -> not true -> false - {"lenient_not_both_missing_eq", "not (x eq y)", rule.D{}, false}, + // 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{}, false}, + {"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}}, false}, + {"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{}, false}, + {"lenient_nested_both_missing_ne", "a.b ne c.d", rule.D{}, true}, } //nolint:gochecknoglobals // Test data var LenientDateTimeTests = []Case{ - // datetime ops vs null -> false - {"lenient_dq_missing", `created_at dq "2024-01-01T00:00:00Z"`, rule.D{}, false}, - {"lenient_dn_missing", `created_at dn "2024-01-01T00:00:00Z"`, rule.D{}, false}, - {"lenient_be_missing", `created_at be "2024-01-01T00:00:00Z"`, rule.D{}, false}, - {"lenient_bq_missing", `created_at bq "2024-01-01T00:00:00Z"`, rule.D{}, false}, - {"lenient_af_missing", `created_at af "2024-01-01T00:00:00Z"`, rule.D{}, false}, - {"lenient_aq_missing", `created_at aq "2024-01-01T00:00:00Z"`, rule.D{}, false}, - {"lenient_dl_missing", "created_at dl 30", rule.D{}, false}, - {"lenient_dg_missing", "created_at dg 30", rule.D{}, false}, - // both missing -> false - {"lenient_dq_both_missing", "a dq b", rule.D{}, false}, - // present still works + // 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 @@ -154,7 +169,7 @@ var LenientPresenceTests = []Case{ {"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 eq -> false and ne -> true. - {"lenient_nil_vs_absent_eq", "x eq y", rule.D{"x": nil}, false}, + // 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}, } From 90a3e9bc2d45e8303308bf77cc1d724bb7542fab Mon Sep 17 00:00:00 2001 From: Matheus Cstro Date: Wed, 5 Aug 2026 17:35:53 -0300 Subject: [PATCH 10/10] docs: drop stale lenientCompare reference from README notes The lenientCompare function was removed when switching to uniform neutral semantics; update the note to refer to 'the lenient branch' instead. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 865282e..fced879 100644 --- a/README.md +++ b/README.md @@ -694,7 +694,7 @@ lenient.Evaluate(`age eq 18 and status eq "settled"`, rule.D{"status": "pending" - 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 `lenientCompare`. + 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`.