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:
Nick Khyl 2024-07-08 10:32:37 -05:00 committed by Nick Khyl
parent 5576972261
commit e21d8768f9
7 changed files with 446 additions and 7 deletions

View File

@ -10,6 +10,12 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/go-json-experiment/json from tailscale.com/types/opt
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
L github.com/google/nftables from tailscale.com/util/linuxfw
L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt

View File

@ -98,11 +98,12 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
💣 github.com/fsnotify/fsnotify from sigs.k8s.io/controller-runtime/pkg/certwatcher
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/gaissmai/bart from tailscale.com/net/ipset+
github.com/go-json-experiment/json from tailscale.com/types/opt
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json/internal/jsonflags+
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json/internal/jsonopts+
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext
github.com/go-json-experiment/json/jsontext from tailscale.com/logtail
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext+
github.com/go-json-experiment/json/jsontext from tailscale.com/logtail+
github.com/go-logr/logr from github.com/go-logr/logr/slogr+
github.com/go-logr/logr/slogr from github.com/go-logr/zapr
github.com/go-logr/zapr from sigs.k8s.io/controller-runtime/pkg/log/zap+
@ -957,7 +958,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
math/big from crypto/dsa+
math/bits from compress/flate+
math/rand from github.com/google/go-cmp/cmp+
math/rand/v2 from database/sql+
math/rand/v2 from tailscale.com/derp+
mime from github.com/prometheus/common/expfmt+
mime/multipart from github.com/go-openapi/swag+
mime/quotedprintable from mime/multipart

View File

@ -2,6 +2,12 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus
github.com/go-json-experiment/json from tailscale.com/types/opt
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
github.com/google/uuid from tailscale.com/util/fastuuid
💣 github.com/prometheus/client_golang/prometheus from tailscale.com/tsweb/promvarz
github.com/prometheus/client_golang/prometheus/internal from github.com/prometheus/client_golang/prometheus
@ -128,6 +134,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
embed from crypto/internal/nistec+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
encoding/base32 from github.com/go-json-experiment/json
encoding/base64 from encoding/json+
encoding/binary from compress/gzip+
encoding/hex from crypto/x509+

View File

@ -9,6 +9,12 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/pe+
W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/winutil/authenticode
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/go-json-experiment/json from tailscale.com/types/opt
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
L github.com/google/nftables from tailscale.com/util/linuxfw
L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt

View File

@ -90,11 +90,12 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 github.com/djherbis/times from tailscale.com/drive/driveimpl
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/gaissmai/bart from tailscale.com/net/tstun+
github.com/go-json-experiment/json from tailscale.com/types/opt
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json/internal/jsonflags+
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json/internal/jsonopts+
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext
github.com/go-json-experiment/json/jsontext from tailscale.com/logtail
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext+
github.com/go-json-experiment/json/jsontext from tailscale.com/logtail+
W 💣 github.com/go-ole/go-ole from github.com/go-ole/go-ole/oleutil+
W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet
L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns+

122
types/opt/value.go Normal file
View 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
View 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)
}
}
}