diff --git a/arrays.go b/arrays.go new file mode 100644 index 0000000..f546025 --- /dev/null +++ b/arrays.go @@ -0,0 +1,149 @@ +package jsonlogic + +import ( + "github.com/diegoholiveira/jsonlogic/v3/internal/typing" +) + +// 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()) + } + }) + } +} 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.