Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Array support #5

Merged
merged 5 commits into from
Apr 15, 2024
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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Or worse, where you had to count spaces in yaml? Fear no more, sackmesser will t

Operations have a signature like `op_name(path, args*)` where

* path is dot delimited, you could use quotes in case field names contain spaces, dots, commas, etc:
* path is dot delimited, you could use quotes in case field names contain spaces, dots, commas, etc. For array access you can use array notation:

```
echo '{ "a" : { "test prop": 1 } }' | sackmesser mod 'set(a."test prop", "test")'
Expand All @@ -27,6 +27,16 @@ Operations have a signature like `op_name(path, args*)` where
}
```

```
echo '{ "a" : { "test prop": [ 1 , true ] } }' | sackmesser mod 'set(a."test prop"[1], "test")'
"a": {
"test prop": [
1,
"test"
]
}
```

* Zero or more arguments are required for an operation. An argument could be one of
- a number
- `null`
Expand All @@ -40,6 +50,8 @@ 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 |
| pop(path) | remove last element from an array |
| push(path, value) | add new element to an array |

## Examples:

Expand Down
2 changes: 1 addition & 1 deletion cmd/mod.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func ModCommand() *cobra.Command {
}
}

var outputRoot types.Node
var outputRoot types.RootNode

switch outputFormat {
case "yaml":
Expand Down
10 changes: 3 additions & 7 deletions pkg/operations/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,14 @@ import (
"github.com/can3p/sackmesser/pkg/traverse/types"
)

func Delete(root types.Node, path []string, args ...any) error {
if len(path) == 1 {
return root.DeleteField(path[0])
}

node, err := root.Visit(path[0])
func Delete(root types.Node, path []types.PathElement, args ...any) error {
node, lastChunk, err := traverseButOne(root, path)

if err == types.ErrFieldMissing {
return nil
} else if err != nil {
return err
}

return Delete(node, path[1:])
return node.DeleteField(lastChunk)
}
19 changes: 16 additions & 3 deletions pkg/operations/delete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,36 @@ import (

"github.com/alecthomas/assert/v2"
"github.com/can3p/sackmesser/pkg/traverse/simplejson"
"github.com/can3p/sackmesser/pkg/traverse/types"
)

func TestDeleteOperation(t *testing.T) {
jstr := `{ "abc": { "def": [ 1, 2, 3 ] } }`

examples := []struct {
description string
path []string
path []types.PathElement
expected string
isErr bool
}{
{
description: "delete existing field",
path: []string{"abc"},
path: testPath("abc"),
expected: `{}`,
},
{
description: "delete array item using object notation",
path: testPath("abc", "def", "1"),
expected: `{ "abc": { "def": [ 1, 3 ] } }`,
},
{
description: "delete array item using array notation",
path: testPath("abc", "def", "1"),
expected: `{ "abc": { "def": [ 1, 3 ] } }`,
},
{
description: "delete missing field is fine, it was deleted already",
path: []string{"nonexistant"},
path: testPath("nonexistant"),
expected: jstr,
},
}
Expand All @@ -36,6 +47,8 @@ func TestDeleteOperation(t *testing.T) {
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))
Expand Down
25 changes: 10 additions & 15 deletions pkg/operations/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"github.com/pkg/errors"
)

func Merge(root types.Node, path []string, args ...any) error {
func Merge(root types.Node, path []types.PathElement, args ...any) error {
if len(args) != 1 {
return errors.Errorf("set operation expects one argument")
}
Expand All @@ -15,29 +15,24 @@ func Merge(root types.Node, path []string, args ...any) error {
return errors.Errorf("Merge expects a json as an argument")
}

if len(path) == 1 {
fieldName := path[0]
fieldVal, err := root.GetField(fieldName)
node, fieldName, err := traverseButOne(root, path)

if err == types.ErrFieldMissing {
return root.SetField(fieldName, value)
}
if err != nil {
return err
}

if err != nil {
return err
}
fieldVal, err := node.GetField(fieldName)

fieldVal = mergeObject(fieldVal, value)
return root.SetField(fieldName, fieldVal)
if err == types.ErrFieldMissing {
return node.SetField(fieldName, value)
}

node, err := root.Visit(path[0])

if err != nil {
return err
}

return Merge(node, path[1:], value)
fieldVal = mergeObject(fieldVal, value)
return node.SetField(fieldName, fieldVal)
}

func mergeObject(existingValue any, value map[string]any) any {
Expand Down
11 changes: 6 additions & 5 deletions pkg/operations/merge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,44 @@ import (

"github.com/alecthomas/assert/v2"
"github.com/can3p/sackmesser/pkg/traverse/simplejson"
"github.com/can3p/sackmesser/pkg/traverse/types"
)

func TestMergeOperation(t *testing.T) {
jstr := `{ "abc": { "def": { "cfa": [1, 2, 3] } } }`

examples := []struct {
description string
path []string
path []types.PathElement
arg any
initial string
expected string
isErr bool
}{
{
description: "add new field to the object",
path: []string{"abc", "def"},
path: testPath("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"},
path: testPath("abc", "def"),
arg: true,
initial: jstr,
isErr: true,
},
{
description: "non existant field is just set",
path: []string{"abc", "new field"},
path: testPath("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"},
path: testPath("abc", "def"),
arg: map[string]any{"added": true},
initial: `{ "abc": { "def": true } }`,
expected: `{ "abc": { "def": { "added": true } } }`,
Expand Down
57 changes: 48 additions & 9 deletions pkg/operations/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package operations
import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"

"github.com/alecthomas/participle/v2"
Expand All @@ -11,17 +13,17 @@ import (
"github.com/pkg/errors"
)

type Operation func(root types.Node, path []string, args ...any) error
type Operation func(root types.Node, path []types.PathElement, args ...any) error

type OpInstance struct {
Op Operation
Name string
Path []string
Path types.PathElementSlice
Args []any
}

func (op *OpInstance) String() string {
return fmt.Sprintf("Op: %s, Path: %s, Args: %v", op.Name, strings.Join(op.Path, "."), op.Args)
return fmt.Sprintf("Op: %s, Path: %s, Args: %v", op.Name, op.Path.String(), op.Args)
}

func (op *OpInstance) Apply(root types.Node) error {
Expand All @@ -32,12 +34,14 @@ var operations = map[string]Operation{
"set": Set,
"del": Delete,
"merge": Merge,
"pop": Pop,
"push": Push,
}

//nolint:govet
type Call struct {
Name string `@Ident`
Path []PathElement `"(" (@Ident | @String ) ( "." (@Ident | @String) )*`
Path []PathElement `"(" @@+`
Arguments []Argument `( "," @@ )* ")"`
}

Expand All @@ -51,10 +55,40 @@ type Argument struct {
JSON *JSON `| @JSON`
}

type PathElement string
//nolint:govet
type PathElement struct {
// potential foot gun there, I did not want to have a
// leading dot, but I could not write a grammar rule
// to exclude it from the first match only, hence
// I've made it optional
ObjectField StringPathElement ` "."? (@String | @Ident)`
ArrayIdx ArrIndexPathElement ` | @JSON`
}

type StringPathElement string

func (b *StringPathElement) Capture(values []string) error {
*b = StringPathElement(strings.Trim(values[0], "\"'`"))
return nil
}

type ArrIndexPathElement int

var arrayAccessRE = regexp.MustCompile(`\[-?\d+\]`)

// we need to do this because lexer will return text like `[0]` as a single token because of json
func (b *ArrIndexPathElement) Capture(values []string) error {
if !arrayAccessRE.MatchString(values[0]) {
return errors.Errorf("Not an array lookup")
}

idx, err := strconv.Atoi(strings.Trim(values[0], "[]"))

if err != nil {
return err
}

func (b *PathElement) Capture(values []string) error {
*b = PathElement(strings.Trim(values[0], "\"'`"))
*b = ArrIndexPathElement(idx)
return nil
}

Expand Down Expand Up @@ -142,9 +176,14 @@ func (p *Parser) Parse(s string) (*OpInstance, error) {
}
}

path := make([]string, 0, len(parsed.Path))
// I've duplicated types to keep parsing data structures
// and traversal api independent
path := make([]types.PathElement, 0, len(parsed.Path))
for _, p := range parsed.Path {
path = append(path, string(p))
path = append(path, types.PathElement{
ObjectField: string(p.ObjectField),
ArrayIdx: int(p.ArrayIdx),
})
}

return &OpInstance{
Expand Down
Loading
Loading