Skip to content
Open
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
59 changes: 58 additions & 1 deletion v5/patch.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"errors"
"fmt"
"math/big"
"strconv"
"strings"
"unicode"
Expand Down Expand Up @@ -344,6 +345,59 @@ func (n *lazyNode) isNull() bool {
return bytes.Equal(n.compact(), rawJSONNull)
}

// normalizeJSONNumber represents a compact JSON number as coefficient * 10^scale.
// Only the scale uses arbitrary precision; the exponent is never expanded.
func normalizeJSONNumber(raw []byte) (string, *big.Int, bool) {
if len(raw) == 0 || (raw[0] != '-' && (raw[0] < '0' || raw[0] > '9')) || !json.Valid(raw) {
return "", nil, false
}

number := string(raw)
negative := number[0] == '-'
if negative {
number = number[1:]
}

exponent := ""
if i := strings.IndexAny(number, "eE"); i >= 0 {
exponent = number[i+1:]
number = number[:i]
}

fractionalDigits := 0
if i := strings.IndexByte(number, '.'); i >= 0 {
fractionalDigits = len(number) - i - 1
number = number[:i] + number[i+1:]
}

number = strings.TrimLeft(number, "0")
if number == "" {
return "0", new(big.Int), true
}

coefficient := strings.TrimRight(number, "0")
scale := new(big.Int)
if exponent != "" {
if _, ok := scale.SetString(exponent, 10); !ok {
return "", nil, false
}
}
scale.Add(scale, big.NewInt(int64(len(number)-len(coefficient)-fractionalDigits)))
if negative {
coefficient = "-" + coefficient
}
return coefficient, scale, true
}

func equalJSONNumbers(a, b []byte) bool {
aCoefficient, aScale, ok := normalizeJSONNumber(a)
if !ok {
return false
}
bCoefficient, bScale, ok := normalizeJSONNumber(b)
return ok && aCoefficient == bCoefficient && aScale.Cmp(bScale) == 0
}

func (n *lazyNode) equal(o *lazyNode) bool {
if n.which == eRaw {
if !n.tryDoc() && !n.tryAry() {
Expand Down Expand Up @@ -371,7 +425,10 @@ func (n *lazyNode) equal(o *lazyNode) bool {
return ns == os
}

return bytes.Equal(nc, oc)
if bytes.Equal(nc, oc) {
return true
}
return equalJSONNumbers(nc, oc)
}
}

Expand Down
72 changes: 72 additions & 0 deletions v5/patch_numeric_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package jsonpatch_test

import (
"bytes"
"errors"
"testing"

jsonpatch "github.com/evanphx/json-patch/v5"
)

func TestPatchTestNumericBoundaries(t *testing.T) {
cases := []struct {
name, document, value string
wantSuccess bool
}{
{"negative_zero", `{"value":-0}`, `0`, true},
{"trailing_zeros", `{"value":1.2300}`, `1.23`, true},
{"positive_exponent", `{"value":-1.23e+2}`, `-123`, true},
{"negative_exponent", `{"value":123e-2}`, `1.23`, true},
{"enormous_scale", `{"value":1e100000000000000000000}`, `10e99999999999999999999`, true},
{"unequal_scale", `{"value":1e1}`, `1`, false},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
patch, err := jsonpatch.DecodePatch([]byte(`[{"op":"test","path":"/value","value":` + tc.value + `}]`))
if err != nil {
t.Fatalf("DecodePatch failed: %v", err)
}

result, err := patch.Apply([]byte(tc.document))
if tc.wantSuccess {
if err != nil {
t.Fatalf("equal numbers should pass: %v", err)
}
if !bytes.Equal(result, []byte(tc.document)) {
t.Fatalf("test operation changed the document: %s", result)
}
return
}

if !errors.Is(err, jsonpatch.ErrTestFailed) {
t.Fatalf("unequal numbers should return ErrTestFailed, got: %v", err)
}
if result != nil {
t.Fatalf("failed test operation returned a document: %s", result)
}
})
}
}

func TestEqualNumericValues(t *testing.T) {
cases := []struct {
name, left, right string
wantEqual bool
}{
{"decimal", `1`, `1.0`, true},
{"recursive", `{"n":[1]}`, `{"n":[1e0]}`, true},
{"distinct_large_integers", `9007199254740992`, `9007199254740993`, false},
{"number_vs_string", `"1"`, `1.0`, false},
{"invalid_leading_zero", `01`, `1`, false},
{"invalid_exponent", `1e`, `1`, false},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := jsonpatch.Equal([]byte(tc.left), []byte(tc.right)); got != tc.wantEqual {
t.Fatalf("Equal(%s, %s) = %v, want %v", tc.left, tc.right, got, tc.wantEqual)
}
})
}
}
55 changes: 55 additions & 0 deletions v5/rfc6902_numeric_diagnostic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package jsonpatch_test

import (
"bytes"
"errors"
"testing"

jsonpatch "github.com/evanphx/json-patch/v5"
)

func TestRFC6902NumericDiagnostic(t *testing.T) {
cases := []struct {
name, document, value string
wantSuccess bool
}{
{"integer_decimal", `{"value":1}`, `1.0`, true},
{"integer_exponent", `{"value":1}`, `1e0`, true},
{"nested_object", `{"value":{"n":1}}`, `{"n":1.0}`, true},
{"nested_array", `{"value":[1,{"n":1}]}`, `[1.0,{"n":1e0}]`, true},
{"identical_number", `{"value":1}`, `1`, true},
{"unequal_number", `{"value":1}`, `2`, false},
{"number_vs_string", `{"value":1}`, `"1"`, false},
{"distinct_above_float64_precision", `{"value":9007199254740992}`, `9007199254740993`, false},
{"distinct_int64_values", `{"value":9223372036854775807}`, `9223372036854775806`, false},
{"identical_large_integer", `{"value":9223372036854775807}`, `9223372036854775807`, true},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
patchJSON := `[{"op":"test","path":"/value","value":` + tc.value + `}]`
patch, err := jsonpatch.DecodePatch([]byte(patchJSON))
if err != nil {
t.Fatalf("DecodePatch setup failed: %v", err)
}

result, err := patch.Apply([]byte(tc.document))
if tc.wantSuccess {
if err != nil {
t.Fatalf("numeric equality should pass through Patch.Apply: %v", err)
}
if !bytes.Equal(result, []byte(tc.document)) {
t.Fatalf("test operation changed the document: %s", result)
}
return
}

if !errors.Is(err, jsonpatch.ErrTestFailed) {
t.Fatalf("unequal values should return ErrTestFailed, got: %v", err)
}
if result != nil {
t.Fatalf("failed test operation returned a document: %s", result)
}
})
}
}