Skip to content

Filters and queries

Most CairnCMS read endpoints accept a common set of query options. They control which rows match, how the result is sorted and paginated, what fields come back, and what statistics the response carries alongside the data. The same options work in REST as query parameters and in SEARCH request bodies. GraphQL exposes a subset of them as arguments and handles the rest through native language features (selections, aliases, nested arguments). The full mapping is in GraphQL below.

This page documents each option, the filter operators they compose with, and the variables that resolve at request time.

OptionPurposeGraphQL equivalent
fieldsField selection, including nested relations.Native selection set.
filterItem-level filter expression.filter argument.
searchFull-text-style match across all string fields.search argument.
sortResult ordering.sort argument.
limitPage size.limit argument.
offsetSkip count.offset argument.
pagePage number (alternative to offset, 1-based).page argument.
aggregateAggregate functions over the result.Separate <collection>_aggregated resolver.
groupByGrouping fields for aggregation.groupBy on the aggregated resolver.
metaExtra metadata on the response (counts).Not exposed; use the aggregated resolver for counts.
deepPer-relation query options on nested data.Arguments on the nested field selection.
aliasAliased fields in the output.Native GraphQL field aliases.

fields controls which columns the response includes. By default, every field the role can read is returned.

GET /items/articles?fields=id,title,published_at

Use * for “all fields the role can read”:

GET /items/articles?fields=*

Nested relations follow the dot path of the relation field:

GET /items/articles?fields=id,title,author.name,author.avatar.id

Combine wildcards with explicit paths to control depth:

GET /items/articles?fields=*,author.name

*.* selects one level of all relations. *.*.* selects two levels, and so on. Use this carefully; deep wildcards on highly relational schemas produce expensive queries.

For many-to-any relations, use the :collection syntax to scope fields per related collection:

GET /items/pages?fields=sections.item:headings.title,sections.item:paragraphs.body

filter is the item-level expression evaluated against each row. The shape is a JSON object that nests field names, operators, and values.

GET /items/articles?filter[status][_eq]=published

In a SEARCH body or GraphQL argument, the same shape is passed as an object:

{
"query": {
"filter": { "status": { "_eq": "published" } }
}
}

Combine clauses with _and and _or:

{
"filter": {
"_and": [
{ "status": { "_eq": "published" } },
{ "_or": [
{ "category": { "_eq": "news" } },
{ "category": { "_eq": "features" } }
] }
]
}
}

The implicit operator at any level is _and, so a top-level object with multiple field keys is shorthand for _and over those fields.

Use the relation’s field name and dot into the related collection:

{
"filter": { "author": { "status": { "_eq": "active" } } }
}

For one-to-many and many-to-many relations, two helper operators exist:

  • _some: matches if any related row matches the inner filter.
  • _none: matches if no related row matches.
{
"filter": { "tags": { "_some": { "label": { "_eq": "featured" } } } }
}

Three runtime values resolve inside filters at the moment the query runs:

  • $NOW: current timestamp.
  • $CURRENT_USER: ID of the authenticated user.
  • $CURRENT_ROLE: role ID of the authenticated user.
{
"filter": {
"_and": [
{ "publish_at": { "_lte": "$NOW" } },
{ "author": { "_eq": "$CURRENT_USER" } }
]
}
}

Filter variables are particularly useful in role permissions, where the same rule applies to every user but resolves to the caller’s ID at request time. Variables work the same way in API filters and in permission filters.

$NOW accepts an offset suffix for relative times, written as $NOW(<duration>) where duration is an ISO 8601-style segment:

{ "publish_at": { "_lte": "$NOW(-7 days)" } }
OperatorMeaning
_eqEqual to.
_neqNot equal to.
_ieqCase-insensitive equal.
_nieqCase-insensitive not equal.
_lt, _lte, _gt, _gteLess / greater than (or equal to).
_inIn the given array.
_ninNot in the given array.
_betweenBetween two values (inclusive). Value is [low, high].
_nbetweenNot between two values.
_nullField is NULL. Pass true, or false for the inverse.
_nnullField is not NULL. Pass true, or false for the inverse.
_emptyField is empty (zero-length string or empty array).
_nemptyField is not empty.
_containsString contains the substring (case sensitive).
_ncontainsString does not contain the substring.
_icontainsCase-insensitive contains.
_nicontainsCase-insensitive not contains.
_starts_withString starts with the substring.
_nstarts_withDoes not start with.
_istarts_withCase-insensitive starts with.
_nistarts_withCase-insensitive does not start with.
_ends_withString ends with the substring.
_nends_withDoes not end with.
_iends_withCase-insensitive ends with.
_niends_withCase-insensitive does not end with.
_intersectsGeometry intersects another geometry.
_nintersectsGeometry does not intersect.
_intersects_bboxGeometry intersects a bounding box.
_nintersects_bboxGeometry does not intersect a bounding box.

Operators that take no value (_null, _nnull, _empty, _nempty) accept true to apply the rule and false to apply its inverse. The case-insensitive variants are useful for fields that store user-supplied text without consistent casing (email addresses, search terms, tags).

_in and _nin with an empty array preserve their mathematical meaning: _in: [] matches no rows (the value is in an empty set, which is impossible), while _nin: [] matches every row (the value is not in an empty set, which is trivially true). Permission rules expressed with these operators behave the same way; an _in: [] permission filter denies all matches, and a _nin: [] permission filter denies nothing.

Read-derived query operands require field-read authority

Section titled “Read-derived query operands require field-read authority”

Several query options derive information from field values rather than returning the value directly: filter row inclusion, search substring matching, sort ordering, groupBy bucketing, value-derived aggregates (min, max, sum, sumDistinct, avg, avgDistinct), and alias mappings. The platform enforces that operands of these operations must be readable by the caller. A caller cannot use a field as an indirect oracle for a value they cannot read directly.

  • Filter — an operand the caller cannot read is rejected with 403 FORBIDDEN.
  • Search — search runs only across fields the caller can read. Other string fields are excluded from the substring match.
  • Sort — an explicit sort by an unread field is rejected with 403. If the schema’s configured sort_field is unread by the caller and no explicit sort is supplied, the request falls back to primary-key order rather than refusing the listing.
  • groupBy — an operand the caller cannot read is rejected with 403.
  • Aggregates — every aggregate operand requires field-read permission, except * (used by countAll and count('*')). On an unread field, all aggregates with a specific field operand are rejected with 403. On concealed fields specifically, value-derived aggregates (min, max, sum, sumDistinct, avg, avgDistinct) are rejected with 400 INVALID_QUERY even when the caller has read permission on the field; count-style aggregates (count, countDistinct) on a permitted concealed field are not blocked by the conceal rule because they return an integer count and do not leak the value.
  • Aliases — an alias must resolve to a field the caller can read; aliases do not bypass field permissions.

Concealed fields are subject to additional restrictions on top of normal read permissions and apply uniformly to all callers, including admins. See Concealed fields.

search is a single-string match across all string fields the role can read.

GET /items/articles?search=climate

The implementation is a LIKE-style match per field combined with OR. It is not a full-text engine and does not stem, rank, or tokenize. For projects that need real search relevance, integrate a dedicated search index (Meilisearch, Typesense, or similar) and let CairnCMS hold the canonical data.

sort accepts a field name, prefixed with - for descending order, or an array of fields for multi-key sort:

GET /items/articles?sort=-published_at,title

Nested fields work with dot notation:

GET /items/articles?sort=author.name

Sort operands are subject to field-read authority. An explicit sort by a field the caller cannot read is rejected with 403. When no explicit sort is supplied and the collection’s configured sort_field is unread by the caller, the implicit default is normalized to primary-key order rather than rejecting the listing. See Read-derived query operands for the full rule.

Two modes:

  • limit and offset:
GET /items/articles?limit=20&offset=40
  • limit and page:
GET /items/articles?limit=20&page=3

page is 1-based, so page=1 returns the first limit rows, page=2 skips the first limit and returns the next, and so on. The platform computes the offset internally as limit * (page - 1). The example above returns rows 41 through 60.

Pick whichever knob fits your client. offset is a raw skip count; page is a friendlier shape when the UI thinks in pages.

limit=-1 returns every matching row with no cap. Reach for it when the result set is bounded by a strict filter (a singleton-style use case). Avoid it on open queries against large collections; it can produce arbitrarily large responses and slow queries.

The default limit for a list endpoint is 100, configurable through the QUERY_LIMIT_DEFAULT environment variable. The hard cap is QUERY_LIMIT_MAX.

aggregate runs a function over the result set without returning per-row data:

GET /items/articles?aggregate[count]=*

The supported functions:

FunctionMeaning
countRow count.
countDistinctDistinct value count.
sumSum of a numeric field.
sumDistinctSum of distinct values.
avgMean of a numeric field.
avgDistinctMean of distinct values.
min, maxMinimum / maximum of a field.

Each function takes a field name (or * for count):

GET /items/articles?aggregate[avg]=read_time&aggregate[max]=word_count

groupBy groups the result for the aggregate to operate over:

GET /items/articles?aggregate[count]=*&groupBy=category

The response is one row per group, each carrying the group keys and the aggregate values.

filter and search apply before the aggregation runs, so an aggregate over a filtered set returns the count of matching rows.

meta adds extra fields to the response envelope alongside data:

KeyMeaning
total_countCount of all rows in the collection (ignores filter).
filter_countCount of rows matching the request’s filter.
GET /items/articles?meta=total_count,filter_count

meta=* is shorthand for both. Combined with filter, the response shows how many rows would match without pagination, which is what UIs need to render “showing 1-20 of 142” labels.

meta only applies on list reads. Single-item reads, creates, and updates ignore it.

deep applies query options to nested relations independently of the top-level query.

GET /items/articles?fields=id,title,comments.body&deep[comments][_filter][approved][_eq]=true&deep[comments][_limit]=5

The shape is deep[<relation>][<option>]=<value>. Most top-level options work in deep: _filter, _sort, _limit, _offset, _page, _search, _fields. Nested deeps are supported with another level of deep[<inner-relation>].

In a SEARCH body or GraphQL argument, deep is an object of objects keyed by relation name:

{
"deep": {
"comments": {
"_filter": { "approved": { "_eq": true } },
"_limit": 5
}
}
}

deep is the right tool for fetching a constrained subset of related rows. Use it for “the article and its three most recent comments” rather than relying on a separate request to fetch the comments and stitching the results together client-side.

alias renames fields in the output. The map is keyed by alias and points at a top-level field on the same collection:

GET /items/articles?alias[headline]=title&fields=headline

Both the key and the value must be plain field names. Periods are rejected by query validation, so alias cannot rename a relation path like author.name to a flat field. To project a related field under a different name, use a GraphQL alias instead, or read the field directly and rename it client-side.

REST encodes the same query as URL parameters using bracket notation:

ShapeEncoding
fields=[a, b, c]fields=a,b,c or fields[]=a&fields[]=b&fields[]=c
filter[status][_eq]=publishedfilter%5Bstatus%5D%5B_eq%5D=published
sort=[-published_at, title]sort=-published_at,title
meta=*meta=%2A

For deeply-nested filters or large fields[] arrays, the URL hits length limits. Use SEARCH (see SEARCH method) to put the same query in the request body instead.

GraphQL accepts a subset of these options as arguments on the collection query:

query {
articles(
filter: { status: { _eq: "published" } }
sort: ["-published_at"]
limit: 20
offset: 0
) {
id
title
author { name }
}
}

The supported arguments are filter, sort, limit, offset, page, and search. The argument names match REST one-to-one.

Three options are not exposed as collection arguments because GraphQL handles them through native language features:

  • Field selection uses GraphQL’s selection set. The id, title, and author { name } lines above are the equivalent of REST’s fields=id,title,author.name.
  • Aliases use GraphQL’s native field-aliasing syntax. Write headline: title in the selection to rename title to headline in the response, or byline: author { name } to project a related field under a different name.
  • Deep query options are arguments on the nested field, not on the parent collection. To filter a relation, set arguments inside the nested selection: comments(filter: { approved: { _eq: true } }, limit: 5) { id body }.

Aggregation and counts live on a separate resolver named <collection>_aggregated. It accepts groupBy, filter, limit, offset, page, search, and sort, and returns one row per group with the requested aggregate functions:

query {
articles_aggregated(groupBy: ["category"]) {
group
count {
id
}
}
}

REST’s meta option is not exposed in GraphQL. Use <collection>_aggregated with a count selection to get the equivalent counts.

  • Items — the endpoints these queries operate on.
  • Files — the same query options applied to /files.
  • GraphQL — schema introspection, query depth, and the user / system endpoint split.
  • Permissions — how role-level filters compose with request-level filters.