Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
8 changes: 4 additions & 4 deletions nup/marshalJSON.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 13 additions & 10 deletions nup/marshalJSON_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
func TestMarshalJSON_OneWay(t *testing.T) {
testCases := []struct {
name string
input interface{}
input any
expected string
}{
{
Expand Down Expand Up @@ -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},
Expand All @@ -105,7 +108,7 @@ func TestMarshalJSON_OneWay(t *testing.T) {
{
name: "EmptyName",
input: struct {
EmptyName int `json:","`
EmptyName int `json:""`
}{
EmptyName: 1,
},
Expand Down Expand Up @@ -241,7 +244,7 @@ func TestMarshalJSON_RoundTrip(t *testing.T) {
NoTag: 1,
})
roundtrip(t, "EmptyName", struct {
EmptyName int `json:","`
EmptyName int `json:""`
}{
EmptyName: 1,
})
Expand Down Expand Up @@ -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",
Expand Down
51 changes: 43 additions & 8 deletions nup/sliceUpdate.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package nup

import (
"encoding/json"
jsonv1 "encoding/json"
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
)

Expand Down Expand Up @@ -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
}
Comment thread
jonathansharman marked this conversation as resolved.
Resolved
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
Expand Down Expand Up @@ -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
}
Expand Down
139 changes: 109 additions & 30 deletions nup/sliceUpdate_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package nup

import (
"encoding/json"
"encoding/json/v2"
"fmt"
"testing"

Expand All @@ -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
Expand All @@ -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))
Comment on lines +87 to +88

@jonathansharman jonathansharman Sep 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling json.Unmarshal will just call UnmarshalJSONFrom since it has higher precedence. Calling Update.UnmarshalJSON directly is the simplest way to exercise the function.

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) {
Expand Down
Loading
Loading