Skip to content

Commit b64930b

Browse files
author
Rémi Lapeyre
authored
Add any and all expression support. (#49)
* Add any and all expression support. The syntax is inspired by Sentinel [Any, All Expressions](https://docs.hashicorp.com/sentinel/language/boolexpr#any-all-expressions) and the [For Statements](https://docs.hashicorp.com/sentinel/language/loops#for-statements).
1 parent d593a08 commit b64930b

File tree

9 files changed

+1455
-530
lines changed

9 files changed

+1455
-530
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,6 @@ The [Makefile](Makefile) contains 3 main targets to aid with testing:
9999
`1s` seemed like too little to get results consistent enough for comparison between two runs.
100100
For the highest degree of confidence that performance has remained steady increase this value
101101
even further. The time it takes to run the bench testing suite grows linearly with this value.
102-
* `BENCHTESTS=BenchmarkEvalute` - This is used to run a particular benchmark including all of its
102+
* `BENCHTESTS=BenchmarkEvaluate` - This is used to run a particular benchmark including all of its
103103
sub-benchmarks. This is just an example and "BenchmarkEvaluate" can be replaced with any
104104
benchmark functions name.

evaluate.go

Lines changed: 146 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ func getMatchExprValue(expression *grammar.MatchExpression, rvalue reflect.Kind)
223223
// be handled by the MatchOperator's NotPresentDisposition method.
224224
//
225225
// Returns false if the Selector Path has a length of 1, or if the parent of
226-
// the Selector's Path is not a map, a pointerstructure.ErrrNotFound error is
226+
// the Selector's Path is not a map, a pointerstructure.ErrNotFound error is
227227
// returned.
228228
func evaluateNotPresent(ptr pointerstructure.Pointer, datum interface{}) bool {
229229
if len(ptr.Parts) < 2 {
@@ -237,10 +237,54 @@ func evaluateNotPresent(ptr pointerstructure.Pointer, datum interface{}) bool {
237237
return reflect.ValueOf(val).Kind() == reflect.Map
238238
}
239239

240-
func evaluateMatchExpression(expression *grammar.MatchExpression, datum interface{}, opt ...Option) (bool, error) {
240+
// getValue resolves path to the value it references by first looking into the
241+
// the local variables, then into the global datum state if it does not.
242+
//
243+
// When the path points to a local variable we have multiple cases we have to
244+
// take care of, in some constructions like
245+
//
246+
// all Slice as item { item != "forbidden" }
247+
//
248+
// `item` is actually an alias to "/Slice/0", "/Slice/1", etc. In that case we
249+
// compute the full path because we tracked what each of them points to.
250+
//
251+
// In some other cases like
252+
//
253+
// all Map as key { key != "forbidden" }
254+
//
255+
// `key` has no equivalent JSON Pointer. In that case we kept track of the the
256+
// concrete value instead of the path and we return it directly.
257+
func getValue(datum interface{}, path []string, opt ...Option) (interface{}, bool, error) {
241258
opts := getOpts(opt...)
259+
if len(path) != 0 && len(opts.withLocalVariables) > 0 {
260+
for i := len(opts.withLocalVariables) - 1; i >= 0; i-- {
261+
name := path[0]
262+
lv := opts.withLocalVariables[i]
263+
if name == lv.name {
264+
if len(lv.path) == 0 {
265+
// This local variable is a key or an index and we know its
266+
// value without having to call pointerstructure, we stop
267+
// here.
268+
if len(path) > 1 {
269+
first := pointerstructure.Pointer{Parts: []string{name}}
270+
full := pointerstructure.Pointer{Parts: path}
271+
return nil, false, fmt.Errorf("%s references a %T so %s is invalid", first.String(), lv.value, full.String())
272+
}
273+
return lv.value, true, nil
274+
} else {
275+
// This local variable references another value, we prepend the
276+
// path of the selector it replaces and continue searching
277+
prefix := append([]string(nil), lv.path...)
278+
path = append(prefix, path[1:]...)
279+
}
280+
}
281+
}
282+
}
283+
284+
// This is not a local variable, we use pointerstructure to look for it
285+
// in the global datum
242286
ptr := pointerstructure.Pointer{
243-
Parts: expression.Selector.Path,
287+
Parts: path,
244288
Config: pointerstructure.Config{
245289
TagName: opts.withTagName,
246290
ValueTransformationHook: opts.withHookFn,
@@ -256,15 +300,31 @@ func evaluateMatchExpression(expression *grammar.MatchExpression, datum interfac
256300
err = nil
257301
val = *opts.withUnknown
258302
case evaluateNotPresent(ptr, datum):
259-
return expression.Operator.NotPresentDisposition(), nil
303+
return nil, false, nil
260304
}
261305
}
262306

263307
if err != nil {
264-
return false, fmt.Errorf("error finding value in datum: %w", err)
308+
return false, false, fmt.Errorf("error finding value in datum: %w", err)
265309
}
266310
}
267311

312+
return val, true, nil
313+
}
314+
315+
func evaluateMatchExpression(expression *grammar.MatchExpression, datum interface{}, opt ...Option) (bool, error) {
316+
val, present, err := getValue(
317+
datum,
318+
expression.Selector.Path,
319+
opt...,
320+
)
321+
if err != nil {
322+
return false, err
323+
}
324+
if !present {
325+
return expression.Operator.NotPresentDisposition(), nil
326+
}
327+
268328
if jn, ok := val.(json.Number); ok {
269329
if jni, err := jn.Int64(); err == nil {
270330
val = jni
@@ -314,6 +374,85 @@ func evaluateMatchExpression(expression *grammar.MatchExpression, datum interfac
314374
}
315375
}
316376

377+
func evaluateCollectionExpression(expression *grammar.CollectionExpression, datum interface{}, opt ...Option) (bool, error) {
378+
val, present, err := getValue(
379+
datum,
380+
expression.Selector.Path,
381+
opt...,
382+
)
383+
if err != nil {
384+
return false, err
385+
}
386+
if !present {
387+
return expression.Op == grammar.CollectionOpAll, nil
388+
}
389+
390+
v := reflect.ValueOf(val)
391+
392+
var keys []reflect.Value
393+
if v.Kind() == reflect.Map {
394+
if v.Type().Key() != reflect.TypeOf("") {
395+
return false, fmt.Errorf("%s can only iterate over maps indexed with strings", expression.Op)
396+
}
397+
keys = v.MapKeys()
398+
}
399+
400+
switch v.Kind() {
401+
case reflect.Slice, reflect.Array, reflect.Map:
402+
for i := 0; i < v.Len(); i++ {
403+
innerOpt := append([]Option(nil), opt...)
404+
405+
if expression.NameBinding.Mode == grammar.CollectionBindIndexAndValue &&
406+
expression.NameBinding.Index == expression.NameBinding.Value {
407+
return false, fmt.Errorf("%q cannot be used as a placeholder for both the index and the value", expression.NameBinding.Index)
408+
}
409+
410+
if v.Kind() == reflect.Map {
411+
key := keys[i]
412+
if expression.NameBinding.Default != "" {
413+
innerOpt = append(innerOpt, WithLocalVariable(expression.NameBinding.Default, nil, key.Interface()))
414+
}
415+
if expression.NameBinding.Index != "" {
416+
innerOpt = append(innerOpt, WithLocalVariable(expression.NameBinding.Index, nil, key.Interface()))
417+
}
418+
if expression.NameBinding.Value != "" {
419+
path := make([]string, 0, len(expression.Selector.Path)+1)
420+
path = append(path, expression.Selector.Path...)
421+
path = append(path, key.Interface().(string))
422+
innerOpt = append(innerOpt, WithLocalVariable(expression.NameBinding.Value, path, nil))
423+
}
424+
} else {
425+
if expression.NameBinding.Index != "" {
426+
innerOpt = append(innerOpt, WithLocalVariable(expression.NameBinding.Index, nil, i))
427+
}
428+
429+
pathValue := make([]string, 0, len(expression.Selector.Path)+1)
430+
pathValue = append(pathValue, expression.Selector.Path...)
431+
pathValue = append(pathValue, fmt.Sprintf("%d", i))
432+
if expression.NameBinding.Default != "" {
433+
innerOpt = append(innerOpt, WithLocalVariable(expression.NameBinding.Default, pathValue, nil))
434+
}
435+
if expression.NameBinding.Value != "" {
436+
innerOpt = append(innerOpt, WithLocalVariable(expression.NameBinding.Value, pathValue, nil))
437+
}
438+
}
439+
440+
result, err := evaluate(expression.Inner, datum, innerOpt...)
441+
if err != nil {
442+
return false, err
443+
}
444+
if (result && expression.Op == grammar.CollectionOpAny) || (!result && expression.Op == grammar.CollectionOpAll) {
445+
return result, nil
446+
}
447+
}
448+
449+
return expression.Op == grammar.CollectionOpAll, nil
450+
451+
default:
452+
return false, fmt.Errorf(`%s is not a list or a map`, expression.Selector.String())
453+
}
454+
}
455+
317456
func evaluate(ast grammar.Expression, datum interface{}, opt ...Option) (bool, error) {
318457
switch node := ast.(type) {
319458
case *grammar.UnaryExpression:
@@ -342,6 +481,8 @@ func evaluate(ast grammar.Expression, datum interface{}, opt ...Option) (bool, e
342481
}
343482
case *grammar.MatchExpression:
344483
return evaluateMatchExpression(node, datum, opt...)
484+
case *grammar.CollectionExpression:
485+
return evaluateCollectionExpression(node, datum, opt...)
345486
}
346487
return false, fmt.Errorf("Invalid AST node")
347488
}

evaluate_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,26 @@ var evaluateTests map[string]expressionTest = map[string]expressionTest{
329329
{expression: "Nested.Notfound is not empty", result: false, err: `error finding value in datum: /Nested/Notfound at part 1: couldn't find key: struct field with name "Notfound"`},
330330
{expression: `Nested.Notfound matches ".*"`, result: false, err: `error finding value in datum: /Nested/Notfound at part 1: couldn't find key: struct field with name "Notfound"`},
331331
{expression: `Nested.Notfound not matches ".*"`, result: false, err: `error finding value in datum: /Nested/Notfound at part 1: couldn't find key: struct field with name "Notfound"`},
332+
// all
333+
{expression: `all Nested.SliceOfInts as i { i != 42 }`, result: true},
334+
{expression: `all Nested.SliceOfInts as i { i == 1 }`, result: false},
335+
{expression: `all Nested.Map as v { v == "bar" }`, result: false},
336+
{expression: `all Nested.Map as v { v != "hello" }`, result: true},
337+
{expression: `all Nested.Map as k, k { TopInt == 5 }`, err: `"k" cannot be used as a placeholder for both the index and the value`},
338+
{expression: `all Nested.Map as k, _ { k != "foo" }`, result: false},
339+
{expression: `all Nested.Map as k, _ { k != "hello" }`, result: true},
340+
{expression: `all Nested.Map as k, v { k != "foo" or v != "baz" }`, result: true},
341+
{expression: `all TopInt as k, v { k != "foo" or v != "baz" }`, err: "TopInt is not a list or a map"},
342+
// any
343+
{expression: `any Nested.SliceOfInts as i { i == 1 }`, result: true},
344+
{expression: `any Nested.SliceOfInts as i { i == 42 }`, result: false},
345+
{expression: `any Nested.SliceOfStructs as i { "/i/X" == 1 }`, result: true},
346+
{expression: `any Nested.Map as k { k != "bar" }`, result: true},
347+
{expression: `any Nested.Map as k { k == "bar" }`, result: true},
348+
{expression: `any Nested.Map as k { k == "hello" }`, result: false},
349+
{expression: `any Nested.Map as k, v { k == "foo" and v == "bar" }`, result: true},
350+
{expression: `any Nested.Map as k { k.Color == "red" }`, err: "/k references a string so /k/Color is invalid"},
351+
{expression: `any Nested.SliceOfInts as i, _ { i.Color == "red" }`, err: "/i references a int so /i/Color is invalid"},
332352
},
333353
},
334354
}

grammar/ast.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,55 @@ func (expr *MatchExpression) ExpressionDump(w io.Writer, indent string, level in
192192
fmt.Fprintf(w, "%[1]s%[3]s {\n%[2]sSelector: %[4]v\n%[1]s}\n", strings.Repeat(indent, level), strings.Repeat(indent, level+1), expr.Operator.String(), expr.Selector)
193193
}
194194
}
195+
196+
type CollectionBindMode string
197+
198+
const (
199+
CollectionBindDefault CollectionBindMode = "Default"
200+
CollectionBindIndex CollectionBindMode = "Index"
201+
CollectionBindValue CollectionBindMode = "Value"
202+
CollectionBindIndexAndValue CollectionBindMode = "Index & Value"
203+
)
204+
205+
type CollectionNameBinding struct {
206+
Mode CollectionBindMode
207+
Default string
208+
Index string
209+
Value string
210+
}
211+
212+
func (b *CollectionNameBinding) String() string {
213+
switch b.Mode {
214+
case CollectionBindDefault:
215+
return fmt.Sprintf("%v (%s)", b.Mode, b.Default)
216+
case CollectionBindIndex:
217+
return fmt.Sprintf("%v (%s)", b.Mode, b.Index)
218+
case CollectionBindValue:
219+
return fmt.Sprintf("%v (%s)", b.Mode, b.Value)
220+
case CollectionBindIndexAndValue:
221+
return fmt.Sprintf("%v (%s, %s)", b.Mode, b.Index, b.Value)
222+
default:
223+
return fmt.Sprintf("UNKNOWN (%s, %s, %s)", b.Default, b.Index, b.Value)
224+
}
225+
}
226+
227+
type CollectionOperator string
228+
229+
const (
230+
CollectionOpAll CollectionOperator = "ALL"
231+
CollectionOpAny CollectionOperator = "ANY"
232+
)
233+
234+
type CollectionExpression struct {
235+
Op CollectionOperator
236+
Selector Selector
237+
Inner Expression
238+
NameBinding CollectionNameBinding
239+
}
240+
241+
func (expr *CollectionExpression) ExpressionDump(w io.Writer, indent string, level int) {
242+
localIndent := strings.Repeat(indent, level)
243+
fmt.Fprintf(w, "%s%s %s on %v {\n", localIndent, expr.Op, expr.NameBinding.String(), expr.Selector)
244+
expr.Inner.ExpressionDump(w, indent, level+1)
245+
fmt.Fprintf(w, "%s}\n", localIndent)
246+
}

grammar/ast_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,79 @@ func TestAST_Dump(t *testing.T) {
7878
},
7979
expected: "UNKNOWN {\n Is Empty {\n Selector: foo.bar\n }\n Is Empty {\n Selector: foo.bar\n }\n}\n",
8080
},
81+
"All single variation": {
82+
expr: &CollectionExpression{
83+
NameBinding: CollectionNameBinding{
84+
Mode: CollectionBindDefault,
85+
Default: "k",
86+
},
87+
Op: CollectionOpAll,
88+
Selector: Selector{
89+
Type: SelectorTypeBexpr,
90+
Path: []string{"obj"},
91+
},
92+
Inner: &MatchExpression{
93+
Selector: Selector{
94+
Type: SelectorTypeBexpr,
95+
Path: []string{"v"},
96+
},
97+
Operator: 0,
98+
Value: &MatchValue{
99+
Raw: "hello",
100+
},
101+
},
102+
},
103+
expected: "ALL Default (k) on obj {\n Equal {\n Selector: v\n Value: \"hello\"\n }\n}\n",
104+
},
105+
"All": {
106+
expr: &CollectionExpression{
107+
NameBinding: CollectionNameBinding{
108+
Mode: CollectionBindIndexAndValue,
109+
Index: "k",
110+
Value: "v",
111+
},
112+
Op: CollectionOpAll,
113+
Selector: Selector{
114+
Type: SelectorTypeBexpr,
115+
Path: []string{"obj"},
116+
},
117+
Inner: &MatchExpression{
118+
Selector: Selector{
119+
Type: SelectorTypeBexpr,
120+
Path: []string{"v"},
121+
},
122+
Operator: 0,
123+
Value: &MatchValue{
124+
Raw: "hello",
125+
},
126+
},
127+
},
128+
expected: "ALL Index & Value (k, v) on obj {\n Equal {\n Selector: v\n Value: \"hello\"\n }\n}\n",
129+
},
130+
"Any": {
131+
expr: &CollectionExpression{
132+
NameBinding: CollectionNameBinding{
133+
Mode: CollectionBindIndex,
134+
Index: "k",
135+
},
136+
Op: CollectionOpAny,
137+
Selector: Selector{
138+
Type: SelectorTypeBexpr,
139+
Path: []string{"obj"},
140+
},
141+
Inner: &MatchExpression{
142+
Selector: Selector{
143+
Type: SelectorTypeBexpr,
144+
Path: []string{"v"},
145+
},
146+
Operator: 0,
147+
Value: &MatchValue{
148+
Raw: "hello",
149+
},
150+
},
151+
},
152+
expected: "ANY Index (k) on obj {\n Equal {\n Selector: v\n Value: \"hello\"\n }\n}\n",
153+
},
81154
}
82155

83156
for name, tcase := range tests {

0 commit comments

Comments
 (0)