From f52331df8ad3510157ec3c74851cab6a749ff37f Mon Sep 17 00:00:00 2001 From: Walid Cavelius Date: Wed, 21 Oct 2026 11:30:49 +0200 Subject: [PATCH 1/6] Keep integer precision when unmarshaling attributes Attributes were decoded into map[string]any before being re-marshaled into the target struct, so every number went through float64 and integers above 2^53 were rounded, even for int64/uint64 fields. Integer literals now stay json.Number during that first decode, so typed integer fields receive every digit. Other numbers still become float64, so values like 100.0 keep decoding into int fields and interface{} values are unchanged. UnmarshalUseNumber is a new opt-in option that decodes numbers in interface{} attribute values (e.g. map[string]any) as json.Number. Co-Authored-By: Claude Opus 5.5 (1M context) --- jsonapi.go | 38 ++++++++++++++++++++- unmarshal.go | 20 +++++++++-- unmarshal_test.go | 86 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 3 deletions(-) diff --git a/jsonapi.go b/jsonapi.go index 73b0a12..d6c4bb7 100644 --- a/jsonapi.go +++ b/jsonapi.go @@ -2,9 +2,11 @@ package jsonapi import ( + "bytes" "encoding/json" "fmt" "reflect" + "strings" ) // ResourceObject is a JSON:API resource object as defined by https://jsonapi.org/format/1.0/#document-resource-objects @@ -22,7 +24,8 @@ func (ro *resourceObject) UnmarshalJSON(data []byte) error { type alias resourceObject auxRaw := &struct { - Rels map[string]json.RawMessage `json:"relationships,omitempty"` + Rels map[string]json.RawMessage `json:"relationships,omitempty"` + Attrs json.RawMessage `json:"attributes,omitempty"` *alias }{ alias: (*alias)(ro), @@ -30,6 +33,17 @@ func (ro *resourceObject) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &auxRaw); err != nil { return err } + if len(auxRaw.Attrs) > 0 { + // decode numbers as json.Number so integers above 2^53 reach typed fields without float64 rounding + d := json.NewDecoder(bytes.NewReader(auxRaw.Attrs)) + d.UseNumber() + if err := d.Decode(&ro.Attributes); err != nil { + return err + } + for k, v := range ro.Attributes { + ro.Attributes[k] = floatNonIntegers(v) + } + } ro.Relationships = make(map[string]*document, len(auxRaw.Rels)) for name, raw := range auxRaw.Rels { @@ -44,6 +58,28 @@ func (ro *resourceObject) UnmarshalJSON(data []byte) error { return nil } +// floatNonIntegers converts json.Number values that aren't integer literals back to float64, so only +// integers change behavior (e.g. 100.0 still decodes into an int field). +func floatNonIntegers(v any) any { + switch t := v.(type) { + case json.Number: + if strings.ContainsAny(string(t), ".eE") { + if f, err := t.Float64(); err == nil { + return f + } + } + case map[string]any: + for k, e := range t { + t[k] = floatNonIntegers(e) + } + case []any: + for i, e := range t { + t[i] = floatNonIntegers(e) + } + } + return v +} + func (ro *resourceObject) getIdentifier() string { return fmt.Sprintf("{Type: %v, ID: %v}", ro.Type, ro.ID) } diff --git a/unmarshal.go b/unmarshal.go index 92eafb3..09e2267 100644 --- a/unmarshal.go +++ b/unmarshal.go @@ -1,6 +1,7 @@ package jsonapi import ( + "bytes" "encoding" "encoding/json" "errors" @@ -13,6 +14,7 @@ type Unmarshaler struct { unmarshalMeta bool unmarshalLinks bool checkUniqueness bool + useNumber bool meta any links *Link memberNameValidationMode MemberNameValidationMode @@ -44,6 +46,14 @@ func UnmarshalCheckUniqueness() UnmarshalOption { } } +// UnmarshalUseNumber decodes numbers in interface{} attribute values (e.g. map[string]any) as json.Number +// instead of float64, so integers above 2^53 keep every digit. +func UnmarshalUseNumber() UnmarshalOption { + return func(m *Unmarshaler) { + m.useNumber = true + } +} + // UnmarshalSetNameValidation enables a given level of document member name validation. func UnmarshalSetNameValidation(mode MemberNameValidationMode) UnmarshalOption { return func(m *Unmarshaler) { @@ -57,6 +67,7 @@ func (m *Unmarshaler) relationshipUnmarshaler() *Unmarshaler { rm := new(Unmarshaler) rm.memberNameValidationMode = m.memberNameValidationMode + rm.useNumber = m.useNumber return rm } @@ -204,7 +215,7 @@ func (ro *resourceObject) unmarshal(v any, m *Unmarshaler) error { return err } - return ro.unmarshalAttributes(v) + return ro.unmarshalAttributes(v, m) } // unmarshalFields unmarshals a resource object into all non-attribute struct fields @@ -338,7 +349,7 @@ func (ro *resourceObject) unmarshalFields(v any, rv reflect.Value, rt reflect.Ty return nil } -func (ro *resourceObject) unmarshalAttributes(v any) error { +func (ro *resourceObject) unmarshalAttributes(v any, m *Unmarshaler) error { if len(ro.Attributes) == 0 { return nil } @@ -347,5 +358,10 @@ func (ro *resourceObject) unmarshalAttributes(v any) error { if err != nil { return err } + if m != nil && m.useNumber { + d := json.NewDecoder(bytes.NewReader(b)) + d.UseNumber() + return d.Decode(v) + } return json.Unmarshal(b, v) } diff --git a/unmarshal_test.go b/unmarshal_test.go index 91b1ad0..1d2eeb6 100644 --- a/unmarshal_test.go +++ b/unmarshal_test.go @@ -764,6 +764,92 @@ func TestUnmarshalLinks(t *testing.T) { } } +func TestUnmarshalNumbers(t *testing.T) { + t.Parallel() + + type nested struct { + Max int64 `json:"max"` + } + type numbers struct { + ID string `jsonapi:"primary,numbers"` + Int int64 `jsonapi:"attribute" json:"int,omitempty"` + Uint uint64 `jsonapi:"attribute" json:"uint,omitempty"` + Float float64 `jsonapi:"attribute" json:"float,omitempty"` + Nested *nested `jsonapi:"attribute" json:"nested,omitempty"` + List []int64 `jsonapi:"attribute" json:"list,omitempty"` + Values map[string]any `jsonapi:"attribute" json:"values,omitempty"` + Int32 int32 `jsonapi:"attribute" json:"int32,omitempty"` + } + + body := func(attributes string) []byte { + return []byte(`{"data":{"type":"numbers","id":"1","attributes":` + attributes + `}}`) + } + + tests := []struct { + description string + given []byte + opts []UnmarshalOption + expect any + }{ + { + description: "int64 above 2^53", + given: body(`{"int":9007199254740993}`), + expect: &numbers{ID: "1", Int: 9007199254740993}, + }, { + description: "uint64 max", + given: body(`{"uint":18446744073709551615}`), + expect: &numbers{ID: "1", Uint: 18446744073709551615}, + }, { + description: "int64 min and max in nested struct and slice", + given: body(`{"nested":{"max":9223372036854775807},"list":[-9223372036854775808,9007199254740993]}`), + expect: &numbers{ID: "1", Nested: &nested{Max: 9223372036854775807}, List: []int64{-9223372036854775808, 9007199254740993}}, + }, { + description: "non-integer literals with integer values decode into int64", + given: body(`{"int":100.0,"list":[1e2]}`), + expect: &numbers{ID: "1", Int: 100, List: []int64{100}}, + }, { + description: "integer above 2^53 into float64 rounds", + given: body(`{"float":9007199254740993}`), + expect: &numbers{ID: "1", Float: 9007199254740992}, + }, { + description: "map[string]any values are float64", + given: body(`{"values":{"int":9007199254740993,"float":1.5}}`), + expect: &numbers{ID: "1", Values: map[string]any{"int": float64(9007199254740992), "float": 1.5}}, + }, { + description: "map[string]any values are json.Number with UnmarshalUseNumber", + given: body(`{"values":{"int":9007199254740993,"float":1.5}}`), + opts: []UnmarshalOption{UnmarshalUseNumber()}, + expect: &numbers{ID: "1", Values: map[string]any{"int": json.Number("9007199254740993"), "float": json.Number("1.5")}}, + }, { + description: "non-integer literals with integer values decode into int64 with UnmarshalUseNumber", + given: body(`{"int":100.0}`), + opts: []UnmarshalOption{UnmarshalUseNumber()}, + expect: &numbers{ID: "1", Int: 100}, + }, + } + + for i, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%02d", i), func(t *testing.T) { + t.Parallel() + t.Log(tc.description) + + var actual numbers + err := Unmarshal(tc.given, &actual, tc.opts...) + is.MustNoError(t, err) + is.Equal(t, tc.expect, &actual) + }) + } + + t.Run("int32 out of range", func(t *testing.T) { + t.Parallel() + + var actual numbers + err := Unmarshal(body(`{"int32":3000000000}`), &actual) + is.Error(t, err) + }) +} + // TestUnmarshalMemberNameValidation collects tests which verify that invalid member names are // caught during unmarshaling, no matter where they're placed. This test does not exhaustively test // every possible invalid name. From 6a555d9a038800fbf14f1ee71ddccf1c3068f25a Mon Sep 17 00:00:00 2001 From: Walid Cavelius Date: Wed, 21 Oct 2026 13:06:28 +0200 Subject: [PATCH 2/6] Skip the json.Number decode when attributes have no long integers Integers with at most 15 digits are exact in float64, so attributes without a run of 16 or more digits are decoded as before, without the extra copy and decode. Attributes now have their own type so the standard library hands its UnmarshalJSON the bytes directly. Co-Authored-By: Claude Opus 5.5 (1M context) --- jsonapi.go | 55 +++++++++++++++++++++++++++++++++++------------ unmarshal_test.go | 4 ++++ 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/jsonapi.go b/jsonapi.go index d6c4bb7..f75ca88 100644 --- a/jsonapi.go +++ b/jsonapi.go @@ -13,7 +13,7 @@ import ( type resourceObject struct { ID string `json:"id,omitempty"` Type string `json:"type"` - Attributes map[string]any `json:"attributes,omitempty"` + Attributes attributes `json:"attributes,omitempty"` Relationships map[string]*document `json:"relationships,omitempty"` Meta any `json:"meta,omitempty"` Links *Link `json:"links,omitempty"` @@ -24,8 +24,7 @@ func (ro *resourceObject) UnmarshalJSON(data []byte) error { type alias resourceObject auxRaw := &struct { - Rels map[string]json.RawMessage `json:"relationships,omitempty"` - Attrs json.RawMessage `json:"attributes,omitempty"` + Rels map[string]json.RawMessage `json:"relationships,omitempty"` *alias }{ alias: (*alias)(ro), @@ -33,17 +32,6 @@ func (ro *resourceObject) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &auxRaw); err != nil { return err } - if len(auxRaw.Attrs) > 0 { - // decode numbers as json.Number so integers above 2^53 reach typed fields without float64 rounding - d := json.NewDecoder(bytes.NewReader(auxRaw.Attrs)) - d.UseNumber() - if err := d.Decode(&ro.Attributes); err != nil { - return err - } - for k, v := range ro.Attributes { - ro.Attributes[k] = floatNonIntegers(v) - } - } ro.Relationships = make(map[string]*document, len(auxRaw.Rels)) for name, raw := range auxRaw.Rels { @@ -58,6 +46,45 @@ func (ro *resourceObject) UnmarshalJSON(data []byte) error { return nil } +// attributes holds resource object attributes, decoded so that integer literals keep every digit. +type attributes map[string]any + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (a *attributes) UnmarshalJSON(data []byte) error { + // integers with at most 15 digits are exact in float64, so only longer ones need json.Number + if !hasLongDigitRun(data) { + return json.Unmarshal(data, (*map[string]any)(a)) + } + + d := json.NewDecoder(bytes.NewReader(data)) + d.UseNumber() + var m map[string]any + if err := d.Decode(&m); err != nil { + return err + } + for k, v := range m { + m[k] = floatNonIntegers(v) + } + *a = m + return nil +} + +// hasLongDigitRun reports whether data contains 16 or more consecutive digits. +func hasLongDigitRun(data []byte) bool { + run := 0 + for _, c := range data { + if c < '0' || c > '9' { + run = 0 + continue + } + run++ + if run >= 16 { + return true + } + } + return false +} + // floatNonIntegers converts json.Number values that aren't integer literals back to float64, so only // integers change behavior (e.g. 100.0 still decodes into an int field). func floatNonIntegers(v any) any { diff --git a/unmarshal_test.go b/unmarshal_test.go index 1d2eeb6..ad1f183 100644 --- a/unmarshal_test.go +++ b/unmarshal_test.go @@ -807,6 +807,10 @@ func TestUnmarshalNumbers(t *testing.T) { description: "non-integer literals with integer values decode into int64", given: body(`{"int":100.0,"list":[1e2]}`), expect: &numbers{ID: "1", Int: 100, List: []int64{100}}, + }, { + description: "non-integer literals with integer values decode into int64 next to an integer above 2^53", + given: body(`{"int":100.0,"uint":9007199254740993}`), + expect: &numbers{ID: "1", Int: 100, Uint: 9007199254740993}, }, { description: "integer above 2^53 into float64 rounds", given: body(`{"float":9007199254740993}`), From 186e504909f4cf38151ffb274b35503f8cbb20ca Mon Sep 17 00:00:00 2001 From: Walid Cavelius Date: Thu, 22 Oct 2026 16:42:50 +0200 Subject: [PATCH 3/6] Put integer precision behind UnmarshalUseNumber Attributes are now kept as raw JSON. Without the option they are decoded as before (through map[string]any and float64), so the default behavior is unchanged. With UnmarshalUseNumber they are decoded directly into the target with UseNumber, so integers above 2^53 keep every digit in typed fields and interface{} values hold json.Number. This removes the digit-run scan and the float64 conversion of non-integer literals that the default fix needed. Co-Authored-By: Claude Opus 5.5 (1M context) --- jsonapi.go | 72 +++++------------------------------------------ unmarshal.go | 26 +++++++++++------ unmarshal_test.go | 49 ++++++++++++++++---------------- 3 files changed, 49 insertions(+), 98 deletions(-) diff --git a/jsonapi.go b/jsonapi.go index f75ca88..75d92d6 100644 --- a/jsonapi.go +++ b/jsonapi.go @@ -2,21 +2,22 @@ package jsonapi import ( - "bytes" "encoding/json" "fmt" "reflect" - "strings" ) // ResourceObject is a JSON:API resource object as defined by https://jsonapi.org/format/1.0/#document-resource-objects type resourceObject struct { ID string `json:"id,omitempty"` Type string `json:"type"` - Attributes attributes `json:"attributes,omitempty"` + Attributes map[string]any `json:"attributes,omitempty"` Relationships map[string]*document `json:"relationships,omitempty"` Meta any `json:"meta,omitempty"` Links *Link `json:"links,omitempty"` + + // rawAttributes holds the attributes as received, so they can be decoded without a float64 round-trip. + rawAttributes json.RawMessage } // UnmarshalJSON implements the json.Unmarshaler interface. @@ -24,7 +25,8 @@ func (ro *resourceObject) UnmarshalJSON(data []byte) error { type alias resourceObject auxRaw := &struct { - Rels map[string]json.RawMessage `json:"relationships,omitempty"` + Rels map[string]json.RawMessage `json:"relationships,omitempty"` + Attrs json.RawMessage `json:"attributes,omitempty"` *alias }{ alias: (*alias)(ro), @@ -32,6 +34,7 @@ func (ro *resourceObject) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &auxRaw); err != nil { return err } + ro.rawAttributes = auxRaw.Attrs ro.Relationships = make(map[string]*document, len(auxRaw.Rels)) for name, raw := range auxRaw.Rels { @@ -46,67 +49,6 @@ func (ro *resourceObject) UnmarshalJSON(data []byte) error { return nil } -// attributes holds resource object attributes, decoded so that integer literals keep every digit. -type attributes map[string]any - -// UnmarshalJSON implements the json.Unmarshaler interface. -func (a *attributes) UnmarshalJSON(data []byte) error { - // integers with at most 15 digits are exact in float64, so only longer ones need json.Number - if !hasLongDigitRun(data) { - return json.Unmarshal(data, (*map[string]any)(a)) - } - - d := json.NewDecoder(bytes.NewReader(data)) - d.UseNumber() - var m map[string]any - if err := d.Decode(&m); err != nil { - return err - } - for k, v := range m { - m[k] = floatNonIntegers(v) - } - *a = m - return nil -} - -// hasLongDigitRun reports whether data contains 16 or more consecutive digits. -func hasLongDigitRun(data []byte) bool { - run := 0 - for _, c := range data { - if c < '0' || c > '9' { - run = 0 - continue - } - run++ - if run >= 16 { - return true - } - } - return false -} - -// floatNonIntegers converts json.Number values that aren't integer literals back to float64, so only -// integers change behavior (e.g. 100.0 still decodes into an int field). -func floatNonIntegers(v any) any { - switch t := v.(type) { - case json.Number: - if strings.ContainsAny(string(t), ".eE") { - if f, err := t.Float64(); err == nil { - return f - } - } - case map[string]any: - for k, e := range t { - t[k] = floatNonIntegers(e) - } - case []any: - for i, e := range t { - t[i] = floatNonIntegers(e) - } - } - return v -} - func (ro *resourceObject) getIdentifier() string { return fmt.Sprintf("{Type: %v, ID: %v}", ro.Type, ro.ID) } diff --git a/unmarshal.go b/unmarshal.go index 09e2267..d841b5f 100644 --- a/unmarshal.go +++ b/unmarshal.go @@ -46,8 +46,9 @@ func UnmarshalCheckUniqueness() UnmarshalOption { } } -// UnmarshalUseNumber decodes numbers in interface{} attribute values (e.g. map[string]any) as json.Number -// instead of float64, so integers above 2^53 keep every digit. +// UnmarshalUseNumber decodes attributes directly with json.Number, so integers above 2^53 keep every digit +// in typed fields and interface{} values (e.g. map[string]any) hold json.Number instead of float64. +// Non-integer literals such as 100.0 no longer decode into integer fields. func UnmarshalUseNumber() UnmarshalOption { return func(m *Unmarshaler) { m.useNumber = true @@ -350,18 +351,27 @@ func (ro *resourceObject) unmarshalFields(v any, rv reflect.Value, rt reflect.Ty } func (ro *resourceObject) unmarshalAttributes(v any, m *Unmarshaler) error { - if len(ro.Attributes) == 0 { + if len(ro.rawAttributes) == 0 || bytes.Equal(ro.rawAttributes, []byte("null")) { return nil } - b, err := json.Marshal(ro.Attributes) - if err != nil { - return err - } if m != nil && m.useNumber { - d := json.NewDecoder(bytes.NewReader(b)) + d := json.NewDecoder(bytes.NewReader(ro.rawAttributes)) d.UseNumber() return d.Decode(v) } + + var attributes map[string]any + if err := json.Unmarshal(ro.rawAttributes, &attributes); err != nil { + return err + } + if len(attributes) == 0 { + return nil + } + + b, err := json.Marshal(attributes) + if err != nil { + return err + } return json.Unmarshal(b, v) } diff --git a/unmarshal_test.go b/unmarshal_test.go index ad1f183..f6db580 100644 --- a/unmarshal_test.go +++ b/unmarshal_test.go @@ -778,12 +778,12 @@ func TestUnmarshalNumbers(t *testing.T) { Nested *nested `jsonapi:"attribute" json:"nested,omitempty"` List []int64 `jsonapi:"attribute" json:"list,omitempty"` Values map[string]any `jsonapi:"attribute" json:"values,omitempty"` - Int32 int32 `jsonapi:"attribute" json:"int32,omitempty"` } body := func(attributes string) []byte { return []byte(`{"data":{"type":"numbers","id":"1","attributes":` + attributes + `}}`) } + useNumber := []UnmarshalOption{UnmarshalUseNumber()} tests := []struct { description string @@ -792,43 +792,42 @@ func TestUnmarshalNumbers(t *testing.T) { expect any }{ { - description: "int64 above 2^53", + description: "int64 above 2^53 rounds", given: body(`{"int":9007199254740993}`), + expect: &numbers{ID: "1", Int: 9007199254740992}, + }, { + description: "non-integer literals with integer values decode into int64", + given: body(`{"int":100.0,"list":[1e2]}`), + expect: &numbers{ID: "1", Int: 100, List: []int64{100}}, + }, { + description: "map[string]any values are float64", + given: body(`{"values":{"int":9007199254740993,"float":1.5}}`), + expect: &numbers{ID: "1", Values: map[string]any{"int": float64(9007199254740992), "float": 1.5}}, + }, { + description: "int64 above 2^53 with UnmarshalUseNumber", + given: body(`{"int":9007199254740993}`), + opts: useNumber, expect: &numbers{ID: "1", Int: 9007199254740993}, }, { - description: "uint64 max", + description: "uint64 max with UnmarshalUseNumber", given: body(`{"uint":18446744073709551615}`), + opts: useNumber, expect: &numbers{ID: "1", Uint: 18446744073709551615}, }, { - description: "int64 min and max in nested struct and slice", + description: "int64 min and max in nested struct and slice with UnmarshalUseNumber", given: body(`{"nested":{"max":9223372036854775807},"list":[-9223372036854775808,9007199254740993]}`), + opts: useNumber, expect: &numbers{ID: "1", Nested: &nested{Max: 9223372036854775807}, List: []int64{-9223372036854775808, 9007199254740993}}, }, { - description: "non-integer literals with integer values decode into int64", - given: body(`{"int":100.0,"list":[1e2]}`), - expect: &numbers{ID: "1", Int: 100, List: []int64{100}}, - }, { - description: "non-integer literals with integer values decode into int64 next to an integer above 2^53", - given: body(`{"int":100.0,"uint":9007199254740993}`), - expect: &numbers{ID: "1", Int: 100, Uint: 9007199254740993}, - }, { - description: "integer above 2^53 into float64 rounds", + description: "integer above 2^53 into float64 rounds with UnmarshalUseNumber", given: body(`{"float":9007199254740993}`), + opts: useNumber, expect: &numbers{ID: "1", Float: 9007199254740992}, - }, { - description: "map[string]any values are float64", - given: body(`{"values":{"int":9007199254740993,"float":1.5}}`), - expect: &numbers{ID: "1", Values: map[string]any{"int": float64(9007199254740992), "float": 1.5}}, }, { description: "map[string]any values are json.Number with UnmarshalUseNumber", given: body(`{"values":{"int":9007199254740993,"float":1.5}}`), - opts: []UnmarshalOption{UnmarshalUseNumber()}, + opts: useNumber, expect: &numbers{ID: "1", Values: map[string]any{"int": json.Number("9007199254740993"), "float": json.Number("1.5")}}, - }, { - description: "non-integer literals with integer values decode into int64 with UnmarshalUseNumber", - given: body(`{"int":100.0}`), - opts: []UnmarshalOption{UnmarshalUseNumber()}, - expect: &numbers{ID: "1", Int: 100}, }, } @@ -845,11 +844,11 @@ func TestUnmarshalNumbers(t *testing.T) { }) } - t.Run("int32 out of range", func(t *testing.T) { + t.Run("non-integer literal into int64 errors with UnmarshalUseNumber", func(t *testing.T) { t.Parallel() var actual numbers - err := Unmarshal(body(`{"int32":3000000000}`), &actual) + err := Unmarshal(body(`{"int":100.0}`), &actual, UnmarshalUseNumber()) is.Error(t, err) }) } From 846fcedc7e3a5cae80b559af736cf80e8bd52b06 Mon Sep 17 00:00:00 2001 From: Walid Cavelius Date: Fri, 23 Oct 2026 11:31:58 +0200 Subject: [PATCH 4/6] Keep the existing decoding path unless UnmarshalUseNumber is set The option is now passed to the document before decoding, so resource objects only keep the raw attribute bytes when it is set (without copying them, and reused by every reference to an included resource). Without the option, attributes are decoded into a map during the first pass, as before. Also fix BenchmarkUnmarshal, which passed a pointer to an interface (and a non-pointer meta target) and discarded the error, so it never reached attribute decoding, and add BenchmarkUnmarshalNumbers for both modes. Co-Authored-By: Claude Opus 5.5 (1M context) --- jsonapi.go | 94 +++++++++++++++++++++++----- unmarshal.go | 24 +++----- unmarshal_test.go | 153 +++++++++++++++++++++++++++++++++++----------- 3 files changed, 207 insertions(+), 64 deletions(-) diff --git a/jsonapi.go b/jsonapi.go index 75d92d6..8f69532 100644 --- a/jsonapi.go +++ b/jsonapi.go @@ -16,25 +16,43 @@ type resourceObject struct { Meta any `json:"meta,omitempty"` Links *Link `json:"links,omitempty"` - // rawAttributes holds the attributes as received, so they can be decoded without a float64 round-trip. - rawAttributes json.RawMessage + // useNumber keeps the attributes as received in rawAttributes, so UnmarshalUseNumber can decode them + // without a float64 round-trip; otherwise they are decoded into Attributes as before. + useNumber bool + rawAttributes rawJSON +} + +// rawJSON keeps a reference to the bytes it is decoded from instead of copying them like json.RawMessage. +// It is only read while Unmarshal runs, so the input is still valid. +type rawJSON []byte + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (r *rawJSON) UnmarshalJSON(data []byte) error { + *r = data + return nil } // UnmarshalJSON implements the json.Unmarshaler interface. func (ro *resourceObject) UnmarshalJSON(data []byte) error { type alias resourceObject - auxRaw := &struct { - Rels map[string]json.RawMessage `json:"relationships,omitempty"` - Attrs json.RawMessage `json:"attributes,omitempty"` + type aux struct { + Rels map[string]json.RawMessage `json:"relationships,omitempty"` *alias - }{ - alias: (*alias)(ro), } - if err := json.Unmarshal(data, &auxRaw); err != nil { + auxRaw := &aux{alias: (*alias)(ro)} + if ro.useNumber { + withRaw := &struct { + Attrs rawJSON `json:"attributes,omitempty"` + *aux + }{aux: auxRaw} + if err := json.Unmarshal(data, withRaw); err != nil { + return err + } + ro.rawAttributes = withRaw.Attrs + } else if err := json.Unmarshal(data, auxRaw); err != nil { return err } - ro.rawAttributes = auxRaw.Attrs ro.Relationships = make(map[string]*document, len(auxRaw.Rels)) for name, raw := range auxRaw.Rels { @@ -148,6 +166,9 @@ type document struct { // isRelationship marks a document as a relationship sub-document (within primary data) isRelationship bool `json:"-"` + // useNumber is passed to the document's resource objects (see resourceObject.useNumber) + useNumber bool `json:"-"` + // Meta is Meta Information as defined by https://jsonapi.org/format/1.0/#document-meta. Meta any `json:"meta,omitempty"` @@ -204,14 +225,27 @@ func (d *document) MarshalJSON() ([]byte, error) { // UnmarshalJSON implements the json.Unmarshaler interface. func (d *document) UnmarshalJSON(data []byte) error { type alias document - - auxRaw := &struct { + type aux struct { Data json.RawMessage `json:"data,omitempty"` *alias - }{ - alias: (*alias)(d), } - if err := json.Unmarshal(data, &auxRaw); err != nil { + + auxRaw := &aux{alias: (*alias)(d)} + if d.useNumber { + // decode included resources one by one so they carry useNumber + withRaw := &struct { + Included []rawJSON `json:"included,omitempty"` + *aux + }{aux: auxRaw} + if err := json.Unmarshal(data, withRaw); err != nil { + return err + } + included, err := d.unmarshalResourceObjects(withRaw.Included) + if err != nil { + return err + } + d.Included = included + } else if err := json.Unmarshal(data, auxRaw); err != nil { return err } @@ -236,11 +270,41 @@ func (d *document) UnmarshalJSON(data []byte) error { if auxRaw.Data[0] == '[' { d.hasMany = true - return json.Unmarshal(auxRaw.Data, &auxRaw.DataMany) + if !d.useNumber { + return json.Unmarshal(auxRaw.Data, &auxRaw.DataMany) + } + var raws []rawJSON + if err := json.Unmarshal(auxRaw.Data, &raws); err != nil { + return err + } + many, err := d.unmarshalResourceObjects(raws) + if err != nil { + return err + } + d.DataMany = many + return nil + } + if d.useNumber { + d.DataOne = &resourceObject{useNumber: true} } return json.Unmarshal(auxRaw.Data, &auxRaw.DataOne) } +// unmarshalResourceObjects decodes each raw resource object with d.useNumber. +func (d *document) unmarshalResourceObjects(raws []rawJSON) ([]*resourceObject, error) { + if raws == nil { + return nil, nil + } + ros := make([]*resourceObject, len(raws)) + for i, raw := range raws { + ros[i] = &resourceObject{useNumber: d.useNumber} + if err := json.Unmarshal(raw, ros[i]); err != nil { + return nil, err + } + } + return ros, nil +} + // isEmpty returns true if there is no primary data in the given document (i.e. null or []). func (d *document) isEmpty() bool { return len(d.DataMany) == 0 && d.DataOne == nil diff --git a/unmarshal.go b/unmarshal.go index d841b5f..8979e4c 100644 --- a/unmarshal.go +++ b/unmarshal.go @@ -68,7 +68,6 @@ func (m *Unmarshaler) relationshipUnmarshaler() *Unmarshaler { rm := new(Unmarshaler) rm.memberNameValidationMode = m.memberNameValidationMode - rm.useNumber = m.useNumber return rm } @@ -94,7 +93,7 @@ func Unmarshal(data []byte, v any, opts ...UnmarshalOption) (err error) { return } - var d document + d := document{useNumber: m.useNumber} if err = json.Unmarshal(data, &d); err != nil { return } @@ -216,7 +215,7 @@ func (ro *resourceObject) unmarshal(v any, m *Unmarshaler) error { return err } - return ro.unmarshalAttributes(v, m) + return ro.unmarshalAttributes(v) } // unmarshalFields unmarshals a resource object into all non-attribute struct fields @@ -350,26 +349,21 @@ func (ro *resourceObject) unmarshalFields(v any, rv reflect.Value, rt reflect.Ty return nil } -func (ro *resourceObject) unmarshalAttributes(v any, m *Unmarshaler) error { - if len(ro.rawAttributes) == 0 || bytes.Equal(ro.rawAttributes, []byte("null")) { - return nil - } - - if m != nil && m.useNumber { +func (ro *resourceObject) unmarshalAttributes(v any) error { + if ro.useNumber { + if len(ro.rawAttributes) == 0 || bytes.Equal(ro.rawAttributes, []byte("null")) { + return nil + } d := json.NewDecoder(bytes.NewReader(ro.rawAttributes)) d.UseNumber() return d.Decode(v) } - var attributes map[string]any - if err := json.Unmarshal(ro.rawAttributes, &attributes); err != nil { - return err - } - if len(attributes) == 0 { + if len(ro.Attributes) == 0 { return nil } - b, err := json.Marshal(attributes) + b, err := json.Marshal(ro.Attributes) if err != nil { return err } diff --git a/unmarshal_test.go b/unmarshal_test.go index f6db580..330cd8b 100644 --- a/unmarshal_test.go +++ b/unmarshal_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "reflect" + "strconv" + "strings" "testing" "github.com/DataDog/jsonapi/internal/is" @@ -844,6 +846,23 @@ func TestUnmarshalNumbers(t *testing.T) { }) } + t.Run("slice and included resource with UnmarshalUseNumber", func(t *testing.T) { + t.Parallel() + + type item struct { + ID string `jsonapi:"primary,items"` + Int int64 `jsonapi:"attribute" json:"int,omitempty"` + Related *item `jsonapi:"relationship" json:"related,omitempty"` + } + given := `{"data":[{"type":"items","id":"1","attributes":{"int":9007199254740993},"relationships":{"related":{"data":{"type":"items","id":"2"}}}}],` + + `"included":[{"type":"items","id":"2","attributes":{"int":9007199254740995}}]}` + + var actual []*item + err := Unmarshal([]byte(given), &actual, UnmarshalUseNumber()) + is.MustNoError(t, err) + is.Equal(t, []*item{{ID: "1", Int: 9007199254740993, Related: &item{ID: "2", Int: 9007199254740995}}}, actual) + }) + t.Run("non-integer literal into int64 errors with UnmarshalUseNumber", func(t *testing.T) { t.Parallel() @@ -1009,43 +1028,42 @@ func TestUnmarshalMemberNameValidation(t *testing.T) { } } +var benchmarkSink any + func BenchmarkUnmarshal(b *testing.B) { benchmarks := []struct { - name string - data string - target any - opts []UnmarshalOption + name string + data string + newTarget func() any + opts func() []UnmarshalOption }{ { - name: "ArticleSimple", - data: articleABody, - target: Article{}, - opts: nil, - }, { - name: "ArticleSimpleWithToplevelMeta", - data: articleAToplevelMetaBody, - target: Article{}, - opts: []UnmarshalOption{UnmarshalMeta(map[string]any{"foo": "bar"})}, - }, { - name: "ArticleComplex", - data: articleRelatedCommentsNestedWithIncludeBody, - target: ArticleRelated{}, - opts: nil, - }, { - name: "ArticleComplexDisableNameValidation", - data: articleRelatedCommentsNestedWithIncludeBody, - target: ArticleRelated{}, - opts: []UnmarshalOption{UnmarshalSetNameValidation(DisableValidation)}, - }, { - name: "ArticlesComplex", - data: articlesRelatedComplexBody, - target: []*ArticleRelated{}, - opts: nil, - }, { - name: "ArticlesComplexDisableNameValidation", - data: articlesRelatedComplexBody, - target: []*ArticleRelated{}, - opts: []UnmarshalOption{UnmarshalSetNameValidation(DisableValidation)}, + name: "ArticleSimple", + data: articleABody, + newTarget: func() any { return new(Article) }, + }, { + name: "ArticleSimpleWithToplevelMeta", + data: articleAToplevelMetaBody, + newTarget: func() any { return new(Article) }, + opts: func() []UnmarshalOption { return []UnmarshalOption{UnmarshalMeta(&map[string]any{})} }, + }, { + name: "ArticleComplex", + data: articleRelatedCommentsNestedWithIncludeBody, + newTarget: func() any { return new(ArticleRelated) }, + }, { + name: "ArticleComplexDisableNameValidation", + data: articleRelatedCommentsNestedWithIncludeBody, + newTarget: func() any { return new(ArticleRelated) }, + opts: func() []UnmarshalOption { return []UnmarshalOption{UnmarshalSetNameValidation(DisableValidation)} }, + }, { + name: "ArticlesComplex", + data: articlesRelatedComplexBody, + newTarget: func() any { return new([]*ArticleRelated) }, + }, { + name: "ArticlesComplexDisableNameValidation", + data: articlesRelatedComplexBody, + newTarget: func() any { return new([]*ArticleRelated) }, + opts: func() []UnmarshalOption { return []UnmarshalOption{UnmarshalSetNameValidation(DisableValidation)} }, }, } @@ -1055,8 +1073,75 @@ func BenchmarkUnmarshal(b *testing.B) { b.Run(bm.name, func(b *testing.B) { b.ReportAllocs() for n := 0; n < b.N; n++ { - _ = Unmarshal(data, &bm.target, bm.opts...) + var opts []UnmarshalOption + if bm.opts != nil { + opts = bm.opts() + } + target := bm.newTarget() + if err := Unmarshal(data, target, opts...); err != nil { + b.Fatal(err) + } + benchmarkSink = target } }) } } + +func BenchmarkUnmarshalNumbers(b *testing.B) { + type item struct { + ID string `jsonapi:"primary,items"` + Blob string `jsonapi:"attribute" json:"blob,omitempty"` + Numbers []int64 `jsonapi:"attribute" json:"numbers,omitempty"` + Related *item `jsonapi:"relationship" json:"related,omitempty"` + } + + blob, _ := json.Marshal(strings.Repeat("a", 4096)) + numbers := make([]string, 1000) + for i := range numbers { + numbers[i] = strconv.Itoa(i) + } + shared := make([]string, 32) + for i := range shared { + shared[i] = `{"type":"items","id":"` + strconv.Itoa(i) + `","relationships":{"related":{"data":{"type":"items","id":"shared"}}}}` + } + + benchmarks := []struct { + name string + data []byte + many bool + }{ + { + name: "String4KiB", + data: []byte(`{"data":{"type":"items","id":"1","attributes":{"blob":` + string(blob) + `}}}`), + }, { + name: "Numbers1000", + data: []byte(`{"data":{"type":"items","id":"1","attributes":{"numbers":[` + strings.Join(numbers, ",") + `]}}}`), + }, { + name: "SharedIncluded32", + data: []byte(`{"data":[` + strings.Join(shared, ",") + `],"included":[{"type":"items","id":"shared","attributes":{"blob":` + string(blob) + `}}]}`), + many: true, + }, + } + + for _, mode := range []struct { + name string + opts []UnmarshalOption + }{{"Default", nil}, {"UseNumber", []UnmarshalOption{UnmarshalUseNumber()}}} { + for _, bm := range benchmarks { + mode, bm := mode, bm + b.Run(bm.name+"/"+mode.name, func(b *testing.B) { + b.ReportAllocs() + for n := 0; n < b.N; n++ { + var target any = new(item) + if bm.many { + target = new([]*item) + } + if err := Unmarshal(bm.data, target, mode.opts...); err != nil { + b.Fatal(err) + } + benchmarkSink = target + } + }) + } + } +} From 03beb7a27030f9dbc62635b1adc56b6f4a38063c Mon Sep 17 00:00:00 2001 From: Walid Cavelius Date: Fri, 23 Oct 2026 13:26:11 +0200 Subject: [PATCH 5/6] Pass UnmarshalUseNumber to relationship documents Attributes inside relationship data were still decoded through float64 with the option set, since relationship documents were created without it. Co-Authored-By: Claude Opus 5.5 (1M context) --- jsonapi.go | 2 +- unmarshal_test.go | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/jsonapi.go b/jsonapi.go index 8f69532..14015ed 100644 --- a/jsonapi.go +++ b/jsonapi.go @@ -58,7 +58,7 @@ func (ro *resourceObject) UnmarshalJSON(data []byte) error { for name, raw := range auxRaw.Rels { // mark the created sub-documents as relationships so that the document Unmarshaler // can handle their different member requirements - d := document{isRelationship: true} + d := document{isRelationship: true, useNumber: ro.useNumber} if err := json.Unmarshal(raw, &d); err != nil { return err } diff --git a/unmarshal_test.go b/unmarshal_test.go index 330cd8b..f4307eb 100644 --- a/unmarshal_test.go +++ b/unmarshal_test.go @@ -861,6 +861,13 @@ func TestUnmarshalNumbers(t *testing.T) { err := Unmarshal([]byte(given), &actual, UnmarshalUseNumber()) is.MustNoError(t, err) is.Equal(t, []*item{{ID: "1", Int: 9007199254740993, Related: &item{ID: "2", Int: 9007199254740995}}}, actual) + + // attributes inside the relationship data itself + given = `{"data":{"type":"items","id":"1","relationships":{"related":{"data":{"type":"items","id":"2","attributes":{"int":9007199254740993}}}}}}` + var single item + err = Unmarshal([]byte(given), &single, UnmarshalUseNumber()) + is.MustNoError(t, err) + is.Equal(t, item{ID: "1", Related: &item{ID: "2", Int: 9007199254740993}}, single) }) t.Run("non-integer literal into int64 errors with UnmarshalUseNumber", func(t *testing.T) { From b28a10fb2d30576cce8f27196fcc3bbe1e7d4d6b Mon Sep 17 00:00:00 2001 From: Walid Cavelius Date: Fri, 23 Oct 2026 13:57:54 +0200 Subject: [PATCH 6/6] Make TestUnmarshalNumbers a single table like the other tests Co-Authored-By: Claude Opus 5.5 (1M context) --- unmarshal_test.go | 111 +++++++++++++++++++++++----------------------- 1 file changed, 56 insertions(+), 55 deletions(-) diff --git a/unmarshal_test.go b/unmarshal_test.go index f4307eb..0aaea2e 100644 --- a/unmarshal_test.go +++ b/unmarshal_test.go @@ -781,55 +781,84 @@ func TestUnmarshalNumbers(t *testing.T) { List []int64 `jsonapi:"attribute" json:"list,omitempty"` Values map[string]any `jsonapi:"attribute" json:"values,omitempty"` } + type item struct { + ID string `jsonapi:"primary,items"` + Int int64 `jsonapi:"attribute" json:"int,omitempty"` + Related *item `jsonapi:"relationship" json:"related,omitempty"` + } - body := func(attributes string) []byte { - return []byte(`{"data":{"type":"numbers","id":"1","attributes":` + attributes + `}}`) + unmarshalNumbers := func(attributes string, opts ...UnmarshalOption) (any, error) { + var n numbers + err := Unmarshal([]byte(`{"data":{"type":"numbers","id":"1","attributes":`+attributes+`}}`), &n, opts...) + return &n, err } - useNumber := []UnmarshalOption{UnmarshalUseNumber()} + itemsWithIncludedBody := `{"data":[{"type":"items","id":"1","attributes":{"int":9007199254740993},"relationships":{"related":{"data":{"type":"items","id":"2"}}}}],` + + `"included":[{"type":"items","id":"2","attributes":{"int":9007199254740995}}]}` + itemWithRelationshipAttributesBody := `{"data":{"type":"items","id":"1","relationships":{"related":{"data":{"type":"items","id":"2","attributes":{"int":9007199254740993}}}}}}` tests := []struct { description string - given []byte - opts []UnmarshalOption + do func() (any, error) expect any + expectError error }{ { description: "int64 above 2^53 rounds", - given: body(`{"int":9007199254740993}`), + do: func() (any, error) { return unmarshalNumbers(`{"int":9007199254740993}`) }, expect: &numbers{ID: "1", Int: 9007199254740992}, }, { description: "non-integer literals with integer values decode into int64", - given: body(`{"int":100.0,"list":[1e2]}`), + do: func() (any, error) { return unmarshalNumbers(`{"int":100.0,"list":[1e2]}`) }, expect: &numbers{ID: "1", Int: 100, List: []int64{100}}, }, { description: "map[string]any values are float64", - given: body(`{"values":{"int":9007199254740993,"float":1.5}}`), + do: func() (any, error) { return unmarshalNumbers(`{"values":{"int":9007199254740993,"float":1.5}}`) }, expect: &numbers{ID: "1", Values: map[string]any{"int": float64(9007199254740992), "float": 1.5}}, }, { description: "int64 above 2^53 with UnmarshalUseNumber", - given: body(`{"int":9007199254740993}`), - opts: useNumber, + do: func() (any, error) { return unmarshalNumbers(`{"int":9007199254740993}`, UnmarshalUseNumber()) }, expect: &numbers{ID: "1", Int: 9007199254740993}, }, { description: "uint64 max with UnmarshalUseNumber", - given: body(`{"uint":18446744073709551615}`), - opts: useNumber, + do: func() (any, error) { return unmarshalNumbers(`{"uint":18446744073709551615}`, UnmarshalUseNumber()) }, expect: &numbers{ID: "1", Uint: 18446744073709551615}, }, { description: "int64 min and max in nested struct and slice with UnmarshalUseNumber", - given: body(`{"nested":{"max":9223372036854775807},"list":[-9223372036854775808,9007199254740993]}`), - opts: useNumber, - expect: &numbers{ID: "1", Nested: &nested{Max: 9223372036854775807}, List: []int64{-9223372036854775808, 9007199254740993}}, + do: func() (any, error) { + return unmarshalNumbers(`{"nested":{"max":9223372036854775807},"list":[-9223372036854775808,9007199254740993]}`, UnmarshalUseNumber()) + }, + expect: &numbers{ID: "1", Nested: &nested{Max: 9223372036854775807}, List: []int64{-9223372036854775808, 9007199254740993}}, }, { description: "integer above 2^53 into float64 rounds with UnmarshalUseNumber", - given: body(`{"float":9007199254740993}`), - opts: useNumber, + do: func() (any, error) { return unmarshalNumbers(`{"float":9007199254740993}`, UnmarshalUseNumber()) }, expect: &numbers{ID: "1", Float: 9007199254740992}, }, { description: "map[string]any values are json.Number with UnmarshalUseNumber", - given: body(`{"values":{"int":9007199254740993,"float":1.5}}`), - opts: useNumber, - expect: &numbers{ID: "1", Values: map[string]any{"int": json.Number("9007199254740993"), "float": json.Number("1.5")}}, + do: func() (any, error) { + return unmarshalNumbers(`{"values":{"int":9007199254740993,"float":1.5}}`, UnmarshalUseNumber()) + }, + expect: &numbers{ID: "1", Values: map[string]any{"int": json.Number("9007199254740993"), "float": json.Number("1.5")}}, + }, { + description: "non-integer literal into int64 errors with UnmarshalUseNumber", + do: func() (any, error) { return unmarshalNumbers(`{"int":100.0}`, UnmarshalUseNumber()) }, + expect: &numbers{ID: "1"}, + expectError: &json.UnmarshalTypeError{Value: "number 100.0", Type: reflect.TypeOf(int64(0)), Struct: "numbers", Field: "int"}, + }, { + description: "[]*item with included resource with UnmarshalUseNumber", + do: func() (any, error) { + var items []*item + err := Unmarshal([]byte(itemsWithIncludedBody), &items, UnmarshalUseNumber()) + return items, err + }, + expect: []*item{{ID: "1", Int: 9007199254740993, Related: &item{ID: "2", Int: 9007199254740995}}}, + }, { + description: "*item with attributes in relationship data with UnmarshalUseNumber", + do: func() (any, error) { + var i item + err := Unmarshal([]byte(itemWithRelationshipAttributesBody), &i, UnmarshalUseNumber()) + return &i, err + }, + expect: &item{ID: "1", Related: &item{ID: "2", Int: 9007199254740993}}, }, } @@ -839,44 +868,16 @@ func TestUnmarshalNumbers(t *testing.T) { t.Parallel() t.Log(tc.description) - var actual numbers - err := Unmarshal(tc.given, &actual, tc.opts...) + actual, err := tc.do() + if tc.expectError != nil { + is.EqualError(t, tc.expectError, err) + is.Equal(t, tc.expect, actual) + return + } is.MustNoError(t, err) - is.Equal(t, tc.expect, &actual) + is.Equal(t, tc.expect, actual) }) } - - t.Run("slice and included resource with UnmarshalUseNumber", func(t *testing.T) { - t.Parallel() - - type item struct { - ID string `jsonapi:"primary,items"` - Int int64 `jsonapi:"attribute" json:"int,omitempty"` - Related *item `jsonapi:"relationship" json:"related,omitempty"` - } - given := `{"data":[{"type":"items","id":"1","attributes":{"int":9007199254740993},"relationships":{"related":{"data":{"type":"items","id":"2"}}}}],` + - `"included":[{"type":"items","id":"2","attributes":{"int":9007199254740995}}]}` - - var actual []*item - err := Unmarshal([]byte(given), &actual, UnmarshalUseNumber()) - is.MustNoError(t, err) - is.Equal(t, []*item{{ID: "1", Int: 9007199254740993, Related: &item{ID: "2", Int: 9007199254740995}}}, actual) - - // attributes inside the relationship data itself - given = `{"data":{"type":"items","id":"1","relationships":{"related":{"data":{"type":"items","id":"2","attributes":{"int":9007199254740993}}}}}}` - var single item - err = Unmarshal([]byte(given), &single, UnmarshalUseNumber()) - is.MustNoError(t, err) - is.Equal(t, item{ID: "1", Related: &item{ID: "2", Int: 9007199254740993}}, single) - }) - - t.Run("non-integer literal into int64 errors with UnmarshalUseNumber", func(t *testing.T) { - t.Parallel() - - var actual numbers - err := Unmarshal(body(`{"int":100.0}`), &actual, UnmarshalUseNumber()) - is.Error(t, err) - }) } // TestUnmarshalMemberNameValidation collects tests which verify that invalid member names are