DataTables Server-Side Processing (juneau-console-datatables)
juneau-console-datatables is an opt-in add-on (not bundled in juneau-distrib, the same precedent as
juneau-rest-server-management-logging) that wires a DataTables grid's server-side
processing contract onto BeanQuery — the request/response envelope, the
query-building rules, and a first-party browser glue script. DataTables itself, jQuery, and their CSS remain
caller-provided: they are not vendored (their license is not ASF category-A).
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-console-datatables</artifactId>
<version>${juneau.version}</version>
</dependency>
The endpoint
A DataTables-backed endpoint takes @Content DataTablesRequest and returns DataTablesResults<T> (or
DataTablesResults<List<Object>> for array rows — see "Array rows" below). DataTablesMixin serves the browser
glue script; the resource's own data endpoint is plain @RestOp:
@Rest(path="/releases", mixins=DataTablesMixin.class)
public class ReleaseResource extends BasicRestServlet {
private final InMemoryBeanQueryContext<Release> ctx = InMemoryBeanQueryContext.create(Release.class).build();
@RestOp(method=POST, path="/query")
public DataTablesResults<Release> query(@Content DataTablesRequest req) {
try (var s = ctx.getSession(releases())) {
return DataTablesQuery.run(req, s);
}
}
}
Declare parsers=JsonParser.class on the data endpoint, e.g. @RestPost(path="/query", parsers=JsonParser.class).
Under BasicUniversalConfig a form-urlencoded body would otherwise also bind to DataTablesRequest, but
JuneauDataTables.ajax only ever sends JSON.
DataTablesQuery.run(req, session) builds a BeanQuery from the request (via the public
DataTablesQuery.toBeanQuery(req, ctx), usable standalone if you need to inspect or adjust the query before running
it — for example to add a tenant guard with query.copy().eq("tenant", id).build()), executes it against the
session, and maps the Page result onto the DataTables envelope (recordsTotal/recordsFiltered/data). The
caller owns the session, so the same adapter works over the in-memory engine or a SQL-backed one.
The browser side
<table id="releases"></table>
<!-- Caller-provided: jQuery and DataTables 2.x, loaded before the glue. -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.datatables.net/2.1.8/js/dataTables.min.js"></script>
<!-- Served by DataTablesMixin relative to the resource (here /releases), not the server root. -->
<script src="/releases/juneau-datatables.js"></script>
<script>
new DataTable('#releases', {
serverSide: true,
ajax: JuneauDataTables.ajax('/releases/query'),
columns: [{data: 'name', title: 'Name'}, {data: 'version', title: 'Version'}]
});
</script>
JuneauDataTables.ajax(url, extra?) builds a DataTables ajax option object that POSTs the DataTables draw state
as a JSON body (not DataTables' default form-encoded request) — matching @Content DataTablesRequest. extra is
shallow-merged over the defaults (except contentType, which is always application/json). An extra.data object
is merged into the outgoing body, and an extra.data function receives the draw state and may mutate it or return a
replacement object — useful for a CSRF token or an active-filter toggle, without rebuilding the whole ajax option
yourself.
Any top-level body property that is not part of the DataTables request — one merged in through extra.data, or one a
DataTables extension adds (SearchBuilder's searchBuilder, SearchPanes' searchPanes) — does not fail the parse: it
is collected into DataTablesRequest.getExtra(), where the handler can read it. The adapter itself ignores it, so
those extensions have no server-side effect unless your handler applies them. The same goes for DataTables 2's
search.fixed() terms: they are parsed (Search.getFixed()) but not applied.
For a table that needs no extra wiring at all, skip the inline <script> block with the
data-juneau-datatable-ajax attribute (DataTablesTable.AJAX_ATTR). The data-juneau-datatable attribute carries
the DataTables options as JSON; it must still declare the columns, since an empty <table> gives DataTables nothing
to infer them from:
<table data-juneau-datatable='{"columns":[{"data":"name","title":"Name"},{"data":"version","title":"Version"}]}'
data-juneau-datatable-ajax="/releases/query"></table>
<!-- jQuery and DataTables first, as above. -->
<script src="/releases/juneau-datatables.js"></script>
JuneauDataTables.initAll() (already wired into the page's auto-init pass) sees data-juneau-datatable-ajax on a
table whose options carry no ajax key, defaults serverSide: true, and installs
ajax: JuneauDataTables.ajax(url) for you. A table that relies on a <thead> instead of declared columns (for
example a server-rendered DataTablesTable) makes DataTables send integer data values, so pair it with
runArrays (see "Array rows"), and keep the header order identical to the context's columns() order.
columns[i].data resolution
Each DataTablesRequest.Column.data resolves against the context's declared columns (BeanQueryContext.columns(),
in declaration order):
data value | Resolves to |
|---|---|
| A declared column name | That column, as-is. |
All digits (e.g. "2") | The n-th declared column, 0-based. |
null or "" | Always skipped — no error, regardless of searchable/orderable. |
A dotted path or an unknown name, on a column with a non-blank per-column search or an order[] entry | BeanQuerySyntaxException: Unknown column 'name'. |
| A dotted path or an unknown name, on a column that is only a candidate for the global search, or never used | No error — resolution is lazy; the column is simply left out. |
A column with searchable: false is ignored for both per-column and global search, and a column with
orderable: false is ignored for sorting. Two DataTables columns resolving to the same key are merged (search terms
are ANDed; a duplicate sort key keeps its first occurrence).
Search
A per-column search box (columns[i].search.value) is a raw BeanQuery search expression for that one column
($eq(...), $in(...), $contains(...), …), parsed exactly like a server-side BeanQueryRequest.search term for
that column. The global search box (search.value) has no column context, so it expands to an $or of one leaf per
searchable, resolvable column, chosen by that column's SearchType:
SearchType | Global-search leaf |
|---|---|
TEXT, ID | $contains |
VERSION | $prefix |
ENUM | $eqic (case-insensitive exact match) |
NUMERIC | $eq, only when the term parses as a decimal number |
BOOLEAN | $eq, only when the term is true or false (case-insensitive) |
TIMESTAMP | $eq, only when the term is an ISO-8601 instant (e.g. 2026-09-30T00:00:00Z) |
A NUMERIC, BOOLEAN or TIMESTAMP column whose type the term does not parse as is left out of the $or — it is
never an error. Java-only numeric spellings such as 1d, NaN, Infinity and hex floats are not treated as
numeric. If only one column is eligible the $or collapses to a single leaf, and if none is eligible the global
search adds nothing. Every column type participates, not just text.
Column and sort-key counts are capped by the context's getMaxSearchClauses()/getMaxSortKeys() (checked against
columns.size() and order.size() before any column is resolved). Exceeding either cap is a
BeanQuerySyntaxException — raise the cap on the BeanQueryContext if a table legitimately needs more columns or
sort keys than the default allows.
Paging maps start/length onto the query's page window: length=-1 means all rows, and a negative start is
clamped to 0.
Array rows
DataTablesQuery.runArrays(req, session) is the run sibling for a table whose columns[i].data are integers.
The query's view is set to every declared column in BeanQueryContext.columns() order and each row is flattened to a
List<Object> in that same order, so data: n and the n-th array cell refer to the same column.
Errors and counts
A malformed request (an unknown column referenced by a search or sort, a cap exceeded, a bad search expression, a
disallowed operator or restricted column) throws BeanQuerySyntaxException, which DataTablesQuery.run/runArrays
let propagate. The central RestContext.convertThrowable mapping turns it into HTTP 400 with an
X-BeanQuery-Error: <CODE> header and the exception's message in the body, the same as every other BeanQueryRequest
endpoint. DataTablesResults.error(draw, message) and the response's error field remain available as public API but
the adapter no longer uses them.
On the client, JuneauDataTables.ajax installs an xhr.dt listener. When a request fails with a status of 400 or more
and carries the X-BeanQuery-Error header, the listener reads the server's message from the response body (falling back
to Invalid query (<status>).), fires DataTables' error.dt event and honors DataTable.ext.errMode (default: a
browser alert; 'none', 'throw' or a function also work) instead of showing the generic "Ajax error". Other failures
keep DataTables' default handling. An unexpected failure in your bean accessors or query execution
(BeanQueryExecutionException) is not caught here; it propagates to the central
RestContext.convertThrowable mapping (HTTP 500), the same as every other BeanQueryRequest endpoint.
Counts are always requested as CountRequest.BOTH, but the context's own CountPolicy still wins.
recordsTotal and recordsFiltered are the long forms of the session's Page.total()/Page.matched(); when a
count is absent, recordsFiltered is left unset and recordsTotal falls back to Page.matched() if that is
present. DataTables' own protocol has no concept of an absent count, so there is no CountPolicy opt-out at this
layer.
See also
- BeanQuery — the query bean, builder, and session this adapter sits on top of.
- Admin-Console Chrome —
<@card type="datatables">(see Building a Console Page) renders aDataTablesTable, and (server-side mode) wires the samedata-juneau-datatable-ajaxattribute this page describes. - Worked example: the petstore Pets page (a server-mode table over BeanQuery) and Audit page.