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, dynamic
default, or field access rule. The package defines the arguments Ridu passes to those functions
and the values they return. You do not need it for a plain field declaration such as
field.Text("title").Required().
The package does not run database operations itself. To create, read, or update a document, use the local Go API.
At a glance
A field callback’s signature is made of types from this package:
func cleanSubtitle(
ctx operation.Context, // The operation and nearby values
value operation.Value[string], // This field's value, if any
) (operation.Change[string], error) // Keep or replace the value| Type | What it is | Where you see it |
|---|---|---|
operation.Context |
The current operation and the values around the field | The first argument of every field callback |
operation.Value[T] |
A value of type T that may be empty |
The value argument, and what defaults return |
operation.Change[T] |
Keep, set, or clear the field’s value | What field transforms return |
operation.Kind |
The operation, such as operation.Create |
ctx.Operation |
operation.Issue |
A validation message for the author | What validators return |
operation.ID, operation.ReferenceOutput |
A related document’s ID, and the reference Ridu returns | Relationship and upload values |
operation.Context
operation.Context describes the operation a field callback is part of. Validators, defaults,
transforms, observers, virtual field resolvers, and field access rules
all receive it:
| Property | Description |
|---|---|
ctx.Operation |
What is happening, as an operation.Kind. |
ctx.ID |
The document’s ID. On a create it is empty until the document is saved; on a duplicate it is the copy’s ID once saved. |
ctx.Actor |
The signed-in user: ctx.Actor.ID, their auth collection, and their fields in ctx.Actor.Data. The ID is empty for an anonymous request. |
ctx.Siblings |
The fields next to this one: the same group, the same array row, or the top level. |
ctx.Root |
The document’s top-level fields. |
ctx.Prior |
The saved values of this field’s group or row before the operation. Empty on create and for new rows. |
ctx.Locale |
The content locale, or empty without localization. |
ctx.Local |
Reads another document with ctx.Local.FindByID(...), as the same user and in the same transaction. |
ctx.Context |
Cancellation and deadline. Pass it to ctx.Local and network calls. |
Siblings, Root, and Prior are read-only snapshots. Read a value by its field name:
title, _ := ctx.Siblings.String("title")
price, _ := ctx.Siblings.Get("price").NumberValue()
seoTitle, _ := ctx.Root.Get("seo").Get("title").StringValue()For the field in a nested group, ctx.Siblings is that group. In an array, it is the current row,
whatever its position. Using other field values covers nested
rows, translations, and related-document lookups.
Collection and global hooks use ridu.HookContext
Collection and global hooks receive ridu.HookContext
instead. It holds the whole document: change entries in its ctx.Data map to change several
fields at once. Its ctx.Actor is a document pointer that is nil for an anonymous request, and
its ctx.Local is the full local API, which can also save other documents.
Hook context compares the two.
operation.Value
operation.Value[T] carries a value of type T that may be empty. T is the field’s Go type:
string for Text, float64 for Number, bool for Checkbox. Call Get() to read the value and
whether one is present:
subtitle, present := value.Get()
if !present {
// There is no value. subtitle is "", Go's zero value for a string.
}Create a value with operation.Present(...), or an empty one with operation.Empty[T](). An
empty value needs its type in square brackets, because there is no argument for Go to infer it
from:
| Value | Get() returns |
Meaning |
|---|---|---|
operation.Present("Hello") |
"Hello", true |
A string |
operation.Present("") |
"", true |
An empty string |
operation.Present(false) |
false, true |
An explicit false |
operation.Empty[bool]() |
false, false |
No value |
Check present before using the value: when it is false, the first result is Go’s zero value,
not something an author entered. A dynamic default returns the same
type: operation.Present(value) supplies a default, and operation.Empty[T]() supplies none.
Missing input and explicit null
A raw callback, such as a BeforeValidate hook, receives operation.Value[store.Value] before
Ridu has checked the input’s type:
operation.Empty[store.Value]()means the caller did not send the field.operation.Present(store.Null())means the caller sentnull.- Any other present value might still have the wrong type for the field.
Later callbacks receive the typed value. By then, an update that did not send the field carries the saved value, so use a raw hook when you need to know what the caller sent. See missing input and null.
operation.Change
A field transform returns an operation.Change[T] that tells Ridu what to do with the field:
| Return | Result |
|---|---|
operation.Keep[string]() |
Leave the value unchanged |
operation.Set("Hello") |
Set the field to "Hello" |
operation.Clear[string]() |
Clear the field |
| A non-nil error | Stop the operation; a change returned with the error is not applied |
Keep preserves the value; Clear empties it. A cleared optional field becomes null, and a
required field still has to pass validation. Set accepts zero values too: operation.Set("")
saves an empty string. This 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.Context,
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.Clear[string](), nil
}
// Set supplies this field's new value for the same save.
return operation.Set(subtitle), nil
}Saving " Hello " stores "Hello", and saving only spaces clears the subtitle. An update that
does not send subtitle keeps the saved value. The same results work in an AfterRead hook,
where they change the response instead of the stored value; see
Field hooks.
operation.Kind
ctx.Operation is an operation.Kind. Compare it with these constants when your code should run
only for some operations:
| Constant | What it identifies |
|---|---|
operation.Create |
Creating a collection document |
operation.Duplicate |
Creating a copy of an existing document |
operation.Read |
Reading one document, a list, or a global |
operation.Update |
Updating a document or global, including a global’s first save |
operation.Publish |
Publishing a document or global |
operation.Unpublish |
Moving a document or global back to draft |
operation.Delete |
Deleting a document, or moving it to the trash when trash is on |
operation.RestoreDeleted |
Restoring a document from the trash |
operation.DeletePermanent |
Permanently deleting a trashed document |
operation.ReadVersions |
Reading a document’s version history |
operation.ReadDrafts |
Deciding whether an actor sees draft documents |
operation.Reference |
Deciding whether a relationship may point at a document |
operation.Admin |
Checking whether a signed-in user may open the admin |
operation.Unlock |
Checking whether a user may take over another editor’s lock |
The kind names the whole operation, not the hook that is running: an AfterRead hook preparing
the response to a create sees operation.Create. Access rules use the same kinds.
Restoring a saved version is not RestoreDeleted. It runs as Publish for a published version
and as Unpublish for a draft; RestoreDeleted is only for the trash.
operation.Issue
A field validator returns []operation.Issue when a value is invalid. Each issue has a Code
that API clients can check and a Message for the author. Return nil, nil to accept the value,
and a non-nil error only when the check itself could not run, such as a failed lookup.
An issue appears on the validated field unless you set Target. Use operation.At(...) to put
it on a child field instead. This group rejects a sale price that is not lower than the 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.Context,
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
}A price of 10 with a sale price of 12 shows a message beside pricing.salePrice, and nothing
is saved. A sale price of 0 is valid, because zero is a real price. Target starts inside the
validated field, so it is "salePrice", not "pricing.salePrice".
Hooks use the same type. Pass issues to ridu.Reject
to stop an operation, show the editor a message, and mark the fields to fix.
Put a message on a repeated row
To target a field inside an array or blocks row, select the row by its _key first. Ridu gives
each row a stable key, so the message follows the row even after reordering:
| Validator on | Target | Message appears on |
|---|---|---|
A details group |
operation.At("seo.title") |
The title in 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 rowKey from the row’s _key value; an array index is not a row key. A target can point to
the validated field or any field inside it, but not to an unrelated field. See
validation targets for nested lists and translations.
operation.ID and operation.ReferenceOutput
A relationship or upload field stores the related document’s ID as an operation.ID. When Ridu
returns the field, it uses an operation.ReferenceOutput instead, which also holds the related
document if the request asked for it:
id := reference.ID() // Always available.
doc, populated := reference.Document()
if populated {
name, _ := doc.Values["name"].StringValue()
// ...
}Write hooks and validators receive the operation.ID; .AfterRead(...) hooks receive the
operation.ReferenceOutput. To return a changed reference, build one with
operation.Populated(document) or operation.Unpopulated(id). The
relationship read hook example uses both types.
operation.LiveValidationContext
A .LiveValidate(...) check runs while an author edits, before saving. It receives
operation.LiveValidationContext and the field’s operation.Value[T], and returns the same
[]operation.Issue, error as a save validator.
Its Root and Siblings hold the unsaved form values, Prior holds the saved values, and
Input shows exactly which properties the form sent. Defaults and save hooks have not run yet,
and ctx.Local can only read. The Live server validation guide
has complete examples, and the operation reference lists every type.