From 1b34b8c74a907ac525fd5412181a2f220a96ff90 Mon Sep 17 00:00:00 2001 From: Jonathan Sharman Date: Tue, 8 Sep 2026 18:51:38 -0400 Subject: [PATCH 1/6] Require Go 1.27 and update dependencies --- go.mod | 6 +++--- go.sum | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index f3a211c..39bd797 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ module github.com/nicheinc/nullable/v2 -go 1.24 +go 1.27.1 require github.com/nicheinc/expect v0.2.0 require ( - github.com/google/go-cmp v0.5.9 // indirect - golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect + github.com/google/go-cmp v0.7.0 // indirect + golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect ) diff --git a/go.sum b/go.sum index 2e91079..45cdc47 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,6 @@ -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/nicheinc/expect v0.2.0 h1:Z0xKpZiDQsRuxhm2HsUh4M9datV1QgM/DpCFWvN/rpY= github.com/nicheinc/expect v0.2.0/go.mod h1:NRiUkkvrrIz1Uj0VccPt3ZBZcqGU3RnPSK55oYVSJiY= -golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o= -golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= From d3e6223134687e41cff55c7499c7e6a3c5b83942 Mon Sep 17 00:00:00 2001 From: Jonathan Sharman Date: Tue, 8 Sep 2026 22:51:10 -0400 Subject: [PATCH 2/6] Implement json/v2 custom marshalers --- nup/sliceUpdate.go | 35 +++++++++++-- nup/sliceUpdate_test.go | 109 ++++++++++++++++++++++++++++++---------- nup/update.go | 35 +++++++++++-- nup/update_test.go | 96 +++++++++++++++++++++++++++-------- 4 files changed, 216 insertions(+), 59 deletions(-) diff --git a/nup/sliceUpdate.go b/nup/sliceUpdate.go index ebe4c4f..84daaa2 100644 --- a/nup/sliceUpdate.go +++ b/nup/sliceUpdate.go @@ -1,7 +1,9 @@ package nup import ( - "encoding/json" + jsonv1 "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" "fmt" ) @@ -123,22 +125,45 @@ func (u SliceUpdate[T]) Diff(value []T) SliceUpdate[T] { return u } -// MarshalJSON implements json.Marshaler. +// Deprecated: Use [SliceUpdate.MarshalJSONTo]. +// +// MarshalJSON implements [jsonv1.Marshaler]. func (u SliceUpdate[T]) MarshalJSON() ([]byte, error) { if u.op == OpSet { - return json.Marshal(u.value) + return jsonv1.Marshal(u.value) } return []byte("null"), nil } -// UnmarshalJSON implements json.Unmarshaler. +// MarshalJSONTo implements [json.MarshalerTo]. +func (u SliceUpdate[T]) MarshalJSONTo(encoder *jsontext.Encoder) error { + if u.op == OpSet { + return json.MarshalEncode(encoder, u.value) + } + return encoder.WriteToken(jsontext.Null) +} + +// Deprecated: Use [SliceUpdate.UnmarshalJSONFrom]. +// +// UnmarshalJSON implements [jsonv1.Unmarshaler]. func (u *SliceUpdate[T]) UnmarshalJSON(data []byte) error { if string(data) == "null" { u.op = OpRemove return nil } u.op = OpSet - return json.Unmarshal(data, &u.value) + return jsonv1.Unmarshal(data, &u.value) +} + +// UnmarshalJSONFrom implements [json.UnmarshalerFrom]. +func (u *SliceUpdate[T]) UnmarshalJSONFrom(decoder *jsontext.Decoder) error { + if decoder.PeekKind() == jsontext.KindNull { + decoder.ReadToken() + u.op = OpRemove + return nil + } + u.op = OpSet + return json.UnmarshalDecode(decoder, &u.value) } // IsSetTo returns whether the update sets to a value that is element-wise equal diff --git a/nup/sliceUpdate_test.go b/nup/sliceUpdate_test.go index f572474..247e9ac 100644 --- a/nup/sliceUpdate_test.go +++ b/nup/sliceUpdate_test.go @@ -1,7 +1,7 @@ package nup import ( - "encoding/json" + "encoding/json/v2" "fmt" "testing" @@ -17,6 +17,35 @@ var ( var _ updateMarshaller = &SliceUpdate[int]{} func TestSliceUpdate_MarshalJSON(t *testing.T) { + type testCase struct { + update SliceUpdate[int] + expected string + } + run := func(name string, testCase testCase) { + t.Helper() + t.Run(name, func(t *testing.T) { + t.Helper() + actual, err := testCase.update.MarshalJSON() + expect.ErrorNil(t, err) + expect.Equal(t, string(actual), testCase.expected) + }) + } + + run("Noop", testCase{ + update: SliceNoop[int](), + expected: "null", + }) + run("Remove", testCase{ + update: SliceRemove[int](), + expected: "null", + }) + run("Set", testCase{ + update: SliceRemoveOrSet(testSlice1), + expected: "[1]", + }) +} + +func TestSliceUpdate_MarshalJSONTo(t *testing.T) { type testCase struct { update SliceUpdate[int] expected string @@ -40,41 +69,50 @@ func TestSliceUpdate_MarshalJSON(t *testing.T) { expected: "null", }) run("Set", testCase{ - update: SliceRemoveOrSet[int](testSlice1), + update: SliceRemoveOrSet(testSlice1), expected: "[1]", }) } func TestSliceUpdate_UnmarshalJSON(t *testing.T) { - testCases := []struct { - name string + type testCase struct { json string expected SliceUpdate[int] - }{ - { - name: "EmptyJSONObject", - json: `{}`, - expected: SliceNoop[int](), - }, - { - name: "NullUpdate", - json: `{"update": null}`, - expected: SliceRemove[int](), - }, - { - name: "EmptyUpdate", - json: `{"update": []}`, - expected: SliceRemoveOrSet([]int{}), - }, - { - name: "NonemptyUpdate", - json: fmt.Sprintf(`{"update": %v}`, testSlice1), - expected: SliceRemoveOrSet(testSlice1), - }, + } + run := func(name string, testCase testCase) { + t.Helper() + t.Run(name, func(t *testing.T) { + t.Helper() + var update SliceUpdate[int] + err := update.UnmarshalJSON([]byte(testCase.json)) + expect.ErrorNil(t, err) + expect.Equal(t, update, testCase.expected) + }) } - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { + run("Null", testCase{ + json: `null`, + expected: SliceRemove[int](), + }) + run("Empty", testCase{ + json: `[]`, + expected: SliceRemoveOrSet([]int{}), + }) + run("Nonempty", testCase{ + json: fmt.Sprint(testSlice1), + expected: SliceRemoveOrSet(testSlice1), + }) +} + +func TestSliceUpdate_UnmarshalJSONFrom(t *testing.T) { + type testCase struct { + json string + expected SliceUpdate[int] + } + run := func(name string, testCase testCase) { + t.Helper() + t.Run(name, func(t *testing.T) { + t.Helper() var dst struct { Update SliceUpdate[int] `json:"update"` } @@ -83,6 +121,23 @@ func TestSliceUpdate_UnmarshalJSON(t *testing.T) { expect.Equal(t, dst.Update, testCase.expected) }) } + + run("EmptyJSONObject", testCase{ + json: `{}`, + expected: SliceNoop[int](), + }) + run("NullUpdate", testCase{ + json: `{"update": null}`, + expected: SliceRemove[int](), + }) + run("EmptyUpdate", testCase{ + json: `{"update": []}`, + expected: SliceRemoveOrSet([]int{}), + }) + run("NonemptyUpdate", testCase{ + json: fmt.Sprintf(`{"update": %v}`, testSlice1), + expected: SliceRemoveOrSet(testSlice1), + }) } func TestSliceRemoveOrSet(t *testing.T) { diff --git a/nup/update.go b/nup/update.go index cf823b5..bf56ab9 100644 --- a/nup/update.go +++ b/nup/update.go @@ -1,7 +1,9 @@ package nup import ( - "encoding/json" + jsonv1 "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" "fmt" ) @@ -162,22 +164,45 @@ func (u Update[T]) DiffPtr(value *T) Update[T] { } } -// MarshalJSON implements json.Marshaler. +// Deprecated: Use [Update.MarshalJSONTo]. +// +// MarshalJSON implements [jsonv1.Marshaler]. func (u Update[T]) MarshalJSON() ([]byte, error) { if u.op == OpSet { - return json.Marshal(u.value) + return jsonv1.Marshal(u.value) } return []byte("null"), nil } -// UnmarshalJSON implements json.Unmarshaler. +// MarshalJSONTo implements [json.MarshalerTo]. +func (u Update[T]) MarshalJSONTo(encoder *jsontext.Encoder) error { + if u.op == OpSet { + return json.MarshalEncode(encoder, u.value) + } + return encoder.WriteToken(jsontext.Null) +} + +// Deprecated: Use [Update.UnmarshalJSONFrom]. +// +// UnmarshalJSON implements [jsonv1.Unmarshaler]. func (u *Update[T]) UnmarshalJSON(data []byte) error { if string(data) == "null" { u.op = OpRemove return nil } u.op = OpSet - return json.Unmarshal(data, &u.value) + return jsonv1.Unmarshal(data, &u.value) +} + +// UnmarshalJSONFrom implements [json.UnmarshalerFrom]. +func (u *Update[T]) UnmarshalJSONFrom(decoder *jsontext.Decoder) error { + if decoder.PeekKind() == jsontext.KindNull { + decoder.ReadToken() + u.op = OpRemove + return nil + } + u.op = OpSet + return json.UnmarshalDecode(decoder, &u.value) } // IsSetTo returns whether the update sets to the given value. diff --git a/nup/update_test.go b/nup/update_test.go index c578a4b..811e846 100644 --- a/nup/update_test.go +++ b/nup/update_test.go @@ -1,7 +1,7 @@ package nup import ( - "encoding/json" + "encoding/json/v2" "fmt" "testing" @@ -14,6 +14,35 @@ var testValue = 42 var _ updateMarshaller = &Update[int]{} func TestUpdate_MarshalJSON(t *testing.T) { + type testCase struct { + update Update[int] + expected string + } + run := func(name string, testCase testCase) { + t.Helper() + t.Run(name, func(t *testing.T) { + t.Helper() + actual, err := testCase.update.MarshalJSON() + expect.ErrorNil(t, err) + expect.Equal(t, string(actual), testCase.expected) + }) + } + + run("Noop", testCase{ + update: Noop[int](), + expected: "null", + }) + run("Remove", testCase{ + update: Remove[int](), + expected: "null", + }) + run("Set", testCase{ + update: Set(testValue), + expected: "42", + }) +} + +func TestUpdate_MarshalJSONTo(t *testing.T) { type testCase struct { update Update[int] expected string @@ -37,36 +66,46 @@ func TestUpdate_MarshalJSON(t *testing.T) { expected: "null", }) run("Set", testCase{ - update: Set[int](testValue), + update: Set(testValue), expected: "42", }) } func TestUpdate_UnmarshalJSON(t *testing.T) { - testCases := []struct { - name string + type testCase struct { json string expected Update[int] - }{ - { - name: "EmptyJSONObject", - json: `{}`, - expected: Noop[int](), - }, - { - name: "NullUpdate", - json: `{"update": null}`, - expected: Remove[int](), - }, - { - name: "ValueUpdate", - json: fmt.Sprintf(`{"update": %v}`, testValue), - expected: Set(testValue), - }, + } + run := func(name string, testCase testCase) { + t.Helper() + t.Run(name, func(t *testing.T) { + t.Helper() + var update Update[int] + err := update.UnmarshalJSON([]byte(testCase.json)) + expect.ErrorNil(t, err) + expect.Equal(t, update, testCase.expected) + }) } - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { + run("Null", testCase{ + json: `null`, + expected: Remove[int](), + }) + run("Value", testCase{ + json: fmt.Sprint(testValue), + expected: Set(testValue), + }) +} + +func TestUpdate_UnmarshalJSONFrom(t *testing.T) { + type testCase struct { + json string + expected Update[int] + } + run := func(name string, testCase testCase) { + t.Helper() + t.Run(name, func(t *testing.T) { + t.Helper() var dst struct { Update Update[int] `json:"update"` } @@ -75,6 +114,19 @@ func TestUpdate_UnmarshalJSON(t *testing.T) { expect.Equal(t, dst.Update, testCase.expected) }) } + + run("EmptyJSONObject", testCase{ + json: `{}`, + expected: Noop[int](), + }) + run("NullUpdate", testCase{ + json: `{"update": null}`, + expected: Remove[int](), + }) + run("ValueUpdate", testCase{ + json: fmt.Sprintf(`{"update": %v}`, testValue), + expected: Set(testValue), + }) } func TestRemoveOrSet(t *testing.T) { From 12ab8346d46dfa45e6ed69b4b657388c2b7225f2 Mon Sep 17 00:00:00 2001 From: Jonathan Sharman Date: Tue, 8 Sep 2026 22:51:21 -0400 Subject: [PATCH 3/6] Remove invalid json tag comma --- nup/marshalJSON_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nup/marshalJSON_test.go b/nup/marshalJSON_test.go index b40d440..bd7cee1 100644 --- a/nup/marshalJSON_test.go +++ b/nup/marshalJSON_test.go @@ -241,7 +241,7 @@ func TestMarshalJSON_RoundTrip(t *testing.T) { NoTag: 1, }) roundtrip(t, "EmptyName", struct { - EmptyName int `json:","` + EmptyName int `json:""` }{ EmptyName: 1, }) From 1b969172de65d7491062b417747a332d1ffc73a3 Mon Sep 17 00:00:00 2001 From: Jonathan Sharman Date: Tue, 8 Sep 2026 22:59:37 -0400 Subject: [PATCH 4/6] Run go fix --- nup/marshalJSON.go | 8 ++++---- nup/marshalJSON_test.go | 21 ++++++++++++--------- nup/sliceUpdate.go | 2 +- nup/update.go | 4 ++-- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/nup/marshalJSON.go b/nup/marshalJSON.go index d1f84d6..6bd035e 100644 --- a/nup/marshalJSON.go +++ b/nup/marshalJSON.go @@ -14,7 +14,7 @@ import ( // Any struct that contains Update or SliceUpdate fields should call this // function instead of the default json.Marshal. For more info, see // https://pkg.go.dev/github.com/nicheinc/nullable/#hdr-Marshalling. -func MarshalJSON(v interface{}) ([]byte, error) { +func MarshalJSON(v any) ([]byte, error) { // Marshal nil as null. if v == nil { return []byte("null"), nil @@ -46,7 +46,7 @@ func MarshalJSON(v interface{}) ([]byte, error) { if key == nil { continue } - appendField := func(fieldInterface interface{}) error { + appendField := func(fieldInterface any) error { valueBuf, err := json.Marshal(fieldInterface) if err != nil { return err @@ -96,7 +96,7 @@ type updateMarshaller interface { IsChange() bool // interfaceValue returns the (possibly nil) updated value as an interface{} // to be marshalled to JSON. - interfaceValue() interface{} + interfaceValue() any } // getKeyName tries to extract the marshalled key name from a struct field and @@ -182,7 +182,7 @@ func isEmptyValue(v reflect.Value) bool { return v.Uint() == 0 case reflect.Float32, reflect.Float64: return v.Float() == 0 - case reflect.Interface, reflect.Ptr: + case reflect.Interface, reflect.Pointer: return v.IsNil() } return false diff --git a/nup/marshalJSON_test.go b/nup/marshalJSON_test.go index bd7cee1..38bed75 100644 --- a/nup/marshalJSON_test.go +++ b/nup/marshalJSON_test.go @@ -11,7 +11,7 @@ import ( func TestMarshalJSON_OneWay(t *testing.T) { testCases := []struct { name string - input interface{} + input any expected string }{ { @@ -75,12 +75,15 @@ func TestMarshalJSON_OneWay(t *testing.T) { { name: "Omitempy/Nonempty", input: struct { - Slice []int `json:",omitempty"` - B bool `json:",omitempty"` - I int `json:",omitempty"` - U uint `json:",omitempty"` - F float32 `json:",omitempty"` - Ptr *int `json:",omitempty"` + Slice []int `json:",omitempty"` + B bool `json:",omitempty"` + I int `json:",omitempty"` + U uint `json:",omitempty"` + F float32 `json:",omitempty"` + Ptr *int `json:",omitempty"` + // go fix will remove the following struct tag since it doesn't + // have any effect on json.Marshal, but that's the behavior + // we're trying to reproduce, so this tag should not be removed. NeverEmpty struct{} `json:",omitempty"` }{ Slice: []int{1}, @@ -105,7 +108,7 @@ func TestMarshalJSON_OneWay(t *testing.T) { { name: "EmptyName", input: struct { - EmptyName int `json:","` + EmptyName int `json:""` }{ EmptyName: 1, }, @@ -316,7 +319,7 @@ func roundtrip[T any](t *testing.T, testName string, input T) { func TestMarshalJSON_FieldErrors(t *testing.T) { testCases := []struct { name string - input interface{} + input any }{ { name: "NonUpdateField", diff --git a/nup/sliceUpdate.go b/nup/sliceUpdate.go index 84daaa2..7d8eccc 100644 --- a/nup/sliceUpdate.go +++ b/nup/sliceUpdate.go @@ -214,7 +214,7 @@ func (u SliceUpdate[T]) Equal(other SliceUpdate[T]) bool { // interfaceValue, along with IsChange, implements updateMarshaller, which // nup.MarshalJSON uses to detect update types and marshal them correctly. -func (u SliceUpdate[T]) interfaceValue() interface{} { +func (u SliceUpdate[T]) interfaceValue() any { if u.op == OpSet { return u.value } diff --git a/nup/update.go b/nup/update.go index bf56ab9..fdd3e95 100644 --- a/nup/update.go +++ b/nup/update.go @@ -225,7 +225,7 @@ func (u Update[T]) String() string { case OpRemove: return "" } - switch value := interface{}(u.value).(type) { + switch value := any(u.value).(type) { case string: return value case fmt.Stringer: @@ -246,7 +246,7 @@ func (u Update[T]) Equal(other Update[T]) bool { // interfaceValue, along with IsChange, implements updateMarshaller, which // nup.MarshalJSON uses to detect update types and marshal them correctly. -func (u Update[T]) interfaceValue() interface{} { +func (u Update[T]) interfaceValue() any { if u.op == OpSet { return u.value } From b1449f51960d3f93f6f0dedeb780e12e74a89495 Mon Sep 17 00:00:00 2001 From: Jonathan Sharman Date: Tue, 8 Sep 2026 23:27:45 -0400 Subject: [PATCH 5/6] Downgrade required Go version to minor version --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 39bd797..e504951 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/nicheinc/nullable/v2 -go 1.27.1 +go 1.27 require github.com/nicheinc/expect v0.2.0 From ebdce2739e4356b479e8661127ba63f4a4c887d3 Mon Sep 17 00:00:00 2001 From: Jonathan Sharman Date: Tue, 8 Sep 2026 23:49:11 -0400 Subject: [PATCH 6/6] Handle ReadToken errors and don't leave updates partially modified during errors --- nup/sliceUpdate.go | 24 +++++++++++----- nup/sliceUpdate_test.go | 64 ++++++++++++++++++++++++++++------------- nup/update.go | 24 +++++++++++----- nup/update_test.go | 54 +++++++++++++++++++++++----------- 4 files changed, 116 insertions(+), 50 deletions(-) diff --git a/nup/sliceUpdate.go b/nup/sliceUpdate.go index 7d8eccc..281e459 100644 --- a/nup/sliceUpdate.go +++ b/nup/sliceUpdate.go @@ -148,22 +148,32 @@ func (u SliceUpdate[T]) MarshalJSONTo(encoder *jsontext.Encoder) error { // UnmarshalJSON implements [jsonv1.Unmarshaler]. func (u *SliceUpdate[T]) UnmarshalJSON(data []byte) error { if string(data) == "null" { - u.op = OpRemove + *u = SliceRemove[T]() return nil } - u.op = OpSet - return jsonv1.Unmarshal(data, &u.value) + var value []T + if unmarshalErr := jsonv1.Unmarshal(data, &value); unmarshalErr != nil { + return unmarshalErr + } + *u = SliceRemoveOrSet(value) + return nil } // UnmarshalJSONFrom implements [json.UnmarshalerFrom]. func (u *SliceUpdate[T]) UnmarshalJSONFrom(decoder *jsontext.Decoder) error { if decoder.PeekKind() == jsontext.KindNull { - decoder.ReadToken() - u.op = OpRemove + if _, readErr := decoder.ReadToken(); readErr != nil { + return readErr + } + *u = SliceRemove[T]() return nil } - u.op = OpSet - return json.UnmarshalDecode(decoder, &u.value) + var value []T + if unmarshalErr := json.UnmarshalDecode(decoder, &value); unmarshalErr != nil { + return unmarshalErr + } + *u = SliceRemoveOrSet(value) + return nil } // IsSetTo returns whether the update sets to a value that is element-wise equal diff --git a/nup/sliceUpdate_test.go b/nup/sliceUpdate_test.go index 247e9ac..81c5a67 100644 --- a/nup/sliceUpdate_test.go +++ b/nup/sliceUpdate_test.go @@ -76,8 +76,9 @@ func TestSliceUpdate_MarshalJSONTo(t *testing.T) { func TestSliceUpdate_UnmarshalJSON(t *testing.T) { type testCase struct { - json string - expected SliceUpdate[int] + json string + expected SliceUpdate[int] + errorCheck expect.ErrorCheck } run := func(name string, testCase testCase) { t.Helper() @@ -85,29 +86,38 @@ func TestSliceUpdate_UnmarshalJSON(t *testing.T) { t.Helper() var update SliceUpdate[int] err := update.UnmarshalJSON([]byte(testCase.json)) - expect.ErrorNil(t, err) + testCase.errorCheck(t, err) expect.Equal(t, update, testCase.expected) }) } + run("MalformedNull", testCase{ + json: `nup`, + expected: SliceNoop[int](), + errorCheck: expect.ErrorNonNil, + }) run("Null", testCase{ - json: `null`, - expected: SliceRemove[int](), + json: `null`, + expected: SliceRemove[int](), + errorCheck: expect.ErrorNil, }) run("Empty", testCase{ - json: `[]`, - expected: SliceRemoveOrSet([]int{}), + json: `[]`, + expected: SliceRemoveOrSet([]int{}), + errorCheck: expect.ErrorNil, }) run("Nonempty", testCase{ - json: fmt.Sprint(testSlice1), - expected: SliceRemoveOrSet(testSlice1), + json: fmt.Sprint(testSlice1), + expected: SliceRemoveOrSet(testSlice1), + errorCheck: expect.ErrorNil, }) } func TestSliceUpdate_UnmarshalJSONFrom(t *testing.T) { type testCase struct { - json string - expected SliceUpdate[int] + json string + expected SliceUpdate[int] + errorCheck expect.ErrorCheck } run := func(name string, testCase testCase) { t.Helper() @@ -117,26 +127,40 @@ func TestSliceUpdate_UnmarshalJSONFrom(t *testing.T) { Update SliceUpdate[int] `json:"update"` } err := json.Unmarshal([]byte(testCase.json), &dst) - expect.ErrorNil(t, err) + testCase.errorCheck(t, err) expect.Equal(t, dst.Update, testCase.expected) }) } run("EmptyJSONObject", testCase{ - json: `{}`, - expected: SliceNoop[int](), + json: `{}`, + expected: SliceNoop[int](), + errorCheck: expect.ErrorNil, + }) + run("MalformedNullUpdate", testCase{ + json: `{"update": nup}`, + expected: SliceNoop[int](), + errorCheck: expect.ErrorNonNil, }) run("NullUpdate", testCase{ - json: `{"update": null}`, - expected: SliceRemove[int](), + json: `{"update": null}`, + expected: SliceRemove[int](), + errorCheck: expect.ErrorNil, }) run("EmptyUpdate", testCase{ - json: `{"update": []}`, - expected: SliceRemoveOrSet([]int{}), + json: `{"update": []}`, + expected: SliceRemoveOrSet([]int{}), + errorCheck: expect.ErrorNil, + }) + run("MalformedNonemptyUpdate", testCase{ + json: `{"update": invalid}`, + expected: SliceNoop[int](), + errorCheck: expect.ErrorNonNil, }) run("NonemptyUpdate", testCase{ - json: fmt.Sprintf(`{"update": %v}`, testSlice1), - expected: SliceRemoveOrSet(testSlice1), + json: fmt.Sprintf(`{"update": %v}`, testSlice1), + expected: SliceRemoveOrSet(testSlice1), + errorCheck: expect.ErrorNil, }) } diff --git a/nup/update.go b/nup/update.go index fdd3e95..84b8b7e 100644 --- a/nup/update.go +++ b/nup/update.go @@ -187,22 +187,32 @@ func (u Update[T]) MarshalJSONTo(encoder *jsontext.Encoder) error { // UnmarshalJSON implements [jsonv1.Unmarshaler]. func (u *Update[T]) UnmarshalJSON(data []byte) error { if string(data) == "null" { - u.op = OpRemove + *u = Remove[T]() return nil } - u.op = OpSet - return jsonv1.Unmarshal(data, &u.value) + var value T + if unmarshalErr := jsonv1.Unmarshal(data, &value); unmarshalErr != nil { + return unmarshalErr + } + *u = Set(value) + return nil } // UnmarshalJSONFrom implements [json.UnmarshalerFrom]. func (u *Update[T]) UnmarshalJSONFrom(decoder *jsontext.Decoder) error { if decoder.PeekKind() == jsontext.KindNull { - decoder.ReadToken() - u.op = OpRemove + if _, readErr := decoder.ReadToken(); readErr != nil { + return readErr + } + *u = Remove[T]() return nil } - u.op = OpSet - return json.UnmarshalDecode(decoder, &u.value) + var value T + if unmarshalErr := json.UnmarshalDecode(decoder, &value); unmarshalErr != nil { + return unmarshalErr + } + *u = Set(value) + return nil } // IsSetTo returns whether the update sets to the given value. diff --git a/nup/update_test.go b/nup/update_test.go index 811e846..88edd9b 100644 --- a/nup/update_test.go +++ b/nup/update_test.go @@ -73,8 +73,9 @@ func TestUpdate_MarshalJSONTo(t *testing.T) { func TestUpdate_UnmarshalJSON(t *testing.T) { type testCase struct { - json string - expected Update[int] + json string + expected Update[int] + errorCheck expect.ErrorCheck } run := func(name string, testCase testCase) { t.Helper() @@ -82,25 +83,33 @@ func TestUpdate_UnmarshalJSON(t *testing.T) { t.Helper() var update Update[int] err := update.UnmarshalJSON([]byte(testCase.json)) - expect.ErrorNil(t, err) + testCase.errorCheck(t, err) expect.Equal(t, update, testCase.expected) }) } + run("MalformedNull", testCase{ + json: `nup`, + expected: Noop[int](), + errorCheck: expect.ErrorNonNil, + }) run("Null", testCase{ - json: `null`, - expected: Remove[int](), + json: `null`, + expected: Remove[int](), + errorCheck: expect.ErrorNil, }) run("Value", testCase{ - json: fmt.Sprint(testValue), - expected: Set(testValue), + json: fmt.Sprint(testValue), + expected: Set(testValue), + errorCheck: expect.ErrorNil, }) } func TestUpdate_UnmarshalJSONFrom(t *testing.T) { type testCase struct { - json string - expected Update[int] + json string + expected Update[int] + errorCheck expect.ErrorCheck } run := func(name string, testCase testCase) { t.Helper() @@ -110,22 +119,35 @@ func TestUpdate_UnmarshalJSONFrom(t *testing.T) { Update Update[int] `json:"update"` } err := json.Unmarshal([]byte(testCase.json), &dst) - expect.ErrorNil(t, err) + testCase.errorCheck(t, err) expect.Equal(t, dst.Update, testCase.expected) }) } run("EmptyJSONObject", testCase{ - json: `{}`, - expected: Noop[int](), + json: `{}`, + expected: Noop[int](), + errorCheck: expect.ErrorNil, + }) + run("MalformedNullUpdate", testCase{ + json: `{"update": nup}`, + expected: Noop[int](), + errorCheck: expect.ErrorNonNil, }) run("NullUpdate", testCase{ - json: `{"update": null}`, - expected: Remove[int](), + json: `{"update": null}`, + expected: Remove[int](), + errorCheck: expect.ErrorNil, + }) + run("MalformedValueUpdate", testCase{ + json: `{"update": invalid}`, + expected: Noop[int](), + errorCheck: expect.ErrorNonNil, }) run("ValueUpdate", testCase{ - json: fmt.Sprintf(`{"update": %v}`, testValue), - expected: Set(testValue), + json: fmt.Sprintf(`{"update": %v}`, testValue), + expected: Set(testValue), + errorCheck: expect.ErrorNil, }) }