From fb12b2237d2b6e0db5e4539727d0408cfd01c2da Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 19 Sep 2026 15:33:16 +0200 Subject: [PATCH] fix(05): WR-03 numeric between/min/max without omitempty or length tags --- lagoon/validate.go | 98 +++++++++++++++++++++++++++++++---------- lagoon/validate_test.go | 19 ++++++-- 2 files changed, 91 insertions(+), 26 deletions(-) diff --git a/lagoon/validate.go b/lagoon/validate.go index 9201526..f0f8f55 100644 --- a/lagoon/validate.go +++ b/lagoon/validate.go @@ -7,6 +7,7 @@ import ( "math/big" "reflect" "regexp" + "strconv" "strings" "git.golem15.com/golem15/summercms/phrasebook" @@ -46,18 +47,19 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri required := false var tags []string var uniqueTable string - var moneyMin, moneyMax string + var betweenMin, betweenMax, minArg, maxArg string hasNumeric := false + hasInteger := false for _, tok := range tokens { name, arg, _ := strings.Cut(tok, ":") switch name { case "nullable": nullable = true - tags = append(tags, "omitempty") case "required": required = true tags = append(tags, "required") case "integer": + hasInteger = true if !isIntegerValue(val) && !isEmptyValue(val) { return []string{validateMessage(ctx, tr, "integer", field, nil)}, nil } @@ -69,19 +71,11 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri if !ok { return nil, fmt.Errorf("lagoon: unrecognized validation rule %q", tok) } - tags = append(tags, "min="+x, "max="+y) + betweenMin, betweenMax = x, y case "min": - if hasNumeric { - moneyMin = arg - } else { - tags = append(tags, "min="+arg) - } + minArg = arg case "max": - if hasNumeric { - moneyMax = arg - } else { - tags = append(tags, "max="+arg) - } + maxArg = arg case "in": tags = append(tags, oneofTag(strings.Split(arg, ","))) case "unique": @@ -95,14 +89,40 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri if nullable && isEmptyValue(val) && !required { return nil, nil } - if hasNumeric && (moneyMin != "" || moneyMax != "") { - s := strings.TrimSpace(fmt.Sprint(val)) - r := new(big.Rat) - if _, ok := r.SetString(s); !ok { - return []string{validateMessage(ctx, tr, "numeric", field, nil)}, nil + numericRange := hasNumeric || hasInteger + var rangeMin, rangeMax string + if numericRange { + if betweenMin != "" { + rangeMin, rangeMax = betweenMin, betweenMax } - if !moneyInRange(val, moneyMin, moneyMax) { - return []string{validateMessage(ctx, tr, "max", field, map[string]string{"max": moneyMax, "min": moneyMin})}, nil + if minArg != "" { + rangeMin = minArg + } + if maxArg != "" { + rangeMax = maxArg + } + } else { + if betweenMin != "" { + tags = append(tags, "min="+betweenMin, "max="+betweenMax) + } + if minArg != "" { + tags = append(tags, "min="+minArg) + } + if maxArg != "" { + tags = append(tags, "max="+maxArg) + } + } + if numericRange && (rangeMin != "" || rangeMax != "") { + s, ok := numericString(val) + if !ok { + ruleName := "numeric" + if hasInteger { + ruleName = "integer" + } + return []string{validateMessage(ctx, tr, ruleName, field, nil)}, nil + } + if !moneyInRange(s, rangeMin, rangeMax) { + return []string{validateMessage(ctx, tr, "max", field, map[string]string{"max": rangeMax, "min": rangeMin})}, nil } tags = withoutTag(tags, "numeric") } @@ -204,10 +224,42 @@ func isIntegerValue(val any) bool { } } +func numericString(val any) (string, bool) { + if val == nil { + return "", false + } + rv := reflect.ValueOf(val) + for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface { + if rv.IsNil() { + return "", false + } + rv = rv.Elem() + } + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(rv.Int(), 10), true + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return strconv.FormatUint(rv.Uint(), 10), true + case reflect.Float32, reflect.Float64: + return strconv.FormatFloat(rv.Float(), 'f', -1, 64), true + case reflect.String: + s := strings.TrimSpace(rv.String()) + if s == "" { + return "", false + } + return s, true + default: + return "", false + } +} + func moneyInRange(val any, min, max string) bool { - s := strings.TrimSpace(fmt.Sprint(val)) - if s == "" || s == "" { - return true + s, ok := numericString(val) + if !ok { + s = strings.TrimSpace(fmt.Sprint(val)) + if s == "" || s == "" { + return true + } } r := new(big.Rat) if _, ok := r.SetString(s); !ok { diff --git a/lagoon/validate_test.go b/lagoon/validate_test.go index d768f63..e72ceee 100644 --- a/lagoon/validate_test.go +++ b/lagoon/validate_test.go @@ -35,9 +35,10 @@ func TestValidateIntegerPointerAndJSONNumber(t *testing.T) { year := 1991 var nilYear *int pass := map[string]any{ - "*int": &year, - "nil *int": nilYear, - "float64": float64(1991), + "*int": &year, + "nil *int": nilYear, + "float64": float64(1991), + "digit string": "1991", } for name, val := range pass { errs, err := Validate(t.Context(), nil, nil, rules, map[string]any{"year": val}, nil) @@ -48,12 +49,17 @@ func TestValidateIntegerPointerAndJSONNumber(t *testing.T) { t.Fatalf("%s must pass, got %v", name, errs) } } + zero := 0 early := 1700 fail := map[string]any{ "fractional float": 1991.5, "out of range *int": &early, "bool": true, "non-digit string": "19x1", + "int 0": 0, + "float64 0": float64(0), + "*int 0": &zero, + "string 0": "0", } for name, val := range fail { errs, err := Validate(t.Context(), nil, nil, rules, map[string]any{"year": val}, nil) @@ -100,6 +106,13 @@ func TestValidateMoneyRange(t *testing.T) { if len(errs) != 0 { t.Fatalf("25.0000 = %v", errs) } + errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{"market_price_stored": 0}, nil) + if err != nil { + t.Fatal(err) + } + if len(errs) != 0 { + t.Fatalf("numeric 0 must pass min:0, got %v", errs) + } errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{"market_price_stored": nil}, nil) if err != nil { t.Fatal(err)