diff --git a/go.mod b/go.mod index d75595d..ce0f704 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.27.0 require ( github.com/fsnotify/fsnotify v1.10.1 github.com/go-gormigrate/gormigrate/v2 v2.1.7 + github.com/go-playground/validator/v10 v10.30.4 github.com/goccy/go-yaml v1.19.2 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/jackc/pgx/v5 v5.10.0 @@ -20,7 +21,7 @@ require ( github.com/wneessen/go-mail v0.8.1 github.com/yuin/goldmark v1.8.6 golang.org/x/term v0.46.0 - golang.org/x/text v0.40.0 + golang.org/x/text v0.41.0 gorm.io/driver/postgres v1.6.3 gorm.io/gorm v1.31.2 ) @@ -42,9 +43,12 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.1 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.15 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -55,6 +59,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.18.6 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/leodido/go-urn v1.5.0 // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect @@ -86,7 +91,7 @@ require ( go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.54.0 // indirect + golang.org/x/crypto v0.55.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.48.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 81583e0..a114a06 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeO github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg= github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -50,6 +52,14 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w= +github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= @@ -92,6 +102,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= @@ -182,8 +194,8 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -194,8 +206,8 @@ golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/lagoon/fill.go b/lagoon/fill.go index ec8a413..6c15ba3 100644 --- a/lagoon/fill.go +++ b/lagoon/fill.go @@ -1,6 +1,8 @@ package lagoon import ( + "database/sql" + "encoding/json" "fmt" "log/slog" "reflect" @@ -95,8 +97,11 @@ func setField(field reflect.Value, val any) error { return fmt.Errorf("field cannot be set") } if val == nil { - if field.Kind() == reflect.Ptr { - field.Set(reflect.Zero(field.Type())) + field.Set(reflect.Zero(field.Type())) + if field.CanAddr() { + if scanner, ok := field.Addr().Interface().(sql.Scanner); ok { + return scanner.Scan(nil) + } } return nil } @@ -113,11 +118,33 @@ func setField(field reflect.Value, val any) error { return nil } converted, err := convertValue(src, field.Type()) - if err != nil { - return err + if err == nil { + field.Set(converted) + return nil + } + if field.CanAddr() { + if scanner, ok := field.Addr().Interface().(sql.Scanner); ok { + scanSrc, scanErr := fillScanSource(val) + if scanErr != nil { + return scanErr + } + return scanner.Scan(scanSrc) + } + } + return err +} + +func fillScanSource(val any) (any, error) { + switch v := val.(type) { + case string, []byte: + return v, nil + default: + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + return b, nil } - field.Set(converted) - return nil } func convertValue(src reflect.Value, destType reflect.Type) (reflect.Value, error) { diff --git a/lagoon/validate.go b/lagoon/validate.go new file mode 100644 index 0000000..7be3597 --- /dev/null +++ b/lagoon/validate.go @@ -0,0 +1,320 @@ +package lagoon + +import ( + "context" + "fmt" + "math/big" + "reflect" + "regexp" + "strings" + + "git.golem15.com/golem15/summercms/phrasebook" + "github.com/go-playground/validator/v10" + "gorm.io/gorm" +) + +var identName = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + +var validateOnce = validator.New(validator.WithRequiredStructEnabled()) + +// Validate translates Laravel-style rule strings onto validator.Var() plus a +// unique:table DB check. Unrecognized tokens fail loudly. The returned map is +// the Laravel-shaped errors object (field -> messages); the HTTP envelope is +// a later-phase concern. +func Validate(ctx context.Context, tx *gorm.DB, model any, rules map[string]string, values map[string]any, tr *phrasebook.Translator) (map[string][]string, error) { + out := map[string][]string{} + for field, rule := range rules { + val, _ := values[field] + msgs, err := validateField(ctx, tx, model, field, rule, val, tr) + if err != nil { + return nil, err + } + if len(msgs) > 0 { + out[field] = msgs + } + } + if len(out) == 0 { + return nil, nil + } + return out, nil +} + +func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule string, val any, tr *phrasebook.Translator) ([]string, error) { + tokens := splitRule(rule) + nullable := false + required := false + var tags []string + var uniqueTable string + var moneyMin, moneyMax string + hasNumeric := 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": + if !isIntegerValue(val) && !isEmptyValue(val) { + return []string{validateMessage(ctx, tr, "integer", field, nil)}, nil + } + case "numeric": + hasNumeric = true + tags = append(tags, "numeric") + case "between": + x, y, ok := strings.Cut(arg, ",") + if !ok { + return nil, fmt.Errorf("lagoon: unrecognized validation rule %q", tok) + } + tags = append(tags, "min="+x, "max="+y) + case "min": + if hasNumeric { + moneyMin = arg + } else { + tags = append(tags, "min="+arg) + } + case "max": + if hasNumeric { + moneyMax = arg + } else { + tags = append(tags, "max="+arg) + } + case "in": + tags = append(tags, oneofTag(strings.Split(arg, ","))) + case "unique": + uniqueTable = arg + default: + return nil, fmt.Errorf("lagoon: unrecognized validation rule %q", tok) + } + } + 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 + } + if !moneyInRange(val, moneyMin, moneyMax) { + return []string{validateMessage(ctx, tr, "max", field, map[string]string{"max": moneyMax, "min": moneyMin})}, nil + } + tags = withoutTag(tags, "numeric") + } + if len(tags) > 0 { + tag := strings.Join(tags, ",") + if err := validateOnce.Var(val, tag); err != nil { + ruleName := "required" + if !required { + ruleName = firstNonOmit(tags) + } + return []string{validateMessage(ctx, tr, ruleName, field, nil)}, nil + } + } + if uniqueTable != "" { + ok, err := uniqueOK(tx, model, uniqueTable, field, val) + if err != nil { + return nil, err + } + if !ok { + return []string{validateMessage(ctx, tr, "unique", field, nil)}, nil + } + } + return nil, nil +} + +func splitRule(rule string) []string { + var out []string + for _, p := range strings.Split(rule, "|") { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func oneofTag(vals []string) string { + parts := make([]string, 0, len(vals)) + for _, v := range vals { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if strings.ContainsAny(v, " \t\"'") { + parts = append(parts, "'"+v+"'") + } else { + parts = append(parts, v) + } + } + return "oneof=" + strings.Join(parts, " ") +} + +func isEmptyValue(val any) bool { + if val == nil { + return true + } + rv := reflect.ValueOf(val) + switch rv.Kind() { + case reflect.Ptr, reflect.Interface: + return rv.IsNil() + case reflect.String, reflect.Slice, reflect.Map: + return rv.Len() == 0 + default: + return false + } +} + +func isIntegerValue(val any) bool { + switch v := val.(type) { + case nil: + return true + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return true + case string: + if v == "" { + return true + } + _, ok := new(big.Int).SetString(v, 10) + return ok + default: + rv := reflect.ValueOf(val) + if rv.Kind() == reflect.Ptr && rv.IsNil() { + return true + } + return false + } +} + +func moneyInRange(val any, min, max string) bool { + s := strings.TrimSpace(fmt.Sprint(val)) + if s == "" || s == "" { + return true + } + r := new(big.Rat) + if _, ok := r.SetString(s); !ok { + return false + } + if min != "" { + m := new(big.Rat) + if _, ok := m.SetString(min); ok && r.Cmp(m) < 0 { + return false + } + } + if max != "" { + m := new(big.Rat) + if _, ok := m.SetString(max); ok && r.Cmp(m) > 0 { + return false + } + } + return true +} + +func uniqueOK(tx *gorm.DB, model any, table, column string, val any) (bool, error) { + if tx == nil { + return false, fmt.Errorf("lagoon: unique:%s requires a database handle", table) + } + if !identName.MatchString(table) || !identName.MatchString(column) { + return false, fmt.Errorf("lagoon: unique identifier %q.%q is not safe", table, column) + } + if isEmptyValue(val) { + return true, nil + } + q := tx.Table(table).Where(column+" = ?", val) + if tx.Migrator().HasColumn(table, "deleted_at") { + q = q.Where("deleted_at IS NULL") + } + if id := modelUintID(model); id != 0 && tx.Migrator().HasColumn(table, "id") { + q = q.Where("id <> ?", id) + } + var n int64 + if err := q.Count(&n).Error; err != nil { + return false, err + } + return n == 0, nil +} + +func modelUintID(model any) uint { + if model == nil { + return 0 + } + rv := reflect.ValueOf(model) + if rv.Kind() == reflect.Ptr { + if rv.IsNil() { + return 0 + } + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return 0 + } + f := rv.FieldByName("ID") + if !f.IsValid() { + return 0 + } + switch f.Kind() { + case reflect.Uint, reflect.Uint32, reflect.Uint64: + return uint(f.Uint()) + case reflect.Int, reflect.Int32, reflect.Int64: + if f.Int() < 0 { + return 0 + } + return uint(f.Int()) + } + return 0 +} + +func withoutTag(tags []string, name string) []string { + out := tags[:0] + for _, t := range tags { + n, _, _ := strings.Cut(t, "=") + if n != name { + out = append(out, t) + } + } + return out +} + +func firstNonOmit(tags []string) string { + for _, t := range tags { + name, _, _ := strings.Cut(t, "=") + if name != "omitempty" && name != "required" { + return name + } + } + return "invalid" +} + +func validateMessage(ctx context.Context, tr *phrasebook.Translator, rule, field string, params map[string]string) string { + if params == nil { + params = map[string]string{} + } + params["attribute"] = field + key := "lagoon.validate." + rule + if tr != nil { + s := tr.Get(ctx, key, params) + if s != "" && s != key { + return s + } + } + switch rule { + case "required": + return "The " + field + " field is required." + case "integer": + return "The " + field + " must be an integer." + case "numeric": + return "The " + field + " must be a number." + case "unique": + return "The " + field + " has already been taken." + case "max": + return "The " + field + " may not be greater than " + params["max"] + "." + case "min": + return "The " + field + " must be at least " + params["min"] + "." + case "oneof": + return "The selected " + field + " is invalid." + default: + return "The " + field + " is invalid." + } +} diff --git a/lagoon/validate_test.go b/lagoon/validate_test.go new file mode 100644 index 0000000..9d8b3ee --- /dev/null +++ b/lagoon/validate_test.go @@ -0,0 +1,114 @@ +package lagoon + +import ( + "strings" + "testing" +) + +func TestValidateBetweenYear(t *testing.T) { + rules := map[string]string{"year": "nullable|integer|between:1889,2100"} + errs, err := Validate(t.Context(), nil, nil, rules, map[string]any{"year": 1700}, nil) + if err != nil { + t.Fatal(err) + } + if len(errs["year"]) == 0 { + t.Fatal("year=1700 must fail") + } + errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{"year": nil}, nil) + if err != nil { + t.Fatal(err) + } + if len(errs) != 0 { + t.Fatalf("nil year = %v", errs) + } + errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{"year": 1991}, nil) + if err != nil { + t.Fatal(err) + } + if len(errs) != 0 { + t.Fatalf("1991 = %v", errs) + } +} + +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) + if err != nil { + t.Fatal(err) + } + if len(errs) != 0 { + t.Fatalf("EP 7\" rejected: %v", errs) + } + errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{"format": "tape"}, nil) + if err != nil { + t.Fatal(err) + } + if len(errs["format"]) == 0 { + t.Fatal("invalid format must fail") + } +} + +func TestValidateMoneyRange(t *testing.T) { + rules := map[string]string{"market_price_stored": "nullable|numeric|min:0|max:999999.9999"} + errs, err := Validate(t.Context(), nil, nil, rules, map[string]any{"market_price_stored": "1000000.0000"}, nil) + if err != nil { + t.Fatal(err) + } + if len(errs["market_price_stored"]) == 0 { + t.Fatal("1000000.0000 must fail max") + } + errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{"market_price_stored": "25.0000"}, nil) + if err != nil { + t.Fatal(err) + } + if len(errs) != 0 { + t.Fatalf("25.0000 = %v", errs) + } + errs, err = Validate(t.Context(), nil, nil, rules, map[string]any{"market_price_stored": nil}, nil) + if err != nil { + t.Fatal(err) + } + if len(errs) != 0 { + t.Fatalf("nil money = %v", errs) + } +} + +func TestValidateUnrecognizedRule(t *testing.T) { + _, err := Validate(t.Context(), nil, nil, map[string]string{"name": "required|nope"}, map[string]any{"name": "x"}, nil) + if err == nil || !strings.Contains(err.Error(), "nope") { + t.Fatalf("want unrecognized nope, got %v", err) + } +} + +func TestValidateUniqueRespectsDeletedAt(t *testing.T) { + sqlDB, _ := dedicatedDB(t, "lagoon_validate_unique") + gdb, err := Use(t.Context(), sqlDB) + if err != nil { + t.Fatal(err) + } + if err := gdb.Exec(`CREATE TABLE lagoon_unique_rows ( + id SERIAL PRIMARY KEY, + slug TEXT NOT NULL, + deleted_at TIMESTAMPTZ +)`).Error; err != nil { + t.Fatal(err) + } + if err := gdb.Exec(`INSERT INTO lagoon_unique_rows (slug, deleted_at) VALUES ('live', NULL), ('gone', NOW())`).Error; err != nil { + t.Fatal(err) + } + rules := map[string]string{"slug": "unique:lagoon_unique_rows"} + errs, err := Validate(t.Context(), gdb, nil, rules, map[string]any{"slug": "live"}, nil) + if err != nil { + t.Fatal(err) + } + if len(errs["slug"]) == 0 { + t.Fatal("live slug must fail unique") + } + errs, err = Validate(t.Context(), gdb, nil, rules, map[string]any{"slug": "gone"}, nil) + if err != nil { + t.Fatal(err) + } + if len(errs) != 0 { + t.Fatalf("soft-deleted slug should pass unique: %v", errs) + } +}