diff --git a/v5/patch.go b/v5/patch.go index 83102e5..2af4392 100644 --- a/v5/patch.go +++ b/v5/patch.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "math/big" "strconv" "strings" "unicode" @@ -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() { @@ -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) } } diff --git a/v5/patch_numeric_test.go b/v5/patch_numeric_test.go new file mode 100644 index 0000000..7ba0d90 --- /dev/null +++ b/v5/patch_numeric_test.go @@ -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) + } + }) + } +} diff --git a/v5/rfc6902_numeric_diagnostic_test.go b/v5/rfc6902_numeric_diagnostic_test.go new file mode 100644 index 0000000..e93fcc7 --- /dev/null +++ b/v5/rfc6902_numeric_diagnostic_test.go @@ -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) + } + }) + } +}