Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,93 @@ And when each key is validated, its rules are also evaluated in the order they a
If a rule fails, an error is recorded for that key, and the validation will continue with the next key.


### Validating Unions (one-of)

Sometimes a value is valid if it matches *one of* several shapes — a discriminated union. A login
payload, for example, might be `{email, password}` **or** `{username, password}`, but never both. The
`MatchOneOf` family validates a value against a set of alternative schemas: the first schema that
fully validates wins, and its index is returned so you can act on the matched shape (parse it, merge
it, branch on it). If none match, a `OneOfError` is returned describing every alternative that was
tried.

#### Maps

A map schema is just a `Map(...)` rule, and a variant forbids a field simply by **not listing it** —
`Map` already reports unlisted keys as "key not expected" (unless you call `AllowExtraKeys`), so no
special "forbidden" rule is needed.

```go
withEmail := validation.Map(
validation.Key("email", validation.Required, is.EmailFormat),
validation.Key("password", validation.Required),
)
withUsername := validation.Map(
validation.Key("username", validation.Required),
validation.Key("password", validation.Required),
)

i, err := validation.MatchOneOf(input, withEmail, withUsername)
// i == 0 -> matched withEmail
// i == 1 -> matched withUsername
// i == -1 -> err is a OneOfError describing both attempts
```

Use `MatchOneOfWithContext` to thread a `context.Context` through to the schema rules.

#### Structs

In a struct every field always exists (as its zero value), so a variant marks the fields it forbids
with the `Never` rule. `MatchOneOfStruct` takes a pointer to the struct and one `[]*FieldRules` per
variant:

```go
emailLogin := []*validation.FieldRules{
validation.Field(&l.Email, validation.Required, is.EmailFormat),
validation.Field(&l.Password, validation.Required),
validation.Field(&l.Username, validation.Never), // forbidden in this variant
}
usernameLogin := []*validation.FieldRules{
validation.Field(&l.Email, validation.Never),
validation.Field(&l.Username, validation.Required),
validation.Field(&l.Password, validation.Required),
}

i, err := validation.MatchOneOfStruct(ctx, &l, emailLogin, usernameLogin)
```

`Never` gives pointer fields nil semantics (a present pointer fails even if it points at an empty
value) and non-pointer fields zero-value semantics (an empty string or `0` passes).

#### The `OneOf` rule and strict matching

When you only need pass/fail and not the winning index, `OneOf(schemas ...Rule)` is the rule form and
composes anywhere a `Rule` is accepted:

```go
err := validation.Validate(input, validation.OneOf(withEmail, withUsername))
```

By default the first matching schema wins (`anyOf` semantics). Call `.Strict()` to require that
*exactly* one schema match; if more than one does, it fails with `ErrAmbiguousMatch` — a useful guard
against schemas that are not mutually exclusive.

#### The error shape

A failed union produces a `OneOfError`, which marshals to JSON as a single `oneOf` field whose value
is an array of the per-variant error maps, one entry per schema, in order. Each entry contains the
fields that failed for that variant:

```go
b, _ := json.Marshal(err)
fmt.Println(string(b))
// {"oneOf":[{"username":"must not be provided"},{"email":"must not be provided"}]}
```

Because `OneOfError` is returned unwrapped, it nests correctly inside a parent `validation.Errors`
(for example when a union is one field of a larger object), serializing structurally rather than
collapsing to a string.


### Validation Errors

The `validation.ValidateStruct` method returns validation errors found in struct fields in terms of `validation.Errors`
Expand Down Expand Up @@ -686,6 +773,9 @@ so you can write `validation.In("a", "b")` or `validation.Min(10)` without spell
* `NilOrNotEmpty`: checks if a value is a nil pointer or a non-empty value. This differs from `Required` in that it treats a nil pointer as valid.
* `Nil`: checks if a value is a nil pointer.
* `Empty`: checks if a value is empty. nil pointers are considered valid.
* `Never`: checks that a value is absent — a nil pointer/interface, or the zero value for any other
type. Use it on the struct fields that a discriminated-union variant forbids (the counterpart to
`Required`). Unlike `Empty`, a *present* pointer fails even when it references an empty value.

**Composition and control flow**

Expand All @@ -694,6 +784,17 @@ so you can write `validation.In("a", "b")` or `validation.Min(10)` without spell
* `Else(rules ...Rule)`: must be used with `When(condition, rules ...Rule)`, validates with the specified rules only when the condition is false.
* `Skip`: this is a special rule used to indicate that all rules following it should be skipped (including the nested ones).

**Unions (one-of)**

* `OneOf(schemas ...Rule)`: passes when the value matches at least one of the schemas (the first
match wins). Call `.Strict()` to require exactly one match. On failure the error is a `OneOfError`
that marshals to `{"oneOf": [...]}`.
* `MatchOneOf(value, schemas ...Rule) (int, error)` / `MatchOneOfWithContext`: like `OneOf` but
returns the index of the matching schema so you can act on the matched shape. Schemas are typically
`Map(...)` rules; a variant forbids a key by omitting it.
* `MatchOneOfStruct[T](ctx, *T, schemas ...[]*FieldRules) (int, error)`: the struct counterpart,
taking one `[]*FieldRules` per variant. Use `Never` to forbid the fields a variant disallows.

The `is` sub-package provides a list of commonly used string validation rules that can be used to check if the format
of a value satisfies certain requirements. Note that these rules only handle strings and byte slices and if a string
or byte slice is empty, it is considered valid. You may use a `Required` rule to ensure a value is not empty.
Expand Down
142 changes: 142 additions & 0 deletions example_union_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright 2016 Qiang Xue. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package validation_test

import (
"context"
"encoding/json"
"fmt"
"testing"

"github.com/monetr/validation"
"github.com/monetr/validation/is"
)

// This file reproduces monetr's server/datasources/table.FieldRef — a reference
// to a column in an imported document that is EITHER a named field OR a derived
// (computed) field, never both and never neither — to show the union API end to
// end against realistic input, in both its struct and map forms.

// importColumns stands in for the columns monetr pulls from the upload context
// via getColumns(ctx).
var importColumns = []string{"date", "amount", "description"}

const derivedKindRowNumber = "rowNumber"

// fieldRef mirrors table.FieldRef.
type fieldRef struct {
Name string `json:"name,omitempty"`
DerivedKind string `json:"derivedKind,omitempty"`
}

// Validate is the polished, library-native version of FieldRef.Validate. It
// returns the index of the matched variant so a caller could branch on which
// shape was supplied.
//
// monetr writes this today as:
//
// return validators.OneOfStruct(ctx, s,
// []*validation.FieldRules{
// validation.Field(&s.Name, validators.In(getColumns(ctx)...), validators.PrintableUnicode, validation.Required),
// validation.Field(&s.DerivedKind, validation.Empty),
// },
// []*validation.FieldRules{
// validation.Field(&s.Name, validation.Empty),
// validation.Field(&s.DerivedKind, validators.In(DerivedKindRowNumber), validation.Required),
// },
// )
//
// The differences: it returns (int, error) so the matched variant is known, the
// forbidden field uses Never (clearer "must not be provided" message and proper
// pointer semantics) instead of Empty, and the OneOfError it returns is already
// JSON-ready — no MarshalErrorTree walker required.
func (s *fieldRef) Validate(ctx context.Context) (int, error) {
return validation.MatchOneOfStruct(
ctx,
s,
// Variant 0 — a named column: Name is required and must be a known
// column; DerivedKind is forbidden.
[]*validation.FieldRules{
validation.Field(&s.Name,
validation.Required,
validation.In(importColumns...),
is.PrintableUnicode,
),
validation.Field(&s.DerivedKind, validation.Never),
},
// Variant 1 — a derived field: DerivedKind is required and must be a
// known kind; Name is forbidden.
[]*validation.FieldRules{
validation.Field(&s.Name, validation.Never),
validation.Field(&s.DerivedKind,
validation.Required,
validation.In(derivedKindRowNumber),
),
},
)
}

func TestUnionExample_FieldRefStruct(t *testing.T) {
ctx := context.Background()
inputs := []fieldRef{
{Name: "amount"}, // valid: a named column
{DerivedKind: "rowNumber"}, // valid: a derived field
{Name: "amount", DerivedKind: "rowNumber"}, // invalid: both supplied
{}, // invalid: neither supplied
{Name: "not_a_column"}, // invalid: unknown column
}

fmt.Println("=== struct form (MatchOneOfStruct + Never) ===")
for _, in := range inputs {
ref := in
raw, _ := json.Marshal(in)
i, err := ref.Validate(ctx)
if err == nil {
fmt.Printf("input %-46s => matched variant %d\n", raw, i)
continue
}
out, _ := json.Marshal(err)
fmt.Printf("input %-46s => %s\n", raw, out)
}
}

func TestUnionExample_RequestMap(t *testing.T) {
// The same union as a map of dynamic request data, mirroring how monetr's
// controller.parse[T] decodes a JSON body into map[string]any and validates
// it. In the map form a variant forbids a field simply by NOT listing it:
// MapRule reports the unexpected key on its own, so Never is not involved.
namedColumn := validation.Map(
validation.Key("name", validation.Required, validation.In(importColumns...), is.PrintableUnicode),
// "derivedKind" intentionally omitted -> forbidden in this variant.
)
derivedField := validation.Map(
// "name" intentionally omitted -> forbidden in this variant.
validation.Key("derivedKind", validation.Required, validation.In(derivedKindRowNumber)),
)

inputs := []map[string]any{
{"name": "amount"},
{"derivedKind": "rowNumber"},
{"name": "amount", "derivedKind": "rowNumber"},
{"name": "not_a_column"},
}

fmt.Println("\n=== map form (MatchOneOf, forbid by omitting the key) ===")
for _, in := range inputs {
raw, _ := json.Marshal(in)
i, err := validation.MatchOneOf(in, namedColumn, derivedField)
if err == nil {
fmt.Printf("input %-50s => matched variant %d\n", raw, i)
continue
}
// This is the entire controller response body now: because OneOfError
// marshals itself, "problems" is just the error — no MarshalErrorTree.
response, _ := json.Marshal(map[string]any{
"error": "Invalid request",
"problems": err,
})
fmt.Printf("input %-50s => %s\n", raw, response)
}
}
79 changes: 79 additions & 0 deletions never.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright 2016 Qiang Xue. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package validation

import "reflect"

// ErrNever is the error returned by [Never] when a value that must be absent is
// instead present with a meaningful value.
var ErrNever = NewError("validation_never", "must not be provided")

// Never is a validation rule that asserts a value is absent. It is the
// discriminated-union counterpart to [Required]: use it on the struct fields
// that a given variant forbids.
//
// A struct field always exists (it is present as its zero value), so Never
// infers "absent" from the value itself:
//
// - pointer or interface: must be nil. A non-nil pointer fails even when it
// references an empty value, because a present pointer is a provided value.
// - any other kind: must be the zero value (empty string, 0, false, empty
// slice/map/array, the zero time.Time). A meaningful value fails.
//
// This auto-switching is what distinguishes Never from the existing absence
// rules: [Empty] dereferences first, so it would let a pointer to an empty
// value pass, while [Nil] would reject a non-pointer zero value. Never gives
// pointers Nil semantics and everything else Empty semantics in one rule.
//
// Never is value-based and intended for struct fields validated with [Field].
// It is deliberately not offered for map keys: a [Map] schema already reports
// keys it does not list as [ErrKeyUnexpected] (unless [MapRule.AllowExtraKeys]
// is set), so a map union forbids a key simply by omitting it from the variant
// that disallows it. Using Never as a map value rule would be a mistake — it
// would let a present-but-empty value pass when, in a map, the key's mere
// presence should fail.
var Never = neverRule{}

type neverRule struct {
err Error
}

// Validate checks that the value is absent: a nil pointer/interface, or the
// zero value for any other kind.
func (r neverRule) Validate(value any) error {
rv := reflect.ValueOf(value)
switch rv.Kind() {
case reflect.Invalid:
// An untyped nil is absent.
return nil
case reflect.Ptr, reflect.Interface:
if rv.IsNil() {
return nil
}
default:
if IsEmpty(value) {
return nil
}
}
if r.err != nil {
return r.err
}
return ErrNever
}

// Error sets the error message for the rule.
func (r neverRule) Error(message string) neverRule {
if r.err == nil {
r.err = ErrNever
}
r.err = r.err.SetMessage(message)
return r
}

// ErrorObject sets the error struct for the rule.
func (r neverRule) ErrorObject(err Error) neverRule {
r.err = err
return r
}
Loading
Loading