diff --git a/README.md b/README.md index c011f04..d024f4d 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Available operations: | ------------- | ------------- | | set(path, value) | assign field to a particular value | | del(path) | delete a key | +| merge(path, value) | merge json value into the path. Only JSON values are allowed | ## Examples: diff --git a/pkg/operations/merge.go b/pkg/operations/merge.go new file mode 100644 index 0000000..6502c6a --- /dev/null +++ b/pkg/operations/merge.go @@ -0,0 +1,70 @@ +package operations + +import ( + "github.com/can3p/sackmesser/pkg/traverse/types" + "github.com/pkg/errors" +) + +func Merge(root types.Node, path []string, args ...any) error { + if len(args) != 1 { + return errors.Errorf("set operation expects one argument") + } + + value, ok := args[0].(map[string]any) + if !ok { + return errors.Errorf("Merge expects a json as an argument") + } + + if len(path) == 1 { + fieldName := path[0] + fieldVal, err := root.GetField(fieldName) + + if err == types.ErrFieldMissing { + return root.SetField(fieldName, value) + } + + if err != nil { + return err + } + + fieldVal = mergeObject(fieldVal, value) + return root.SetField(fieldName, fieldVal) + } + + node, err := root.Visit(path[0]) + + if err != nil { + return err + } + + return Merge(node, path[1:], value) +} + +func mergeObject(existingValue any, value map[string]any) any { + typed, ok := existingValue.(map[string]any) + + if !ok { + return value + } + + for fieldName, value := range value { + subfieldValue, exists := typed[fieldName] + + if !exists { + typed[fieldName] = value + continue + } + + typedSubfieldValue, subfieldIsMap := subfieldValue.(map[string]any) + typedNewValue, newValueIsMap := value.(map[string]any) + + if !subfieldIsMap || !newValueIsMap { + typed[fieldName] = value + continue + } + + typed[fieldName] = mergeObject(typedSubfieldValue, typedNewValue) + } + + return typed +} diff --git a/pkg/operations/merge_test.go b/pkg/operations/merge_test.go new file mode 100644 index 0000000..c3951d6 --- /dev/null +++ b/pkg/operations/merge_test.go @@ -0,0 +1,68 @@ +package operations + +import ( + "testing" + + "github.com/alecthomas/assert/v2" + "github.com/can3p/sackmesser/pkg/traverse/simplejson" +) + +func TestMergeOperation(t *testing.T) { + jstr := `{ "abc": { "def": { "cfa": [1, 2, 3] } } }` + + examples := []struct { + description string + path []string + arg any + initial string + expected string + isErr bool + }{ + { + description: "add new field to the object", + path: []string{"abc", "def"}, + arg: map[string]any{"added": true}, + initial: jstr, + expected: `{ "abc": { "def": { "cfa": [ 1, 2, 3 ], "added": true } } }`, + }, + { + description: "scalar value should produce an error", + path: []string{"abc", "def"}, + arg: true, + initial: jstr, + isErr: true, + }, + { + description: "non existant field is just set", + path: []string{"abc", "new field"}, + arg: map[string]any{"added": true}, + initial: jstr, + expected: `{ "abc": { "def": { "cfa": [ 1, 2, 3 ] }, "new field": { "added": true } } }`, + }, + { + description: "non object target field means set", + path: []string{"abc", "def"}, + arg: map[string]any{"added": true}, + initial: `{ "abc": { "def": true } }`, + expected: `{ "abc": { "def": { "added": true } } }`, + }, + } + + for idx, ex := range examples { + node := simplejson.MustParse([]byte(ex.initial)) + + err := Merge(node, ex.path, ex.arg) + + if ex.isErr { + assert.Error(t, err, "[Ex %d - %s]", idx+1, ex.description) + continue + } else { + assert.NoError(t, err, "[Ex %d - %s]", idx+1, ex.description) + } + + expected := simplejson.MustParse([]byte(ex.expected)) + + assert.Equal(t, expected.Value(), node.Value(), "[Ex %d - %s]", idx+1, ex.description) + } + +} diff --git a/pkg/operations/parse.go b/pkg/operations/parse.go index fad236a..25cb7d5 100644 --- a/pkg/operations/parse.go +++ b/pkg/operations/parse.go @@ -29,8 +29,9 @@ func (op *OpInstance) Apply(root types.Node) error { } var operations = map[string]Operation{ - "set": Set, - "del": Delete, + "set": Set, + "del": Delete, + "merge": Merge, } //nolint:govet diff --git a/pkg/traverse/simplejson/simplejson.go b/pkg/traverse/simplejson/simplejson.go index c20bd8b..f496613 100644 --- a/pkg/traverse/simplejson/simplejson.go +++ b/pkg/traverse/simplejson/simplejson.go @@ -70,6 +70,20 @@ func (n *jnode) Visit(field string) (types.Node, error) { panic("unreachable") } +func (n *jnode) GetField(field string) (any, error) { + typed, ok := n.v.(map[string]any) + + if !ok { + return nil, types.ErrWrongVisit + } + + if value, ok := typed[field]; ok { + return value, nil + } + + return nil, types.ErrFieldMissing +} + func (n *jnode) SetField(field string, value any) error { switch n.NodeType() { case types.NodeTypeNull: diff --git a/pkg/traverse/simpleyaml/simpleyaml.go b/pkg/traverse/simpleyaml/simpleyaml.go index ad64313..92baabb 100644 --- a/pkg/traverse/simpleyaml/simpleyaml.go +++ b/pkg/traverse/simpleyaml/simpleyaml.go @@ -71,6 +71,20 @@ func (n *jnode) Visit(field string) (types.Node, error) { panic("unreachable") } +func (n *jnode) GetField(field string) (any, error) { + typed, ok := n.v.(map[string]any) + + if !ok { + return nil, types.ErrWrongVisit + } + + if value, ok := typed[field]; ok { + return value, nil + } + + return nil, types.ErrFieldMissing +} + func (n *jnode) SetField(field string, value any) error { switch n.NodeType() { case types.NodeTypeNull: diff --git a/pkg/traverse/types/types.go b/pkg/traverse/types/types.go index 99f6b28..0edd70e 100644 --- a/pkg/traverse/types/types.go +++ b/pkg/traverse/types/types.go @@ -21,6 +21,7 @@ type Node interface { Visit(field string) (Node, error) NodeType() NodeType Value() any + GetField(field string) (any, error) SetField(field string, value any) error DeleteField(field string) error Serialize() ([]byte, error)