Operations and callbacks
Use the operation package to read callback values, change a field, check what is happening, and return validation messages.
Import github.com/riducms/ridu/operation when you write a field hook, validator, access rule, or dynamic default.
It provides the arguments Ridu passes to your function and the results you return: the current
operation, a value that may be empty, a replacement value, or a validation message.
You do not need it for a basic field declaration such as field.Text("title").Required().
It becomes useful when you add your own behavior, such as trimming a subtitle before saving
or checking that a sale price is lower than the regular price.
The package does not execute database operations. To create, read, or update a document yourself, use the local Go API. For the values in a document’s map, see Document values.
Check what is happening
ctx.Operation is an operation.Kind: a named value such as operation.Create or
operation.Update. Compare it with these constants when your function should run only for
certain actions.
| Constant | What it identifies |
|---|---|
operation.Create |
Creating a collection document |
operation.Duplicate |
Creating a copy of an existing document |
operation.Read |
Reading one document or a list, or reading a global |
operation.Update |
Updating a document or global, including a global’s first save |
operation.Delete |
Deleting a document; moves it to trash when trash is enabled |
operation.RestoreDeleted |
Restoring a document from trash |
operation.DeletePermanent |
Permanently deleting a trashed document |
operation.Publish |
Publishing a document or global |
operation.Unpublish |
Moving a document or global back to draft |
operation.ReadVersions |
Reading retained version history |
operation.Admin |
Checking whether an authenticated user may enter the admin |
operation.Unlock |
Checking permission to take over another editor’s document lock |
These names describe the action, not the hook phase. An AfterRead hook that prepares a create
response still sees operation.Create. Access checks use these kinds too; a kind’s presence
does not mean every hook runs for it. See the hook lifecycle
for the stages that run on reads, writes, and deletes.
Restoring a saved version is different from restoring trash. A version restore checks
ReadVersions permission, then runs Publish for a published snapshot or Unpublish for a
draft snapshot. Restoring explicitly as a draft uses Unpublish. Use RestoreDeleted only for
the trash action; it does not run AfterChange.
Read a value that may be empty
A field callback reads or returns operation.Value[T]. T is the Go type of the field value: string
for Text, float64 for Number, or bool for Checkbox, for example. The wrapper records whether
a value is present separately from the value itself.
Call value.Get() to read both results, as in subtitle, present := value.Get(). Check
present before using the value. When it is false, the first result is Go’s zero value for
that type; it is not a value supplied by the author.
| Value you construct | What Get() returns |
Meaning |
|---|---|---|
operation.Empty[bool]() |
false, false |
No boolean value |
operation.Present(false) |
false, true |
An explicit false |
operation.Present(0.0) |
0, true |
An explicit number zero |
operation.Present("") |
"", true |
An explicit empty string in the wrapper |
operation.Present("Hello") |
"Hello", true |
A supplied string |
Present(...) wraps a value. Empty[T]() wraps no value and needs the type in square brackets
because there is no argument from which Go can infer it. An uninitialized operation.Value[T]
is also empty. Normal field validation still applies: a present empty string does not bypass
a Text field’s Required() rule.
A dynamic default returns this same wrapper. operation.Present(value) supplies the initial
value; operation.Empty[T]() supplies no default, and required validation still applies.
The callback receives operation.DefaultContext, with request information and the input
available before validation. It has no separate current-value argument because it only runs
for omitted fields that need an initial value. See Set default field values.
Missing input and explicit null
A raw field hook, such as BeforeValidate, receives operation.Value[store.Value] before the
input has passed type checks. At that stage:
operation.Empty[store.Value]()means the caller omitted the field.operation.Present(store.Null())means the caller explicitly supplied null.- Other present values may still have the wrong type for the field.
Typed callbacks run later. An omitted field in an update may already have its saved value,
and an optional field’s null or missing value can become the same empty typed value. Do not
use a typed callback’s present flag to decide whether the caller submitted the field.
Use a raw hook when that distinction affects your rule. ctx.Root and ctx.Siblings are
snapshots of values at the current phase; their membership is not a record of submitted keys.
See missing input and null for more detail.
Keep, replace, or clear a field
A field transform returns an operation.Change[T] and an error. Its return value tells Ridu
what to do with this field:
| Return | Result |
|---|---|
operation.Keep[string]() |
Leave the current value unchanged |
operation.Replace(operation.Present("Hello")) |
Set the field to "Hello" |
operation.Replace(operation.Empty[string]()) |
Clear the field |
| A non-nil error | Stop the operation; a replacement returned with the error is not applied |
Keep and Replace(Empty) are different. The first preserves a value; the second clears it.
For an ordinary optional field, clearing produces null. It does not remove the field property
from stored documents. A required field still has to pass validation.
This reusable field trims a subtitle and clears it when the author enters only spaces:
package content
import (
"strings"
"github.com/riducms/ridu/field"
"github.com/riducms/ridu/operation"
)
var Subtitle = field.Text("subtitle").Hooks(field.Hooks[string]{
BeforeChange: []field.Transform[string]{cleanSubtitle},
})
func cleanSubtitle(
_ operation.WriteContext,
value operation.Value[string],
) (operation.Change[string], error) {
subtitle, present := value.Get()
if !present {
// There is no value to clean up; keep its current empty state.
return operation.Keep[string](), nil
}
subtitle = strings.TrimSpace(subtitle)
if subtitle == "" {
// Clear this optional field instead of saving spaces.
return operation.Replace(operation.Empty[string]()), nil
}
// Replace supplies this field's new value for the same save.
return operation.Replace(operation.Present(subtitle)), nil
}Add Subtitle to a collection’s Fields. Saving " Hello " stores "Hello"; saving only
spaces clears it. An update that omits subtitle retains the saved value. The _ parameter
means this function does not need its context.
BeforeChange receives typed values and runs before custom validators. Ridu checks the
replacement again before saving. AfterRead transforms use the same keep-or-replace results
but change only the returned value, leaving storage unchanged. See
Field hooks for both kinds of hook and their registration.
Read the user and surrounding fields
Field callbacks receive a context whose type describes the job the function does:
| Context | Where you use it |
|---|---|
operation.WriteContext |
A raw or typed hook that can return a field replacement |
operation.ReadContext |
A response transform or a Virtual field’s value resolver |
operation.ValidationContext |
A .Validate(...) callback that returns messages |
operation.DefaultContext |
A .DefaultFrom(...) callback that chooses an initial field value |
operation.AccessContext |
A field access rule that allows or denies access |
operation.EventContext |
A field event hook, such as AfterChange, that returns only an error |
These contexts expose the same named properties, with values appropriate to their phase:
ctx.Operationidentifies the action, andctx.IDidentifies the document when it has an ID.ctx.Actor.IDidentifies the signed-in user; an empty ID means an anonymous request.ctx.Siblingscontains this field and nearby fields in the same group or row.ctx.Rootcontains top-level fields.ctx.Priorcontains the previously saved version of this field’s group or row. It is empty when there was no previous value there, including a new row or a standalone read.ctx.Local.FindByIDreads a related document with the current user, exact locale, and active transaction. Passctx.Contextso cancellation travels with the call.
For example, ctx.Siblings.String("title") reads a nearby title without a Go type assertion.
These views share immutable values: ordinary reads do not copy maps or lists. Each view keeps
its snapshot when later hooks make changes. A transform must return operation.Replace(...)
for its own field to save a change. See
Using other field values for nested rows, previous values,
translations, and related lookups.
Collection and global hooks use a different context
Collection and global hooks receive ridu.HookContext, which works with the whole document.
Change entries in its ctx.Data map before saving to update several fields together. Its
ctx.Actor is a document pointer and is nil for an anonymous request. Its ctx.Local is the
full local API, so it can also write related records.
Field transforms and validators receive their value as a separate argument. Field contexts provide a read-only local reader. Use the field context for one field’s rule; use a collection or global hook for a change involving several fields or related writes. Hook context explains when values are available and how to forward the user and locale to related operations.
A relationship also illustrates why the callback phase matters: its write value is an
operation.ID, while its read value is an operation.ReferenceOutput. On a read, call ID()
for the referenced ID and Document() to check whether the related document was populated.
Do not assume every read includes the related document.
Return a validation message
Return []operation.Issue from a field validator when a value is invalid. Each issue has a
stable Code for API clients and a Message for the author. Returning nil, nil accepts the
value. The separate error return means the check could not run, such as a failed lookup.
Leave Target unset to show the message on the field being validated. When a rule compares
children of a group, use operation.At("childName") to show it on the child that needs fixing.
This pricing group rejects a sale price that is not lower than the regular price:
package content
import (
"github.com/riducms/ridu/field"
"github.com/riducms/ridu/operation"
"github.com/riducms/ridu/store"
)
var Pricing = field.Group("pricing", field.Fields{
field.Number("price").Required().Min(0),
field.Number("salePrice").Min(0),
}).Validate(validatePricing)
func validatePricing(
_ operation.ValidationContext,
value operation.Value[store.Value],
) ([]operation.Issue, error) {
group, present := value.Get()
if !present {
return nil, nil
}
// A Group's value is an object containing its child fields.
price, hasPrice := group.Get("price").NumberValue()
sale, hasSale := group.Get("salePrice").NumberValue()
if !hasPrice || !hasSale || sale < price {
return nil, nil
}
return []operation.Issue{{
Code: "sale_price_too_high",
Message: "Set a sale price lower than the regular price",
// Start inside pricing and place the message on salePrice.
Target: operation.At("salePrice"),
}}, nil
}Add Pricing to a collection’s Fields. A price of 10 with a sale price of 0 is valid:
zero is a real price. A sale price of 12 produces a message beside pricing.salePrice, and
the invalid change is not saved. Target starts inside the field being validated, so it uses
"salePrice", not "pricing.salePrice".
Put a message on a repeated row
When validating an entire Array or Blocks field, select a row by its _key before selecting
its child field. Ridu gives each row a stable key so the message can follow it after reordering.
| Validator location | Target | Message belongs to |
|---|---|---|
A details group |
operation.At("seo.title") |
The title inside its SEO group |
A variants array |
operation.At().Row(rowKey).Field("sku") |
The SKU in that variant |
A layout Blocks field |
operation.At().Block(rowKey, "hero").Field("heading") |
The heading in that Hero block |
Read the key from the candidate row; an array index is not a row key. Block also checks the
block type. A target can stay on the current field or go into its children; it cannot point to
an unrelated field. Ridu checks the target against the submitted candidate and fills in the
document, field, and locale information for you.
For nested lists and translations, see validation targets. For additional types and methods, see the operation reference.
Check a field while the author edits
.LiveValidate(...) callbacks receive operation.LiveValidationContext and an
operation.Value[T], and return the same []operation.Issue, error results as a save validator.
For a text field, T is string; a missing or null value is empty.
This context describes the unsaved form. Root and Siblings include submitted values and
retained update values, while Prior contains saved values. Input lets you distinguish an
omitted property from an explicit null. Defaults and save hooks have not run, and ctx.Local
can only read documents.
Use the Live server validation guide for commented examples,
the complete context table, and how to share a rule with .Validate(...) on save.