validation

package
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 24, 2026 License: MIT Imports: 8 Imported by: 0

Documentation

Overview

Package validation validates entities before mutations (Create, Update, Patch, Upsert, PatchWhere) on any r3.CRUD[T, ID]. The decorator wraps the repo, transparently satisfies it, and drops in anywhere a CRUD is expected. Reads (Get, List) and Delete pass through unvalidated.

R3 ships no validation rules. You implement the Validator interface with any library - go-playground/validator, go-ozzo/ozzo-validation, or plain Go; the decorator calls it and short-circuits with a structured Error on failure. Composable helpers: NoValidation, Compose, OperationValidators.

Basic Usage

repo := validation.WithValidation[Pet, int64](
    innerRepo,
    validation.ValidatorFunc[Pet, int64](func(ctx context.Context, req validation.Request[Pet, int64]) error {
        if req.Entity.Name == "" {
            return validation.NewError(req.Operation,
                validation.NewFieldError("name", "is required", "required"),
            )
        }
        return nil
    }),
)

With go-playground/validator

type petValidator struct {
    validate *validator.Validate
}

func (v *petValidator) Validate(_ context.Context, req validation.Request[Pet, int64]) error {
    if err := v.validate.Struct(req.Entity); err != nil {
        // Convert go-playground errors to r3 validation.Error
        var fieldErrors []validation.FieldError
        for _, fe := range err.(validator.ValidationErrors) {
            fieldErrors = append(fieldErrors, validation.NewFieldError(
                fe.Field(), fe.Tag()+" validation failed", fe.Tag(),
            ))
        }
        return validation.NewError(req.Operation, fieldErrors...)
    }
    return nil
}

State-Transition Validation

With WithIDFunc, the decorator fetches the existing entity before Update and Patch, so the validator receives req.Existing (current DB state) and can enforce rules like "status can only move from draft to published":

repo := validation.WithValidation[Post, int64](
    innerRepo, myValidator,
    validation.WithIDFunc[Post, int64](func(p Post) int64 { return p.ID }),
)

Patch-Aware Validation

For Patch, Request.Fields lists the fields being changed, so validators can skip rules for unchanged ones:

func (v *myValidator) Validate(ctx context.Context, req validation.Request[Post, int64]) error {
    if req.Operation == validation.OpPatch {
        // Only validate fields that are being patched
        for _, f := range req.Fields {
            switch f.Name {
            case "name":
                if req.Entity.Name == "" {
                    return validation.NewError(req.Operation,
                        validation.NewFieldError("name", "is required", "required"),
                    )
                }
            }
        }
        return nil
    }
    // Full validation for Create/Update ...
}

For whole-entity rules on Patch (e.g. "name must always be non-empty"), do NOT validate req.Entity directly - on a Patch it carries only the patched fields, the rest zeroed, so the rule would wrongly fire. When WithIDFunc is set, the request carries Request.Merged (the patch overlaid on current state) - validate that instead:

if req.Operation == validation.OpPatch && req.Merged != nil {
    return v.validate.Struct(*req.Merged)
}

Atomic State-Transition Validation

The fetch-validate-write sequence is only atomic when the decorator runs inside a transaction: wrap the repo with the transactor feature and run Update/Patch in InTx so the read and write share one transaction. Otherwise a concurrent writer can change the row between fetch and write (a TOCTOU window), and two concurrent updates may both pass a state-transition rule.

Index

Constants

This section is empty.

Variables

View Source
var ErrValidation = errors.New("r3/validation: validation failed")

ErrValidation is the sentinel every validation failure matches via errors.Is.

Functions

This section is empty.

Types

type CRUD

type CRUD[T any, ID comparable] struct {
	// contains filtered or unexported fields
}

CRUD validates entities before mutations, delegating the rules to a user-provided Validator; reads and Delete pass through. See the package doc.

func WithValidation

func WithValidation[T any, ID comparable](
	inner r3.CRUD[T, ID],
	validator Validator[T, ID],
	optFns ...Option[T, ID],
) *CRUD[T, ID]

WithValidation wraps inner so mutations are validated by validator.

func (*CRUD[T, ID]) Aggregate added in v0.0.3

func (v *CRUD[T, ID]) Aggregate(ctx context.Context, qarg ...r3.Query) ([]r3.AggregateRow, error)

Aggregate delegates directly to the inner CRUD's Aggregate (no validation on reads).

func (*CRUD[T, ID]) AggregateThroughRelation added in v0.0.5

func (v *CRUD[T, ID]) AggregateThroughRelation(
	ctx context.Context, relation string, qarg ...r3.Query,
) ([]r3.AggregateRow, error)

AggregateThroughRelation delegates directly to the inner CRUD (no validation on reads).

func (*CRUD[T, ID]) Count

func (v *CRUD[T, ID]) Count(ctx context.Context, qarg ...r3.Query) (int64, error)

Count delegates directly to inner.Count (no validation on reads).

func (*CRUD[T, ID]) Create

func (v *CRUD[T, ID]) Create(ctx context.Context, entity T) (T, error)

Create validates the entity, then delegates to inner.Create.

func (*CRUD[T, ID]) Delete

func (v *CRUD[T, ID]) Delete(ctx context.Context, id ID) error

Delete delegates directly to inner.Delete (no validation on deletes).

func (*CRUD[T, ID]) Get

func (v *CRUD[T, ID]) Get(ctx context.Context, id ID, qarg ...r3.Query) (T, error)

Get delegates directly to inner.Get (no validation on reads).

func (*CRUD[T, ID]) Inner

func (v *CRUD[T, ID]) Inner() r3.CRUD[T, ID]

Inner returns the underlying CRUD, unwrapped - bypasses validation.

func (*CRUD[T, ID]) List

func (v *CRUD[T, ID]) List(ctx context.Context, qarg ...r3.Query) ([]T, int64, error)

List delegates directly to inner.List (no validation on reads).

func (*CRUD[T, ID]) Patch

func (v *CRUD[T, ID]) Patch(ctx context.Context, entity T, fields r3.Fields) (T, error)

Patch fetches the existing entity (if IDFunc is set), validates with Fields populated, then delegates to inner.Patch. With IDFunc set the request also carries Merged (the patch overlaid on current state), so whole-entity validators don't see the sparse (zeroed) input.

Fetch-validate-write is atomic only inside a transaction: wrap the repo with the transactor feature and call Patch within InTx. Otherwise a concurrent writer can change the row between the Get and the Patch (a TOCTOU window).

func (*CRUD[T, ID]) PatchWhere added in v0.0.4

func (v *CRUD[T, ID]) PatchWhere(
	ctx context.Context, filters r3.Filters, entity T, fields r3.Fields,
) (int64, error)

PatchWhere validates the entity/fields (OpPatchWhere), then delegates to inner.PatchWhere. Like Patch, only Fields are meaningful; there is no single Existing row, so Existing/Merged stay nil. Returns r3.ErrBulkPatchNotSupported when the inner repo can't bulk-patch.

func (*CRUD[T, ID]) Rewrap

func (v *CRUD[T, ID]) Rewrap(inner r3.CRUD[T, ID]) r3.CRUD[T, ID]

Rewrap rebuilds this decorator around inner, re-applying the validation layer on top of a transaction-bound CRUD.

func (*CRUD[T, ID]) Unwrap

func (v *CRUD[T, ID]) Unwrap() r3.CRUD[T, ID]

Unwrap returns the wrapped CRUD so decorator-chain walks (capability detection, transaction propagation) can reach the backend.

func (*CRUD[T, ID]) Update

func (v *CRUD[T, ID]) Update(ctx context.Context, entity T) (T, error)

Update fetches the existing entity (if IDFunc is set), validates, then delegates to inner.Update. As with Patch, fetch-validate-write is atomic only inside a transaction (see the transactor feature); otherwise the read has a TOCTOU window against concurrent writers.

func (*CRUD[T, ID]) Upsert added in v0.0.4

func (v *CRUD[T, ID]) Upsert(ctx context.Context, entity T, opts ...r3.UpsertOption) (T, error)

Upsert validates the full entity (OpUpsert, mirroring Create since an upsert carries the whole entity), then delegates to inner.Upsert. Returns r3.ErrUpsertNotSupported when the inner repo can't upsert.

type Error

type Error struct {
	// Operation is the mutation that was attempted.
	Operation Operation

	// Errors is the list of per-field failures.
	Errors []FieldError
}

Error carries structured per-field validation failures and matches errors.Is(err, ErrValidation) via its Is method.

func NewError

func NewError(op Operation, errs ...FieldError) *Error

NewError creates a new Error with the given field errors.

func (*Error) Error

func (e *Error) Error() string

Error returns a human-readable summary of all validation failures.

func (*Error) HasField

func (e *Error) HasField(name string) bool

HasField reports whether the validation error contains a failure for the named field.

func (*Error) Is

func (e *Error) Is(target error) bool

Is supports errors.Is(err, ErrValidation).

type FieldError

type FieldError struct {
	// Field that failed (e.g. "name", "email").
	Field string

	// Message is the human-readable failure (e.g. "is required").
	Message string

	// Code is a machine-readable code (e.g. "required", "min") for i18n or
	// client-side error mapping.
	Code string
}

FieldError describes a validation failure for a single field.

func NewFieldError

func NewFieldError(field, message, code string) FieldError

NewFieldError creates a new FieldError.

func (FieldError) Error

func (e FieldError) Error() string

Error returns a human-readable representation of the field error.

type IDFunc

type IDFunc[T any, ID comparable] func(entity T) ID

IDFunc extracts the primary key from an entity, letting the decorator fetch the existing entity for state-transition validation (Update, Patch).

type Operation

type Operation string

Operation represents a mutation type that triggers validation. Read operations (Get, List) and Delete do not trigger validation.

const (
	OpCreate Operation = "create"
	OpUpdate Operation = "update"
	OpPatch  Operation = "patch"
	// OpUpsert validates the full entity of an Upsert (insert-or-update); the
	// whole entity is present, as on Create.
	OpUpsert Operation = "upsert"
	// OpPatchWhere validates the entity/fields of a bulk conditional update. Like
	// Patch, only Fields carry meaning; there is no single Existing row, so
	// Existing/Merged are never populated.
	OpPatchWhere Operation = "patch_where"
)

type Option

type Option[T any, ID comparable] func(*Options[T, ID])

Option is a functional option for configuring the validation CRUD decorator.

func WithIDFunc

func WithIDFunc[T any, ID comparable](fn IDFunc[T, ID]) Option[T, ID]

WithIDFunc sets the primary-key extractor, enabling state-transition validation: the decorator fetches current DB state before Update and Patch so the validator can compare old vs new.

func WithRecordType

func WithRecordType[T any, ID comparable](name string) Option[T, ID]

WithRecordType sets an explicit entity type name for error messages.

type Options

type Options[T any, ID comparable] struct {
	// IDFunc, when set, makes the decorator fetch the existing entity before
	// Update and Patch, populating Request.Existing for state-transition
	// validation. Without it, Existing is always nil.
	IDFunc IDFunc[T, ID]

	// RecordType names the entity type in error messages. If empty, derived from
	// T (e.g. Order -> "orders", OrderItem -> "order_items").
	RecordType string
}

Options configures the validation decorator.

type Request

type Request[T any, ID comparable] struct {
	// Operation is the mutation type.
	Operation Operation

	// Entity being validated. Always populated.
	Entity T

	// Fields being patched. Non-nil only for Patch; lets validators skip rules for
	// unchanged fields.
	Fields r3.Fields

	// Existing is the current DB state. Populated only for Update/Patch when
	// WithIDFunc is set - enables state-transition rules (e.g. "draft -> published").
	Existing *T

	// Merged is the entity AFTER the patch is applied (patched fields overlaid on
	// Existing). Populated only for Patch when WithIDFunc is set. Prefer Merged over
	// Entity for whole-entity rules on Patch, since Entity carries only the patched
	// fields (rest zeroed) and validating it directly would reject valid patches.
	Merged *T
}

Request carries the context for a validation decision.

type Validator

type Validator[T any, ID comparable] interface {
	Validate(ctx context.Context, req Request[T, ID]) error
}

Validator validates entities before mutations, using any library or plain Go. Validate returns nil to allow or an error to reject; an *Error carries structured per-field details, but any error type prevents the mutation.

func Compose

func Compose[T any, ID comparable](validators ...Validator[T, ID]) Validator[T, ID]

Compose runs all validators, merging their *Error field errors into one Error. A non-*Error short-circuits and is returned immediately. An empty list allows everything.

func NoValidation

func NoValidation[T any, ID comparable]() Validator[T, ID]

NoValidation returns a validator that allows everything - for tests or prototyping when the decorator is in the chain but rules aren't ready.

func OperationValidators

func OperationValidators[T any, ID comparable](
	perOp map[Operation]Validator[T, ID],
) Validator[T, ID]

OperationValidators maps specific operations to specific validators. Unmapped operations pass through without validation.

Example:

validation.OperationValidators[Pet, int64](map[validation.Operation]validation.Validator[Pet, int64]{
    validation.OpCreate: createValidator,
    validation.OpUpdate: updateValidator,
    // OpPatch not mapped -> passes through
})

type ValidatorFunc

type ValidatorFunc[T any, ID comparable] func(ctx context.Context, req Request[T, ID]) error

ValidatorFunc is an adapter to allow use of ordinary functions as Validators.

func (ValidatorFunc[T, ID]) Validate

func (f ValidatorFunc[T, ID]) Validate(ctx context.Context, req Request[T, ID]) error

Validate calls the underlying function.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL