Query Tool Directives
Overview
Introduced in Cascade CMS 8.26, Query Directives are an alternative to the .execute() method on the Query API. They process results one asset at a time instead of loading everything into a list, which means they can handle up to 100,000 results (compared to the 2,000 limit with .execute()).
There are three directives:
#queryexecute– iterate over results and output content for each asset#queryfilter– apply custom filtering logic beforemaxResults()is applied#querysortvalue– define a custom sort value beforemaxResults()is applied
All three use the same basic pattern: pass a query object and an asset variable, write your logic in the body, and close with #end.
$enabledCustomDirectives to check which custom directives are available in your CMS environment.When to use directives
| Scenario | Use |
|---|---|
| Results under 2,000 with no custom filtering or sorting | .execute() |
| Need more than 2,000 results | #queryexecute |
| Need to filter on values not available via built-in query methods | #queryfilter |
| Need to sort by structured data, dynamic metadata, or computed values | #querysortvalue |
| Under 2,000 results but need custom filtering or sorting | #queryfilter / #querysortvalue + .execute() |
| Large dataset with custom filter + sort + output | All three combined |
#queryexecute
Replaces the .execute() + #foreach pattern. The directive iterates over results directly, processing one asset at a time. Supports maxResults() up to 100,000.
| Argument | Type | Description |
|---|---|---|
| query | SearchQuery required |
A query object. |
| asset | Object required |
Populated automatically by the system. Points to a new asset on each iteration, while the old asset gets cleared to save memory. |
| body | Logic required |
Logic to execute and output for each asset. |
#foreach, this directive clears the previous asset from memory on each iteration. This means $foreach.count, $foreach.hasNext, $foreach.index, and other loop variables are not available. If you need a counter or first-iteration check, manage it yourself with a #set variable.Basic usage
Build a query, then pass it to #queryexecute instead of calling .execute().
## Build the query (do NOT call .execute())
#set ($query = $_.query().byContentType("Article").maxResults(10).sortBy("startDate").sortDirection("desc"))
## Iterate with the directive
#queryexecute($query, $page)
<h3>$page.metadata.title</h3>
<p>$page.metadata.summary</p>
#end
Sitemap generation
Query directives are well suited for sitemaps where the result set can be very large.
#set ($query = $_.query().byContentType("Default-Page").indexableOnly(true).preloadDynamicMetadata())
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
#queryexecute($query, $page)
<url>
<loc>https://yoursite.com${page.path}.html</loc>
</url>
#end
</urlset>
Large datasets with preloaded data
When working with structured data across many results, preload it on the query to avoid per-asset database round trips.
#set ($query = $_.query().byContentType("Event").preloadStructuredData().maxResults(5000).sortBy("startDate").sortDirection("asc"))
#queryexecute($query, $event)
#set ($start = $event.getStructuredDataNode("startDateTime").textValue)
#set ($title = $event.metadata.title)
<div class="event-card">
<h3>$title</h3>
<span>$start</span>
</div>
#end
Cross-site query
Query across all sites for assets using a shared Content Type.
#set ($query = $_.query().byContentType("site://Global/Shared Page").searchAcrossAllSites().preloadDynamicMetadata().maxResults(10000))
#queryexecute($query, $page)
<li>
<a href="${page.link}">${page.metadata.title}</a>
<small>(${page.siteName})</small>
</li>
#end
JSON feed
Since loop variables aren't available, use a flag variable like $isFirstIteration for comma separation in JSON output.
#set($query = $_.query().byContentType("Event").preloadStructuredData().sortBy("startDate"))
#set($isFirstIteration = true)
[
#queryexecute($query, $event)
#if(!$isFirstIteration),#end
#set($title = $_EscapeTool.java($event.metadata.title.trim()))
#set($date = $event.getStructuredDataNode("event-date").textValue)
{
"title": "${title}",
"date": "${date}",
"url": ""
}
#set($isFirstIteration = false)
#end
]
The key pattern: #if(!$isFirstIteration),#end outputs a comma before every entry except the first, replacing what you'd normally do with $foreach.hasNext.
#queryfilter
Executes body logic on each asset matching a query before maxResults() is applied. If the body logic returns true, the asset is included in the results.
| Argument | Type | Description |
|---|---|---|
| query | SearchQuery required |
A query object. |
| asset | Object required |
Populated automatically by the system. Points to a new asset on each iteration, while the old asset gets cleared to save memory. |
| body | Logic required |
Logic to execute. Must return the exact string true to include the asset. |
.bySiteName will be faster than using #queryfilter with Site name comparison logic.#queryexecute temporarily to test the functionality and ensure that your body logic returns the exact string true.#set to capture the result, then output it:
#queryfilter($query, $asset)
#set($result = $asset.path.contains("index"))
$result
#end
This pattern handles negation and multiple conditions cleanly without needing #if/true/#end.Filter by path pattern
Only include index pages from the query results.
#set ($query = $_.query().byContentType("Article"))
#queryfilter($query, $asset)
$asset.path.contains("index")
#end
#queryexecute($query, $page)
<li>${page.metadata.title}</li>
#end
Filter by structured data value
Include only assets where a structured data checkbox has a specific value. This is useful for multi-value fields where built-in methods can't filter directly.
#set ($query = $_.query().byContentType("News").preloadStructuredData())
#queryfilter($query, $asset)
$asset.getStructuredDataNode("details/is-featured").textValue.contains("Yes")
#end
#queryexecute($query, $page)
<div class="featured-news">
<h3>${page.metadata.title}</h3>
<p>${page.metadata.summary}</p>
</div>
#end
Filter by dynamic metadata
Include only assets where a dynamic metadata field matches a condition.
#set ($query = $_.query().byContentType("Page").preloadDynamicMetadata())
#queryfilter($query, $asset)
$asset.metadata.getDynamicField("display-in-nav").value.contains("Yes")
#end
#queryexecute($query, $page)
<li><a href="${page.link}">${page.metadata.displayName}</a></li>
#end
Filter by empty or populated field
Use $_PropertyTool.isNotEmpty() to include only assets where a field has a value. Negate it to find assets where a field is empty.
## Only pages where the author field is populated
#set ($query = $_.query().byContentType("Article"))
#queryfilter($query, $asset)
$_PropertyTool.isNotEmpty($asset.metadata.author)
#end
#queryexecute($query, $page)
<li>${page.metadata.title} — ${page.metadata.author}</li>
#end
To find assets where a field is empty, negate the check with #set:
#queryfilter($query, $asset)
#set($result = !$_PropertyTool.isNotEmpty($asset.metadata.author))
$result
#end
Multiple conditions
Use #set to combine conditions with && or ||. The result is captured as a boolean and output directly.
#set ($query = $_.query().byContentType("Article").preloadStructuredData())
#queryfilter($query, $asset)
#set($result = $asset.path.contains("index") && $asset.getStructuredDataNode("details/is-featured").textValue.contains("Yes"))
$result
#end
#queryexecute($query, $page)
<li>${page.metadata.title}</li>
#end
OR logic works the same way:
#queryfilter($query, $asset)
#set($result = $asset.metadata.title.contains("News") || $asset.metadata.title.contains("Events"))
$result
#end
Exclude pattern
To exclude assets matching a condition, negate the check.
#set ($query = $_.query().byContentType("Article"))
#queryfilter($query, $asset)
#set($result = !$asset.path.contains("/archive/"))
$result
#end
#queryexecute($query, $page)
<li>${page.metadata.title}</li>
#end
#querysortvalue
Executes body logic on each asset to determine the sort value before maxResults() is applied. This is an alternative to .sortBy() and can be used with both #queryexecute and .execute().
| Argument | Type | Description |
|---|---|---|
| query | SearchQuery required |
A query object. |
| asset | Object required |
Populated automatically by the system. Points to a new asset on each iteration, while the old asset gets cleared to save memory. |
| body | Logic required |
Logic to execute. The return value is used as the sort key. |
.sortBy("name") will be faster than using #querysortvalue with $asset.name as the logic.- This directive sorts by string only. When sorting numbers, use
$_NumberTool.withPaddingto ensure results sort correctly. - This directive respects the value passed into
.sortDirection()on the query.
Sort by metadata title
#set ($query = $_.query().byContentType("Article"))
#querysortvalue($query, $asset)
$asset.metadata.title
#end
#queryexecute($query, $page)
<li>${page.metadata.title}</li>
#end
Sort by structured data text field
Sort by a text value stored in structured data rather than a metadata field.
#set ($query = $_.query().byContentType("Article").preloadStructuredData().sortDirection("asc"))
#querysortvalue($query, $asset)
$asset.getStructuredDataNode("title").textValue
#end
#queryexecute($query, $page)
<li>${page.getStructuredDataNode("title").textValue}</li>
#end
Numeric sorting with $_NumberTool.withPadding
Since #querysortvalue sorts by string, numeric values need padding to sort correctly (e.g., "5" would sort after "10" without padding).
#set ($query = $_.query().byContentType("Course").preloadStructuredData().sortDirection("asc"))
#querysortvalue($query, $asset)
$_NumberTool.withPadding($asset.getStructuredDataNode("course-number").textValue)
#end
#queryexecute($query, $course)
<li>${course.getStructuredDataNode("course-number").textValue} - ${course.metadata.title}</li>
#end
Multi-level sorting
Since sorting is string-based, you can concatenate multiple values to sort by more than one field. The first value acts as the primary sort, the second as the tiebreaker, and so on.
Alphabetical by last name, then first name
#set ($query = $_.query().byContentType("Staff").preloadStructuredData().sortDirection("asc"))
#querysortvalue($query, $asset)
$asset.getStructuredDataNode("last-name").textValue $asset.getStructuredDataNode("first-name").textValue
#end
#queryexecute($query, $person)
<li>${person.getStructuredDataNode("last-name").textValue}, ${person.getStructuredDataNode("first-name").textValue}</li>
#end
By department number, then course name
#set ($query = $_.query().byContentType("Course").preloadStructuredData().sortDirection("asc"))
#querysortvalue($query, $asset)
$_NumberTool.withPadding($asset.getStructuredDataNode("department-number").textValue) $asset.metadata.title
#end
#queryexecute($query, $course)
<li>${course.getStructuredDataNode("department-number").textValue} - ${course.metadata.title}</li>
#end
Note the $_NumberTool.withPadding on the department number. Without it, department "5" would sort after "10" since it's comparing strings.
By author, then title
#set ($query = $_.query().byContentType("Article").sortDirection("asc").maxResults(50))
#querysortvalue($query, $asset)
$asset.metadata.author $asset.metadata.title
#end
#queryexecute($query, $page)
<li>${page.metadata.author} — ${page.metadata.title}</li>
#end
Sort by date stored in structured data
Date strings like 01-15-2024 02:30:00 PM won't sort chronologically as plain strings because the month comes first. Convert the value to epoch milliseconds with $_DateTool so the sort is truly chronological.
#set ($query = $_.query().byContentType("Event").preloadStructuredData().sortDirection("desc").maxResults(20))
#querysortvalue($query, $asset)
#set ($dt = $asset.getStructuredDataNode("event-date").textValue)
#if($_PropertyTool.isNotEmpty($dt))
#set ($date = $_DateTool.toDate("MM-dd-yyyy hh:mm:ss a", $dt))
${date.getTime()}
#else
0
#end
#end
#queryexecute($query, $event)
<li>
${event.metadata.title}
<span>${event.getStructuredDataNode("event-date").textValue}</span>
</li>
#end
The format string passed to $_DateTool.toDate() must match the format stored in your data definition. Adjust it to match your field's format.
Sort descending
The directive respects .sortDirection(). Set it on the query object before using #querysortvalue.
#set ($query = $_.query().byContentType("News").sortDirection("desc"))
#querysortvalue($query, $asset)
$asset.metadata.title
#end
#queryexecute($query, $page)
<li>${page.metadata.title}</li>
#end
Combining directives
All three directives can be used together on the same query. The order of operations is: #queryfilter runs first to narrow the results, then #querysortvalue determines the sort order, and finally #queryexecute outputs the results up to the maxResults() limit.
Full example: filtered, sorted event listing
Query all events, filter to only featured ones, sort by a structured data date field, and output the first 50.
## Build the query
#set ($query = $_.query().byContentType("Event").preloadStructuredData().sortDirection("asc").maxResults(50))
## Filter: only featured events
#queryfilter($query, $asset)
$asset.getStructuredDataNode("is-featured").textValue.contains("Yes")
#end
## Sort: by event date stored in structured data
#querysortvalue($query, $asset)
$asset.getStructuredDataNode("event-date").textValue
#end
## Output
<ul>
#queryexecute($query, $event)
<li>
<strong>${event.metadata.title}</strong>
<span>${event.getStructuredDataNode("event-date").textValue}</span>
</li>
#end
</ul>
Date range filter with chronological sorting
Filter to events within a specific date range and sort them chronologically. The filter parses each date into epoch milliseconds for comparison, and the sort does the same for ordering.
#set ($query = $_.query().byContentType("Event").preloadStructuredData().sortDirection("asc").maxResults(100))
#set ($rangeStart = $_DateTool.toDate("yyyy-MM-dd", "2024-01-01").getTime())
#set ($rangeEnd = $_DateTool.toDate("yyyy-MM-dd", "2024-12-31").getTime())
## Filter: only events in 2024
#queryfilter($query, $asset)
#set ($dt = $asset.getStructuredDataNode("event-date").textValue)
#if($_PropertyTool.isNotEmpty($dt))
#set ($ts = $_DateTool.toDate("MM-dd-yyyy hh:mm:ss a", $dt).getTime())
#if($ts >= $rangeStart && $ts <= $rangeEnd)
true
#end
#end
#end
## Sort: chronological by event date
#querysortvalue($query, $asset)
#set ($dt = $asset.getStructuredDataNode("event-date").textValue)
#if($_PropertyTool.isNotEmpty($dt))
#set ($date = $_DateTool.toDate("MM-dd-yyyy hh:mm:ss a", $dt))
${date.getTime()}
#else
0
#end
#end
## Output with formatted date
#queryexecute($query, $event)
#set ($dt = $event.getStructuredDataNode("event-date").textValue)
#set ($date = $_DateTool.toDate("MM-dd-yyyy hh:mm:ss a", $dt))
#set ($formatted = $_DateTool.format("MMMM d, yyyy", $date))
<li>
<strong>${event.metadata.title}</strong>
<span>$formatted</span>
</li>
#end
In-depth examples
These examples demonstrate advanced patterns that build on the basics above. They reference a Content Type with the following fields. Adapt the content type path and field names to match your own setup.
- Structured data:
title(text),event-date(datetime inMM-dd-yyyy hh:mm:ss aformat),asset-chooser(page chooser) - Dynamic metadata:
multiselect(multi-select),checkbox(checkbox),department(dropdown),radio(radio),noindex(radio) - Standard metadata: title, author, displayName, summary, description, teaser, keywords, startDate, endDate
Filtering
Filter by all values in a multi-select field
Check that a multi-value field contains every expected value using multiple .contains() calls joined with &&.
#set ($query = $_.query().byContentType("Article").preloadDynamicMetadata().maxResults(-1))
#queryfilter($query, $asset)
#set ($cb = $asset.metadata.getDynamicField("checkbox").value)
#set($result = $cb.contains("Easy") && $cb.contains("Fast") && $cb.contains("Cheap"))
$result
#end
#queryexecute($query, $page)
<li>${page.metadata.title}</li>
#end
Combine structured data, dynamic metadata, and standard metadata
Filter using fields from three different sources in one condition. Preload both structured data and dynamic metadata on the query.
#set ($query = $_.query().byContentType("Article").preloadStructuredData().preloadDynamicMetadata().maxResults(-1))
#queryfilter($query, $asset)
#set($result = $_PropertyTool.isNotEmpty($asset.getStructuredDataNode("event-date").textValue) && $asset.metadata.getDynamicField("checkbox").value.contains("Easy") && $asset.getStructuredDataNode("title").textValue.contains("Guide"))
$result
#end
#queryexecute($query, $page)
<li>${page.getStructuredDataNode("title").textValue}</li>
#end
Sorting
Sort by field value, then by date chronologically
Concatenate a text field with epoch milliseconds for a two-level sort. The text field acts as the primary sort, and the date as the tiebreaker.
#set ($query = $_.query().byContentType("Event").preloadStructuredData().preloadDynamicMetadata().sortDirection("asc").maxResults(30))
#querysortvalue($query, $asset)
#set ($group = $asset.metadata.getDynamicField("department").value)
#set ($dt = $asset.getStructuredDataNode("event-date").textValue)
#if($_PropertyTool.isNotEmpty($dt))
#set ($date = $_DateTool.toDate("MM-dd-yyyy hh:mm:ss a", $dt))
${group} ${date.getTime()}
#else
${group} 0
#end
#end
#queryexecute($query, $page)
<li>${page.metadata.getDynamicField("department").value} — ${page.metadata.title}</li>
#end
Combined
Most recent pages in a filtered category
Combine #queryfilter to narrow by category, #querysortvalue to sort by date descending, and maxResults(10) to take only the top 10.
#set ($query = $_.query().byContentType("Article").preloadStructuredData().sortDirection("desc").maxResults(10))
#queryfilter($query, $asset)
$asset.metadata.title.contains("Science")
#end
#querysortvalue($query, $asset)
#set ($dt = $asset.getStructuredDataNode("event-date").textValue)
#if($_PropertyTool.isNotEmpty($dt))
#set ($date = $_DateTool.toDate("MM-dd-yyyy hh:mm:ss a", $dt))
${date.getTime()}
#else
0
#end
#end
#queryexecute($query, $page)
<li>${page.metadata.title}</li>
#end
Three-field filter with two-field sort
Filter on three conditions (dynamic metadata + standard metadata), then sort by two concatenated fields.
#set ($query = $_.query().byContentType("Article").preloadStructuredData().preloadDynamicMetadata().sortDirection("asc").maxResults(25))
#queryfilter($query, $asset)
#set($result = $asset.metadata.getDynamicField("checkbox").value.contains("Fast") && $asset.metadata.getDynamicField("noindex").value.contains("No") && $_PropertyTool.isNotEmpty($asset.metadata.summary))
$result
#end
#querysortvalue($query, $asset)
$asset.metadata.getDynamicField("department").value $asset.metadata.author
#end
#queryexecute($query, $page)
<li>
${page.metadata.getDynamicField("department").value} —
${page.metadata.author} —
${page.metadata.title}
</li>
#end
Group results under dynamic headers
Sort by the grouping field so identical values are adjacent, then detect when the value changes to insert a header.
#set ($query = $_.query().byContentType("Article").preloadDynamicMetadata().sortDirection("asc").maxResults(50))
#querysortvalue($query, $asset)
$asset.metadata.getDynamicField("department").value $asset.metadata.title
#end
#set ($currentGroup = "")
#queryexecute($query, $page)
#set ($group = $page.metadata.getDynamicField("department").value)
#if($group != $currentGroup)
<h3>$group</h3>
#set ($currentGroup = $group)
#end
<li>${page.metadata.title}</li>
#end
Advanced patterns
Aggregate counts by field value
Use a map to count how many pages have each value in a field. Arithmetic (+) only works inside #set directives, not inside method arguments like .put().
#set ($query = $_.query().byContentType("Article").preloadDynamicMetadata().maxResults(-1))
#set ($counts = {"Yes": 0, "No": 0, "Maybe": 0})
#queryexecute($query, $page)
#set ($val = $page.metadata.getDynamicField("radio").value)
#if($val.contains("Yes"))
#set ($n = $counts.get("Yes") + 1)
#set ($dummy = $counts.put("Yes", $n))
#elseif($val.contains("No"))
#set ($n = $counts.get("No") + 1)
#set ($dummy = $counts.put("No", $n))
#elseif($val.contains("Maybe"))
#set ($n = $counts.get("Maybe") + 1)
#set ($dummy = $counts.put("Maybe", $n))
#end
#end
Yes: $counts.get("Yes"), No: $counts.get("No"), Maybe: $counts.get("Maybe")
Parse, sort, and format dates for display
Filter to pages with a date, sort chronologically using epoch milliseconds, then format the date for readable output with $_DateTool.format().
#set ($query = $_.query().byContentType("Event").preloadStructuredData().sortDirection("desc").maxResults(15))
#queryfilter($query, $asset)
$_PropertyTool.isNotEmpty($asset.getStructuredDataNode("event-date").textValue)
#end
#querysortvalue($query, $asset)
#set ($dt = $asset.getStructuredDataNode("event-date").textValue)
#set ($date = $_DateTool.toDate("MM-dd-yyyy hh:mm:ss a", $dt))
${date.getTime()}
#end
#queryexecute($query, $event)
#set ($dt = $event.getStructuredDataNode("event-date").textValue)
#set ($date = $_DateTool.toDate("MM-dd-yyyy hh:mm:ss a", $dt))
#set ($formatted = $_DateTool.format("MMMM d, yyyy", $date))
<li>
<strong>${event.metadata.title}</strong>
<span>$formatted</span>
</li>
#end
Count pages per category using a map
Define a map of categories with initial counts of zero. Iterate all pages, check each category, and increment the count. Output the breakdown with #foreach on the map entries.
#set ($query = $_.query().byContentType("Article").maxResults(-1))
#set ($cats = {"Technology": 0, "Science": 0, "Education": 0, "Healthcare": 0, "Business": 0})
#queryexecute($query, $page)
#set ($t = $page.metadata.title)
#foreach($cat in $cats.keySet())
#if($t.contains($cat))
#set ($n = $cats.get($cat) + 1)
#set ($dummy = $cats.put($cat, $n))
#end
#end
#end
<ul>
#foreach($entry in $cats.entrySet())
<li>$entry.key: $entry.value</li>
#end
</ul>
Migration guide
Converting an existing .execute() pattern to use directives is straightforward.
Before: .execute() + #foreach
#set ($results = $_.query().byContentType("Article").maxResults(10).sortBy("startDate").sortDirection("desc").execute())
#foreach ($page in $results)
<h3>$page.metadata.title</h3>
<p>$page.metadata.summary</p>
#end
After: #queryexecute
#set ($query = $_.query().byContentType("Article").maxResults(10).sortBy("startDate").sortDirection("desc"))
#queryexecute($query, $page)
<h3>$page.metadata.title</h3>
<p>$page.metadata.summary</p>
#end
Key changes:
- Remove
.execute()from the query chain. - Replace
#foreach ($page in $results)with#queryexecute($query, $page). - The body stays the same.
Before: .execute() with manual filtering
#set ($list = $_.query().byContentType("News").preloadStructuredData().execute())
#foreach ($page in $list)
#if ($page.getStructuredDataNode("details/is-featured").textValue.contains("Yes"))
<div>$page.metadata.title</div>
#end
#end
After: #queryfilter + #queryexecute
#set ($query = $_.query().byContentType("News").preloadStructuredData())
#queryfilter($query, $asset)
$asset.getStructuredDataNode("details/is-featured").textValue.contains("Yes")
#end
#queryexecute($query, $page)
<div>$page.metadata.title</div>
#end
Key changes:
- Remove
.execute()and the#foreach/#ifblock. - Move the condition into
#queryfilter. The body must return the exact stringtrue. - Use
#queryexecutefor the output, now with only matching results.
#queryfilter, the filtering happens before maxResults() is applied. This means you get the correct number of matching results, rather than filtering down from a potentially truncated list.