Skip to content
Draft
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
51 changes: 42 additions & 9 deletions go/mysql/json/json_path.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const (
jpMemberAny
jpArrayLocation
jpArrayLocationAny
jpArrayLocationRange
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to add a new json path kind here. Previously offset0 and offset1 were used to distinguish between an array offset and an array range, but this does not work for things like $[0 to 0] or $[last to last]. So I figured adding a separate kind is simpler and cleaner.

jpAny
)

Expand Down Expand Up @@ -71,6 +72,15 @@ func (jp *Path) format(b *strings.Builder) {
case jpMemberAny:
b.WriteString(".*")
case jpArrayLocation:
switch {
case jp.offset0 == -1:
b.WriteString("[last]")
case jp.offset0 >= 0:
_, _ = fmt.Fprintf(b, "[%d]", jp.offset0)
case jp.offset0 < 0:
_, _ = fmt.Fprintf(b, "[last%d]", jp.offset0+1)
}
case jpArrayLocationRange:
switch {
case jp.offset0 == -1:
b.WriteString("[last")
Expand All @@ -82,7 +92,7 @@ func (jp *Path) format(b *strings.Builder) {
switch {
case jp.offset1 == -1:
b.WriteString(" to last")
case jp.offset1 > 0:
case jp.offset1 >= 0:
_, _ = fmt.Fprintf(b, " to %d", jp.offset1)
case jp.offset1 < 0:
_, _ = fmt.Fprintf(b, " to last%d", jp.offset1+1)
Expand All @@ -107,12 +117,8 @@ func (jp *Path) String() string {
func (jp *Path) ContainsWildcards() bool {
for jp != nil {
switch jp.kind {
case jpAny, jpArrayLocationAny, jpMemberAny:
case jpAny, jpArrayLocationAny, jpArrayLocationRange, jpMemberAny:
return true
case jpArrayLocation:
if jp.offset1 != 0 {
return true
}
}
jp = jp.next
}
Expand Down Expand Up @@ -186,6 +192,15 @@ func (m *matcher) value(p *Path, v *Value) {
})
}
case jpArrayLocation:
if ary, ok := v.Array(); ok {
from, _ := p.arrayOffsets(ary)
if from >= 0 && from < len(ary) {
m.value(p.next, ary[from])
}
} else if m.wrap && (p.offset0 == 0 || p.offset0 == -1) {
m.value(p.next, v)
}
case jpArrayLocationRange:
if ary, ok := v.Array(); ok {
from, to := p.arrayOffsets(ary)
if from >= 0 && from < len(ary) {
Expand All @@ -196,7 +211,7 @@ func (m *matcher) value(p *Path, v *Value) {
m.value(p.next, ary[n])
}
}
} else if m.wrap && (p.offset0 == 0 || p.offset0 == -1) {
} else if m.wrap && (p.offset0 == 0 || p.offset1 == -1) {
m.value(p.next, v)
}
case jpArrayLocationAny:
Expand Down Expand Up @@ -233,6 +248,20 @@ func (jp *Path) transform(v *Value, t func(pp *Path, vv *Value)) {
jp.next.transform(obj.Get(jp.name), t)
}
case jpArrayLocation:
if ary, ok := v.Array(); ok {
from, _ := jp.arrayOffsets(ary)
if from >= 0 && from < len(ary) {
jp.next.transform(ary[from], t)
}
} else if jp.offset0 == 0 || jp.offset0 == -1 {
/*
If the path is evaluated against a value that is not an array,
the result of the evaluation is the same as if the value had been
wrapped in a single-element array:
*/
jp.next.transform(v, t)
}
case jpArrayLocationRange:
if ary, ok := v.Array(); ok {
from, to := jp.arrayOffsets(ary)
if from != to {
Expand All @@ -241,7 +270,7 @@ func (jp *Path) transform(v *Value, t func(pp *Path, vv *Value)) {
if from >= 0 && from < len(ary) {
jp.next.transform(ary[from], t)
}
} else if jp.offset0 == 0 || jp.offset0 == -1 {
} else if jp.offset0 == 0 || jp.offset1 == -1 {
/*
If the path is evaluated against a value that is not an array,
the result of the evaluation is the same as if the value had been
Expand Down Expand Up @@ -532,13 +561,17 @@ func stepArrayLocationTo(p *PathParser, in []byte) ([]byte, error) {
if in == nil || skip == 0 {
return nil, errInvalid
}

// Upgrade to range
p.path.kind = jpArrayLocationRange

if in[0] >= '0' && in[0] <= '9' {
p.step = stepArrayLocationClose
offset, in2, err := p.lexNumeric(in)
if err != nil {
return nil, err
}
if offset <= p.path.offset0 {
if offset < p.path.offset0 {
return nil, fmt.Errorf("range %d should be >= %d", offset, p.path.offset0)
}
p.path.offset1 = offset
Expand Down
127 changes: 64 additions & 63 deletions go/mysql/json/json_path_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ limitations under the License.
package json

import (
"slices"
"fmt"
"testing"

"github.com/stretchr/testify/require"
)

func TestParseJSONPath(t *testing.T) {
Expand All @@ -38,35 +40,33 @@ func TestParseJSONPath(t *testing.T) {
{P: `$.c[last-23 to last-444]`},
{P: `$.c[last-23 to last]`},
{P: `$.c[last]`},
{P: `$.c[last to last]`},
{P: `$.c[0 to 0]`},
{P: `$.c[last - 23 to last]`, Want: `$.c[last-23 to last]`},
{P: `$ . c [ last - 23 to last - 444 ]`, Want: `$.c[last-23 to last-444]`},
{P: `$. "a fish" `, Want: `$."a fish"`},
{P: `$ [last - 3 to last - 1`, Err: "Invalid JSON path expression. The error is around character position 28."},
}

for _, tc := range cases {
var p PathParser
jp, err := p.ParseBytes([]byte(tc.P))
if tc.Err == "" {
if err != nil {
t.Fatalf("failed to parse '%s': %v", tc.P, err)
t.Run(tc.P, func(t *testing.T) {
var p PathParser
jp, err := p.ParseBytes([]byte(tc.P))

if tc.Err != "" {
require.EqualError(t, err, tc.Err)
return
}
} else {
if err == nil {
t.Fatalf("bad parse for '%s': expected an error", tc.P)
} else if err.Error() != tc.Err {
t.Fatalf("bad parse for '%s': expected err='%s', got err='%s'", tc.P, tc.Err, err)

require.NoError(t, err)

want := tc.Want
if want == "" {
want = tc.P
}
continue
}
want := tc.Want
if want == "" {
want = tc.P
}
got := jp.String()
if got != want {
t.Fatalf("bad parse for '%s': want '%s', got '%s'", tc.P, want, got)
}

require.Equal(t, want, jp.String())
})
}
}

Expand All @@ -88,29 +88,33 @@ func TestJSONExtract(t *testing.T) {
{`{"a": 1, "b": 2, "c": [3, 4, 5]}`, `$.*`, []string{"1", "2", "[3, 4, 5]"}},
{`true`, `$[0]`, []string{"true"}},
{`true`, `$[last]`, []string{"true"}},
{`true`, `$[1]`, []string{}},
{`true`, `$[last-1]`, []string{}},
{`true`, `$[0 to 0]`, []string{"true"}},
{`true`, `$[0 to 1]`, []string{"true"}},
{`true`, `$[1 to 2]`, nil},
{`true`, `$[last to last]`, []string{"true"}},
{`true`, `$[last-4 to last]`, []string{"true"}},
{`true`, `$[last-4 to last-1]`, nil},
{`true`, `$[1]`, nil},
{`true`, `$[last-1]`, nil},
{`[ { "a": 1 }, { "a": 2 } ]`, `$**[0]`, []string{`{"a": 1}`, `1`, `{"a": 2}`, `2`}},
{`{ "a" : "foo", "b" : [ true, { "c" : 123, "c" : 456 } ] }`, `$.b[1].c`, []string{"456"}},
{`{ "a" : "foo", "b" : [ true, { "c" : "123" } ] }`, `$.b[1].c`, []string{"\"123\""}},
{`{ "a" : "foo", "b" : [ true, { "c" : 123 } ] }`, `$.b[1].c`, []string{"123"}},
}

for _, tc := range cases {
var matched []string
err := MatchPath([]byte(tc.J), []byte(tc.JP), func(value *Value) {
if value == nil {
return
}
matched = append(matched, string(value.MarshalTo(nil)))
t.Run(fmt.Sprintf("'%s' -> '%s'", tc.J, tc.JP), func(t *testing.T) {
var matched []string
err := MatchPath([]byte(tc.J), []byte(tc.JP), func(value *Value) {
if value == nil {
return
}
matched = append(matched, string(value.MarshalTo(nil)))
})

require.NoError(t, err)
require.Equal(t, tc.Expected, matched)
})
if err != nil {
t.Errorf("failed to match '%s'->'%s': %v", tc.J, tc.JP, err)
continue
}
if !slices.Equal(tc.Expected, matched) {
t.Errorf("'%s'->'%s' = %v (expected %v)", tc.J, tc.JP, matched, tc.Expected)
}
}
}

Expand All @@ -137,63 +141,60 @@ func TestTransformations(t *testing.T) {
const Path1 = `$[1].b[0]`
const Path2 = `$[2][2]`

cases := []struct {
cases := map[string]struct {
T Transformation
Document string
Paths []string
Values []string
Expected string
}{
{
"set operation": {
T: Set,
Document: Document1,
Paths: []string{Path1, Path2},
Values: []string{"1", "2"},
Expected: `["a", {"b": [1, false]}, [10, 20, 2]]`,
},
{
"insert operation": {
T: Insert,
Document: Document1,
Paths: []string{Path1, Path2},
Values: []string{"1", "2"},
Expected: `["a", {"b": [true, false]}, [10, 20, 2]]`,
},
{
"replace operation": {
T: Replace,
Document: Document1,
Paths: []string{Path1, Path2},
Values: []string{"1", "2"},
Expected: `["a", {"b": [1, false]}, [10, 20]]`,
},
{
"remove operation": {
T: Remove,
Document: Document1,
Paths: []string{`$[2]`, `$[1].b[1]`, `$[1].b[1]`},
Expected: `["a", {"b": [true]}]`,
},
}

for _, tc := range cases {
doc := json(t, tc.Document)

var paths []*Path
for _, p := range tc.Paths {
paths = append(paths, path(t, p))
}

var values []*Value
for _, v := range tc.Values {
values = append(values, json(t, v))
}

err := ApplyTransform(tc.T, doc, paths, values)
if err != nil {
t.Fatal(err)
}

result := string(doc.MarshalTo(nil))
if result != tc.Expected {
t.Errorf("bad transformation (%v)\nwant: %s\ngot: %s", tc.T, tc.Expected, result)
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
doc := json(t, tc.Document)

var paths []*Path
for _, p := range tc.Paths {
paths = append(paths, path(t, p))
}

var values []*Value
for _, v := range tc.Values {
values = append(values, json(t, v))
}

err := ApplyTransform(tc.T, doc, paths, values)
require.NoError(t, err)

require.Equal(t, tc.Expected, string(doc.MarshalTo(nil)))
})
}
}
Loading