Skip to main content

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) — the SearchType of 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 to create(Class).

Queries​

A BeanQuery carries only raw strings, so it binds straight from request parameters:

FieldExampleMeaning
searchage=$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).
sortname,age-Sort keys; a trailing - (or :desc) sorts descending.
viewname,ageColumns to return from findValues / streamValues, in order.
position / limit40 / 20The page window. A missing limit means defaultLimit; a negative one means maxLimit; one above maxLimit is clamped to it.
optscounts=bothOptions. 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 RelativeDuration value — a signed [+-]<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):

SearchTypeGlobal-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 BeanQueryRequest as 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:

ParameterTypeMeaning
searchstringcolumn=expression clauses
viewstringColumns to return, in order (a column projection, not a saved-view id)
sortstringSort columns; :desc or - for descending
positioninteger, minimum=00-based index of the first row (negative is rejected by part validation, HTTP 400)
limitintegerMaximum rows; the context applies defaultLimit and clamps to maxLimit
optsstringkey=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:

ExceptionHTTPBodyHeader
BeanQuerySyntaxException400The message. With problem details on, a "code" member tooX-BeanQuery-Error: <Code name>, e.g. UNKNOWN_COLUMN, BAD_VALUE, REGEX_DISABLED
BeanQueryExecutionException500Query 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 ResolvedQuery before any engine sees it. 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.

SettingDefaultPer-session override
defaultLimit / maxLimit100 / 1000 (null = uncapped)yes
maxSearchClauses / maxSortKeys64 / 8yes
maxSearchLength / maxExpressionDepth4096 / 16yes
countPolicyIF_REQUESTEDyes
regexTimeout50 msyes
allowRegex, operators, columnOperatorson, standard setno

Session overrides are for trusted server-side callers, such as a CSV export. They should never be driven by request data.

CountPolicy decides which counts a Page carries:

  • NONE: no counts.
  • MATCHED: only matched(), the rows that pass the guards and the search.
  • BOTH: matched() and total() (the rows that pass the guards).
  • IF_REQUESTED: whatever the query's opts ask 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 typeParsed value
NUMERICBigDecimal
BOOLEANBoolean (true/false, case-insensitive)
TIMESTAMPOffsetDateTime (explicit offset, or epoch milliseconds as UTC), LocalDateTime (no offset), or LocalDate (date only)
TEXT, ID, ENUM, VERSIONString, 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.

TIMESTAMP offsets

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 a LinkedHashMap<String,Object> keyed by column name in SELECT order. SQL NULL is null.
  • SqlRowMapper.bean(Class): each row becomes a record (canonical constructor) or a class with a public no-arg constructor and public single-argument setX methods (fluent setters are fine). Columns match properties by exact name; unmatched columns are ignored; properties with no column (or a NULL for a primitive) keep the type default. bean(...) plans the class once and throws IllegalArgumentException for an unmappable type (interface, abstract class, enum, no no-arg constructor, overloaded setter). A read failure throws BeanQueryExecutionException naming 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 $regex on a dialect with no regular-expression operator, is BAD_VALUE.
  • A dialect that does not order VERSION values as dotted-integer tuples (it does not declare supportsVersionOrdering()) rejects the VERSION range operators $gt, $gte, $lt, $lte and $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 Postgres enum column behaves like text, including bare */? wildcards (case-insensitive, whole constant name).
  • VERSION. Stored as dotted text (10.0.1) and compared as an integer tuple (10.9 sorts before 10.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|s map 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 a statement_timeout on connections that run user-supplied patterns.
  • $not and NULL. $not(x) renders as NOT COALESCE(x, FALSE), so a NULL cell matches $not(...) exactly as in memory. $blank means NULL or ASCII-only whitespace.
  • Binds. Values are always ? parameters, never SQL text.

Errors​

ExceptionCauseTypical HTTP mapping
BeanQuerySyntaxExceptionThe query breaks a rule (unknown column or operator, malformed expression, over a limit).400
BeanQueryExecutionExceptionThe 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 / IllegalStateExceptionAuthor 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, $in and $ne are exact and case-sensitive.
  • $eqic is the case-insensitive exact-match counterpart to $eq — same exact-match semantics, just ignoring case.
  • Bare patterns and $contains / $prefix / $regex are 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_OPERATOR
  • MALFORMED_OPERATOR
  • BAD_ARG_COUNT
  • UNBALANCED
  • EMPTY_ARGUMENT
  • EMPTY_EXPRESSION
  • TRAILING_TEXT
  • UNTERMINATED_QUOTE
  • INVALID_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:

SearchTypebareHelp()
TEXT, IDMatches any value containing this text (case-insensitive). Use $eq(...) for an exact, case-sensitive match.
ENUMMatches one value exactly, case-insensitive.
NUMERICMatches a number exactly. Use $gt / $gte / $lt / $lte / $between for a range, or a duration such as -24h or -PT24H (= -86400000 ms).
TIMESTAMPMatches 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.
VERSIONMatches any version starting with this prefix, e.g. 250 matches 250.1 and 250.2.3.
BOOLEANMatches true or false exactly.
Porting searches from another $-operator syntax

Some 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.