Skip to content

Commit

Permalink
refactor: change Unsettable to Nullable
Browse files Browse the repository at this point in the history
  • Loading branch information
ctrombley committed Jan 31, 2024
1 parent b499e1b commit 363f8b0
Show file tree
Hide file tree
Showing 4 changed files with 113 additions and 43 deletions.
2 changes: 1 addition & 1 deletion examples/workspaces/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func main() {
AutoApply: tfe.Bool(false),
TerraformVersion: tfe.String("0.11.1"),
WorkingDirectory: tfe.String("my-app/infra"),
AutoDestroyAt: tfe.UnsettableTime(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)),
AutoDestroyAt: tfe.NullableTime(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)),
})
if err != nil {
log.Fatal(err)
Expand Down
146 changes: 108 additions & 38 deletions type_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
package tfe

import (
"bytes"
"encoding/json"
"errors"
"reflect"
"time"
)
Expand Down Expand Up @@ -129,69 +131,137 @@ func String(v string) *string {
return &v
}

// Unsettable is a wrapper that can be used for marshaling attributes which use
// significant nil values, but still require `omitempty` behavior.
// Nullable 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.
// This is generally useful for PATCH requests, where attributes with zero
// values are intentionally not marshaled into the request payload.
// values are intentionally not marshaled into the request payload so that
// existing attribute values are not overwritten.
//
// 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
//
// Helper functions are provided to help avoid pointer wrangling.
// If the field is expected to be optional, add the `omitempty` JSON tags. Do NOT use `*Nullable`!
//
// NOTE: This type is only for use with outbound requests. It will cause errors
// if used to unmarshal API responses, since the jsonapi module does not yet
// support custom unmarshaling.
// Adapted from https://www.jvt.me/posts/2024/01/09/go-json-nullable/

type Unsettable[T any] struct {
Value *T
}
type Nullable[T any] map[bool]T

func UnsettableString(v string) *Unsettable[string] {
return &Unsettable[string]{&v}
// 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]
n.Set(t)
return n
}

func NullString() *Unsettable[string] {
return &Unsettable[string]{}
// 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]
n.SetNull()
return n
}

func UnsettableInt(v int) *Unsettable[int] {
return &Unsettable[int]{&v}
// 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
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
}

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

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

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

var iso8601TimeFormat = "2006-01-02T15:04:05Z"

func UnsettableTime(v time.Time) *Unsettable[time.Time] {
return &Unsettable[time.Time]{&v}
// IsSpecified indicates whether the field was sent
func (t Nullable[T]) IsSpecified() bool {
return len(t) != 0
}

func NullTime() *Unsettable[time.Time] {
return &Unsettable[time.Time]{}
// SetUnspecified indicate whether the field was sent
func (t *Nullable[T]) SetUnspecified() {
*t = map[bool]T{}
}

func (t *Unsettable[T]) MarshalJSON() ([]byte, error) {
if t == nil || t.Value == nil {
return json.RawMessage(`null`), nil
func (t Nullable[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

// if the value is of type time.Time, format it as an RFC3339 string.
v := reflect.ValueOf(t[true])
if v.Type() == reflect.TypeOf(new(time.Time)) {
return json.Marshal(v.Elem().Interface().(time.Time).Format(time.RFC3339))
}

var b []byte
var err error
// we have a value, so marshal it
return json.Marshal(t[true])
}

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

val := reflect.ValueOf(t.Value)
if val.Type().Kind() == reflect.Ptr && val.Elem().Type() == reflect.TypeOf(time.Time{}) {
return json.Marshal(val.Elem().Interface().(time.Time).Format(iso8601TimeFormat))
// If field is specified, and `null`
if bytes.Equal(data, []byte("null")) {
t.SetNull()
return nil
}

b, err = json.Marshal(t.Value)
return b, err
// 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
}

func NullableBool(v bool) Nullable[bool] {
return NewNullableWithValue[bool](v)
}

func NullBool() Nullable[bool] {
return NewNullNullable[bool]()
}

func NullableTime(v time.Time) Nullable[time.Time] {
return NewNullableWithValue[time.Time](v)
}

func NullTime() Nullable[time.Time] {
return NewNullNullable[time.Time]()
}
2 changes: 1 addition & 1 deletion workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ type WorkspaceUpdateOptions struct {
AutoApplyRunTrigger *bool `jsonapi:"attr,auto-apply-run-trigger,omitempty"`

// Optional: The time after which an automatic destroy run will be queued
AutoDestroyAt *Unsettable[time.Time] `jsonapi:"attr,auto_destroy_at,omitempty"`
AutoDestroyAt Nullable[time.Time] `jsonapi:"attr,auto_destroy_at,omitempty"`

// Optional: A new name for the workspace, which can only include letters, numbers, -,
// and _. This will be used as an identifier and must be unique in the
Expand Down
6 changes: 3 additions & 3 deletions workspace_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1126,7 +1126,7 @@ func TestWorkspacesUpdate(t *testing.T) {
Name: String(randomString(t)),
AllowDestroyPlan: Bool(true),
AutoApply: Bool(false),
AutoDestroyAt: UnsettableTime(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)),
AutoDestroyAt: NullableTime(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)),
FileTriggersEnabled: Bool(true),
Operations: Bool(false),
QueueAllRuns: Bool(false),
Expand All @@ -1152,7 +1152,7 @@ func TestWorkspacesUpdate(t *testing.T) {
assert.Equal(t, *options.Name, item.Name)
assert.Equal(t, *options.AllowDestroyPlan, item.AllowDestroyPlan)
assert.Equal(t, *options.AutoApply, item.AutoApply)
assert.Equal(t, options.AutoDestroyAt.Value, item.AutoDestroyAt)
assert.Equal(t, options.AutoDestroyAt[true], item.AutoDestroyAt)
assert.Equal(t, *options.FileTriggersEnabled, item.FileTriggersEnabled)
assert.Equal(t, *options.Description, item.Description)
assert.Equal(t, *options.Operations, item.Operations)
Expand Down Expand Up @@ -2677,7 +2677,7 @@ func TestWorkspacesAutoDestroy(t *testing.T) {

// explicitly update the value of auto_destroy_at
w, err = client.Workspaces.Update(ctx, orgTest.Name, wTest.Name, WorkspaceUpdateOptions{
AutoDestroyAt: UnsettableTime(time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)),
AutoDestroyAt: NullableTime(time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)),
})

require.NoError(t, err)
Expand Down

0 comments on commit 363f8b0

Please sign in to comment.