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
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,5 @@ _testmain.go
# Vim swap files
*.swp

# Don't apply gitignore rules to vendored projects
!/vendor/**
# Vendored files
/vendor
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![Build Status](https://github.com/nicheinc/nullable/actions/workflows/ci.yml/badge.svg)](https://github.com/nicheinc/nullable/actions/workflows/ci.yml)
[![Go Report Card](https://goreportcard.com/badge/github.com/nicheinc/nullable)](https://goreportcard.com/report/github.com/nicheinc/nullable)
[![Godoc](https://godoc.org/github.com/nicheinc/nullable?status.svg)](https://godoc.org/github.com/nicheinc/nullable)
[![Godoc](https://godoc.org/github.com/nicheinc/nullable?status.svg)](https://godoc.org/github.com/nicheinc/nullable)
[![license](https://img.shields.io/github/license/nicheinc/nullable.svg?cacheSeconds=2592000)](LICENSE)

This package provides types representing updates to struct fields,
Expand All @@ -20,15 +20,24 @@ indicate deletion. If a certain key is not present, the corresponding field is
not modified. We want to define go structs corresponding to these updates, which
need to be marshalled to/from JSON.

If we were to use pointer fields with the `omitempty` JSON struct tag option for
If we were to use pointer fields with the `omitzero` JSON struct tag option for
these structs, then fields explicitly set to `nil` to be removed would instead
simply be absent from the marshalled JSON, i.e. unchanged. If we were to use
pointer fields without `omitempty`, then `nil` fields would be present and
`null` in the JSON output, i.e. removed.
pointer fields without `omitzero`, then `nil` fields would be present and `null`
in the JSON output, i.e. removed.

The `Update` and `SliceUpdate` types distinguish between no-op and removal
updates, allowing them to correctly and seamlessly unmarshal themselves from
JSON.
The `nup.Update` and `nup.SliceUpdate` types distinguish between no-op and
removal updates, allowing them to correctly and seamlessly unmarshal themselves
from JSON.

## Marshalling

For best results, use
[`json.Marshal`](https://pkg.go.dev/encoding/json#Marshal)'s `omitzero` struct
tag option on all struct fields of type `nup.Update` or `nup.SliceUpdate`. This
will ensure that if the field is a no-op, it's correctly omitted from the JSON
output. (If the `omitzero` tag is absent, the field will be marshalled as
`null`.)

## Installation

Expand Down
9 changes: 8 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
module github.com/nicheinc/nullable/v2

go 1.18
go 1.24

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
)
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +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/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=
60 changes: 16 additions & 44 deletions nup/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,59 +3,31 @@ Package nup (think "nullable update") provides types representing updates to
struct fields, distinguishing between no-ops, removals, and modifications when
marshalling those updates to/from JSON.

Motivation
# Motivation

It's often useful to define data updates using JSON objects, where each
key-value pair represents an update to a field, using null to indicate deletion.
If a certain key is not present, the corresponding field is not modified. We
want to define go structs corresponding to these updates, which need to be
marshalled to/from JSON.
key-value pair represents an update to a field, using a value of null to
indicate deletion. If a certain key is not present, the corresponding field is
not modified. We want to define go structs corresponding to these updates, which
need to be marshalled to/from JSON.

If we were to use pointer fields with the omitempty JSON struct tag option for
If we were to use pointer fields with the omitzero JSON struct tag option for
these structs, then fields explicitly set to nil to be removed would instead
simply be absent from the marshalled JSON, i.e. unchanged. If we were to use
pointer fields without omitempty, then nil fields would be present and null in
pointer fields without omitzero, then nil fields would be present and null in
the JSON output, i.e. removed.

The Update and SliceUpdate types distinguish between no-op and removal updates,
allowing them to correctly and seamlessly unmarshal themselves from JSON.
The nup.Update and nup.SliceUpdate types distinguish between no-op and removal
updates, allowing them to correctly and seamlessly unmarshal themselves from
JSON.

Marshalling
# Marshalling

Unfortunately, the default JSON marshaller is unaware of nup types, and
providing a MarshalJSON implementation in the types themselves is insufficient
because it's the containing struct that determines which field names appear in
the JSON output.
For best results, use [json.Marshal]'s omitzero struct tag option on all struct
fields of type nup.Update or nup.SliceUpdate. This will ensure that if the field
is a no-op, it's correctly omitted from the JSON output. (If the omitzero tag is
absent, the field will be marshalled as null.)

A custom implementation can use an ad-hoc struct mirroring the original struct
(but with an extra level of indirection), along with a check per field that the
field is set before copying it into the output struct, but implementing this
method for every update type is laborious and error-prone. To avoid this
boilerplate, this package provides the nup.MarshalJSON function, which
implements a version of json.Marshal that respects the no-op/remove distinction.

Besides its treatment of Update/SliceUpdate fields, nup.MarshalJSON behaves
exactly like json.Marshal (https://golang.org/pkg/encoding/json/#Marshal), with
the following exceptions:

• Anonymous fields are skipped

• The string tag option is ignored

Note that the omitempty option does not affect nup types. The default JSON
marshaller never omits struct values, but nup.MarshalJSON takes the use of a nup
update type per se as an indication to omit the field if it's a no-op, even if
omitempty is absent.

To avoid accidentally calling the default implementation, it's prudent to
implement for each relevant type a MarshalJSON method that simply calls
nup.MarshalJSON.

There are several outstanding golang proposals that could eliminate the need for
a custom MarshalJSON implementation in the future. One proposal
(https://github.com/golang/go/issues/11939) would allow zero-valued structs to
be treated as empty with respect to omitempty fields. Another proposal
(https://github.com/golang/go/issues/50480) would allow types to return (nil,
nil) from MarshalJSON to indicate they should be treated as empty by omitempty.
[json.Marshal]: https://pkg.go.dev/encoding/json#Marshal
*/
package nup
13 changes: 10 additions & 3 deletions nup/marshalJSON.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import (
"strings"
)

// Deprecated: As of Go 1.24, json.Marshal handles fields of nup types correctly
// by default, as long as those fields are marked with the "omitzero" JSON
// struct tag.
//
// MarshalJSON is a reimplementation of json.Marshal that understands nup types.
// Any struct that contains Update or SliceUpdate fields should call this
// function instead of the default json.Marshal. For more info, see
Expand Down Expand Up @@ -142,13 +146,16 @@ func getKeyName(field reflect.StructField, fieldValue reflect.Value) *string {
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// - Redistributions of source code must retain the above copyright
//
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// - Redistributions in binary form must reproduce the above
//
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// - Neither the name of Google Inc. nor the names of its
//
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
Expand Down
34 changes: 11 additions & 23 deletions nup/marshalJSON_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ package nup
import (
"encoding/json"
"errors"
"reflect"
"testing"

"github.com/nicheinc/expect"
)

func TestMarshalJSON_OneWay(t *testing.T) {
Expand Down Expand Up @@ -209,13 +210,9 @@ func TestMarshalJSON_OneWay(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
data, err := MarshalJSON(testCase.input)
if err != nil {
t.Errorf("Error while marshalling: %v", err)
}
expect.ErrorNil(t, err)
actual := string(data)
if actual != testCase.expected {
t.Errorf("Expected: %v. Actual: %v", testCase.expected, actual)
}
expect.Equal(t, actual, testCase.expected)
})
}
}
Expand Down Expand Up @@ -305,19 +302,14 @@ func roundtrip[T any](t *testing.T, testName string, input T) {
t.Run(testName, func(t *testing.T) {
t.Helper()
// Marshal input.
data, err := MarshalJSON(input)
if err != nil {
t.Errorf("Error while marshalling: %v", err)
}
data, marshalErr := MarshalJSON(input)
expect.ErrorNil(t, marshalErr)
// Unmarshal resulting JSON to output.
var output T
if err := json.Unmarshal(data, &output); err != nil {
t.Errorf("Error while unmarshalling: %v", err)
}
unmarshalErr := json.Unmarshal(data, &output)
expect.ErrorNil(t, unmarshalErr)
// Marshalling then unmarshalling should result in the same value.
if !reflect.DeepEqual(input, output) {
t.Errorf("Expected: %v. Actual: %v", input, output)
}
expect.Equal(t, input, output)
})
}

Expand Down Expand Up @@ -355,12 +347,8 @@ func TestMarshalJSON_FieldErrors(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
data, err := MarshalJSON(testCase.input)
if data != nil {
t.Errorf("Expected nil data. Actual: %v", string(data))
}
if err == nil {
t.Error("Expected a non-nil error")
}
expect.ErrorNonNil(t, err)
expect.Equal(t, data, nil)
})
}
}
Expand Down
10 changes: 6 additions & 4 deletions nup/operation_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package nup

import "testing"
import (
"testing"

"github.com/nicheinc/expect"
)

func TestOperation_String(t *testing.T) {
testCases := []struct {
Expand Down Expand Up @@ -28,9 +32,7 @@ func TestOperation_String(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
actual := testCase.op.String()
if actual != testCase.expected {
t.Errorf("Expected: %v. Actual: %v", testCase.expected, actual)
}
expect.Equal(t, actual, testCase.expected)
})
}
}
13 changes: 13 additions & 0 deletions nup/sliceUpdate.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ func (u SliceUpdate[T]) IsNoop() bool {
return u.op == OpNoop
}

// IsZero is equivalent to IsNoop.
func (u SliceUpdate[T]) IsZero() bool {
return u.IsNoop()
}

// IsRemove returns whether this update is a remove operation. IsRemove is
// equivalent to Operation() == OpRemove.
func (u SliceUpdate[T]) IsRemove() bool {
Expand Down Expand Up @@ -118,6 +123,14 @@ func (u SliceUpdate[T]) Diff(value []T) SliceUpdate[T] {
return u
}

// MarshalJSON implements json.Marshaler.
func (u SliceUpdate[T]) MarshalJSON() ([]byte, error) {
if u.op == OpSet {
return json.Marshal(u.value)
}
return []byte("null"), nil
}

// UnmarshalJSON implements json.Unmarshaler.
func (u *SliceUpdate[T]) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
Expand Down
Loading
Loading