Skip to content

Query DSL

The Arda Query DSL is a JSON-based language for filtering, sorting, and paginating records across the Arda Cloud API. It applies to both the Item and Kanban Card domains, and to any DataAuthorityEndpoint.

A query is a JSON object with three optional top-level keys:

{
"filter": { },
"sort": { },
"paginate": { }
}

All three are optional. Omitting filter returns all records. Omitting paginate uses server defaults.

The Query DSL is defined by Kotlin data classes and sealed interfaces in Query.kt (cards.arda.common.lib.lang.query).

The top-level object:

data class Query(
val filter: Filter = Filter.TRUE,
val sort: Sort = Sort.NO_SORT,
val paginate: Pagination = Pagination.FIRST_PAGE
)

Helper properties thisPage, nextPage, and previousPage are GZIPed and Base64 URL encoded string representations of the Query object, suitable for use as page cursors.

A sealed interface representing a filtering condition:

Filter
├── Filter.Literal
│ ├── Filter.TRUE — always evaluates to true
│ └── Filter.FALSE — always evaluates to false
├── Filter.Composite
│ ├── Filter.And(clauses: List<Filter>)
│ └── Filter.Or(clauses: List<Filter>)
├── Filter.Transform
│ └── Filter.Not(clause: Filter)
└── Filter.Term
├── Filter.UnaryTerm
│ ├── Filter.IsNull(locator)
│ └── Filter.IsNotNull(locator)
├── Filter.CompareTerm<FIELD>
│ ├── Filter.Eq(locator, value)
│ ├── Filter.Ne(locator, value)
│ ├── Filter.Gt(locator, value)
│ ├── Filter.Ge(locator, value)
│ ├── Filter.Lt(locator, value)
│ ├── Filter.Le(locator, value)
│ └── Filter.RegEx(locator, value: String)
└── Filter.SetTerm<FIELD>
├── Filter.In(locator, values: List<FIELD>)
└── Filter.NotIn(locator, values: List<FIELD>)

Locator is a type alias for String, identifying a field/property path (e.g., "fieldName", "nested.object.field").

data class Sort(val entries: List<SortEntry>) {
companion object { val NO_SORT = Sort(emptyList()) }
}
data class SortEntry(val key: Locator, val direction: SortDirection)
enum class SortDirection { ASC, DESC }
data class Pagination(val index: Int, val size: Int) {
companion object { val FIRST_PAGE = Pagination(0, 20) }
val next: Pagination get() = copy(index = index + 1)
val previous: Pagination? get() = if (index > 0) copy(index = index - 1) else null
val start: Int get() = index * size
}
Kotlin FilterJSON Representation
Filter.TRUEtrue
Filter.FALSEfalse
Filter.And{ "and": [<clause1>, <clause2>, ...] }
Filter.Or{ "or": [<clause1>, <clause2>, ...] }
Filter.Not{ "not": <clause> }
Filter.IsNull{ "isNull": "fieldName" }
Filter.IsNotNull{ "isNotNull": "fieldName" }
Filter.Eq{ "locator": "fieldName", "eq": <value> }
Filter.Ne{ "locator": "fieldName", "ne": <value> }
Filter.Gt{ "locator": "fieldName", "gt": <value> }
Filter.Ge{ "locator": "fieldName", "ge": <value> }
Filter.Lt{ "locator": "fieldName", "lt": <value> }
Filter.Le{ "locator": "fieldName", "le": <value> }
Filter.EqI{ "locator": "fieldName", "eqI": "text" }
Filter.NeI{ "locator": "fieldName", "neI": "text" }
Filter.RegEx{ "locator": "fieldName", "regex": "pattern" }
Filter.In{ "in": { "locator": "fieldName", "values": [...] } }
Filter.NotIn{ "notIn": { "locator": "fieldName", "values": [...] } }

Instant values are serialized as ISO 8601 strings (e.g., "2023-10-27T10:30:00Z").

eqI and neI compare text without regard to case, as terms of the algebra rather than as an anchored regex.

The distinction matters because the regex spelling was a trap rather than merely verbose: a pattern makes the caller responsible for escaping, so a user typing C++ or a.b into an equality filter got a pattern instead of a literal unless every call site remembered to escape — and C++ is not a valid pattern at all. It also lost the intent on the wire, so a reader of a stored request could not tell an exact match from a case-folded one.

Three things to know before using them:

  • The value must be text. These two terms advertise string alone, where the others accept string, number or boolean. A value of another type is refused at the wire; a locator resolving to a non-text column is refused when the filter is compiled. Enum columns are accepted, since they store the constant’s name.
  • Neither term matches an absent value, in either engine. lower(col) <> 'acme' is unknown rather than true when the column holds nothing, so the row is not returned. This makes case-folded inequality read differently from ordinary ne.
  • Case is folded on both sides, not by giving the column a case-insensitive type — SQL lower() on the column, and the value lowered before binding, which is why the value appears already lowercase in the example above. See Bitemporal Indexes for what that means when a query on this gets slow.

Deserialization uses smartParseJsonPrimitive to infer types: "true" → Boolean, "123" → Long, "123.45" → Double, ISO8601 string → Instant.

{
"entries": [
{ "key": "fieldNameA", "direction": "ASC" },
{ "key": "fieldNameB", "direction": "DESC" }
]
}
{ "index": 12, "size": 50 }
// Boolean comparison
{ "locator": "field", "eq": true }
// Number comparison
{ "locator": "field", "gt": 42 }
// String comparison
{ "locator": "field", "eq": "value" }
// Instant comparison
{ "locator": "field", "eq": "2025-08-12T23:41:30.399755Z" }
// IN with numbers
{ "in": { "locator": "field", "values": [1, 2, 3] } }
// IN with strings
{ "in": { "locator": "field", "values": ["a", "b", "c"] } }
// NOT IN
{ "notIn": { "locator": "field", "values": ["x", "y"] } }
// IS NULL
{ "isNull": "field" }
// IS NOT NULL
{ "isNotNull": "field" }
// REGEX
{ "locator": "field", "regex": "^abc.*" }
// AND
{ "and": [true, false] }
// OR
{ "or": [true, false] }
// NOT
{ "not": true }

Active items in a specific facility, sorted by location:

{
"filter": {
"and": [
{ "locator": "retired", "eq": false },
{ "locator": "physical_locator_facility", "eq": "Building A" },
{
"notIn": {
"locator": "classification_type",
"values": ["Deprecated", "Archive"]
}
},
{ "not": { "isNull": "primary_supply_supplier" } }
]
},
"sort": {
"entries": [
{ "key": "physical_locator_location", "direction": "ASC" }
]
},
"paginate": {
"index": 0,
"size": 100
}
}

The QueryCompiler translates Filter and Sort DSL components into Kotlin Exposed SQL operators.

QueryCompiler(private val tbl: EntityTable, private val translator: ExposedLocatorTranslator? = null)

When a translator is provided, locator resolution uses the structured translator first (see below), then falls back to the table’s column lookup. Without a translator, locators resolve directly against the EntityTable’s column names.

Key methods:

  • filter(f: Filter, asOf: TimeCoordinates): Op<Boolean> — converts a Filter to an Exposed SQL condition, at a stated point in time
  • filterWithoutReadTime(f: Filter): Op<Boolean> — for the two reads that genuinely have no such moment
  • where(f: Filter, asOf: TimeCoordinates): Op<Boolean> — combines filter with bitemporal visibility
  • ordering(sort: Sort): List<Pair<Expression<*>, SortOrder>> — converts Sort to Exposed ordering (prepends bitemporal default sort)
  • DecoratePaging(raw: SizedIterable<T>, p: Pagination): SizedIterable<T> — applies limit/offset

FilterExposedVisitor implements FilterVisitor<Op<Boolean>, EntityTable>, using the EntityTable to resolve locators to columns.

Since 16.0.0, compiling a filter requires the moment it will answer at. This is required rather than optional, and the reason is worth understanding before working around it.

Only quantified locators consult the read time — roles[any].role resolves to a subquery over a child table, which has to be confined to the child rows visible at that instant. But whether a locator is quantified is decided by a string in a request body, not by the code issuing the read. A caller therefore cannot know in advance that it will not need one.

An optional argument could not distinguish a read that has a moment and forgot to pass it from one that genuinely has none — and adapting the call sites immediately found the first kind: a caller-supplied filter on the item fuzzy-lookup path, compiled with nothing, which would have refused any collection-crossing filter a client sent it.

The two reads that genuinely have no read time — those addressing specific record versions by id — say so by calling filterWithoutReadTime. A quantified locator reaching that path is refused rather than answered without one. crossesCollection(locator) lets a caller without a read time check whether it is about to need what it does not have.

A quantified child locator sees only the visible children

Section titled “A quantified child locator sees only the visible children”

roles[any].role, supplies[any]… and lines[any]… compile to an EXISTS over the child table, and that subquery is confined the same way the outer query confines the parent: a bitemporal cut-off at the read time, latest-version-per-entity selection, and the soft-delete check.

Without that confinement the filter meant ever held this value, in any version, at any time rather than holds it now — a bitemporal table keeps every version of every entity, live and retired, so the rows a child table contains are not the rows a caller can see.

Two properties of the construction are load-bearing:

  • The confinement is applied to the child set before the quantifier, not folded into the caller’s own term. Folded in, [all] would read “no child is both visible and failing” — vacuously true for an entity whose every child is invisible, so an entity whose children had all been removed would satisfy any “all children are X” filter.
  • Retired children are excluded unconditionally, even under includeRetired. That flag widens which entities a read returns; a filter term answers a different question — whether an entity’s collection holds a value now. Letting the flag reach into the subquery would silently change what every collection filter means for administrative reads.

EntityServiceConfiguration and Structured Locator Translators

Section titled “EntityServiceConfiguration and Structured Locator Translators”

EntityServiceConfiguration introspects a @Serializable entity payload class and builds an ExposedLocatorTranslator that maps JSON field names (camelCase property paths) to database columns. This allows API clients to use the same field names they see in JSON responses as filter/sort locators.

// Define once per entity type
val myQueryConfig = EntityServiceConfiguration.create(MyEntity.Entity::class) {
opaque("settings") // exclude fields that are not filterable (e.g., JSON blobs)
}.also { it.freeze() }
// Bind to a table to get a translator
val translator = myQueryConfig.bindToTable(MY_TABLE)

The translator is then passed to QueryCompiler or set as the universe’s translator property:

class MyUniverse : AbstractScopedUniverse<...>() {
override val translator by lazy { myQueryConfig.bindToTable(persistence.bt) }
}

The ExposedLocatorTranslator.resolveColumn() method uses a multi-step resolution strategy:

  1. Structured path resolution — matches the locator as a JSON property path (e.g., identity.email → the identity_email column)
  2. Exact column name match — falls back to findColumn() which matches the raw SQL column name (e.g., identity_email)
  3. Case-insensitive and underscore-insensitive match — handles minor naming variations

This ensures backward compatibility: existing filters using snake_case column names continue to work alongside new camelCase JSON field paths.

For child universes, the translator must be bound to a ChildTable. Due to invariant generics on ExposedLocatorTranslator, an explicit cast with a runtime guard is required:

override val translator by lazy {
check(persistence.bt is ChildTable) {
"MyChildUniverse requires a ChildTable, got ${persistence.bt::class}"
}
myChildQueryConfig.bindToTable(persistence.bt as ChildTable)
}

Locators can be expressed as either JSON payload field paths (camelCase, matching the serialized field names) or database column names (snake_case). The structured EntityServiceConfiguration translator accepts both forms; a QueryCompiler without a configured translator accepts only raw column names.

StyleExampleWhen to use
JSON field pathidentity.email, cardQuantity.amountPreferred for API clients — matches JSON response structure
Database column nameidentity_email, card_quantity_amountLegacy — still supported for backward compatibility

Note: Universes configured with EntityServiceConfiguration accept both locator styles. Universes without a configured translator accept only raw column names.

LocatorTypeDescription
eiduuidUnique entity ID
item_namevarchar(255)Display name
internal_skuvarchar(255)Internal SKU / part number
classification_typevarchar(255)Item type (e.g., Raw Material)
classification_sub_typevarchar(255)Item subtype
use_casevarchar(255)Use case category
gl_codevarchar(255)GL code for accounting
physical_locator_facilityvarchar(255)Facility name
physical_locator_departmentvarchar(255)Department
physical_locator_locationvarchar(255)Storage location
physical_locator_sub_locationvarchar(255)Sub-location
image_urlvarchar(8192)Product image URL
notesvarchar(8192)Item notes
card_notes_defaultvarchar(8192)Default notes for auto-created cards
retiredbooleanSoft-delete flag
taxablebooleanTax-applicable flag
primary_supply_suppliervarchar(255)Primary supplier name
primary_supply_skuvarchar(255)Supplier part number
primary_supply_order_methodvarchar(255)Order mechanism (see OrderMethod enum)
primary_supply_urlvarchar(8192)Supplier URL
primary_supply_order_quantity_amountdoubleOrder quantity
primary_supply_order_quantity_unitvarchar(255)Order unit (e.g., EA, BOX)
primary_supply_unit_cost_valuedoubleUnit cost
primary_supply_unit_cost_currencyvarchar(5)Currency code (see Currency enum)
primary_supply_average_lead_time_lengthintegerLead time value
primary_supply_average_lead_time_time_unitvarchar(10)Lead time unit (see TimeUnit enum)
secondary_supply_suppliervarchar(255)Secondary supplier
secondary_supply_skuvarchar(255)Secondary supplier SKU
secondary_supply_order_methodvarchar(255)Secondary order mechanism
secondary_supply_urlvarchar(8192)Secondary supplier URL
secondary_supply_order_quantity_amountdoubleSecondary order quantity
secondary_supply_order_quantity_unitvarchar(255)Secondary order unit
secondary_supply_unit_cost_valuedoubleSecondary unit cost
secondary_supply_unit_cost_currencyvarchar(5)Secondary currency
secondary_supply_average_lead_time_lengthintegerSecondary lead time
secondary_supply_average_lead_time_time_unitvarchar(10)Secondary lead time unit
default_supplyvarchar(255)Which supply chain is default
default_supply_eiduuidDefault supply entity ID
card_sizevarchar(255)Kanban card size (see CardSize enum)
label_sizevarchar(255)Label size
breadcrumb_sizevarchar(255)Breadcrumb label size
item_colorvarchar(255)Display color (see ItemColor enum)
min_quantity_amountdoubleMinimum quantity threshold
min_quantity_unitvarchar(255)Min quantity unit
descriptionvarchar(8192)Extended description
created_byvarchar(255)Creator identifier
created_at_effectivetimestampWhen the record was effectively created
created_at_recordedtimestampWhen the record was recorded in the system
effective_as_oftimestampBitemporal: when the fact was true
recorded_as_oftimestampBitemporal: when the fact was recorded

The fields below are drawn from the KanbanCard.Entity schema. The eid locator is confirmed to work against the database column directly. For other fields, the EntityServiceConfiguration translator maps JSON field paths to DB column names; if no translator is in effect, use the snake_case column name equivalent (e.g., serial_number instead of serialNumber).

Locator (JSON path)TypeDescription
eiduuidUnique card entity ID
serialNumberstringCard serial number
item.eIduuidReferenced item entity ID
item.namestringReferenced item display name
cardQuantity.amountdoubleOrder quantity amount
cardQuantity.unitstringOrder quantity unit
locator.facilitystringPhysical location: facility
locator.departmentstringPhysical location: department
locator.locationstringPhysical location: location
locator.subLocationstringPhysical location: sub-location
statusKanbanCardStatusCurrent card status (see enum below)
printStatusKanbanCardPrintStatusCurrent print status (see enum below)
notesstringCard-level notes
retiredbooleanSoft-delete flag (from EntityRecord wrapper)

Enum values used in locator comparisons and returned in query results.

ValueDescription
AVAILABLECard is available / at rest
REQUESTEDReorder has been requested
REQUESTINGRequest is being processed
IN_PROCESSOrder is being processed
READYOrder is ready
FULFILLINGFulfillment in progress
FULFILLEDOrder fulfilled
IN_USEItem is in use
DEPLETEDItem has been fully consumed
UNKNOWNStatus not determined
ValueDescription
REQUESTReorder requested
ACCEPTRequest accepted
SHELVECard shelved
START_PROCESSINGProcessing started
COMPLETE_PROCESSINGProcessing completed
FULFILLFulfillment action
RECEIVEItem received
USEItem put into use
DEPLETEItem depleted
WITHDRAWCard withdrawn
NONENo event
FAILED_ACTIONAction failed

NOT_PRINTED | PRINTED | LOST | DEPRECATED | RETIRED | UNKNOWN

UNKNOWN | PURCHASE_ORDER | EMAIL | PHONE | IN_STORE | ONLINE | RFQ | PRODUCTION | TASK | THIRD_PARTY | OTHER

SMALL | MEDIUM | LARGE | X_LARGE

BLUE | GREEN | YELLOW | ORANGE | RED | PINK | PURPLE | GRAY

USD | CAD | EUR | GBP | JPY | AUD | CNY | INR | RUB | BRL | ZAR | MXN | KRW | SGD | HKD | NZD | CHF

MILLI | SECOND | MINUTE | HOUR | DAY | WEEK | STANDARD_MONTH | LONG_MONTH | STANDARD_FEBRUARY | LEAP_FEBRUARY | STANDARD_YEAR | CALENDAR_YEAR | LEAP_YEAR

ASC | DESC

The Query DSL request body is accepted by POST /query and POST /details endpoints on DataAuthorityEndpoint services. The following endpoints currently support it.

EndpointMethodQuery DSL support
/v1/item/item/queryPOSTFull Query DSL (filter, sort, paginate)
/v1/item/item/detailsPOSTFull Query DSL — returns hydrated item details
/v1/item/item/{item-eid}/historyPOSTFull Query DSL — scoped to a single item’s history
EndpointMethodQuery DSL support
/v1/kanban/kanban-card/queryPOSTFull Query DSL (filter, sort, paginate)
/v1/kanban/kanban-card/detailsPOSTFull Query DSL — returns hydrated card details with embedded item
/v1/kanban/kanban-card/{card-eid}/historyPOSTFull Query DSL — scoped to a single card’s history

All other endpoints on these APIs (single-record GET/PUT/DELETE, bulk-update, upload, lifecycle events) do not accept the Query DSL body. See the API Endpoint Catalog for the full list of endpoints and their OpenAPI specs.

The FilterVisitor<R, TDATA> interface allows extending the DSL for new compilation targets.

interface FilterVisitor<R, TDATA: Any> {
fun visit(filter: Filter, data: TDATA): Result<R>
}

Steps to create a new visitor:

  1. Determine target type R and contextual data type TDATA
  2. Implement FilterVisitor<R, TDATA>
  3. Handle all Filter subtypes in the when expression; recursively call accept on composite filters
  4. Use TDATA for field mappings or configuration
  5. Return Result.success(value) or Result.failure(error)

The collectAll() extension function is useful for processing lists of Result<R>.