Data Queries

Data queries provide a declarative XML-based DSL for building type-safe, permission-checked queries against entity and JSON models. Queries are defined in XML files, stored as MetaQuery records, and executed through the query engine with automatic JPQL generation, parameterized execution, and row-level security.

Overview

A data query is defined using the <query> element inside a <data-queries> file. Query files are placed in the src/main/resources/queries/ directory of a module and are automatically loaded during application startup.

<?xml version="1.0" encoding="UTF-8"?>
<data-queries xmlns="http://axelor.com/xml/ns/data-queries"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://axelor.com/xml/ns/data-queries
      https://axelor.com/xml/ns/data-queries/data-queries_10.0.xsd">

  <query name="active-contacts">
    <from model="com.axelor.contact.db.Contact" as="c"/>
    <select field="c.fullName" as="name"/>
    <select field="c.email" as="email"/>
    <where field="c.archived" op="ne" value="true"/>
    <orderBy field="c.fullName" dir="asc"/>
  </query>

</data-queries>

Queries can also be created and managed through the UI at Administration > View Management > Queries.

Query Structure

A query has the following elements, all optional except <from>:

Element Description

<from>

Required. The source model and its alias

<join>

Explicit relation joins or cross joins

<select>

Fields or expressions to return (omit for entity result)

<where>

Filter conditions

<groupBy>

Group-by expressions

<having>

Conditions on grouped results

<orderBy>

Sort order

<limit>

Maximum number of results

<offset>

Number of results to skip

<meta>

Column, parameter and query-total metadata declarations

The <query> element supports these attributes:

Attribute Description

name

Unique name for the query (required when stored as MetaQuery)

distinct

When true, eliminates duplicate rows

From

The <from> element defines the root model for the query.

Attribute Description

model

Fully qualified entity class name, or JSON model name with type="json"

as

Alias for the model (auto-generated if omitted)

type

Set to json for JSON (custom) models

<!-- Entity model -->
<from model="com.axelor.sale.db.SaleOrder" as="s"/>

<!-- JSON (custom) model -->
<from model="CustomInvoice" as="inv" type="json"/>

Select

The <select> element defines a column in the result. If no <select> elements are specified, the query returns full entity objects.

Attribute Description

field

A field path (e.g. c.fullName or c.title.name)

value

A literal value

param

A runtime parameter name

fn

A function to apply (see Functions)

as

Column alias in the result

Each <select> must define exactly one source: field, value, param, or a nested <calc> / <case> element.

<select field="c.fullName" as="name"/>
<select field="c.email" as="email"/>
<select field="c.id" fn="COUNT" as="total"/>
<select value="Active" as="status"/>

Implicit Joins

Dotted field paths like c.title.name automatically generate LEFT JOIN clauses. The engine deduplicates joins, so multiple references to the same relation share a single join.

<!-- These two references generate only one join to title -->
<select field="c.title.name" as="titleName"/>
<where field="c.title.code" op="eq" value="mr"/>

Join

The <join> element supports two modes: relation joins and cross joins.

Relation Join

Joins via a relation field path. The target model is inferred from the relation metadata.

Attribute Description

field

The relation field path (e.g. c.title)

as

Alias for the joined model

type

inner or left (default is inner)

<from model="com.axelor.contact.db.Contact" as="c"/>
<join field="c.title" as="t" type="left"/>
<select field="c.fullName" as="name"/>
<select field="t.name" as="titleName"/>
Collection joins (one-to-many, many-to-many) are not supported. Only single-value relations (many-to-one, one-to-one) can be joined.

Cross Join

Joins an arbitrary model with explicit <on> conditions. Useful for analytics queries that need to join tables without a direct relation.

Attribute Description

model

The target model name (e.g. com.axelor.sale.db.OrderLine)

as

Alias for the joined model

type

inner or left (default is inner)

The <on> child elements use the same syntax as <where> conditions. Use ref to reference fields from other aliases.

<from model="com.axelor.sale.db.SaleOrder" as="o"/>
<join model="com.axelor.sale.db.SaleOrderLine" as="line" type="left">
  <on field="line.order.id" op="eq" ref="o.id"/>
</join>
<select field="o.name" as="orderName"/>
<select field="line.productName" as="product"/>
<select fn="SUM" field="line.price" as="total"/>
<groupBy field="o.name"/>
<groupBy field="line.productName"/>
Cross joins require at least one <on> condition.

Where

The <where> element defines filter conditions. Multiple <where> elements at the same level are combined with AND.

Comparison Operators

Operator Description

eq

Equal (=)

ne

Not equal (<>)

gt

Greater than (>)

lt

Less than (<)

gte

Greater than or equal (>=)

lte

Less than or equal (<=)

like

Pattern match (LIKE)

notLike

Negated pattern match (NOT LIKE)

<where field="c.email" op="eq" value="jsmith@example.com"/>
<where field="c.firstName" op="like" value="J%"/>
<where field="c.age" op="gte" param="minAge"/>
<where field="c.title.code" op="eq" ref="t.code"/> (1)
1 The ref attribute references another field instead of a literal value.

Special Operators

Operator Description

in / notIn

Value list or parameter

between

Range with low/high or param

isNull / isNotNull

Null checks

<!-- IN with literal values -->
<where field="c.email" op="in">
  <value>a@example.com</value>
  <value>b@example.com</value>
</where>

<!-- IN with runtime parameter (expects a List) -->
<where field="c.status" op="in" param="statuses"/>

<!-- BETWEEN with literals -->
<where field="c.age" op="between" low="18" high="65"/>

<!-- BETWEEN with parameter (creates amountLow and amountHigh params) -->
<where field="c.amount" op="between" param="amount"/>

<!-- Null check -->
<where field="c.email" op="isNotNull"/>

Logical Operators

Use and, or, and not operators with nested <where> children to build complex conditions.

<where op="or">
  <where field="c.firstName" op="eq" value="John"/>
  <where op="and">
    <where field="c.lastName" op="eq" value="Smith"/>
    <where field="c.email" op="isNotNull"/>
  </where>
</where>

Exists Subqueries

The exists operator supports correlated subqueries using a nested <query>. The subquery can reference outer aliases via the ref attribute.

<query name="titles-with-contacts">
  <from model="com.axelor.contact.db.Title" as="t"/>
  <select field="t.name" as="titleName"/>
  <where op="exists">
    <query>
      <from model="com.axelor.contact.db.Contact" as="c"/>
      <select field="c.id" as="id"/>
      <where field="c.title.id" op="eq" ref="t.id"/>
    </query>
  </where>
</query>

Functions

Functions can be applied to fields in <select>, <where>, <groupBy>, <having>, and <orderBy> using the fn attribute.

Aggregate Functions

Function Description

COUNT

Count of values

SUM

Sum of numeric values

AVG

Average of numeric values

MIN

Minimum value

MAX

Maximum value

Scalar Functions

Function Description

YEAR

Extract year from date

MONTH

Extract month from date

DAY

Extract day from date

HOUR

Extract hour from time

UPPER

Convert to uppercase

LOWER

Convert to lowercase

LENGTH

String length

TRIM

Remove leading/trailing whitespace

<select field="c.id" fn="COUNT" as="total"/>
<select field="c.amount" fn="SUM" as="totalAmount"/>
<groupBy field="c.status"/>
<having field="c.id" fn="COUNT" op="gt" value="5"/>

GroupBy and Having

Use <groupBy> for grouping and <having> for conditions on aggregated results. The <having> element uses the same syntax as <where>.

<query name="contacts-by-title">
  <from model="com.axelor.contact.db.Contact" as="c"/>
  <join field="c.title" as="t"/>
  <select field="t.name" as="title"/>
  <select field="c.id" fn="COUNT" as="count"/>
  <groupBy field="t.name"/>
  <having field="c.id" fn="COUNT" op="gte" value="2"/>
  <orderBy field="t.name" dir="asc"/>
</query>

OrderBy

Attribute Description

field

Field path to sort by

fn

Optional function to apply before sorting

dir

asc (default) or desc

Calc Expressions

The <calc> element defines arithmetic or string operations. It can be used inside <select>, <then>, <else>, or nested within another <calc>.

Operator Description

add

Addition (+)

subtract

Subtraction (-)

multiply

Multiplication (*)

divide

Division (/)

concat

String concatenation

coalesce

First non-null value

Each <arg> defines an operand using field, value, or param.

<select as="total">
  <calc op="multiply">
    <arg field="line.qty"/>
    <arg field="line.price"/>
  </calc>
</select>

<select as="fullAddress">
  <calc op="concat">
    <arg field="c.address.street"/>
    <arg value=", "/>
    <arg field="c.address.city"/>
  </calc>
</select>

<select as="displayName">
  <calc op="coalesce">
    <arg field="c.fullName"/>
    <arg field="c.email"/>
    <arg value="Unknown"/>
  </calc>
</select>

Case Expressions

The <case> element defines conditional logic using <when> / <then> / <else>.

<select as="category">
  <case>
    <when>
      <where field="c.amount" op="gte" value="1000"/>
      <then value="Premium"/>
    </when>
    <when>
      <where field="c.amount" op="gte" value="100"/>
      <then value="Standard"/>
    </when>
    <else value="Basic"/>
  </case>
</select>

The <then> and <else> elements accept field, value, param, or nested <calc> / <case>.

Conditional Aggregation (Pivot Queries)

Use fn on <select> to wrap a <case> expression with an aggregate function, producing the SUM(CASE WHEN …​ THEN x ELSE 0 END) pattern for pivot-style queries:

<query name="sales-pivot">
  <from model="com.axelor.sale.db.OrderLine" as="line"/>
  <join field="line.order" as="o"/>
  <select field="o.customer.fullName" as="customer"/>

  <!-- SUM(CASE WHEN status='DRAFT' THEN qty ELSE 0 END) -->
  <select as="draftQty" fn="SUM">
    <case>
      <when>
        <where field="o.status" op="eq" value="DRAFT"/>
        <then field="line.quantity"/>
      </when>
      <else value="0"/>
    </case>
  </select>

  <select as="openQty" fn="SUM">
    <case>
      <when>
        <where field="o.status" op="eq" value="OPEN"/>
        <then field="line.quantity"/>
      </when>
      <else value="0"/>
    </case>
  </select>

  <!-- Grand total -->
  <select field="line.quantity" fn="SUM" as="grandQty"/>

  <groupBy field="o.customer.fullName"/>
  <orderBy field="o.customer.fullName" dir="asc"/>
</query>

Parameters

Queries support runtime parameters using the param attribute. Parameter types are automatically inferred from the field they are compared against.

<where field="c.email" op="eq" param="email"/>
<where field="c.status" op="in" param="statuses"/> (1)
<where field="c.amount" op="between" param="amount"/> (2)
1 For in / notIn, the parameter is expected to be a List.
2 For between, two parameters are created: amountLow and amountHigh.

Parameter Metadata

The <meta> section declares parameter metadata for validation, defaults, and fixed values.

Attribute Description

name

Parameter name (must match a param used in the query)

title

Display title

type

Explicit type: string, integer, long, decimal, boolean, date, time, datetime, many-to-one, many-to-many

required

When true, execution fails if the parameter is not provided

defaultValue

Used when no value is provided. Supports eval: prefix for dynamic expressions (see below)

fixed

When true, the parameter always uses fixedValue regardless of input

fixedValue

The fixed value to use

selection

Selection name for UI display

target

Fully qualified entity class name (for many-to-one / many-to-many types)

jsonTarget

JSON (custom) model name (alternative to target for JSON models)

targetName

Display field on the target model (default: "name")

domain

Domain filter expression for the target model

<query name="contacts-by-status">
  <from model="com.axelor.contact.db.Contact" as="c"/>
  <select field="c.fullName" as="name"/>
  <where field="c.firstName" op="eq" param="firstName"/>
  <where field="c.email" op="eq" param="email"/>
  <meta>
    <params>
      <param name="firstName" fixed="true" fixedValue="John"/>
      <param name="email" required="true" defaultValue="jsmith@example.com"/>
    </params>
  </meta>
</query>

Relational Parameters

Use many-to-one or many-to-many types to render an auto-complete widget for selecting records from a related model. The parameter value is bound as the record’s id (or a list of ids for many-to-many).

<query name="customer-orders">
  <from model="com.axelor.sale.db.OrderLine" as="line"/>
  <join field="line.order" as="o"/>
  <select field="o.name" as="orderNumber"/>
  <select field="line.product.name" as="product"/>
  <where field="o.customer.id" op="eq" param="customerId"/>
  <meta>
    <params>
      <param name="customerId" type="many-to-one" required="true"
        target="com.axelor.contact.db.Contact" targetName="fullName"
        title="Customer"/>
    </params>
  </meta>
</query>

For JSON (custom) models, use jsonTarget instead of target:

<param name="invoiceId" type="many-to-one"
  jsonTarget="CustomInvoice" targetName="invoiceNumber"
  title="Invoice"/>

Dynamic Default Values

Parameter default values support eval: expressions that are evaluated at execution time. This uses the same expression engine as actions, with access to built-in variables like __date__, __time__, __datetime__, and __user__.

<meta>
  <params>
    <!-- Last 3 months from today -->
    <param name="fromDate" type="date" defaultValue="eval:__date__.minusMonths(3)"/>

    <!-- Current user -->
    <param name="userId" type="long" defaultValue="eval:__user__.id"/>
  </params>
</meta>

Column Metadata

The <columns> section inside <meta> describes the output columns of the query. This metadata is used by dashboards and UI consumers to render proper column headers, types, and formatting.

Attribute Description

name

Column name (must match a <select> alias)

title

Display title

type

Column type: string, integer, long, decimal, boolean, date, time, datetime

format

How values are presented: text, number, currency, percent, compact, date, datetime, time, boolean

currency

ISO currency code. Required when format="currency", unless currencyField is given

currencyField

For amounts in several currencies: another column of the query holding each row’s currency code. A value is shown in its row’s currency, and rows in different currencies are never added together. Not with currency

scale

What a percentage’s stored number means: ratio (0.6 shows as 60%) or percent (60 shows as 60%). Required when format="percent"

selection

Selection whose titles the values are shown with. The result’s column carries its options, titles translated for the user

Declaring the presentation here rather than on each widget means a measure reads the same wherever it appears — in a metric, a table cell and a chart tooltip — and changing it changes all of them at once.

<query name="revenue-summary">
  <from model="com.axelor.sale.db.Order" as="o"/>
  <select field="o.customer.fullName" as="customer"/>
  <select field="o.totalAmount" fn="SUM" as="revenue"/>
  <groupBy field="o.customer.fullName"/>
  <meta>
    <columns>
      <column name="customer" title="Customer" type="string"/>
      <column name="revenue" title="Revenue" type="decimal"
        format="currency" currency="EUR"/>
    </columns>
  </meta>
</query>

These declarations are checked when the query is saved, so a format the schema does not recognise or a currency code that does not exist is reported to whoever is editing the query rather than becoming an unexplainable value in a dashboard later.

A percentage measure must say which scale it is stored in, because 0.6 and 60 both display as 60% and only the query knows which one was written:

<column name="margin" title="Margin" type="decimal" format="percent" scale="ratio"/>

Aggregation

A column produced by an aggregate function carries that function, and the query as a whole reports whether it returns individual rows or grouped measures. Both are derived from the query itself, so a consumer never has to infer them from the values:

  • columns[].aggregate is sum, avg, min, max or count for a measure, and absent for anything else. The scalar functions — YEAR, UPPER and the rest — transform a value without combining rows, so they do not make a column a measure.

  • aggregationLevel is aggregate when the query groups or when any selection aggregates, and detail otherwise. A query that aggregates without grouping is still aggregate: no row in its result stands for a single underlying row.

This is what lets a pivot refuse to average averages instead of guessing.

Query-Scope Totals

A total added up from the rows on screen is a total of those rows. When the result is a page, or was cut down by the row cap, presenting that number as the query’s total is simply wrong, and nothing the client does can recover the real one.

<summary> declares an aggregate evaluated over the whole filtered set, without the result window and without the grouping:

Attribute Description

field

The field to aggregate, as a path in the query’s own scope

fn

COUNT, SUM, AVG, MIN or MAX

as

The name the total is reported under. Set it to the result column the total belongs to, so a consumer can pair them

<query name="revenue-summary">
  <from model="com.axelor.sale.db.Order" as="o"/>
  <select field="o.customer.fullName" as="customer"/>
  <select field="o.totalAmount" fn="SUM" as="revenue"/>
  <groupBy field="o.customer.fullName"/>
  <meta>
    <columns>
      <column name="customer" title="Customer" type="string"/>
      <column name="revenue" title="Revenue" type="decimal"
        format="currency" currency="EUR"/>
    </columns>
    <summary field="o.totalAmount" fn="SUM" as="revenue"/>
  </meta>
</query>

Totals appear in the envelope’s summary, each carrying the method that produced it, since a bare number cannot be captioned — a sum may be labelled a total, an average may not be added to anything:

"summary": [{"field": "revenue", "fn": "sum", "value": 284500.00}]

The same filters apply, so a summary is a query total and not a table total. It honours row-level security for the same reason: the WHERE clause is shared with the data query, so a summary can never cover rows the user may not see.

<summary> cannot be combined with <having>. A summary covers the rows <where> selects, while <having> discards whole groups after aggregation, so the two would describe different sets. Rather than return a number that quietly disagrees with the table beside it, the query is refused when it is saved.

Source Capabilities

describe() reports what the source can actually do, so a client disables what it cannot rather than approximating it or inventing an endpoint:

"capabilities": {"serverSort": false, "querySummary": true, "fullExport": false}
Capability Meaning

serverSort

Whether the source can sort server-side. Currently always false: execution takes a window, not an ordering, so any sorting is local to the rows returned

querySummary

Whether the query declares <summary>, and so whether a true query total is available

fullExport

Whether data beyond the returned window can be read. Currently always false

A client that knows sorting is local will not offer a control that would silently reorder a single page, and one that knows there is no query summary will label its total as covering the rows on screen rather than the query.

JSON Models

Queries support JSON (custom) models by setting type="json" on the <from> element. Field access is automatically translated to the appropriate JSON extraction functions.

<query name="custom-invoices">
  <from model="CustomInvoice" as="inv" type="json"/>
  <select field="status" as="status"/>
  <select field="amount" fn="SUM" as="totalAmount"/>
  <groupBy field="status"/>
  <orderBy field="status" dir="asc"/>
</query>

JSON models support joins to other JSON models and to entity models:

<!-- JSON to entity join -->
<from model="CustomInvoice" as="inv" type="json"/>
<join field="customer" as="c"/> (1)
<select field="c.fullName" as="customerName"/>

<!-- JSON to JSON join via dotted path -->
<from model="CustomOrder" as="o" type="json"/>
<select field="o.lineItem.name" as="itemName"/> (2)
1 Explicit join from a JSON relation field to an entity model.
2 Implicit join through a JSON relation path.

Pagination

Use <limit> and <offset> elements for pagination. These can be overridden at execution time via the API or RPC call.

<limit>20</limit>
<offset>0</offset>

Security

The query engine enforces security at two levels:

Authoring time — when a query is saved as a MetaQuery, the current user’s permissions are checked against all referenced models and fields. Only users with appropriate read access can create or modify queries.

Execution time — the row-level security filter for the root model is applied automatically. This scopes the result set to what the executing user is allowed to see. Joined and subquery models are considered trusted since they were validated at authoring time.

Role-Based Access

MetaQuery records can be associated with roles. When roles are assigned, only users holding at least one of those roles can execute the query.

Execution

From XML Files

Place query XML files in your module’s src/main/resources/queries/ directory. They are automatically loaded and stored as MetaQuery records during application startup.

From Java

Use the QueryEngine service to execute queries programmatically:

@Inject private QueryEngine queryEngine;

public void runQuery() {
  var query = XMLQueries.fromXML("""
      <query name="my-query">
        <from model="com.axelor.sale.db.SaleOrder" as="s"/>
        <select field="s.name" as="name"/>
        <where field="s.status" op="eq" param="status"/>
      </query>
      """).getQueries().getFirst();

  var result = queryEngine.execute(query, Map.of("status", "confirmed"));

  if (result instanceof QueryResult.Rows rows) {
    for (var row : rows.records()) {
      System.out.println(row.get("name"));
    }
  }
}

For the envelope a dashboard receives (records with their columns, completeness, totals and source, as the Execute endpoint below returns them), use MetaQueryService.resolve: by name for a saved query, or with a Query for one that is not stored. maxRows tightens the row cap for that one call, and a result cut to it reports truncated, as one cut by data.query.max-results does:

var envelope = metaQueryService.resolve("sale.by-status", Map.of(), null, null, 100);

Via REST API

Named queries can be accessed through REST endpoints under /ws/meta/query/.

Describe (GET)

Returns query metadata including column definitions and parameter declarations. Use this to build dynamic UIs before executing the query.

GET /ws/meta/query/{name}

Response:

{
  "data": [{
    "name": "sale.dashboard.top-customers",
    "model": "com.axelor.sale.db.Order",
    "limit": 10,
    "aggregationLevel": "aggregate",
    "capabilities": {"serverSort": false, "querySummary": true, "fullExport": false},
    "columns": [
      {"name": "customer", "title": "Customer", "type": "string"},
      {"name": "revenue", "title": "Revenue", "type": "decimal",
       "aggregate": "sum", "format": "currency", "currency": "EUR"}
    ],
    "params": [
      {"name": "status", "title": "Status", "type": "string", "required": true}
    ]
  }]
}

Execute (POST)

Executes the query with optional parameters and pagination.

POST /ws/meta/query/{name}

Request body:

{
  "data": {
    "_params": {
      "status": "OPEN"
    }
  },
  "limit": 20,
  "offset": 0
}

Response:

{
  "data": [{
    "records": [
      {"customer": "John Smith", "revenue": 15000.00},
      {"customer": "Jane Doe", "revenue": 12500.00}
    ],
    "columns": [
      {"name": "customer", "title": "Customer", "type": "string"},
      {"name": "revenue", "title": "Revenue", "type": "decimal",
       "aggregate": "sum", "format": "currency", "currency": "EUR"}
    ],
    "retrievedAt": "2026-09-15T10:00:00Z",
    "parameters": [{"name": "status", "title": "Status", "value": "OPEN"}],
    "completeness": "page",
    "total": 47,
    "offset": 0,
    "limit": 20,
    "aggregationLevel": "aggregate",
    "summary": [{"field": "revenue", "fn": "sum", "value": 284500.00}],
    "source": {"name": "sale.dashboard.top-customers", "revision": "9f2c41ab77e05d13"}
  }]
}

The result is wrapped in an envelope rather than returned as a bare array, because the records alone cannot say what they are.

Property Meaning

records

The returned rows

columns

Column metadata, including any aggregation, formatting and selection options the query declares

retrievedAt

When the result was retrieved

parameters

The parameter values actually applied, after defaults and eval: expressions resolved

completeness

complete, page, truncated or unknown — see below

total

Counted rows. Absent when no count ran; it is never the number of rows returned

offset, limit

The window actually applied, after any cap

aggregationLevel

detail or aggregate

summary

Query-scope totals, when the query declares any. Absent when it declares none, which is the signal that no total wider than the returned rows exists

source

The query’s name and a revision identifying its definition by content

completeness is how a result says how much of the answer it is:

  • complete — the window starts at the first row and returned every counted row.

  • page — a narrower window was requested, and total says how many rows match. A later page is a page even when it reaches the last row.

  • truncated — the engine’s own row cap applied. The query asked for no limit, or asked for more than data.query.max-results allows, and the result was cut down to fit. The caller never chose this window.

  • unknown — no count ran, so the relationship between the window and the whole is not known.

A consumer must not present a total computed from records as a total for the whole query unless completeness is complete. Where it is not, either state the scope or withhold the number.

source.revision is a hash of the query definition, not the MetaQuery record’s version. Saving the record without changing the query leaves the revision unchanged, so a stored result can tell whether the definition it came from has actually moved.

Via RPC

MetaQuery records can also be executed via the RPC action mechanism using MetaQueryController.

Complete Example

<query name="sales-summary" distinct="true">
  <from model="com.axelor.sale.db.SaleOrderLine" as="line"/>
  <join field="line.saleOrder" as="s" type="left"/>
  <join field="s.customer" as="c" type="left"/>

  <select field="c.fullName" as="customer"/>
  <select field="s.orderDate" fn="YEAR" as="year"/>
  <select as="revenue">
    <calc op="multiply">
      <arg field="line.qty"/>
      <arg field="line.price"/>
    </calc>
  </select>
  <select as="tier">
    <case>
      <when>
        <where field="line.qty" op="gte" value="100"/>
        <then value="Bulk"/>
      </when>
      <else value="Standard"/>
    </case>
  </select>

  <where field="s.status" op="eq" param="status"/>
  <where field="s.orderDate" op="between" param="dateRange"/>

  <groupBy field="c.fullName"/>
  <groupBy field="s.orderDate" fn="YEAR"/>
  <orderBy field="c.fullName" dir="asc"/>
  <limit>50</limit>

  <meta>
    <params>
      <param name="status" required="true"/>
      <param name="dateRangeLow" type="date" required="true"/>
      <param name="dateRangeHigh" type="date" required="true"/>
    </params>
  </meta>
</query>