validation

package
v0.0.5 Latest Latest
Warning

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

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

Documentation

Overview

Package validation provides a decorator that validates entities before mutation operations (Create, Update, Patch) on any r3.CRUD[T, ID] repository.

R3 does not include built-in validation rules. Instead, it provides a Validator interface that users implement using any validation library of their choice -- go-playground/validator, go-ozzo/ozzo-validation, or plain Go code. The decorator intercepts mutations, calls the user's Validator, and short-circuits with a structured Error if validation fails.

Read operations (Get, List) and Delete pass through without validation.

Key features:

  • Library-agnostic: bring your own validation (go-playground, ozzo, custom)
  • Patch-aware: the validator receives which fields are being patched
  • State-transition aware: optionally fetches the existing entity before Update/Patch
  • Structured errors: Error carries per-field error details
  • 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
}

With ozzo-validation

type petValidator struct{}

func (v petValidator) Validate(_ context.Context, req validation.Request[Pet, int64]) error {
    pet := req.Entity
    if err := ozzovalidation.ValidateStruct(&pet,
        ozzovalidation.Field(&pet.Name, ozzovalidation.Required, ozzovalidation.Length(1, 100)),
        ozzovalidation.Field(&pet.Price, ozzovalidation.Min(0.0)),
    ); err != nil {
        // Convert ozzo errors to r3 validation.Error
        var fieldErrors []validation.FieldError
        if ozzoErrs, ok := err.(ozzovalidation.Errors); ok {
            for field, fieldErr := range ozzoErrs {
                fieldErrors = append(fieldErrors, validation.NewFieldError(field, fieldErr.Error(), "invalid"))
            }
        }
        return validation.NewError(req.Operation, fieldErrors...)
    }
    return nil
}

State-Transition Validation

When WithIDFunc is configured, the decorator fetches the existing entity before Update and Patch operations. This enables state-transition 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 }),
)

The validator then receives req.Existing with the current DB state.

Patch-Aware Validation

For Patch operations, Request.Fields contains the list of fields being updated. Validators can use this to skip rules for unchanged fields:

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, with the rest zeroed, so the rule would wrongly fire. When WithIDFunc is set the request carries Request.Merged: the patch overlaid on the current DB state. Validate that instead:

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

Atomic State-Transition Validation

The decorator fetches the existing entity (or merged state) and then writes; that fetch-validate-write sequence is only atomic when the decorator runs inside a transaction. Wrap the repository with the transactor feature and perform the Update/Patch within InTx so the read and the write share one transaction. Outside a transaction a concurrent writer can change the row between the fetch and the write (a TOCTOU window), so 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 error for validation failures. Use errors.Is(err, validation.ErrValidation) to detect validation errors.

Functions

This section is empty.

Types

type CRUD

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

CRUD is a decorator that wraps any r3.CRUD[T, ID] and validates entities before mutation operations (Create, Update, Patch). Read operations (Get, List) and Delete pass through without validation.

The decorator is stateless -- it doesn't define validation rules. It delegates to a user-provided Validator implementation that can use any validation library.

It transparently satisfies the r3.CRUD[T, ID] interface, so it can be used as a drop-in replacement for any CRUD repository.

Usage:

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

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 an existing r3.CRUD with validation. The validator parameter defines the validation logic for mutation operations.

Example:

repo := validation.WithValidation[Pet, int64](
    innerRepo,
    myValidator,
)

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 repository (unwrapped). Useful when you need to bypass validation for a specific operation.

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 optionally fetches the existing entity (if IDFunc is set), validates with the Fields list populated, then delegates to inner.Patch.

When IDFunc is set the request also carries Merged — the patch overlaid on the current state — so whole-entity validators don't see the sparse (zeroed) input.

The fetch-validate-write sequence is only atomic when this decorator runs inside a transaction: wrap the repo with the transactor feature and call Patch within InTx so the Get and the write share one transaction. Outside a transaction the read-before-validate is best-effort (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 the patched Fields are meaningful; there is no single Existing row to fetch, so Existing/Merged are not populated. Returns r3.ErrBulkPatchNotSupported when the inner repo has no bulk-patch capability.

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 a different inner CRUD (used to re-apply 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 capability detection and transaction propagation can walk the decorator chain.

func (*CRUD[T, ID]) Update

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

Update optionally fetches the existing entity (if IDFunc is set), validates, then delegates to inner.Update.

As with Patch, the fetch-validate-write sequence is atomic only when this decorator runs inside a transaction (see the transactor feature); otherwise the read-before-validate 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), then delegates to inner.Upsert. An upsert carries the whole entity, so validation mirrors Create. Returns r3.ErrUpsertNotSupported when the inner repo has no upsert capability.

type Error

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

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

Error provides structured details about validation failures. It satisfies 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 is the name of the field that failed validation (e.g. "name", "email").
	Field string

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

	// Code is a machine-readable error code (e.g. "required", "min", "email").
	// Useful 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. Used by the decorator to fetch existing entities 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 function that extracts the primary key from an entity. This enables state-transition validation: the decorator fetches the current DB state before Update and Patch, so the validator can compare old vs new values.

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 extracts the primary key from an entity.
	// When set, the decorator fetches the existing entity before Update and Patch
	// operations, populating ValidationRequest.Existing for state-transition validation.
	// Without IDFunc, Existing is always nil.
	IDFunc IDFunc[T, ID]

	// RecordType is the entity type name used in error messages.
	// If empty, it is derived automatically from the struct type T
	// (e.g. Order -> "orders", CampaignAdset -> "campaign_adsets").
	RecordType string
}

Options configures the behavior of the validation decorator.

type Request

type Request[T any, ID comparable] struct {
	// Operation is the mutation type: create, update, or patch.
	Operation Operation

	// Entity is the entity being validated. Always populated.
	Entity T

	// Fields is the list of fields being patched. Non-nil only for Patch operations.
	// Validators can use this to skip rules for fields that aren't being changed.
	Fields r3.Fields

	// Existing is the current database state of the entity.
	// Populated only for Update and Patch operations when WithIDFunc is configured.
	// Enables state-transition validation (e.g. "status can only go draft -> published").
	Existing *T

	// Merged is the full entity as it will look AFTER the patch is applied: the
	// patched fields overlaid on Existing. Populated only for Patch when WithIDFunc
	// is configured. Validators should prefer Merged over Entity for whole-entity
	// rules on Patch, since Entity carries only the patched fields (the rest are
	// zeroed) — validating Entity directly would reject otherwise-valid patches.
	Merged *T
}

Request carries all context needed for a validation decision.

Fields:

  • Operation: which mutation is being performed
  • Entity: the entity being validated (always populated)
  • Fields: for Patch operations, which fields are being changed (nil for Create/Update)
  • Existing: for Update/Patch, the current DB state (nil unless IDFunc is configured)

type Validator

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

Validator validates entities before mutation operations. Implementations can use any validation library (go-playground/validator, ozzo-validation, custom logic, etc.)

Validate returns nil to allow the operation, or an error to reject it. Returning an *Error provides structured per-field error details. Any other error type is also accepted and will prevent the mutation.

func Compose

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

Compose chains multiple validators. All validators are called, and their errors are collected into a single Error. If any validator returns a *Error, the field errors are merged. If a validator returns a non-Error, it is returned immediately (short-circuit).

An empty list of validators allows everything.

func NoValidation

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

NoValidation returns a validator that allows everything. Useful for testing or prototyping when you want the decorator in the chain but don't have validation rules yet.

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