fix(05): WR-03 numeric between/min/max without omitempty or length tags

This commit is contained in:
Jakub Zych
2026-09-19 15:33:16 +02:00
parent e54f9d7f10
commit fb12b2237d
2 changed files with 91 additions and 26 deletions

View File

@@ -7,6 +7,7 @@ import (
"math/big" "math/big"
"reflect" "reflect"
"regexp" "regexp"
"strconv"
"strings" "strings"
"git.golem15.com/golem15/summercms/phrasebook" "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 required := false
var tags []string var tags []string
var uniqueTable string var uniqueTable string
var moneyMin, moneyMax string var betweenMin, betweenMax, minArg, maxArg string
hasNumeric := false hasNumeric := false
hasInteger := false
for _, tok := range tokens { for _, tok := range tokens {
name, arg, _ := strings.Cut(tok, ":") name, arg, _ := strings.Cut(tok, ":")
switch name { switch name {
case "nullable": case "nullable":
nullable = true nullable = true
tags = append(tags, "omitempty")
case "required": case "required":
required = true required = true
tags = append(tags, "required") tags = append(tags, "required")
case "integer": case "integer":
hasInteger = true
if !isIntegerValue(val) && !isEmptyValue(val) { if !isIntegerValue(val) && !isEmptyValue(val) {
return []string{validateMessage(ctx, tr, "integer", field, nil)}, nil 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 { if !ok {
return nil, fmt.Errorf("lagoon: unrecognized validation rule %q", tok) return nil, fmt.Errorf("lagoon: unrecognized validation rule %q", tok)
} }
tags = append(tags, "min="+x, "max="+y) betweenMin, betweenMax = x, y
case "min": case "min":
if hasNumeric { minArg = arg
moneyMin = arg
} else {
tags = append(tags, "min="+arg)
}
case "max": case "max":
if hasNumeric { maxArg = arg
moneyMax = arg
} else {
tags = append(tags, "max="+arg)
}
case "in": case "in":
tags = append(tags, oneofTag(strings.Split(arg, ","))) tags = append(tags, oneofTag(strings.Split(arg, ",")))
case "unique": case "unique":
@@ -95,14 +89,40 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
if nullable && isEmptyValue(val) && !required { if nullable && isEmptyValue(val) && !required {
return nil, nil return nil, nil
} }
if hasNumeric && (moneyMin != "" || moneyMax != "") { numericRange := hasNumeric || hasInteger
s := strings.TrimSpace(fmt.Sprint(val)) var rangeMin, rangeMax string
r := new(big.Rat) if numericRange {
if _, ok := r.SetString(s); !ok { if betweenMin != "" {
return []string{validateMessage(ctx, tr, "numeric", field, nil)}, nil rangeMin, rangeMax = betweenMin, betweenMax
} }
if !moneyInRange(val, moneyMin, moneyMax) { if minArg != "" {
return []string{validateMessage(ctx, tr, "max", field, map[string]string{"max": moneyMax, "min": moneyMin})}, nil 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") 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 { func moneyInRange(val any, min, max string) bool {
s := strings.TrimSpace(fmt.Sprint(val)) s, ok := numericString(val)
if s == "" || s == "<nil>" { if !ok {
return true s = strings.TrimSpace(fmt.Sprint(val))
if s == "" || s == "<nil>" {
return true
}
} }
r := new(big.Rat) r := new(big.Rat)
if _, ok := r.SetString(s); !ok { if _, ok := r.SetString(s); !ok {

View File

@@ -35,9 +35,10 @@ func TestValidateIntegerPointerAndJSONNumber(t *testing.T) {
year := 1991 year := 1991
var nilYear *int var nilYear *int
pass := map[string]any{ pass := map[string]any{
"*int": &year, "*int": &year,
"nil *int": nilYear, "nil *int": nilYear,
"float64": float64(1991), "float64": float64(1991),
"digit string": "1991",
} }
for name, val := range pass { for name, val := range pass {
errs, err := Validate(t.Context(), nil, nil, rules, map[string]any{"year": val}, nil) 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) t.Fatalf("%s must pass, got %v", name, errs)
} }
} }
zero := 0
early := 1700 early := 1700
fail := map[string]any{ fail := map[string]any{
"fractional float": 1991.5, "fractional float": 1991.5,
"out of range *int": &early, "out of range *int": &early,
"bool": true, "bool": true,
"non-digit string": "19x1", "non-digit string": "19x1",
"int 0": 0,
"float64 0": float64(0),
"*int 0": &zero,
"string 0": "0",
} }
for name, val := range fail { for name, val := range fail {
errs, err := Validate(t.Context(), nil, nil, rules, map[string]any{"year": val}, nil) 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 { if len(errs) != 0 {
t.Fatalf("25.0000 = %v", errs) 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) errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{"market_price_stored": nil}, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)