From 6d7bb585689f49e8bfd154bc7a5d5e80fe847ca3 Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Wed, 12 Nov 2025 20:08:19 +0100 Subject: [PATCH 1/5] feat: update golangci-lint version and add UUID generation utility - Updated `golangci-lint` from version 2.5.0 to 2.6.1 in `devbox.json` and `devbox.lock`. - Introduced a new `uuid` package for generating UUIDs. The `GenerateUUID` function creates UUIDs compliant with version 4 specifications. - Added comprehensive unit tests for the `uuid` package to ensure correctness, including format validation, uniqueness, length checks, and bit correctness. - Enhanced the `govy` package with new features: - Added `instanceID` for unique identification of `Validator` and `PropertyRules` instances. - Introduced `WithID` and `GetID` methods for `Validator` and `PropertyRules` to manage and retrieve identifiers. - Implemented `RemoveProperties` in `Validator` to allow selective removal of properties or nested validators. - Updated benchmarks in `rules` package to use `b.Loop()` for improved readability and consistency. - Added new examples and tests to demonstrate and validate the usage of `RemoveProperties` and `GetID` functionalities. --- devbox.json | 2 +- devbox.lock | 24 ++-- internal/uuid/uuid.go | 17 +++ internal/uuid/uuid_test.go | 54 +++++++++ pkg/govy/example_test.go | 158 +++++++++++++++++++++++++ pkg/govy/identifier.go | 32 +++++ pkg/govy/rules.go | 20 ++++ pkg/govy/rules_for_map.go | 5 + pkg/govy/rules_for_slice.go | 5 + pkg/govy/validation.go | 1 + pkg/govy/validator.go | 60 +++++++++- pkg/govy/validator_test.go | 229 ++++++++++++++++++++++++++++++++++++ pkg/rules/string_test.go | 80 ++++++------- 13 files changed, 633 insertions(+), 54 deletions(-) create mode 100644 internal/uuid/uuid.go create mode 100644 internal/uuid/uuid_test.go create mode 100644 pkg/govy/identifier.go diff --git a/devbox.json b/devbox.json index d1d019b..69d2648 100644 --- a/devbox.json +++ b/devbox.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/refs/heads/main/.schema/devbox.schema.json", "packages": [ "govulncheck@1.1.4", - "golangci-lint@2.5.0", + "golangci-lint@2.6.1", "gosec@2.22.10", "go@1.25.2", "gotools@0.34.0", diff --git a/devbox.lock b/devbox.lock index a2a8c13..89fa544 100644 --- a/devbox.lock +++ b/devbox.lock @@ -97,51 +97,51 @@ } } }, - "golangci-lint@2.5.0": { - "last_modified": "2025-10-07T08:41:47Z", - "resolved": "github:NixOS/nixpkgs/bce5fe2bb998488d8e7e7856315f90496723793c#golangci-lint", + "golangci-lint@2.6.1": { + "last_modified": "2025-11-07T02:32:13Z", + "resolved": "github:NixOS/nixpkgs/e1ebeec86b771e9d387dd02d82ffdc77ac753abc#golangci-lint", "source": "devbox-search", - "version": "2.5.0", + "version": "2.6.1", "systems": { "aarch64-darwin": { "outputs": [ { "name": "out", - "path": "/nix/store/15bzlcc1h11sv9vwawrnfcw0avn3nhlw-golangci-lint-2.5.0", + "path": "/nix/store/38zwfr9vb8lw5a5lbszmqj1ipnwkg81n-golangci-lint-2.6.1", "default": true } ], - "store_path": "/nix/store/15bzlcc1h11sv9vwawrnfcw0avn3nhlw-golangci-lint-2.5.0" + "store_path": "/nix/store/38zwfr9vb8lw5a5lbszmqj1ipnwkg81n-golangci-lint-2.6.1" }, "aarch64-linux": { "outputs": [ { "name": "out", - "path": "/nix/store/60qpwfy5bac8lflw12kffxdhvvylsr4k-golangci-lint-2.5.0", + "path": "/nix/store/qgqzzlmnj51qn1phw8jw34h8d5xlr22b-golangci-lint-2.6.1", "default": true } ], - "store_path": "/nix/store/60qpwfy5bac8lflw12kffxdhvvylsr4k-golangci-lint-2.5.0" + "store_path": "/nix/store/qgqzzlmnj51qn1phw8jw34h8d5xlr22b-golangci-lint-2.6.1" }, "x86_64-darwin": { "outputs": [ { "name": "out", - "path": "/nix/store/laxn1i91p32i319i5ixslxz4bl451hvq-golangci-lint-2.5.0", + "path": "/nix/store/lrl9hfxqw1i5ggn0lwwp2gvvnpvf3iwy-golangci-lint-2.6.1", "default": true } ], - "store_path": "/nix/store/laxn1i91p32i319i5ixslxz4bl451hvq-golangci-lint-2.5.0" + "store_path": "/nix/store/lrl9hfxqw1i5ggn0lwwp2gvvnpvf3iwy-golangci-lint-2.6.1" }, "x86_64-linux": { "outputs": [ { "name": "out", - "path": "/nix/store/s95zr6py9ppj3hpm295mdzs40q59177p-golangci-lint-2.5.0", + "path": "/nix/store/9d2r57v8h6bk378qdx0hixbaim8irn03-golangci-lint-2.6.1", "default": true } ], - "store_path": "/nix/store/s95zr6py9ppj3hpm295mdzs40q59177p-golangci-lint-2.5.0" + "store_path": "/nix/store/9d2r57v8h6bk378qdx0hixbaim8irn03-golangci-lint-2.6.1" } } }, diff --git a/internal/uuid/uuid.go b/internal/uuid/uuid.go new file mode 100644 index 0000000..b01a996 --- /dev/null +++ b/internal/uuid/uuid.go @@ -0,0 +1,17 @@ +package uuid + +import ( + "crypto/rand" + "fmt" +) + +func GenerateUUID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + + // Set version (4) and variant bits + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) +} diff --git a/internal/uuid/uuid_test.go b/internal/uuid/uuid_test.go new file mode 100644 index 0000000..22066b8 --- /dev/null +++ b/internal/uuid/uuid_test.go @@ -0,0 +1,54 @@ +package uuid + +import ( + "regexp" + "testing" + + "github.com/nobl9/govy/internal/assert" +) + +func TestGenerateUUID(t *testing.T) { + t.Run("generates valid UUID format", func(t *testing.T) { + id := GenerateUUID() + + // UUID v4 format: + pattern := `^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$` + matched, err := regexp.MatchString(pattern, id) + assert.NoError(t, err) + assert.True(t, matched) + }) + + t.Run("generates unique IDs", func(t *testing.T) { + ids := make(map[string]bool) + iterations := 1000 + + for range iterations { + id := GenerateUUID() + + assert.False(t, ids[id]) + ids[id] = true + } + + assert.Equal(t, iterations, len(ids)) + }) + + t.Run("has correct length", func(t *testing.T) { + id := GenerateUUID() + + expectedLen := 36 // 32 hex chars + 4 hyphens + assert.Equal(t, expectedLen, len(id)) + }) + + t.Run("has correct version bits", func(t *testing.T) { + id := GenerateUUID() + // Version should be 4 (at position 14) + assert.Equal(t, byte('4'), id[14]) + }) + + t.Run("has correct variant bits", func(t *testing.T) { + id := GenerateUUID() + // Variant should be 8, 9, a, or b (at position 19) + variant := id[19] + assert.True(t, variant == '8' || variant == '9' || variant == 'a' || variant == 'b') + }) +} diff --git a/pkg/govy/example_test.go b/pkg/govy/example_test.go index bc4c3e9..13f1d2b 100644 --- a/pkg/govy/example_test.go +++ b/pkg/govy/example_test.go @@ -1842,3 +1842,161 @@ func ExamplePlan_validation() { // Output: // predicates without description found at: validator level, $.name } + +// Sometimes you want to reuse a validator but exclude certain property rules. +// This is particularly useful when you have a base validator that you want to customize +// for different contexts without duplicating code. +// Use [govy.Validator.RemoveProperties] to filter out specific property rules by their identifiers. +// +// Property identifiers are determined by: +// - User-supplied ID (via [govy.Validator.WithID]) if set +// - Property name (via [govy.PropertyRules.WithName]) if set +// - Generated UUID otherwise +// +// This example demonstrates how to create a base validator for [Teacher] and then +// create specialized versions by removing certain validations. +func ExampleValidator_RemoveProperties() { + universityValidation := govy.New( + govy.For(func(u University) string { return u.Address }). + WithName("address"). + Required(), + ) + + baseTeacherValidator := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Required(). + Rules( + rules.StringNotEmpty(), + rules.OneOf("Jake", "George")), + govy.For(func(t Teacher) time.Duration { return t.Age }). + WithName("age"). + Rules(rules.GT(18*year)), + govy.For(func(t Teacher) University { return t.University }). + WithName("university"). + Include(universityValidation), + ).WithName("Teacher") + + relaxedTeacherValidator := baseTeacherValidator.RemoveProperties("age", "university") + + strictTeacher := Teacher{ + Name: "John", + Age: 17 * year, + University: University{ + Name: "Poznan University of Technology", + Address: "", + }, + } + + relaxedTeacher := Teacher{ + Name: "Jake", + } + + err := baseTeacherValidator.Validate(strictTeacher) + if err != nil { + fmt.Println("Base validator:") + fmt.Println(err) + fmt.Println() + } + + err = relaxedTeacherValidator.Validate(strictTeacher) + if err != nil { + fmt.Println("Relaxed validator with strict data:") + fmt.Println(err) + fmt.Println() + } + + err = relaxedTeacherValidator.Validate(relaxedTeacher) + if err != nil { + fmt.Println("Relaxed validator with relaxed data:") + fmt.Println(err) + } else { + fmt.Println("Relaxed validator passed!") + } + + // Output: + // Base validator: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'John': + // - must be one of: Jake, George + // - 'age' with value '148920h0m0s': + // - should be greater than '157680h0m0s' + // - 'university.address': + // - property is required but was empty + // + // Relaxed validator with strict data: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'John': + // - must be one of: Jake, George + // + // Relaxed validator passed! +} + +// You can retrieve the identifier of a [Validator] or [PropertyRules] using the [Validator.GetID] method. +// This is useful when you need to: +// - Dynamically determine which properties to remove +// - Log or track validation components +// - Build debugging or introspection tools +// +// The [Validator.GetID] and [PropertyRules.GetID] methods follow the same priority resolution: +// 1. User-supplied ID (via [Validator.WithID]) +// 2. Validator/Property name (via [Validator.WithName] or [PropertyRules.WithName]) +// 3. Auto-generated UUID +// +// This example demonstrates how to use GetID to inspect and manipulate validators. +func ExampleValidator_GetID() { + universityValidator := govy.New( + govy.For(func(u University) string { return u.Address }). + WithName("address"). + Required(), + ).WithID("university-validator") + + nameProperty := govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Required() + + ageProperty := govy.For(func(t Teacher) time.Duration { return t.Age }). + WithName("age"). + Rules(rules.GT(18 * year)) + + universityProperty := govy.For(func(t Teacher) University { return t.University }). + WithName("university"). + Include(universityValidator) + + teacherValidator := govy.New( + nameProperty, + ageProperty, + universityProperty, + ).WithName("Teacher") + + fmt.Println("Validator ID:", teacherValidator.GetID()) + fmt.Println("Name property ID:", nameProperty.GetID()) + fmt.Println("Age property ID:", ageProperty.GetID()) + fmt.Println("University property ID:", universityProperty.GetID()) + + propertiesToRemove := []string{ageProperty.GetID(), universityProperty.GetID()} + relaxedValidator := teacherValidator.RemoveProperties(propertiesToRemove...) + + teacher := Teacher{ + Name: "Jake", + Age: 17 * year, + University: University{ + Name: "Poznan University of Technology", + Address: "", + }, + } + + err := relaxedValidator.Validate(teacher) + if err != nil { + fmt.Println("Relaxed validation failed:", err) + } else { + fmt.Println("Relaxed validation passed!") + } + + // Output: + // Validator ID: Teacher + // Name property ID: name + // Age property ID: age + // University property ID: university + // Relaxed validation passed! +} diff --git a/pkg/govy/identifier.go b/pkg/govy/identifier.go new file mode 100644 index 0000000..3043624 --- /dev/null +++ b/pkg/govy/identifier.go @@ -0,0 +1,32 @@ +package govy + +import "github.com/nobl9/govy/internal/uuid" + +// instanceID is a composite identifier used to identify [Validator] and [PropertyRules] variations. +type instanceID struct { + // generatedID is always filled and generated upon creation of [instanceID]. + generatedID string + // userSuppliedID overrides generatedID and is supplied by the user. + userSuppliedID string +} + +func newInstanceID() instanceID { + return instanceID{generatedID: uuid.GenerateUUID()} +} + +func (i instanceID) WithUserSuppliedID(id string) instanceID { + i.userSuppliedID = id + return i +} + +func (i instanceID) HasUserSuppliedID() bool { + return i.userSuppliedID != "" +} + +func (i instanceID) GetUserSuppliedID() string { + return i.userSuppliedID +} + +func (i instanceID) GetGeneratedID() string { + return i.generatedID +} diff --git a/pkg/govy/rules.go b/pkg/govy/rules.go index 0c8d0ef..f1aaab0 100644 --- a/pkg/govy/rules.go +++ b/pkg/govy/rules.go @@ -15,6 +15,7 @@ func For[T, P any](getter PropertyGetter[T, P]) PropertyRules[T, P] { func forConstructor[T, P any](getter PropertyGetter[T, P], name string) PropertyRules[T, P] { return PropertyRules[T, P]{ + id: newInstanceID(), name: name, getter: func(parent P) (v T, err error) { return getter(parent), nil }, } @@ -26,6 +27,7 @@ func forConstructor[T, P any](getter PropertyGetter[T, P], name string) Property // validation will not proceed. func ForPointer[T, P any](getter PropertyGetter[*T, P]) PropertyRules[T, P] { return PropertyRules[T, P]{ + id: newInstanceID(), name: inferName(), getter: func(parent P) (indirect T, err error) { ptr := getter(parent) @@ -46,6 +48,7 @@ func ForPointer[T, P any](getter PropertyGetter[*T, P]) PropertyRules[T, P] { func Transform[T, N, P any](getter PropertyGetter[T, P], transform Transformer[T, N]) PropertyRules[N, P] { typInfo := typeinfo.Get[T]() return PropertyRules[N, P]{ + id: newInstanceID(), name: inferName(), transformGetter: func(parent P) (transformed N, original any, err error) { v := getter(parent) @@ -87,6 +90,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 instanceID name string getter internalPropertyGetter[T, P] transformGetter internalTransformPropertyGetter[T, P] @@ -217,6 +221,22 @@ func (r PropertyRules[T, P]) Cascade(mode CascadeMode) PropertyRules[T, P] { return r } +// GetID returns an identifier for these property rules. +// The identifier is resolved in the following priority order: +// - User-supplied ID if set +// - Property name (via [PropertyRules.WithName]) if set +// - Auto-generated UUID otherwise +func (r PropertyRules[T, P]) GetID() string { + switch { + case r.id.HasUserSuppliedID(): + return r.id.GetUserSuppliedID() + case r.name != "": + return r.name + default: + return r.id.GetGeneratedID() + } +} + // cascadeInternal is an internal wrapper around [PropertyRules.Cascade] which // fulfills [propertyRulesInterface] interface. // If the [CascadeMode] is already set, it won't change it. diff --git a/pkg/govy/rules_for_map.go b/pkg/govy/rules_for_map.go index eecb1ca..8765d9f 100644 --- a/pkg/govy/rules_for_map.go +++ b/pkg/govy/rules_for_map.go @@ -190,6 +190,11 @@ func (r PropertyRulesForMap[M, K, V, P]) Cascade(mode CascadeMode) PropertyRules return r } +// GetID => refer to [PropertyRules.GetID] documentation. +func (r PropertyRulesForMap[M, K, V, P]) GetID() string { + return r.mapRules.GetID() +} + // cascadeInternal is an internal wrapper around [PropertyRulesForMap.Cascade] which // fulfills [propertyRulesInterface] interface. // If the [CascadeMode] is already set, it won't change it. diff --git a/pkg/govy/rules_for_slice.go b/pkg/govy/rules_for_slice.go index a8f8874..12b288d 100644 --- a/pkg/govy/rules_for_slice.go +++ b/pkg/govy/rules_for_slice.go @@ -119,6 +119,11 @@ func (r PropertyRulesForSlice[S, T, P]) Cascade(mode CascadeMode) PropertyRulesF return r } +// GetID => refer to [PropertyRules.GetID] documentation. +func (r PropertyRulesForSlice[S, T, P]) GetID() string { + return r.sliceRules.GetID() +} + // cascadeInternal is an internal wrapper around [PropertyRulesForSlice.Cascade] which // fulfills [propertyRulesInterface] interface. // If the [CascadeMode] is already set, it won't change it. diff --git a/pkg/govy/validation.go b/pkg/govy/validation.go index ee3a4c3..1d0335b 100644 --- a/pkg/govy/validation.go +++ b/pkg/govy/validation.go @@ -21,6 +21,7 @@ type validatorInterface[T any] interface { // in an immutable fashion (no pointer receivers). type propertyRulesInterface[T any] interface { validationInterface[T] + GetID() string cascadeInternal(mode CascadeMode) propertyRulesInterface[T] isPropertyRules() } diff --git a/pkg/govy/validator.go b/pkg/govy/validator.go index fc8ec4e..49c63c9 100644 --- a/pkg/govy/validator.go +++ b/pkg/govy/validator.go @@ -2,18 +2,23 @@ package govy import ( "fmt" + "slices" "strings" ) // New creates a new [Validator] aggregating the provided property rules. func New[T any](props ...propertyRulesInterface[T]) Validator[T] { - return Validator[T]{props: props} + return Validator[T]{ + id: newInstanceID(), + props: props, + } } // Validator is the top level validation entity. // It serves as an aggregator for [PropertyRules]. // Typically, it represents a struct. type Validator[T any] struct { + id instanceID props []propertyRulesInterface[T] name string nameFunc func(value T) string @@ -29,6 +34,18 @@ func (v Validator[T]) WithName(name string) Validator[T] { return v } +// WithID sets a unique identifier for this [Validator] instance. +// The identifier can be used to: +// - Retrieve the validator's ID via [Validator.GetID] +// - Reference the validator when using [Validator.RemoveProperties] +// +// This is useful when you want explicit control over identifiers +// rather than relying on validator names or auto-generated UUIDs. +func (v Validator[T]) WithID(id string) Validator[T] { + v.id = v.id.WithUserSuppliedID(id) + return v +} + // WithNameFunc when a rule fails extracts name from provided function and passes it to [ValidatorError.WithName]. // The function receives validated entity's instance as an argument. func (v Validator[T]) WithNameFunc(f func(value T) string) Validator[T] { @@ -71,6 +88,47 @@ func (v Validator[T]) Cascade(mode CascadeMode) Validator[T] { return v } +// RemoveProperties removes any [PropertyRules] or included [Validator] +// which match the provided identifiers. +// It returns a modified [Validator] instance without these rules, +// the original [Validator] is not changed. +// +// Identifiers can be obtained using [PropertyRules.GetID]. +// The identifier resolution follows this priority: +// - User-supplied ID if set +// - Property name (via [PropertyRules.WithName]) if set +// - Auto-generated UUID otherwise +func (v Validator[T]) RemoveProperties(ids ...string) Validator[T] { + if len(ids) == 0 { + return v + } + filtered := make([]propertyRulesInterface[T], 0, len(v.props)) + for _, prop := range v.props { + if slices.Contains(ids, prop.GetID()) { + continue + } + filtered = append(filtered, prop) + } + v.props = filtered + return v +} + +// GetID returns an identifier for this [Validator] instance. +// The identifier is resolved in the following priority order: +// - User-supplied ID (via [Validator.WithID]) if set +// - Validator name (via [Validator.WithName]) if set +// - Auto-generated UUID otherwise +func (v Validator[T]) GetID() string { + switch { + case v.id.HasUserSuppliedID(): + return v.id.GetUserSuppliedID() + case v.name != "": + return v.name + default: + return v.id.GetGeneratedID() + } +} + // Validate will first evaluate predicates before validating any rules. // If any predicate does not pass the validation won't be executed (returns nil). // All errors returned by property rules will be aggregated and wrapped in [ValidatorError]. diff --git a/pkg/govy/validator_test.go b/pkg/govy/validator_test.go index f4401d7..c54837a 100644 --- a/pkg/govy/validator_test.go +++ b/pkg/govy/validator_test.go @@ -315,6 +315,235 @@ func mustValidatorErrors(t *testing.T, err error) govy.ValidatorErrors { return mustErrorType[govy.ValidatorErrors](t, err) } +func TestValidatorWithID(t *testing.T) { + t.Run("set custom ID", func(t *testing.T) { + v := govy.New( + govy.For(func(m mockValidatorStruct) string { return "test" }). + WithName("test"). + Rules(govy.NewRule(func(v string) error { return nil })), + ).WithID("custom-validator-id") + + err := v.Validate(mockValidatorStruct{}) + assert.NoError(t, err) + }) + + t.Run("WithID creates a copy", func(t *testing.T) { + original := govy.New( + govy.For(func(m mockValidatorStruct) string { return "test" }). + WithName("test"). + Rules(govy.NewRule(func(v string) error { return nil })), + ) + modified := original.WithID("custom-id") + + assert.NoError(t, original.Validate(mockValidatorStruct{})) + assert.NoError(t, modified.Validate(mockValidatorStruct{})) + }) +} + +func TestValidatorRemoveProperties(t *testing.T) { + t.Run("remove single property by name", func(t *testing.T) { + err1 := errors.New("error1") + err2 := errors.New("error2") + v := govy.New( + govy.For(func(m mockValidatorStruct) string { return "test1" }). + WithName("property1"). + Rules(govy.NewRule(func(v string) error { return err1 })), + govy.For(func(m mockValidatorStruct) string { return "test2" }). + WithName("property2"). + Rules(govy.NewRule(func(v string) error { return err2 })), + ) + + filteredV := v.RemoveProperties("property1") + err := mustValidatorError(t, filteredV.Validate(mockValidatorStruct{})) + + assert.Require(t, assert.Len(t, err.Errors, 1)) + assert.Equal(t, "property2", err.Errors[0].PropertyName) + }) + + t.Run("remove multiple properties by name", func(t *testing.T) { + err1 := errors.New("error1") + err2 := errors.New("error2") + err3 := errors.New("error3") + v := govy.New( + govy.For(func(m mockValidatorStruct) string { return "test1" }). + WithName("property1"). + Rules(govy.NewRule(func(v string) error { return err1 })), + govy.For(func(m mockValidatorStruct) string { return "test2" }). + WithName("property2"). + Rules(govy.NewRule(func(v string) error { return err2 })), + govy.For(func(m mockValidatorStruct) string { return "test3" }). + WithName("property3"). + Rules(govy.NewRule(func(v string) error { return err3 })), + ) + + filteredV := v.RemoveProperties("property1", "property3") + err := mustValidatorError(t, filteredV.Validate(mockValidatorStruct{})) + + assert.Require(t, assert.Len(t, err.Errors, 1)) + assert.Equal(t, "property2", err.Errors[0].PropertyName) + }) + + t.Run("remove property by generated ID", func(t *testing.T) { + err1 := errors.New("error1") + err2 := errors.New("error2") + + prop1 := govy.For(func(m mockValidatorStruct) string { return "test1" }). + WithName("property1"). + Rules(govy.NewRule(func(v string) error { return err1 })) + + prop2 := govy.For(func(m mockValidatorStruct) string { return "test2" }). + WithName("property2"). + Rules(govy.NewRule(func(v string) error { return err2 })) + + v := govy.New(prop1, prop2) + + filteredV := v.RemoveProperties("property1") + err := mustValidatorError(t, filteredV.Validate(mockValidatorStruct{})) + + assert.Require(t, assert.Len(t, err.Errors, 1)) + assert.Equal(t, "property2", err.Errors[0].PropertyName) + }) + + t.Run("remove all properties", func(t *testing.T) { + v := govy.New( + govy.For(func(m mockValidatorStruct) string { return "test1" }). + WithName("property1"). + Rules(govy.NewRule(func(v string) error { return errors.New("error1") })), + govy.For(func(m mockValidatorStruct) string { return "test2" }). + WithName("property2"). + Rules(govy.NewRule(func(v string) error { return errors.New("error2") })), + ) + + filteredV := v.RemoveProperties("property1", "property2") + err := filteredV.Validate(mockValidatorStruct{}) + + assert.NoError(t, err) + }) + + t.Run("remove non-existent property", func(t *testing.T) { + err1 := errors.New("error1") + v := govy.New( + govy.For(func(m mockValidatorStruct) string { return "test1" }). + WithName("property1"). + Rules(govy.NewRule(func(v string) error { return err1 })), + ) + + filteredV := v.RemoveProperties("nonExistent") + err := mustValidatorError(t, filteredV.Validate(mockValidatorStruct{})) + + assert.Require(t, assert.Len(t, err.Errors, 1)) + assert.Equal(t, "property1", err.Errors[0].PropertyName) + }) + + t.Run("remove with empty IDs slice", func(t *testing.T) { + err1 := errors.New("error1") + v := govy.New( + govy.For(func(m mockValidatorStruct) string { return "test1" }). + WithName("property1"). + Rules(govy.NewRule(func(v string) error { return err1 })), + ) + + filteredV := v.RemoveProperties() + err := mustValidatorError(t, filteredV.Validate(mockValidatorStruct{})) + + assert.Require(t, assert.Len(t, err.Errors, 1)) + assert.Equal(t, "property1", err.Errors[0].PropertyName) + }) + + t.Run("original validator is unchanged", func(t *testing.T) { + err1 := errors.New("error1") + err2 := errors.New("error2") + v := govy.New( + govy.For(func(m mockValidatorStruct) string { return "test1" }). + WithName("property1"). + Rules(govy.NewRule(func(v string) error { return err1 })), + govy.For(func(m mockValidatorStruct) string { return "test2" }). + WithName("property2"). + Rules(govy.NewRule(func(v string) error { return err2 })), + ) + + filteredV := v.RemoveProperties("property1") + + originalErr := mustValidatorError(t, v.Validate(mockValidatorStruct{})) + assert.Len(t, originalErr.Errors, 2) + + filteredErr := mustValidatorError(t, filteredV.Validate(mockValidatorStruct{})) + assert.Len(t, filteredErr.Errors, 1) + }) + + t.Run("remove nested validators with include", func(t *testing.T) { + type nested struct { + Value string + } + type parent struct { + Nested nested + } + + nestedValidator := govy.New( + govy.For(func(n nested) string { return n.Value }). + WithName("value"). + Rules(rules.EQ("expected")), + ).WithID("nested-validator") + + parentValidator := govy.New( + govy.For(func(p parent) nested { return p.Nested }). + WithName("nested"). + Include(nestedValidator), + ) + + obj := parent{Nested: nested{Value: "wrong"}} + + err := mustValidatorError(t, parentValidator.Validate(obj)) + assert.Require(t, assert.Len(t, err.Errors, 1)) + + filteredValidator := parentValidator.RemoveProperties("nested") + filteredErr := filteredValidator.Validate(obj) + assert.NoError(t, filteredErr) + }) + + t.Run("remove slice property rules", func(t *testing.T) { + type withSlice struct { + Items []string + } + + v := govy.New( + govy.ForSlice(func(w withSlice) []string { return w.Items }). + WithName("items"). + Rules(rules.SliceMaxLength[[]string](1)), + ) + + obj := withSlice{Items: []string{"a", "b"}} + + err := mustValidatorError(t, v.Validate(obj)) + assert.Require(t, assert.Len(t, err.Errors, 1)) + + filteredV := v.RemoveProperties("items") + filteredErr := filteredV.Validate(obj) + assert.NoError(t, filteredErr) + }) + + t.Run("remove map property rules", func(t *testing.T) { + type withMap struct { + Data map[string]string + } + + v := govy.New( + govy.ForMap(func(w withMap) map[string]string { return w.Data }). + WithName("data"). + Rules(rules.MapMaxLength[map[string]string](1)), + ) + + obj := withMap{Data: map[string]string{"a": "1", "b": "2"}} + + err := mustValidatorError(t, v.Validate(obj)) + assert.Require(t, assert.Len(t, err.Errors, 1)) + + filteredV := v.RemoveProperties("data") + filteredErr := filteredV.Validate(obj) + assert.NoError(t, filteredErr) + }) +} + type mockValidatorStruct struct { Field string } diff --git a/pkg/rules/string_test.go b/pkg/rules/string_test.go index 2b91f6f..fe81ce3 100644 --- a/pkg/rules/string_test.go +++ b/pkg/rules/string_test.go @@ -40,7 +40,7 @@ func TestStringNotEmpty(t *testing.T) { func BenchmarkStringNotEmpty(b *testing.B) { for _, tc := range stringNotEmptyTestCases { rule := StringNotEmpty() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -77,7 +77,7 @@ func TestStringMatchRegexp(t *testing.T) { func BenchmarkStringMatchRegexp(b *testing.B) { for _, tc := range stringMatchRegexpTestCases { rule := StringMatchRegexp(stringMatchRegexpRegexp) - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -114,7 +114,7 @@ func TestStringDenyRegexp(t *testing.T) { func BenchmarkStringDenyRegexp(b *testing.B) { for _, tc := range stringDenyRegexpTestCases { rule := StringDenyRegexp(stringDenyRegexpRegexp) - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -157,7 +157,7 @@ func TestStringDNSLabel(t *testing.T) { func BenchmarkStringDNSLabel(b *testing.B) { for _, tc := range stringDNSLabelTestCases { rule := StringDNSLabel() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -221,7 +221,7 @@ func TestStringDNSSubdomain(t *testing.T) { func BenchmarkStringDNSSubdomain(b *testing.B) { for _, tc := range stringDNSSubdomainTestCases { rule := StringDNSSubdomain() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -261,7 +261,7 @@ func TestStringASCII(t *testing.T) { func BenchmarkStringASCII(b *testing.B) { for _, tc := range stringASCIITestCases { rule := StringASCII() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -305,7 +305,7 @@ func TestStringUUID(t *testing.T) { func BenchmarkStringUUID(b *testing.B) { for _, tc := range stringUUIDTestCases { rule := StringUUID() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -350,7 +350,7 @@ func TestStringEmail(t *testing.T) { func BenchmarkStringEmail(b *testing.B) { for _, tc := range stringEmailTestCases { rule := StringEmail() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -380,7 +380,7 @@ func TestStringURL(t *testing.T) { func BenchmarkStringURL(b *testing.B) { for _, tc := range urlTestCases { rule := StringURL() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.url) } } @@ -416,7 +416,7 @@ func TestStringMAC(t *testing.T) { func BenchmarkStringMAC(b *testing.B) { for _, tc := range stringMACTestCases { rule := StringMAC() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -456,7 +456,7 @@ func TestStringIP(t *testing.T) { func BenchmarkStringIP(b *testing.B) { for _, tc := range stringIPTestCases { rule := StringIP() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -495,7 +495,7 @@ func TestStringIPv4(t *testing.T) { func BenchmarkStringIPv4(b *testing.B) { for _, tc := range stringIPv4TestCases { rule := StringIPv4() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -534,7 +534,7 @@ func TestStringIPv6(t *testing.T) { func BenchmarkStringIPv6(b *testing.B) { for _, tc := range stringIPv6TestCases { rule := StringIPv6() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -576,7 +576,7 @@ func TestStringCIDR(t *testing.T) { func BenchmarkStringCIDR(b *testing.B) { for _, tc := range stringCIDRTestCases { rule := StringCIDR() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -625,7 +625,7 @@ func TestStringCIDRv4(t *testing.T) { func BenchmarkStringCIDRv4(b *testing.B) { for _, tc := range stringCIDRv4TestCases { rule := StringCIDRv4() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -667,7 +667,7 @@ func TestStringCIDRv6(t *testing.T) { func BenchmarkStringCIDRv6(b *testing.B) { for _, tc := range stringCIDRv6TestCases { rule := StringCIDRv6() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -700,7 +700,7 @@ func TestStringJSON(t *testing.T) { func BenchmarkStringJSON(b *testing.B) { for _, tc := range stringJSONTestCases { rule := StringJSON() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -759,7 +759,7 @@ func TestStringContains(t *testing.T) { func BenchmarkStringContains(b *testing.B) { for _, tc := range stringContainsTestCases { rule := StringContains(tc.substrings...) - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -819,7 +819,7 @@ func TestStringExcludes(t *testing.T) { func BenchmarkStringExcludes(b *testing.B) { for _, tc := range stringExcludesTestCases { rule := StringExcludes(tc.substrings...) - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -865,7 +865,7 @@ func TestStringStartsWith(t *testing.T) { func BenchmarkStringStartsWith(b *testing.B) { for _, tc := range stringStartsWithTestCases { rule := StringStartsWith(tc.prefixes...) - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -911,7 +911,7 @@ func TestStringEndsWith(t *testing.T) { func BenchmarkStringEndsWith(b *testing.B) { for _, tc := range stringEndsWithTestCases { rule := StringEndsWith(tc.suffixes...) - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -954,7 +954,7 @@ func TestStringTitle(t *testing.T) { func BenchmarkStringTitle(b *testing.B) { for _, tc := range stringTitleTestCases { rule := StringTitle() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -1041,7 +1041,7 @@ func TestStringGitRef(t *testing.T) { func BenchmarkStringGitRef(b *testing.B) { for _, tc := range stringGitRefTestCases { rule := StringGitRef() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -1123,7 +1123,7 @@ func BenchmarkStringFileSystemPath(b *testing.B) { testCases := getStringFileSystemPathTestCases(root) for _, tc := range testCases { rule := StringFileSystemPath() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -1174,7 +1174,7 @@ func BenchmarkStringFilePath(b *testing.B) { testCases := getStringFilePathTestCases(root) for _, tc := range testCases { rule := StringFilePath() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -1225,7 +1225,7 @@ func BenchmarkStringDirPath(b *testing.B) { testCases := getStringDirPathTestCases(root) for _, tc := range testCases { rule := StringDirPath() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -1314,7 +1314,7 @@ func TestStringMatchFileSystemPath(t *testing.T) { func BenchmarkStringMatchFileSystemPath(b *testing.B) { for _, tc := range stringMatchFileSystemPathTestCases { rule := StringMatchFileSystemPath(tc.pattern) - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -1373,7 +1373,7 @@ func TestStringRegexp(t *testing.T) { func BenchmarkStringRegexp(b *testing.B) { for _, tc := range stringRegexpTestCases { rule := StringRegexp() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -1479,19 +1479,19 @@ func getStringCronTestCases() []*stringCrontabTestCase { &stringCrontabTestCase{createCron(field.n, "%d-%d/a", field.lower, field.upper), true}, &stringCrontabTestCase{createCron(field.n, "%d-%d/-10", field.lower, field.upper), true}, &stringCrontabTestCase{createCron(field.n, "%d-*/10", field.lower), true}, - &stringCrontabTestCase{createCron(field.n, "*-*/10", field.lower, field.upper), true}, + &stringCrontabTestCase{createCron(field.n, "*-*/10"), true}, &stringCrontabTestCase{createCron(field.n, "*-%d/10", field.upper), true}, ) } for month := range crontabMonthsMap { - testCases = append(testCases, &stringCrontabTestCase{createCron(3, month), false}) + testCases = append(testCases, &stringCrontabTestCase{createCron(3, "%s", month), false}) } for day := range crontabDaysMap { // Skip special cases for Sunday. if strings.Contains(day, "-") { continue } - testCases = append(testCases, &stringCrontabTestCase{createCron(4, day), false}) + testCases = append(testCases, &stringCrontabTestCase{createCron(4, "%s", day), false}) } return testCases } @@ -1511,7 +1511,7 @@ func TestStringCrontab(t *testing.T) { } func BenchmarkStringCrontab(b *testing.B) { - for range b.N { + for b.Loop() { testCases := getStringCronTestCases() for _, tc := range testCases { _ = StringCrontab().Validate(tc.in) @@ -1563,7 +1563,7 @@ func TestStringDateTime(t *testing.T) { func BenchmarkStringDateTime(b *testing.B) { for _, tc := range stringDateTimeTestCases { rule := StringDateTime(tc.layout) - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -1600,7 +1600,7 @@ func TestStringTimeZone(t *testing.T) { func BenchmarkStringTimeZone(b *testing.B) { for _, tc := range stringDateTimeTestCases { rule := StringTimeZone() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -1639,7 +1639,7 @@ func TestStringAlpha(t *testing.T) { func BenchmarkStringAlpha(b *testing.B) { for _, tc := range stringAlphaTestCases { rule := StringAlpha() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -1682,7 +1682,7 @@ func TestStringAlphanumeric(t *testing.T) { func BenchmarkStringAlphanumeric(b *testing.B) { for _, tc := range stringAlphanumericTestCases { rule := StringAlphanumeric() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -1725,7 +1725,7 @@ func TestStringAlphaUnicode(t *testing.T) { func BenchmarkStringAlphaUnicode(b *testing.B) { for _, tc := range stringAlphaUnicodeTestCases { rule := StringAlphaUnicode() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -1773,7 +1773,7 @@ func TestStringAlphanumericUnicode(t *testing.T) { func BenchmarkStringAlphanumericUnicode(b *testing.B) { for _, tc := range stringAlphanumericUnicodeTestCases { rule := StringAlphanumericUnicode() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -1822,7 +1822,7 @@ func TestStringFQDN(t *testing.T) { func BenchmarkStringFQDN(b *testing.B) { for _, tc := range stringFQDNTestCases { rule := StringFQDN() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } @@ -1899,7 +1899,7 @@ func TestStringKubernetesQualifiedName(t *testing.T) { func BenchmarkStringKubernetesQualifiedName(b *testing.B) { for _, tc := range stringK8sQualifiedNameTestCases { rule := StringKubernetesQualifiedName() - for range b.N { + for b.Loop() { _ = rule.Validate(tc.in) } } From 52869b7025b48323c15664446968f67add439a62 Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Wed, 12 Nov 2025 21:31:41 +0100 Subject: [PATCH 2/5] feat: add support for custom property rule identifiers in govy This update introduces the ability to set custom identifiers for property rules using the `WithID` method. The feature is implemented across `PropertyRules`, `PropertyRulesForSlice`, and `PropertyRulesForMap`. These identifiers can be used for referencing specific rules, such as when removing properties from a validator. The changes include: - Implementation of the `WithID` method in `PropertyRules`, `PropertyRulesForSlice`, and `PropertyRulesForMap`. - Updates to the `GetID` method to prioritize user-supplied IDs over names and auto-generated UUIDs. - Modifications to example and test files to demonstrate and validate the new functionality. - Adjustments to the `Makefile` to ignore `node_modules` during markdown linting. --- Makefile | 2 +- pkg/govy/example_test.go | 121 ++++++++++++++++++++++++++++-------- pkg/govy/rules.go | 12 ++++ pkg/govy/rules_for_map.go | 6 ++ pkg/govy/rules_for_slice.go | 6 ++ pkg/govy/rules_test.go | 73 ++++++++++++++++++++++ 6 files changed, 194 insertions(+), 26 deletions(-) diff --git a/Makefile b/Makefile index 6b2c29a..76a3fcd 100644 --- a/Makefile +++ b/Makefile @@ -86,7 +86,7 @@ check/trailing: ## Check markdown files for potential issues with markdownlint. check/markdown: $(call _print_step,Verifying Markdown files) - markdownlint '**/*.md' + markdownlint '**/*.md' --ignore node_modules ## Check for potential vulnerabilities across all Go dependencies. check/vulns: diff --git a/pkg/govy/example_test.go b/pkg/govy/example_test.go index 13f1d2b..57875e8 100644 --- a/pkg/govy/example_test.go +++ b/pkg/govy/example_test.go @@ -1843,17 +1843,17 @@ func ExamplePlan_validation() { // predicates without description found at: validator level, $.name } -// Sometimes you want to reuse a validator but exclude certain property rules. +// Sometimes you want to reuse a [govy.Validator] but exclude certain property rules. // This is particularly useful when you have a base validator that you want to customize // for different contexts without duplicating code. // Use [govy.Validator.RemoveProperties] to filter out specific property rules by their identifiers. // -// Property identifiers are determined by: -// - User-supplied ID (via [govy.Validator.WithID]) if set -// - Property name (via [govy.PropertyRules.WithName]) if set -// - Generated UUID otherwise +// Property identifiers are determined by (in the following precedence): +// - user-supplied ID (via [govy.Validator.WithID]) if set +// - property name (via [govy.PropertyRules.WithName]) if set +// - UUID generated when first instantiating validators or properties // -// This example demonstrates how to create a base validator for [Teacher] and then +// This example demonstrates how to create a base [govy.Validator] for [Teacher] and then // create specialized versions by removing certain validations. func ExampleValidator_RemoveProperties() { universityValidation := govy.New( @@ -1894,23 +1894,16 @@ func ExampleValidator_RemoveProperties() { err := baseTeacherValidator.Validate(strictTeacher) if err != nil { - fmt.Println("Base validator:") - fmt.Println(err) - fmt.Println() + fmt.Printf("Base validator:\n%v\n\n", err) } err = relaxedTeacherValidator.Validate(strictTeacher) if err != nil { - fmt.Println("Relaxed validator with strict data:") - fmt.Println(err) - fmt.Println() + fmt.Printf("Relaxed validator with strict data:\n%v\n\n", err) } err = relaxedTeacherValidator.Validate(relaxedTeacher) - if err != nil { - fmt.Println("Relaxed validator with relaxed data:") - fmt.Println(err) - } else { + if err == nil { fmt.Println("Relaxed validator passed!") } @@ -1932,19 +1925,18 @@ func ExampleValidator_RemoveProperties() { // Relaxed validator passed! } -// You can retrieve the identifier of a [Validator] or [PropertyRules] using the [Validator.GetID] method. -// This is useful when you need to: -// - Dynamically determine which properties to remove -// - Log or track validation components -// - Build debugging or introspection tools +// You can retrieve the identifier of a [govy.Validator] or [govy.PropertyRules] +// using the [govy.Validator.GetID] method. +// This is useful when you need to utilize [govy.Validator.RemoveProperties] +// method and you don't want to manually provide [govy.PropertyRules.WithID] // -// The [Validator.GetID] and [PropertyRules.GetID] methods follow the same priority resolution: -// 1. User-supplied ID (via [Validator.WithID]) -// 2. Validator/Property name (via [Validator.WithName] or [PropertyRules.WithName]) +// The [govy.Validator.GetID] and [govy.PropertyRules.GetID] methods follow the same priority resolution: +// 1. User-supplied ID (via [govy.Validator.WithID]) +// 2. Validator/Property name (via [govy.Validator.WithName] or [PropertyRules.WithName]) // 3. Auto-generated UUID // // This example demonstrates how to use GetID to inspect and manipulate validators. -func ExampleValidator_GetID() { +func ExamplePropertyRules_GetID() { universityValidator := govy.New( govy.For(func(u University) string { return u.Address }). WithName("address"). @@ -2000,3 +1992,82 @@ func ExampleValidator_GetID() { // University property ID: university // Relaxed validation passed! } + +// You can also set custom identifiers on [PropertyRules] using [PropertyRules.WithID]. +// This is particularly useful when: +// - You want to reference specific property rules for removal +// - Property names might change but you need stable identifiers +// - You're building dynamic validators and need to track specific rules +// +// The WithID method works for [PropertyRules], [PropertyRulesForSlice], and [PropertyRulesForMap]. +// +// This example demonstrates creating a validator with custom property IDs +// and then selectively removing properties by their IDs. +func ExamplePropertyRules_WithID() { + nameRules := govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + WithID("teacher-name-rules"). + Required(). + Rules( + rules.StringNotEmpty(), + rules.OneOf("Jake", "George")) + + ageRules := govy.For(func(t Teacher) time.Duration { return t.Age }). + WithName("age"). + WithID("teacher-age-rules"). + Rules(rules.GT(18 * year)) + + studentsRules := govy.ForSlice(func(t Teacher) []Student { return t.Students }). + WithName("students"). + WithID("teacher-students-rules"). + Rules(rules.SliceMaxLength[[]Student](5)) + + fullValidator := govy.New( + nameRules, + ageRules, + studentsRules, + ).WithName("Teacher") + + nameOnlyValidator := fullValidator.RemoveProperties( + ageRules.GetID(), + studentsRules.GetID(), + ) + + teacher := Teacher{ + Name: "Jake", + Age: 17 * year, + Students: []Student{ + {Index: "1"}, + {Index: "2"}, + {Index: "3"}, + {Index: "4"}, + {Index: "5"}, + {Index: "6"}, + }, + } + + fmt.Println("Full validator:") + err := fullValidator.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + fmt.Println("\nName-only validator:") + err = nameOnlyValidator.Validate(teacher) + if err != nil { + fmt.Println(err) + } else { + fmt.Println("Validation passed!") + } + + // Output: + // Full validator: + // Validation for Teacher has failed for the following properties: + // - 'age' with value '148920h0m0s': + // - should be greater than '157680h0m0s' + // - 'students' with value '[{"index":"1"},{"index":"2"},{"index":"3"},{"index":"4"},{"index":"5"},{"index":"6"}]': + // - length must be less than or equal to 5 + // + // Name-only validator: + // Validation passed! +} diff --git a/pkg/govy/rules.go b/pkg/govy/rules.go index f1aaab0..67ef616 100644 --- a/pkg/govy/rules.go +++ b/pkg/govy/rules.go @@ -164,6 +164,18 @@ func (r PropertyRules[T, P]) WithName(name string) PropertyRules[T, P] { return r } +// WithID sets a unique identifier for this [PropertyRules] instance. +// The identifier can be used to: +// - Retrieve the property rules' ID via [PropertyRules.GetID] +// - Reference the property rules when using [Validator.RemoveProperties] +// +// This is useful when you want explicit control over identifiers +// rather than relying on property names or auto-generated UUIDs. +func (r PropertyRules[T, P]) WithID(id string) PropertyRules[T, P] { + r.id = r.id.WithUserSuppliedID(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...) diff --git a/pkg/govy/rules_for_map.go b/pkg/govy/rules_for_map.go index 8765d9f..20cd752 100644 --- a/pkg/govy/rules_for_map.go +++ b/pkg/govy/rules_for_map.go @@ -101,6 +101,12 @@ func (r PropertyRulesForMap[M, K, V, P]) WithName(name string) PropertyRulesForM 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...) diff --git a/pkg/govy/rules_for_slice.go b/pkg/govy/rules_for_slice.go index 12b288d..53b45ce 100644 --- a/pkg/govy/rules_for_slice.go +++ b/pkg/govy/rules_for_slice.go @@ -72,6 +72,12 @@ func (r PropertyRulesForSlice[S, T, P]) WithName(name string) PropertyRulesForSl 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...) diff --git a/pkg/govy/rules_test.go b/pkg/govy/rules_test.go index 13e22e9..7e7c955 100644 --- a/pkg/govy/rules_test.go +++ b/pkg/govy/rules_test.go @@ -422,6 +422,79 @@ func TestPropertyRules_InferName(t *testing.T) { }) } +func TestPropertyRulesWithID(t *testing.T) { + type mockStruct struct { + Field string + } + + t.Run("PropertyRules with custom ID", func(t *testing.T) { + prop := govy.For(func(m mockStruct) string { return m.Field }). + WithName("field"). + WithID("custom-field-id"). + Rules(rules.EQ("test")) + + assert.Equal(t, "custom-field-id", prop.GetID()) + }) + + t.Run("PropertyRules ID priority: custom ID over name", func(t *testing.T) { + prop := govy.For(func(m mockStruct) string { return m.Field }). + WithName("field"). + WithID("custom-id"). + Rules(rules.EQ("test")) + + assert.Equal(t, "custom-id", prop.GetID()) + }) + + t.Run("PropertyRulesForSlice with custom ID", func(t *testing.T) { + prop := govy.ForSlice(func(m mockStruct) []string { return []string{m.Field} }). + WithName("items"). + WithID("custom-slice-id"). + Rules(rules.SliceMaxLength[[]string](10)) + + assert.Equal(t, "custom-slice-id", prop.GetID()) + }) + + t.Run("PropertyRulesForMap with custom ID", func(t *testing.T) { + prop := govy.ForMap(func(m mockStruct) map[string]string { + return map[string]string{"key": m.Field} + }). + WithName("data"). + WithID("custom-map-id"). + Rules(rules.MapMaxLength[map[string]string](10)) + + assert.Equal(t, "custom-map-id", prop.GetID()) + }) + + t.Run("use WithID for RemoveProperties", func(t *testing.T) { + fieldProp := govy.For(func(m mockStruct) string { return m.Field }). + WithName("field"). + WithID("field-to-remove"). + Rules(rules.EQ("expected")) + + otherProp := govy.For(func(m mockStruct) string { return "value" }). + WithName("other"). + Rules(rules.EQ("value")) + + v := govy.New(fieldProp, otherProp) + + filteredV := v.RemoveProperties("field-to-remove") + + err := filteredV.Validate(mockStruct{Field: "wrong"}) + assert.NoError(t, err) + }) + + t.Run("WithID creates a copy", func(t *testing.T) { + original := govy.For(func(m mockStruct) string { return m.Field }). + WithName("field"). + Rules(rules.EQ("test")) + + modified := original.WithID("custom-id") + + assert.Equal(t, "field", original.GetID()) + assert.Equal(t, "custom-id", modified.GetID()) + }) +} + func mustPropertyErrors(t *testing.T, err error) govy.PropertyErrors { t.Helper() return mustErrorType[govy.PropertyErrors](t, err) From 412c2c574957e15e216fded1fee48e2d2ffeccde Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Wed, 12 Nov 2025 22:18:10 +0100 Subject: [PATCH 3/5] feat: replace name-based property removal with ID-based approach This update introduces a shift from name-based property removal to ID-based property removal in the `govy` package. The changes include: - Replacing `RemoveProperties` with `RemovePropertiesByID` in the `Validator` API. - Modifying `PropertyRules.GetID` and `Validator.GetID` to return only user-supplied IDs or auto-generated UUIDs, removing reliance on property names. - Updating examples and tests to reflect the new ID-based approach, ensuring clarity and consistency. - Simplifying the internal logic for ID retrieval by consolidating methods in the `instanceID` struct. These changes enhance the flexibility and reliability of property rule management, particularly in dynamic or complex validation scenarios. --- pkg/govy/example_test.go | 177 ++++++++++++------------------------- pkg/govy/identifier.go | 13 +-- pkg/govy/rules.go | 13 +-- pkg/govy/rules_test.go | 11 ++- pkg/govy/validator.go | 26 +++--- pkg/govy/validator_test.go | 131 +++++++++++++-------------- 6 files changed, 145 insertions(+), 226 deletions(-) diff --git a/pkg/govy/example_test.go b/pkg/govy/example_test.go index 57875e8..7c869c0 100644 --- a/pkg/govy/example_test.go +++ b/pkg/govy/example_test.go @@ -1846,38 +1846,42 @@ func ExamplePlan_validation() { // Sometimes you want to reuse a [govy.Validator] but exclude certain property rules. // This is particularly useful when you have a base validator that you want to customize // for different contexts without duplicating code. -// Use [govy.Validator.RemoveProperties] to filter out specific property rules by their identifiers. +// Use [govy.Validator.RemovePropertiesByID] to filter out specific property rules by their identifiers. // -// Property identifiers are determined by (in the following precedence): -// - user-supplied ID (via [govy.Validator.WithID]) if set -// - property name (via [govy.PropertyRules.WithName]) if set -// - UUID generated when first instantiating validators or properties +// Property identifiers must be obtained using [PropertyRules.GetID] or explicitly set with [PropertyRules.WithID]. +// Property names are not accepted - you must use actual IDs. // // This example demonstrates how to create a base [govy.Validator] for [Teacher] and then -// create specialized versions by removing certain validations. -func ExampleValidator_RemoveProperties() { +// create specialized versions by removing certain validations using GetID(). +func ExampleValidator_RemovePropertiesByID() { universityValidation := govy.New( govy.For(func(u University) string { return u.Address }). WithName("address"). Required(), ) + nameProp := govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Required(). + Rules( + rules.StringNotEmpty(), + rules.OneOf("Jake", "George")) + + ageProp := govy.For(func(t Teacher) time.Duration { return t.Age }). + WithName("age"). + Rules(rules.GT(18 * year)) + + universityProp := govy.For(func(t Teacher) University { return t.University }). + WithName("university"). + Include(universityValidation) + baseTeacherValidator := govy.New( - govy.For(func(t Teacher) string { return t.Name }). - WithName("name"). - Required(). - Rules( - rules.StringNotEmpty(), - rules.OneOf("Jake", "George")), - govy.For(func(t Teacher) time.Duration { return t.Age }). - WithName("age"). - Rules(rules.GT(18*year)), - govy.For(func(t Teacher) University { return t.University }). - WithName("university"). - Include(universityValidation), + nameProp, + ageProp, + universityProp, ).WithName("Teacher") - relaxedTeacherValidator := baseTeacherValidator.RemoveProperties("age", "university") + relaxedTeacherValidator := baseTeacherValidator.RemovePropertiesByID(ageProp.GetID(), universityProp.GetID()) strictTeacher := Teacher{ Name: "John", @@ -1896,12 +1900,10 @@ func ExampleValidator_RemoveProperties() { if err != nil { fmt.Printf("Base validator:\n%v\n\n", err) } - err = relaxedTeacherValidator.Validate(strictTeacher) if err != nil { fmt.Printf("Relaxed validator with strict data:\n%v\n\n", err) } - err = relaxedTeacherValidator.Validate(relaxedTeacher) if err == nil { fmt.Println("Relaxed validator passed!") @@ -1926,16 +1928,17 @@ func ExampleValidator_RemoveProperties() { } // You can retrieve the identifier of a [govy.Validator] or [govy.PropertyRules] -// using the [govy.Validator.GetID] method. -// This is useful when you need to utilize [govy.Validator.RemoveProperties] -// method and you don't want to manually provide [govy.PropertyRules.WithID] +// using the [govy.Validator.GetID] or [govy.PropertyRules.GetID] methods. +// This is useful when you need to call [govy.Validator.RemovePropertiesByID] method +// without hardcoding ID strings. // -// The [govy.Validator.GetID] and [govy.PropertyRules.GetID] methods follow the same priority resolution: -// 1. User-supplied ID (via [govy.Validator.WithID]) -// 2. Validator/Property name (via [govy.Validator.WithName] or [PropertyRules.WithName]) -// 3. Auto-generated UUID +// The [govy.Validator.GetID] and [govy.PropertyRules.GetID] methods return: +// - User-supplied ID (via [govy.Validator.WithID] or [govy.PropertyRules.WithID]) if set +// - Auto-generated UUID otherwise // -// This example demonstrates how to use GetID to inspect and manipulate validators. +// This example demonstrates how to use GetID to dynamically remove properties. +// Notice how there are two [govy.PropertyRules] for "name" - using explicit IDs +// allows us to distinguish between them, which wouldn't be possible with name-based removal. func ExamplePropertyRules_GetID() { universityValidator := govy.New( govy.For(func(u University) string { return u.Address }). @@ -1945,29 +1948,43 @@ func ExamplePropertyRules_GetID() { nameProperty := govy.For(func(t Teacher) string { return t.Name }). WithName("name"). + WithID("name-property"). Required() + namePropertyExtra := govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + WithID("name-property-extra"). + Required(). + Rules(rules.OneOf("John", "George")) + ageProperty := govy.For(func(t Teacher) time.Duration { return t.Age }). WithName("age"). + WithID("age-property"). Rules(rules.GT(18 * year)) universityProperty := govy.For(func(t Teacher) University { return t.University }). WithName("university"). + WithID("university-property"). Include(universityValidator) teacherValidator := govy.New( nameProperty, + namePropertyExtra, ageProperty, universityProperty, - ).WithName("Teacher") + ).WithName("Teacher").WithID("teacher-validator") - fmt.Println("Validator ID:", teacherValidator.GetID()) + fmt.Println("Teacher validator ID:", teacherValidator.GetID()) fmt.Println("Name property ID:", nameProperty.GetID()) + fmt.Println("Name property (extra) ID:", namePropertyExtra.GetID()) fmt.Println("Age property ID:", ageProperty.GetID()) fmt.Println("University property ID:", universityProperty.GetID()) - propertiesToRemove := []string{ageProperty.GetID(), universityProperty.GetID()} - relaxedValidator := teacherValidator.RemoveProperties(propertiesToRemove...) + relaxedValidator := teacherValidator.RemovePropertiesByID( + ageProperty.GetID(), + universityProperty.GetID(), + namePropertyExtra.GetID(), + ) teacher := Teacher{ Name: "Jake", @@ -1979,95 +1996,15 @@ func ExamplePropertyRules_GetID() { } err := relaxedValidator.Validate(teacher) - if err != nil { - fmt.Println("Relaxed validation failed:", err) - } else { + if err == nil { fmt.Println("Relaxed validation passed!") } // Output: - // Validator ID: Teacher - // Name property ID: name - // Age property ID: age - // University property ID: university + // Teacher validator ID: teacher-validator + // Name property ID: name-property + // Name property (extra) ID: name-property-extra + // Age property ID: age-property + // University property ID: university-property // Relaxed validation passed! } - -// You can also set custom identifiers on [PropertyRules] using [PropertyRules.WithID]. -// This is particularly useful when: -// - You want to reference specific property rules for removal -// - Property names might change but you need stable identifiers -// - You're building dynamic validators and need to track specific rules -// -// The WithID method works for [PropertyRules], [PropertyRulesForSlice], and [PropertyRulesForMap]. -// -// This example demonstrates creating a validator with custom property IDs -// and then selectively removing properties by their IDs. -func ExamplePropertyRules_WithID() { - nameRules := govy.For(func(t Teacher) string { return t.Name }). - WithName("name"). - WithID("teacher-name-rules"). - Required(). - Rules( - rules.StringNotEmpty(), - rules.OneOf("Jake", "George")) - - ageRules := govy.For(func(t Teacher) time.Duration { return t.Age }). - WithName("age"). - WithID("teacher-age-rules"). - Rules(rules.GT(18 * year)) - - studentsRules := govy.ForSlice(func(t Teacher) []Student { return t.Students }). - WithName("students"). - WithID("teacher-students-rules"). - Rules(rules.SliceMaxLength[[]Student](5)) - - fullValidator := govy.New( - nameRules, - ageRules, - studentsRules, - ).WithName("Teacher") - - nameOnlyValidator := fullValidator.RemoveProperties( - ageRules.GetID(), - studentsRules.GetID(), - ) - - teacher := Teacher{ - Name: "Jake", - Age: 17 * year, - Students: []Student{ - {Index: "1"}, - {Index: "2"}, - {Index: "3"}, - {Index: "4"}, - {Index: "5"}, - {Index: "6"}, - }, - } - - fmt.Println("Full validator:") - err := fullValidator.Validate(teacher) - if err != nil { - fmt.Println(err) - } - - fmt.Println("\nName-only validator:") - err = nameOnlyValidator.Validate(teacher) - if err != nil { - fmt.Println(err) - } else { - fmt.Println("Validation passed!") - } - - // Output: - // Full validator: - // Validation for Teacher has failed for the following properties: - // - 'age' with value '148920h0m0s': - // - should be greater than '157680h0m0s' - // - 'students' with value '[{"index":"1"},{"index":"2"},{"index":"3"},{"index":"4"},{"index":"5"},{"index":"6"}]': - // - length must be less than or equal to 5 - // - // Name-only validator: - // Validation passed! -} diff --git a/pkg/govy/identifier.go b/pkg/govy/identifier.go index 3043624..94a46ff 100644 --- a/pkg/govy/identifier.go +++ b/pkg/govy/identifier.go @@ -19,14 +19,9 @@ func (i instanceID) WithUserSuppliedID(id string) instanceID { return i } -func (i instanceID) HasUserSuppliedID() bool { - return i.userSuppliedID != "" -} - -func (i instanceID) GetUserSuppliedID() string { - return i.userSuppliedID -} - -func (i instanceID) GetGeneratedID() string { +func (i instanceID) GetID() string { + if i.userSuppliedID != "" { + return i.userSuppliedID + } return i.generatedID } diff --git a/pkg/govy/rules.go b/pkg/govy/rules.go index 67ef616..45e3829 100644 --- a/pkg/govy/rules.go +++ b/pkg/govy/rules.go @@ -167,10 +167,10 @@ func (r PropertyRules[T, P]) WithName(name string) PropertyRules[T, P] { // WithID sets a unique identifier for this [PropertyRules] instance. // The identifier can be used to: // - Retrieve the property rules' ID via [PropertyRules.GetID] -// - Reference the property rules when using [Validator.RemoveProperties] +// - Reference the property rules when using [Validator.RemovePropertiesByID] // // This is useful when you want explicit control over identifiers -// rather than relying on property names or auto-generated UUIDs. +// rather than relying on auto-generated UUIDs. func (r PropertyRules[T, P]) WithID(id string) PropertyRules[T, P] { r.id = r.id.WithUserSuppliedID(id) return r @@ -239,14 +239,7 @@ func (r PropertyRules[T, P]) Cascade(mode CascadeMode) PropertyRules[T, P] { // - Property name (via [PropertyRules.WithName]) if set // - Auto-generated UUID otherwise func (r PropertyRules[T, P]) GetID() string { - switch { - case r.id.HasUserSuppliedID(): - return r.id.GetUserSuppliedID() - case r.name != "": - return r.name - default: - return r.id.GetGeneratedID() - } + return r.id.GetID() } // cascadeInternal is an internal wrapper around [PropertyRules.Cascade] which diff --git a/pkg/govy/rules_test.go b/pkg/govy/rules_test.go index 7e7c955..ef96505 100644 --- a/pkg/govy/rules_test.go +++ b/pkg/govy/rules_test.go @@ -465,7 +465,7 @@ func TestPropertyRulesWithID(t *testing.T) { assert.Equal(t, "custom-map-id", prop.GetID()) }) - t.Run("use WithID for RemoveProperties", func(t *testing.T) { + t.Run("use WithID for RemovePropertiesByID", func(t *testing.T) { fieldProp := govy.For(func(m mockStruct) string { return m.Field }). WithName("field"). WithID("field-to-remove"). @@ -477,7 +477,7 @@ func TestPropertyRulesWithID(t *testing.T) { v := govy.New(fieldProp, otherProp) - filteredV := v.RemoveProperties("field-to-remove") + filteredV := v.RemovePropertiesByID(fieldProp.GetID()) err := filteredV.Validate(mockStruct{Field: "wrong"}) assert.NoError(t, err) @@ -488,10 +488,15 @@ func TestPropertyRulesWithID(t *testing.T) { WithName("field"). Rules(rules.EQ("test")) + originalID := original.GetID() modified := original.WithID("custom-id") - assert.Equal(t, "field", original.GetID()) + // Original should still have its generated UUID + assert.Equal(t, originalID, original.GetID()) + // Modified should have the custom ID assert.Equal(t, "custom-id", modified.GetID()) + // IDs should be different + assert.True(t, originalID != modified.GetID()) }) } diff --git a/pkg/govy/validator.go b/pkg/govy/validator.go index 49c63c9..0467222 100644 --- a/pkg/govy/validator.go +++ b/pkg/govy/validator.go @@ -37,10 +37,10 @@ func (v Validator[T]) WithName(name string) Validator[T] { // WithID sets a unique identifier for this [Validator] instance. // The identifier can be used to: // - Retrieve the validator's ID via [Validator.GetID] -// - Reference the validator when using [Validator.RemoveProperties] +// - Reference the validator when using [Validator.RemovePropertiesByID] // // This is useful when you want explicit control over identifiers -// rather than relying on validator names or auto-generated UUIDs. +// rather than relying on auto-generated UUIDs. func (v Validator[T]) WithID(id string) Validator[T] { v.id = v.id.WithUserSuppliedID(id) return v @@ -88,17 +88,18 @@ func (v Validator[T]) Cascade(mode CascadeMode) Validator[T] { return v } -// RemoveProperties removes any [PropertyRules] or included [Validator] +// RemovePropertiesByID removes any [PropertyRules] or included [Validator] // which match the provided identifiers. // It returns a modified [Validator] instance without these rules, // the original [Validator] is not changed. // -// Identifiers can be obtained using [PropertyRules.GetID]. -// The identifier resolution follows this priority: -// - User-supplied ID if set -// - Property name (via [PropertyRules.WithName]) if set +// Identifiers must be obtained using [PropertyRules.GetID] or [Validator.GetID]. +// Property names are not accepted as identifiers - you must use explicit IDs. +// +// The identifier returned by GetID follows this priority: +// - User-supplied ID (via [PropertyRules.WithID] or [Validator.WithID]) if set // - Auto-generated UUID otherwise -func (v Validator[T]) RemoveProperties(ids ...string) Validator[T] { +func (v Validator[T]) RemovePropertiesByID(ids ...string) Validator[T] { if len(ids) == 0 { return v } @@ -119,14 +120,7 @@ func (v Validator[T]) RemoveProperties(ids ...string) Validator[T] { // - Validator name (via [Validator.WithName]) if set // - Auto-generated UUID otherwise func (v Validator[T]) GetID() string { - switch { - case v.id.HasUserSuppliedID(): - return v.id.GetUserSuppliedID() - case v.name != "": - return v.name - default: - return v.id.GetGeneratedID() - } + return v.id.GetID() } // Validate will first evaluate predicates before validating any rules. diff --git a/pkg/govy/validator_test.go b/pkg/govy/validator_test.go index c54837a..f356789 100644 --- a/pkg/govy/validator_test.go +++ b/pkg/govy/validator_test.go @@ -340,43 +340,41 @@ func TestValidatorWithID(t *testing.T) { }) } -func TestValidatorRemoveProperties(t *testing.T) { - t.Run("remove single property by name", func(t *testing.T) { +func TestValidatorRemovePropertiesByID(t *testing.T) { + t.Run("remove single property by ID", func(t *testing.T) { err1 := errors.New("error1") err2 := errors.New("error2") - v := govy.New( - govy.For(func(m mockValidatorStruct) string { return "test1" }). - WithName("property1"). - Rules(govy.NewRule(func(v string) error { return err1 })), - govy.For(func(m mockValidatorStruct) string { return "test2" }). - WithName("property2"). - Rules(govy.NewRule(func(v string) error { return err2 })), - ) + prop1 := govy.For(func(m mockValidatorStruct) string { return "test1" }). + WithName("property1"). + Rules(govy.NewRule(func(v string) error { return err1 })) + prop2 := govy.For(func(m mockValidatorStruct) string { return "test2" }). + WithName("property2"). + Rules(govy.NewRule(func(v string) error { return err2 })) + v := govy.New(prop1, prop2) - filteredV := v.RemoveProperties("property1") + filteredV := v.RemovePropertiesByID(prop1.GetID()) err := mustValidatorError(t, filteredV.Validate(mockValidatorStruct{})) assert.Require(t, assert.Len(t, err.Errors, 1)) assert.Equal(t, "property2", err.Errors[0].PropertyName) }) - t.Run("remove multiple properties by name", func(t *testing.T) { + t.Run("remove multiple properties by ID", func(t *testing.T) { err1 := errors.New("error1") err2 := errors.New("error2") err3 := errors.New("error3") - v := govy.New( - govy.For(func(m mockValidatorStruct) string { return "test1" }). - WithName("property1"). - Rules(govy.NewRule(func(v string) error { return err1 })), - govy.For(func(m mockValidatorStruct) string { return "test2" }). - WithName("property2"). - Rules(govy.NewRule(func(v string) error { return err2 })), - govy.For(func(m mockValidatorStruct) string { return "test3" }). - WithName("property3"). - Rules(govy.NewRule(func(v string) error { return err3 })), - ) + prop1 := govy.For(func(m mockValidatorStruct) string { return "test1" }). + WithName("property1"). + Rules(govy.NewRule(func(v string) error { return err1 })) + prop2 := govy.For(func(m mockValidatorStruct) string { return "test2" }). + WithName("property2"). + Rules(govy.NewRule(func(v string) error { return err2 })) + prop3 := govy.For(func(m mockValidatorStruct) string { return "test3" }). + WithName("property3"). + Rules(govy.NewRule(func(v string) error { return err3 })) + v := govy.New(prop1, prop2, prop3) - filteredV := v.RemoveProperties("property1", "property3") + filteredV := v.RemovePropertiesByID(prop1.GetID(), prop3.GetID()) err := mustValidatorError(t, filteredV.Validate(mockValidatorStruct{})) assert.Require(t, assert.Len(t, err.Errors, 1)) @@ -397,7 +395,7 @@ func TestValidatorRemoveProperties(t *testing.T) { v := govy.New(prop1, prop2) - filteredV := v.RemoveProperties("property1") + filteredV := v.RemovePropertiesByID(prop1.GetID()) err := mustValidatorError(t, filteredV.Validate(mockValidatorStruct{})) assert.Require(t, assert.Len(t, err.Errors, 1)) @@ -405,16 +403,15 @@ func TestValidatorRemoveProperties(t *testing.T) { }) t.Run("remove all properties", func(t *testing.T) { - v := govy.New( - govy.For(func(m mockValidatorStruct) string { return "test1" }). - WithName("property1"). - Rules(govy.NewRule(func(v string) error { return errors.New("error1") })), - govy.For(func(m mockValidatorStruct) string { return "test2" }). - WithName("property2"). - Rules(govy.NewRule(func(v string) error { return errors.New("error2") })), - ) + prop1 := govy.For(func(m mockValidatorStruct) string { return "test1" }). + WithName("property1"). + Rules(govy.NewRule(func(v string) error { return errors.New("error1") })) + prop2 := govy.For(func(m mockValidatorStruct) string { return "test2" }). + WithName("property2"). + Rules(govy.NewRule(func(v string) error { return errors.New("error2") })) + v := govy.New(prop1, prop2) - filteredV := v.RemoveProperties("property1", "property2") + filteredV := v.RemovePropertiesByID(prop1.GetID(), prop2.GetID()) err := filteredV.Validate(mockValidatorStruct{}) assert.NoError(t, err) @@ -422,13 +419,12 @@ func TestValidatorRemoveProperties(t *testing.T) { t.Run("remove non-existent property", func(t *testing.T) { err1 := errors.New("error1") - v := govy.New( - govy.For(func(m mockValidatorStruct) string { return "test1" }). - WithName("property1"). - Rules(govy.NewRule(func(v string) error { return err1 })), - ) + prop1 := govy.For(func(m mockValidatorStruct) string { return "test1" }). + WithName("property1"). + Rules(govy.NewRule(func(v string) error { return err1 })) + v := govy.New(prop1) - filteredV := v.RemoveProperties("nonExistent") + filteredV := v.RemovePropertiesByID("non-existent-uuid-12345") err := mustValidatorError(t, filteredV.Validate(mockValidatorStruct{})) assert.Require(t, assert.Len(t, err.Errors, 1)) @@ -443,7 +439,7 @@ func TestValidatorRemoveProperties(t *testing.T) { Rules(govy.NewRule(func(v string) error { return err1 })), ) - filteredV := v.RemoveProperties() + filteredV := v.RemovePropertiesByID() err := mustValidatorError(t, filteredV.Validate(mockValidatorStruct{})) assert.Require(t, assert.Len(t, err.Errors, 1)) @@ -453,16 +449,15 @@ func TestValidatorRemoveProperties(t *testing.T) { t.Run("original validator is unchanged", func(t *testing.T) { err1 := errors.New("error1") err2 := errors.New("error2") - v := govy.New( - govy.For(func(m mockValidatorStruct) string { return "test1" }). - WithName("property1"). - Rules(govy.NewRule(func(v string) error { return err1 })), - govy.For(func(m mockValidatorStruct) string { return "test2" }). - WithName("property2"). - Rules(govy.NewRule(func(v string) error { return err2 })), - ) + prop1 := govy.For(func(m mockValidatorStruct) string { return "test1" }). + WithName("property1"). + Rules(govy.NewRule(func(v string) error { return err1 })) + prop2 := govy.For(func(m mockValidatorStruct) string { return "test2" }). + WithName("property2"). + Rules(govy.NewRule(func(v string) error { return err2 })) + v := govy.New(prop1, prop2) - filteredV := v.RemoveProperties("property1") + filteredV := v.RemovePropertiesByID(prop1.GetID()) originalErr := mustValidatorError(t, v.Validate(mockValidatorStruct{})) assert.Len(t, originalErr.Errors, 2) @@ -485,18 +480,18 @@ func TestValidatorRemoveProperties(t *testing.T) { Rules(rules.EQ("expected")), ).WithID("nested-validator") - parentValidator := govy.New( - govy.For(func(p parent) nested { return p.Nested }). - WithName("nested"). - Include(nestedValidator), - ) + nestedProp := govy.For(func(p parent) nested { return p.Nested }). + WithName("nested"). + Include(nestedValidator) + + parentValidator := govy.New(nestedProp) obj := parent{Nested: nested{Value: "wrong"}} err := mustValidatorError(t, parentValidator.Validate(obj)) assert.Require(t, assert.Len(t, err.Errors, 1)) - filteredValidator := parentValidator.RemoveProperties("nested") + filteredValidator := parentValidator.RemovePropertiesByID(nestedProp.GetID()) filteredErr := filteredValidator.Validate(obj) assert.NoError(t, filteredErr) }) @@ -506,18 +501,18 @@ func TestValidatorRemoveProperties(t *testing.T) { Items []string } - v := govy.New( - govy.ForSlice(func(w withSlice) []string { return w.Items }). - WithName("items"). - Rules(rules.SliceMaxLength[[]string](1)), - ) + sliceProp := govy.ForSlice(func(w withSlice) []string { return w.Items }). + WithName("items"). + Rules(rules.SliceMaxLength[[]string](1)) + + v := govy.New(sliceProp) obj := withSlice{Items: []string{"a", "b"}} err := mustValidatorError(t, v.Validate(obj)) assert.Require(t, assert.Len(t, err.Errors, 1)) - filteredV := v.RemoveProperties("items") + filteredV := v.RemovePropertiesByID(sliceProp.GetID()) filteredErr := filteredV.Validate(obj) assert.NoError(t, filteredErr) }) @@ -527,18 +522,18 @@ func TestValidatorRemoveProperties(t *testing.T) { Data map[string]string } - v := govy.New( - govy.ForMap(func(w withMap) map[string]string { return w.Data }). - WithName("data"). - Rules(rules.MapMaxLength[map[string]string](1)), - ) + mapProp := govy.ForMap(func(w withMap) map[string]string { return w.Data }). + WithName("data"). + Rules(rules.MapMaxLength[map[string]string](1)) + + v := govy.New(mapProp) obj := withMap{Data: map[string]string{"a": "1", "b": "2"}} err := mustValidatorError(t, v.Validate(obj)) assert.Require(t, assert.Len(t, err.Errors, 1)) - filteredV := v.RemoveProperties("data") + filteredV := v.RemovePropertiesByID(mapProp.GetID()) filteredErr := filteredV.Validate(obj) assert.NoError(t, filteredErr) }) From 23101960e632ad50cf8b1e7017a8e927643d1527 Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Tue, 16 Jun 2026 18:13:57 +0200 Subject: [PATCH 4/5] fix: handle invalid template keys in String Avoid mutating the enum value before bounds checking so unknown keys render their original numeric value and valid lookups use a safe zero-based index. --- internal/messagetemplates/templatekey_string.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/messagetemplates/templatekey_string.go b/internal/messagetemplates/templatekey_string.go index e5d60e9..e3197a5 100644 --- a/internal/messagetemplates/templatekey_string.go +++ b/internal/messagetemplates/templatekey_string.go @@ -71,9 +71,9 @@ const _templateKey_name = "LengthTemplateMinLengthTemplateMaxLengthTemplateEQTem var _templateKey_index = [...]uint16{0, 14, 31, 48, 58, 69, 79, 90, 100, 111, 134, 154, 175, 195, 216, 246, 277, 307, 338, 363, 380, 393, 409, 432, 457, 482, 498, 520, 545, 569, 588, 605, 621, 639, 657, 675, 695, 715, 733, 755, 777, 801, 823, 842, 862, 890, 912, 933, 966, 986, 1007, 1029, 1051, 1088, 1099, 1118, 1142} func (i templateKey) String() string { - i -= 1 - if i < 0 || i >= templateKey(len(_templateKey_index)-1) { - return "templateKey(" + strconv.FormatInt(int64(i+1), 10) + ")" + idx := int(i) - 1 + if i < 1 || idx >= len(_templateKey_index)-1 { + return "templateKey(" + strconv.FormatInt(int64(i), 10) + ")" } - return _templateKey_name[_templateKey_index[i]:_templateKey_index[i+1]] + return _templateKey_name[_templateKey_index[idx]:_templateKey_index[idx+1]] } From 946b65406d54d8a500fd6a475c10cfd0a225b38e Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus Date: Tue, 16 Jun 2026 18:53:02 +0200 Subject: [PATCH 5/5] fix: assign ids to pointer and transformed properties Ensure `ForPointer` and `Transform` create generated property IDs so `RemovePropertiesByID` can filter those rules consistently with `For`. --- pkg/govy/rules.go | 2 ++ pkg/govy/validator_test.go | 49 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/pkg/govy/rules.go b/pkg/govy/rules.go index 060be13..e353970 100644 --- a/pkg/govy/rules.go +++ b/pkg/govy/rules.go @@ -20,6 +20,7 @@ func For[T, P any](getter PropertyGetter[T, P]) PropertyRules[T, P] { // validation will not proceed. func ForPointer[T, P any](getter PropertyGetter[*T, P]) PropertyRules[T, P] { return PropertyRules[T, P]{ + id: newInstanceID(), pathFunc: getInferPathFunc(getCallersAndProgramCounter(4)), getter: func(parent P) (indirect T, err error) { ptr := getter(parent) @@ -40,6 +41,7 @@ func ForPointer[T, P any](getter PropertyGetter[*T, P]) PropertyRules[T, P] { func Transform[T, N, P any](getter PropertyGetter[T, P], transform Transformer[T, N]) PropertyRules[N, P] { typInfo := typeinfo.Get[T]() return PropertyRules[N, P]{ + id: newInstanceID(), pathFunc: getInferPathFunc(getCallersAndProgramCounter(4)), transformGetter: func(parent P) (transformed N, original any, err error) { v := getter(parent) diff --git a/pkg/govy/validator_test.go b/pkg/govy/validator_test.go index 18fbb93..4c93acc 100644 --- a/pkg/govy/validator_test.go +++ b/pkg/govy/validator_test.go @@ -699,6 +699,55 @@ func TestValidatorRemovePropertiesByID(t *testing.T) { assert.Equal(t, jsonpath.Parse("property2"), err.Errors[0].PropertyPath) }) + t.Run("remove pointer property by generated ID", func(t *testing.T) { + type withPointers struct { + Primary *string + Secondary *string + } + + prop1 := govy.ForPointer(func(w withPointers) *string { return w.Primary }). + WithName("primary"). + Rules(rules.StringMinLength(3)) + prop2 := govy.ForPointer(func(w withPointers) *string { return w.Secondary }). + WithName("secondary"). + Rules(rules.StringMinLength(3)) + v := govy.New(prop1, prop2) + + filteredV := v.RemovePropertiesByID(prop1.GetID()) + err := mustValidatorError(t, filteredV.Validate(withPointers{ + Primary: ptr("a"), + Secondary: ptr("b"), + })) + + assert.Require(t, assert.Len(t, err.Errors, 1)) + assert.Equal(t, jsonpath.Parse("secondary"), err.Errors[0].PropertyPath) + }) + + t.Run("remove transformed property by generated ID", func(t *testing.T) { + type withTransformed struct { + Primary string + Secondary string + } + transform := func(s string) (int, error) { return len(s), nil } + + prop1 := govy.Transform(func(w withTransformed) string { return w.Primary }, transform). + WithName("primary"). + Rules(rules.GT(1)) + prop2 := govy.Transform(func(w withTransformed) string { return w.Secondary }, transform). + WithName("secondary"). + Rules(rules.GT(1)) + v := govy.New(prop1, prop2) + + filteredV := v.RemovePropertiesByID(prop1.GetID()) + err := mustValidatorError(t, filteredV.Validate(withTransformed{ + Primary: "a", + Secondary: "b", + })) + + assert.Require(t, assert.Len(t, err.Errors, 1)) + assert.Equal(t, jsonpath.Parse("secondary"), err.Errors[0].PropertyPath) + }) + t.Run("remove all properties", func(t *testing.T) { prop1 := govy.For(func(m mockValidatorStruct) string { return "test1" }). WithName("property1").