From 21fe862a523bb4876346089a444444c0df063062 Mon Sep 17 00:00:00 2001 From: Johan Fylling Date: Wed, 1 Jul 2026 08:30:40 +0200 Subject: [PATCH] planner: Support `and`/`or` logical operators (#8827) Fixes: #8681 --------- Signed-off-by: Johan Fylling --- build/generate-extended-cases/exceptions.yaml | 3 +- .../generate-extended-cases/extended_cases.go | 3 + internal/planner/planner.go | 163 ++++++++++++-- internal/planner/planner_test.go | 207 +++++++++++++++++- internal/wasm/sdk/test/e2e/exceptions.yaml | 1 - internal/wasm/sdk/test/e2e/external_test.go | 3 +- .../v1/logic_operators/and_basic.yaml | 72 ++++++ .../v1/logic_operators/builtin_calls.yaml | 81 +++++++ .../testdata/v1/logic_operators/chained.yaml | 129 +++++++++++ .../v1/logic_operators/comprehension.yaml | 41 ++++ .../v1/logic_operators/default_and_else.yaml | 94 ++++++++ .../v1/logic_operators/iteration.yaml | 64 ++++++ .../v1/logic_operators/local_collision.yaml | 71 ++++++ .../testdata/v1/logic_operators/or_basic.yaml | 90 ++++++++ .../v1/logic_operators/rule_head.yaml | 124 +++++++++++ 15 files changed, 1118 insertions(+), 28 deletions(-) create mode 100644 v1/test/cases/testdata/v1/logic_operators/builtin_calls.yaml create mode 100644 v1/test/cases/testdata/v1/logic_operators/chained.yaml create mode 100644 v1/test/cases/testdata/v1/logic_operators/default_and_else.yaml create mode 100644 v1/test/cases/testdata/v1/logic_operators/local_collision.yaml create mode 100644 v1/test/cases/testdata/v1/logic_operators/rule_head.yaml diff --git a/build/generate-extended-cases/exceptions.yaml b/build/generate-extended-cases/exceptions.yaml index 3249fb1f26..3bd88e76b9 100644 --- a/build/generate-extended-cases/exceptions.yaml +++ b/build/generate-extended-cases/exceptions.yaml @@ -1,2 +1 @@ -# Exception Format is : -"logic_op/**": "No planner support" \ No newline at end of file +# Exception Format is : \ No newline at end of file diff --git a/build/generate-extended-cases/extended_cases.go b/build/generate-extended-cases/extended_cases.go index 3eae38750d..69e75020a7 100644 --- a/build/generate-extended-cases/extended_cases.go +++ b/build/generate-extended-cases/extended_cases.go @@ -212,6 +212,9 @@ func LoadIrExtendedTestCasesFiltered(filters ...Filters) ([]ExtendedSet, error) rego.Query(tc.Query), rego.SetRegoVersion(ast.RegoV1), } + if tc.ExperimentalKeywords { + opts = append(opts, rego.Capabilities(ast.CapabilitiesForThisVersion(ast.CapabilitiesExperimentalKeywords(true)))) + } for i := range tc.Modules { opts = append(opts, rego.Module(fmt.Sprintf("module-%d.rego", i), tc.Modules[i])) } diff --git a/internal/planner/planner.go b/internal/planner/planner.go index 9e71e83e72..a7d62542c2 100644 --- a/internal/planner/planner.go +++ b/internal/planner/planner.go @@ -648,6 +648,12 @@ func (p *Planner) planExpr(e *ast.Expr, iter planiter) error { case e.IsNegated(): return p.planNot(e, iter) + case e.IsAnd(): + return p.planExprLogicalAnd(e, iter) + + case e.IsOr(): + return p.planExprLogicalOr(e, iter) + case e.IsCall(): return p.planExprCall(e, iter) @@ -659,34 +665,34 @@ func (p *Planner) planExpr(e *ast.Expr, iter planiter) error { } func (p *Planner) planNot(e *ast.Expr, iter planiter) error { - not := &ir.NotStmt{ - Block: &ir.Block{}, - } - - prev := p.curr - p.curr = not.Block - if n, ok := e.Terms.(*ast.Not); ok { - cond := p.newLocal() // success condition + // We're constructing the following plan: + // + // | not + // | | # assigns Local = true at each success point + // | | is_defined &{Source:Local} # aborts inner block if body produced no success + // | iter() # caller's continuation - err := p.planQuery(n.Body, 0, func() error { - p.appendStmt(&ir.AssignVarStmt{ - Source: op(ir.Bool(true)), - Target: cond, - }) - return nil - }) + cond := p.newLocal() + sub, err := p.planBodyAsScope(n.Body, cond) if err != nil { return err } - p.appendStmt(&ir.IsDefinedStmt{ - Source: cond, - }) - } else { - if err := p.planExpr(e.Complement(), func() error { return nil }); err != nil { - return err - } + sub.Stmts = append(sub.Stmts, &ir.IsDefinedStmt{Source: cond}) + p.appendStmt(&ir.NotStmt{Block: sub}) + + return iter() + } + + // Legacy negation + + not := &ir.NotStmt{Block: &ir.Block{}} + prev := p.curr + p.curr = not.Block + + if err := p.planExpr(e.Complement(), func() error { return nil }); err != nil { + return err } p.curr = prev @@ -695,6 +701,119 @@ func (p *Planner) planNot(e *ast.Expr, iter planiter) error { return iter() } +func (p *Planner) planExprLogicalAnd(e *ast.Expr, iter planiter) error { + // We're constructing the following plan: + // + // | reset &{Target:Local} + // | block lhs + // | | + // | | assign_var &{Target:Local} # Local = true on success + // | is_defined &{Source:Local} # aborts outer if LHS produced no success + // | reset &{Target:Local} # clear before RHS + // | block rhs + // | | + // | | assign_var &{Target:Local} # Local = true on success + // | is_defined &{Source:Local} # aborts outer if RHS produced no success + // | iter() # caller's continuation + + and := e.Terms.(*ast.LogicalAnd) + cond := p.newLocal() // success condition + + if err := planLogicalOperand(p, and.Lhs, cond); err != nil { + return err + } + + if err := planLogicalOperand(p, and.Rhs, cond); err != nil { + return err + } + + return iter() +} + +func planLogicalOperand(p *Planner, body ast.Body, cond ir.Local) error { + p.appendStmt(&ir.ResetLocalStmt{Target: cond}) + + sub, err := p.planBodyAsScope(body, cond) + if err != nil { + return err + } + + p.appendStmt(&ir.BlockStmt{Blocks: []*ir.Block{sub}}) + p.appendStmt(&ir.IsDefinedStmt{Source: cond}) + + return nil +} + +func (p *Planner) planExprLogicalOr(e *ast.Expr, iter planiter) error { + // We're constructing the following plan: + // + // | reset &{Target:Local} + // | block lhs + // | | + // | | assign_var &{Target:Local} # Local = true on success + // | block outer + // | | block skip + // | | | is_defined &{Source:Local} # if defined .. + // | | | break &{Index:1} # .. break past RHS + // | | block rhs + // | | | + // | | | assign_var &{Target:Local} # Local = true on success + // | is_defined &{Source:Local} # aborts outer if neither produced a success + // | iter() # caller's continuation + + or := e.Terms.(*ast.LogicalOr) + + cond := p.newLocal() // success condition + p.appendStmt(&ir.ResetLocalStmt{Target: cond}) + + lhsBlock, err := p.planBodyAsScope(or.Lhs, cond) + if err != nil { + return err + } + p.appendStmt(&ir.BlockStmt{Blocks: []*ir.Block{lhsBlock}}) + + rhsBlock, err := p.planBodyAsScope(or.Rhs, cond) + if err != nil { + return err + } + + // skip-rhs-if-lhs-succeeded: if cond is defined, break out past the + // RHS block; otherwise this inner block aborts and the outer block + // falls through into the RHS plan. + skip := &ir.Block{Stmts: []ir.Stmt{ + &ir.IsDefinedStmt{Source: cond}, + &ir.BreakStmt{Index: 1}, + }} + outer := &ir.Block{Stmts: []ir.Stmt{ + &ir.BlockStmt{Blocks: []*ir.Block{skip}}, + &ir.BlockStmt{Blocks: []*ir.Block{rhsBlock}}, + }} + p.appendStmt(&ir.BlockStmt{Blocks: []*ir.Block{outer}}) + + p.appendStmt(&ir.IsDefinedStmt{Source: cond}) + return iter() +} + +func (p *Planner) planBodyAsScope(body ast.Body, cond ir.Local) (*ir.Block, error) { + sub := &ir.Block{} + prev := p.curr + p.curr = sub + p.vars.Push(map[ast.Var]ir.Local{}) + + err := p.planQuery(body, 0, func() error { + p.appendStmt(&ir.AssignVarStmt{ + Source: op(ir.Bool(true)), + Target: cond, + }) + return nil + }) + + p.vars.Pop() + p.curr = prev + + return sub, err +} + func (p *Planner) planWith(e *ast.Expr, iter planiter) error { // Plan the values that will be applied by the `with` modifiers. All values diff --git a/internal/planner/planner_test.go b/internal/planner/planner_test.go index a327e4b874..5b9fb30592 100644 --- a/internal/planner/planner_test.go +++ b/internal/planner/planner_test.go @@ -5,10 +5,12 @@ package planner import ( + "bytes" "errors" "fmt" "os" "reflect" + "regexp" "slices" "strings" "testing" @@ -735,8 +737,211 @@ a if { } } -func TestMultipleNamedQueries(t *testing.T) { +func TestPlannerLogicalOps(t *testing.T) { + tests := []struct { + note string + module string + want string + }{ + { + note: "and", + module: ` + package test + import future.keywords.and + p if { + input.x > 0 and input.y > 0 + } + `, + want: `| *ir.Funcs Funcs (1 funcs) +| | *ir.Func g0.data.test.p (2 params: [Local<0> Local<1>], 3 blocks, path: [g0 test p]) +| | | *ir.Block Block (8 statements) +| | | | *ir.ResetLocalStmt &{Target:Local<3>} +| | | | *ir.ResetLocalStmt &{Target:Local<4>} +| | | | *ir.BlockStmt BlockStmt (1 blocks) +| | | | | *ir.Block Block (5 statements) +| | | | | | *ir.DotStmt &{Source:{Value:Local<0>} Key:{Value:String<0>} Target:Local<5>} +| | | | | | *ir.MakeNumberRefStmt &{Index:1 Target:Local<6>} +| | | | | | *ir.CallStmt &{Func:gt Args:[{Value:Local<5>} {Value:Local<6>}] Result:Local<7>} +| | | | | | *ir.NotEqualStmt &{A:{Value:Local<7>} B:{Value:Bool}} +| | | | | | *ir.AssignVarStmt &{Source:{Value:Bool} Target:Local<4>} +| | | | *ir.IsDefinedStmt &{Source:Local<4>} +| | | | *ir.ResetLocalStmt &{Target:Local<4>} +| | | | *ir.BlockStmt BlockStmt (1 blocks) +| | | | | *ir.Block Block (5 statements) +| | | | | | *ir.DotStmt &{Source:{Value:Local<0>} Key:{Value:String<2>} Target:Local<8>} +| | | | | | *ir.MakeNumberRefStmt &{Index:1 Target:Local<9>} +| | | | | | *ir.CallStmt &{Func:gt Args:[{Value:Local<8>} {Value:Local<9>}] Result:Local<10>} +| | | | | | *ir.NotEqualStmt &{A:{Value:Local<10>} B:{Value:Bool}} +| | | | | | *ir.AssignVarStmt &{Source:{Value:Bool} Target:Local<4>} +| | | | *ir.IsDefinedStmt &{Source:Local<4>} +| | | | *ir.AssignVarOnceStmt &{Source:{Value:Bool} Target:Local<3>} +| | | *ir.Block Block (2 statements) +| | | | *ir.IsDefinedStmt &{Source:Local<3>} +| | | | *ir.AssignVarOnceStmt &{Source:{Value:Local<3>} Target:Local<2>} +| | | *ir.Block Block (1 statements) +| | | | *ir.ReturnLocalStmt &{Source:Local<2>} +`, + }, + { + note: "or", + module: ` + package test + import future.keywords.or + + p if { + input.x > 0 or input.y > 0 + } + `, + want: `| *ir.Funcs Funcs (1 funcs) +| | *ir.Func g0.data.test.p (2 params: [Local<0> Local<1>], 3 blocks, path: [g0 test p]) +| | | *ir.Block Block (6 statements) +| | | | *ir.ResetLocalStmt &{Target:Local<3>} +| | | | *ir.ResetLocalStmt &{Target:Local<4>} +| | | | *ir.BlockStmt BlockStmt (1 blocks) +| | | | | *ir.Block Block (5 statements) +| | | | | | *ir.DotStmt &{Source:{Value:Local<0>} Key:{Value:String<0>} Target:Local<5>} +| | | | | | *ir.MakeNumberRefStmt &{Index:1 Target:Local<6>} +| | | | | | *ir.CallStmt &{Func:gt Args:[{Value:Local<5>} {Value:Local<6>}] Result:Local<7>} +| | | | | | *ir.NotEqualStmt &{A:{Value:Local<7>} B:{Value:Bool}} +| | | | | | *ir.AssignVarStmt &{Source:{Value:Bool} Target:Local<4>} +| | | | *ir.BlockStmt BlockStmt (1 blocks) +| | | | | *ir.Block Block (2 statements) +| | | | | | *ir.BlockStmt BlockStmt (1 blocks) +| | | | | | | *ir.Block Block (2 statements) +| | | | | | | | *ir.IsDefinedStmt &{Source:Local<4>} +| | | | | | | | *ir.BreakStmt &{Index:1} +| | | | | | *ir.BlockStmt BlockStmt (1 blocks) +| | | | | | | *ir.Block Block (5 statements) +| | | | | | | | *ir.DotStmt &{Source:{Value:Local<0>} Key:{Value:String<2>} Target:Local<8>} +| | | | | | | | *ir.MakeNumberRefStmt &{Index:1 Target:Local<9>} +| | | | | | | | *ir.CallStmt &{Func:gt Args:[{Value:Local<8>} {Value:Local<9>}] Result:Local<10>} +| | | | | | | | *ir.NotEqualStmt &{A:{Value:Local<10>} B:{Value:Bool}} +| | | | | | | | *ir.AssignVarStmt &{Source:{Value:Bool} Target:Local<4>} +| | | | *ir.IsDefinedStmt &{Source:Local<4>} +| | | | *ir.AssignVarOnceStmt &{Source:{Value:Bool} Target:Local<3>} +| | | *ir.Block Block (2 statements) +| | | | *ir.IsDefinedStmt &{Source:Local<3>} +| | | | *ir.AssignVarOnceStmt &{Source:{Value:Local<3>} Target:Local<2>} +| | | *ir.Block Block (1 statements) +| | | | *ir.ReturnLocalStmt &{Source:Local<2>} +`, + }, + { + note: "not", + module: ` + package test + import future.keywords.not + + p if { + not input.x > 0 + } + `, + want: `| *ir.Funcs Funcs (1 funcs) +| | *ir.Func g0.data.test.p (2 params: [Local<0> Local<1>], 3 blocks, path: [g0 test p]) +| | | *ir.Block Block (3 statements) +| | | | *ir.ResetLocalStmt &{Target:Local<3>} +| | | | *ir.NotStmt &{Block:Block (6 statements)} +| | | | | *ir.Block Block (6 statements) +| | | | | | *ir.DotStmt &{Source:{Value:Local<0>} Key:{Value:String<0>} Target:Local<5>} +| | | | | | *ir.MakeNumberRefStmt &{Index:1 Target:Local<6>} +| | | | | | *ir.CallStmt &{Func:gt Args:[{Value:Local<5>} {Value:Local<6>}] Result:Local<7>} +| | | | | | *ir.NotEqualStmt &{A:{Value:Local<7>} B:{Value:Bool}} +| | | | | | *ir.AssignVarStmt &{Source:{Value:Bool} Target:Local<4>} +| | | | | | *ir.IsDefinedStmt &{Source:Local<4>} +| | | | *ir.AssignVarOnceStmt &{Source:{Value:Bool} Target:Local<3>} +| | | *ir.Block Block (2 statements) +| | | | *ir.IsDefinedStmt &{Source:Local<3>} +| | | | *ir.AssignVarOnceStmt &{Source:{Value:Local<3>} Target:Local<2>} +| | | *ir.Block Block (1 statements) +| | | | *ir.ReturnLocalStmt &{Source:Local<2>} +`, + }, + { + note: "not with iteration", // success-cond (Local<4>) distinguishes empty scan from matching scan + module: ` + package test + import future.keywords.not + + p if { + not { input.xs[i]; i > 0 } + } + `, + want: `| *ir.Funcs Funcs (1 funcs) +| | *ir.Func g0.data.test.p (2 params: [Local<0> Local<1>], 3 blocks, path: [g0 test p]) +| | | *ir.Block Block (3 statements) +| | | | *ir.ResetLocalStmt &{Target:Local<3>} +| | | | *ir.NotStmt &{Block:Block (3 statements)} +| | | | | *ir.Block Block (3 statements) +| | | | | | *ir.DotStmt &{Source:{Value:Local<0>} Key:{Value:String<1>} Target:Local<5>} +| | | | | | *ir.ScanStmt &{Source:Local<5> Key:Local<6> Value:Local<7> Block:Block (6 statements)} +| | | | | | | *ir.Block Block (6 statements) +| | | | | | | | *ir.AssignVarStmt &{Source:{Value:Local<6>} Target:Local<8>} +| | | | | | | | *ir.NotEqualStmt &{A:{Value:Local<7>} B:{Value:Bool}} +| | | | | | | | *ir.MakeNumberRefStmt &{Index:2 Target:Local<9>} +| | | | | | | | *ir.CallStmt &{Func:gt Args:[{Value:Local<8>} {Value:Local<9>}] Result:Local<10>} +| | | | | | | | *ir.NotEqualStmt &{A:{Value:Local<10>} B:{Value:Bool}} +| | | | | | | | *ir.AssignVarStmt &{Source:{Value:Bool} Target:Local<4>} +| | | | | | *ir.IsDefinedStmt &{Source:Local<4>} +| | | | *ir.AssignVarOnceStmt &{Source:{Value:Bool} Target:Local<3>} +| | | *ir.Block Block (2 statements) +| | | | *ir.IsDefinedStmt &{Source:Local<3>} +| | | | *ir.AssignVarOnceStmt &{Source:{Value:Local<3>} Target:Local<2>} +| | | *ir.Block Block (1 statements) +| | | | *ir.ReturnLocalStmt &{Source:Local<2>} +`, + }, + } + + parserOpts := ast.ParserOptions{ + Capabilities: ast.CapabilitiesForThisVersion(ast.CapabilitiesExperimentalKeywords(true)), + FutureKeywords: []string{"and", "or", "not"}, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + m, err := ast.ParseModuleWithOpts("test.rego", tc.module, parserOpts) + if err != nil { + t.Fatal(err) + } + + policy, err := New(). + WithQueries([]QuerySet{{ + Name: "test", + Queries: []ast.Body{ast.MustParseBody("data.test.p = x")}, + }}). + WithModules([]*ast.Module{m}). + WithBuiltinDecls(ast.BuiltinMap). + Plan() + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := ir.Pretty(&buf, policy); err != nil { + t.Fatal(err) + } + got := stripIRLocations(buf.String()) + + if !strings.Contains(got, tc.want) { + t.Errorf("unexpected plan to contain: \n%s\ngot:\n%s", tc.want, got) + } + }) + } +} + +var ( + irLocationFieldRE = regexp.MustCompile(` Location:\{[^{}]*(?:\{[^{}]*\}[^{}]*)*}`) + irLocationInlineRE = regexp.MustCompile(` &\{[0-9]+ [0-9]+ [0-9]+[^}]*}`) +) + +func stripIRLocations(s string) string { + s = irLocationFieldRE.ReplaceAllString(s, "") + s = irLocationInlineRE.ReplaceAllString(s, "") + return s +} + +func TestMultipleNamedQueries(t *testing.T) { q1 := []ast.Body{ ast.MustParseBody(`a=1`), } diff --git a/internal/wasm/sdk/test/e2e/exceptions.yaml b/internal/wasm/sdk/test/e2e/exceptions.yaml index fb6708d6b1..d8eb5d5283 100644 --- a/internal/wasm/sdk/test/e2e/exceptions.yaml +++ b/internal/wasm/sdk/test/e2e/exceptions.yaml @@ -1,4 +1,3 @@ # Exception Format is : "data/toplevel integer": "https://github.com/open-policy-agent/opa/issues/3711" "data/nested integer": "https://github.com/open-policy-agent/opa/issues/3711" -"logic_op/**": "No planner support" diff --git a/internal/wasm/sdk/test/e2e/external_test.go b/internal/wasm/sdk/test/e2e/external_test.go index 9dee6f408e..bbace82bbc 100644 --- a/internal/wasm/sdk/test/e2e/external_test.go +++ b/internal/wasm/sdk/test/e2e/external_test.go @@ -95,8 +95,7 @@ func TestWasmE2E(t *testing.T) { t.Setenv(k, v) } - caps := ast.CapabilitiesForThisVersion() - caps.FutureKeywords = append(caps.FutureKeywords, "not") + caps := ast.CapabilitiesForThisVersion(ast.CapabilitiesExperimentalKeywords(tc.ExperimentalKeywords)) opts := []func(*rego.Rego){ rego.Query(tc.Query), diff --git a/v1/test/cases/testdata/v1/logic_operators/and_basic.yaml b/v1/test/cases/testdata/v1/logic_operators/and_basic.yaml index 4d9c9c6f3f..e69d488778 100644 --- a/v1/test/cases/testdata/v1/logic_operators/and_basic.yaml +++ b/v1/test/cases/testdata/v1/logic_operators/and_basic.yaml @@ -93,3 +93,75 @@ cases: input: x: 1 want_result: [] + - note: "logic_op/and/basic: virtual rule refs on both sides" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + q if input.x > 0 + r if input.y > 0 + + p if { + data.test.q and data.test.r + } + input: + x: 1 + "y": 2 + want_result: + - x: true + - note: "logic_op/and/basic: virtual rule ref, rhs fails" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + q if input.x > 0 + r if input.y > 0 + + p if { + data.test.q and data.test.r + } + input: + x: 1 + "y": 0 + want_result: [] + - note: "logic_op/and/basic: dotted data refs both defined" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + p if { + data.cfg.foo and data.cfg.bar + } + data: + cfg: + foo: true + bar: true + want_result: + - x: true + - note: "logic_op/and/basic: indexed data refs" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + p if { + data.cfg.flags["a"] and data.cfg.flags["b"] + } + data: + cfg: + flags: + a: true + b: true + want_result: + - x: true diff --git a/v1/test/cases/testdata/v1/logic_operators/builtin_calls.yaml b/v1/test/cases/testdata/v1/logic_operators/builtin_calls.yaml new file mode 100644 index 0000000000..8c6b009ee3 --- /dev/null +++ b/v1/test/cases/testdata/v1/logic_operators/builtin_calls.yaml @@ -0,0 +1,81 @@ +--- +cases: + - note: "logic_op/builtin_calls: and with builtins on both sides" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + p if { + count(input.xs) > 0 and startswith(input.s, "/") + } + input: + xs: [1, 2, 3] + s: "/etc/hosts" + want_result: + - x: true + - note: "logic_op/builtin_calls: and with builtins, rhs fails" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + p if { + count(input.xs) > 0 and startswith(input.s, "/") + } + input: + xs: [1, 2, 3] + s: "no-leading-slash" + want_result: [] + - note: "logic_op/builtin_calls: or with builtins on both sides" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + p if { + count(input.xs) > 0 or endswith(input.s, ".txt") + } + input: + xs: [] + s: "report.txt" + want_result: + - x: true + - note: "logic_op/builtin_calls: nested builtin call, distinct result temporaries" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + p if { + count(split(input.a, ",")) > 1 and count(split(input.b, ";")) > 1 + } + input: + a: "x,y,z" + b: "1;2;3" + want_result: + - x: true + - note: "logic_op/builtin_calls: comprehension in builtin, or branch" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + p if { + count([n | n := input.xs[_]; n > 0]) > 0 or count([n | n := input.ys[_]; n > 0]) > 0 + } + input: + xs: [-1, -2] + ys: [3] + want_result: + - x: true diff --git a/v1/test/cases/testdata/v1/logic_operators/chained.yaml b/v1/test/cases/testdata/v1/logic_operators/chained.yaml new file mode 100644 index 0000000000..afedeeff4e --- /dev/null +++ b/v1/test/cases/testdata/v1/logic_operators/chained.yaml @@ -0,0 +1,129 @@ +--- +cases: + - note: "logic_op/chained: a and b and c, all true" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + p if { + input.a and input.b and input.c + } + input: + a: true + b: true + c: true + want_result: + - x: true + - note: "logic_op/chained: a and b and c, middle false" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + p if { + input.a and input.b and input.c + } + input: + a: true + b: false + c: true + want_result: [] + - note: "logic_op/chained: a or b or c, only middle true" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + p if { + input.a or input.b or input.c + } + input: + a: false + b: true + c: false + want_result: + - x: true + - note: "logic_op/chained: a or b or c, all false" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + p if { + input.a or input.b or input.c + } + input: + a: false + b: false + c: false + want_result: [] + - note: "logic_op/chained: a and b or c and d, right side wins" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + import future.keywords.or + + p if { + # parses as (a and b) or (c and d) + input.a and input.b or input.c and input.d + } + input: + a: false + b: true + c: true + d: true + want_result: + - x: true + - note: "logic_op/chained: a or b and c or d, b-and-c is the only true conjunct" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + import future.keywords.or + + p if { + # parses as: a or (b and c) or d + input.a or input.b and input.c or input.d + } + input: + a: false + b: true + c: true + d: false + want_result: + - x: true + - note: "logic_op/chained: deeply nested explicit bodies" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + import future.keywords.or + + p if { + { input.a and { input.b or input.c } } and { input.d or { input.e and input.f } } + } + input: + a: true + b: false + c: true + d: false + e: true + f: true + want_result: + - x: true diff --git a/v1/test/cases/testdata/v1/logic_operators/comprehension.yaml b/v1/test/cases/testdata/v1/logic_operators/comprehension.yaml index 0a6f56eb56..45d13626e3 100644 --- a/v1/test/cases/testdata/v1/logic_operators/comprehension.yaml +++ b/v1/test/cases/testdata/v1/logic_operators/comprehension.yaml @@ -93,3 +93,44 @@ cases: - [12, 3, -4] want_result: - x: [[1, 5], [3]] + - note: "logic_op/comprehension: walk-bound iteration with and as filter" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + p := [v | + walk(input, [_, v]) + is_string(v) and count(v) > 0 + ] + input: + a: "hello" + b: "" + c: + d: "nested" + e: 42 + sort_bindings: true + want_result: + - x: ["hello", "nested"] + - note: "logic_op/comprehension: walk-bound iteration with or as filter" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + p := [v | + walk(input, [_, v]) + is_number(v) or { is_array(v); count(v) > 1 } + ] + input: + a: 1 + b: "x" + c: [10, 20, 30] + d: [99] + sort_bindings: true + want_result: + - x: [1, 10, 20, 30, 99, [10, 20, 30]] diff --git a/v1/test/cases/testdata/v1/logic_operators/default_and_else.yaml b/v1/test/cases/testdata/v1/logic_operators/default_and_else.yaml new file mode 100644 index 0000000000..541890d81b --- /dev/null +++ b/v1/test/cases/testdata/v1/logic_operators/default_and_else.yaml @@ -0,0 +1,94 @@ +--- +cases: + - note: "logic_op/default: default returns when or body fails" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + default p := false + + p if input.x > 0 or input.y > 0 + input: + x: 0 + "y": 0 + want_result: + - x: false + - note: "logic_op/default: default skipped when or body succeeds" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + default p := false + + p if input.x > 0 or input.y > 0 + input: + x: 0 + "y": 5 + want_result: + - x: true + - note: "logic_op/default: default returns when and body fails" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + default p := "fallback" + + p := "ok" if input.x > 0 and input.y > 0 + input: + x: 1 + "y": 0 + want_result: + - x: "fallback" + - note: "logic_op/else: else fires when and body fails" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + p := "ok" if input.x > 0 and input.y > 0 + else := "fallback" + input: + x: 1 + "y": 0 + want_result: + - x: "fallback" + - note: "logic_op/else: else skipped when or body succeeds" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + p := "ok" if input.x > 0 or input.y > 0 + else := "fallback" + input: + x: 0 + "y": 5 + want_result: + - x: "ok" + - note: "logic_op/else: chained else with or in primary branch" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + p := "primary" if input.role == "admin" or input.role == "superuser" + else := "secondary" + input: + role: "guest" + want_result: + - x: "secondary" diff --git a/v1/test/cases/testdata/v1/logic_operators/iteration.yaml b/v1/test/cases/testdata/v1/logic_operators/iteration.yaml index 6e245737c8..0685d6c765 100644 --- a/v1/test/cases/testdata/v1/logic_operators/iteration.yaml +++ b/v1/test/cases/testdata/v1/logic_operators/iteration.yaml @@ -107,3 +107,67 @@ cases: ] want_result: - x: [] + - note: "logic_op/iteration: not around or with iteration" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + import future.keywords.not + + # not body iterates xs; succeeds iff NO element satisfies either branch + p if { + not { some n in input.xs; n < 0 or n > 100 } + } + input: + xs: [0, 5, 99] + want_result: + - x: true + - note: "logic_op/iteration: not around or with iteration, one element matches" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + import future.keywords.not + + p if { + not { some n in input.xs; n < 0 or n > 100 } + } + input: + xs: [0, 5, 200] + want_result: [] + - note: "logic_op/iteration: not around and with iteration" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + import future.keywords.not + + # Succeeds iff no n in xs is both >0 and <10 + p if { + not { some n in input.xs; n > 0 and n < 10 } + } + input: + xs: [-1, 0, 20] + want_result: + - x: true + - note: "logic_op/iteration: not around and with iteration, one element matches" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + import future.keywords.not + + p if { + not { some n in input.xs; n > 0 and n < 10 } + } + input: + xs: [-1, 0, 5] + want_result: [] diff --git a/v1/test/cases/testdata/v1/logic_operators/local_collision.yaml b/v1/test/cases/testdata/v1/logic_operators/local_collision.yaml new file mode 100644 index 0000000000..9ef53a14f2 --- /dev/null +++ b/v1/test/cases/testdata/v1/logic_operators/local_collision.yaml @@ -0,0 +1,71 @@ +--- +cases: + - note: "logic_op/local_collision: same name in both and-operand bodies" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + p if { + { a := 1; a > 0 } and { a := 2; a > 1 } + } + want_result: + - x: true + - note: "logic_op/local_collision: same name in both or-operand bodies" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + p if { + { a := 0; a > 0 } or { a := 5; a > 0 } + } + want_result: + - x: true + - note: "logic_op/local_collision: same name across and-body and outer scope" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + # outer `a` must not leak into the operand body and vice-versa. + p if { + a := -100 + { a := 1; a > 0 } and { a := 2; a > 1 } + a == -100 + } + want_result: + - x: true + - note: "logic_op/local_collision: same `some` iter name in both operand bodies" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + p if { + { some n in [1, 2, 3]; n > 2 } and { some n in [10, 20, 30]; n > 20 } + } + want_result: + - x: true + - note: "logic_op/local_collision: nested explicit body re-uses same name" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + import future.keywords.or + + p if { + { a := 1; a > 0 } and { { a := 2; a > 1 } or { a := 3; a > 5 } } + } + want_result: + - x: true diff --git a/v1/test/cases/testdata/v1/logic_operators/or_basic.yaml b/v1/test/cases/testdata/v1/logic_operators/or_basic.yaml index 4deeb53aca..a67aa057fd 100644 --- a/v1/test/cases/testdata/v1/logic_operators/or_basic.yaml +++ b/v1/test/cases/testdata/v1/logic_operators/or_basic.yaml @@ -108,3 +108,93 @@ cases: } want_result: - x: true + - note: "logic_op/or/basic: virtual rule refs, lhs succeeds" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + q if input.x > 0 + r if input.y > 0 + + p if { + data.test.q or data.test.r + } + input: + x: 1 + "y": 0 + want_result: + - x: true + - note: "logic_op/or/basic: virtual rule refs, rhs succeeds" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + q if input.x > 0 + r if input.y > 0 + + p if { + data.test.q or data.test.r + } + input: + x: 0 + "y": 1 + want_result: + - x: true + - note: "logic_op/or/basic: virtual rule refs, neither succeeds" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + q if input.x > 0 + r if input.y > 0 + + p if { + data.test.q or data.test.r + } + input: + x: 0 + "y": 0 + want_result: [] + - note: "logic_op/or/basic: dotted data refs, only one defined" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + p if { + data.cfg.foo or data.cfg.bar + } + data: + cfg: + bar: true + want_result: + - x: true + - note: "logic_op/or/basic: indexed data refs" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + p if { + data.cfg.flags["a"] or data.cfg.flags["b"] + } + data: + cfg: + flags: + a: false + b: true + want_result: + - x: true diff --git a/v1/test/cases/testdata/v1/logic_operators/rule_head.yaml b/v1/test/cases/testdata/v1/logic_operators/rule_head.yaml new file mode 100644 index 0000000000..0f7b4751f4 --- /dev/null +++ b/v1/test/cases/testdata/v1/logic_operators/rule_head.yaml @@ -0,0 +1,124 @@ +--- +cases: + - note: "logic_op/rule_head: and under if-head, both true" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + p if input.x > 0 and input.y > 0 + input: + x: 1 + "y": 2 + want_result: + - x: true + - note: "logic_op/rule_head: and under if-head, lhs false" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + p if input.x > 0 and input.y > 0 + input: + x: 0 + "y": 2 + want_result: [] + - note: "logic_op/rule_head: or under if-head, lhs true" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + p if input.x > 0 or input.y > 0 + input: + x: 1 + "y": 0 + want_result: + - x: true + - note: "logic_op/rule_head: or under if-head, both false" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + p if input.x > 0 or input.y > 0 + input: + x: 0 + "y": 0 + want_result: [] + - note: "logic_op/rule_head: and under assignment head" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + p := 1 if input.x > 0 and input.y > 0 + input: + x: 1 + "y": 2 + want_result: + - x: 1 + - note: "logic_op/rule_head: or under assignment head" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + p := "matched" if input.x > 0 or input.y > 0 + input: + x: 0 + "y": 5 + want_result: + - x: "matched" + - note: "logic_op/rule_head: and in function body" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + in_range(n) if n > 0 and n < 10 + + p if in_range(5) + want_result: + - x: true + - note: "logic_op/rule_head: and in function body, fails" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.and + + in_range(n) if n > 0 and n < 10 + + p if in_range(42) + want_result: [] + - note: "logic_op/rule_head: or in function body" + experimental_keywords: true + query: data.test.p = x + modules: + - | + package test + import future.keywords.or + + boundary(n) if n < 1 or n > 99 + + p if boundary(input.n) + input: + "n": 100 + want_result: + - x: true