From 8b9ac8db86c2435e8a90859eaa937d87ab510f84 Mon Sep 17 00:00:00 2001 From: youngkyunIm Date: Sun, 30 Nov 2025 20:28:12 +0900 Subject: [PATCH 1/2] feat: add array comparison operators (contains_all, contains_any, contains_none) Add three new operators for comparing arrays: - contains_all: Check if all elements of array B exist in array A - contains_any: Check if any element of array B exists in array A - contains_none: Check if no elements of array B exist in array A These operators are useful for workflow automation systems where users define conditions like: - "If selected options contain ALL of ['VIP', 'Premium']" - "If tags contain ANY of ['urgent', 'important']" - "If categories contain NONE of ['blocked', 'spam']" Includes comprehensive test cases for each operator. --- arrays.go | 155 ++++++++++++++++++++++++++++++++++ arrays_test.go | 220 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 arrays.go create mode 100644 arrays_test.go diff --git a/arrays.go b/arrays.go new file mode 100644 index 0000000..f777aee --- /dev/null +++ b/arrays.go @@ -0,0 +1,155 @@ +package jsonlogic + +import ( + "github.com/diegoholiveira/jsonlogic/v3/internal/typing" +) + +func init() { + AddOperator("contains_all", containsAll) + AddOperator("contains_any", containsAny) + AddOperator("contains_none", containsNone) +} + +// containsAll checks if all elements in the second array exist in the first array. +// Returns true if every element of the required array is found in the search array. +// +// Example: +// +// {"contains_all": [["a", "b", "c"], ["a", "b"]]} // true +// {"contains_all": [["a", "b"], ["a", "b", "c"]]} // false +func containsAll(values, data any) any { + parsed, ok := values.([]any) + if !ok || len(parsed) != 2 { + return false + } + + searchArray := toAnySlice(parsed[0]) + if searchArray == nil { + return false + } + + requiredArray := toAnySlice(parsed[1]) + if requiredArray == nil { + return false + } + + // Empty required array means all are "contained" + if len(requiredArray) == 0 { + return true + } + + for _, required := range requiredArray { + if !containsElement(searchArray, required) { + return false + } + } + + return true +} + +// containsAny checks if any element in the second array exists in the first array. +// Returns true if at least one element of the check array is found in the search array. +// +// Example: +// +// {"contains_any": [["a", "b", "c"], ["x", "b"]]} // true +// {"contains_any": [["a", "b", "c"], ["x", "y"]]} // false +func containsAny(values, data any) any { + parsed, ok := values.([]any) + if !ok || len(parsed) != 2 { + return false + } + + searchArray := toAnySlice(parsed[0]) + if searchArray == nil { + return false + } + + checkArray := toAnySlice(parsed[1]) + if checkArray == nil { + return false + } + + for _, check := range checkArray { + if containsElement(searchArray, check) { + return true + } + } + + return false +} + +// containsNone checks if no elements in the second array exist in the first array. +// Returns true if none of the elements of the check array are found in the search array. +// +// Example: +// +// {"contains_none": [["a", "b", "c"], ["x", "y"]]} // true +// {"contains_none": [["a", "b", "c"], ["x", "b"]]} // false +func containsNone(values, data any) any { + parsed, ok := values.([]any) + if !ok || len(parsed) != 2 { + return true + } + + searchArray := toAnySlice(parsed[0]) + if searchArray == nil { + return true + } + + checkArray := toAnySlice(parsed[1]) + if checkArray == nil { + return true + } + + for _, check := range checkArray { + if containsElement(searchArray, check) { + return false + } + } + + return true +} + +// toAnySlice converts an interface{} to []any if possible. +func toAnySlice(value any) []any { + if value == nil { + return nil + } + + if slice, ok := value.([]any); ok { + return slice + } + + return nil +} + +// containsElement checks if an element exists in a slice using proper comparison. +func containsElement(slice []any, element any) bool { + for _, item := range slice { + if isEqualValue(item, element) { + return true + } + } + return false +} + +// isEqualValue compares two values with type coercion for numbers. +func isEqualValue(a, b any) bool { + // Direct equality check + if a == b { + return true + } + + // Handle number comparison with type coercion + if typing.IsNumber(a) && typing.IsNumber(b) { + return typing.ToNumber(a) == typing.ToNumber(b) + } + + // Handle string comparison + if typing.IsString(a) && typing.IsString(b) { + return a.(string) == b.(string) + } + + return false +} diff --git a/arrays_test.go b/arrays_test.go new file mode 100644 index 0000000..7b81f6b --- /dev/null +++ b/arrays_test.go @@ -0,0 +1,220 @@ +package jsonlogic + +import ( + "bytes" + "strings" + "testing" +) + +func TestContainsAll(t *testing.T) { + tests := []struct { + name string + rule string + data string + expected string + }{ + { + name: "all elements present", + rule: `{"contains_all": [["a", "b", "c"], ["a", "b"]]}`, + data: `{}`, + expected: "true", + }, + { + name: "all elements present - exact match", + rule: `{"contains_all": [["a", "b"], ["a", "b"]]}`, + data: `{}`, + expected: "true", + }, + { + name: "some elements missing", + rule: `{"contains_all": [["a", "b"], ["a", "b", "c"]]}`, + data: `{}`, + expected: "false", + }, + { + name: "empty required array", + rule: `{"contains_all": [["a", "b", "c"], []]}`, + data: `{}`, + expected: "true", + }, + { + name: "empty search array", + rule: `{"contains_all": [[], ["a"]]}`, + data: `{}`, + expected: "false", + }, + { + name: "with variable", + rule: `{"contains_all": [{"var": "selected"}, ["vip", "premium"]]}`, + data: `{"selected": ["vip", "premium", "gold"]}`, + expected: "true", + }, + { + name: "with variable - missing element", + rule: `{"contains_all": [{"var": "selected"}, ["vip", "diamond"]]}`, + data: `{"selected": ["vip", "premium", "gold"]}`, + expected: "false", + }, + { + name: "with numbers", + rule: `{"contains_all": [[1, 2, 3, 4], [1, 3]]}`, + data: `{}`, + expected: "true", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var result bytes.Buffer + err := Apply(strings.NewReader(tt.rule), strings.NewReader(tt.data), &result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.TrimSpace(result.String()) != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, result.String()) + } + }) + } +} + +func TestContainsAny(t *testing.T) { + tests := []struct { + name string + rule string + data string + expected string + }{ + { + name: "one element present", + rule: `{"contains_any": [["a", "b", "c"], ["x", "b"]]}`, + data: `{}`, + expected: "true", + }, + { + name: "multiple elements present", + rule: `{"contains_any": [["a", "b", "c"], ["a", "c"]]}`, + data: `{}`, + expected: "true", + }, + { + name: "no elements present", + rule: `{"contains_any": [["a", "b", "c"], ["x", "y"]]}`, + data: `{}`, + expected: "false", + }, + { + name: "empty check array", + rule: `{"contains_any": [["a", "b", "c"], []]}`, + data: `{}`, + expected: "false", + }, + { + name: "empty search array", + rule: `{"contains_any": [[], ["a"]]}`, + data: `{}`, + expected: "false", + }, + { + name: "with variable", + rule: `{"contains_any": [{"var": "tags"}, ["urgent", "important"]]}`, + data: `{"tags": ["normal", "urgent"]}`, + expected: "true", + }, + { + name: "with variable - no match", + rule: `{"contains_any": [{"var": "tags"}, ["urgent", "important"]]}`, + data: `{"tags": ["normal", "low"]}`, + expected: "false", + }, + { + name: "with numbers", + rule: `{"contains_any": [[1, 2, 3], [5, 3, 7]]}`, + data: `{}`, + expected: "true", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var result bytes.Buffer + err := Apply(strings.NewReader(tt.rule), strings.NewReader(tt.data), &result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.TrimSpace(result.String()) != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, result.String()) + } + }) + } +} + +func TestContainsNone(t *testing.T) { + tests := []struct { + name string + rule string + data string + expected string + }{ + { + name: "no elements present", + rule: `{"contains_none": [["a", "b", "c"], ["x", "y"]]}`, + data: `{}`, + expected: "true", + }, + { + name: "one element present", + rule: `{"contains_none": [["a", "b", "c"], ["x", "b"]]}`, + data: `{}`, + expected: "false", + }, + { + name: "all elements present", + rule: `{"contains_none": [["a", "b", "c"], ["a", "b"]]}`, + data: `{}`, + expected: "false", + }, + { + name: "empty check array", + rule: `{"contains_none": [["a", "b", "c"], []]}`, + data: `{}`, + expected: "true", + }, + { + name: "empty search array", + rule: `{"contains_none": [[], ["a"]]}`, + data: `{}`, + expected: "true", + }, + { + name: "with variable - blocked words not present", + rule: `{"contains_none": [{"var": "content"}, ["spam", "blocked"]]}`, + data: `{"content": ["hello", "world"]}`, + expected: "true", + }, + { + name: "with variable - blocked word present", + rule: `{"contains_none": [{"var": "content"}, ["spam", "blocked"]]}`, + data: `{"content": ["hello", "spam"]}`, + expected: "false", + }, + { + name: "with numbers", + rule: `{"contains_none": [[1, 2, 3], [7, 8, 9]]}`, + data: `{}`, + expected: "true", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var result bytes.Buffer + err := Apply(strings.NewReader(tt.rule), strings.NewReader(tt.data), &result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.TrimSpace(result.String()) != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, result.String()) + } + }) + } +} From 143af87f23dbd50679f75537b7f96bf878c63f67 Mon Sep 17 00:00:00 2001 From: youngkyunIm Date: Mon, 1 Dec 2025 23:59:52 +0900 Subject: [PATCH 2/2] docs: add custom operators documentation with deprecation warning - Add Custom Operators section to README.md before LICENSE - Move operator registration from arrays.go init() to operation.go - Mark custom operators with /* CUSTOM OPERATORS */ comment --- arrays.go | 6 ------ operation.go | 5 +++++ readme.md | 10 ++++++++++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/arrays.go b/arrays.go index f777aee..f546025 100644 --- a/arrays.go +++ b/arrays.go @@ -4,12 +4,6 @@ import ( "github.com/diegoholiveira/jsonlogic/v3/internal/typing" ) -func init() { - AddOperator("contains_all", containsAll) - AddOperator("contains_any", containsAny) - AddOperator("contains_none", containsNone) -} - // containsAll checks if all elements in the second array exist in the first array. // Returns true if every element of the required array is found in the search array. // diff --git a/operation.go b/operation.go index 20f306e..bccef32 100644 --- a/operation.go +++ b/operation.go @@ -96,4 +96,9 @@ func init() { operators[">="] = isGreaterOrEqualThan operators["=="] = isEqual operators["!="] = func(v, d any) any { return !isEqual(v, d).(bool) } + + /* CUSTOM OPERATORS */ + operators["contains_all"] = func(v, d any) any { return containsAll(parseValues(v, d), d) } + operators["contains_any"] = func(v, d any) any { return containsAny(parseValues(v, d), d) } + operators["contains_none"] = func(v, d any) any { return containsNone(parseValues(v, d), d) } } diff --git a/readme.md b/readme.md index c904e81..603014d 100644 --- a/readme.md +++ b/readme.md @@ -164,6 +164,16 @@ func main() { } ``` +## Custom Operators (Non-standard) + +> ⚠️ **Warning**: These operators are not part of the official JsonLogic specification and may be deprecated in future versions. + +| Operator | Description | Example | +|----------|-------------|---------| +| `contains_all` | Returns `true` if **all** elements in the second array exist in the first array | `{"contains_all": [["a","b","c"], ["a","b"]]}` → `true` | +| `contains_any` | Returns `true` if **any** element in the second array exists in the first array | `{"contains_any": [["a","b"], ["x","a"]]}` → `true` | +| `contains_none` | Returns `true` if **no** elements in the second array exist in the first array | `{"contains_none": [["a","b"], ["x","y"]]}` → `true` | + # License This project is licensed under the MIT License - see [LICENSE](./LICENSE) for details.