From 70b90d98985ee5634f85378bf369276a0781b872 Mon Sep 17 00:00:00 2001 From: Aaron Prindle Date: Fri, 13 Feb 2026 17:27:11 +0000 Subject: [PATCH] feedback: Address review comments - Rename type params to Tstruct/Tfield/Tdisc for Subfield consistency - Make DiscriminatedRule.Value generic over Tdisc and emit typed literals - Move discriminator assignment adjacent to value assignment - Fix mockEqual to test value equivalence and add pointer test - Add test for value changed with discriminator unchanged - Add regex validation for discriminator group names - Disallow "default" as an explicit discriminator group name - Add directComparable cross-reference comment for ratcheting - Rename mode/modal terminology to discriminator/member --- .../validate/{mode.go => discriminator.go} | 28 +- .../pkg/api/validate/discriminator_test.go | 265 ++++++++++++++++++ .../pkg/api/validate/mode_test.go | 224 --------------- .../tags/{mode => discriminator}/doc.go | 2 +- .../tags/{mode => discriminator}/doc_test.go | 2 +- .../zz_generated.validations.go | 118 ++++---- .../validators/{mode.go => discriminator.go} | 170 +++++++---- 7 files changed, 438 insertions(+), 371 deletions(-) rename staging/src/k8s.io/apimachinery/pkg/api/validate/{mode.go => discriminator.go} (69%) create mode 100644 staging/src/k8s.io/apimachinery/pkg/api/validate/discriminator_test.go delete mode 100644 staging/src/k8s.io/apimachinery/pkg/api/validate/mode_test.go rename staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/{mode => discriminator}/doc.go (99%) rename staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/{mode => discriminator}/doc_test.go (99%) rename staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/{mode => discriminator}/zz_generated.validations.go (69%) rename staging/src/k8s.io/code-generator/cmd/validation-gen/validators/{mode.go => discriminator.go} (64%) diff --git a/staging/src/k8s.io/apimachinery/pkg/api/validate/mode.go b/staging/src/k8s.io/apimachinery/pkg/api/validate/discriminator.go similarity index 69% rename from staging/src/k8s.io/apimachinery/pkg/api/validate/mode.go rename to staging/src/k8s.io/apimachinery/pkg/api/validate/discriminator.go index 191273198cc..787ae6b0b7f 100644 --- a/staging/src/k8s.io/apimachinery/pkg/api/validate/mode.go +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/discriminator.go @@ -18,47 +18,44 @@ package validate import ( "context" - "fmt" "k8s.io/apimachinery/pkg/api/operation" "k8s.io/apimachinery/pkg/util/validation/field" ) -// ModalRule defines a validation to apply for a specific mode value. -type ModalRule[T any] struct { - Value string - Validation ValidateFunc[T] +// DiscriminatedRule defines a validation to apply for a specific discriminator value. +type DiscriminatedRule[Tfield any, Tdisc comparable] struct { + Value Tdisc + Validation ValidateFunc[Tfield] } -// Modal validates a value based on a discriminator value. +// Discriminated validates a member field based on a discriminator value. // It iterates through the rules and applies the first one that matches the discriminator. // If no rule matches, it applies the defaultValidation if provided. // // It performs ratcheting: if the operation is an Update, and neither the discriminator // nor the value (checked via equiv) have changed, validation is skipped. -func Modal[T any, D comparable, P any](ctx context.Context, op operation.Operation, structPath *field.Path, - obj, oldObj *P, fieldName string, getMemberValue func(*P) T, getDiscriminator func(*P) D, - equiv MatchFunc[T], defaultValidation ValidateFunc[T], rules []ModalRule[T], +func Discriminated[Tfield any, Tdisc comparable, Tstruct any](ctx context.Context, op operation.Operation, structPath *field.Path, + obj, oldObj *Tstruct, fieldName string, getMemberValue func(*Tstruct) Tfield, getDiscriminator func(*Tstruct) Tdisc, + equiv MatchFunc[Tfield], defaultValidation ValidateFunc[Tfield], rules []DiscriminatedRule[Tfield, Tdisc], ) field.ErrorList { value := getMemberValue(obj) - var oldValue T - var oldDiscriminator D + discriminator := getDiscriminator(obj) + var oldValue Tfield + var oldDiscriminator Tdisc if oldObj != nil { oldValue = getMemberValue(oldObj) oldDiscriminator = getDiscriminator(oldObj) } - discriminator := getDiscriminator(obj) - if op.Type == operation.Update && oldObj != nil && discriminator == oldDiscriminator && equiv(value, oldValue) { return nil } fldPath := structPath.Child(fieldName) - dStr := fmt.Sprintf("%v", discriminator) for _, rule := range rules { - if rule.Value == dStr { + if rule.Value == discriminator { if rule.Validation == nil { return nil } @@ -71,5 +68,4 @@ func Modal[T any, D comparable, P any](ctx context.Context, op operation.Operati } return nil - } diff --git a/staging/src/k8s.io/apimachinery/pkg/api/validate/discriminator_test.go b/staging/src/k8s.io/apimachinery/pkg/api/validate/discriminator_test.go new file mode 100644 index 00000000000..ae9621b2a9e --- /dev/null +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/discriminator_test.go @@ -0,0 +1,265 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validate + +import ( + "context" + "reflect" + "testing" + + "k8s.io/apimachinery/pkg/api/operation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func TestDiscriminated(t *testing.T) { + errMatch := field.ErrorList{field.Invalid(field.NewPath("foo"), "bar", "match error")} + errDefault := field.ErrorList{field.Invalid(field.NewPath("foo"), "bar", "default error")} + + mockValid := func(_ context.Context, _ operation.Operation, _ *field.Path, _, _ *string) field.ErrorList { + return nil + } + mockErrorMatch := func(_ context.Context, _ operation.Operation, _ *field.Path, _, _ *string) field.ErrorList { + return errMatch + } + mockErrorDefault := func(_ context.Context, _ operation.Operation, _ *field.Path, _, _ *string) field.ErrorList { + return errDefault + } + + // mockEqual compares pointer values by dereferencing, not by pointer identity. + mockEqual := func(a, b *string) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b + } + + testCases := []struct { + name string + opType operation.Type + discriminator string + oldDiscriminator string + value *string + oldValue *string + rules []DiscriminatedRule[*string, string] + defaultValidation ValidateFunc[*string] + expected field.ErrorList + }{ + { + name: "matches rule, returns valid", + opType: operation.Create, + discriminator: "A", + oldDiscriminator: "A", + rules: []DiscriminatedRule[*string, string]{ + {Value: "A", Validation: mockValid}, + {Value: "B", Validation: mockErrorMatch}, + }, + defaultValidation: mockErrorDefault, + expected: nil, + }, + { + name: "matches rule, returns error", + opType: operation.Create, + discriminator: "B", + oldDiscriminator: "B", + rules: []DiscriminatedRule[*string, string]{ + {Value: "A", Validation: mockValid}, + {Value: "B", Validation: mockErrorMatch}, + }, + defaultValidation: mockErrorDefault, + expected: errMatch, + }, + { + name: "ratcheting: update, unchanged, skips validation", + opType: operation.Update, + discriminator: "B", + oldDiscriminator: "B", // unchanged + value: nil, + oldValue: nil, // unchanged + rules: []DiscriminatedRule[*string, string]{ + {Value: "B", Validation: mockErrorMatch}, // would fail if run + }, + defaultValidation: mockErrorDefault, + expected: nil, + }, + { + name: "ratcheting: update, same value different pointers, skips validation", + opType: operation.Update, + discriminator: "B", + oldDiscriminator: "B", + value: strPtr("same"), + oldValue: strPtr("same"), // different pointer, same value + rules: []DiscriminatedRule[*string, string]{ + {Value: "B", Validation: mockErrorMatch}, // would fail if run + }, + defaultValidation: mockErrorDefault, + expected: nil, + }, + { + name: "ratcheting: update, discriminator changed, runs validation", + opType: operation.Update, + discriminator: "B", + oldDiscriminator: "A", // changed + value: nil, + oldValue: nil, + rules: []DiscriminatedRule[*string, string]{ + {Value: "B", Validation: mockErrorMatch}, + }, + defaultValidation: mockErrorDefault, + expected: errMatch, + }, + { + name: "ratcheting: update, value changed, discriminator unchanged, runs validation", + opType: operation.Update, + discriminator: "B", + oldDiscriminator: "B", // unchanged + value: strPtr("new"), + oldValue: strPtr("old"), // changed + rules: []DiscriminatedRule[*string, string]{ + {Value: "B", Validation: mockErrorMatch}, + }, + defaultValidation: mockErrorDefault, + expected: errMatch, + }, + { + name: "matches rule with nil validation, returns valid", + opType: operation.Create, + discriminator: "A", + rules: []DiscriminatedRule[*string, string]{ + {Value: "A", Validation: nil}, + }, + defaultValidation: mockErrorDefault, + expected: nil, + }, + { + name: "no match, runs default", + opType: operation.Create, + discriminator: "C", + rules: []DiscriminatedRule[*string, string]{ + {Value: "A", Validation: mockValid}, + {Value: "B", Validation: mockErrorMatch}, + }, + defaultValidation: mockErrorDefault, + expected: errDefault, + }, + { + name: "no match, nil default, returns valid", + opType: operation.Create, + discriminator: "C", + rules: []DiscriminatedRule[*string, string]{ + {Value: "A", Validation: mockValid}, + }, + defaultValidation: nil, + expected: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + oldDisc := tc.oldDiscriminator + if oldDisc == "" { + oldDisc = tc.discriminator + } + + type StringP struct { + Val *string + Disc string + } + newObj := &StringP{Val: tc.value, Disc: tc.discriminator} + var oldObj *StringP + if tc.opType == operation.Update { + oldObj = &StringP{Val: tc.oldValue, Disc: oldDisc} + } + getVal := func(p *StringP) *string { return p.Val } + getDisc := func(p *StringP) string { return p.Disc } + got := Discriminated[*string, string, StringP](context.Background(), operation.Operation{Type: tc.opType}, field.NewPath("root"), newObj, oldObj, "field", getVal, getDisc, mockEqual, tc.defaultValidation, tc.rules) + + if !reflect.DeepEqual(got, tc.expected) { + t.Errorf("got %v want %v", got, tc.expected) + } + }) + } +} + +func TestDiscriminatedIntDiscriminator(t *testing.T) { + errMatch := field.ErrorList{field.Invalid(field.NewPath("foo"), "bar", "match error")} + errDefault := field.ErrorList{field.Invalid(field.NewPath("foo"), "bar", "default error")} + + mockErrorMatch := func(_ context.Context, _ operation.Operation, _ *field.Path, _, _ *string) field.ErrorList { + return errMatch + } + mockErrorDefault := func(_ context.Context, _ operation.Operation, _ *field.Path, _, _ *string) field.ErrorList { + return errDefault + } + + mockEqual := func(a, b *string) bool { + return a == b + } + + type IntP struct { + Val *string + Disc int + } + newObj := &IntP{Val: nil, Disc: 1} + getVal := func(p *IntP) *string { return p.Val } + getDisc := func(p *IntP) int { return p.Disc } + + got := Discriminated[*string, int, IntP](context.Background(), operation.Operation{Type: operation.Create}, field.NewPath("root"), newObj, nil, "field", getVal, getDisc, mockEqual, mockErrorDefault, []DiscriminatedRule[*string, int]{ + {Value: 1, Validation: mockErrorMatch}, + }) + if !reflect.DeepEqual(got, errMatch) { + t.Errorf("int discriminator: got %v want %v", got, errMatch) + } +} + +func TestDiscriminatedBoolDiscriminator(t *testing.T) { + errMatch := field.ErrorList{field.Invalid(field.NewPath("foo"), "bar", "match error")} + errDefault := field.ErrorList{field.Invalid(field.NewPath("foo"), "bar", "default error")} + + mockErrorMatch := func(_ context.Context, _ operation.Operation, _ *field.Path, _, _ *string) field.ErrorList { + return errMatch + } + mockErrorDefault := func(_ context.Context, _ operation.Operation, _ *field.Path, _, _ *string) field.ErrorList { + return errDefault + } + + mockEqual := func(a, b *string) bool { + return a == b + } + + type BoolP struct { + Val *string + Disc bool + } + newObj := &BoolP{Val: nil, Disc: true} + getVal := func(p *BoolP) *string { return p.Val } + getDisc := func(p *BoolP) bool { return p.Disc } + + got := Discriminated[*string, bool, BoolP](context.Background(), operation.Operation{Type: operation.Create}, field.NewPath("root"), newObj, nil, "field", getVal, getDisc, mockEqual, mockErrorDefault, []DiscriminatedRule[*string, bool]{ + {Value: true, Validation: mockErrorMatch}, + }) + if !reflect.DeepEqual(got, errMatch) { + t.Errorf("bool discriminator: got %v want %v", got, errMatch) + } +} + +// strPtr returns a new pointer to a copy of s, guaranteeing a distinct allocation. +func strPtr(s string) *string { + return &s +} diff --git a/staging/src/k8s.io/apimachinery/pkg/api/validate/mode_test.go b/staging/src/k8s.io/apimachinery/pkg/api/validate/mode_test.go deleted file mode 100644 index 306a4196084..00000000000 --- a/staging/src/k8s.io/apimachinery/pkg/api/validate/mode_test.go +++ /dev/null @@ -1,224 +0,0 @@ -/* -Copyright 2025 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package validate - -import ( - "context" - "reflect" - "testing" - - "k8s.io/apimachinery/pkg/api/operation" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -func TestModal(t *testing.T) { - errMatch := field.ErrorList{field.Invalid(field.NewPath("foo"), "bar", "match error")} - errDefault := field.ErrorList{field.Invalid(field.NewPath("foo"), "bar", "default error")} - - mockValid := func(_ context.Context, _ operation.Operation, _ *field.Path, _, _ *string) field.ErrorList { - return nil - } - mockErrorMatch := func(_ context.Context, _ operation.Operation, _ *field.Path, _, _ *string) field.ErrorList { - return errMatch - } - mockErrorDefault := func(_ context.Context, _ operation.Operation, _ *field.Path, _, _ *string) field.ErrorList { - return errDefault - } - - mockEqual := func(a, b *string) bool { - return a == b - } - - testCases := []struct { - name string - opType operation.Type - discriminator any - oldDiscriminator any - value *string - oldValue *string - rules []ModalRule[*string] - defaultValidation ValidateFunc[*string] - expected field.ErrorList - }{ - { - name: "matches rule, returns valid", - opType: operation.Create, - discriminator: "A", - oldDiscriminator: "A", - rules: []ModalRule[*string]{ - {Value: "A", Validation: mockValid}, - {Value: "B", Validation: mockErrorMatch}, - }, - defaultValidation: mockErrorDefault, - expected: nil, - }, - { - name: "matches rule, returns error", - opType: operation.Create, - discriminator: "B", - oldDiscriminator: "B", - rules: []ModalRule[*string]{ - {Value: "A", Validation: mockValid}, - {Value: "B", Validation: mockErrorMatch}, - }, - defaultValidation: mockErrorDefault, - expected: errMatch, - }, - { - name: "ratcheting: update, unchanged, skips validation", - opType: operation.Update, - discriminator: "B", - oldDiscriminator: "B", // unchanged - value: nil, - oldValue: nil, // unchanged - rules: []ModalRule[*string]{ - {Value: "B", Validation: mockErrorMatch}, // would fail if run - }, - defaultValidation: mockErrorDefault, - expected: nil, - }, - { - name: "ratcheting: update, discriminator changed, runs validation", - opType: operation.Update, - discriminator: "B", - oldDiscriminator: "A", // changed - value: nil, - oldValue: nil, - rules: []ModalRule[*string]{ - {Value: "B", Validation: mockErrorMatch}, - }, - defaultValidation: mockErrorDefault, - expected: errMatch, - }, - { - name: "matches rule with nil validation, returns valid", - opType: operation.Create, - discriminator: "A", - rules: []ModalRule[*string]{ - {Value: "A", Validation: nil}, - }, - defaultValidation: mockErrorDefault, - expected: nil, - }, - { - name: "no match, runs default", - opType: operation.Create, - discriminator: "C", - rules: []ModalRule[*string]{ - {Value: "A", Validation: mockValid}, - {Value: "B", Validation: mockErrorMatch}, - }, - defaultValidation: mockErrorDefault, - expected: errDefault, - }, - { - name: "no match, nil default, returns valid", - opType: operation.Create, - discriminator: "C", - rules: []ModalRule[*string]{ - {Value: "A", Validation: mockValid}, - }, - defaultValidation: nil, - expected: nil, - }, - { - name: "int discriminator matches", - opType: operation.Create, - discriminator: 1, - rules: []ModalRule[*string]{ - {Value: "1", Validation: mockErrorMatch}, - }, - defaultValidation: mockErrorDefault, - expected: errMatch, - }, - { - name: "bool discriminator matches", - opType: operation.Create, - discriminator: true, - rules: []ModalRule[*string]{ - {Value: "true", Validation: mockErrorMatch}, - }, - defaultValidation: mockErrorDefault, - expected: errMatch, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // Handle nil oldDiscriminator for tests that don't specify it - oldDisc := tc.oldDiscriminator - if oldDisc == nil { - oldDisc = tc.discriminator - } - - // We need separate Modal calls because D is different per test case - var got field.ErrorList - - switch d := tc.discriminator.(type) { - case int: - od, _ := oldDisc.(int) - type IntP struct { - Val *string - Disc int - } - newObj := &IntP{Val: tc.value, Disc: d} - var oldObj *IntP - if tc.opType == operation.Update { - oldObj = &IntP{Val: tc.oldValue, Disc: od} - } - getVal := func(p *IntP) *string { return p.Val } - getDisc := func(p *IntP) int { return p.Disc } - - got = Modal[*string, int, IntP](context.Background(), operation.Operation{Type: tc.opType}, field.NewPath("root"), newObj, oldObj, "field", getVal, getDisc, mockEqual, tc.defaultValidation, tc.rules) - - case bool: - od, _ := oldDisc.(bool) - type BoolP struct { - Val *string - Disc bool - } - newObj := &BoolP{Val: tc.value, Disc: d} - var oldObj *BoolP - if tc.opType == operation.Update { - oldObj = &BoolP{Val: tc.oldValue, Disc: od} - } - getVal := func(p *BoolP) *string { return p.Val } - getDisc := func(p *BoolP) bool { return p.Disc } - got = Modal[*string, bool, BoolP](context.Background(), operation.Operation{Type: tc.opType}, field.NewPath("root"), newObj, oldObj, "field", getVal, getDisc, mockEqual, tc.defaultValidation, tc.rules) - - case string: - od, _ := oldDisc.(string) - type StringP struct { - Val *string - Disc string - } - newObj := &StringP{Val: tc.value, Disc: d} - var oldObj *StringP - if tc.opType == operation.Update { - oldObj = &StringP{Val: tc.oldValue, Disc: od} - } - getVal := func(p *StringP) *string { return p.Val } - getDisc := func(p *StringP) string { return p.Disc } - got = Modal[*string, string, StringP](context.Background(), operation.Operation{Type: tc.opType}, field.NewPath("root"), newObj, oldObj, "field", getVal, getDisc, mockEqual, tc.defaultValidation, tc.rules) - } - - if !reflect.DeepEqual(got, tc.expected) { - t.Errorf("got %v want %v", got, tc.expected) - } - }) - } -} diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/doc.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/discriminator/doc.go similarity index 99% rename from staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/doc.go rename to staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/discriminator/doc.go index f03cae372bf..389ec9d0221 100644 --- a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/doc.go +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/discriminator/doc.go @@ -18,7 +18,7 @@ limitations under the License. // +k8s:validation-gen-scheme-registry=k8s.io/code-generator/cmd/validation-gen/testscheme.Scheme // This is a test package. -package mode +package discriminator import "k8s.io/code-generator/cmd/validation-gen/testscheme" diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/doc_test.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/discriminator/doc_test.go similarity index 99% rename from staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/doc_test.go rename to staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/discriminator/doc_test.go index 9c4bb9eaca7..ff66c24ed47 100644 --- a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/doc_test.go +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/discriminator/doc_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package mode +package discriminator import ( "testing" diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/zz_generated.validations.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/discriminator/zz_generated.validations.go similarity index 69% rename from staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/zz_generated.validations.go rename to staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/discriminator/zz_generated.validations.go index 16c7659ca4d..30890c73d47 100644 --- a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/mode/zz_generated.validations.go +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/discriminator/zz_generated.validations.go @@ -19,7 +19,7 @@ limitations under the License. // Code generated by validation-gen. DO NOT EDIT. -package mode +package discriminator import ( context "context" @@ -99,14 +99,13 @@ func RegisterValidations(scheme *testscheme.Scheme) error { // Validate_ChainedValidation validates an instance of ChainedValidation according // to declarative validation rules in the API schema. func Validate_ChainedValidation(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *ChainedValidation) (errs field.ErrorList) { - errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *ChainedValidation) *string { return obj.FieldA }, func(obj *ChainedValidation) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs = append(errs, validate.Discriminated(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *ChainedValidation) *string { return obj.FieldA }, func(obj *ChainedValidation) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) return errs - }, []validate.ModalRule[*string]{ + }, []validate.DiscriminatedRule[*string, string]{ { - Value: "A", - Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + Value: "A", Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} earlyReturn := false if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { @@ -118,8 +117,7 @@ func Validate_ChainedValidation(ctx context.Context, op operation.Operation, fld } errs = append(errs, validate.MaxLength(ctx, op, fldPath, obj, oldObj, 5)...) return errs - }, - }, + }}, })...) // field ChainedValidation.TypeMeta has no validation @@ -131,14 +129,13 @@ func Validate_ChainedValidation(ctx context.Context, op operation.Operation, fld // Validate_Collections validates an instance of Collections according // to declarative validation rules in the API schema. func Validate_Collections(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *Collections) (errs field.ErrorList) { - errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "listField", func(obj *Collections) []string { return obj.ListField }, func(obj *Collections) string { return obj.D1 }, validate.SemanticDeepEqual, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj []string) field.ErrorList { + errs = append(errs, validate.Discriminated(ctx, op, fldPath, obj, oldObj, "listField", func(obj *Collections) []string { return obj.ListField }, func(obj *Collections) string { return obj.D1 }, validate.SemanticDeepEqual, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj []string) field.ErrorList { errs := field.ErrorList{} errs = append(errs, validate.ForbiddenSlice(ctx, op, fldPath, obj, oldObj)...) return errs - }, []validate.ModalRule[[]string]{ + }, []validate.DiscriminatedRule[[]string, string]{ { - Value: "A", - Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj []string) field.ErrorList { + Value: "A", Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj []string) field.ErrorList { errs := field.ErrorList{} earlyReturn := false if e := validate.OptionalSlice(ctx, op, fldPath, obj, oldObj); len(e) != 0 { @@ -148,17 +145,15 @@ func Validate_Collections(ctx context.Context, op operation.Operation, fldPath * return errs } return errs - }, - }, + }}, })...) - errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "mapField", func(obj *Collections) map[string]string { return obj.MapField }, func(obj *Collections) string { return obj.D1 }, validate.SemanticDeepEqual, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj map[string]string) field.ErrorList { + errs = append(errs, validate.Discriminated(ctx, op, fldPath, obj, oldObj, "mapField", func(obj *Collections) map[string]string { return obj.MapField }, func(obj *Collections) string { return obj.D1 }, validate.SemanticDeepEqual, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj map[string]string) field.ErrorList { errs := field.ErrorList{} errs = append(errs, validate.ForbiddenMap(ctx, op, fldPath, obj, oldObj)...) return errs - }, []validate.ModalRule[map[string]string]{ + }, []validate.DiscriminatedRule[map[string]string, string]{ { - Value: "A", - Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj map[string]string) field.ErrorList { + Value: "A", Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj map[string]string) field.ErrorList { errs := field.ErrorList{} earlyReturn := false if e := validate.OptionalMap(ctx, op, fldPath, obj, oldObj); len(e) != 0 { @@ -168,8 +163,7 @@ func Validate_Collections(ctx context.Context, op operation.Operation, fldPath * return errs } return errs - }, - }, + }}, })...) // field Collections.TypeMeta has no validation @@ -182,14 +176,13 @@ func Validate_Collections(ctx context.Context, op operation.Operation, fldPath * // Validate_ImplicitForbidden validates an instance of ImplicitForbidden according // to declarative validation rules in the API schema. func Validate_ImplicitForbidden(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *ImplicitForbidden) (errs field.ErrorList) { - errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *ImplicitForbidden) *string { return obj.FieldA }, func(obj *ImplicitForbidden) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs = append(errs, validate.Discriminated(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *ImplicitForbidden) *string { return obj.FieldA }, func(obj *ImplicitForbidden) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) return errs - }, []validate.ModalRule[*string]{ + }, []validate.DiscriminatedRule[*string, string]{ { - Value: "A", - Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + Value: "A", Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} earlyReturn := false if e := validate.OptionalPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { @@ -199,8 +192,7 @@ func Validate_ImplicitForbidden(ctx context.Context, op operation.Operation, fld return errs } return errs - }, - }, + }}, })...) // field ImplicitForbidden.TypeMeta has no validation @@ -212,14 +204,13 @@ func Validate_ImplicitForbidden(ctx context.Context, op operation.Operation, fld // Validate_MultipleDiscriminators validates an instance of MultipleDiscriminators according // to declarative validation rules in the API schema. func Validate_MultipleDiscriminators(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *MultipleDiscriminators) (errs field.ErrorList) { - errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *MultipleDiscriminators) *string { return obj.FieldA }, func(obj *MultipleDiscriminators) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs = append(errs, validate.Discriminated(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *MultipleDiscriminators) *string { return obj.FieldA }, func(obj *MultipleDiscriminators) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) return errs - }, []validate.ModalRule[*string]{ + }, []validate.DiscriminatedRule[*string, string]{ { - Value: "A", - Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + Value: "A", Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} earlyReturn := false if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { @@ -230,17 +221,15 @@ func Validate_MultipleDiscriminators(ctx context.Context, op operation.Operation return errs } return errs - }, - }, + }}, })...) - errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldB", func(obj *MultipleDiscriminators) *string { return obj.FieldB }, func(obj *MultipleDiscriminators) string { return obj.D2 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs = append(errs, validate.Discriminated(ctx, op, fldPath, obj, oldObj, "fieldB", func(obj *MultipleDiscriminators) *string { return obj.FieldB }, func(obj *MultipleDiscriminators) string { return obj.D2 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) return errs - }, []validate.ModalRule[*string]{ + }, []validate.DiscriminatedRule[*string, string]{ { - Value: "B", - Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + Value: "B", Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} earlyReturn := false if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { @@ -251,8 +240,7 @@ func Validate_MultipleDiscriminators(ctx context.Context, op operation.Operation return errs } return errs - }, - }, + }}, })...) // field MultipleDiscriminators.TypeMeta has no validation @@ -266,14 +254,13 @@ func Validate_MultipleDiscriminators(ctx context.Context, op operation.Operation // Validate_NonStringDiscriminator validates an instance of NonStringDiscriminator according // to declarative validation rules in the API schema. func Validate_NonStringDiscriminator(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *NonStringDiscriminator) (errs field.ErrorList) { - errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *NonStringDiscriminator) *string { return obj.FieldA }, func(obj *NonStringDiscriminator) bool { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs = append(errs, validate.Discriminated(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *NonStringDiscriminator) *string { return obj.FieldA }, func(obj *NonStringDiscriminator) bool { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) return errs - }, []validate.ModalRule[*string]{ + }, []validate.DiscriminatedRule[*string, bool]{ { - Value: "true", - Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + Value: true, Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} earlyReturn := false if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { @@ -284,17 +271,15 @@ func Validate_NonStringDiscriminator(ctx context.Context, op operation.Operation return errs } return errs - }, - }, + }}, })...) - errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldB", func(obj *NonStringDiscriminator) *string { return obj.FieldB }, func(obj *NonStringDiscriminator) int { return obj.D2 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs = append(errs, validate.Discriminated(ctx, op, fldPath, obj, oldObj, "fieldB", func(obj *NonStringDiscriminator) *string { return obj.FieldB }, func(obj *NonStringDiscriminator) int { return obj.D2 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) return errs - }, []validate.ModalRule[*string]{ + }, []validate.DiscriminatedRule[*string, int]{ { - Value: "1", - Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + Value: 1, Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} earlyReturn := false if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { @@ -305,8 +290,7 @@ func Validate_NonStringDiscriminator(ctx context.Context, op operation.Operation return errs } return errs - }, - }, + }}, })...) // field NonStringDiscriminator.TypeMeta has no validation @@ -320,14 +304,13 @@ func Validate_NonStringDiscriminator(ctx context.Context, op operation.Operation // Validate_SharedField validates an instance of SharedField according // to declarative validation rules in the API schema. func Validate_SharedField(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *SharedField) (errs field.ErrorList) { - errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *SharedField) *string { return obj.FieldA }, func(obj *SharedField) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs = append(errs, validate.Discriminated(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *SharedField) *string { return obj.FieldA }, func(obj *SharedField) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) return errs - }, []validate.ModalRule[*string]{ + }, []validate.DiscriminatedRule[*string, string]{ { - Value: "A", - Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + Value: "A", Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} earlyReturn := false if e := validate.OptionalPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { @@ -337,11 +320,9 @@ func Validate_SharedField(ctx context.Context, op operation.Operation, fldPath * return errs } return errs - }, - }, + }}, { - Value: "B", - Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + Value: "B", Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} earlyReturn := false if e := validate.OptionalPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { @@ -351,8 +332,7 @@ func Validate_SharedField(ctx context.Context, op operation.Operation, fldPath * return errs } return errs - }, - }, + }}, })...) // field SharedField.TypeMeta has no validation @@ -364,14 +344,13 @@ func Validate_SharedField(ctx context.Context, op operation.Operation, fldPath * // Validate_StrictUnion validates an instance of StrictUnion according // to declarative validation rules in the API schema. func Validate_StrictUnion(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *StrictUnion) (errs field.ErrorList) { - errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *StrictUnion) *string { return obj.FieldA }, func(obj *StrictUnion) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs = append(errs, validate.Discriminated(ctx, op, fldPath, obj, oldObj, "fieldA", func(obj *StrictUnion) *string { return obj.FieldA }, func(obj *StrictUnion) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) return errs - }, []validate.ModalRule[*string]{ + }, []validate.DiscriminatedRule[*string, string]{ { - Value: "A", - Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + Value: "A", Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} earlyReturn := false if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { @@ -382,17 +361,15 @@ func Validate_StrictUnion(ctx context.Context, op operation.Operation, fldPath * return errs } return errs - }, - }, + }}, })...) - errs = append(errs, validate.Modal(ctx, op, fldPath, obj, oldObj, "fieldB", func(obj *StrictUnion) *string { return obj.FieldB }, func(obj *StrictUnion) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + errs = append(errs, validate.Discriminated(ctx, op, fldPath, obj, oldObj, "fieldB", func(obj *StrictUnion) *string { return obj.FieldB }, func(obj *StrictUnion) string { return obj.D1 }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} errs = append(errs, validate.ForbiddenPointer(ctx, op, fldPath, obj, oldObj)...) return errs - }, []validate.ModalRule[*string]{ + }, []validate.DiscriminatedRule[*string, string]{ { - Value: "B", - Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { + Value: "B", Validation: func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList { errs := field.ErrorList{} earlyReturn := false if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 { @@ -403,8 +380,7 @@ func Validate_StrictUnion(ctx context.Context, op operation.Operation, fldPath * return errs } return errs - }, - }, + }}, })...) // field StrictUnion.TypeMeta has no validation diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/mode.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/discriminator.go similarity index 64% rename from staging/src/k8s.io/code-generator/cmd/validation-gen/validators/mode.go rename to staging/src/k8s.io/code-generator/cmd/validation-gen/validators/discriminator.go index bb032c480f1..3fd42a1ecd7 100644 --- a/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/mode.go +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/discriminator.go @@ -18,7 +18,9 @@ package validators import ( "fmt" + "regexp" "slices" + "strconv" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/code-generator/cmd/validation-gen/util" @@ -32,66 +34,70 @@ const ( memberTagName = "k8s:member" ) +// validGroupNameRegex restricts discriminator group names to identifiers that +// start with a letter and contain only alphanumeric characters and underscores. +var validGroupNameRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`) + func init() { - RegisterTagValidator(&modeTagValidator{modeDefinitions}) - RegisterTagValidator(&memberTagValidator{modeDefinitions, nil}) - RegisterTypeValidator(&modeTypeOrFieldValidator{modeDefinitions}) - RegisterFieldValidator(&modeTypeOrFieldValidator{modeDefinitions}) + RegisterTagValidator(&discriminatorTagValidator{discriminatorDefinitions}) + RegisterTagValidator(&memberTagValidator{discriminatorDefinitions, nil}) + RegisterTypeValidator(&discriminatorFieldValidator{discriminatorDefinitions}) + RegisterFieldValidator(&discriminatorFieldValidator{discriminatorDefinitions}) } -// modeDefinitions stores all mode definitions found by tag validators. +// discriminatorDefinitions stores all discriminator definitions found by tag validators. // Key is the struct path. -var modeDefinitions = map[string]modeGroups{} +var discriminatorDefinitions = map[string]discriminatorGroups{} -type modeGroups map[string]*modeGroup +type discriminatorGroups map[string]*discriminatorGroup -type modeGroup struct { +type discriminatorGroup struct { name string discriminatorMember *types.Member - // members maps field names to their rules in this mode group. - members map[string]*fieldModeRules + // members maps field names to their rules in this discriminator group. + members map[string]*fieldMemberRules } -type fieldModeRules struct { +type fieldMemberRules struct { member *types.Member - rules []modeRule + rules []memberRule } -type modeRule struct { +type memberRule struct { value string validations Validations } -func (mg modeGroups) getOrCreate(name string) *modeGroup { +func (mg discriminatorGroups) getOrCreate(name string) *discriminatorGroup { if name == "" { name = "default" } g, ok := mg[name] if !ok { - g = &modeGroup{ + g = &discriminatorGroup{ name: name, - members: make(map[string]*fieldModeRules), + members: make(map[string]*fieldMemberRules), } mg[name] = g } return g } -type modeTagValidator struct { - shared map[string]modeGroups +type discriminatorTagValidator struct { + shared map[string]discriminatorGroups } -func (mtv *modeTagValidator) Init(_ Config) {} +func (mtv *discriminatorTagValidator) Init(_ Config) {} -func (mtv *modeTagValidator) TagName() string { +func (mtv *discriminatorTagValidator) TagName() string { return discriminatorTagName } -func (mtv *modeTagValidator) ValidScopes() sets.Set[Scope] { +func (mtv *discriminatorTagValidator) ValidScopes() sets.Set[Scope] { return sets.New(ScopeField) } -func (mtv *modeTagValidator) GetValidations(context Context, tag codetags.Tag) (Validations, error) { +func (mtv *discriminatorTagValidator) GetValidations(context Context, tag codetags.Tag) (Validations, error) { if util.NativeType(context.Type).Kind == types.Pointer { return Validations{}, fmt.Errorf("can only be used on non-pointer types") } @@ -101,22 +107,28 @@ func (mtv *modeTagValidator) GetValidations(context Context, tag codetags.Tag) ( } if mtv.shared[context.ParentPath.String()] == nil { - mtv.shared[context.ParentPath.String()] = make(modeGroups) + mtv.shared[context.ParentPath.String()] = make(discriminatorGroups) } - modeName := "" + groupName := "" if nameArg, ok := tag.NamedArg("name"); ok { - modeName = nameArg.Value + groupName = nameArg.Value } - group := mtv.shared[context.ParentPath.String()].getOrCreate(modeName) + if groupName != "" && !validGroupNameRegex.MatchString(groupName) { + return Validations{}, fmt.Errorf("discriminator group name must match %s, got %q", validGroupNameRegex.String(), groupName) + } + if groupName == "default" { + return Validations{}, fmt.Errorf("discriminator group name %q is reserved", groupName) + } + group := mtv.shared[context.ParentPath.String()].getOrCreate(groupName) if group.discriminatorMember != nil && group.discriminatorMember != context.Member { - return Validations{}, fmt.Errorf("duplicate discriminator: %q", modeName) + return Validations{}, fmt.Errorf("duplicate discriminator: %q", groupName) } group.discriminatorMember = context.Member return Validations{}, nil } -func (mtv *modeTagValidator) Docs() TagDoc { +func (mtv *discriminatorTagValidator) Docs() TagDoc { return TagDoc{ Tag: mtv.TagName(), StabilityLevel: TagStabilityLevelAlpha, @@ -132,7 +144,7 @@ func (mtv *modeTagValidator) Docs() TagDoc { } type memberTagValidator struct { - shared map[string]modeGroups + shared map[string]discriminatorGroups validator TagValidationExtractor } @@ -167,9 +179,12 @@ func (mtv *memberTagValidator) GetValidations(context Context, tag codetags.Tag) return Validations{}, fmt.Errorf("unsupported payload tag: %q", tag.ValueTag.Name) } - modeName := "" + groupName := "" if modeArg, ok := tag.NamedArg("discriminator"); ok { - modeName = modeArg.Value + groupName = modeArg.Value + } + if groupName == "default" { + return Validations{}, fmt.Errorf("discriminator group name %q is reserved", groupName) } value := "" @@ -183,9 +198,9 @@ func (mtv *memberTagValidator) GetValidations(context Context, tag codetags.Tag) } if mtv.shared[context.ParentPath.String()] == nil { - mtv.shared[context.ParentPath.String()] = make(modeGroups) + mtv.shared[context.ParentPath.String()] = make(discriminatorGroups) } - group := mtv.shared[context.ParentPath.String()].getOrCreate(modeName) + group := mtv.shared[context.ParentPath.String()].getOrCreate(groupName) fieldName := context.Member.Name if rules, ok := group.members[fieldName]; ok { @@ -193,7 +208,7 @@ func (mtv *memberTagValidator) GetValidations(context Context, tag codetags.Tag) return Validations{}, fmt.Errorf("internal error: member mismatch for field %q", fieldName) } } else { - group.members[fieldName] = &fieldModeRules{ + group.members[fieldName] = &fieldMemberRules{ member: context.Member, } } @@ -203,7 +218,7 @@ func (mtv *memberTagValidator) GetValidations(context Context, tag codetags.Tag) return Validations{}, err } - group.members[fieldName].rules = append(group.members[fieldName].rules, modeRule{ + group.members[fieldName].rules = append(group.members[fieldName].rules, memberRule{ value: value, validations: payloadValidations, }) @@ -238,17 +253,17 @@ func (mtv *memberTagValidator) Docs() TagDoc { } } -type modeTypeOrFieldValidator struct { - shared map[string]modeGroups +type discriminatorFieldValidator struct { + shared map[string]discriminatorGroups } -func (modeTypeOrFieldValidator) Init(_ Config) {} +func (discriminatorFieldValidator) Init(_ Config) {} -func (modeTypeOrFieldValidator) Name() string { - return "modeTypeOrFieldValidator" +func (discriminatorFieldValidator) Name() string { + return "discriminatorFieldValidator" } -func (mtfv *modeTypeOrFieldValidator) GetValidations(context Context) (Validations, error) { +func (mtfv *discriminatorFieldValidator) GetValidations(context Context) (Validations, error) { // Extract the most concrete type possible. if k := util.NonPointer(util.NativeType(context.Type)).Kind; k != types.Struct { return Validations{}, nil @@ -288,7 +303,7 @@ func (mtfv *modeTypeOrFieldValidator) GetValidations(context Context) (Validatio for _, fn := range fieldNames { rules := group.members[fn] - v, err := mtfv.generateModeFieldValidation(context, group, rules) + v, err := mtfv.generateMemberFieldValidation(context, group, rules) if err != nil { return Validations{}, err } @@ -299,7 +314,7 @@ func (mtfv *modeTypeOrFieldValidator) GetValidations(context Context) (Validatio return result, nil } -func (mtfv *modeTypeOrFieldValidator) generateModeFieldValidation(context Context, group *modeGroup, rules *fieldModeRules) (Validations, error) { +func (mtfv *discriminatorFieldValidator) generateMemberFieldValidation(context Context, group *discriminatorGroup, rules *fieldMemberRules) (Validations, error) { fieldType := rules.member.Type // Use the nilable form to handle missing values. @@ -322,7 +337,7 @@ func (mtfv *modeTypeOrFieldValidator) generateModeFieldValidation(context Contex return Validations{}, err } - // Prepare ModalRules + // Prepare DiscriminatedRules // Aggregate rules by value rulesByValue := make(map[string]Validations) var values []string @@ -336,7 +351,9 @@ func (mtfv *modeTypeOrFieldValidator) generateModeFieldValidation(context Contex } slices.Sort(values) - var modalRules []any + discriminatorType := group.discriminatorMember.Type + + var discriminatedRules []any for _, val := range values { ruleValidations := rulesByValue[val] @@ -345,22 +362,29 @@ func (mtfv *modeTypeOrFieldValidator) generateModeFieldValidation(context Contex ObjType: nilableFieldType, } - modalRules = append(modalRules, StructLiteral{ - Type: types.Name{Package: libValidationPkg, Name: "ModalRule"}, - TypeArgs: []*types.Type{nilableFieldType}, + // Convert the string tag value to the appropriate typed Go literal + // for the discriminator type. + typedValue, err := convertDiscriminatorValue(val, discriminatorType) + if err != nil { + return Validations{}, fmt.Errorf("invalid discriminator value %q: %w", val, err) + } + + discriminatedRules = append(discriminatedRules, StructLiteral{ + Type: types.Name{Package: libValidationPkg, Name: "DiscriminatedRule"}, + TypeArgs: []*types.Type{nilableFieldType, discriminatorType}, Fields: []StructLiteralField{ - {Name: "Value", Value: val}, + {Name: "Value", Value: typedValue}, {Name: "Validation", Value: wrapper}, }, }) } - modalValidator := types.Name{Package: libValidationPkg, Name: "Modal"} + discriminatedValidator := types.Name{Package: libValidationPkg, Name: "Discriminated"} rulesSlice := SliceLiteral{ - ElementType: types.Name{Package: libValidationPkg, Name: "ModalRule"}, - ElementTypeArgs: []*types.Type{nilableFieldType}, - Elements: modalRules, + ElementType: types.Name{Package: libValidationPkg, Name: "DiscriminatedRule"}, + ElementTypeArgs: []*types.Type{nilableFieldType, discriminatorType}, + Elements: discriminatedRules, } // getValue extractor @@ -371,14 +395,15 @@ func (mtfv *modeTypeOrFieldValidator) generateModeFieldValidation(context Contex } // getDiscriminator extractor - discriminatorType := group.discriminatorMember.Type getDiscriminator := FunctionLiteral{ Parameters: []ParamResult{{Name: "obj", Type: types.PointerTo(context.Type)}}, Results: []ParamResult{{Type: discriminatorType}}, Body: fmt.Sprintf("return obj.%s", group.discriminatorMember.Name), } - // Calculate equivArg for ratcheting the value + // directComparable is used to determine whether we can use the direct + // comparison operator "==" or need to use the semantic DeepEqual when + // looking up and comparing correlated list elements for validation ratcheting. var equivArg any if util.IsDirectComparable(util.NonPointer(util.NativeType(fieldType))) { equivArg = Identifier(validateDirectEqualPtr) @@ -386,7 +411,7 @@ func (mtfv *modeTypeOrFieldValidator) generateModeFieldValidation(context Contex equivArg = Identifier(validateSemanticDeepEqual) } - fn := Function(discriminatorTagName, DefaultFlags, modalValidator, + fn := Function(discriminatorTagName, DefaultFlags, discriminatedValidator, Literal(fmt.Sprintf("%q", jsonName)), getValue, getDiscriminator, @@ -398,7 +423,7 @@ func (mtfv *modeTypeOrFieldValidator) generateModeFieldValidation(context Contex return Validations{Functions: []FunctionGen{fn}}, nil } -func (mtfv *modeTypeOrFieldValidator) getForbiddenValidation(t *types.Type) (any, error) { +func (mtfv *discriminatorFieldValidator) getForbiddenValidation(t *types.Type) (any, error) { var forbidden types.Name nt := util.NativeType(t) switch nt.Kind { @@ -409,7 +434,7 @@ func (mtfv *modeTypeOrFieldValidator) getForbiddenValidation(t *types.Type) (any case types.Pointer: forbidden = types.Name{Package: libValidationPkg, Name: "ForbiddenPointer"} case types.Struct: - return nil, fmt.Errorf("modal member fields of struct type must be pointers") + return nil, fmt.Errorf("discriminated member fields of struct type must be pointers") default: forbidden = types.Name{Package: libValidationPkg, Name: "ForbiddenValue"} } @@ -427,3 +452,32 @@ func (mtfv *modeTypeOrFieldValidator) getForbiddenValidation(t *types.Type) (any ObjType: wrapperObjType, }, nil } + +// convertDiscriminatorValue converts a string tag value to the appropriate +// typed Go literal for the given discriminator type. +func convertDiscriminatorValue(val string, discType *types.Type) (any, error) { + nt := util.NonPointer(util.NativeType(discType)) + if nt.Kind != types.Builtin { + return nil, fmt.Errorf("unsupported discriminator type: %s", nt.Name.Name) + } + + switch nt.Name.Name { + case "string": + return val, nil + case "bool": + b, err := strconv.ParseBool(val) + if err != nil { + return nil, fmt.Errorf("cannot parse %q as bool: %w", val, err) + } + return b, nil + default: + if types.IsInteger(nt) { + i, err := strconv.ParseInt(val, 10, 64) + if err != nil { + return nil, fmt.Errorf("cannot parse %q as integer: %w", val, err) + } + return int(i), nil + } + return nil, fmt.Errorf("unsupported discriminator type: %s", nt.Name.Name) + } +}