diff --git a/validation/engine.go b/validation/engine.go index e0bf7c42f..19557593e 100644 --- a/validation/engine.go +++ b/validation/engine.go @@ -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 } @@ -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), diff --git a/validation/engine_test.go b/validation/engine_test.go index 457a622e2..04c4f2165 100644 --- a/validation/engine_test.go +++ b/validation/engine_test.go @@ -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) { @@ -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"}}, @@ -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{ @@ -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{ diff --git a/validation/filters.go b/validation/filters.go index 3fd6ed556..948b8f2eb 100644 --- a/validation/filters.go +++ b/validation/filters.go @@ -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) }, @@ -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 { diff --git a/validation/rules_test.go b/validation/rules_test.go index 5b04e89d8..7065902f4 100644 --- a/validation/rules_test.go +++ b/validation/rules_test.go @@ -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 ===== diff --git a/validation/utils.go b/validation/utils.go index 8af8951e2..77957f95e 100644 --- a/validation/utils.go +++ b/validation/utils.go @@ -107,12 +107,6 @@ func dotGet(data any, segments []string) (any, bool) { return nil, false } return dotGet(val, remaining) - case []any: - idx, err := strconv.Atoi(segment) - if err != nil || idx < 0 || idx >= len(v) { - return nil, false - } - return dotGet(v[idx], remaining) case []map[string]any: idx, err := strconv.Atoi(segment) if err != nil || idx < 0 || idx >= len(v) { @@ -120,6 +114,20 @@ func dotGet(data any, segments []string) (any, bool) { } return dotGet(v[idx], remaining) default: + if data == nil { + return nil, false + } + rv := reflect.ValueOf(data) + if !rv.IsValid() { + return nil, false + } + if rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array { + idx, err := strconv.Atoi(segment) + if err != nil || idx < 0 || idx >= rv.Len() { + return nil, false + } + return dotGet(rv.Index(idx).Interface(), remaining) + } return nil, false } } @@ -180,6 +188,229 @@ func dotSet(data map[string]any, segments []string, val any) { } } +// setValidated sets a value in nested maps/slices using path segments while +// preserving container shape from source data for validated output. +func setValidated(current map[string]any, source any, segments []string, val any) { + if len(segments) == 0 { + return + } + + segment := segments[0] + if len(segments) == 1 { + current[segment] = val + return + } + + sourceChild, sourceExists := getValidatedChild(source, segment) + useSlice := sourceExists && isIndexSegment(segments[1]) && isSliceOrArray(sourceChild) + + next, exists := current[segment] + if !exists || !isExpectedContainer(next, useSlice) { + if useSlice { + nextIdx, _ := strconv.Atoi(segments[1]) + next = make([]any, nextIdx+1) + } else { + next = make(map[string]any) + } + } + + if useSlice { + nextSlice, ok := toAnySlice(next) + if !ok { + nextIdx, _ := strconv.Atoi(segments[1]) + nextSlice = make([]any, nextIdx+1) + } + current[segment] = setValidatedOnSlice(nextSlice, sourceChild, segments[1:], val) + return + } + + nextMap, ok := next.(map[string]any) + if !ok { + nextMap = make(map[string]any) + } + setValidated(nextMap, sourceChild, segments[1:], val) + current[segment] = nextMap +} + +func setValidatedOnSlice(current []any, source any, segments []string, val any) []any { + if len(segments) == 0 { + return current + } + + idx, err := strconv.Atoi(segments[0]) + if err != nil || idx < 0 { + return current + } + + current = ensureAnySliceLen(current, idx+1) + if len(segments) == 1 { + current[idx] = val + return current + } + + sourceChild, sourceExists := getValidatedChild(source, segments[0]) + useSlice := sourceExists && isIndexSegment(segments[1]) && isSliceOrArray(sourceChild) + + if useSlice { + existingSlice, ok := toAnySlice(current[idx]) + if !ok { + nextIdx, _ := strconv.Atoi(segments[1]) + existingSlice = make([]any, nextIdx+1) + } + current[idx] = setValidatedOnSlice(existingSlice, sourceChild, segments[1:], val) + return current + } + + existingMap, ok := current[idx].(map[string]any) + if !ok { + existingMap = make(map[string]any) + } + setValidated(existingMap, sourceChild, segments[1:], val) + current[idx] = existingMap + return current +} + +func isExpectedContainer(val any, wantSlice bool) bool { + if wantSlice { + _, ok := toAnySlice(val) + return ok + } + _, ok := val.(map[string]any) + return ok +} + +func isIndexSegment(segment string) bool { + idx, err := strconv.Atoi(segment) + return err == nil && idx >= 0 +} + +func isSliceOrArray(val any) bool { + if val == nil { + return false + } + rv := reflect.ValueOf(val) + return rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array +} + +func ensureAnySliceLen(in []any, n int) []any { + if len(in) >= n { + return in + } + return append(in, make([]any, n-len(in))...) +} + +func toAnySlice(val any) ([]any, bool) { + switch v := val.(type) { + case []any: + return v, true + default: + if v == nil { + return nil, false + } + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array { + return nil, false + } + out := make([]any, rv.Len()) + for i := 0; i < rv.Len(); i++ { + out[i] = rv.Index(i).Interface() + } + return out, true + } +} + +func getValidatedChild(source any, segment string) (any, bool) { + if source == nil { + return nil, false + } + + switch v := source.(type) { + case map[string]any: + child, ok := v[segment] + return child, ok + default: + rv := reflect.ValueOf(source) + if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array { + return nil, false + } + idx, err := strconv.Atoi(segment) + if err != nil || idx < 0 || idx >= rv.Len() { + return nil, false + } + return rv.Index(idx).Interface(), true + } +} + +// normalizeValidatedShape recursively normalizes validated output and converts +// []any back to source slice type when conversion is safe. +func normalizeValidatedShape(data any, source any) any { + switch v := data.(type) { + case map[string]any: + for key, child := range v { + sourceChild, _ := getValidatedChild(source, key) + v[key] = normalizeValidatedShape(child, sourceChild) + } + return v + case []any: + for i := range v { + sourceChild, _ := getValidatedChild(source, strconv.Itoa(i)) + v[i] = normalizeValidatedShape(v[i], sourceChild) + } + return convertAnySliceToSourceType(v, source) + default: + return data + } +} + +func convertAnySliceToSourceType(data []any, source any) any { + if source == nil { + return data + } + + sourceType := reflect.TypeOf(source) + switch sourceType.Kind() { + case reflect.Slice: + case reflect.Array: + sourceType = reflect.SliceOf(sourceType.Elem()) + default: + return data + } + + elemType := sourceType.Elem() + out := reflect.MakeSlice(sourceType, len(data), len(data)) + for i, item := range data { + if item == nil { + if canBeNil(elemType) { + out.Index(i).Set(reflect.Zero(elemType)) + continue + } + return data + } + + itemValue := reflect.ValueOf(item) + if itemValue.Type().AssignableTo(elemType) { + out.Index(i).Set(itemValue) + continue + } + if itemValue.Type().ConvertibleTo(elemType) { + out.Index(i).Set(itemValue.Convert(elemType)) + continue + } + return data + } + + return out.Interface() +} + +func canBeNil(t reflect.Type) bool { + switch t.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return true + default: + return false + } +} + // collectKeys recursively collects all dot-notation keys. func collectKeys(data any, prefix string, keys *[]string) { switch v := data.(type) { @@ -192,7 +423,7 @@ func collectKeys(data any, prefix string, keys *[]string) { *keys = append(*keys, fullKey) collectKeys(val, fullKey, keys) } - case []any: + case []map[string]any: for i, val := range v { fullKey := strconv.Itoa(i) if prefix != "" { @@ -201,14 +432,23 @@ func collectKeys(data any, prefix string, keys *[]string) { *keys = append(*keys, fullKey) collectKeys(val, fullKey, keys) } - case []map[string]any: - for i, val := range v { - fullKey := strconv.Itoa(i) - if prefix != "" { - fullKey = prefix + "." + strconv.Itoa(i) + default: + if v == nil { + return + } + rv := reflect.ValueOf(v) + if !rv.IsValid() { + return + } + if rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array { + for i := 0; i < rv.Len(); i++ { + fullKey := strconv.Itoa(i) + if prefix != "" { + fullKey = prefix + "." + strconv.Itoa(i) + } + *keys = append(*keys, fullKey) + collectKeys(rv.Index(i).Interface(), fullKey, keys) } - *keys = append(*keys, fullKey) - collectKeys(val, fullKey, keys) } } } diff --git a/validation/utils_test.go b/validation/utils_test.go index 9a84c6cab..dadafcd77 100644 --- a/validation/utils_test.go +++ b/validation/utils_test.go @@ -2,6 +2,7 @@ package validation import ( "context" + "mime/multipart" "net/url" "reflect" "testing" @@ -147,6 +148,18 @@ func TestDotGet(t *testing.T) { _, ok := dotGet("string", []string{"key"}) assert.False(t, ok) }) + + t.Run("nil intermediate does not panic", func(t *testing.T) { + data := map[string]any{"user": nil} + _, ok := dotGet(data, []string{"user", "name"}) + assert.False(t, ok) + }) + + t.Run("nil slice element does not panic", func(t *testing.T) { + data := map[string]any{"items": []any{nil, "b"}} + _, ok := dotGet(data, []string{"items", "0", "key"}) + assert.False(t, ok) + }) } func TestDotSet(t *testing.T) { @@ -200,6 +213,229 @@ func TestDotSet(t *testing.T) { }) } +func TestSetValidated(t *testing.T) { + t.Run("empty segments does nothing", func(t *testing.T) { + data := map[string]any{"name": "Alice"} + setValidated(data, map[string]any{"name": "Alice"}, []string{}, "Bob") + assert.Equal(t, map[string]any{"name": "Alice"}, data) + }) + + t.Run("creates slice containers based on source shape", func(t *testing.T) { + data := map[string]any{} + source := map[string]any{ + "tags": []any{"a", "b"}, + } + + setValidated(data, source, []string{"tags", "1"}, "B") + normalized := normalizeValidatedShape(data, source).(map[string]any) + + tags, ok := normalized["tags"].([]any) + assert.True(t, ok) + assert.Equal(t, []any{nil, "B"}, tags) + }) + + t.Run("preserves map container for numeric map key", func(t *testing.T) { + data := map[string]any{} + source := map[string]any{ + "meta": map[string]any{"0": "x"}, + } + + setValidated(data, source, []string{"meta", "0"}, "value") + + meta, ok := data["meta"].(map[string]any) + assert.True(t, ok) + assert.Equal(t, "value", meta["0"]) + }) + + t.Run("normalizes to typed slice when conversion is safe", func(t *testing.T) { + data := map[string]any{} + source := map[string]any{ + "scores": []int{1, 2}, + } + + setValidated(data, source, []string{"scores", "0"}, 1) + setValidated(data, source, []string{"scores", "1"}, 2) + + normalized := normalizeValidatedShape(data, source).(map[string]any) + scores, ok := normalized["scores"].([]int) + assert.True(t, ok) + assert.Equal(t, []int{1, 2}, scores) + }) + + t.Run("replaces incompatible existing container with map", func(t *testing.T) { + data := map[string]any{"meta": []any{"wrong"}} + source := map[string]any{ + "meta": map[string]any{}, + } + + setValidated(data, source, []string{"meta", "name"}, "goravel") + + meta, ok := data["meta"].(map[string]any) + assert.True(t, ok) + assert.Equal(t, "goravel", meta["name"]) + }) +} + +func TestSetValidatedOnSlice(t *testing.T) { + t.Run("empty segments returns current", func(t *testing.T) { + current := []any{"a"} + result := setValidatedOnSlice(current, nil, []string{}, "b") + assert.Equal(t, []any{"a"}, result) + }) + + t.Run("invalid index leaves current unchanged", func(t *testing.T) { + current := []any{"a"} + result := setValidatedOnSlice(current, nil, []string{"x"}, "b") + assert.Equal(t, []any{"a"}, result) + }) + + t.Run("single segment sets value", func(t *testing.T) { + current := []any{"a"} + result := setValidatedOnSlice(current, nil, []string{"0"}, "b") + assert.Equal(t, []any{"b"}, result) + }) + + t.Run("creates nested map when source is not slice", func(t *testing.T) { + current := []any{} + source := map[string]any{"name": "a"} + + result := setValidatedOnSlice(current, source, []string{"0", "name"}, "b") + item, ok := result[0].(map[string]any) + assert.True(t, ok) + assert.Equal(t, "b", item["name"]) + }) + + t.Run("creates nested slice when source child is slice", func(t *testing.T) { + current := []any{} + source := []any{ + []int{1, 2}, + } + + result := setValidatedOnSlice(current, source, []string{"0", "1"}, 99) + nested, ok := result[0].([]any) + assert.True(t, ok) + assert.Equal(t, []any{nil, 99}, nested) + }) +} + +func TestHelpersForValidatedShape(t *testing.T) { + t.Run("isExpectedContainer", func(t *testing.T) { + assert.True(t, isExpectedContainer([]any{"a"}, true)) + assert.True(t, isExpectedContainer(map[string]any{"a": 1}, false)) + assert.False(t, isExpectedContainer(map[string]any{"a": 1}, true)) + assert.False(t, isExpectedContainer([]any{"a"}, false)) + }) + + t.Run("isIndexSegment", func(t *testing.T) { + assert.True(t, isIndexSegment("0")) + assert.False(t, isIndexSegment("-1")) + assert.False(t, isIndexSegment("abc")) + }) + + t.Run("isSliceOrArray", func(t *testing.T) { + assert.True(t, isSliceOrArray([]int{1})) + assert.True(t, isSliceOrArray([1]int{1})) + assert.False(t, isSliceOrArray(nil)) + assert.False(t, isSliceOrArray("x")) + }) + + t.Run("ensureAnySliceLen", func(t *testing.T) { + assert.Equal(t, []any{"a", nil, nil}, ensureAnySliceLen([]any{"a"}, 3)) + assert.Equal(t, []any{"a"}, ensureAnySliceLen([]any{"a"}, 1)) + }) + + t.Run("toAnySlice", func(t *testing.T) { + v, ok := toAnySlice([]any{"a"}) + assert.True(t, ok) + assert.Equal(t, []any{"a"}, v) + + v, ok = toAnySlice([]int{1, 2}) + assert.True(t, ok) + assert.Equal(t, []any{1, 2}, v) + + v, ok = toAnySlice([2]int{3, 4}) + assert.True(t, ok) + assert.Equal(t, []any{3, 4}, v) + + _, ok = toAnySlice(nil) + assert.False(t, ok) + + _, ok = toAnySlice("not-slice") + assert.False(t, ok) + }) + + t.Run("getValidatedChild", func(t *testing.T) { + _, ok := getValidatedChild(nil, "name") + assert.False(t, ok) + + child, ok := getValidatedChild(map[string]any{"name": "goravel"}, "name") + assert.True(t, ok) + assert.Equal(t, "goravel", child) + + _, ok = getValidatedChild(map[string]any{"name": "goravel"}, "missing") + assert.False(t, ok) + + child, ok = getValidatedChild([]any{"a", "b"}, "1") + assert.True(t, ok) + assert.Equal(t, "b", child) + + _, ok = getValidatedChild([]any{"a", "b"}, "x") + assert.False(t, ok) + + _, ok = getValidatedChild("plain", "0") + assert.False(t, ok) + }) +} + +func TestConvertAnySliceToSourceType(t *testing.T) { + t.Run("returns original for nil source", func(t *testing.T) { + data := []any{1} + assert.Equal(t, data, convertAnySliceToSourceType(data, nil)) + }) + + t.Run("returns original for non-slice source", func(t *testing.T) { + data := []any{1} + assert.Equal(t, data, convertAnySliceToSourceType(data, "x")) + }) + + t.Run("converts to typed slice when assignable", func(t *testing.T) { + data := []any{1, 2} + result := convertAnySliceToSourceType(data, []int{0}) + assert.Equal(t, []int{1, 2}, result) + }) + + t.Run("converts to typed slice when convertible", func(t *testing.T) { + data := []any{int32(1), int32(2)} + result := convertAnySliceToSourceType(data, []int{0}) + assert.Equal(t, []int{1, 2}, result) + }) + + t.Run("returns original when nil element cannot be represented", func(t *testing.T) { + data := []any{nil} + assert.Equal(t, data, convertAnySliceToSourceType(data, []int{0})) + }) + + t.Run("keeps nil for nil-able element type", func(t *testing.T) { + data := []any{nil} + result := convertAnySliceToSourceType(data, []*int{}) + typed, ok := result.([]*int) + assert.True(t, ok) + assert.Len(t, typed, 1) + assert.Nil(t, typed[0]) + }) + + t.Run("returns original when element type mismatches", func(t *testing.T) { + data := []any{"x"} + assert.Equal(t, data, convertAnySliceToSourceType(data, []int{0})) + }) + + t.Run("array source converts to slice type", func(t *testing.T) { + data := []any{1, 2} + result := convertAnySliceToSourceType(data, [2]int{}) + assert.Equal(t, []int{1, 2}, result) + }) +} + func TestCollectKeys(t *testing.T) { t.Run("flat map", func(t *testing.T) { data := map[string]any{"a": 1, "b": 2} @@ -250,6 +486,21 @@ func TestCollectKeys(t *testing.T) { assert.Contains(t, keys, "arr.0") assert.Contains(t, keys, "arr.1") }) + + t.Run("nil map value does not panic", func(t *testing.T) { + data := map[string]any{"name": nil} + var keys []string + assert.NotPanics(t, func() { collectKeys(data, "", &keys) }) + assert.Contains(t, keys, "name") + }) + + t.Run("nil slice element does not panic", func(t *testing.T) { + data := []any{nil, "b"} + var keys []string + assert.NotPanics(t, func() { collectKeys(data, "items", &keys) }) + assert.Contains(t, keys, "items.0") + assert.Contains(t, keys, "items.1") + }) } func TestExpandWildcardFields(t *testing.T) { @@ -471,6 +722,22 @@ func TestGetSize(t *testing.T) { _, ok = getSize("not-array", "array") assert.False(t, ok) }) + + t.Run("file", func(t *testing.T) { + size, ok := getSize(&multipart.FileHeader{Size: 2048}, "file") + assert.True(t, ok) + assert.Equal(t, float64(2), size) + + size, ok = getSize([]*multipart.FileHeader{ + {Size: 1024}, + {Size: 3072}, + }, "file") + assert.True(t, ok) + assert.Equal(t, float64(4), size) + + _, ok = getSize("not-file", "file") + assert.False(t, ok) + }) } func TestParseDateValue(t *testing.T) { @@ -757,3 +1024,15 @@ func TestGetFileExtension(t *testing.T) { }) } } + +func TestStrToInts(t *testing.T) { + assert.Equal(t, []int{1, 2, 3}, strToInts("1,2,3")) + assert.Equal(t, []int{1, 0, 3}, strToInts("1, nope, 3")) + assert.Equal(t, []int{}, strToInts(" , , ")) +} + +func TestStrToArray(t *testing.T) { + assert.Equal(t, []string{"a", "b", "c"}, strToArray("a,b,c")) + assert.Equal(t, []string{"a", "b"}, strToArray(" a , , b ")) + assert.Equal(t, []string{}, strToArray(" , ")) +} diff --git a/validation/validation_test.go b/validation/validation_test.go index 6423a8488..5e78d75a6 100644 --- a/validation/validation_test.go +++ b/validation/validation_test.go @@ -1045,6 +1045,50 @@ func TestValidated(t *testing.T) { assert.False(t, exists) } +func TestMapRules(t *testing.T) { + validation := NewValidation() + + t.Run("map rule fails when nested key does not exist", func(t *testing.T) { + validator, err := validation.Make(context.Background(), map[string]any{ + "users": map[string]any{}, + }, map[string]any{ + "users.name": "required", + }) + assert.Nil(t, err) + assert.True(t, validator.Fails()) + assert.Equal(t, map[string]string{ + "required": "The users.name field is required.", + }, validator.Errors().Get("users.name")) + }) + + t.Run("map rule fails when nested key is empty", func(t *testing.T) { + validator, err := validation.Make(context.Background(), map[string]any{ + "users": map[string]any{ + "name": "", + }, + }, map[string]any{ + "users.name": "required", + }) + assert.Nil(t, err) + assert.True(t, validator.Fails()) + assert.Equal(t, map[string]string{ + "required": "The users.name field is required.", + }, validator.Errors().Get("users.name")) + }) + + t.Run("map rule succeeds when nested key is present", func(t *testing.T) { + validator, err := validation.Make(context.Background(), map[string]any{ + "users": map[string]any{ + "name": "Alice", + }, + }, map[string]any{ + "users.name": "required", + }) + assert.Nil(t, err) + assert.False(t, validator.Fails()) + }) +} + func TestWildcardRules(t *testing.T) { validation := NewValidation() @@ -1073,6 +1117,36 @@ func TestWildcardRules(t *testing.T) { assert.Nil(t, err) assert.False(t, validator.Fails()) }) + + t.Run("validates wildcard fields with typed string slice", func(t *testing.T) { + validator, err := validation.Make(context.Background(), map[string]any{ + "scores": []string{"a", "b"}, + }, map[string]any{ + "scores.*": "required|string", + }) + assert.Nil(t, err) + assert.False(t, validator.Fails()) + }) + + t.Run("validates wildcard fields with typed int slice", func(t *testing.T) { + validator, err := validation.Make(context.Background(), map[string]any{ + "scores": []int{1, 2}, + }, map[string]any{ + "scores.*": "required|int", + }) + assert.Nil(t, err) + assert.False(t, validator.Fails()) + }) + + t.Run("validates wildcard fields with []any primitive array", func(t *testing.T) { + validator, err := validation.Make(context.Background(), map[string]any{ + "scores": []any{float64(1), float64(2)}, + }, map[string]any{ + "scores.*": "required|int", + }) + assert.Nil(t, err) + assert.False(t, validator.Fails()) + }) } func TestSliceRuleSyntax(t *testing.T) {