diff --git a/pkg/govy/example_test.go b/pkg/govy/example_test.go index 68d4502..4a9f1b4 100644 --- a/pkg/govy/example_test.go +++ b/pkg/govy/example_test.go @@ -1966,6 +1966,25 @@ func ExampleValidator_RemovePropertiesByPath() { // Modified validator passed } +// RemovePropertiesByID selects property rules by IDs set with [govy.PropertyRules.WithID]. +// It traverses included validators and leaves the original validator unchanged. +func ExampleValidator_RemovePropertiesByID() { + ageProperty := govy.For(func(t Teacher) time.Duration { return t.Age }). + WithName("age"). + WithID("age"). + Rules(rules.GT(time.Duration(0))) + baseValidator := govy.New(ageProperty) + modifiedValidator := baseValidator.RemovePropertiesByID("age") + teacher := Teacher{Age: -1} + + fmt.Println(baseValidator.Validate(teacher) != nil) + fmt.Println(modifiedValidator.Validate(teacher) != nil) + + // Output: + // true + // false +} + // In the interactive tutorial for govy, we've been using // [govy.PropertyRules.WithName] to provide explicit path segments for our properties. // @@ -2048,7 +2067,7 @@ func ExampleInferPathModeGenerate() { govyconfig.SetInferredPath(govyconfig.InferredPath{ Path: jsonpath.New().Name("name"), File: "pkg/govy/example_test.go", - Line: 2055, + Line: 2074, }) v2 := govy.New( diff --git a/pkg/govy/rules.go b/pkg/govy/rules.go index 5e59db9..0f053b9 100644 --- a/pkg/govy/rules.go +++ b/pkg/govy/rules.go @@ -102,6 +102,7 @@ func (emptyErr) Error() string { return "" } // It is the middle-level building block of the validation process, // aggregated by [Validator] and aggregating [Rule]. type PropertyRules[T, P any] struct { + id string path jsonpath.Path pathFunc inferPathFunc getter internalPropertyGetter[T, P] @@ -191,6 +192,14 @@ func (r PropertyRules[T, P]) WithPath(path jsonpath.Path) PropertyRules[T, P] { return r } +// WithID sets an identifier for these property rules. +// It can be used with [Validator.RemovePropertiesByID]. +// An empty identifier leaves the property without an identifier. +func (r PropertyRules[T, P]) WithID(id string) PropertyRules[T, P] { + r.id = id + return r +} + // WithExamples sets the examples for the property. func (r PropertyRules[T, P]) WithExamples(examples ...string) PropertyRules[T, P] { r.examples = append(r.examples, examples...) @@ -289,6 +298,26 @@ func (r PropertyRules[T, P]) inferPathModeInternal(mode InferPathMode) PropertyR return r.InferPath(mode) } +func (r PropertyRules[T, P]) propertyID() string { + return r.id +} + +func (r PropertyRules[T, P]) removePropertiesByID(ids []string) PropertyRulesInterface[P] { + return r.removePropertiesByIDFromIncludes(ids) +} + +func (r PropertyRules[T, P]) removePropertiesByIDFromIncludes(ids []string) PropertyRules[T, P] { + rules := make([]validationInterface[T], 0, len(r.rules)) + for _, rule := range r.rules { + if validator, ok := rule.(ValidatorInterface[T]); ok { + rule = validator.removePropertiesByID(ids) + } + rules = append(rules, rule) + } + r.rules = rules + return r +} + // plan constructs a validation plan for the property. func (r PropertyRules[T, P]) plan(builder planBuilder) { vOpts := newValidationOptions(r.validationOptions...) diff --git a/pkg/govy/rules_for_map.go b/pkg/govy/rules_for_map.go index 02f60bf..a2a78d5 100644 --- a/pkg/govy/rules_for_map.go +++ b/pkg/govy/rules_for_map.go @@ -118,6 +118,12 @@ func (r PropertyRulesForMap[M, K, V, P]) WithPath(path jsonpath.Path) PropertyRu return r } +// WithID => refer to [PropertyRules.WithID] documentation. +func (r PropertyRulesForMap[M, K, V, P]) WithID(id string) PropertyRulesForMap[M, K, V, P] { + r.mapRules = r.mapRules.WithID(id) + return r +} + // WithExamples => refer to [PropertyRules.WithExamples] documentation. func (r PropertyRulesForMap[M, K, V, P]) WithExamples(examples ...string) PropertyRulesForMap[M, K, V, P] { r.mapRules = r.mapRules.WithExamples(examples...) @@ -250,6 +256,18 @@ func (r PropertyRulesForMap[M, K, V, P]) inferPathModeInternal(mode InferPathMod return r.InferPath(mode) } +func (r PropertyRulesForMap[M, K, V, P]) propertyID() string { + return r.mapRules.propertyID() +} + +func (r PropertyRulesForMap[M, K, V, P]) removePropertiesByID(ids []string) PropertyRulesInterface[P] { + r.mapRules = r.mapRules.removePropertiesByIDFromIncludes(ids) + r.forKeyRules = r.forKeyRules.removePropertiesByIDFromIncludes(ids) + r.forValueRules = r.forValueRules.removePropertiesByIDFromIncludes(ids) + r.forItemRules = r.forItemRules.removePropertiesByIDFromIncludes(ids) + return r +} + // plan constructs a validation plan for the property rules. func (r PropertyRulesForMap[M, K, V, P]) plan(builder planBuilder) { builder = appendPredicatesToPlanBuilder(builder, r.predicates) diff --git a/pkg/govy/rules_for_slice.go b/pkg/govy/rules_for_slice.go index 53b11fe..bedf3c0 100644 --- a/pkg/govy/rules_for_slice.go +++ b/pkg/govy/rules_for_slice.go @@ -78,6 +78,12 @@ func (r PropertyRulesForSlice[S, T, P]) WithPath(path jsonpath.Path) PropertyRul return r } +// WithID => refer to [PropertyRules.WithID] documentation. +func (r PropertyRulesForSlice[S, T, P]) WithID(id string) PropertyRulesForSlice[S, T, P] { + r.sliceRules = r.sliceRules.WithID(id) + return r +} + // WithExamples => refer to [PropertyRules.WithExamples] documentation. func (r PropertyRulesForSlice[S, T, P]) WithExamples(examples ...string) PropertyRulesForSlice[S, T, P] { r.sliceRules = r.sliceRules.WithExamples(examples...) @@ -164,6 +170,16 @@ func (r PropertyRulesForSlice[S, T, P]) inferPathModeInternal(mode InferPathMode return r.InferPath(mode) } +func (r PropertyRulesForSlice[S, T, P]) propertyID() string { + return r.sliceRules.propertyID() +} + +func (r PropertyRulesForSlice[S, T, P]) removePropertiesByID(ids []string) PropertyRulesInterface[P] { + r.sliceRules = r.sliceRules.removePropertiesByIDFromIncludes(ids) + r.forEachRules = r.forEachRules.removePropertiesByIDFromIncludes(ids) + return r +} + // plan generates a validation plan for the property rules. func (r PropertyRulesForSlice[S, T, P]) plan(builder planBuilder) { builder = appendPredicatesToPlanBuilder(builder, r.predicates) diff --git a/pkg/govy/validation.go b/pkg/govy/validation.go index 1af0a66..6d4d798 100644 --- a/pkg/govy/validation.go +++ b/pkg/govy/validation.go @@ -12,6 +12,7 @@ import ( type ValidatorInterface[T any] interface { validationInterface[T] isValidator() + removePropertiesByID(ids []string) ValidatorInterface[T] } // PropertyRulesInterface defines validation entities which describe properties, @@ -26,6 +27,8 @@ type PropertyRulesInterface[T any] interface { getPath() jsonpath.Path inferPathModeInternal(mode InferPathMode) PropertyRulesInterface[T] isPropertyRules() + propertyID() string + removePropertiesByID(ids []string) PropertyRulesInterface[T] } // RulesInterface defines validation entities on the validation rule level, diff --git a/pkg/govy/validator.go b/pkg/govy/validator.go index 1f74a18..5ba9c3d 100644 --- a/pkg/govy/validator.go +++ b/pkg/govy/validator.go @@ -80,6 +80,31 @@ func (v Validator[T]) RemovePropertiesByPath(paths ...jsonpath.Path) Validator[T return v } +// RemovePropertiesByID recursively removes every property whose identifier +// matches one of the provided identifiers, including properties of included validators. +// Properties without an identifier and empty identifiers passed to this method are ignored. +// It returns a modified [Validator] without the matching properties. +// The original [Validator] is not changed. +func (v Validator[T]) RemovePropertiesByID(ids ...string) Validator[T] { + if len(ids) == 0 { + return v + } + filtered := make([]PropertyRulesInterface[T], 0, len(v.props)) + for _, prop := range v.props { + id := prop.propertyID() + if id != "" && slices.Contains(ids, id) { + continue + } + filtered = append(filtered, prop.removePropertiesByID(ids)) + } + v.props = filtered + return v +} + +func (v Validator[T]) removePropertiesByID(ids []string) ValidatorInterface[T] { + return v.RemovePropertiesByID(ids...) +} + // InferPath sets the [InferPathMode] for the validator, // which controls relative property path inference for validation rules. func (v Validator[T]) InferPath(mode InferPathMode) Validator[T] { diff --git a/pkg/govy/validator_test.go b/pkg/govy/validator_test.go index 04b3b47..31ccee9 100644 --- a/pkg/govy/validator_test.go +++ b/pkg/govy/validator_test.go @@ -631,6 +631,110 @@ func TestValidatorInferPath(t *testing.T) { }) } +func TestValidatorRemovePropertiesByID(t *testing.T) { + newProperty := func(path, id string) govy.PropertyRules[string, mockValidatorStruct] { + return govy.For(func(mockValidatorStruct) string { return path }). + WithName(path). + WithID(id). + Rules(govy.NewRule(func(string) error { return errors.New(path) })) + } + base := govy.New( + newProperty("first", "remove"), + newProperty("kept", "keep"), + newProperty("second", "remove"), + newProperty("other", "other"), + newProperty("unset", ""), + ) + + t.Run("removes every matching direct property", func(t *testing.T) { + filtered := base.RemovePropertiesByID("remove", "other", "") + filteredErr := mustValidatorError(t, filtered.Validate(mockValidatorStruct{})) + assert.Require(t, assert.Len(t, filteredErr.Errors, 2)) + assert.Equal(t, jsonpath.Parse("kept"), filteredErr.Errors[0].PropertyPath) + assert.Equal(t, jsonpath.Parse("unset"), filteredErr.Errors[1].PropertyPath) + + originalErr := mustValidatorError(t, base.Validate(mockValidatorStruct{})) + assert.Len(t, originalErr.Errors, 5) + }) + + t.Run("ignores empty and unknown IDs", func(t *testing.T) { + emptyIDErr := mustValidatorError(t, base.RemovePropertiesByID("", "missing").Validate(mockValidatorStruct{})) + assert.Len(t, emptyIDErr.Errors, 5) + + noIDErr := mustValidatorError(t, base.RemovePropertiesByID().Validate(mockValidatorStruct{})) + assert.Len(t, noIDErr.Errors, 5) + }) + + t.Run("traverses included validators", func(t *testing.T) { + type leaf struct { + Value string + } + type child struct { + Leaf leaf + } + type parent struct { + Children map[string][]child + } + + leafValidator := govy.New( + govy.For(func(value leaf) string { return value.Value }). + WithName("value"). + WithID("inner"). + Rules(rules.EQ("expected")), + ) + childValidator := govy.New( + govy.For(func(value child) leaf { return value.Leaf }). + WithName("leaf"). + Include(leafValidator), + ) + sliceValidator := govy.New( + govy.ForSlice(govy.GetSelf[[]child]()). + IncludeForEach(childValidator), + ) + parentValidator := govy.New( + govy.ForMap(func(value parent) map[string][]child { return value.Children }). + WithName("children"). + IncludeForValues(sliceValidator), + ) + value := parent{ + Children: map[string][]child{ + "first": {{Leaf: leaf{Value: "actual"}}}, + }, + } + + filtered := parentValidator.RemovePropertiesByID("inner") + assert.Error(t, parentValidator.Validate(value)) + assert.NoError(t, filtered.Validate(value)) + }) + + t.Run("supports scalar, slice, and map properties", func(t *testing.T) { + type value struct { + Scalar string + Slice []string + Map map[string]string + } + validator := govy.New( + govy.For(func(value value) string { return value.Scalar }). + WithName("scalar"). + WithID("scalar"). + Rules(govy.NewRule(func(string) error { return errors.New("scalar") })), + govy.ForSlice(func(value value) []string { return value.Slice }). + WithName("slice"). + WithID("slice"). + Rules(govy.NewRule(func([]string) error { return errors.New("slice") })), + govy.ForMap(func(value value) map[string]string { return value.Map }). + WithName("map"). + WithID("map"). + Rules(govy.NewRule(func(map[string]string) error { return errors.New("map") })), + ). + Cascade(govy.CascadeModeContinue). + InferPath(govy.InferPathModeDisable) + + assert.Error(t, validator.Validate(value{})) + assert.NoError(t, validator.RemovePropertiesByID("scalar", "slice", "map").Validate(value{})) + }) +} + func mustValidatorError(t *testing.T, err error) *govy.ValidatorError { t.Helper() return mustErrorType[*govy.ValidatorError](t, err)