Skip to main content

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);
}
}
}
Restrict the endpoint to JSON

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 valueResolves to
A declared column nameThat 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[] entryBeanQuerySyntaxException: Unknown column 'name'.
A dotted path or an unknown name, on a column that is only a candidate for the global search, or never usedNo 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).

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:

SearchTypeGlobal-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 a DataTablesTable, and (server-side mode) wires the same data-juneau-datatable-ajax attribute this page describes.
  • Worked example: the petstore Pets page (a server-mode table over BeanQuery) and Audit page.