Skip to content
Merged
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
18 changes: 11 additions & 7 deletions validation/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,13 @@ func (e *Engine) ValidatedData() map[string]any {
continue
}
if val, ok := e.data.Get(field); ok {
dotSet(result, strings.Split(field, "."), val)
setValidated(result, e.data.All(), strings.Split(field, "."), val)
}
}

if normalized, ok := normalizeValidatedShape(result, e.data.All()).(map[string]any); ok {
return normalized
}
return result
}

Expand Down Expand Up @@ -289,13 +292,14 @@ func (e *Engine) trackDistinct(field string, value any) bool {

// formatErrorMessage creates the error message for a rule failure.
func (e *Engine) formatErrorMessage(field string, rule ParsedRule, attrType string) string {
// Check for custom rule message
if customRule, ok := e.customRules[rule.Name]; ok {
msg := customRule.Message(e.ctx)
return strings.ReplaceAll(msg, ":attribute", getDisplayableAttribute(field, e.attributes))
}

msg := getMessage(field, rule.Name, e.messages, attrType)
if _, hasFieldRuleMessage := e.messages[field+"."+rule.Name]; !hasFieldRuleMessage {
if _, hasRuleMessage := e.messages[rule.Name]; !hasRuleMessage {
if customRule, ok := e.customRules[rule.Name]; ok {
msg = customRule.Message(e.ctx)
}
}
}

replacements := map[string]string{
":attribute": getDisplayableAttribute(field, e.attributes),
Expand Down
93 changes: 92 additions & 1 deletion validation/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,53 @@ func TestEngine_ValidatedData(t *testing.T) {
assert.Equal(t, "Alice", data["name"])
assert.NotContains(t, data, "secret")
})

t.Run("wildcard arrays are reconstructed as slices", func(t *testing.T) {
bag, _ := NewDataBag(map[string]any{
"tags": []any{"tag1", "tag2"},
"scores": []int{1, 2},
})
rules := map[string][]ParsedRule{
"tags.*": {{Name: "custom_pass"}},
"scores.*": {{Name: "custom_pass"}},
}
engine := NewEngine(context.Background(), bag, rules, engineOptions{
customRules: map[string]contractsvalidation.Rule{
"custom_pass": newAlwaysPassRule("custom_pass"),
},
})
engine.Validate()

data := engine.ValidatedData()

tags, ok := data["tags"].([]any)
assert.True(t, ok)
assert.Equal(t, []any{"tag1", "tag2"}, tags)

scores, ok := data["scores"].([]int)
assert.True(t, ok)
assert.Equal(t, []int{1, 2}, scores)
})

t.Run("sparse indexed wildcard falls back to []any with nil gaps", func(t *testing.T) {
bag, _ := NewDataBag(map[string]any{
"items": []int{10, 20, 30},
})
rules := map[string][]ParsedRule{
"items.2": {{Name: "custom_pass"}},
}
engine := NewEngine(context.Background(), bag, rules, engineOptions{
customRules: map[string]contractsvalidation.Rule{
"custom_pass": newAlwaysPassRule("custom_pass"),
},
})
engine.Validate()

data := engine.ValidatedData()
items, ok := data["items"].([]any)
assert.True(t, ok)
assert.Equal(t, []any{nil, nil, 30}, items)
})
}

func TestEngine_HandleExcludeRule(t *testing.T) {
Expand Down Expand Up @@ -356,6 +403,20 @@ func TestEngine_ExpandWildcardRules(t *testing.T) {
assert.Equal(t, expanded, expanded2)
}

func TestEngine_ExpandWildcardRules_TypedSlice(t *testing.T) {
bag, _ := NewDataBag(map[string]any{
"scores": []int{1, 2},
})
rules := map[string][]ParsedRule{
"scores.*": {{Name: "required"}},
}
engine := NewEngine(context.Background(), bag, rules, engineOptions{})

expanded := engine.expandWildcardRules()
assert.Contains(t, expanded, "scores.0")
assert.Contains(t, expanded, "scores.1")
}

func TestEngine_TrackDistinct(t *testing.T) {
rules := map[string][]ParsedRule{
"items.*.id": {{Name: "distinct"}},
Expand All @@ -375,7 +436,7 @@ func TestEngine_TrackDistinct(t *testing.T) {
}

func TestEngine_FormatErrorMessage(t *testing.T) {
t.Run("custom rule message", func(t *testing.T) {
t.Run("custom rule message without custom message override", func(t *testing.T) {
bag, _ := NewDataBag(map[string]any{})
engine := NewEngine(context.Background(), bag, nil, engineOptions{
customRules: map[string]contractsvalidation.Rule{
Expand All @@ -388,6 +449,36 @@ func TestEngine_FormatErrorMessage(t *testing.T) {
assert.Equal(t, "The Full Name is bad.", msg)
})

t.Run("custom field+rule message overrides custom rule message", func(t *testing.T) {
bag, _ := NewDataBag(map[string]any{})
engine := NewEngine(context.Background(), bag, nil, engineOptions{
customRules: map[string]contractsvalidation.Rule{
"custom_exists": newAlwaysFailRule("custom_exists", "The :attribute does not exist in custom rule."),
},
messages: map[string]string{
"f.custom_exists": "custom_exists failed for :attribute",
},
})

msg := engine.formatErrorMessage("f", ParsedRule{Name: "custom_exists"}, "string")
assert.Equal(t, "custom_exists failed for f", msg)
})

t.Run("custom rule message override overrides custom rule message", func(t *testing.T) {
bag, _ := NewDataBag(map[string]any{})
engine := NewEngine(context.Background(), bag, nil, engineOptions{
customRules: map[string]contractsvalidation.Rule{
"custom_exists": newAlwaysFailRule("custom_exists", "The :attribute does not exist in custom rule."),
},
messages: map[string]string{
"custom_exists": "custom_exists failed",
},
})

msg := engine.formatErrorMessage("f", ParsedRule{Name: "custom_exists"}, "string")
assert.Equal(t, "custom_exists failed", msg)
})

t.Run("custom message override", func(t *testing.T) {
bag, _ := NewDataBag(map[string]any{})
engine := NewEngine(context.Background(), bag, nil, engineOptions{
Expand Down
19 changes: 19 additions & 0 deletions validation/filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ var builtinFilters = map[string]func(val any) any{
"camelCase": func(val any) any { return toCamelCase(cast.ToString(val)) },
"snakeCase": func(val any) any { return toSnakeCase(cast.ToString(val)) },
"toInt": func(val any) any { return cast.ToInt(val) },
"integer": func(val any) any { return cast.ToInt(val) },
"toUint": func(val any) any { return cast.ToUint(val) },
"toInt64": func(val any) any { return cast.ToInt64(val) },
"toFloat": func(val any) any { return cast.ToFloat64(val) },
Expand All @@ -125,6 +126,24 @@ var builtinFilters = map[string]func(val any) any{
"escapeJs": func(val any) any { return escapeJS(cast.ToString(val)) },
"escapeJS": func(val any) any { return escapeJS(cast.ToString(val)) },
"urlEncode": func(val any) any { return url.QueryEscape(cast.ToString(val)) },
"ucFirst": func(val any) any {
s := cast.ToString(val)
if len(s) == 0 {
return s
}
runes := []rune(s)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
},
"lcFirst": func(val any) any {
s := cast.ToString(val)
if len(s) == 0 {
return s
}
runes := []rune(s)
runes[0] = unicode.ToLower(runes[0])
return string(runes)
},
"urlDecode": func(val any) any {
decoded, err := url.QueryUnescape(cast.ToString(val))
if err != nil {
Expand Down
55 changes: 46 additions & 9 deletions validation/rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3611,15 +3611,52 @@ func (s *RulesTestSuite) TestValidatedDataNestedDot() {
}

func (s *RulesTestSuite) TestValidatedDataWithWildcard() {
v := s.makeValidator(
map[string]any{"tags": []any{"go", "rust", "zig"}},
map[string]any{"tags.*": "required|string"},
)
s.False(v.Fails())
data := v.Validated()
tags, ok := data["tags"].(map[string]any)
s.True(ok)
s.Equal("go", tags["0"])
s.Run("any slice", func() {
v := s.makeValidator(
map[string]any{"tags": []any{"go", "rust", "zig"}},
map[string]any{"tags.*": "required|string"},
)
s.False(v.Fails())
data := v.Validated()
tags, ok := data["tags"].([]any)
s.True(ok)
s.Equal([]any{"go", "rust", "zig"}, tags)
})

s.Run("typed int slice", func() {
v := s.makeValidator(
map[string]any{"scores": []int{1, 2}},
map[string]any{"scores.*": "required|integer"},
)
s.False(v.Fails())
data := v.Validated()
scores, ok := data["scores"].([]int)
s.True(ok)
s.Equal([]int{1, 2}, scores)
})

s.Run("nested wildcard", func() {
v := s.makeValidator(
map[string]any{
"users": []any{
map[string]any{"name": "alice", "email": "alice@example.com"},
map[string]any{"name": "bob", "email": "bob@example.com"},
},
},
map[string]any{"users.*.name": "required|string"},
)
s.False(v.Fails())
data := v.Validated()
users, ok := data["users"].([]any)
s.True(ok)
s.Equal(
[]any{
map[string]any{"name": "alice"},
map[string]any{"name": "bob"},
},
users,
)
})
}

// ===== Error Bag Methods Tests =====
Expand Down
Loading
Loading