BeanQuery Package
The org.apache.juneau.commons.beanquery package is a protocol-agnostic API for searching, sorting, projecting and paging rows of beans. The same query runs over an in-memory collection or, through the juneau-beanquery-sql module, a database table.
Contexts and sessions
A query engine has two halves:
- A context is built once, is immutable, and is safe to share. Its builder fixes the queryable columns, the operators, the limits and a server-side guard.
- A session is opened per request and closed with try-with-resources. It can narrow the columns, override the limits, and add a per-request guard, but it can never widen what the context allows.
InMemoryBeanQueryContext
Queries an in-JVM collection. create(Class) derives the columns and their search types from the bean's readable Juneau bean properties: getters, public fields and record components, with @BeanIgnore, @BeanProp and the property namer applied; write-only properties are excluded. create(Class, BeanConfigContext) and create(BeanMeta) do the same under a custom bean configuration. create() (no class) is for Map rows and needs explicit column(...) declarations.
// Once, at startup.
static final InMemoryBeanQueryContext<Person> PEOPLE = InMemoryBeanQueryContext
.create(Person.class)
.exclude("passwordHash") // Never searchable, sortable or viewable.
.guard(p -> ! p.isDeleted()) // Server-only filter a request cannot remove.
.defaultLimit(50)
.maxLimit(500)
.build();
// Per request — binding straight from raw request parameters.
try (InMemoryBeanQuerySession<Person> session = PEOPLE.getSession(people)) {
BeanQuery query = new BeanQuery()
.setSearch("age=$gt(21),name=$prefix(Al)")
.setSort("name,age-")
.setLimit(20)
.setOpts("counts=both");
Page<Person> page = session.find(query);
page.rows().forEach(this::render);
page.total().ifPresent(total -> renderFooter(page.matched().orElse(0) + " of " + total));
}
A session builder adds per-request rules:
try (InMemoryBeanQuerySession<Person> session = PEOPLE.createSession()
.rows(people)
.guard(p -> p.getTenant().equals(tenant)) // This request's tenant only.
.restrictColumns("name", "age") // Narrow only.
.build()) {
Counts counts = session.count(query);
}
Use copy() to derive a variant of a built context:
InMemoryBeanQueryContext<Person> admin = PEOPLE.copy().maxLimit(5000).build();
SqlBeanQueryContext
Renders each validated query into parameterized SQL for a SqlDialect (Juneau ships PostgresDialect in juneau-beanquery-postgres). Columns are always declared explicitly; the class only fixes the row type.
static final SqlBeanQueryContext<Person> PEOPLE = SqlBeanQueryContext
.create(Person.class)
.dialect(PostgresDialect.INSTANCE)
.table("person")
.column("name", SearchType.TEXT)
.column("age", SearchType.NUMERIC)
.columnSql("name", "full_name") // Column name -> SQL column.
.guard(SqlFragment.of("\"deleted\" = false"))
.rowMapper((rs, cols) -> new Person(rs.getString("full_name"), rs.getInt("age")))
.dataSource(dataSource)
.queryTimeout(Duration.ofSeconds(5))
.build();
// A session takes a connection from the data source and gives it back on close.
try (SqlBeanQuerySession<Person> session = PEOPLE.getSession()) {
Page<Person> page = session.find(query);
}
// Or run on a caller-owned connection (and transaction), which the session never closes.
try (SqlBeanQuerySession<Person> session = PEOPLE.createSession()
.connection(connection)
.guard(SqlFragment.of("\"tenant\" = ?", tenant))
.build();
Stream<Map<String,Object>> rows = session.streamValues(exportQuery)) {
rows.forEach(csv::write);
}
renderRows(...) and renderCount(...) return the SQL and binds without opening a connection, which is useful in tests. Streams read rows on demand; closing the stream, or the session, closes the cursor. A closed SqlBeanQuerySession throws IllegalStateException on further queries.
Accessors
Both BeanQueryContext and BeanQuerySession expose a few read-only accessors so callers can introspect the active settings instead of hard-coding them:
BeanQueryContext.getColumnType(String)— theSearchTypeof a declared column.BeanQueryContext.getMaxSearchClauses()/getMaxSortKeys()— the context's caps.BeanQuerySession.getContext()— the context that opened the session.BeanQuerySession.getMaxSearchClauses()/getMaxSortKeys()— the session's caps (the context's value, or this session's override).SqlBeanQueryContext.getType()— the row type passed tocreate(Class).
Queries
A
| Field | Example | Meaning |
|---|---|---|
search | age=$gt(21),name=Al* | Comma-separated column=expression clauses, AND-ed. A bare value on a text or id column is a case-insensitive substring or * wildcard match; on other types it is an exact match (prefix for versions). |
sort | name,age- | Sort keys; a trailing - (or :desc) sorts descending. |
view | name,age | Columns to return from findValues / streamValues, in order. |
position / limit | 40 / 20 | The page window. A missing limit means defaultLimit; a negative one means maxLimit; one above maxLimit is clamped to it. |
opts | counts=both | Options. Under CountPolicy.IF_REQUESTED: counts=matched asks for the matched count only; counts=both or counts=true asks for both. |
Operators include $eq, $ne, $in, $gt, $gte, $lt, $lte, $between, $contains, $prefix, $blank, and the combinators $and, $or and $not. $regex is enabled by default; allowRegex(false) disables it. In memory, each call's regex matching shares one regexTimeout deadline (default 50 ms).
For a TIMESTAMP or duration column, $gt/$gte/$lt/$lte/$eq/$ne/$between also accept a [+-]<n>(ms|s|m|h|d) literal (or the equivalent ISO-8601 duration such as -PT24H) relative to the time the request is evaluated. BeanQuery.create().gte("lastSeen", RelativeDuration.ofHours(-24)).build() renders as lastSeen=$gte("-24h"), meaning "the last 24 hours."
Building a query through BeanQuery.create()/GroupBuilder is forgiving about repeats: a second condition on a column already present at the same level no longer overwrites the first, it merges with it into an implicit $and(...), e.g. BeanQuery.create().gt("age", 21).lt("age", 65) renders as age=$and($gt("21"),$lt("65")). A hand-written search string binding straight from request parameters is stricter: a column repeated at the top level is rejected with BeanQuerySyntaxException, telling the caller to combine the conditions with $and(...) explicitly rather than guessing which one should win.
DataTables integration
The juneau-console-datatables module's DataTablesQuery adapter maps a DataTables request bean onto a BeanQuery via BeanQuery.Builder, runs it on a BeanQuerySession, and copies the result onto the DataTables response envelope (total → recordsTotal, matched → recordsFiltered, each read from the independently-optional Page.total()/Page.matched()).
Each searchable column's own, non-blank per-column search box becomes one column=expression clause, AND-ed across columns by the engine. The table's single global search box is different: a non-blank global search term synthesizes a top-level $or of one leaf per searchable column, and the leaf's operator depends on that column's declared SearchType on the session's BeanQueryContext (an undeclared or unknown column is treated as TEXT):
SearchType | Global-search leaf |
|---|---|
TEXT, ID | $contains |
VERSION | $prefix |
ENUM | $eq (standing in for a future $eqic) |
NUMERIC, BOOLEAN, TIMESTAMP | $eq, only when the term parses as that type — otherwise the column is skipped |
An AND-ed per-column term on the same column as the global search is intentional, not a bug: the column's own leaf and the global $or group narrow independently, matching DataTables' own column-search-plus-global-search UX.
REST binding
The juneau-rest-server module binds BeanQuery to HTTP two ways.
BeanQueryRequest. Take a @RestOp argument. It is a BeanQuery subclass annotated @Request, so it binds straight from the request and can be passed to a session without an adapter. Every wire name equals the property name:
| Parameter | Type | Meaning |
|---|---|---|
search | string | column=expression clauses |
view | string | Columns to return, in order (a column projection, not a saved-view id) |
sort | string | Sort columns; :desc or - for descending |
position | integer, minimum=0 | 0-based index of the first row (negative is rejected by part validation, HTTP 400) |
limit | integer | Maximum rows; the context applies defaultLimit and clamps to maxLimit |
opts | string | key=value options, e.g. counts=true |
private static final InMemoryBeanQueryContext<Person> PEOPLE = InMemoryBeanQueryContext.create(Person.class).build();
@RestGet("/people")
public List<Person> getPeople(BeanQueryRequest q) {
try (var s = PEOPLE.getSession(people)) {
return s.find(q).rows();
}
}
Each getter carries a @Schema description, so the operation is documented in Swagger automatically. To use different parameter names, subclass BeanQueryRequest and redeclare the getter with your own @Query name. Saved-view endpoints that use view as an id must not take a BeanQueryRequest. A concrete @Request class needs a public no-arg constructor and a public setter for every annotated getter; a violation throws IllegalArgumentException when the resource's operation table is built (at startup with @Rest(eagerInit=true), otherwise on the first request).
Queryable. For a response that is already a Collection or array of beans or maps, register @Rest(converters=Queryable.class) (or @RestOp(converters=...)). It builds and caches a context per element class, applies any QueryableSettings bean (defaultLimit, maxLimit, allowRegex, regexTimeout, countPolicy and the caps), runs the request, and returns the rows. ?opts=counts=true adds X-BeanQuery-Total and X-BeanQuery-Matched headers, each only when that count was computed. See Converters.
HTTP error mapping
RestContext.convertThrowable maps BeanQuery exceptions thrown from any operation or converter:
| Exception | HTTP | Body | Header |
|---|---|---|---|
BeanQuerySyntaxException | 400 | The message. With problem details on, a "code" member too | X-BeanQuery-Error: <Code name>, e.g. UNKNOWN_COLUMN, BAD_VALUE, REGEX_DISABLED |
BeanQueryExecutionException | 500 | Query execution failed. | none (the cause is logged, never sent) |
GET /people?search=pasword=x&sort=name,age:desc&view=name,age
HTTP/1.1 400 Bad Request
X-BeanQuery-Error: UNKNOWN_COLUMN
{ "status": 400, "title": "Bad Request", "detail": "Unknown column 'pasword' in search.", "code": "UNKNOWN_COLUMN" }
Without problem details, the body is the plain message and the header is the only machine-readable code. The DataTables adapter (DataTablesQuery) follows the same mapping: it lets the exception propagate, so a syntax error is HTTP 400 with the X-BeanQuery-Error header, and JuneauDataTables.ajax shows the server's message to the user.
Validation and limits
Every query is validated into a session.resolve(query) exposes that step on its own. Unknown columns, operators the column does not allow, and anything over a cap (clauses, sort keys, expression depth) are rejected, never silently dropped. A page limit above maxLimit is clamped to maxLimit. maxSearchLength caps the raw string length of all four query fields — search, sort, view and opts — not just the search string.
A $regex pattern is also capped at 256 characters, rejected before it is ever matched. regexTimeout only bounds the in-memory engine's regex matching; it has no effect on SqlBeanQueryContext, which pushes $regex down to the database's own regex operator with no engine-side time limit. If a SQL context allows $regex, set SqlBeanQueryContext.Builder#queryTimeout (default: none) so a pathological pattern cannot run unbounded.
| Setting | Default | Per-session override |
|---|---|---|
defaultLimit / maxLimit | 100 / 1000 (null = uncapped) | yes |
maxSearchClauses / maxSortKeys | 64 / 8 | yes |
maxSearchLength / maxExpressionDepth | 4096 / 16 | yes |
countPolicy | IF_REQUESTED | yes |
regexTimeout | 50 ms | yes |
allowRegex, operators, columnOperators | on, standard set | no |
Session overrides are for trusted server-side callers, such as a CSV export. They should never be driven by request data.
Page carries:
NONE: no counts.MATCHED: onlymatched(), the rows that pass the guards and the search.BOTH:matched()andtotal()(the rows that pass the guards).IF_REQUESTED: whatever the query'soptsask for.
session.count(query) always returns both counts.
SQL dialects
SqlBeanQueryContext renders a validated query through a SqlDialect. Juneau ships one, PostgresDialect, in its own module and package (org.apache.juneau.beanquery.postgres):
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-beanquery-sql</artifactId></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-beanquery-postgres</artifactId></dependency>
import org.apache.juneau.beanquery.postgres.PostgresDialect;
SqlBeanQueryContext<Task> ctx = SqlBeanQueryContext.create(Task.class)
.dialect(PostgresDialect.INSTANCE)
.table("tasks")
.column("name", SearchType.TEXT)
.column("status", SearchType.ENUM)
.dataSource(dataSource)
.build();
Typed values
During query resolution every literal that belongs to a value operator (a bare token, $eq, $ne, $in, $gt, $gte, $lt, $lte, $between) is parsed once with SearchType.parse(String). Both engines then see the same typed value (SearchExpression.typedValue()):
| Column type | Parsed value |
|---|---|
NUMERIC | BigDecimal |
BOOLEAN | Boolean (true/false, case-insensitive) |
TIMESTAMP | OffsetDateTime (explicit offset, or epoch milliseconds as UTC), LocalDateTime (no offset), or LocalDate (date only) |
TEXT, ID, ENUM, VERSION | String, unchanged |
A value that does not parse for its column type (age=$gt(abc) on a NUMERIC column) fails with BeanQuerySyntaxException code BAD_VALUE (HTTP 400), the same in the in-memory and SQL engines. It is never "no match" and never a database error.
A value with an explicit offset (2026-09-30T12:00:00Z) is one absolute instant. A local form (date-time without offset, or a bare date) is UTC in the in-memory engine but is interpreted in the database session time zone in SQL. Use offset forms, or epoch milliseconds, in any search that must behave identically everywhere.
Relative durations
A NUMERIC or TIMESTAMP literal may be a relative duration, [+-]<n>(ms|s|m|h|d) (lowercase units), for example -24h, or the equivalent signed ISO-8601 duration (PT24H, -PT24H, +PT30M, P1D), which has identical semantics (-24h and -PT24H are interchangeable). Only days, hours, minutes and (fractional) seconds are accepted, with a single leading sign; years, months, weeks and per-component signs such as PT-1H are BAD_VALUE, and fractions of a millisecond are floored. ISO durations go through the same code path and typed binds in the in-memory and SQL engines, so a SQL dialect never sees the text. It resolves against one requestTime taken once per query, so every clause sees the same "now". A TIMESTAMP becomes requestTime plus the signed amount, as an OffsetDateTime at UTC. A NUMERIC becomes the signed amount in milliseconds. A bare signed number such as -5 is never a duration. A total that overflows is BAD_VALUE; on BOOLEAN a duration is BAD_VALUE; TEXT, ID, ENUM and VERSION keep it as literal text.
Instant now = Instant.parse("2026-09-30T12:00:00Z");
SearchType.TIMESTAMP.parse("-24h", now); // 2026-09-29T12:00Z (OffsetDateTime)
SearchType.NUMERIC.parse("-24h", now); // BigDecimal("-86400000")
SearchType.NUMERIC.parse("-PT24H", now); // BigDecimal("-86400000"), same as -24h
Row mappers
A SqlRowMapper<T> turns the current ResultSet row into a bean for find and stream. findValues, streamValues and count never need one. Two are built in:
SqlRowMapper.map(): each row becomes aLinkedHashMap<String,Object>keyed by column name inSELECTorder. SQLNULLisnull.SqlRowMapper.bean(Class): each row becomes arecord(canonical constructor) or a class with a public no-arg constructor and public single-argumentsetXmethods (fluent setters are fine). Columns match properties by exact name; unmatched columns are ignored; properties with no column (or aNULLfor a primitive) keep the type default.bean(...)plans the class once and throwsIllegalArgumentExceptionfor an unmappable type (interface, abstract class, enum, no no-arg constructor, overloaded setter). A read failure throwsBeanQueryExecutionExceptionnaming the column, never the SQL.
record Task(String name, int age) {}
SqlRowMapper<Task> beans = SqlRowMapper.bean(Task.class);
SqlRowMapper<Map<String,Object>> maps = SqlRowMapper.map();
When rowMapper(...) is not called, build() chooses from the row type T: a Map type that LinkedHashMap is assignable to gets map(); any other type bean(T) can plan gets bean(T); Object, other Map types (such as TreeMap) and unplannable types get none, and find/stream then throw IllegalStateException with the reason. An explicit rowMapper(...) always wins.
Custom operators in SQL
A custom operator carries a SearchSqlRenderer as a typed extension. The renderer receives the dialect, the quoted column, the column's SearchType and List<Object> args, and returns a SqlFragment (SQL plus ordered binds). Always bind values; never concatenate them into SQL. By default the args are the literal Strings; call typedArgs(true) to receive the parsed values from the table above (built-in value operators always get typed args).
SearchOperator divisibleBy = SearchOperator
.create("$divby", "Matches multiples of N. Example: $divby(3)")
.types(SearchType.NUMERIC)
.minArgs(1).maxArgs(1)
.typedArgs(true) // args.get(0) is a BigDecimal, bound as-is
.extension(SearchSqlRenderer.class,
(dialect, col, type, args) -> SqlFragment.of("(" + col + " % ? = 0)", args.get(0)))
.build();
In-memory and SQL parity
The two engines must agree on which rows match. Where a dialect cannot express a feature with the in-memory meaning, it rejects the query with BeanQuerySyntaxException code BAD_VALUE (HTTP 400) instead of silently returning different rows:
- An operator the dialect does not support, such as
$regexon a dialect with no regular-expression operator, isBAD_VALUE. - A dialect that does not order
VERSIONvalues as dotted-integer tuples (it does not declaresupportsVersionOrdering()) rejects theVERSIONrange operators$gt,$gte,$lt,$lteand$between.
PostgresDialect declares supportsVersionOrdering() and renders $regex, so it supports both, with the differences listed below.
PostgresDialect behaviour
- ENUM. Compared as text; the column is cast (
col::text) wherever a text function applies, so a native Postgresenumcolumn behaves liketext, including bare*/?wildcards (case-insensitive, whole constant name). - VERSION. Stored as dotted text (
10.0.1) and compared as an integer tuple (10.9sorts before10.10), like the in-memory engine. A stored value that is not a dotted sequence of integers never matches. Differences from in-memory: each part is limited to 9 digits, and a trailing empty part (10.) or signed part (+1) is rejected. $regex. Rendered with the POSIX operators~(case-sensitive) and~*(case-insensitive), anchored to the full string;flags=i|m|smap to the Postgres embedded options. This is Postgres (ARE) regex syntax, not Java regex: possessive quantifiers and\p{L}style classes are Java-only, and the server can reject a pattern the in-memory engine accepts, failing the query. Set astatement_timeouton connections that run user-supplied patterns.$notand NULL.$not(x)renders asNOT COALESCE(x, FALSE), so aNULLcell matches$not(...)exactly as in memory.$blankmeansNULLor ASCII-only whitespace.- Binds. Values are always
?parameters, never SQL text.
Errors
| Exception | Cause | Typical HTTP mapping |
|---|---|---|
BeanQuerySyntaxException | The query breaks a rule (unknown column or operator, malformed expression, over a limit). | 400 |
BeanQueryExecutionException | The engine failed (a getter, guard or rowMapper threw, a row was null, SQL failed, a connection could not be opened). The message names no SQL or bind values. | 500 |
IllegalArgumentException / IllegalStateException | Author misconfiguration or API misuse (for example a closed session, or reading a count that was not computed). | n/a — a deployment bug to fix, not a request to reject |
try (var session = PEOPLE.getSession(people)) {
return session.find(query);
} catch (BeanQuerySyntaxException e) {
throw new BadRequest(e, "Invalid query: %s", e.getMessage());
}
Search language
A clause's right-hand side is either an operator call, $name(arg,...), or a bare pattern with no
operator at all, e.g. age=$gt(21) or name=Al*. A literal $ in a bare pattern or an operator argument is
written $$ — $$ is always a literal $, so $$eq(x) is the literal text $eq(x), while $eq(x) is
the operator call.
Case sensitivity
$eq,$inand$neare exact and case-sensitive.$eqicis the case-insensitive exact-match counterpart to$eq— same exact-match semantics, just ignoring case.- Bare patterns and
$contains/$prefix/$regexare case-insensitive, as before. - Operator names are themselves case-insensitive:
$EQ(...)and$eq(...)are the same operator.
Wildcards and quoting
A bare pattern supports two wildcards: * (any run of characters) and ? (exactly one character), e.g.
name=Al* or name=Al?n. Quoting a token with double quotes bypasses wildcard interpretation entirely and
matches the quoted text literally — name="a*b" matches the literal string a*b, not a wildcard pattern.
$regex
A regex argument is quoted the same as any other operator argument whenever it contains a , or ) that would
otherwise be read as argument punctuation, e.g. name=$regex("a,b\)"). $regex also accepts an optional second
argument, flags=X, where X is one or more of i (case-insensitive), m (multiline) or s (dot-all): e.g.
name=$regex(^A.*,flags=im). A second argument that is not prefixed flags= is ignored.
With no flags= argument, $regex matches case-insensitively by default. Passing flags= replaces that
default rather than adding to it — $regex(^A.*,flags=m) is case-sensitive, not case-insensitive-and-
multiline. To keep case-insensitivity alongside another flag, include i explicitly, e.g. flags=im.
Custom operators (SearchOperator)
SearchOperator is immutable. Register a new operator through its builder:
SearchOperator startsWithIgnoreCase = SearchOperator.create("$starts", "Matches a case-insensitive prefix.")
.types(SearchType.TEXT, SearchType.ID)
.predicate((cell, args) -> cell != null
&& String.valueOf(cell).toLowerCase(Locale.ROOT).startsWith(args.get(0).toLowerCase(Locale.ROOT)))
.build();
.build() is required — the intermediate builder stages returned by .types(...) / .predicate(...) are
not themselves usable as a SearchOperator. Use copy() on an existing SearchOperator to get a pre-filled
builder for a variant.
Parse errors (BeanQuerySyntaxException.Code)
A malformed search expression (the right-hand side of one clause) throws BeanQuerySyntaxException with a
specific Code naming the exact grammar problem, rather than a generic parse failure, so a request-facing
adapter can show a specific message:
UNKNOWN_OPERATORMALFORMED_OPERATORBAD_ARG_COUNTUNBALANCEDEMPTY_ARGUMENTEMPTY_EXPRESSIONTRAILING_TEXTUNTERMINATED_QUOTEINVALID_OPERATOR_NAME
Code has further values beyond these nine, covering problems the expression grammar itself can't detect
— an unknown or duplicate column name, a search string or option string over its length limit, too many
sort keys or $and/$or clauses, nesting too deep, a disabled or too-long regex, a regex that exceeds its
matching-time
budget, and $and/$or/$not called with the wrong number of arguments. Those are thrown during query
resolution rather than expression parsing, and are outside the scope of this grammar reference.
Client-side parity (juneau-search.js)
juneau-search.js, the client-side datatable search box, mirrors every rule above byte-for-byte — this is
verified by running a shared JSON test corpus against both the Java engine and the JavaScript engine.
Per-type search help (SearchType.bareHelp())
SearchType.bareHelp() returns a one-sentence, per-type explanation of what a bare (operator-less) value means
for a column of that type. This is the text a search-bar popover shows next to each searchable column:
SearchType | bareHelp() |
|---|---|
TEXT, ID | Matches any value containing this text (case-insensitive). Use $eq(...) for an exact, case-sensitive match. |
ENUM | Matches one value exactly, case-insensitive. |
NUMERIC | Matches a number exactly. Use $gt / $gte / $lt / $lte / $between for a range, or a duration such as -24h or -PT24H (= -86400000 ms). |
TIMESTAMP | Matches an exact instant; a bare date means UTC midnight exactly, so for a whole day use $between(d, d+1). Also accepts a duration such as -24h or -PT24H, relative to the request time. |
VERSION | Matches any version starting with this prefix, e.g. 250 matches 250.1 and 250.2.3. |
BOOLEAN | Matches true or false exactly. |
$-operator syntaxSome existing $-operator search syntaxes treat $eq(...) as case-insensitive; Juneau's $eq(...) is exact and case-sensitive. The direct port of such an
$eq(...) call is Juneau's $eqic(...), not $eq(...).
A bare (operator-less) search value behaves per the grammar above: a case-insensitive substring match on
TEXT/ID, a dotted-tuple prefix on VERSION, and an exact match on NUMERIC/TIMESTAMP/ENUM/BOOLEAN. For TIMESTAMP
specifically, a bare date is an exact UTC-midnight instant, not "that whole day" — use
$between(d, d+1) for a day range.
There is no linter or automated check for a case-insensitive-style $eq(...) call site carried over from another syntax; this callout is the whole
mitigation.