diff --git a/go.mod b/go.mod index f3a211c..e504951 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ module github.com/nicheinc/nullable/v2 -go 1.24 +go 1.27 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= 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 b40d440..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, }, @@ -241,7 +244,7 @@ func TestMarshalJSON_RoundTrip(t *testing.T) { NoTag: 1, }) roundtrip(t, "EmptyName", 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 ebe4c4f..281e459 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,55 @@ 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 + *u = SliceRemove[T]() return nil } - u.op = OpSet - return json.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 { + if _, readErr := decoder.ReadToken(); readErr != nil { + return readErr + } + *u = SliceRemove[T]() + return nil + } + 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 @@ -189,7 +224,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/sliceUpdate_test.go b/nup/sliceUpdate_test.go index f572474..81c5a67 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,49 +69,99 @@ 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 - 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), - }, + type testCase struct { + json string + expected SliceUpdate[int] + errorCheck expect.ErrorCheck + } + 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)) + testCase.errorCheck(t, err) + expect.Equal(t, update, testCase.expected) + }) } - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { + run("MalformedNull", testCase{ + json: `nup`, + expected: SliceNoop[int](), + errorCheck: expect.ErrorNonNil, + }) + run("Null", testCase{ + json: `null`, + expected: SliceRemove[int](), + errorCheck: expect.ErrorNil, + }) + run("Empty", testCase{ + json: `[]`, + expected: SliceRemoveOrSet([]int{}), + errorCheck: expect.ErrorNil, + }) + run("Nonempty", testCase{ + json: fmt.Sprint(testSlice1), + expected: SliceRemoveOrSet(testSlice1), + errorCheck: expect.ErrorNil, + }) +} + +func TestSliceUpdate_UnmarshalJSONFrom(t *testing.T) { + type testCase struct { + json string + expected SliceUpdate[int] + errorCheck expect.ErrorCheck + } + 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"` } 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](), + errorCheck: expect.ErrorNil, + }) + run("MalformedNullUpdate", testCase{ + json: `{"update": nup}`, + expected: SliceNoop[int](), + errorCheck: expect.ErrorNonNil, + }) + run("NullUpdate", testCase{ + json: `{"update": null}`, + expected: SliceRemove[int](), + errorCheck: expect.ErrorNil, + }) + run("EmptyUpdate", testCase{ + 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), + errorCheck: expect.ErrorNil, + }) } func TestSliceRemoveOrSet(t *testing.T) { diff --git a/nup/update.go b/nup/update.go index cf823b5..84b8b7e 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,55 @@ 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 + *u = Remove[T]() + return nil + } + 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 { + if _, readErr := decoder.ReadToken(); readErr != nil { + return readErr + } + *u = Remove[T]() return nil } - u.op = OpSet - return json.Unmarshal(data, &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. @@ -200,7 +235,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: @@ -221,7 +256,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 } diff --git a/nup/update_test.go b/nup/update_test.go index c578a4b..88edd9b 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,44 +66,89 @@ 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 - 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), - }, + type testCase struct { + json string + expected Update[int] + errorCheck expect.ErrorCheck + } + 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)) + testCase.errorCheck(t, err) + expect.Equal(t, update, testCase.expected) + }) } - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { + run("MalformedNull", testCase{ + json: `nup`, + expected: Noop[int](), + errorCheck: expect.ErrorNonNil, + }) + run("Null", testCase{ + json: `null`, + expected: Remove[int](), + errorCheck: expect.ErrorNil, + }) + run("Value", testCase{ + json: fmt.Sprint(testValue), + expected: Set(testValue), + errorCheck: expect.ErrorNil, + }) +} + +func TestUpdate_UnmarshalJSONFrom(t *testing.T) { + type testCase struct { + json string + expected Update[int] + errorCheck expect.ErrorCheck + } + 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"` } 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](), + errorCheck: expect.ErrorNil, + }) + run("MalformedNullUpdate", testCase{ + json: `{"update": nup}`, + expected: Noop[int](), + errorCheck: expect.ErrorNonNil, + }) + run("NullUpdate", testCase{ + 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), + errorCheck: expect.ErrorNil, + }) } func TestRemoveOrSet(t *testing.T) {