Repository: honeydipper/honeydipper
Pull request #570 (2023-09-24 00:57:23)

feat: support negated condition in match conditions
<!--
Thanks for contributing!

See https://github.com/honeydipper/honeydipper/blob/master/CODE_OF_CONDUCT.md for information on our contributor code of conduct, and https://github.com/honeydipper/honeydipper/tree/master/docs for more information on developing on Honeydipper.

If possible, please follow these guidelines for commit messages:
https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines

-->
#### Description
<!--
Add a description of your pull request.
-->
Use `!` suffix in variable name to negate a condition. Use `:except:` to
negate a combination of conditions in a map. Add `:present:` for checking
existence of a key in a map.

Negating a condition is useful since Golang regular expression doesn't
support negative lookahead. Sometime it is needed to negate parts of the
regex condition.

#### This PR fixes the following issues
<!--
Replace this comment with fixed issues in the following format:
Fixes #123
Fixes #124
-->



Patch:
diff --git a/docs/workflow.md b/docs/workflow.md
index aba1ecc..f582a3e 100644
--- a/docs/workflow.md
+++ b/docs/workflow.md
@@ -162,7 +162,35 @@ workflows:
 
 Please note how we use regular expression, list of options to match the contextual data, and how to match a field deep into the data structure.
 
-Below are some examples of using list of conditions:
+The matching condition also supports some special keywords.
+
+ - :absent: - true if a key is not in the data structure
+ - :present: - true if a key is present in the data structure
+ - :except: - true if the data structure does NOT match the given criteria
+
+You can also use a `!` suffix following a key name to negate the matching criteria. See some examples below.
+
+```yaml
+---
+workflows:
+  responding_to_github_push:
+    if_match:
+      git_repo: myorg/myrepo
+      git_ref!: ":regex:^refs/tags/" # except tags
+    ...
+
+  alert_unauthorized_access:
+    if_match:
+      scope: production
+      ":except:":
+        user:
+          role: authorized
+          ":present:": authorized_by
+    ...
+
+```
+
+Below are some more examples of using list of conditions:
 <!-- {% raw %} -->
 ```yaml
 ---
diff --git a/pkg/dipper/condition.go b/pkg/dipper/condition.go
index 82d1374..d2bb444 100644
--- a/pkg/dipper/condition.go
+++ b/pkg/dipper/condition.go
@@ -64,18 +64,32 @@ func CompareMap(actual interface{}, criteria interface{}) bool {
 			case key == ":auth:":
 				// offload to another driver using RPC
 				// pass
+			case key == ":present:":
+				fallthrough
 			case key == ":absent:":
+				expectPresence := key == ":present:"
 				keys := []interface{}{}
 				for _, k := range value.MapKeys() {
 					keys = append(keys, k.Interface())
 				}
-				if CompareAll(keys, subCriteria) {
-					// key not absent
+				presence := CompareAll(keys, subCriteria)
+				if presence != expectPresence {
+					// key presence not matching expectation
+					return false
+				}
+			case key == ":except:":
+				if CompareAll(actual, subCriteria) {
 					return false
 				}
 			default:
+				neg := key[len(key)-1] == '!'
+				if neg {
+					key = key[0 : len(key)-1]
+				}
+
 				subVal := value.MapIndex(reflect.ValueOf(key))
-				if !subVal.IsValid() || (subVal.IsValid() && !CompareAll(subVal.Interface(), subCriteria)) {
+				match := subVal.IsValid() && CompareAll(subVal.Interface(), subCriteria)
+				if match == neg {
 					return false
 				}
 			}


Test patch:
diff --git a/pkg/dipper/condition_test.go b/pkg/dipper/condition_test.go
index f365367..3277451 100644
--- a/pkg/dipper/condition_test.go
+++ b/pkg/dipper/condition_test.go
@@ -68,10 +68,25 @@ func TestCompareAllMap(t *testing.T) {
 	assert.True(t, CompareAll(map[string]interface{}{"key1": "val1", "key2": "val2"}, map[string]interface{}{":absent:": "key3"}), "map should have key3 absent")
 	assert.True(t, CompareAll(map[string]interface{}{"key1": "val1", "key2": "val2"}, map[string]interface{}{":absent:": regexp.MustCompile("key3[1-3]")}), "map should have key3* absent")
 	assert.False(t, CompareAll(map[string]interface{}{"key1": "val1", "key2": "val2", "key3": "val3"}, map[string]interface{}{":absent:": regexp.MustCompile("key3[1-3]*")}), "map should have key3* absent and fail")
+	assert.False(t, CompareAll(map[string]interface{}{"key1": "val1", "key2": "val2"}, map[string]interface{}{":present:": "key3"}), "map should have key3 present")
+	assert.False(t, CompareAll(map[string]interface{}{"key1": "val1", "key2": "val2"}, map[string]interface{}{":present:": regexp.MustCompile("key3[1-3]")}), "map should have key3* present")
+	assert.True(t, CompareAll(map[string]interface{}{"key1": "val1", "key2": "val2", "key3": "val3"}, map[string]interface{}{":present:": regexp.MustCompile("key3[1-3]*")}), "map should have key3* present and fail")
 	assert.False(t, CompareAll(map[string]interface{}{"key1": "val1", "key2": "val2", "key3": "val3"}, "invalid condition"), "fail with invalid condition for map value")
 	assert.False(t, CompareAll(map[string]interface{}{"key1": "val1", "key2": "val2", "key3": "val3"}, map[string]interface{}{"key4": "111"}), "fail with condition that missing value")
 }
 
+func TestCompareWithNegates(t *testing.T) {
+	assert.False(t, CompareAll(map[string]interface{}{"key1": "val1", "key2": "val2"}, map[string]interface{}{"key1": "val1", "key2!": "val2"}), "map match nagated condition")
+	assert.True(t, CompareAll(map[string]interface{}{"key1": "val1", "key2": "val2"}, map[string]interface{}{"key1": "val1", "key2!": "val3"}), "map not match nagated condition")
+	assert.True(t, CompareAll(map[string]interface{}{"key1": "val1", "key2": "val2"}, map[string]interface{}{"key1": "val1", "key3!": "val3"}), "map missking key for nagated condition")
+}
+
+func TestCompareWithExcept(t *testing.T) {
+	assert.False(t, CompareAll(map[string]interface{}{"key1": "val1", "key2": "val2"}, map[string]interface{}{"key1": "val1", ":except:": map[string]interface{}{"key2": "val2"}}), "map match except condition")
+	assert.True(t, CompareAll(map[string]interface{}{"key1": "val1", "key2": "val2"}, map[string]interface{}{"key1": "val1", ":except:": map[string]interface{}{"key2": "val3"}}), "map not match except condition")
+	assert.True(t, CompareAll(map[string]interface{}{"key1": "val1", "key2": "val2"}, map[string]interface{}{"key1": "val1", ":except:": map[string]interface{}{"key3": "val3"}}), "map missking key for except condition")
+}
+
 func TestIsTruthy(t *testing.T) {
 	assert.False(t, IsTruthy(nil), "nil should NOT be truthy")
 	assert.False(t, IsTruthy(" 	"), "whitespace only string should NOT be truthy")