diff --git a/README.md b/README.md index fa2dc6f..7b87d7d 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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"` }{} ``` @@ -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) diff --git a/internal/test/value_test.go b/internal/test/value_test.go new file mode 100644 index 0000000..32b85d0 --- /dev/null +++ b/internal/test/value_test.go @@ -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) +} diff --git a/nullable.go b/nullable.go index d2e3238..06ed25e 100644 --- a/nullable.go +++ b/nullable.go @@ -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