Skip to content

Commit

Permalink
Accept number-like keys in JSON Pointers
Browse files Browse the repository at this point in the history
  • Loading branch information
josephburnett committed Feb 25, 2024
1 parent 1e10b14 commit 0a1ac0a
Show file tree
Hide file tree
Showing 5 changed files with 185 additions and 7 deletions.
13 changes: 12 additions & 1 deletion lib/list.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package jd

import "fmt"
import (
"fmt"
"strconv"
)

type jsonList []JsonNode

Expand Down Expand Up @@ -146,6 +149,14 @@ func (l jsonList) patch(pathBehind, pathAhead path, oldValues, newValues []JsonN
}
// Recursive case
n, _, rest := pathAhead.next()
// Special case for jsonStringOrInteger
sori, ok := n.(jsonStringOrInteger)
if ok {
if i, err := strconv.Atoi(string(sori)); err == nil {
n = jsonNumber(float64(i))
}
}
// Path entries for lists must be a number
jn, ok := n.(jsonNumber)
if !ok {
return nil, fmt.Errorf(
Expand Down
6 changes: 6 additions & 0 deletions lib/object.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,12 @@ func (o jsonObject) patch(pathBehind, pathAhead path, oldValues, newValues []Jso
}
// Recursive case
n, _, rest := pathAhead.next()
// Special case for jsonStringOrInteger
sori, ok := n.(jsonStringOrInteger)
if ok {
n = jsonString(sori)
}
// Path entries for objects must be a string
pe, ok := n.(jsonString)
if !ok {
return nil, fmt.Errorf(
Expand Down
11 changes: 5 additions & 6 deletions lib/pointer.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ func readPointer(s string) ([]JsonNode, error) {
for i, t := range tokens {
var element JsonNode
var err error
number, err := strconv.Atoi(t)
if err == nil {
element, err = NewJsonNode(number)
if _, err := strconv.Atoi(t); err == nil {
// Wait to decide if we use this token as a string or integer.
element = jsonStringOrInteger(t)
} else {
element, err = NewJsonNode(t)
}
Expand All @@ -47,14 +47,13 @@ func writePointer(path []JsonNode) (string, error) {
b.WriteString(jsonpointer.Escape(strconv.Itoa(int(e))))
}
case jsonString:
if _, err := strconv.Atoi(string(e)); err == nil {
return "", fmt.Errorf("JSON Pointer does not support object keys that look like numbers: %v", e)
}
if string(e) == "-" {
return "", fmt.Errorf("JSON Pointer does not support object key '-'")
}
s := jsonpointer.Escape(string(e))
b.WriteString(s)
case jsonStringOrInteger:
b.WriteString(string(e))
case jsonArray:
return "", fmt.Errorf("JSON Pointer does not support jd metadata")
default:
Expand Down
82 changes: 82 additions & 0 deletions lib/string_or_integer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package jd

import "strconv"

// jsonStringOrInteger is a string unless it needs to be a number. It's
// created only when encountering an integer token in a JSON
// Pointer. Because the JSON Pointer spec doesn't quote strings, it's
// impossible to tell if an integer is supposed to be an array index or a
// map key. Rather than forbidding map keys that look like integers, we
// defer determining the concrete type until the token is actually
// used. When indexing into an object it's a string. When indexing into a
// list it's an integer.
type jsonStringOrInteger string

var _ JsonNode = jsonStringOrInteger("")

func (sori jsonStringOrInteger) Json(_ ...Metadata) string {
return renderJson(sori.raw())
}

func (sori jsonStringOrInteger) Yaml(_ ...Metadata) string {
return renderYaml(sori.raw())
}

func (sori jsonStringOrInteger) raw() interface{} {
return string(sori)
}

func (sori jsonStringOrInteger) Equals(node JsonNode, metadata ...Metadata) bool {
switch node.(type) {
case jsonString:
return jsonString(sori).Equals(node, metadata...)
case jsonNumber:
i, err := strconv.Atoi(string(sori))
if err != nil {
return false
}
return jsonNumber(i).Equals(node, metadata...)
default:
return false
}
}

func (sori jsonStringOrInteger) hashCode(metadata []Metadata) [8]byte {
return jsonString(sori).hashCode(metadata)
}

func (sori jsonStringOrInteger) Diff(node JsonNode, metadata ...Metadata) Diff {
return sori.diff(node, make(path, 0), metadata, getPatchStrategy(metadata))
}

func (sori jsonStringOrInteger) diff(
node JsonNode,
path path,
metadata []Metadata,
strategy patchStrategy,
) Diff {
switch node.(type) {
case jsonString:
return jsonString(sori).Diff(node, metadata...)
case jsonNumber:
i, err := strconv.Atoi(string(sori))
if err != nil {
return jsonString(sori).Diff(node, metadata...)
}
return jsonNumber(i).Diff(node, metadata...)
default:
return jsonString(sori).Diff(node, metadata...)
}
}

func (sori jsonStringOrInteger) Patch(d Diff) (JsonNode, error) {
return patchAll(jsonString(sori), d)
}

func (sori jsonStringOrInteger) patch(
pathBehind, pathAhead path,
oldValue, newValue []JsonNode,
strategy patchStrategy,
) (JsonNode, error) {
return patch(jsonString(sori), pathBehind, pathAhead, oldValue, newValue, strategy)
}
80 changes: 80 additions & 0 deletions lib/string_or_integer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package jd

import (
"strings"
"testing"
)

func TestPatchJsonStringOrInteger(t *testing.T) {
tests := []struct {
a string
b string
diff []string
wantError bool
}{{
a: `{"0":{}}`,
b: `{"0":{"foo":"bar"}}`,
diff: ss(
`@ ["0", "foo"]`,
`+ "bar"`,
),
}, {
a: `{"0":{}}`,
b: `{"0":{"foo":"bar"}}`,
diff: ss(
`@ [0, "foo"]`,
`+ "bar"`,
),
}, {
a: `[]`,
b: `[1]`,
diff: ss(
`@ ["0"]`,
`+ 1`,
),
}, {
a: `[]`,
b: `[1]`,
diff: ss(
`@ [0]`,
`+ 1`,
),
}}

for _, tt := range tests {
diffString := strings.Join(tt.diff, "\n")
initial, err := ReadJsonString(tt.a)
if err != nil {
t.Errorf(err.Error())
}
diff, err := ReadDiffString(diffString)
if err != nil {
t.Errorf(err.Error())
}
expect, err := ReadJsonString(tt.b)
if err != nil {
t.Errorf(err.Error())
}
// Coerce to patch format so we'll create a jsonStringOrNumber object when reading the diff.
patchString, err := diff.RenderPatch()
if err != nil {
t.Errorf(err.Error())
}
patchDiff, err := ReadPatchString(patchString)
if err != nil {
t.Errorf(err.Error())
}
b, err := initial.Patch(patchDiff)
if tt.wantError && err == nil {
t.Errorf("wanted error but got none")
}
if !tt.wantError && err != nil {
t.Errorf("wanted no error but got %v", err)
}
if !tt.wantError && !expect.Equals(b) {
t.Errorf("%v.Patch(%v) = %v. Want %v.",
tt.a, diffString, b, tt.b)
}
}

}

0 comments on commit 0a1ac0a

Please sign in to comment.