Skip to content

Commit

Permalink
implement array notation and add tests for all operations to cover ar…
Browse files Browse the repository at this point in the history
…rays, extract common parts of simplejson and simpleyaml into simpleobject
  • Loading branch information
can3p committed Apr 15, 2024
1 parent 9c250bb commit 79bd193
Show file tree
Hide file tree
Showing 15 changed files with 520 additions and 502 deletions.
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
2 changes: 1 addition & 1 deletion pkg/operations/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"github.com/can3p/sackmesser/pkg/traverse/types"
)

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

if err == types.ErrFieldMissing {
Expand Down
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
2 changes: 1 addition & 1 deletion 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 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
55 changes: 46 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 @@ -37,7 +39,7 @@ var operations = map[string]Operation{
//nolint:govet
type Call struct {
Name string `@Ident`
Path []PathElement `"(" (@Ident | @String ) ( "." (@Ident | @String) )*`
Path []PathElement `"(" @@+`
Arguments []Argument `( "," @@ )* ")"`
}

Expand All @@ -51,10 +53,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 +174,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
40 changes: 22 additions & 18 deletions pkg/operations/parse_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package operations

import (
"strings"
"testing"

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

func TestParse(t *testing.T) {
Expand All @@ -14,90 +14,97 @@ func TestParse(t *testing.T) {
isError bool
ExpectedOp string
ExpectedArgs []any
ExpectedPath []string
ExpectedPath []types.PathElement
}{
{
description: "test nested path",
input: "set(field.another_field, true)",
ExpectedOp: "set",
ExpectedPath: []string{"field", "another_field"},
ExpectedPath: testPath("field", "another_field"),
ExpectedArgs: []any{true},
},
{
description: "test nested path with array lookup",
input: `set(field[0].another_field[8][9]."one more", true)`,
ExpectedOp: "set",
ExpectedPath: testPath("field", 0, "another_field", 8, 9, "one more"),
ExpectedArgs: []any{true},
},
{
description: "test path with strings",
input: "set(field.\"another field\", true)",
ExpectedOp: "set",
ExpectedPath: []string{"field", "another field"},
ExpectedPath: testPath("field", "another field"),
ExpectedArgs: []any{true},
},
{
description: "test boolean",
input: "set(field, true)",
ExpectedOp: "set",
ExpectedPath: []string{"field"},
ExpectedPath: testPath("field"),
ExpectedArgs: []any{true},
},
{
description: "test int",
input: "set(field, 12345)",
ExpectedOp: "set",
ExpectedPath: []string{"field"},
ExpectedPath: testPath("field"),
ExpectedArgs: []any{12345},
},
{
description: "test string with single quotes",
input: `set(field, '123" 45')`,
ExpectedOp: "set",
ExpectedPath: []string{"field"},
ExpectedPath: testPath("field"),
ExpectedArgs: []any{"123\" 45"},
},
{
description: "test string with double quotes",
input: `set(field, "123' 45")`,
ExpectedOp: "set",
ExpectedPath: []string{"field"},
ExpectedPath: testPath("field"),
ExpectedArgs: []any{"123' 45"},
},
{
description: "test string with back ticks",
input: "set(field, `123'\" 45`)",
ExpectedOp: "set",
ExpectedPath: []string{"field"},
ExpectedPath: testPath("field"),
ExpectedArgs: []any{`123'" 45`},
},
{
description: "test string with single quotes",
input: `set(field, '123" 45')`,
ExpectedOp: "set",
ExpectedPath: []string{"field"},
ExpectedPath: testPath("field"),
ExpectedArgs: []any{"123\" 45"},
},
{
description: "test bare word without quotes",
input: `set(field, awesome)`,
ExpectedOp: "set",
ExpectedPath: []string{"field"},
ExpectedPath: testPath("field"),
ExpectedArgs: []any{"awesome"},
},
{
description: "test null",
input: "set(field, null)",
ExpectedOp: "set",
ExpectedPath: []string{"field"},
ExpectedPath: testPath("field"),
ExpectedArgs: []any{nil},
},
{
description: "test json",
input: `set(field, { "a": true })`,
ExpectedOp: "set",
ExpectedPath: []string{"field"},
ExpectedPath: testPath("field"),
ExpectedArgs: []any{map[string]any{"a": true}},
},
{
description: "test one more json",
input: `set(field, { "abc": [1,2, false] })`,
ExpectedOp: "set",
ExpectedPath: []string{"field"},
ExpectedPath: testPath("field"),
ExpectedArgs: []any{map[string]any{"abc": []any{float64(1), float64(2), false}}},
},
}
Expand All @@ -124,10 +131,7 @@ func TestParse(t *testing.T) {
continue
}

expectedPath := strings.Join(ex.ExpectedPath, ".")
gotPath := strings.Join(parsed.Path, ".")

assert.Equal(t, ex.ExpectedPath, parsed.Path, "[%d - %s] expected path %s, but got %s", idx+1, ex.description, expectedPath, gotPath)
assert.Equal(t, ex.ExpectedPath, parsed.Path, "[%d - %s] expected path %s, but got %s", idx+1, ex.description, ex.ExpectedPath, parsed.Path)

assert.Equal(t, ex.ExpectedArgs, parsed.Args, "[%d - %s] arguments mismatch", idx+1, ex.description)
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/operations/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"github.com/pkg/errors"
)

func Set(root types.Node, path []string, args ...any) error {
func Set(root types.Node, path []types.PathElement, args ...any) error {
if len(args) != 1 {
return errors.Errorf("set operation expects one argument")
}
Expand Down
Loading

0 comments on commit 79bd193

Please sign in to comment.