Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions internal/uuid/uuid.go
Original file line number Diff line number Diff line change
@@ -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:])
}
54 changes: 54 additions & 0 deletions internal/uuid/uuid_test.go
Original file line number Diff line number Diff line change
@@ -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')
})
}
27 changes: 27 additions & 0 deletions pkg/govy/identifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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) GetID() string {
if i.userSuppliedID != "" {
return i.userSuppliedID
}
return i.generatedID
}
17 changes: 17 additions & 0 deletions pkg/govy/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -60,6 +62,7 @@ func Transform[T, N, P any](getter PropertyGetter[T, P], transform Transformer[T
// It wraps the getter function in [internalPropertyGetter] and adds path inference.
func forConstructor[T, P any](getter PropertyGetter[T, P]) PropertyRules[T, P] {
return PropertyRules[T, P]{
id: newInstanceID(),
pathFunc: getInferPathFunc(getCallersAndProgramCounter(4)),
getter: func(parent P) (v T, err error) { return getter(parent), nil },
}
Expand All @@ -69,6 +72,7 @@ func forConstructor[T, P any](getter PropertyGetter[T, P]) PropertyRules[T, P] {
// Used for internal rules in [ForSlice] and [ForMap] where paths are managed separately.
func forConstructorWithoutPathInference[T, P any](getter PropertyGetter[T, P]) PropertyRules[T, P] {
return PropertyRules[T, P]{
id: newInstanceID(),
getter: func(parent P) (v T, err error) { return getter(parent), nil },
}
}
Expand Down Expand Up @@ -98,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 instanceID
path jsonpath.Path
pathFunc inferPathFunc
getter internalPropertyGetter[T, P]
Expand Down Expand Up @@ -186,6 +191,13 @@ func (r PropertyRules[T, P]) WithPath(path jsonpath.Path) PropertyRules[T, P] {
return r
}

// WithID sets a unique identifier for these property rules.
// The identifier can be used with [Validator.RemovePropertiesByID].
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...)
Expand Down Expand Up @@ -253,6 +265,11 @@ func (r PropertyRules[T, P]) InferPath(mode InferPathMode) PropertyRules[T, P] {
return r
}

// GetID returns the identifier for these property rules.
func (r PropertyRules[T, P]) GetID() string {
return r.id.GetID()
}

// cascadeInternal is an internal wrapper around [PropertyRules.Cascade] which
// fulfills [PropertyRulesInterface] interface.
// If the [CascadeMode] is already set, it won't change it.
Expand Down
11 changes: 11 additions & 0 deletions pkg/govy/rules_for_map.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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...)
Expand Down Expand Up @@ -206,6 +212,11 @@ func (r PropertyRulesForMap[M, K, V, P]) InferPath(mode InferPathMode) PropertyR
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.
Expand Down
11 changes: 11 additions & 0 deletions pkg/govy/rules_for_slice.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand Down Expand Up @@ -133,6 +139,11 @@ func (r PropertyRulesForSlice[S, T, P]) InferPath(mode InferPathMode) PropertyRu
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.
Expand Down
44 changes: 44 additions & 0 deletions pkg/govy/rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,50 @@ type mockStruct struct {
Field string `json:"field"`
}

func TestPropertyRulesWithID(t *testing.T) {
t.Run("property rules", 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("slice property rules", 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("map property rules", 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("WithID creates a copy", func(t *testing.T) {
original := govy.For(func(m mockStruct) string { return m.Field }).
WithName("field").
Rules(rules.EQ("test"))

originalID := original.GetID()
modified := original.WithID("custom-id")

assert.Equal(t, originalID, original.GetID())
assert.Equal(t, "custom-id", modified.GetID())
assert.True(t, originalID != modified.GetID())
})
}

func BenchmarkFor(b *testing.B) {
for b.Loop() {
_ = govy.For(func(m mockStruct) string { return m.Field })
Expand Down
1 change: 1 addition & 0 deletions pkg/govy/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type ValidatorInterface[T any] interface {
type PropertyRulesInterface[T any] interface {
validationInterface[T]
cascadeInternal(mode CascadeMode) PropertyRulesInterface[T]
GetID() string
getPath() jsonpath.Path
inferPathModeInternal(mode InferPathMode) PropertyRulesInterface[T]
isPropertyRules()
Expand Down
34 changes: 33 additions & 1 deletion pkg/govy/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@ import (

// 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
Expand All @@ -30,6 +34,12 @@ func (v Validator[T]) WithName(name string) Validator[T] {
return v
}

// WithID sets a unique identifier for this validator.
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] {
Expand Down Expand Up @@ -75,6 +85,23 @@ func (v Validator[T]) RemovePropertiesByPath(paths ...jsonpath.Path) Validator[T
return v
}

// RemovePropertiesByID removes any [PropertyRules] matching the provided identifiers.
// It returns a modified [Validator] instance without these rules,
// 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 {
if !slices.Contains(ids, prop.GetID()) {
filtered = append(filtered, prop)
}
}
v.props = filtered
return v
}

// 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] {
Expand All @@ -86,6 +113,11 @@ func (v Validator[T]) InferPath(mode InferPathMode) Validator[T] {
return v
}

// GetID returns the identifier for this validator.
func (v Validator[T]) GetID() string {
return v.id.GetID()
}

// 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].
Expand Down
Loading
Loading