mirror of
https://github.com/tailscale/tailscale.git
synced 2025-10-10 09:45:08 +00:00
types/opt: add generic Value[T any] for optional values of any types
Updates #12736 Signed-off-by: Nick Khyl <nickk@tailscale.com>
This commit is contained in:
122
types/opt/value.go
Normal file
122
types/opt/value.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package opt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
jsonv2 "github.com/go-json-experiment/json"
|
||||
"github.com/go-json-experiment/json/jsontext"
|
||||
)
|
||||
|
||||
// Value is an optional value to be JSON-encoded.
|
||||
// With [encoding/json], a zero Value is marshaled as a JSON null.
|
||||
// With [github.com/go-json-experiment/json], a zero Value is omitted from the
|
||||
// JSON object if the Go struct field specified with omitzero.
|
||||
// The omitempty tag option should never be used with Value fields.
|
||||
type Value[T any] struct {
|
||||
value T
|
||||
set bool
|
||||
}
|
||||
|
||||
// Equal reports whether the receiver and the other value are equal.
|
||||
// If the template type T in Value[T] implements an Equal method, it will be used
|
||||
// instead of the == operator for comparing values.
|
||||
type equatable[T any] interface {
|
||||
// Equal reports whether the receiver and the other values are equal.
|
||||
Equal(other T) bool
|
||||
}
|
||||
|
||||
// ValueOf returns an optional Value containing the specified value.
|
||||
// It treats nil slices and maps as empty slices and maps.
|
||||
func ValueOf[T any](v T) Value[T] {
|
||||
return Value[T]{value: v, set: true}
|
||||
}
|
||||
|
||||
// String implements [fmt.Stringer].
|
||||
func (o *Value[T]) String() string {
|
||||
if !o.set {
|
||||
return fmt.Sprintf("(empty[%T])", o.value)
|
||||
}
|
||||
return fmt.Sprint(o.value)
|
||||
}
|
||||
|
||||
// Set assigns the specified value to the optional value o.
|
||||
func (o *Value[T]) Set(v T) {
|
||||
*o = ValueOf(v)
|
||||
}
|
||||
|
||||
// Clear resets o to an empty state.
|
||||
func (o *Value[T]) Clear() {
|
||||
*o = Value[T]{}
|
||||
}
|
||||
|
||||
// IsSet reports whether o has a value set.
|
||||
func (o *Value[T]) IsSet() bool {
|
||||
return o.set
|
||||
}
|
||||
|
||||
// Get returns the value of o.
|
||||
// If a value hasn't been set, a zero value of T will be returned.
|
||||
func (o Value[T]) Get() T {
|
||||
return o.value
|
||||
}
|
||||
|
||||
// Get returns the value and a flag indicating whether the value is set.
|
||||
func (o Value[T]) GetOk() (v T, ok bool) {
|
||||
return o.value, o.set
|
||||
}
|
||||
|
||||
// Equal reports whether o is equal to v.
|
||||
// Two optional values are equal if both are empty,
|
||||
// or if both are set and the underlying values are equal.
|
||||
// If the template type T implements an Equal(T) bool method, it will be used
|
||||
// instead of the == operator for value comparison.
|
||||
// If T is not comparable, it returns false.
|
||||
func (o Value[T]) Equal(v Value[T]) bool {
|
||||
if o.set != v.set {
|
||||
return false
|
||||
}
|
||||
if !o.set {
|
||||
return true
|
||||
}
|
||||
ov := any(o.value)
|
||||
if eq, ok := ov.(equatable[T]); ok {
|
||||
return eq.Equal(v.value)
|
||||
}
|
||||
if reflect.TypeFor[T]().Comparable() {
|
||||
return ov == any(v.value)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
|
||||
func (o Value[T]) MarshalJSONV2(enc *jsontext.Encoder, opts jsonv2.Options) error {
|
||||
if !o.set {
|
||||
return enc.WriteToken(jsontext.Null)
|
||||
}
|
||||
return jsonv2.MarshalEncode(enc, &o.value, opts)
|
||||
}
|
||||
|
||||
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
|
||||
func (o *Value[T]) UnmarshalJSONV2(dec *jsontext.Decoder, opts jsonv2.Options) error {
|
||||
if dec.PeekKind() == 'n' {
|
||||
*o = Value[T]{}
|
||||
_, err := dec.ReadToken() // read null
|
||||
return err
|
||||
}
|
||||
o.set = true
|
||||
return jsonv2.UnmarshalDecode(dec, &o.value, opts)
|
||||
}
|
||||
|
||||
// MarshalJSON implements [json.Marshaler].
|
||||
func (o Value[T]) MarshalJSON() ([]byte, error) {
|
||||
return jsonv2.Marshal(o) // uses MarshalJSONV2
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements [json.Unmarshaler].
|
||||
func (o *Value[T]) UnmarshalJSON(b []byte) error {
|
||||
return jsonv2.Unmarshal(b, o) // uses UnmarshalJSONV2
|
||||
}
|
296
types/opt/value_test.go
Normal file
296
types/opt/value_test.go
Normal file
@@ -0,0 +1,296 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package opt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
jsonv2 "github.com/go-json-experiment/json"
|
||||
)
|
||||
|
||||
type testStruct struct {
|
||||
Int int `json:",omitempty,omitzero"`
|
||||
Str string `json:",omitempty"`
|
||||
}
|
||||
|
||||
func TestValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in any
|
||||
jsonv2 bool
|
||||
want string // JSON
|
||||
wantBack any
|
||||
}{
|
||||
{
|
||||
name: "null_for_unset",
|
||||
in: struct {
|
||||
True Value[bool]
|
||||
False Value[bool]
|
||||
Unset Value[bool]
|
||||
ExplicitUnset Value[bool]
|
||||
}{
|
||||
True: ValueOf(true),
|
||||
False: ValueOf(false),
|
||||
ExplicitUnset: Value[bool]{},
|
||||
},
|
||||
want: `{"True":true,"False":false,"Unset":null,"ExplicitUnset":null}`,
|
||||
wantBack: struct {
|
||||
True Value[bool]
|
||||
False Value[bool]
|
||||
Unset Value[bool]
|
||||
ExplicitUnset Value[bool]
|
||||
}{
|
||||
True: ValueOf(true),
|
||||
False: ValueOf(false),
|
||||
Unset: Value[bool]{},
|
||||
ExplicitUnset: Value[bool]{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "null_for_unset_jsonv2",
|
||||
in: struct {
|
||||
True Value[bool]
|
||||
False Value[bool]
|
||||
Unset Value[bool]
|
||||
ExplicitUnset Value[bool]
|
||||
}{
|
||||
True: ValueOf(true),
|
||||
False: ValueOf(false),
|
||||
ExplicitUnset: Value[bool]{},
|
||||
},
|
||||
jsonv2: true,
|
||||
want: `{"True":true,"False":false,"Unset":null,"ExplicitUnset":null}`,
|
||||
wantBack: struct {
|
||||
True Value[bool]
|
||||
False Value[bool]
|
||||
Unset Value[bool]
|
||||
ExplicitUnset Value[bool]
|
||||
}{
|
||||
True: ValueOf(true),
|
||||
False: ValueOf(false),
|
||||
Unset: Value[bool]{},
|
||||
ExplicitUnset: Value[bool]{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "null_for_unset_omitzero",
|
||||
in: struct {
|
||||
True Value[bool] `json:",omitzero"`
|
||||
False Value[bool] `json:",omitzero"`
|
||||
Unset Value[bool] `json:",omitzero"`
|
||||
ExplicitUnset Value[bool] `json:",omitzero"`
|
||||
}{
|
||||
True: ValueOf(true),
|
||||
False: ValueOf(false),
|
||||
ExplicitUnset: Value[bool]{},
|
||||
},
|
||||
want: `{"True":true,"False":false,"Unset":null,"ExplicitUnset":null}`,
|
||||
wantBack: struct {
|
||||
True Value[bool] `json:",omitzero"`
|
||||
False Value[bool] `json:",omitzero"`
|
||||
Unset Value[bool] `json:",omitzero"`
|
||||
ExplicitUnset Value[bool] `json:",omitzero"`
|
||||
}{
|
||||
True: ValueOf(true),
|
||||
False: ValueOf(false),
|
||||
Unset: Value[bool]{},
|
||||
ExplicitUnset: Value[bool]{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "null_for_unset_omitzero_jsonv2",
|
||||
in: struct {
|
||||
True Value[bool] `json:",omitzero"`
|
||||
False Value[bool] `json:",omitzero"`
|
||||
Unset Value[bool] `json:",omitzero"`
|
||||
ExplicitUnset Value[bool] `json:",omitzero"`
|
||||
}{
|
||||
True: ValueOf(true),
|
||||
False: ValueOf(false),
|
||||
ExplicitUnset: Value[bool]{},
|
||||
},
|
||||
jsonv2: true,
|
||||
want: `{"True":true,"False":false}`,
|
||||
wantBack: struct {
|
||||
True Value[bool] `json:",omitzero"`
|
||||
False Value[bool] `json:",omitzero"`
|
||||
Unset Value[bool] `json:",omitzero"`
|
||||
ExplicitUnset Value[bool] `json:",omitzero"`
|
||||
}{
|
||||
True: ValueOf(true),
|
||||
False: ValueOf(false),
|
||||
Unset: Value[bool]{},
|
||||
ExplicitUnset: Value[bool]{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "string",
|
||||
in: struct {
|
||||
EmptyString Value[string]
|
||||
NonEmpty Value[string]
|
||||
Unset Value[string]
|
||||
}{
|
||||
EmptyString: ValueOf(""),
|
||||
NonEmpty: ValueOf("value"),
|
||||
Unset: Value[string]{},
|
||||
},
|
||||
want: `{"EmptyString":"","NonEmpty":"value","Unset":null}`,
|
||||
wantBack: struct {
|
||||
EmptyString Value[string]
|
||||
NonEmpty Value[string]
|
||||
Unset Value[string]
|
||||
}{ValueOf(""), ValueOf("value"), Value[string]{}},
|
||||
},
|
||||
{
|
||||
name: "integer",
|
||||
in: struct {
|
||||
Zero Value[int]
|
||||
NonZero Value[int]
|
||||
Unset Value[int]
|
||||
}{
|
||||
Zero: ValueOf(0),
|
||||
NonZero: ValueOf(42),
|
||||
Unset: Value[int]{},
|
||||
},
|
||||
want: `{"Zero":0,"NonZero":42,"Unset":null}`,
|
||||
wantBack: struct {
|
||||
Zero Value[int]
|
||||
NonZero Value[int]
|
||||
Unset Value[int]
|
||||
}{ValueOf(0), ValueOf(42), Value[int]{}},
|
||||
},
|
||||
{
|
||||
name: "struct",
|
||||
in: struct {
|
||||
Zero Value[testStruct]
|
||||
NonZero Value[testStruct]
|
||||
Unset Value[testStruct]
|
||||
}{
|
||||
Zero: ValueOf(testStruct{}),
|
||||
NonZero: ValueOf(testStruct{Int: 42, Str: "String"}),
|
||||
Unset: Value[testStruct]{},
|
||||
},
|
||||
want: `{"Zero":{},"NonZero":{"Int":42,"Str":"String"},"Unset":null}`,
|
||||
wantBack: struct {
|
||||
Zero Value[testStruct]
|
||||
NonZero Value[testStruct]
|
||||
Unset Value[testStruct]
|
||||
}{ValueOf(testStruct{}), ValueOf(testStruct{Int: 42, Str: "String"}), Value[testStruct]{}},
|
||||
},
|
||||
{
|
||||
name: "struct_ptr",
|
||||
in: struct {
|
||||
Zero Value[*testStruct]
|
||||
NonZero Value[*testStruct]
|
||||
Unset Value[*testStruct]
|
||||
}{
|
||||
Zero: ValueOf(&testStruct{}),
|
||||
NonZero: ValueOf(&testStruct{Int: 42, Str: "String"}),
|
||||
Unset: Value[*testStruct]{},
|
||||
},
|
||||
want: `{"Zero":{},"NonZero":{"Int":42,"Str":"String"},"Unset":null}`,
|
||||
wantBack: struct {
|
||||
Zero Value[*testStruct]
|
||||
NonZero Value[*testStruct]
|
||||
Unset Value[*testStruct]
|
||||
}{ValueOf(&testStruct{}), ValueOf(&testStruct{Int: 42, Str: "String"}), Value[*testStruct]{}},
|
||||
},
|
||||
{
|
||||
name: "nil-slice-and-map",
|
||||
in: struct {
|
||||
Slice Value[[]int]
|
||||
Map Value[map[string]int]
|
||||
}{
|
||||
Slice: ValueOf[[]int](nil), // marshalled as []
|
||||
Map: ValueOf[map[string]int](nil), // marshalled as {}
|
||||
},
|
||||
want: `{"Slice":[],"Map":{}}`,
|
||||
wantBack: struct {
|
||||
Slice Value[[]int]
|
||||
Map Value[map[string]int]
|
||||
}{ValueOf([]int{}), ValueOf(map[string]int{})},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var j []byte
|
||||
var err error
|
||||
if tt.jsonv2 {
|
||||
j, err = jsonv2.Marshal(tt.in)
|
||||
} else {
|
||||
j, err = json.Marshal(tt.in)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(j) != tt.want {
|
||||
t.Errorf("wrong JSON:\n got: %s\nwant: %s\n", j, tt.want)
|
||||
}
|
||||
|
||||
wantBack := tt.in
|
||||
if tt.wantBack != nil {
|
||||
wantBack = tt.wantBack
|
||||
}
|
||||
// And back again:
|
||||
newVal := reflect.New(reflect.TypeOf(tt.in))
|
||||
out := newVal.Interface()
|
||||
if tt.jsonv2 {
|
||||
err = jsonv2.Unmarshal(j, out)
|
||||
} else {
|
||||
err = json.Unmarshal(j, out)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Unmarshal %#q: %v", j, err)
|
||||
}
|
||||
got := newVal.Elem().Interface()
|
||||
if !reflect.DeepEqual(got, wantBack) {
|
||||
t.Errorf("value mismatch\n got: %+v\nwant: %+v\n", got, wantBack)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValueEqual(t *testing.T) {
|
||||
tests := []struct {
|
||||
o Value[bool]
|
||||
v Value[bool]
|
||||
want bool
|
||||
}{
|
||||
{ValueOf(true), ValueOf(true), true},
|
||||
{ValueOf(true), ValueOf(false), false},
|
||||
{ValueOf(true), Value[bool]{}, false},
|
||||
{ValueOf(false), ValueOf(false), true},
|
||||
{ValueOf(false), ValueOf(true), false},
|
||||
{ValueOf(false), Value[bool]{}, false},
|
||||
{Value[bool]{}, Value[bool]{}, true},
|
||||
{Value[bool]{}, ValueOf(true), false},
|
||||
{Value[bool]{}, ValueOf(false), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.o.Equal(tt.v); got != tt.want {
|
||||
t.Errorf("(%v).Equals(%v) = %v; want %v", tt.o, tt.v, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncomparableValueEqual(t *testing.T) {
|
||||
tests := []struct {
|
||||
o Value[[]bool]
|
||||
v Value[[]bool]
|
||||
want bool
|
||||
}{
|
||||
{ValueOf([]bool{}), ValueOf([]bool{}), false},
|
||||
{ValueOf([]bool{true}), ValueOf([]bool{true}), false},
|
||||
{Value[[]bool]{}, ValueOf([]bool{}), false},
|
||||
{ValueOf([]bool{}), Value[[]bool]{}, false},
|
||||
{Value[[]bool]{}, Value[[]bool]{}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.o.Equal(tt.v); got != tt.want {
|
||||
t.Errorf("(%v).Equals(%v) = %v; want %v", tt.o, tt.v, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user