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.

## Migration note

- `nullable.Nullable[T]` is still available but deprecated.
- Prefer `nullable.Value[T]` going forward.
- Constructors are provided for both:

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

// Deprecated, still available
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
105 changes: 105 additions & 0 deletions internal/test/value_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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())
// 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")
// 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.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())

// 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")
}

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)
}
105 changes: 105 additions & 0 deletions nullable.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,127 @@ import (
// If the field is expected to be optional, add the `omitempty` JSON tags. Do NOT use `*Nullable`!
//
// Adapted from https://github.com/golang/go/issues/64515#issuecomment-1841057182
//
// Deprecated: Nullable has been renamed to Value. Use Value[T] instead.
type Nullable[T any] map[bool]T

// Value is the preferred name for Nullable. It represents a tri-state JSON field:
// unspecified, null, or a concrete value.
//
// This is a separate named type for compatibility with older Go versions.
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
//
// Deprecated: Use NewValue instead.
func NewNullableWithValue[T any](t T) Nullable[T] {
var n Nullable[T]
n.Set(t)
return n
}

// NewValue is a convenience helper to construct a nullable `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
//
// Deprecated: Use NewNullValue instead.
func NewNullNullable[T any]() Nullable[T] {
var n Nullable[T]
n.SetNull()
return n
}

// NewNullValue is a convenience helper to construct 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
}

// Get retrieves the underlying value, if present, and returns an error if the value was not present
func (t Value[T]) Get() (T, error) {
var empty T
if t.IsNull() {
return empty, errors.New("value is null")
}
if !t.IsSpecified() {
return empty, errors.New("value is not specified")
}
return t[true], nil
}

// 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)
}
return v
}

// Set sets the underlying value to a given value
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 Value[T]) IsNull() bool {
_, foundNull := t[false]
return foundNull
}

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

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

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

func (t Value[T]) MarshalJSON() ([]byte, error) {
// if field was specified, and `null`, marshal it
if t.IsNull() {
return []byte("null"), nil
}

// if field was unspecified, and `omitempty` is set on the field's tags, `json.Marshal` will omit this field

// otherwise: we have a value, so marshal it
return json.Marshal(t[true])
}

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

// if field is specified, and `null`
if bytes.Equal(data, []byte("null")) {
t.SetNull()
return nil
}
// otherwise, we have an actual value, so parse it
var v T
if err := json.Unmarshal(data, &v); err != nil {
return err
}
t.Set(v)
return nil
}

// Get retrieves the underlying value, if present, and returns an error if the value was not present
func (t Nullable[T]) Get() (T, error) {
var empty T
Expand Down