diff --git a/lagoon/validate.go b/lagoon/validate.go index 98d4f01..9201526 100644 --- a/lagoon/validate.go +++ b/lagoon/validate.go @@ -3,6 +3,7 @@ package lagoon import ( "context" "fmt" + "math" "math/big" "reflect" "regexp" @@ -169,23 +170,36 @@ func isEmptyValue(val any) bool { } } +// isIntegerValue reports whether val is an integer in Laravel's sense. It +// dereferences pointers (model fields such as *int) and accepts whole +// floating-point numbers, the shape encoding/json gives a map[string]any. func isIntegerValue(val any) bool { - switch v := val.(type) { - case nil: + if val == nil { return true - case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: - return true - case string: - if v == "" { + } + rv := reflect.ValueOf(val) + for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface { + if rv.IsNil() { return true } - _, ok := new(big.Int).SetString(v, 10) + rv = rv.Elem() + } + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return true + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return true + case reflect.Float32, reflect.Float64: + f := rv.Float() + return f == math.Trunc(f) && !math.IsInf(f, 0) + case reflect.String: + s := rv.String() + if s == "" { + return true + } + _, ok := new(big.Int).SetString(s, 10) return ok default: - rv := reflect.ValueOf(val) - if rv.Kind() == reflect.Ptr && rv.IsNil() { - return true - } return false } } diff --git a/lagoon/validate_test.go b/lagoon/validate_test.go index 348d9ab..d768f63 100644 --- a/lagoon/validate_test.go +++ b/lagoon/validate_test.go @@ -30,6 +30,42 @@ func TestValidateBetweenYear(t *testing.T) { } } +func TestValidateIntegerPointerAndJSONNumber(t *testing.T) { + rules := map[string]string{"year": "nullable|integer|between:1889,2100"} + year := 1991 + var nilYear *int + pass := map[string]any{ + "*int": &year, + "nil *int": nilYear, + "float64": float64(1991), + } + for name, val := range pass { + errs, err := Validate(t.Context(), nil, nil, rules, map[string]any{"year": val}, nil) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if len(errs) != 0 { + t.Fatalf("%s must pass, got %v", name, errs) + } + } + early := 1700 + fail := map[string]any{ + "fractional float": 1991.5, + "out of range *int": &early, + "bool": true, + "non-digit string": "19x1", + } + for name, val := range fail { + errs, err := Validate(t.Context(), nil, nil, rules, map[string]any{"year": val}, nil) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if len(errs["year"]) == 0 { + t.Fatalf("%s must fail", name) + } + } +} + func TestValidateOneofQuotedSpace(t *testing.T) { rules := map[string]string{"format": `nullable|in:LP,2LP,CD,2CD,MC,Box,EP 7"`} errs, err := Validate(t.Context(), nil, nil, rules, map[string]any{"format": `EP 7"`}, nil)