Query API
The Query API is the v1 replacement for the legacy Generic API
(POST /api/rest/v2/generic/all/{entity}). It reads any entity in Billerang
through one unified interface: filtering, pagination, sorting, nested-entity
loading, field selection, joins, grouping, and aggregation. It is
read-only. Create, update, and delete always go through the
entity-specific v1 resource (for example POST /api/v1/sellers), never
through the Query API.
Both the resource interface and its implementation are live in the codebase
today: org.billerang.api.query.QueryV1Resource and QueryV1ResourceImpl
(billerang-backend/billerang-api/src/main/java/org/billerang/api/query/),
registered in BillerangV1Application.getClasses(). Every example on this
page was run against the live local stack; the captured responses are pasted
verbatim.
Two dialects, one implementation
The Query API exposes the same read semantics through two HTTP dialects,
side by side. Both are handled by the same QueryV1ResourceImpl methods,
which delegate to GenericApiLoadService / GenericRequestMapper /
GenericHelper, the same classes the legacy Generic API uses. That shared
implementation is why list/count/get responses are the same shape for the
same inputs whichever dialect a client picks.
GET dialect (query parameters)
GET /api/v1/query/{entity}?filters=...&fields=...&nested=...&limit=...
Idiomatic for v1: cacheable, bookmarkable, easy to inspect in a browser or a proxy log. Every example below uses this dialect unless stated otherwise.
POST dialect (JSON body, same payload as the legacy Generic API)
POST /api/v1/query/{entity}
Content-Type: application/json
{
"filters": { "countryCode": "FR" },
"nestedEntities": ["tradingCurrency"],
"limit": 25
}
Useful for filter payloads too large or structurally awkward for a query
string (deep $filterN nesting, long inList arrays), and for clients
migrating straight off the legacy Generic API's POST body without reshaping
it. sortOrder values ASC/DESC are normalized to
ASCENDING/DESCENDING on this dialect too, so both dialects accept both
spellings.
Both dialects exist for list, count, and get by id:
| Read | GET | POST |
|---|---|---|
| List | GET /api/v1/query/{entity} | POST /api/v1/query/{entity} |
| Count | GET /api/v1/query/{entity}/count | POST /api/v1/query/{entity}/count |
| Get by id | GET /api/v1/query/{entity}/{id} | POST /api/v1/query/{entity}/{id} |
Source: QueryV1Resource.java, the list/listPost, count/countPost,
get/getPost method pairs.
Endpoints
| Method | Path | Description |
|---|---|---|
GET/POST | /api/v1/query/{entity} | List and filter entities |
GET/POST | /api/v1/query/{entity}/count | Count matching entities |
GET/POST | /api/v1/query/{entity}/{id} | Get one entity by numeric id |
GET | /api/v1/query/entities | List queryable entity names |
GET | /api/v1/query/entities/{entity} | Field-level mapping for one entity |
{entity} is the same camelCase or simple-name token the legacy Generic API
accepts today (for example seller, billingAccount, walletOperation).
Resolution goes through GenericHelper.getEntityClass(entityName), the same
resolver the legacy Generic API uses.
Parameters (GET /api/v1/query/{entity})
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
filters | string (JSON-encoded) | No | none | Filter object, URL-encoded. See "Filter operators" below. |
fields | string (CSV) | No | all fields | Equivalent to the legacy genericFields. Projects only the listed fields; also carries aggregation expressions (see "Aggregation"). |
nested | string (CSV) | No | none | Equivalent to the legacy nestedEntities. Eagerly loads related entities. |
depth | number | No | 0 | Equivalent to nestedDepth. How many levels deep to expand nested entities. |
offset | number | No | 0 | Number of results to skip. |
limit | number | No | 100 | Maximum number of results. Capped at 1000 regardless of the requested value. |
sortBy | string | No | id | Field name to sort by. |
sortOrder | string | No | ASCENDING | ASC/DESC or ASCENDING/DESCENDING (see "sortOrder spelling"). |
joinType | string | No | INNER | INNER, LEFT, or RIGHT. Controls the join Hibernate uses when a filter or fetch touches a related entity. |
groupBy | string (CSV) | No | none | Field(s) to group by, for use with aggregation fields. Accepts dotted paths, e.g. seller.code. |
having | string (CSV) | No | none | Post-aggregation filter clauses. |
excluding | string (CSV) | No | none | Field names to drop from the response, applied after fields/nested resolve. |
forceCount | boolean | No | false | Forces a COUNT query even for entities configured as huge-volume (see "forceCount and huge entities"). |
extractList | boolean | No | server default | Passthrough of the legacy extractList flag (return a bare array instead of the envelope, for entities configured to do so). |
Source: QueryParamMapper.build(...) (billerang-backend/billerang-api/src/main/java/org/billerang/api/query/QueryParamMapper.java),
which builds a GenericPagingAndFiltering from these raw query params, and
GenericRequestMapper.getPaginationConfiguration(...)
(org.meveo.apiv2.generic.core.GenericRequestMapper), which turns that into
the PaginationConfiguration the persistence layer actually executes.
sortOrder spelling
QueryParamMapper.normalizeSortOrder accepts all four spellings and
normalizes them before they reach the persistence layer, which only knows
ASCENDING/DESCENDING:
switch (upper) {
case "ASC": return "ASCENDING";
case "DESC": return "DESCENDING";
case "ASCENDING":
case "DESCENDING": return upper;
default: throw new BadRequestException(...);
}
Any other value returns 400 Bad Request with the message Invalid sortOrder '...'. Expected ASC, DESC, ASCENDING or DESCENDING.
joinType
static JoinType parseJoinType(String joinType) {
try {
return JoinType.valueOf(joinType.trim().toUpperCase());
} catch (IllegalArgumentException e) {
throw new BadRequestException("Invalid joinType '" + joinType + "'. Expected INNER, LEFT or RIGHT.");
}
}
jakarta.persistence.criteria.JoinType has three values: INNER, LEFT,
RIGHT. Anything else is a 400.
Filter operators
filters carries a JSON object, URL-encoded as a single query parameter
value (or, on the POST dialect, the filters key of the JSON body). Most
keys follow the pattern <condition> <fieldName> (condition and field name
separated by a space); the condition is optional and defaults to an
equals-ignoring-case comparison. This table is the complete, verified
operator list, cross-checked against three places in source that must agree:
PersistenceService.getQuery()'s javadoc, the switch in
NativeExpressionFactory.checkOnCondition(...)
(billerang-backend/billerang-admin/ejbs/src/main/java/org/meveo/service/base/expressions/NativeExpressionFactory.java),
and the constants declared on PersistenceService itself. No operators were
added or removed when the Generic API was re-homed as the Query API; this is
the same grammar the legacy API has always had.
| Key pattern | Meaning | Example |
|---|---|---|
| (bare field name) | Equals (default when no condition given). Supports wildcards for strings. | {"code": "SELLER_FR"} |
ne <field> | Not equals | {"ne code": "SELLER_FR"} |
eqOptional <field> / neOptional <field> | Same as eq/ne, but the field value is optional (omitted from the WHERE clause if the filter value is null) | {"eqOptional code": "SELLER_FR"} |
fromRange <field> | field >= value (dates truncated to start of day) | {"fromRange amount": 100} |
fromRangeExclusive <field> | field > value | {"fromRangeExclusive amount": 100} |
fromOptionalRange <field> | Same as fromRange, value optional | {"fromOptionalRange amount": 100} |
toRange <field> | field < value (exclusive) | {"toRange amount": 500} |
toRangeInclusive <field> | field <= value | {"toRangeInclusive amount": 500} |
toOptionalRange <field> / toOptionalRangeInclusive <field> | Same as above, value optional | {"toOptionalRange amount": 500} |
list <field> | Value is in the field's own list-valued column | {"list statuses": "ACTIVE"} |
listInList <field> | Value (a list) should be contained in the field's list value | see javadoc |
inList <field> | Field value is in the given list | {"inList status": ["ACTIVE","SUSPENDED"]} |
not-inList <field> | Field value is not in the given list | {"not-inList status": ["CANCELLED"]} |
inSqlList <field> | Field value is in the result of a sub-select | see addSqlListFilters |
minmaxRange <f1> <f2> | Value is between two field values (f1 <= value < f2) | {"minmaxRange f1 f2": "2026-01-01"} |
minmaxRangeInclusive <f1> <f2> | Same, f2 inclusive | |
minmaxOptionalRange <f1> <f2> / minmaxOptionalRangeInclusive <f1> <f2> | Same, either field optional | |
overlapOptionalRange <f1> <f2> | Value RANGE (a 2-element array) overlaps the [f1,f2] field range, exclusive bounds | {"overlapOptionalRange validFrom validTo": ["2026-01-01","2026-06-01"]} |
overlapOptionalRangeInclusive <f1> <f2> | Same, inclusive bounds | |
likeCriterias <f1> <f2> ... | Any of the listed fields matches (OR), case-insensitive; * in the value triggers a LIKE match | {"likeCriterias param1 param2": "energy"} |
wildcardOr <f1> <f2> ... | Like likeCriterias, but always wraps the value in *...* automatically | {"wildcardOr param1 param2": "energy"} |
wildcardOrIgnoreCase <f1> <f2> ... | Same as wildcardOr, explicit case-insensitive | |
or <f1> <f2> ... | Alias: routes to the same handler as wildcardOrIgnoreCase | source: case "or": case SEARCH_WILDCARD_OR_IGNORE_CAS: in NativeExpressionFactory |
anyMatch <f1> <f2> ... | OR across several different field paths (not the same field on several rows) all compared against the same value, LEFT-joined | {"anyMatch customer.id billingAccount.customerAccount.customer.id": "19"} |
SQL (or SQL2, SQL..., any suffix) | Raw SQL fragment used as-is. The SQL prefix can repeat with a different suffix to add more than one SQL clause. | {"SQL": "a.code like 'SELLER%'"} |
OR (as the whole key, value is a nested filter object) | Wraps the nested object's filters in a single OR clause between them | {"OR": {"code":"X","description":"Y"}} |
AND (word can be suffixed, e.g. AND2) | Lets the same field appear more than once with different conditions; the word is stripped before evaluation | see javadoc |
(value) IS_NULL | Field is null | {"status": "IS_NULL"} |
(value) IS_NOT_NULL | Field is not null | {"status": "IS_NOT_NULL"} |
entity.field | Filter on a related entity's field | {"customerAccount.code": "CA_001"} |
entity (value is a code) | Filter by related entity's code directly | {"customerAccount": "CA_001"} |
$filterN (e.g. $filter1) | Named nested sub-filter; groups its own contents (can nest $operator). Key is case-insensitive ($FILTER1 works too) | see "Nested filters" |
$operator (inside a $filterN block or as a top-level sibling) | AND or OR; combines this filter's sibling keys. Key is case-insensitive ($OPERATOR works too) | see "Nested filters" |
type_class | Value is a fully-qualified classname (or list of them); restricts polymorphic queries to a subclass. Combine with ne to exclude. | {"type_class": "org.meveo.model.billing.Invoice"} |
<field>.cfValues or cfValues | Filters on a custom field value, including on a related entity's custom fields | see "Custom field (cfValues) filtering" |
Fields not present in the table above (fromRangeExclusive, listInList,
inSqlList, AND) are part of the same verified javadoc/switch but have no
live example on this page; treat them as documented-but-not-yet
live-demonstrated.
$operator and $filterN keys are case-insensitiveBoth spellings used across the codebase and javadoc ($operator/$OPERATOR,
$filter1/$FILTER1) are accepted; the backend matches these keys ignoring
case. Historically uppercase $OPERATOR silently fell back to AND
(case-sensitive lookup in PaginationConfiguration); this was fixed and is
covered by regression tests in PersistenceServiceTest and
GenericRequestMapperTest. Live-verified on this stack: lowercase and
uppercase produce the identical OR result (see example 4 below).
Nested filters ($filterN + $operator)
$filterN (any key matching \$filter[0-9]+) groups a nested filter object
so it can carry its own $operator (AND/OR) independent of the outer
filter set. The outer filter set and any $filterN blocks are always joined
by AND to each other; the OR/AND choice only applies to the keys inside
one $filterN block (or, using the top-level $operator shown above, to
the whole top-level filter set).
{
"$filter1": {
"$operator": "OR",
"code": "SELLER_FR",
"description": "US Seller"
}
}
This says: match sellers where code = 'SELLER_FR' OR description = 'US Seller'. See the live capture below; the top-level form and the
$filter1-wrapped form were run and returned the same two rows, in both
lowercase and uppercase key spellings.
Custom field (cfValues) filtering
A filter key of exactly cfValues, or a dotted path ending in .cfValues
(e.g. customerAccount.cfValues), filters on a custom field value instead
of a mapped column. Resolution: FactoryMapper.create(...)
(billerang-backend/billerang-admin/ejbs/src/main/java/org/meveo/api/generics/filter/FactoryMapper.java)
special-cases cfValues as a "simple field" (line: Set<String> simpleField = {"id", "type_class", "cfValues"}), and for a dotted <relation>.cfValues
path it reflects the related field's generic type before building a
CustomFieldMapper.
{"cfValues": {"loyaltyTier": [{"value": "GOLD"}]}}
CustomFieldMapper.toCustomFieldValue(...)
(org/meveo/api/generics/filter/filtermapper/CustomFieldMapper.java) maps
the filter value by the custom field's declared type:
CustomFieldTypeEnum | Mapped as |
|---|---|
DATE | parsed date |
LONG | long |
DOUBLE | double |
BOOLEAN | boolean |
ENTITY / CHILD_ENTITY | EntityReferenceWrapper (by classname + code) |
| everything else (default) | string |
Limitation, verified in source: the mapper's switch has no case for
list, map, or matrix-valued custom fields — a CF declared as LIST, MAP,
Matrix, or Multi-value falls through to the default: stringValue branch,
which is not a correct representation of those types. Filtering by a
list/map/matrix custom field through the Query API (or the legacy Generic
API, since they share this code) is not supported; use the entity-specific
v1 resource's own read path instead for those fields.
Nested-entity cfValues filtering (e.g. filtering sellers by a custom field
declared on their linked customerAccount) is supported by the same
mechanism: <relation>.cfValues resolves the relation's target type first,
then applies the same CustomFieldMapper against that type's custom field
templates.
groupBy / having + aggregation
Putting an aggregation expression in fields switches the whole request onto
a different, native-SQL aggregation code path
(GenericApiLoadService.findPaginatedRecords, the isAggregationQueries(...)
branch). A field is treated as an aggregation expression when it starts with
one of:
private boolean isAggregationField(String field) {
return field.startsWith("SUM(") || field.startsWith("COUNT(") || field.startsWith("AVG(")
|| field.startsWith("MAX(") || field.startsWith("MIN(") || field.startsWith("COALESCE(SUM(");
}
The parenthesized form is required (SUM(amountWithoutTax), not SUM amountWithoutTax — the space-separated form is filter-operator syntax, not
aggregation syntax, and is a different mechanism entirely). Combine one or
more aggregation fields with plain fields (typically the groupBy column
itself) in the same fields list, and put the grouping column(s) in
groupBy:
fields=seller.code,SUM(amountWithoutTax),COUNT(id)
groupBy=seller.code
See the live capture below for the exact response shape: the aggregation
path returns one object per group, keyed by the literal field expressions
you asked for ("seller.code", "SUM(amountWithoutTax)",
"COUNT(id)"), and total is the number of returned groups, not the number
of underlying rows.
having (CSV of post-aggregation conditions) rides the same groupBy
mechanism at the PaginationConfiguration level
(getGroupBy()/getHaving() on GenericPagingAndFiltering) but has no live
example captured on this page.
having syntax (the exact condition-string grammar accepted in the CSV) was
not live-tested. Confirm the expected format with the API Architect or by
reading PaginationConfiguration's consumer of getHaving() before relying
on it in an integration.
excluding
CSV list of field names to drop from the response after fields/nested
have already been resolved. Passed straight through to
JsonGenericMapper/findByClassNameAndId as excludedFields. No live
example captured; behavior inferred from the parameter being threaded
unchanged from QueryParamMapper through to the same excludedFields
argument the legacy Generic API already uses.
forceCount and huge entities
Some entities are annotated @HugeEntity in the domain model (for example
WalletOperation, RatedTransaction, Invoice, CDR, EDR,
AccountOperation — see org.meveo.model.HugeEntity, an empty marker
annotation). That annotation by itself does not skip the count. The
count-skipping behavior is driven by a separate, tenant-configurable
registry: FinanceSettings.getEntitiesWithHugeVolume(), a
Map<String, HugeEntity> keyed by entity simple name (a different, DTO-level
HugeEntity class, not the marker annotation). The actual logic, in
GenericApiPersistenceDelegate.list(...):
boolean isHugeVolume = ... entitiesWithHugeVolume.keySet() ...
.anyMatch(e -> e.equalsIgnoreCase(entityClass.getSimpleName()));
Long count = null;
if (!isHugeVolume || searchConfig.getForceCount()) {
count = this.count(entityClass, searchConfig);
}
In plain terms: if an entity is not configured as huge-volume, it is
always counted (total is always accurate). If it is configured as
huge-volume, the count is skipped by default (paged calls with no total
in a real deployment) unless the caller passes forceCount=true, which
forces the count regardless.
The @HugeEntity-annotated entities are what
GET /api/v1/query/entities?onlyHugeEntities=true reports (see "Metadata
endpoints" below) — that flag reflects the annotation, not the
FinanceSettings registry, so it is a hint about which entities are
candidates for huge-volume configuration, not a live report of which ones
currently skip counting.
On this local stack, FinanceSettings.entitiesWithHugeVolume is empty by
default (verified: GET /api/v1/query/financeSettings returns no populated
map), so walletOperation (an @HugeEntity) behaves like any other entity
here: total is accurate with or without forceCount. Both calls below
returned "total": 4747. In a deployment where an operator has configured
WalletOperation (or RatedTransaction, Invoice, etc.) as huge-volume,
expect the no-forceCount call to omit or zero out total and the
forceCount=true call to pay the cost of a real COUNT query.
extractList
Passthrough boolean. When absent, QueryV1ResourceImpl falls back to
genericOpencellRestful.shouldExtractList(), a server-side default (the same
one the legacy Generic API uses) that governs whether certain entities
serialize as a bare array instead of the {total, limit, offset, data}
envelope. No live example captured for a case where the two differ; every
capture on this page used the default envelope shape.
Response envelope
List (GET/POST /api/v1/query/{entity})
{
"total": 42,
"limit": 100,
"offset": 0,
"filters": { "code": "SELLER_EU_001" },
"data": [ { "id": 1, "code": "SELLER_EU_001", "...": "..." } ]
}
filters echoes the parsed filter object back. data shape honors
fields/nested/depth. This envelope is
ImmutableGenericPaginatedResource, serialized by JsonGenericMapper, the
exact same builder the legacy Generic API constructs — so a Query API
response and the equivalent legacy Generic API response are the same shape
for the same logical query.
Count (GET/POST /api/v1/query/{entity}/count)
{ "total": 42 }
Get by id (GET/POST /api/v1/query/{entity}/{id})
{ "data": { "id": 1, "code": "SELLER_EU_001", "...": "..." } }
404s as jakarta.ws.rs.NotFoundException when the id does not resolve
(QueryV1ResourceImpl.doGet, .orElseThrow(() -> new NotFoundException("entity " + entity + " with id " + id + " not found."))).
Metadata endpoints
List queryable entities
curl -H "Authorization: Bearer $TOKEN" https://acme.billerang.com/api/v1/query/entities
{ "entities": ["AccountOperation", "BillingAccount", "Customer", "Invoice", "Seller"] }
Optional flags onlyBusinessEntities and onlyHugeEntities (both boolean,
default false) narrow the list, resolving each name back to a class via
GenericHelper and checking BusinessEntity.class.isAssignableFrom(...) or
entityClass.isAnnotationPresent(HugeEntity.class) respectively
(QueryV1ResourceImpl.matchesEntityFlags).
withFullName
Optional boolean, default false. By default entities is a plain array
of simple class names. Pass withFullName=true to get an array of
{name, fullName} objects instead, exposing the fully qualified class name
for callers (for example code generators) that need it to build an import
or a reflective lookup:
curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'withFullName=true' \
--data-urlencode 'onlyBusinessEntities=true' \
http://localhost:8080/api/v1/query/entities
{"entities":[
{"name":"AccountEntity","fullName":"org.meveo.model.AccountEntity"},
{"name":"AccountOperation","fullName":"org.meveo.model.payments.AccountOperation"},
{"name":"AccountingArticle","fullName":"org.meveo.model.article.AccountingArticle"},
{"name":"AccountingCode","fullName":"org.meveo.model.billing.AccountingCode"},
{"name":"AccountingScheme","fullName":"org.meveo.model.payments.AccountingScheme"}
]}
Live-verified against the running local stack; response truncated to the
first 5 entries above (onlyBusinessEntities=true narrows the full list
considerably, but it is still long).
Field mapping for one entity
curl -H "Authorization: Bearer $TOKEN" https://acme.billerang.com/api/v1/query/entities/billingAccount
{
"javaName": "BillingAccount",
"tableName": "billing_billing_account",
"fields": [
{ "javaField": "id", "javaType": "Long" },
{ "javaField": "code", "javaType": "String", "columnName": "code", "nullable": false },
{ "javaField": "customerAccount", "javaType": "CustomerAccount", "columnName": "customer_account_id", "isFK": true, "isAssociation": true }
]
}
Per-field keys are emitted only when applicable: javaField, javaType,
columnName, nullable: false, isFK: true, isAssociation: true.
Delegates to SchemaMappingService.getEntityMapping(...), the same service
WorkbenchSchemaRsImpl uses. Unknown entity returns 404 with
{"code": "ENTITY_NOT_FOUND", "message": "Unknown entity '...'"}.
Live-verified examples
All captures below ran against http://localhost:8080 on 2026-07-09, using a
Keycloak client-credentials bearer token. Responses are pasted exactly as
returned (only re-wrapped for line length where the original was one long
line).
1. ne filter (sellers not named SELLER_FR)
curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filters={"ne code":"SELLER_FR"}' \
--data-urlencode 'fields=code,description' \
--data-urlencode 'limit=5' \
http://localhost:8080/api/v1/query/seller
{"total":10,"limit":5,"offset":0,"data":[
{"code":"SELLER_US","description":"US Seller"},
{"code":"MAIN_SELLER","description":"Demo Distributor"},
{"code":"INWI_MA","description":"Inwi Morocco"},
{"code":"NETFLIX_MA","description":"Netflix Maroc"},
{"code":"OPENAI_MA","description":"OpenAI Morocco"}
]}
2. inList filter
curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filters={"inList code":["SELLER_FR","SELLER_US","MAIN_SELLER"]}' \
--data-urlencode 'fields=code,description' \
http://localhost:8080/api/v1/query/seller
{"total":3,"limit":100,"offset":0,"data":[
{"code":"SELLER_FR","description":"France Shops"},
{"code":"SELLER_US","description":"US Seller"},
{"code":"MAIN_SELLER","description":"Demo Distributor"}
]}
3. Get by id, with field selection
curl -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/api/v1/query/seller/-3?fields=id,code,description"
{"data":{"id":-3,"code":"SELLER_FR","description":"France Shops"}}
4. $operator OR — top-level and nested, any key case
Top-level $operator (uppercase $OPERATOR returns the identical result):
curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filters={"$operator":"OR","code":"SELLER_FR","description":"US Seller"}' \
--data-urlencode 'fields=code,description' \
http://localhost:8080/api/v1/query/seller
{"total":2,"limit":100,"offset":0,"data":[
{"code":"SELLER_FR","description":"France Shops"},
{"code":"SELLER_US","description":"US Seller"}
]}
The same OR wrapped in a named $filter1 block, here with the uppercase
$OPERATOR spelling (live-verified: same two rows; $FILTER1 also works):
curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filters={"$filter1":{"$OPERATOR":"OR","code":"SELLER_FR","description":"US Seller"}}' \
--data-urlencode 'fields=code,description' \
http://localhost:8080/api/v1/query/seller
{"total":2,"limit":100,"offset":0,"data":[
{"code":"SELLER_FR","description":"France Shops"},
{"code":"SELLER_US","description":"US Seller"}
]}