Merge pull request #137927 from lalitc375/cherry-pick-137864

Cherry pick of 137864
This commit is contained in:
Kubernetes Prow Robot
2026-04-09 16:04:20 +05:30
committed by GitHub
12 changed files with 395 additions and 44 deletions

View File

@@ -284,6 +284,13 @@ func TestDeclarativeValidateUpdate(t *testing.T) {
field.Invalid(field.NewPath("spec", "devices").Index(0).Child("attributes").Key("test.io/multiple"), "", "may have only one of the following fields set: bool, int, string, version"),
},
},
"invalid update: device attribute no value": {
old: mkResourceSliceWithDevices(),
update: mkResourceSliceWithDevices(tweakDeviceAttribute("test.io/empty", resource.DeviceAttribute{})),
expectedErrs: field.ErrorList{
field.Invalid(field.NewPath("spec", "devices").Index(0).Child("attributes").Key("test.io/empty"), "", ""),
},
},
// spec.sharedCounters
"valid update: at limit shared counters": {
old: mkResourceSliceWithSharedCounters(),

View File

@@ -19,6 +19,7 @@ package validate
import (
"context"
"fmt"
"reflect"
"strings"
"k8s.io/apimachinery/pkg/api/operation"
@@ -60,6 +61,10 @@ type UnionValidationOptions struct {
// )...)
// return errs
// }
//
// Note that T is "any", rather than "comparable", because union-members can be
// slices, meaning T might be a struct with a slice, meaning it is not
// comparable.
func Union[T any](_ context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj T, union *UnionMembership, isSetFns ...ExtractorFn[T, bool]) field.ErrorList {
options := UnionValidationOptions{
ErrorForEmpty: func(fldPath *field.Path, allFields []string) *field.Error {
@@ -98,6 +103,10 @@ func Union[T any](_ context.Context, op operation.Operation, fldPath *field.Path
//
// It is not an error for the discriminatorValue to be unknown. That must be
// validated on its own.
//
// Note that T is "any", rather than "comparable", because union-members can be
// slices, meaning T might be a struct with a slice, meaning it is not
// comparable.
func DiscriminatedUnion[T any, D ~string](_ context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj T, union *UnionMembership, discriminatorExtractor ExtractorFn[T, D], isSetFns ...ExtractorFn[T, bool]) (errs field.ErrorList) {
if len(union.members) != len(isSetFns) {
return field.ErrorList{
@@ -106,6 +115,7 @@ func DiscriminatedUnion[T any, D ~string](_ context.Context, op operation.Operat
len(isSetFns), len(union.members))),
}
}
hasOldValue := !reflect.ValueOf(oldObj).IsZero() // because T is any, rather than comparable
var changed bool
discriminatorValue := discriminatorExtractor(obj)
if op.Type == operation.Update {
@@ -131,7 +141,7 @@ func DiscriminatedUnion[T any, D ~string](_ context.Context, op operation.Operat
}
// If the union discriminator and membership is unchanged, we don't need to
// re-validate.
if op.Type == operation.Update && !changed {
if op.Type == operation.Update && hasOldValue && !changed {
return nil
}
return errs
@@ -195,6 +205,7 @@ func unionValidate[T any](op operation.Operation, fldPath *field.Path,
}
}
hasOldValue := !reflect.ValueOf(oldObj).IsZero() // because T is any, rather than comparable
var specifiedFields []string
var changed bool
for i, fieldIsSet := range isSetFns {
@@ -209,7 +220,7 @@ func unionValidate[T any](op operation.Operation, fldPath *field.Path,
}
// If the union membership is unchanged, we don't need to re-validate.
if op.Type == operation.Update && !changed {
if op.Type == operation.Update && hasOldValue && !changed {
return nil
}

View File

@@ -67,18 +67,34 @@ func TestUnion(t *testing.T) {
members = append(members, NewUnionMember(f))
}
// Create mock extractors that return predefined values instead of
// actually extracting from the object.
extractors := make([]ExtractorFn[*testMember, bool], len(tc.fieldValues))
for i, val := range tc.fieldValues {
extractors[i] = func(_ *testMember) bool { return val }
}
t.Run("pointer", func(t *testing.T) {
// Create mock extractors that return predefined values instead of
// actually extracting from the object.
extractors := make([]ExtractorFn[*testMember, bool], len(tc.fieldValues))
for i, val := range tc.fieldValues {
extractors[i] = func(_ *testMember) bool { return val }
}
got := Union(context.Background(), operation.Operation{}, nil, &testMember{}, nil,
NewUnionMembership(members...), extractors...)
if !reflect.DeepEqual(got, tc.expected) {
t.Errorf("got %v want %v", got, tc.expected)
}
got := Union(context.Background(), operation.Operation{}, nil, &testMember{}, nil,
NewUnionMembership(members...), extractors...)
if !reflect.DeepEqual(got, tc.expected) {
t.Errorf("got %v want %v", got, tc.expected)
}
})
t.Run("value", func(t *testing.T) {
// Create mock extractors that return predefined values instead of
// actually extracting from the object.
extractors := make([]ExtractorFn[testMember, bool], len(tc.fieldValues))
for i, val := range tc.fieldValues {
extractors[i] = func(_ testMember) bool { return val }
}
got := Union(context.Background(), operation.Operation{}, nil, testMember{}, testMember{},
NewUnionMembership(members...), extractors...)
if !reflect.DeepEqual(got, tc.expected) {
t.Errorf("got %v want %v", got, tc.expected)
}
})
})
}
}
@@ -131,33 +147,53 @@ func TestDiscriminatedUnion(t *testing.T) {
}
for _, tc := range testCases {
members := []UnionMember{}
for _, f := range tc.fields {
members = append(members, NewDiscriminatedUnionMember(f[0], f[1]))
}
t.Run(tc.name, func(t *testing.T) {
members := []UnionMember{}
for _, f := range tc.fields {
members = append(members, NewDiscriminatedUnionMember(f[0], f[1]))
}
t.Run("pointer", func(t *testing.T) {
discriminatorExtractor := func(_ *testMember) string { return tc.discriminatorValue }
discriminatorExtractor := func(_ *testMember) string { return tc.discriminatorValue }
// Create mock extractors that return predefined values instead of
// actually extracting from the object.
extractors := make([]ExtractorFn[*testMember, bool], len(tc.fieldValues))
for i, val := range tc.fieldValues {
extractors[i] = func(_ *testMember) bool { return val }
}
// Create mock extractors that return predefined values instead of
// actually extracting from the object.
extractors := make([]ExtractorFn[*testMember, bool], len(tc.fieldValues))
for i, val := range tc.fieldValues {
extractors[i] = func(_ *testMember) bool { return val }
}
got := DiscriminatedUnion(context.Background(), operation.Operation{}, nil, &testMember{}, nil,
NewDiscriminatedUnionMembership(tc.discriminatorField, members...), discriminatorExtractor, extractors...)
if !reflect.DeepEqual(got, tc.expected) {
t.Errorf("got %v want %v", got.ToAggregate(), tc.expected.ToAggregate())
}
})
t.Run("value", func(t *testing.T) {
discriminatorExtractor := func(_ testMember) string { return tc.discriminatorValue }
got := DiscriminatedUnion(context.Background(), operation.Operation{}, nil, &testMember{}, nil,
NewDiscriminatedUnionMembership(tc.discriminatorField, members...), discriminatorExtractor, extractors...)
if !reflect.DeepEqual(got, tc.expected) {
t.Errorf("got %v want %v", got.ToAggregate(), tc.expected.ToAggregate())
}
// Create mock extractors that return predefined values instead of
// actually extracting from the object.
extractors := make([]ExtractorFn[testMember, bool], len(tc.fieldValues))
for i, val := range tc.fieldValues {
extractors[i] = func(_ testMember) bool { return val }
}
got := DiscriminatedUnion(context.Background(), operation.Operation{}, nil, testMember{}, testMember{},
NewDiscriminatedUnionMembership(tc.discriminatorField, members...), discriminatorExtractor, extractors...)
if !reflect.DeepEqual(got, tc.expected) {
t.Errorf("got %v want %v", got.ToAggregate(), tc.expected.ToAggregate())
}
})
})
}
}
type testStruct struct {
M1 *m1 `json:"m1"`
M2 *m2 `json:"m2"`
M1 *m1 `json:"m1"`
M2 *m2 `json:"m2"`
M3 []string `json:"m3"`
M4 map[string]string `json:"m4"`
}
type m1 struct{}
@@ -176,6 +212,18 @@ var extractors = []ExtractorFn[*testStruct, bool]{
}
return s.M2 != nil
},
func(s *testStruct) bool {
if s == nil {
return false
}
return len(s.M3) != 0
},
func(s *testStruct) bool {
if s == nil {
return false
}
return len(s.M4) != 0
},
}
func TestUnionRatcheting(t *testing.T) {
@@ -186,9 +234,12 @@ func TestUnionRatcheting(t *testing.T) {
expected field.ErrorList
}{
{
name: "both nil",
name: "old nil - no ratcheting",
oldStruct: nil,
newStruct: nil,
newStruct: &testStruct{},
expected: field.ErrorList{
field.Invalid(nil, "", "must specify one of: `m1`, `m2`, `m3`, `m4`"),
},
},
{
name: "both empty struct",
@@ -216,14 +267,40 @@ func TestUnionRatcheting(t *testing.T) {
M2: &m2{},
},
expected: field.ErrorList{
field.Invalid(nil, "{m1, m2}", "must specify exactly one of: `m1`, `m2`"),
field.Invalid(nil, "{m1, m2}", "must specify exactly one of: `m1`, `m2`, `m3`, `m4`"),
},
},
{
name: "slice member ratcheting: unchanged membership",
oldStruct: &testStruct{M3: []string{"a"}},
newStruct: &testStruct{M3: []string{"b"}},
},
{
name: "map member ratcheting: unchanged membership",
oldStruct: &testStruct{M4: map[string]string{"k": "v1"}},
newStruct: &testStruct{M4: map[string]string{"k": "v2"}},
},
{
name: "empty slice is not set",
oldStruct: nil,
newStruct: &testStruct{M3: []string{}},
expected: field.ErrorList{
field.Invalid(nil, "", "must specify one of: `m1`, `m2`, `m3`, `m4`"),
},
},
{
name: "empty map is not set",
oldStruct: nil,
newStruct: &testStruct{M4: map[string]string{}},
expected: field.ErrorList{
field.Invalid(nil, "", "must specify one of: `m1`, `m2`, `m3`, `m4`"),
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
members := []UnionMember{NewUnionMember("m1"), NewUnionMember("m2")}
members := []UnionMember{NewUnionMember("m1"), NewUnionMember("m2"), NewUnionMember("m3"), NewUnionMember("m4")}
got := Union(context.Background(), operation.Operation{Type: operation.Update}, nil, tc.newStruct, tc.oldStruct,
NewUnionMembership(members...), extractors...)
if !reflect.DeepEqual(got, tc.expected) {
@@ -234,9 +311,11 @@ func TestUnionRatcheting(t *testing.T) {
}
type testDiscriminatedStruct struct {
D string `json:"d"`
M1 *m1 `json:"m1"`
M2 *m2 `json:"m2"`
D string `json:"d"`
M1 *m1 `json:"m1"`
M2 *m2 `json:"m2"`
M3 []string `json:"m3"`
M4 map[string]string `json:"m4"`
}
var testDiscriminatorExtractor = func(s *testDiscriminatedStruct) string {
@@ -258,6 +337,18 @@ var testDiscriminatedExtractors = []ExtractorFn[*testDiscriminatedStruct, bool]{
}
return s.M2 != nil
},
func(s *testDiscriminatedStruct) bool {
if s == nil {
return false
}
return len(s.M3) != 0
},
func(s *testDiscriminatedStruct) bool {
if s == nil {
return false
}
return len(s.M4) != 0
},
}
func TestDiscriminatedUnionRatcheting(t *testing.T) {
@@ -329,11 +420,61 @@ func TestDiscriminatedUnionRatcheting(t *testing.T) {
field.Invalid(field.NewPath("m2"), "", "must be specified when `d` is \"m2\""),
},
},
{
name: "slice member ratcheting: unchanged membership",
oldStruct: &testDiscriminatedStruct{
D: "m3",
M3: []string{"a"},
},
newStruct: &testDiscriminatedStruct{
D: "m3",
M3: []string{"b"},
},
},
{
name: "map member ratcheting: unchanged membership",
oldStruct: &testDiscriminatedStruct{
D: "m4",
M4: map[string]string{"k": "v1"},
},
newStruct: &testDiscriminatedStruct{
D: "m4",
M4: map[string]string{"k": "v2"},
},
},
{
name: "empty slice is not set",
oldStruct: &testDiscriminatedStruct{
D: "m3",
M3: []string{"a"},
},
newStruct: &testDiscriminatedStruct{
D: "m3",
M3: []string{},
},
expected: field.ErrorList{
field.Invalid(field.NewPath("m3"), "", "must be specified when `d` is \"m3\""),
},
},
{
name: "empty map is not set",
oldStruct: &testDiscriminatedStruct{
D: "m4",
M4: map[string]string{"k": "v"},
},
newStruct: &testDiscriminatedStruct{
D: "m4",
M4: map[string]string{},
},
expected: field.ErrorList{
field.Invalid(field.NewPath("m4"), "", "must be specified when `d` is \"m4\""),
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
members := []UnionMember{NewDiscriminatedUnionMember("m1", "m1"), NewDiscriminatedUnionMember("m2", "m2")}
members := []UnionMember{NewDiscriminatedUnionMember("m1", "m1"), NewDiscriminatedUnionMember("m2", "m2"), NewDiscriminatedUnionMember("m3", "m3"), NewDiscriminatedUnionMember("m4", "m4")}
got := DiscriminatedUnion(context.Background(), operation.Operation{Type: operation.Update}, nil, tc.newStruct, tc.oldStruct,
NewDiscriminatedUnionMembership("d", members...), testDiscriminatorExtractor, testDiscriminatedExtractors...)
if !reflect.DeepEqual(got, tc.expected) {

View File

@@ -119,7 +119,7 @@ func TestZeroOrOneOfUnionRatcheting(t *testing.T) {
M2: &m2{},
},
expected: field.ErrorList{
field.Invalid(nil, "{m1, m2}", "must specify at most one of: `m1`, `m2`").WithOrigin("zeroOrOneOf"),
field.Invalid(nil, "{m1, m2}", "must specify at most one of: `m1`, `m2`, `m3`, `m4`").WithOrigin("zeroOrOneOf"),
},
},
{
@@ -142,7 +142,7 @@ func TestZeroOrOneOfUnionRatcheting(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
members := []UnionMember{NewUnionMember("m1"), NewUnionMember("m2")}
members := []UnionMember{NewUnionMember("m1"), NewUnionMember("m2"), NewUnionMember("m3"), NewUnionMember("m4")}
got := ZeroOrOneOfUnion(context.Background(), operation.Operation{Type: operation.Update}, nil, tc.newStruct, tc.oldStruct,
NewUnionMembership(members...), extractors...)
if !reflect.DeepEqual(got, tc.expected) {

View File

@@ -38,6 +38,14 @@ type Struct struct {
// +k8s:unionMember
// +k8s:optional
M2 *M2 `json:"m2"`
// +k8s:unionMember
// +k8s:optional
M3 []string `json:"m3"`
// +k8s:unionMember
// +k8s:optional
M4 map[string]string `json:"m4"`
}
type D string
@@ -45,6 +53,8 @@ type D string
const (
DM1 D = "M1"
DM2 D = "M2"
DM3 D = "M3"
DM4 D = "M4"
)
type M1 struct{}

View File

@@ -29,6 +29,8 @@ func Test(t *testing.T) {
st.Value(&Struct{D: DM1, M1: &M1{}}).ExpectValid()
st.Value(&Struct{D: DM2, M2: &M2{}}).ExpectValid()
st.Value(&Struct{D: DM3, M3: []string{"a"}}).ExpectValid()
st.Value(&Struct{D: DM4, M4: map[string]string{"k": "v"}}).ExpectValid()
st.Value(&Struct{D: DM2, M1: &M1{}, M2: &M2{}}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{
field.Invalid(field.NewPath("m1"), nil, "may only be specified when"),
@@ -38,7 +40,55 @@ func Test(t *testing.T) {
field.Invalid(field.NewPath("m1"), nil, "must be specified when"),
})
// Slice member: discriminator matches but slice is nil
st.Value(&Struct{D: DM3}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{
field.Invalid(field.NewPath("m3"), nil, "must be specified when"),
})
// Slice member: discriminator matches but slice is empty (len==0, not nil)
st.Value(&Struct{D: DM3, M3: []string{}}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{
field.Invalid(field.NewPath("m3"), nil, "must be specified when"),
})
// Slice member: discriminator doesn't match but slice is set
st.Value(&Struct{D: DM1, M1: &M1{}, M3: []string{"a"}}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{
field.Invalid(field.NewPath("m3"), nil, "may only be specified when"),
})
// Map member: discriminator matches but map is nil
st.Value(&Struct{D: DM4}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{
field.Invalid(field.NewPath("m4"), nil, "must be specified when"),
})
// Map member: discriminator matches but map is empty (len==0, not nil)
st.Value(&Struct{D: DM4, M4: map[string]string{}}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{
field.Invalid(field.NewPath("m4"), nil, "must be specified when"),
})
// Map member: discriminator doesn't match but map is set
st.Value(&Struct{D: DM1, M1: &M1{}, M4: map[string]string{"k": "v"}}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{
field.Invalid(field.NewPath("m4"), nil, "may only be specified when"),
})
// Test validation ratcheting
st.Value(&Struct{D: DM2, M1: &M1{}, M2: &M2{}}).OldValue(&Struct{D: DM2, M1: &M1{}, M2: &M2{}}).ExpectValid()
st.Value(&Struct{D: DM1}).OldValue(&Struct{D: DM1}).ExpectValid()
// Slice member ratcheting: unchanged membership
st.Value(&Struct{D: DM3, M3: []string{"a"}}).OldValue(&Struct{D: DM3, M3: []string{"b"}}).ExpectValid()
// Slice member ratcheting: changed from set to unset
st.Value(&Struct{D: DM3}).OldValue(&Struct{D: DM3, M3: []string{"a"}}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{
field.Invalid(field.NewPath("m3"), nil, "must be specified when"),
})
// Map member ratcheting: unchanged membership
st.Value(&Struct{D: DM4, M4: map[string]string{"k": "v1"}}).OldValue(&Struct{D: DM4, M4: map[string]string{"k": "v2"}}).ExpectValid()
// Map member ratcheting: changed from set to unset
st.Value(&Struct{D: DM4}).OldValue(&Struct{D: DM4, M4: map[string]string{"k": "v"}}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{
field.Invalid(field.NewPath("m4"), nil, "must be specified when"),
})
// Test update with nil old value (simulates newly-set pointer field during update).
// Discriminated union validation should still detect mismatches even though oldObj is nil.
st.Value(&Struct{D: DM1}).OldValue(nil).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{
field.Invalid(field.NewPath("m1"), nil, "must be specified when"),
})
}

View File

@@ -25,6 +25,7 @@ import (
context "context"
fmt "fmt"
equality "k8s.io/apimachinery/pkg/api/equality"
operation "k8s.io/apimachinery/pkg/api/operation"
safe "k8s.io/apimachinery/pkg/api/safe"
validate "k8s.io/apimachinery/pkg/api/validate"
@@ -48,7 +49,7 @@ func RegisterValidations(scheme *testscheme.Scheme) error {
return nil
}
var unionMembershipFor_k8s_io_code_generator_cmd_validation_gen_output_tests_tags_union_union_discriminated_simple_Struct_ = validate.NewDiscriminatedUnionMembership("d", validate.NewDiscriminatedUnionMember("m1", "M1"), validate.NewDiscriminatedUnionMember("m2", "M2"))
var unionMembershipFor_k8s_io_code_generator_cmd_validation_gen_output_tests_tags_union_union_discriminated_simple_Struct_ = validate.NewDiscriminatedUnionMembership("d", validate.NewDiscriminatedUnionMember("m1", "M1"), validate.NewDiscriminatedUnionMember("m2", "M2"), validate.NewDiscriminatedUnionMember("m3", "M3"), validate.NewDiscriminatedUnionMember("m4", "M4"))
// Validate_Struct validates an instance of Struct according
// to declarative validation rules in the API schema.
@@ -68,6 +69,16 @@ func Validate_Struct(ctx context.Context, op operation.Operation, fldPath *field
return false
}
return obj.M2 != nil
}, func(obj *Struct) bool {
if obj == nil {
return false
}
return len(obj.M3) != 0
}, func(obj *Struct) bool {
if obj == nil {
return false
}
return len(obj.M4) != 0
})...)
// field Struct.TypeMeta has no validation
@@ -109,5 +120,41 @@ func Validate_Struct(ctx context.Context, op operation.Operation, fldPath *field
return
}(fldPath.Child("m2"), obj.M2, safe.Field(oldObj, func(oldObj *Struct) *M2 { return oldObj.M2 }), oldObj != nil)...)
// field Struct.M3
errs = append(errs,
func(fldPath *field.Path, obj, oldObj []string, oldValueCorrelated bool) (errs field.ErrorList) {
// don't revalidate unchanged data
if oldValueCorrelated && op.Type == operation.Update && equality.Semantic.DeepEqual(obj, oldObj) {
return nil
}
// call field-attached validations
earlyReturn := false
if e := validate.OptionalSlice(ctx, op, fldPath, obj, oldObj); len(e) != 0 {
earlyReturn = true
}
if earlyReturn {
return // do not proceed
}
return
}(fldPath.Child("m3"), obj.M3, safe.Field(oldObj, func(oldObj *Struct) []string { return oldObj.M3 }), oldObj != nil)...)
// field Struct.M4
errs = append(errs,
func(fldPath *field.Path, obj, oldObj map[string]string, oldValueCorrelated bool) (errs field.ErrorList) {
// don't revalidate unchanged data
if oldValueCorrelated && op.Type == operation.Update && equality.Semantic.DeepEqual(obj, oldObj) {
return nil
}
// call field-attached validations
earlyReturn := false
if e := validate.OptionalMap(ctx, op, fldPath, obj, oldObj); len(e) != 0 {
earlyReturn = true
}
if earlyReturn {
return // do not proceed
}
return
}(fldPath.Child("m4"), obj.M4, safe.Field(oldObj, func(oldObj *Struct) map[string]string { return oldObj.M4 }), oldObj != nil)...)
return errs
}

View File

@@ -45,6 +45,14 @@ type Struct struct {
// +k8s:unionMember
// +k8s:optional
M4 *string `json:"m4"`
// +k8s:unionMember
// +k8s:optional
M5 []string `json:"m5"`
// +k8s:unionMember
// +k8s:optional
M6 map[string]string `json:"m6"`
}
type M1 struct{}

View File

@@ -34,6 +34,16 @@ func Test(t *testing.T) {
st.Value(&Struct{M2: &M2{}}).ExpectValid()
st.Value(&Struct{M3: "a string"}).ExpectValid()
st.Value(&Struct{M4: ptr.To("a string")}).ExpectValid()
st.Value(&Struct{M5: []string{"a string"}}).ExpectValid()
st.Value(&Struct{M6: map[string]string{"k": "v"}}).ExpectValid()
// Empty slice/map are "not set" (same as nil for union membership)
st.Value(&Struct{M5: []string{}}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{
field.Invalid(nil, nil, "must specify one of"),
})
st.Value(&Struct{M6: map[string]string{}}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{
field.Invalid(nil, nil, "must specify one of"),
})
st.Value(&Struct{M1: &M1{}, M2: &M2{}}).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{
field.Invalid(nil, nil, "must specify exactly one of"),
@@ -52,4 +62,14 @@ func Test(t *testing.T) {
st.Value(&Struct{}).OldValue(&Struct{}).ExpectValid()
st.Value(&Struct{M1: &M1{}, M2: &M2{}}).OldValue(&Struct{M1: &M1{}, M2: &M2{}}).ExpectValid()
st.Value(&Struct{M3: "a string", M2: &M2{}}).OldValue(&Struct{M3: "different string", M2: &M2{}}).ExpectValid()
// Slice/map member ratcheting: unchanged membership, different values
st.Value(&Struct{M5: []string{"a"}}).OldValue(&Struct{M5: []string{"b"}}).ExpectValid()
st.Value(&Struct{M6: map[string]string{"k": "v1"}}).OldValue(&Struct{M6: map[string]string{"k": "v2"}}).ExpectValid()
// Test update with nil old value (simulates new map entry or newly-set pointer field during update).
// Union validation should still detect the empty union even though oldObj is nil.
st.Value(&Struct{}).OldValue(nil).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{
field.Invalid(nil, nil, "must specify one of"),
})
}

View File

@@ -25,6 +25,7 @@ import (
context "context"
fmt "fmt"
equality "k8s.io/apimachinery/pkg/api/equality"
operation "k8s.io/apimachinery/pkg/api/operation"
safe "k8s.io/apimachinery/pkg/api/safe"
validate "k8s.io/apimachinery/pkg/api/validate"
@@ -48,7 +49,7 @@ func RegisterValidations(scheme *testscheme.Scheme) error {
return nil
}
var unionMembershipFor_k8s_io_code_generator_cmd_validation_gen_output_tests_tags_union_union_undiscriminated_simple_Struct_ = validate.NewUnionMembership(validate.NewUnionMember("m1"), validate.NewUnionMember("m2"), validate.NewUnionMember("m3"), validate.NewUnionMember("m4"))
var unionMembershipFor_k8s_io_code_generator_cmd_validation_gen_output_tests_tags_union_union_undiscriminated_simple_Struct_ = validate.NewUnionMembership(validate.NewUnionMember("m1"), validate.NewUnionMember("m2"), validate.NewUnionMember("m3"), validate.NewUnionMember("m4"), validate.NewUnionMember("m5"), validate.NewUnionMember("m6"))
// Validate_Struct validates an instance of Struct according
// to declarative validation rules in the API schema.
@@ -74,6 +75,16 @@ func Validate_Struct(ctx context.Context, op operation.Operation, fldPath *field
return false
}
return obj.M4 != nil
}, func(obj *Struct) bool {
if obj == nil {
return false
}
return len(obj.M5) != 0
}, func(obj *Struct) bool {
if obj == nil {
return false
}
return len(obj.M6) != 0
})...)
// field Struct.TypeMeta has no validation
@@ -151,5 +162,41 @@ func Validate_Struct(ctx context.Context, op operation.Operation, fldPath *field
return
}(fldPath.Child("m4"), obj.M4, safe.Field(oldObj, func(oldObj *Struct) *string { return oldObj.M4 }), oldObj != nil)...)
// field Struct.M5
errs = append(errs,
func(fldPath *field.Path, obj, oldObj []string, oldValueCorrelated bool) (errs field.ErrorList) {
// don't revalidate unchanged data
if oldValueCorrelated && op.Type == operation.Update && equality.Semantic.DeepEqual(obj, oldObj) {
return nil
}
// call field-attached validations
earlyReturn := false
if e := validate.OptionalSlice(ctx, op, fldPath, obj, oldObj); len(e) != 0 {
earlyReturn = true
}
if earlyReturn {
return // do not proceed
}
return
}(fldPath.Child("m5"), obj.M5, safe.Field(oldObj, func(oldObj *Struct) []string { return oldObj.M5 }), oldObj != nil)...)
// field Struct.M6
errs = append(errs,
func(fldPath *field.Path, obj, oldObj map[string]string, oldValueCorrelated bool) (errs field.ErrorList) {
// don't revalidate unchanged data
if oldValueCorrelated && op.Type == operation.Update && equality.Semantic.DeepEqual(obj, oldObj) {
return nil
}
// call field-attached validations
earlyReturn := false
if e := validate.OptionalMap(ctx, op, fldPath, obj, oldObj); len(e) != 0 {
earlyReturn = true
}
if earlyReturn {
return // do not proceed
}
return
}(fldPath.Child("m6"), obj.M6, safe.Field(oldObj, func(oldObj *Struct) map[string]string { return oldObj.M6 }), oldObj != nil)...)
return errs
}

View File

@@ -51,4 +51,12 @@ func Test(t *testing.T) {
st.Value(&Struct{}).OldValue(&Struct{}).ExpectValid()
st.Value(&Struct{M1: &M1{}, M2: &M2{}}).OldValue(&Struct{M1: &M1{}, M2: &M2{}}).ExpectValid()
st.Value(&Struct{M3: "a string", M2: &M2{}}).OldValue(&Struct{M3: "different string", M2: &M2{}}).ExpectValid()
// Test update with nil old value (simulates new map entry added during update).
// Empty union is still valid for zeroOrOneOf, even with nil old value.
st.Value(&Struct{}).OldValue((*Struct)(nil)).ExpectValid()
// Multiple members set should still be caught even with nil old value.
st.Value(&Struct{M1: &M1{}, M2: &M2{}}).OldValue((*Struct)(nil)).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{
field.Invalid(nil, nil, "must specify at most one of").WithOrigin("zeroOrOneOf"),
})
}

View File

@@ -325,8 +325,10 @@ func createMemberExtractor(ptrType *types.Type, member *types.Member) FunctionLi
}
nt := util.NativeType(member.Type)
switch nt.Kind {
case types.Pointer, types.Map, types.Slice:
case types.Pointer:
extractor.Body = fmt.Sprintf("if obj == nil {return false}; return obj.%s != nil", member.Name)
case types.Map, types.Slice:
extractor.Body = fmt.Sprintf("if obj == nil {return false}; return len(obj.%s) != 0", member.Name)
case types.Builtin:
extractor.Body = fmt.Sprintf("if obj == nil {return false}; var z %s; return obj.%s != z", member.Type, member.Name)
default: