Skip to content
Open
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
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# oapi-codegen/nullable

> An implementation of a `Nullable` type for JSON bodies, indicating whether the field is absent, set to null, or set to a value
> An implementation of a `Value` (formerly `Nullable`) type for JSON bodies, indicating whether the field is absent, set to null, or set to a value

Unlike other known implementations, this makes it possible to both marshal and unmarshal the value, as well as represent all three states:

Expand All @@ -13,10 +13,10 @@ And can be embedded in structs, for instance with the following definition:
```go
obj := struct {
// RequiredID is a required, nullable field
RequiredID nullable.Nullable[int] `json:"id"`
RequiredID nullable.Value[int] `json:"id"`
// OptionalString is an optional, nullable field
// NOTE that no pointer is required, only `omitempty`
OptionalString nullable.Nullable[string] `json:"optionalString,omitempty"`
OptionalString nullable.Value[string] `json:"optionalString,omitempty"`
}{}
```

Expand All @@ -33,6 +33,22 @@ go get github.com/oapi-codegen/nullable

Check out the examples in [the package documentation on pkg.go.dev](https://pkg.go.dev/github.com/oapi-codegen/nullable) for more details.

## Naming note

The type was originally called `Nullable[T]`, and is now called `Value[T]`. `Nullable[T]` remains available as a type alias, so the two are interchangeable — existing code keeps compiling, and you can pass a `Nullable[T]` anywhere a `Value[T]` is expected without a conversion.

Both sets of constructors are available and do the same thing:

```go
// Preferred
n := nullable.NewValue(123)
nNull := nullable.NewNullValue[int]()

// Original names, still supported
o := nullable.NewNullableWithValue(123)
oNull := nullable.NewNullNullable[int]()
```

## Credits

- [KumanekoSakura](https://github.com/KumanekoSakura), [via](https://github.com/golang/go/issues/64515#issuecomment-1842973794)
Expand Down
110 changes: 110 additions & 0 deletions internal/test/value_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package nullable_test

import (
"encoding/json"
"testing"

"github.com/oapi-codegen/nullable"

"github.com/stretchr/testify/require"
)

type ObjV struct {
Foo nullable.Value[string] `json:"foo,omitempty"` // note "omitempty" is important for fields that are optional
}

func TestValue(t *testing.T) {
// --- parsing from json and serializing back to JSON

// -- case where there is an actual value
data := `{"foo":"bar"}`
// deserialize from json
myObj := parseV(data, t)
require.Equal(t, myObj, ObjV{Foo: nullable.Value[string]{true: "bar"}})
require.False(t, myObj.Foo.IsNull())
require.True(t, myObj.Foo.IsSpecified())
value, err := myObj.Foo.Get()
require.NoError(t, err)
require.Equal(t, "bar", value)
require.Equal(t, "bar", myObj.Foo.MustGet())
require.Equal(t, "bar", myObj.Foo.GetOrEmpty())
// serialize back to json: leads to the same data
require.Equal(t, data, serializeV(myObj, t))

// -- case where no value is specified: parsed from JSON
data = `{}`
// deserialize from json
myObj = parseV(data, t)
require.Equal(t, myObj, ObjV{Foo: nil})
require.False(t, myObj.Foo.IsNull())
require.False(t, myObj.Foo.IsSpecified())
_, err = myObj.Foo.Get()
require.ErrorContains(t, err, "value is not specified")
require.Equal(t, "", myObj.Foo.GetOrEmpty())
// serialize back to json: leads to the same data
require.Equal(t, data, serializeV(myObj, t))

// -- case where the specified value is explicitly null
data = `{"foo":null}`
// deserialize from json
myObj = parseV(data, t)
require.Equal(t, myObj, ObjV{Foo: nullable.Value[string]{false: ""}})
require.True(t, myObj.Foo.IsNull())
require.True(t, myObj.Foo.IsSpecified())
_, err = myObj.Foo.Get()
require.ErrorContains(t, err, "value is null")
require.Equal(t, "", myObj.Foo.GetOrEmpty())
require.Panics(t, func() { myObj.Foo.MustGet() })
// serialize back to json: leads to the same data
require.Equal(t, data, serializeV(myObj, t))

// --- building objects from a Go client

// - case where there is an actual value
myObj = ObjV{}
myObj.Foo.Set("bar")
require.Equal(t, `{"foo":"bar"}`, serializeV(myObj, t))

// - case where the value should be unspecified
myObj = ObjV{}
// do nothing: unspecified by default
require.Equal(t, `{}`, serializeV(myObj, t))
// explicitly mark unspecified
myObj.Foo.SetUnspecified()
require.Equal(t, `{}`, serializeV(myObj, t))

// - case where the value should be null
myObj = ObjV{}
myObj.Foo.SetNull()
require.Equal(t, `{"foo":null}`, serializeV(myObj, t))
}

func TestValueConstructors(t *testing.T) {
// NewValue sets a concrete value
v := nullable.NewValue(123)
require.True(t, v.IsSpecified())
require.False(t, v.IsNull())
require.Equal(t, 123, v.MustGet())
require.Equal(t, 123, v.GetOrEmpty())

// NewNullValue sets an explicit null
n := nullable.NewNullValue[int]()
require.True(t, n.IsSpecified())
require.True(t, n.IsNull())
_, err := n.Get()
require.ErrorContains(t, err, "value is null")
require.Equal(t, 0, n.GetOrEmpty())
}

func parseV(data string, t *testing.T) ObjV {
var myObj ObjV
err := json.Unmarshal([]byte(data), &myObj)
require.NoError(t, err)
return myObj
}

func serializeV(o ObjV, t *testing.T) string {
data, err := json.Marshal(o)
require.NoError(t, err)
return string(data)
}
67 changes: 46 additions & 21 deletions nullable.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,41 +6,65 @@ import (
"errors"
)

// Nullable is a generic type, which implements a field that can be one of three states:
// Value is a generic type, which implements a field that can be one of three states:
//
// - field is not set in the request
// - field is explicitly set to `null` in the request
// - field is explicitly set to a valid value in the request
//
// Nullable is intended to be used with JSON marshalling and unmarshalling.
// Value is intended to be used with JSON marshalling and unmarshalling.
//
// Internal implementation details:
//
// - map[true]T means a value was provided
// - map[false]T means an explicit null was provided
// - nil or zero map means the field was not provided
//
// If the field is expected to be optional, add the `omitempty` JSON tags. Do NOT use `*Nullable`!
// If the field is expected to be optional, add the `omitempty` JSON tags. Do NOT use `*Value`!
//
// Adapted from https://github.com/golang/go/issues/64515#issuecomment-1841057182
type Nullable[T any] map[bool]T
type Value[T any] map[bool]T

// NewNullableWithValue is a convenience helper to allow constructing a `Nullable` with a given value, for instance to construct a field inside a struct, without introducing an intermediate variable
func NewNullableWithValue[T any](t T) Nullable[T] {
var n Nullable[T]
// Nullable is the original name of [Value], and is retained as an alias so that
// existing code continues to compile. Because it is an alias rather than a
// distinct type, `Nullable[T]` and `Value[T]` are interchangeable everywhere:
// no conversion is needed to pass one where the other is expected.
//
// New code should prefer [Value].
type Nullable[T any] = Value[T]

// NewValue is a convenience helper to allow constructing a `Value` with a given value, for instance to construct a field inside a struct, without introducing an intermediate variable
func NewValue[T any](t T) Value[T] {
var n Value[T]
n.Set(t)
return n
}

// NewNullNullable is a convenience helper to allow constructing a `Nullable` with an explicit `null`, for instance to construct a field inside a struct, without introducing an intermediate variable
func NewNullNullable[T any]() Nullable[T] {
var n Nullable[T]
// NewNullValue is a convenience helper to allow constructing a `Value` with an explicit `null`, for instance to construct a field inside a struct, without introducing an intermediate variable
func NewNullValue[T any]() Value[T] {
var n Value[T]
n.SetNull()
return n
}

// NewNullableWithValue is the original name of [NewValue], and is retained so
// that existing code continues to compile.
//
// New code should prefer [NewValue].
func NewNullableWithValue[T any](t T) Nullable[T] {
return NewValue(t)
}

// NewNullNullable is the original name of [NewNullValue], and is retained so
// that existing code continues to compile.
//
// New code should prefer [NewNullValue].
func NewNullNullable[T any]() Nullable[T] {
return NewNullValue[T]()
}

// Get retrieves the underlying value, if present, and returns an error if the value was not present
func (t Nullable[T]) Get() (T, error) {
func (t Value[T]) Get() (T, error) {
var empty T
if t.IsNull() {
return empty, errors.New("value is null")
Expand All @@ -51,16 +75,17 @@ func (t Nullable[T]) Get() (T, error) {
return t[true], nil
}

// Dig retrieves the underlying value or returns empty value if not present or was `null`. Use Get to distinguish between these cases.
func (t Nullable[T]) GetOrEmpty() T {
// GetOrEmpty retrieves the underlying value or returns empty value if not present or was `null`. Use Get to distinguish between these cases.
func (t Value[T]) GetOrEmpty() T {
var empty T
if !t.IsSpecified() || t.IsNull() {
return empty
}
return t[true]
}

func (t Nullable[T]) MustGet() T {
// MustGet retrieves the underlying value, if present, and panics if the value was not present
func (t Value[T]) MustGet() T {
v, err := t.Get()
if err != nil {
panic(err)
Expand All @@ -69,33 +94,33 @@ func (t Nullable[T]) MustGet() T {
}

// Set sets the underlying value to a given value
func (t *Nullable[T]) Set(value T) {
func (t *Value[T]) Set(value T) {
*t = map[bool]T{true: value}
}

// IsNull indicate whether the field was sent, and had a value of `null`
func (t Nullable[T]) IsNull() bool {
func (t Value[T]) IsNull() bool {
_, foundNull := t[false]
return foundNull
}

// SetNull indicate that the field was sent, and had a value of `null`
func (t *Nullable[T]) SetNull() {
func (t *Value[T]) SetNull() {
var empty T
*t = map[bool]T{false: empty}
}

// IsSpecified indicates whether the field was sent
func (t Nullable[T]) IsSpecified() bool {
func (t Value[T]) IsSpecified() bool {
return len(t) != 0
}

// SetUnspecified indicate whether the field was sent
func (t *Nullable[T]) SetUnspecified() {
func (t *Value[T]) SetUnspecified() {
*t = map[bool]T{}
}

func (t Nullable[T]) MarshalJSON() ([]byte, error) {
func (t Value[T]) MarshalJSON() ([]byte, error) {
// if field was specified, and `null`, marshal it
if t.IsNull() {
return []byte("null"), nil
Expand All @@ -107,7 +132,7 @@ func (t Nullable[T]) MarshalJSON() ([]byte, error) {
return json.Marshal(t[true])
}

func (t *Nullable[T]) UnmarshalJSON(data []byte) error {
func (t *Value[T]) UnmarshalJSON(data []byte) error {
// if field is unspecified, UnmarshalJSON won't be called

// if field is specified, and `null`
Expand Down