go-playground / validator

:100:Go Struct and Field validation, including Cross Field, Cross Struct, Map, Slice and Array diving
MIT License
16.64k stars 1.32k forks source link

Is it possible to allow null but deny empty string with null.String or sql.NullString? #1209

Closed pi-kei closed 8 months ago

pi-kei commented 9 months ago

Package version eg. v9, v10:

v10.16.0

Issue, Question or Enhancement:

There's an example for custom field types (e.g. null.String or sql.NullString) here But it doesn't cover the case to allow null value and deny empty string value. So I've tried to implement it by myself but turnes out that it is not possible and I need help.

Code sample, to showcase or reproduce:

We have only two options for conditional validation: omitempty and omitnil. omitempty is not for our case because it turns off validation on empty string. Here are some tests:

package main

import (
    "database/sql"
    "database/sql/driver"
    "reflect"
    "strings"
    "testing"

    "github.com/go-playground/validator/v10"
)

// ValidateValuer implements validator.CustomTypeFunc
func ValidateValuer(field reflect.Value) interface{} {

    if valuer, ok := field.Interface().(driver.Valuer); ok {

        val, err := valuer.Value()
        if err == nil {
            return val
        }
    }

    return nil
}

func TestNullNoOmitnil(t *testing.T) {
    validate := validator.New()
    validate.RegisterCustomTypeFunc(ValidateValuer, sql.NullString{}, sql.NullInt64{})
    x := struct {
        Name sql.NullString `validate:"gte=1,lte=255"`
        Age  sql.NullInt64  `validate:"gte=18"`
    }{Name: sql.NullString{String: "", Valid: false}, Age: sql.NullInt64{Int64: 0, Valid: false}}
    err := validate.Struct(x)
    if err == nil {
        t.Error("Validation passed but expected to fail")
    }
    errStr := err.Error()
    if !strings.Contains(errStr, "'Name' failed on the 'gte'") {
        t.Error("Validation error does not contain 'Name'")
    }
    if !strings.Contains(errStr, "'Age' failed on the 'gte'") {
        t.Error("Validation error does not contain 'Age'")
    }
}

func TestNull(t *testing.T) {
    validate := validator.New()
    validate.RegisterCustomTypeFunc(ValidateValuer, sql.NullString{}, sql.NullInt64{})
    x := struct {
        Name sql.NullString `validate:"omitnil,gte=1,lte=255"`
        Age  sql.NullInt64  `validate:"omitnil,gte=18"`
    }{Name: sql.NullString{String: "", Valid: false}, Age: sql.NullInt64{Int64: 0, Valid: false}}
    err := validate.Struct(x)
    if err != nil {
        t.Logf("Err(s):\n%+v\n", err)
        t.Error("Validation failed but expected to pass")
    }
}

func TestZero(t *testing.T) {
    validate := validator.New()
    validate.RegisterCustomTypeFunc(ValidateValuer, sql.NullString{}, sql.NullInt64{})
    x := struct {
        Name sql.NullString `validate:"omitnil,gte=1,lte=255"`
        Age  sql.NullInt64  `validate:"omitnil,gte=18"`
    }{Name: sql.NullString{String: "", Valid: true}, Age: sql.NullInt64{Int64: 0, Valid: true}}
    err := validate.Struct(x)
    if err == nil {
        t.Error("Validation passed but expected to fail")
    }
    errStr := err.Error()
    if !strings.Contains(errStr, "'Name' failed on the 'gte'") {
        t.Error("Validation error does not contain 'Name'")
    }
    if !strings.Contains(errStr, "'Age' failed on the 'gte'") {
        t.Error("Validation error does not contain 'Age'")
    }
}

func TestValid(t *testing.T) {
    validate := validator.New()
    validate.RegisterCustomTypeFunc(ValidateValuer, sql.NullString{}, sql.NullInt64{})
    x := struct {
        Name sql.NullString `validate:"omitnil,gte=1,lte=255"`
        Age  sql.NullInt64  `validate:"omitnil,gte=18"`
    }{Name: sql.NullString{String: "J", Valid: true}, Age: sql.NullInt64{Int64: 18, Valid: true}}
    err := validate.Struct(x)
    if err != nil {
        t.Logf("Err(s):\n%+v\n", err)
        t.Error("Validation failed but expected to pass")
    }
}
  1. TestNullNoOmitnil proves that we need some conditional validation.
  2. TestNull is failing. omitnil is not doing it's job.
  3. TestZero proves that gte is not allowing zero values.
  4. TestValid proves that valid values are passing validation.
dropwhile commented 9 months ago

I ran into a similar case with mo.Option1. I want to validate that if a value is Some (present) it isn't an empty string, while also allowing the option to be None (absent).

pi-kei commented 8 months ago

I found one solution (not sure if it is the best one). If I change ValidateValuer function to this:

var nilValue *bool

// ValidateValuer implements validator.CustomTypeFunc
func ValidateValuer(field reflect.Value) interface{} {

    if valuer, ok := field.Interface().(driver.Valuer); ok {

        val, err := valuer.Value()
        if err == nil {
            if val == nil {
                return nilValue
            }
            return val
        }
    }

    return nil
}

Notice return nilValue when val == nil. In this case omitnil works as expected.

dropwhile commented 8 months ago

@pi-kei your solution worked for me, with a slight modification (thanks!!):

func init() {
    Validate = validator.New(validator.WithRequiredStructEnabled())
    _ = Validate.RegisterValidation("notblank", validators.NotBlank)
    Validate.RegisterCustomTypeFunc(OptionValuer,
        mo.Option[string]{},
        mo.Option[bool]{},
        mo.Option[[]byte]{},
        mo.Option[[]int]{},
        mo.Option[time.Time]{},
    )
}
// ...

// OptionValuer implements validator.CustomTypeFunc for samber/mo.Option
func OptionValuer(field reflect.Value) interface{} {
    if valuer, ok := field.Interface().(driver.Valuer); ok {
        if val, err := valuer.Value(); err == nil {
            // return pointer here, so omitnil checks work
            // for validator.
            return &val
        }
    }
    return nil
}

// later example struct using validation
type ExampleOptionValues struct {
    StartTime     mo.Option[time.Time] `validate:"omitnil"`
    Name          mo.Option[string]    `validate:"omitnil,notblank"`
    Description   mo.Option[string]    `validate:"omitnil,notblank"`
    Tz            mo.Option[string]    `validate:"omitnil,timezone"`
    IntList       mo.Option[[]int]     `validate:"omitnil,gt=0"`
}

This allows me to skip mo.None values (via omitnil), while still ensuring notblank works (whereas omitempty was not working with notblank as it would skip a present but empty string mo.Some[string]("") for Name, Description, etc.

pi-kei commented 8 months ago

@dropwhile Have you tried to compile with escape analyzer? I'm just wondering if it says that val has moved to heap in OptionValuer function from your example.

dropwhile commented 8 months ago

@pi-kei Yes, it looks like val in this line does move to the heap:

val, err := valuer.Value()
escape(val escapes to heap)optimizer details
validate.go(51, 11): escflow: flow: ~r0 = &val:
validate.go(51, 11): escflow: from &val (address-of)
validate.go(51, 11): escflow: from &val (interface-converted)
validate.go(51, 4): escflow: from return &val (return)
canhlinh commented 2 days ago

This is the solution to work with null package

validate.RegisterCustomTypeFunc(validateNullType, null.Bool{}, null.String{}, null.Int{}, null.Float{}, null.Time{})

type NullType interface {
    IsZero() bool
    driver.Valuer
}

var nilString *string

func validateNullType(field reflect.Value) interface{} {
    if nullValue, ok := field.Interface().(NullType); ok {
        if nullValue.IsZero() {
            return nilString
        }
        if val, err := nullValue.Value(); err == nil {
            return val
        }
    }
    return nil
}

Test cases

func TestValidateNull(t *testing.T) {
    type Form struct {
        Name  null.String `json:"name" validate:"omitnil,required"`
        OK    null.Bool   `json:"ok" validate:"omitnil"`
        TagID null.String `json:"tag_id" validate:"omitnil,ulid"`
    }

    testCases := []struct {
        name  string
        form  Form
        error bool
    }{
        {
            name:  "empty form",
            form:  Form{},
            error: false,
        },
        {
            name: "name is empty",
            form: Form{
                Name: null.StringFrom(""),
            },
            error: true,
        },
        {
            name: "name is not empty",
            form: Form{
                Name: null.StringFrom("name"),
            },
            error: false,
        },
        {
            name: "tag is empty",
            form: Form{
                TagID: null.StringFrom(""),
            },
            error: true,
        },
        {
            name: "tag is not ULID",
            form: Form{
                TagID: null.StringFrom("tag"),
            },
            error: true,
        },
        {
            name: "tag is ULID",
            form: Form{
                TagID: null.NewString("01J95KAAMRW1MNPR39SA3SV99K", true),
            },
            error: false,
        },
    }
    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            if tc.error {
                assert.Error(t, validate.Struct(tc.form))
            } else {
                assert.NoError(t, validate.Struct(tc.form))
            }
        })
    }
}