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 |
|---|---|
|
Required. The source model and its alias |
|
Explicit relation joins or cross joins |
|
Fields or expressions to return (omit for entity result) |
|
Filter conditions |
|
Group-by expressions |
|
Conditions on grouped results |
|
Sort order |
|
Maximum number of results |
|
Number of results to skip |
|
Column, parameter and query-total metadata declarations |
The <query> element supports these attributes:
| Attribute | Description |
|---|---|
|
Unique name for the query (required when stored as MetaQuery) |
|
When |
From
The <from> element defines the root model for the query.
| Attribute | Description |
|---|---|
|
Fully qualified entity class name, or JSON model name with |
|
Alias for the model (auto-generated if omitted) |
|
Set to |
<!-- 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 |
|---|---|
|
A field path (e.g. |
|
A literal value |
|
A runtime parameter name |
|
A function to apply (see Functions) |
|
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 |
|---|---|
|
The relation field path (e.g. |
|
Alias for the joined model |
|
|
<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 |
|---|---|
|
The target model name (e.g. |
|
Alias for the joined model |
|
|
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 |
|---|---|
|
Equal ( |
|
Not equal ( |
|
Greater than ( |
|
Less than ( |
|
Greater than or equal ( |
|
Less than or equal ( |
|
Pattern match ( |
|
Negated pattern match ( |
<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 |
|---|---|
|
Value list or parameter |
|
Range with |
|
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 of values |
|
Sum of numeric values |
|
Average of numeric values |
|
Minimum value |
|
Maximum value |
Scalar Functions
| Function | Description |
|---|---|
|
Extract year from date |
|
Extract month from date |
|
Extract day from date |
|
Extract hour from time |
|
Convert to uppercase |
|
Convert to lowercase |
|
String length |
|
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 path to sort by |
|
Optional function to apply before sorting |
|
|
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 |
|---|---|
|
Addition ( |
|
Subtraction ( |
|
Multiplication ( |
|
Division ( |
|
String concatenation |
|
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 |
|---|---|
|
Parameter name (must match a |
|
Display title |
|
Explicit type: |
|
When |
|
Used when no value is provided. Supports |
|
When |
|
The fixed value to use |
|
Selection name for UI display |
|
Fully qualified entity class name (for |
|
JSON (custom) model name (alternative to |
|
Display field on the target model (default: |
|
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 |
|---|---|
|
Column name (must match a |
|
Display title |
|
Column type: |
|
How values are presented: |
|
ISO currency code. Required when |
|
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 |
|
What a percentage’s stored number means: |
|
Selection whose titles the values are shown with. The result’s column carries its |
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[].aggregateissum,avg,min,maxorcountfor a measure, and absent for anything else. The scalar functions —YEAR,UPPERand the rest — transform a value without combining rows, so they do not make a column a measure. -
aggregationLevelisaggregatewhen the query groups or when any selection aggregates, anddetailotherwise. A query that aggregates without grouping is stillaggregate: 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 |
|---|---|
|
The field to aggregate, as a path in the query’s own scope |
|
|
|
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 |
|---|---|
|
Whether the source can sort server-side. Currently always |
|
Whether the query declares |
|
Whether data beyond the returned window can be read. Currently always |
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.
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 |
|---|---|
|
The returned rows |
|
Column metadata, including any aggregation, formatting and selection options the query declares |
|
When the result was retrieved |
|
The parameter values actually applied, after defaults and |
|
|
|
Counted rows. Absent when no count ran; it is never the number of rows returned |
|
The window actually applied, after any cap |
|
|
|
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 |
|
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, andtotalsays 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 thandata.query.max-resultsallows, 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.
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>