Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions arrays.go
Original file line number Diff line number Diff line change
@@ -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
}
220 changes: 220 additions & 0 deletions arrays_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
})
}
}
5 changes: 5 additions & 0 deletions operation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
10 changes: 10 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.