Formats

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 before maxResults() is applied
  • #querysortvalue – define a custom sort value before maxResults() 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.

Tip: Use $enabledCustomDirectives to check which custom directives are available in your CMS environment.

When to use directives

Quick decision guide: .execute() vs 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.

Available arguments for #queryexecute.
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.
Important: Unlike #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.

Available arguments for #queryfilter.
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.
Note: Built-in filtering methods result in faster performance and this directive should only be used for scenarios where there is no built-in method. For example, .bySiteName will be faster than using #queryfilter with Site name comparison logic.
Tip: You can move your logic into #queryexecute temporarily to test the functionality and ensure that your body logic returns the exact string true.
Tip: The preferred way to return a value from the filter body is to use #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} &mdash; ${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().

Available arguments for #querysortvalue.
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.
Note: Built-in sorting methods result in faster performance. For example, .sortBy("name") will be faster than using #querysortvalue with $asset.name as the logic.
Tips:
  • This directive sorts by string only. When sorting numbers, use $_NumberTool.withPadding to 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} &mdash; ${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 in MM-dd-yyyy hh:mm:ss a format), 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} &mdash; ${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} &mdash;
    ${page.metadata.author} &mdash;
    ${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:

  1. Remove .execute() from the query chain.
  2. Replace #foreach ($page in $results) with #queryexecute($query, $page).
  3. 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:

  1. Remove .execute() and the #foreach/#if block.
  2. Move the condition into #queryfilter. The body must return the exact string true.
  3. Use #queryexecute for the output, now with only matching results.
Tip: With #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.