V10 Query Language
A V10 service answers queries of its read models at POST /<context>/<aggregate>/<model>/query (see HTTP API). This page is the reference of the query a body carries: its filter, sort, projection and paging, each kind's request and response, the capability descriptor, the Kotlin DSL that builds the same queries in process, and the annotations that tune the snapshot model.
The protocol is plain data in wow-query-api (me.ahoo.wow.api.query); wow-query reads and writes it as JSON. The snippets below are request bodies of the snapshot model.
A request
A body is one JSON object: kind names the operation, the other properties are the query of that kind.
{
"kind": "paged",
"filter": {
"op": "AND",
"operands": [
{ "op": "EQ", "field": "state.categoryId", "value": "books" },
{ "op": "BETWEEN", "field": "state.price", "lowerBound": 10, "upperBound": 50 }
]
},
"sort": [{ "field": "state.price", "direction": "ASC" }],
"pagination": { "index": 1, "size": 20 }
}A property the kind does not have is 400 wow.query.malformed with the violation code UNKNOWN_PROPERTY; a body without kind or with another value is INVALID_VALUE or UNKNOWN_VALUE.
Across tenants
A query reads the caller's tenant. A caller with crossScope may read other tenants on a query route by naming them in Wow-Scope: tenant=* for every tenant, or an inner list, tenant=("t1" "t2"). This is off until wow.query.cross-tenant.enabled is on, and while it is off such a query is 403 wow.auth.forbidden.
- An aggregate type whose tenant is fixed is still read in that tenant alone. A list that leaves it out is 400
wow.query.malformed. - Every cross-tenant query is logged at INFO on
me.ahoo.wow.query.CrossTenantAudit, whether or not a query observer audits. - Commands and state reads never cross tenants: a
tenantof*or a list is 400 there. - In process,
QueryApi.trusted()reads every tenant.
Field paths
A field is named by its logical path, a JSON string of segments joined by .: state.address.city. A segment starts with a letter or _ and holds letters, digits, _ and -; a segment after the first may also be an array index (state.lines.0).
The snapshot model's records hold the system fields and the state under state:
| Field | Type | Holds |
|---|---|---|
tenantId | string | The aggregate's tenant |
aggregateId | string | The aggregate's id, unique within its tenant; the record's identity |
version | integer | The number of its events |
ownerId, spaceId | string or null | Its owner and space |
deleted | boolean | Whether it is deleted |
tags | object of string arrays | Its tags; tags.<key> names one |
firstEventTime, lastEventTime | integer | Its first and last event time, epoch milliseconds |
operator, firstOperator | object or null | Who sent the last command and the first: {id, tenantId, crossScope}; null for an anonymous caller. Filter on operator.id |
state.* | as the state schema says | The state, as the completed Manifest fragment's state schema describes it |
A path the model does not know is 400 wow.query.invalid, UNKNOWN_FIELD. The fields of a collection's elements are named only inside an ELEMENT_MATCH of that collection, relative to an element (ELEMENT_SCOPE_REQUIRED otherwise). The capability descriptor lists every path with the operators, sorts and aggregations it takes.
The events model reads one aggregate's event streams: its filter is an AND of the aggregate (aggregateId EQ, or ID / AGGREGATE_ID), optionally version and commitTime bounds (EQ, GT, GTE, LT, LTE, BETWEEN; a time as epoch milliseconds or an ISO-8601 instant), and nothing else. It sorts by version ascending only, takes no projection and no aggregate. Each record is a stream: aggregateId, tenantId, version, commitTime, commandId, requestId, commandType, ownerId and spaceId when set, operator when known, tags (the aggregate's tags after that commit, null for a stream appended before the log recorded them), and events, each {name, revision, body}. With ABAC on, each stream is filtered by its own tags, so a timeline shows a stream as its aggregate was tagged when it was committed.
Filters
A filter is a tree of nodes. Each node is a JSON object whose op names its operator, with that operator's properties beside it. An object without op (the 9.x Condition) is refused.
Values
A value is a JSON scalar: a string, a number, a boolean, or null where the operator says so. Numbers keep their precision: 1.50 stays a decimal, and integers beyond 2^53 are exact. A value must match the field's type in the state schema (VALUE_MISMATCH otherwise): a string field compares with a string, a number field with a number. A time field (an Instant, a LocalDate, or an integer or string with @QueryTemporal) compares with its own encoding: epoch milliseconds for an Instant, "2026-10-13" for a LocalDate.
Comparisons
op | Properties | Matches |
|---|---|---|
EQ | field, value: a scalar or null | The field equals value; null asks whether the field is null |
NE | field, value | The field differs from value |
GT, GTE, LT, LTE | field, value: a scalar, not null | The field is greater, greater or equal, less, less or equal |
BETWEEN | field, lowerBound, upperBound: scalars, not null | lowerBound <= field <= upperBound, both bounds included |
IN | field, values: a non-empty array of scalars, no null | The field equals one of values |
NOT_IN | field, values | The field equals none of values |
{ "op": "IN", "field": "state.status", "values": ["PAID", "SHIPPED"] }
{ "op": "BETWEEN", "field": "state.price", "lowerBound": 10, "upperBound": 50 }IN takes values, not value; BETWEEN takes lowerBound and upperBound, not an array.
Strings
op | Properties | Matches |
|---|---|---|
CONTAINS | field, value: a string, stringComparison | The field contains value |
STARTS_WITH | field, value, stringComparison | The field starts with value |
ENDS_WITH | field, value, stringComparison | The field ends with value |
IS_EMPTY_STRING | field | The field is "" |
IS_NOT_EMPTY_STRING | field | The field is a string other than "" |
stringComparison is CASE_SENSITIVE (the default) or CASE_INSENSITIVE.
Collections
op | Properties | Matches |
|---|---|---|
CONTAINS_ALL | field, values: a non-empty array of scalars | The array field holds every one of values |
IS_EMPTY | field | The array field is empty |
ELEMENT_MATCH | field, predicate: a filter | An element of the array field matches predicate, whose fields are relative to the element |
{
"op": "ELEMENT_MATCH",
"field": "state.lines",
"predicate": {
"op": "AND",
"operands": [
{ "op": "EQ", "field": "sku", "value": "B" },
{ "op": "GTE", "field": "quantity", "value": 2 }
]
}
}The predicate matches one element: both conditions hold on the same line. It cannot hold a system-field filter or a SEARCH of the whole model.
Presence
A missing field and an explicit null are both null; only a missing field does not exist. A comparison with a value, a string operator or a range never matches a null field, while NE and NOT_IN do. On an array field, a scalar operand matches any element, and an EQ with an array operand (in process only) matches the whole array.
op | Properties | Matches |
|---|---|---|
IS_NULL | field | The field is null or absent |
IS_NOT_NULL | field | The field has a value |
EXISTS | field | The field is present, null included |
NOT_EXISTS | field | The field is absent |
Relative time
Each takes field, and optionally zoneId (an IANA zone such as Asia/Shanghai, or an offset such as +08:00; UTC by default, never the server's own zone), datePattern (a DateTimeFormatter pattern, for a field stored as a formatted string) and timeUnit (MILLISECONDS, the default, or another TimeUnit name, for a field stored as an epoch number).
op | Further properties | Matches |
|---|---|---|
TODAY, YESTERDAY, TOMORROW | That day | |
THIS_WEEK, LAST_WEEK, NEXT_WEEK | That week | |
THIS_MONTH, LAST_MONTH, NEXT_MONTH | That month | |
THIS_YEAR, LAST_YEAR, NEXT_YEAR | That year | |
BEFORE_TODAY | time: "18:00" or "18:00:30" | Before that time of today |
RECENT_DAYS | days: an integer | Within the last days days, today included |
EARLIER_DAYS | days | Before the last days days |
BEFORE_NOW, AFTER_NOW | offset: an ISO-8601 duration, PT0S by default | Before or after the server's now plus offset (-PT30M looks back half an hour) |
{ "op": "RECENT_DAYS", "field": "lastEventTime", "days": 7, "zoneId": "Asia/Shanghai" }Instants travel as instants: an epoch number, or an ISO-8601 string with its offset (2026-10-21T08:00:00Z). Where a day's boundaries matter, the request names its zone; the TypeScript DSL sends zoneId whenever the caller gives one.
System fields
These name no field: each filters a system field the model defines.
op | Properties | Matches |
|---|---|---|
ID | value: a string | The record's identity: aggregateId on the snapshot model |
IDS | values: a non-empty array of strings | One of these identities |
AGGREGATE_ID, AGGREGATE_IDS | value / values | The aggregate id |
TENANT_ID, OWNER_ID, SPACE_ID | value | The tenant, the owner, the space |
DELETION | state: ACTIVE, DELETED or ALL | The aggregates in that deletion state |
MATCH_ALL, MATCH_NONE | Every record, no record |
A snapshot query sees only aggregates that are not deleted, unless its filter names a DELETION itself. The caller's tenant, owner and space are always added (see HTTP API); a filter cannot widen them.
Search and expressions
op | Properties | Matches |
|---|---|---|
SEARCH | query: a non-blank string, fields: paths (none for every searchable field), mode: TERMS (default) or PHRASE | A full-text match, where the backend supports it (MODEL_SEARCH_UNSUPPORTED otherwise) |
EXPRESSION | expression, comparison: EQ, NE, GT, GTE, LT, LTE, value: a finite number | The computed expression of each record compares so; a record where it has no value does not match |
An expression is one of {"type":"FIELD","field":…}, {"type":"CONSTANT","value":…}, {"type":"BINARY","operator":"ADD|SUBTRACT|MULTIPLY|DIVIDE","left":…,"right":…} and {"type":"DATE_DIFF","from":…,"to":…,"unit":"SECOND|MINUTE|HOUR|DAY"}:
{
"op": "EXPRESSION",
"expression": { "type": "DATE_DIFF", "from": "state.paidAt", "to": "state.shippedAt", "unit": "HOUR" },
"comparison": "GT",
"value": 24
}Combinators
op | Properties | Matches |
|---|---|---|
AND | operands: a non-empty array of filters | Every operand |
OR | operands | At least one operand |
NOR | operands | No operand |
There is no NOT: a NOR of one operand negates it.
{ "op": "NOR", "operands": [{ "op": "EQ", "field": "state.status", "value": "CANCELLED" }] }Limits and cost
A filter holds at most wow.query.http.max-filter-nodes nodes (128) and max-filter-values values (1000) over HTTP (FILTER_TOO_LARGE). Some operators cannot use an index: NOR, NE, NOT_IN, CONTAINS, ENDS_WITH, a case-insensitive or empty STARTS_WITH, IS_EMPTY, IS_NOT_EMPTY_STRING, IS_NULL, IS_NOT_NULL, NOT_EXISTS and EXPRESSION. With wow.query.http.allow-expensive-operators: false they are refused (EXPENSIVE_OPERATOR_DISABLED).
Sort and projection
sort is an array of {"field": …, "direction": "ASC" | "DESC"}, at most 32, each field once. A field sorts only where the descriptor says it does (sort.paged, sort.cursor).
projection is {"include": [paths], "exclude": [paths]}; with neither, records come whole. An included path keeps its descendants.
{ "kind": "list", "filter": { "op": "MATCH_ALL" }, "projection": { "include": ["aggregateId", "state.name"] }, "limit": 50 }Query kinds
kind | Request properties | Response |
|---|---|---|
single | filter, projection, sort | The first record by sort; 404 wow.query.not-found when none matches |
list | filter, projection, sort, limit | A JSON array of records; with Accept: text/event-stream, server-sent events |
paged | filter, projection, sort, pagination, total | {"total": …, "exact": …, "list": [records]} |
cursor | filter, projection, sort, size, cursor | {"list": [records], "nextCursor": …} |
count | filter, limit | {"count": …, "exact": …} |
aggregate | filter, elements, groupBy, metrics, sort, limit, having | A JSON array of rows |
filter is required in every kind but aggregate (where it defaults to MATCH_ALL); {"op":"MATCH_ALL"} matches every record.
list
limit is the most records returned. 0 or none takes wow.query.http.default-list-size (100), and at most max-list-size (1000) are allowed (SIZE_OUT_OF_RANGE). With Accept: text/event-stream each record is an event row (id its index from 0); a failure after the first record ends the stream with an event error holding the problem.
paged
pagination is {"index": 1, "size": 10} by default: index counts from 1, size is at most max-page-size (100), and index × size at most max-page-window (10000). total says how the total is counted:
BOUNDED(the default): up towow.query.http.count-limit(10000).exactis false when there are more, andtotalis the limit, read as "at least" (10000+).EXACT: every match; refused over HTTP unlesswow.query.http.allow-exact-countis on.NONE: not counted;totalisnullandexactfalse.
{ "total": 10000, "exact": false, "list": [{ "aggregateId": "p-1", "...": "..." }] }cursor
A cursor page is the next size records (10 by default, at most max-page-size) after cursor, the nextCursor of the previous page; the first page has none. The server appends the record's unique key to sort (tenantId, aggregateId on the snapshot model), so the order is total and a page never repeats or skips a record. nextCursor is null on the last page. A cursor is opaque and belongs to its model and sort: send it with the same sort and filter (another sort is INVALID_CURSOR). Endless scrolling uses a cursor; it never counts.
{ "kind": "cursor", "filter": { "op": "MATCH_ALL" }, "sort": [{ "field": "lastEventTime", "direction": "DESC" }], "size": 20 }{ "list": [{ "aggregateId": "o-9", "...": "..." }], "nextCursor": "eyJ…" }count
limit, at least 1, bounds the count; none takes count-limit. exact is false when the count stopped at it. A count is for a filtered, bounded number; fixed statistics, such as orders per status, belong to a projection's counters or a dataset.
aggregate
An aggregation groups the matches and computes metrics per group. limit is the most groups (100 by default).
{
"kind": "aggregate",
"filter": { "op": "NE", "field": "state.status", "value": "CANCELLED" },
"groupBy": [{ "type": "TERMS", "field": "state.status", "alias": "status" }],
"metrics": [
{ "type": "COUNT", "alias": "count" },
{ "type": "NUMERIC", "function": "SUM", "expression": { "type": "FIELD", "field": "state.amount" }, "alias": "amount" }
],
"sort": [{ "field": "count", "direction": "DESC" }]
}[{ "status": "PAID", "count": 2, "amount": 30.5 }, { "status": "NEW", "count": 1, "amount": 30.0 }]Each row holds the group aliases and the metric aliases.
elements:[{"path": "state.lines", "filter": …}]expands a collection so the groups and metrics run over its elements (at most 5 levels); a field is then relative to the innermost element, and a group with"scope": "ROOT"reads the record's absolute path instead.groupBy:TERMS(fieldorexpression, optionalmissingKey),HISTOGRAM(fieldorexpression,interval),DATE_HISTOGRAM(field,unit:YEAR,QUARTER,MONTH,WEEK,DAY,HOUR,MINUTE,SECOND,timeZone,dense) andDATE_PART(field,part:DAY_OF_WEEK,DAY_OF_MONTH,HOUR_OF_DAY,MONTH_OF_YEAR,timeZone,dense); each has analias.densefills the empty buckets and needs the group to be the only one.metrics:COUNT,NUMERIC(function:SUM,AVG,MIN,MAX,STDDEV,VARIANCE,expression),DISTINCT_COUNT(expression),PERCENTILE(expression,percentile),ANY,FIRSTandLAST(field,orderBy, by defaultlastEventTime), andDERIVED(expressionover other metrics:METRIC_REF,CONSTANT,BINARY,TOTAL,RUNNING,PREVIOUS,RANK). Each butDERIVEDtakes an optionalfilter, counting only what matches.sortnames group and metric aliases; groups not named sort ascending after them.havingkeeps the groups whose metrics match:CONDITION(metric,operator,value),BETWEEN(metric,lower,upper),IN(metric,values),IS_NULL(metric,negated),ANDandOR(operands).
The descriptor's analysis says which of these the backend runs. The events model has no aggregation.
The capability descriptor
GET /<context>/<aggregate>/<model>/query answers what the model accepts, for the caller's entry:
{
"model": "SNAPSHOT",
"version": "sha256:b3c7…",
"timeZone": "UTC",
"record": { "identity": "aggregateId", "paging": ["LIST", "PAGED", "CURSOR"], "defaultScope": "ACTIVE",
"rootOperators": ["ID", "IDS", "AGGREGATE_ID", "AGGREGATE_IDS", "TENANT_ID", "OWNER_ID", "SPACE_ID", "DELETION", "EXPRESSION"] },
"limits": { "maxListSize": 1000, "defaultListSize": 100, "maxPageSize": 100, "maxPageWindow": 10000, "maxFilterNodes": 128, "...": "..." },
"analysis": { "metrics": ["COUNT", "NUMERIC", "..."], "...": "..." },
"fields": [
{ "path": "aggregateId", "types": ["STRING"], "kind": "SCALAR", "nullable": false, "project": true,
"filter": { "operators": ["EQ", "NE", "GT", "..."] }, "sort": { "paged": true, "cursor": true },
"aggregate": { "groups": ["TERMS"], "...": "..." }, "aliases": [] }
],
"elements": [{ "path": "state.lines", "filter": true, "aggregate": true }],
"dynamic": [{ "pattern": "tags.{key}", "types": ["STRING"], "kind": "ARRAY", "filter": { "operators": ["EQ", "..."] } }],
"constraints": [{ "type": "CURSOR_UNIQUE_SORT", "appended": "aggregateId", "fields": ["tenantId", "aggregateId"] }]
}fieldslists each path with its value types, the operators itsfiltertakes, whether it sorts in a page and in a cursor, and how it groups and aggregates.elementsare the collections anELEMENT_MATCHorelementsentry reaches, anddynamicthe patterns of map keys, such astags.{key}.record.rootOperatorsare the operators that name no field; the combinatorsAND,ORandNORapply everywhere.versionis a digest of the descriptor. The response carries it as itsETag; aGETwithIf-None-Match: "<version>"answers 304 while the descriptor stays the same, so a client caches it and asks again cheaply.
Errors
| Code | Status | When |
|---|---|---|
wow.query.malformed | 400 | The body does not read as a query: EMPTY_BODY, BODY_NOT_OBJECT, INVALID_JSON, UNKNOWN_PROPERTY, UNKNOWN_TYPE (an unknown op or type), UNKNOWN_VALUE, INVALID_VALUE, INVALID_REQUEST (a rule of the query type, such as an empty operands) |
wow.query.invalid | 400 | The model refuses it; each violation names its rule, such as UNKNOWN_FIELD, UNSUPPORTED_CAPABILITY, VALUE_MISMATCH, ELEMENT_SCOPE_REQUIRED, SIZE_OUT_OF_RANGE, FILTER_TOO_LARGE, EXPENSIVE_OPERATOR_DISABLED, INVALID_CURSOR, STORAGE_UNSUPPORTED |
wow.query.failed | 500 | The backend failed |
wow.query.timeout | 504 | The query ran longer than wow.query.http.timeout; not retried |
Each violation of the problem's violations has a path (the JSON path in the body, such as filter.operands), a code and a message.
The Kotlin DSL
wow-query-api holds a Kotlin DSL (me.ahoo.wow.api.query.dsl) that builds the same query objects as the JSON: a query built with it is the query its JSON reads as, so in-process callers (QueryGateway) and HTTP clients send one language.
import me.ahoo.wow.api.query.dsl.pagedQuery
val query = pagedQuery {
filter {
"state.categoryId" eq "books"
"state.price" between 10..50
not { "state.status" eq "DISCONTINUED" }
}
sort { "state.price".asc() }
pagination {
index(1)
size(20)
}
}| Builder | Builds | Sets |
|---|---|---|
singleQuery { } | SingleQuery | filter, projection, sort |
listQuery { } | ListQuery | the same, limit(n) |
pagedQuery { } | PagedQuery | the same, pagination { index(); size() }, total(TotalMode.EXACT) |
cursorQuery { } | CursorQuery | the same, size(n), cursor(next) |
countQuery { } | CountQuery | filter, limit(n) |
aggregation { } | AggregationQuery | filter, expand, groups, metrics, sort, limit, having |
filter { } | FilterExpression | alone, to reuse in several queries |
Inside filter { } each call adds one node, and a block's nodes combine with AND. Every field operator takes the path as a string or as a QueryField, such as a generated constant:
| DSL | Wire |
|---|---|
"f" eq v, "f" ne v | EQ, NE; eq null is IS_NULL, ne null is IS_NOT_NULL |
"f" gt v, gte, lt, lte | GT, GTE, LT, LTE |
"f".between(a, b), "f" between a..b | BETWEEN |
"f" isIn list, "f" notIn list, "f" containsAll list | IN, NOT_IN, CONTAINS_ALL |
"f".containsText(s), startsWithText(s), endsWithText(s), each with a StringComparison | CONTAINS, STARTS_WITH, ENDS_WITH |
"f".isNull(), isNotNull(), exists(), notExists() | IS_NULL, IS_NOT_NULL, EXISTS, NOT_EXISTS |
"f".isEmptyCollection(), isEmptyString(), isNotEmptyString() | IS_EMPTY, IS_EMPTY_STRING, IS_NOT_EMPTY_STRING |
"f".today(), recentDays(7), beforeToday(LocalTime.NOON), afterNow(Duration.ofMinutes(-30)), … | the relative-time operators, with zoneId, datePattern, timeUnit |
"f".elementMatch { … } | ELEMENT_MATCH, the block relative to an element |
search("q", mode, fields), "f" search "q" | SEARCH |
expression(expr, ComparisonOperator.GT, 24.0) | EXPRESSION |
id, ids, aggregateId, aggregateIds, tenantId, ownerId, spaceId, deletion(DeletionState.ALL), matchAll(), matchNone() | the system-field operators |
and { }, or { }, nor { }, not { } | AND, OR, NOR; not is a NOR of its block |
"state".path { "name" eq "x" } | the block's relative fields under state. |
A value goes on the wire as it is. A runtime value, such as an enum, is compared as the JSON it writes as: "state.status" eq OrderStatus.PAID finds what "PAID" finds. sort { "f".asc(); "g".desc() } and projection { include(…); exclude(…) } take strings or QueryFields; nested("state") names the fields under a path.
An aggregation names its parts by alias:
val byStatus = aggregation {
filter { "state.status" ne "CANCELLED" }
terms("state.status", "status")
count("count")
sum("state.amount", "amount")
derived("share") { ref("amount") / total("amount") }
sort { "count".desc() }
having { "count" gte 1.0 }
}The 9.x DSL lives in me.ahoo.wow.query.dsl (wow-query); the V10 one has the same builders, minus condition { }, in me.ahoo.wow.api.query.dsl, plus countQuery { }, not { }, between with a range and the QueryField receivers.
Generated field constants
When a domain module has wow-query-api on its compile class path, the compiler writes <State>Properties beside each aggregate's state: an object of QueryField constants, one per path a snapshot query can name, extending SnapshotProperties, which holds the system fields.
// Generated for OrderState
public object OrderStateProperties : me.ahoo.wow.api.query.SnapshotProperties() {
public val STATE__ID = QueryField("state.id")
public val STATE__ITEMS = QueryField("state.items")
public val STATE__ITEMS__PRODUCT_ID = QueryField("state.items.productId")
public val STATE__ADDRESS__CITY = QueryField("state.address.city")
public val STATE__STATUS = QueryField("state.status")
// ...
}- System fields come from
SnapshotProperties:TENANT_ID,AGGREGATE_ID,VERSION,OWNER_ID,SPACE_ID,DELETED,TAGSwithtag(name),FIRST_EVENT_TIME,LAST_EVENT_TIME,OPERATOR,OPERATOR__ID,OPERATOR__TENANT_ID,OPERATOR__CROSS_SCOPE, the same ofFIRST_OPERATOR, andSTATE. Without a state at hand they are onSnapshotPropertiesitself. - Names: each constant is named after the Kotlin properties of its path, in upper snake case, a nested segment after
__; its value is the path Jackson writes,@JsonPropertynames included. - Which paths: each public property with a backing field that Jackson writes (no
@JsonIgnore, none a@JsonIgnorePropertiesnames), into application classes and the elements of collections. Maps, enums, interfaces, abstract, sealed and value classes, the JDK and Kotlin types, and a class already on the path are leaves. These are the paths the state schema in the completed fragment describes. - Elements: an element's constant, such as
STATE__ITEMS__PRODUCT_ID, is named inside anELEMENT_MATCHof its collection. The DSL reads a field under the element's own path relative to it:
val query = listQuery {
filter {
OrderStateProperties.STATE__STATUS isIn listOf(OrderStatus.PAID, OrderStatus.SHIPPED)
OrderStateProperties.STATE__ITEMS.elementMatch {
OrderStateProperties.STATE__ITEMS__PRODUCT_ID eq "p-1" // productId, relative to an item
}
}
sort { OrderStateProperties.LAST_EVENT_TIME.desc() }
}The 9.x <State>Properties held const val strings relative to the state (ITEMS__PRODUCT_ID = "items.productId"); the V10 ones are QueryFields of the whole path, so ITEMS__PRODUCT_ID becomes STATE__ITEMS__PRODUCT_ID.
Query annotations
The annotations of me.ahoo.wow.api.query.annotation declare what the snapshot model cannot infer from a state's types. They live in wow-query-api, which the domain module adds beside the kernel:
dependencies {
implementation("me.ahoo.wow:wow-query-api")
}They go on a Kotlin property with the @field: use-site target, since manifest-tools reads them from the field of the compiled state. manifest-tools writes them into the state schema of the module's Manifest fragment as x-wow-* keywords; the query model and the descriptor read them from there.
| Annotation | Attributes | Declares |
|---|---|---|
@QueryIndex | name, on, with | An index the backend builds; see below |
@QueryTemporal | unit (a TimeUnit, MILLISECONDS by default), pattern | An integer field holding an epoch time in unit, or a string field formatted with pattern; standard time types need none |
@QueryDecimal | scale | A fixed-point decimal with scale fraction digits |
@QueryMoney | currency or currencyField, scale | An amount of money, in a fixed ISO 4217 currency or the one a sibling string field holds |
@QueryDuration | unit | A length of time counted in unit |
@QueryReference | aggregateName, contextName | The id of another aggregate (in this context by default) |
@QueryAlias | value: paths | Other full paths a query may use for the field, such as its name before a rename |
@Sensitive | level: DISPLAY or CONFIDENTIAL, mask: Mask(keepPrefix, keepSuffix, strategy) | A string field masked in every result; DISPLAY may still be filtered and sorted in a page (wow.query.sensitivity.display-comparable), CONFIDENTIAL is never compared |
A field has one semantic type: @QueryTemporal, @QueryDecimal, @QueryMoney, @QueryDuration and @QueryReference do not combine.
@QueryIndex(on = "ownerId", with = ["lastEventTime"])
class ProductState(val id: String) {
@field:QueryIndex(with = ["state.status", "state.price"])
var brandId: String = ""
private set
@field:QueryDecimal(scale = 2)
var price: BigDecimal = BigDecimal.ZERO
private set
@field:QueryTemporal(unit = TimeUnit.SECONDS)
var listedAt: Long = 0
private set
@field:QueryAlias("state.title")
var name: String = ""
private set
}@QueryIndex
- On a property, the property leads the index (
onstays empty), andwithlists the further keys in order. On the state class,onnames the leading key. The annotation repeats: a field can lead several indexes. - Keys are full logical paths of the model: a system field (
ownerId,lastEventTime, …) orstate.<path>, as JSON names them. A key must take an exact match, a range or a sort; on PostgreSQL it holds one value (an array path cannot be a key), and MongoDB cannot index two arrays in one index. - Every index starts with the tenant, which admission adds to every query, and is partial on the snapshots that are not deleted.
name, empty by default, is derived:ix_<aggregate>_<keys>, each key in snake case with its dots as_, such asix_product_state_brand_id_state_status_state_price. A name longer than 63 characters keeps its first 54 and appends_and 8 hex digits of its SHA-256. Names are unique within a bounded context.wow.query.index.ddlmanages them:autocreates the missing ones and rebuilds invalid ones (PostgreSQL'sCREATE INDEX CONCURRENTLY),validateonly reports,noneleaves the schema alone.
On PostgreSQL the index is an expression index on the aggregate's snapshot table, wow_snapshot__<context>__<aggregate>, named <table>_<name>: wow_snapshot__eshop_catalog__product_ix_product_state_brand_id_…. That is again limited to 63 characters, the first 54 then _ and 8 hex digits of the SHA-256 of the whole name, so a long name ends in a hash: to match a Manifest index with the database, compute <table>_<name> and limit it so, or compare the definitions. Each key is the very expression the SQL compiler emits for that path, typed by its schema (a CASE WHEN jsonb_typeof(...) over the state column, cast to numeric or boolean, text in the "C" collation), so the planner uses the index:
CREATE INDEX CONCURRENTLY IF NOT EXISTS "<table>_<name>" ON "<table>"
(tenant_id, <key expression> NULLS FIRST, ...) WHERE NOT deletedOn MongoDB it is a compound index of the aggregate's snapshot collection, {tenant_id: 1, <key>: 1, …} with partialFilterExpression: {deleted: false}, under its own name, since MongoDB names indexes per collection.