Filters and paths
Build Go filters with query paths, comparison values, and conditions, then use them in reads and access rules.
Use the query package to describe which documents you want, such as “products that cost at most
50 and are in stock”. Pass the filter to the local Go API to read matching
documents, or return it from an access rule to limit what a user can see.
For the where syntax used by REST and the TypeScript SDK, see Querying data.
A filter is a query.Expression. You build one by naming a field and giving an ordinary Go value
to compare it with:
| Part | What it is | Example |
|---|---|---|
| Field name | The field to check | "price" |
| Value | The value to compare with | 50 |
query.Expression |
The resulting condition | price is at most 50 |
Build a filter
This function builds a filter for in-stock products within a budget:
package content
import "github.com/riducms/ridu/query"
func AffordableProducts(maxPrice float64) query.Expression {
// Describe both requirements; this does not read the database yet.
return query.And(
query.LessThanEqual("price", maxPrice),
query.Equal("inStock", true),
)
}Each comparison takes a field name and a value, and query.And requires both conditions. Building
the expression does not read the database: it is a value you can store, pass around, and reuse.
Run a filter executes it.
query.Path
A query.Path names one field. In filters you write in code, pass the field’s name as a string,
joining group and field names with a dot: "title", or "seo.title" for the title field in the
seo group. Every comparison helper, and query.Asc and query.Desc, converts the name for you.
Build a query.Path yourself only when a name is not written in your code, or where an option
takes paths:
| To name | Write |
|---|---|
| A dotted name you received as text | query.ParsePath("seo.title") |
| Field names computed at run time | query.NewPath(group, name) |
A field for ListOptions.Select |
query.Field("seo", "title") |
A malformed name written in code, such as "SEO.title", panics, like regexp.MustCompile. For
names that come from input, use ParsePath or NewPath, which return an error instead.
A name is checked against your schema only when the operation runs. query.Equal("unknown", "x")
succeeds, but a read that uses it fails with bad_query if the collection has no such field.
Fields with read access rules cannot be used in caller filters; see
protected field queries.
Groups, arrays, and blocks
| Structure | Path | What it checks |
|---|---|---|
A seo group |
seo.title |
The group’s title |
A variants array |
variants.sku |
The SKU in each row of the array |
A layout blocks field with a hero block |
layout.hero.heading |
The heading in each Hero block |
A path never contains a row index. A comparison on variants.sku matches a document when any
row matches, and NotEqual excludes a document when any row equals the value. Two comparisons
joined with And may be satisfied by different rows: there is no way yet to require the same row
to match both.
query.Value
Pass the value you compare with as an ordinary Go value of the kind the field holds:
| To compare with | Write |
|---|---|
| Text, or a relationship’s ID | "notebook", ctx.Actor.ID |
| A number | 50, price, 2.5 |
| A checkbox value | true |
| A select option | "published", or a named type |
| A date and time | query.DateTime(time.Now()) |
| A date only | "2026-09-29" |
| A polymorphic reference | query.Reference("posts", id) |
| Null | query.Null() |
Named types work too, so a type Status string constant can be compared with a select field
directly. "50" is text and 50 is a number, so choose the one that matches the field.
Dates are stored as text, so a comparison must use the same form the field stores. Ridu stores
every date-and-time value as UTC with milliseconds, such as 2026-09-29T14:05:00.000Z, however it
was written. query.DateTime converts a time.Time to that form for date-and-time fields,
createdAt, and updatedAt:
recent := query.GreaterThan(
"publishedAt",
query.DateTime(time.Now().AddDate(0, 0, -7)),
)Compare a date-only field with a "2026-09-29" string, such as t.Format(time.DateOnly).
A number is stored as a 64-bit float, which holds integers exactly only up to 2^53. Larger integers panic instead of silently losing precision; compare them as strings.
A polymorphic relationship can point into several collections, so its candidates name the
collection as well as the ID: query.In("subjects", query.Reference("posts", postID)). Lists,
has-many fields and polymorphic relationships take query.In only; see
Filter by membership.
A query.Value is the typed form the helpers build internally. Build one with query.String,
query.Number, query.Boolean, query.Null, query.Reference, or query.List when you call
query.Compare or need null. The similarly named store.Number(50) is a document value for
saving content, not a comparison value.
Comparisons
Each helper takes a field name and a value, and returns a query.Expression:
| Matches documents where the field… | Example |
|---|---|
| Equals the value | query.Equal("status", "published") |
| Does not equal the value | query.NotEqual("status", "draft") |
| Equals one of several values, or holds one item | query.In("color", "red", "blue") |
| Is greater than, or at least, a number or string | query.GreaterThan("price", 10), GreaterThanEqual |
| Is less than, or at most, a number or string | query.LessThan("price", 50), LessThanEqual |
| Contains the text, ignoring case | query.Contains("title", "ridu") |
| Contains every word of the text, ignoring case | query.Like("title", "go cms") |
| Has a value, or has none | query.Exists("image", true), query.Exists(..., false) |
To pass a list you built, spread it: query.In("color", colors...).
The helpers expect a valid name and a matching value, and panic otherwise. That is fine for
filters written in your code. When the operator or value comes from user input, use
query.Compare, which returns an error instead. The
operator table shows the matching REST and TypeScript names.
And, Or, and Not
query.And matches documents that satisfy every condition, query.Or matches any, and
query.Not inverts one:
visible := query.Or(
query.Equal("status", "published"),
query.Equal("author", userID),
)To add optional conditions, extend the filter as you go. query.And(filter, next) merges into the
existing And instead of nesting it, so building a filter this way stays cheap:
filter := query.Equal("group", groupID)
if kind != "" {
filter = query.And(filter, query.Equal("kind", kind))
}With a single condition, And and Or return that condition, so you can pass a list whose
length varies. Like the comparison helpers, they panic when given no conditions or a nil one;
check the length of a list built from input before calling them.
Run a filter
Pass the expression as ridu.ListOptions.Where:
package content
import (
"context"
"github.com/riducms/ridu"
"github.com/riducms/ridu/store"
)
func FindAffordableProducts(
ctx context.Context,
local *ridu.LocalAPI,
maxPrice float64,
) (store.Page, error) {
// List executes the filter and also applies collection read access.
return local.List(ctx, "products", ridu.ListOptions{
Where: AffordableProducts(maxPrice),
Page: 1,
Limit: 20,
})
}FindAffordableProducts(ctx, app.Local(), 50) returns visible, in-stock products that cost at
most 50. Ridu applies your filter and the collection’s access rules together, before pagination.
This example reads anonymously because it passes no actor. To read as a signed-in user, set
Actor and ActorCollection in the options; see passing the caller.
Typed collection handles
Generated Go collection handles return your application’s document types instead of
store.Values. Their ridu.TypedListOptions take the same query.Expression in Where, so
generated.ProductsCollection.With(app.Local()).List(...) uses the filters on this page
unchanged.
Use a filter in an access rule
An access rule can return ridu.Where with a filter to allow access to
matching documents only. This collection allows reads of visible products:
package content
import (
"github.com/riducms/ridu"
"github.com/riducms/ridu/field"
"github.com/riducms/ridu/query"
)
func visibleProducts(ridu.AccessContext) (ridu.AccessDecision, error) {
// This filter limits every read, including reads with other filters.
return ridu.Where(query.Equal("visible", true)), nil
}
var Products = ridu.Collection{
Slug: "products",
Fields: field.Fields{
field.Text("title").Required(),
field.Number("price").Required().Min(0),
field.Checkbox("inStock").Default(false),
field.Checkbox("visible").Default(false),
},
Access: ridu.CollectionAccess{Read: visibleProducts},
}The rule applies to reads from the admin, REST, the SDK, and the local API, and it filters the database query itself, so counts and pagination only include visible products.
Two different things are called “where”:
ridu.ListOptions{Where: filter}asks for matching documents. Access rules still apply.ridu.Where(filter)is an access rule’s decision to allow matching documents.
Sort results
A filter chooses documents; the list options choose their order and page. Sort with query.Asc
and query.Desc; later terms break ties in earlier ones:
return local.List(ctx, "products", ridu.ListOptions{
Where: filter,
Sort: []query.Sort{
query.Asc("price"),
query.Desc("createdAt"),
},
Limit: 20,
})When the direction comes from input, use query.NewSort, which returns an error for an unknown
direction. Some fields, such as arrays and blocks, cannot be sorted. See sorting and pagination for
limits, and the query reference for every function.