Skip to content

Commit fc99aae

Browse files
committed
feat: conversion between jsonlogic and nikunjy/rules expression
1 parent 37867d6 commit fc99aae

File tree

13 files changed

+366
-0
lines changed

13 files changed

+366
-0
lines changed

converter/converter.go

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package converter
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"strconv"
8+
"strings"
9+
)
10+
11+
// JSONToExpression parses jsonlogic in string format to an expression
12+
// compatible with nikunjy/rules engine
13+
func JSONToExpression(jsonLogic string) (string, error) {
14+
var logicTree map[string]interface{}
15+
if err := json.Unmarshal([]byte(jsonLogic), &logicTree); err != nil {
16+
return "", err
17+
}
18+
return parseLogicTree(logicTree), nil
19+
}
20+
21+
func parseLogicTree(node map[string]interface{}) string {
22+
for _, key := range []string{"and", "or"} {
23+
if rulesList, ok := node[key].([]interface{}); ok {
24+
rules := make([]string, len(rulesList))
25+
for i, r := range rulesList {
26+
rules[i] = parseLogicTree(r.(map[string]interface{}))
27+
}
28+
return "(" + strings.Join(rules, " "+key+" ") + ")"
29+
}
30+
}
31+
32+
for op, value := range node {
33+
values := value.([]interface{})
34+
left := values[0]
35+
if m, ok := left.(map[string]interface{}); ok {
36+
left = m["var"]
37+
}
38+
return "(" + fmt.Sprintf("%v %s %v", left, op, values[1]) + ")"
39+
}
40+
41+
return ""
42+
}
43+
44+
45+
// ExpressionToJSON parses a nikunjy/rules expression to jsonLogic
46+
func ExpressionToJSON(expression string) (string, error) {
47+
tokens := strings.Fields(expression)
48+
49+
logicTree, err := buildLogicTree(tokens)
50+
if err != nil {
51+
return "", err
52+
}
53+
54+
buf := new(bytes.Buffer)
55+
enc := json.NewEncoder(buf)
56+
enc.SetEscapeHTML(false)
57+
58+
if err := enc.Encode(logicTree); err != nil {
59+
return "", err
60+
}
61+
62+
return strings.TrimSpace(buf.String()), nil
63+
}
64+
65+
func buildLogicTree(tokens []string) (map[string]interface{}, error) {
66+
var nodes []interface{}
67+
var logicalOperator string
68+
69+
for i := 0; i < len(tokens); i++ {
70+
token := tokens[i]
71+
72+
switch token {
73+
case "(":
74+
expression, tokensProcessed, err := parseParenExpression(tokens[i:])
75+
if err != nil {
76+
return nil, err
77+
}
78+
nodes = append(nodes, expression)
79+
i += tokensProcessed
80+
case "and", "or":
81+
logicalOperator = token
82+
case "not":
83+
expression, tokensProcessed, err := parseNotExpression(tokens[i:])
84+
if err != nil {
85+
return nil, err
86+
}
87+
nodes = append(nodes, expression)
88+
i += tokensProcessed
89+
default:
90+
expression, err := parseComparisonExpression(tokens[i:])
91+
if err != nil {
92+
return nil, err
93+
}
94+
nodes = append(nodes, expression)
95+
i += 2
96+
}
97+
}
98+
99+
if len(nodes) == 1 {
100+
return nodes[0].(map[string]interface{}), nil
101+
}
102+
103+
return map[string]interface{}{
104+
logicalOperator: nodes,
105+
}, nil
106+
}
107+
108+
func parseParenExpression(tokens []string) (map[string]interface{}, int, error) {
109+
level := 1
110+
end := 1
111+
112+
for level > 0 && end < len(tokens) {
113+
if tokens[end] == "(" {
114+
level++
115+
} else if tokens[end] == ")" {
116+
level--
117+
}
118+
end++
119+
}
120+
if level != 0 {
121+
return nil, 0, fmt.Errorf("invalid expression")
122+
}
123+
124+
expression, err := buildLogicTree(tokens[1 : end-1])
125+
if err != nil {
126+
return nil, 0, err
127+
}
128+
129+
return expression, end - 1, nil
130+
}
131+
132+
func parseNotExpression(tokens []string) (map[string]interface{}, int, error) {
133+
if len(tokens) < 2 {
134+
return nil, 0, fmt.Errorf("invalid expression")
135+
}
136+
137+
if tokens[1] == "(" {
138+
subExpr, newIndex, err := parseParenExpression(tokens[1:])
139+
if err != nil {
140+
return nil, 0, err
141+
}
142+
return map[string]interface{}{"!": subExpr}, newIndex + 1, nil
143+
}
144+
145+
if len(tokens) >= 4 && isComparisonOperator(tokens[2]) {
146+
return map[string]interface{}{
147+
"!": map[string]interface{}{
148+
tokens[2]: []interface{}{
149+
map[string]interface{}{"var": tokens[1]},
150+
parseValue(tokens[3]),
151+
},
152+
},
153+
}, 3, nil
154+
}
155+
156+
return map[string]interface{}{
157+
"!": map[string]interface{}{"var": tokens[1]},
158+
}, 1, nil
159+
}
160+
func isComparisonOperator(op string) bool {
161+
switch op {
162+
case "eq", "==",
163+
"ne", "!=",
164+
"lt", "<",
165+
"gt", ">",
166+
"le", "<=",
167+
"ge", ">=",
168+
"co", "sw", "ew",
169+
"in", "pr":
170+
return true
171+
}
172+
return false
173+
}
174+
175+
func parseComparisonExpression(tokens []string) (map[string]interface{}, error) {
176+
if len(tokens) < 3 {
177+
return nil, fmt.Errorf("invalid expression")
178+
}
179+
180+
varName := strings.Trim(tokens[0], "()")
181+
value := strings.Trim(tokens[2], "()")
182+
183+
return map[string]interface{}{
184+
tokens[1]: []interface{}{
185+
map[string]interface{}{"var": varName},
186+
parseValue(value),
187+
},
188+
}, nil
189+
}
190+
191+
func parseValue(val string) interface{} {
192+
if strings.HasPrefix(val, "[") && strings.HasSuffix(val, "]") {
193+
items := strings.Split(strings.Trim(val, "[]"), ",")
194+
var result []interface{}
195+
196+
for _, item := range items {
197+
item = strings.TrimSpace(item)
198+
if num, err := strconv.Atoi(item); err == nil {
199+
result = append(result, num)
200+
} else {
201+
result = append(result, strings.Trim(item, "\"'"))
202+
}
203+
}
204+
return result
205+
}
206+
207+
if num, err := strconv.Atoi(val); err == nil {
208+
return num
209+
}
210+
211+
return strings.Trim(val, "\"'")
212+
}

test/converter/converter_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/ahuangg/json-rules/converter"
7+
)
8+
9+
10+
func TestJSONToExpression(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
input string
14+
expected string
15+
wantErr bool
16+
}{
17+
{
18+
name: "equal test",
19+
input: `{"eq": [{"var": "x"}, 1]}`,
20+
expected: "(x eq 1)",
21+
},
22+
{
23+
name: "equal test 2",
24+
input: `{"==": [{"var": "x"}, 2]}`,
25+
expected: "(x == 2)",
26+
},
27+
{
28+
name: "less than test",
29+
input: `{"<": [{"var": "x"}, 2]}`,
30+
expected: "(x < 2)",
31+
},
32+
{
33+
name: "less than test 2",
34+
input: `{"<": [{"var": "x"}, 1]}`,
35+
expected: "(x < 1)",
36+
},
37+
{
38+
name: "greater than test",
39+
input: `{">": [{"var": "x"}, 0]}`,
40+
expected: "(x > 0)",
41+
},
42+
{
43+
name: "greater than test 2",
44+
input: `{">": [{"var": "x"}, 2]}`,
45+
expected: "(x > 2)",
46+
},
47+
{
48+
name: "equal and less than equal test",
49+
input: `{"and": [{"==": [{"var": "x.a"}, 1]}, {"<=": [{"var": "x.b.c"}, 2]}]}`,
50+
expected: "((x.a == 1) and (x.b.c <= 2))",
51+
},
52+
{
53+
name: "equal and greater than test",
54+
input: `{"and": [{"==": [{"var": "y"}, 4]}, {">": [{"var": "x"}, 1]}]}`,
55+
expected: "((y == 4) and (x > 1))",
56+
},
57+
{
58+
name: "in test",
59+
input: `{"and": [{"==": [{"var": "y"}, 4]}, {"in": [{"var": "x"}, [1, 2, 3]]}]}`,
60+
expected: "((y == 4) and (x in [1 2 3]))",
61+
},
62+
{
63+
name: "equal string test",
64+
input: `{"and": [{"==": [{"var": "y"}, 4]}, {"eq": [{"var": "x"}, "1.2.3"]}]}`,
65+
expected: `((y == 4) and (x eq 1.2.3))`,
66+
},
67+
}
68+
69+
for _, tt := range tests {
70+
t.Run(tt.name, func(t *testing.T) {
71+
got, err := converter.JSONToExpression(tt.input)
72+
if err != nil {
73+
return
74+
}
75+
76+
if got != tt.expected {
77+
t.Errorf("JSONToExpression() = %v, want %v", got, tt.expected)
78+
}
79+
})
80+
}
81+
}
82+
83+
func TestExpressionToJSON(t *testing.T) {
84+
tests := []struct {
85+
name string
86+
input string
87+
expected string
88+
wantErr bool
89+
}{
90+
{
91+
name: "equal test",
92+
input: "x eq 1",
93+
expected: `{"eq":[{"var":"x"},1]}`,
94+
},
95+
{
96+
name: "equal test 2",
97+
input: "x == 2",
98+
expected: `{"==":[{"var":"x"},2]}`,
99+
},
100+
{
101+
name: "less than test",
102+
input: "x < 2",
103+
expected: `{"<":[{"var":"x"},2]}`,
104+
},
105+
{
106+
name: "less than test 2",
107+
input: "x < 1",
108+
expected: `{"<":[{"var":"x"},1]}`,
109+
},
110+
{
111+
name: "greater than test",
112+
input: "x > 0",
113+
expected: `{">":[{"var":"x"},0]}`,
114+
},
115+
{
116+
name: "greater than test 2",
117+
input: "x > 2",
118+
expected: `{">":[{"var":"x"},2]}`,
119+
},
120+
{
121+
name: "equal and less than equal test",
122+
input: "((x.a == 1) and (x.b.c <= 2))",
123+
expected: `{"and":[{"==":[{"var":"x.a"},1]},{"<=":[{"var":"x.b.c"},2]}]}`,
124+
},
125+
{
126+
name: "equal and greater than test",
127+
input: "y == 4 and (x > 1)",
128+
expected: `{"and":[{"==":[{"var":"y"},4]},{">":[{"var":"x"},1]}]}`,
129+
},
130+
{
131+
name: "in test",
132+
input: "y == 4 and (x in [1 2 3])",
133+
expected: `{"and":[{"==":[{"var":"y"},4]},{"in":[{"var":"x"},[1,2,3]]}]}`,
134+
},
135+
{
136+
name: "equal string test",
137+
input: `y == 4 and (x eq 1.2.3)`,
138+
expected: `{"and":[{"==":[{"var":"y"},4]},{"eq":[{"var":"x"},"1.2.3"]}]}`,
139+
},
140+
}
141+
142+
for _, tt := range tests {
143+
t.Run(tt.name, func(t *testing.T) {
144+
got, err := converter.ExpressionToJSON(tt.input)
145+
if err != nil {
146+
return
147+
}
148+
149+
if got != tt.expected {
150+
t.Errorf("ExpressionToJSON() = %v, want %v", got, tt.expected)
151+
}
152+
})
153+
}
154+
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.

0 commit comments

Comments
 (0)