Browse all documentation

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:

go
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:

go
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:

go
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 sent null.
  • 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:

content/subtitle.go
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:

content/pricing.go
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:

go
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.