Querying with AQL
The Archetype Query Language (AQL 1.1) is how you read data out of FerroEHR.
Instead of querying hidden database tables, you query the clinical model
directly: you name the RM types and archetypes you want, express structural
nesting with CONTAINS, and select values by their path within an archetype. The
same query runs unchanged on any conformant openEHR system. This chapter is a
practical walkthrough: the language, how to run queries over HTTP, parameters,
stored queries, version scope, terminology, pagination and limits, and the
supported feature envelope.
- The shape of a query
- Running a query over HTTP
- The result set
- Parameters
- Stored queries
- Cohort queries across the pseudonymisation boundary
- Queries rooted at a demographic class
- Version scope: LATEST_VERSION and ALL_VERSIONS
- Terminology in queries
- Pagination and limits
- What is supported
The shape of a query
An AQL statement has the familiar SELECT … FROM … WHERE … ORDER BY skeleton,
but the “tables” are RM types and the “columns” are archetype paths:
SELECT
c/name/value AS composition_name,
o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude AS systolic
FROM EHR e
CONTAINS COMPOSITION c
CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]
WHERE o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude > 140
ORDER BY systolic DESC
-
FROMbinds variables to RM types (EHR e,COMPOSITION c,OBSERVATION o). A type can be constrained by archetype id in square brackets (OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]). Naming a parent archetype also returns data recorded under its specialisations, as openEHR requires: for ADL 1.4 identifiers the specialisation is the hyphen-extended concept (…blood_pressurematches…blood_pressure-cuff), and for ADL 2 identifiers (where the hyphen carries no such meaning) the lineage is read from the ADL 2 archetypes and templates you have uploaded, so the parent matches every stored specialisation of it whatever its concept is named. In both cases the major version is a hard boundary:.v1never matches.v2data. -
CONTAINSexpresses structural containment: “an EHR that contains a composition that contains a blood-pressure observation”. Chains can nest several deep, and combine withAND,OR, andNOT. Folder containment follows the RM’s reference model:FOLDER f CONTAINS COMPOSITION cmatches the compositions a folder’sitemsreference, transitively over the folder’s sub-tree, andFOLDER f1 CONTAINS FOLDER f2matches strict sub-folders plus the folders of a versioned folder theitemsreference — one reference hop, so a chain of references is expressed by chainingCONTAINS. A pair the RM defines no containment relationship for (sayCOMPOSITION CONTAINS COMPOSITION) is refused with a typed error. Containment is strict: a node does not contain itself, soCLUSTER cl1 CONTAINS CLUSTER cl2binds only clusters nested inside another cluster. A variable declared inside anORbranch can be read inSELECT,WHERE, andORDER BY: a row where its branch did not match readsnullfor it, and when both branches match, each match of one pairs with each match of the other. This holds for a branch that is a versioned object of its own too:EHR e CONTAINS (COMPOSITION c1[...] OR COMPOSITION c2[...])returns the current version of each matching composition, itsc1/uid/valueincluded, and aFOLDERbranch reads the compositions the folder references. -
SELECTprojects values by path. Paths use archetype node ids (at0004) and RM attribute names (value/magnitude);ASnames a column. A path through a repeated node returns one row per node it reaches: selecting the systolic and the diastolic of a blood-pressureEVENTreturns one row per event, each pairing that event’s own two values, withnullwhere an event lacks one. Paths that share a prefix share that node in a row, and a path that reaches nothing readsnullwithout removing the row. The AQL specification does not define this row shape; it is this engine’s documented convention. -
WHEREfilters on typed leaf values, with comparisons,EXISTS,LIKE,MATCHES, and boolean combinators. The AQL specification does not say how a test behaves on a path that reaches several nodes; this engine’s rule is:- Every repeated step a
WHEREpath descends, and theSELECThas not already bound, is bound once for the wholeWHEREclause, the way aSELECTpath binds it: one node per binding, and onenullbinding when nothing matches. Steps written identically (predicate included) are one binding, soevents[at0006]andeventsare two. - A row is kept when some assignment of those bindings makes the whole
clause true, with SQL’s three-valued logic: a test of a
nullvalue is unknown, and an unknown clause does not keep the row. - A path that shares a prefix with a selected path reads the node that row is bound to.
The boolean laws therefore hold.
<systolic> >= 140 AND <diastolic> >= 90holds only for an event carrying both values, also underORandNOT, whether or not theSELECTreads the event;NOT (<systolic> > 140)holds when some event’s systolic is 140 or lower, and is unknown when the path reaches nothing;NOT (A OR B)equalsNOT A AND NOT B; adding a test withANDnever adds rows. Paths compared with each other, and paths inside a function call, read through the same bindings. Elements of a list attribute inside one node’s data (links,participations,identifiers, …) are not bindings: a test of such a path against a value holds when any element of the bound node satisfies it, and is unknown when the list is empty or absent, soNOT (c/links/target/value = 'x')drops a composition without links. Beside another path or inside a function, such a path reads its first element.EXISTS <path>asks whether the data at that path exists (the AQL specification’s own definition). It reads through theSELECTbindings, and otherwise looks at the whole path on its own, outside the clause’s bindings:EXISTSholds when any node on the path carries the data andNOT EXISTSwhen none does, whatever the other tests bind.A list attribute in a projected path is read by one of two rules, depending on what the list holds:
- Row rule: a list that holds structure nodes (
c/content,p/identities,.../items,.../events) gives one row per node.SELECT o/data[at0001]/events/time/valueover an observation with two events answers two rows, one time each. - Cell rule: a list inside one node’s data (
links,context/participations,composer/identifiers,mappings,feeder_audit/originating_system_item_ids) gives one cell. A path that ends on the list answers the list itself, and a path that continues below it answers the array of its matches. Both arenullwhere nothing matches. Over a composition with a placer and a filler order number,c/feeder_audit/originating_system_item_idsanswers[{"_type": "DV_IDENTIFIER", "id": "ORD-PLACER-1", ...}, {"_type": "DV_IDENTIFIER", "id": "ORD-FILLER-1", ...}]andc/feeder_audit/originating_system_item_ids/idanswers["ORD-PLACER-1", "ORD-FILLER-1"].
Paths through different repeated steps of one variable are bound independently, so selecting both returns every combination of their nodes.
- Every repeated step a
-
Aggregates read every data item of their path in the rows
FROMandWHEREselect:MAX(<systolic>)over an observation measured twice sees both events, andCOUNTover a list attribute counts its elements. An item counts when some assignment of theWHEREbindings, with the steps the item shares fixed to its own nodes, makes the clause true, soSELECT COUNT(<systolic>) ... WHERE NOT (<systolic> < 140)counts exactly the readings that selecting<systolic>would return. List elements inside one node are not filtered one by one:COUNT(<links path>)under aWHEREon that path counts every link of a row the clause keeps. An aggregate adds no rows, soCOUNT(*)counts the rows theWHEREclause keeps whatever other aggregates sit beside it.COUNT(DISTINCT)compares numbers by value (120and120.0are one value), a stored JSONnullcounts as no value, andAVGreturns a decimal mean. -
ORDER BY,LIMIT, andOFFSETbehave as you expect; quantities order by their openEHR magnitude semantics. -
Date/time comparisons accept reduced precision on both sides: openEHR admits partial values (
2019,1985-06), and the engine compares them by flooring to the first instant they contain (a partial date assumes the first month/day, a partial time assumes zero). A comparison value that is not an ISO 8601 date/time at all is refused with a 400.
Running a query over HTTP
The query API lives under the base path at /query/aql. The simplest form is a
POST with a JSON body:
curl -u ferroehr:ferroehr \
-H 'Content-Type: application/json' \
-d '{"q":"SELECT e/ehr_id/value FROM EHR e"}' \
http://localhost:8080/ferroehr/rest/openehr/v1/query/aql
The body fields are:
| Field | Meaning |
|---|---|
q | The AQL text (required). |
offset | Rows to skip (default 0). |
fetch | Maximum rows to return. |
query_parameters | An object of named parameter values (see below). |
There is also a GET /query/aql form taking q, offset, fetch, an optional
ehr_id, and query_parameters as query-string parameters, convenient for
simple, cacheable reads.
offset, fetch, ehr_id and named parameters are accepted in the query string
on the POST forms too. A value supplied in both the body and the URL must
agree; a disagreement is a 400 Bad Request rather than a silent choice
between them.
The query API is JSON only (Accept: application/json).
Scoping to an EHR
You can restrict a query to one EHR without writing the constraint into the AQL:
pass an ehr_id query-string parameter, or the openehr-ehr-id request header.
Both forms work on every execution endpoint: ad-hoc and stored, GET and
POST alike. (openEHR-EHR-id is the deprecated spelling of the same header and
still resolves, HTTP header names being case-insensitive.)
If a request carries both forms they must name the same EHR; a request whose parameter and header name different EHRs is self-contradictory and is rejected with a 400 Bad Request. So is a request repeating the header with two different values. An empty header value counts as “not supplied” and conflicts with nothing.
The id must exist: a malformed id is a 400, and a well-formed id that matches no EHR is an honest 404 Not Found rather than an empty result set, so a typo cannot masquerade as “no data”.
The result set
A query returns a RESULT_SET: a description of the columns and an array of row
tuples.
{
"meta": {
"_type": "RESULTSET",
"_schema_version": "1.0.0",
"_created": "…",
"_executed_aql": "SELECT e/ehr_id/value FROM EHR e"
},
"q": "SELECT e/ehr_id/value FROM EHR e",
"columns": [
{ "name": "#0", "path": "/ehr_id/value" }
],
"rows": [
[ "7d44b88c-4199-4bad-9764-5da0e2a97441" ],
[ "b1e2c3d4-5678-90ab-cdef-1234567890ab" ]
]
}
Each entry in columns names the column (the AS alias, or #<index> when you
did not alias it) and its path. Each row in rows is an array of cells, one
per column in column order. A cell can be a scalar or a full RM object (for
example {"_type":"DV_TEXT","value":"Labs"}) depending on what you selected. A
stored-query execution additionally carries the query’s name.
The meta block’s _executed_aql field is the AQL the server actually ran, with
your named parameters substituted in as literals. Paste it straight back into an
ad-hoc query when debugging a parameterised call. The top-level q keeps the
text exactly as you submitted it, and _created stamps when this response was
produced.
Query responses carry a weak ETag that is a content digest of the result
set: two runs returning identical results carry the identical tag, so a client
can cheaply detect “nothing changed” between polls. The digest deliberately
covers the query, the executed AQL, the columns and the rows, and not the
per-response _created stamp, so an unchanged result does not mint a new tag
every second.
Parameters
Parameterise a query with named placeholders (a name preceded by a dollar sign)
and supply the values in query_parameters. This is the safe way to inject
values, with no string concatenation:
curl -u ferroehr:ferroehr -H 'Content-Type: application/json' -d '{
"q": "SELECT c FROM EHR e CONTAINS COMPOSITION c WHERE c/name/value = $name",
"query_parameters": { "name": "Vital signs" }
}' http://localhost:8080/ferroehr/rest/openehr/v1/query/aql
On the URL, each parameter can also be its own query-string key, which is the form the openEHR request documentation shows and is usually easier to build by hand:
curl -u ferroehr:ferroehr --get \
--data-urlencode 'q=SELECT c FROM EHR e CONTAINS COMPOSITION c WHERE c/name/value = $name' \
--data-urlencode 'name=Vital signs' \
http://localhost:8080/ferroehr/rest/openehr/v1/query/aql
Four details about the URL form: the leading $ is optional ($name and name
bind the same parameter); a value in double quotes binds the JSON string it
denotes, so uid="123" binds the text 123, as {"uid": "123"} does in a
POST body (a value that only starts with a quote keeps its text); any other
value that parses as a JSON scalar binds as that type (36 as a number, true
as a boolean) while everything else binds as text; and the reserved keys q, offset, fetch, ehr_id and query_parameters are
request controls, never parameters. Where the same name appears both as its own
key and inside query_parameters, the named key wins.
Stored queries
You can register a query once, under a qualified name and version, and execute it by name later. Storing is done through the definition API with the AQL as a plain-text body; executing is done through the query API.
# Store a query as org.example::bp_over, version 1.0.0
curl -u ferroehr:ferroehr -X PUT -i \
-H 'Content-Type: text/plain' \
--data-binary 'SELECT o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude FROM EHR e CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]' \
http://localhost:8080/ferroehr/rest/openehr/v1/definition/query/org.example::bp_over/1.0.0
# List and fetch stored queries
curl -u ferroehr:ferroehr \
http://localhost:8080/ferroehr/rest/openehr/v1/definition/query/org.example::bp_over
# Execute it
curl -u ferroehr:ferroehr \
http://localhost:8080/ferroehr/rest/openehr/v1/query/org.example::bp_over/1.0.0
Storing. Both store forms answer 200 OK with an empty body and a
Location header naming exactly the version that was written:
PUT /definition/query/{name}/{version}stores at that exact SemVer. The pair is immutable (re-storing an existing(name, version)is a 409 Conflict, never an overwrite) and a partial or malformed version segment is a 400.PUT /definition/query/{name}(no version) stores or updates at the default version1.0.0.
The body must be text/plain: declaring another media type is a 415, and an
absent Content-Type reads as the plain-text body. The AQL is parsed at store
time, so a syntactically invalid query is rejected 400; the server never
stores a query it cannot execute. An optional query_type parameter names the
formalism, default AQL (case-insensitive); anything else is rejected 400
with an honest “unsupported formalism” message rather than a misleading “invalid
AQL”.
Naming. The qualified name is [{namespace}::]{query-name}, and the
three-part {namespace}::{formalism}::{query-name} form is recognised as well.
The namespace is optional: a bare name is stored under the assumed namespace
misc, so my_compositions and misc::my_compositions are the same query and
listings show the qualified form. Identity is case-insensitive while the casing
you stored is preserved. The query-name aql is reserved (case-insensitive; it would collide with
the ad-hoc /query/aql route) and is rejected with a
400.
Reading and executing.
GET /definition/query/{name}lists the queries under that name as a prefix pattern;GET /definition/query(no name at all) lists every stored query, a FerroEHR convenience, since the openEHR API defines only the named form.GET /definition/query/{name}/{version}fetches one, by exact SemVer or by prefix (1,1.0→ the highest matching stored version).GET|POST /query/{name}executes the latest version;GET|POST /query/{name}/{version}executes that version, again by exact SemVer or prefix. Both take the sameoffset,fetch,ehr_idand named parameters as ad-hoc queries; aPOSTbody of{}executes a parameterless stored query.
Deleting a stored query is an admin operation; see Admin & messaging APIs.
Cohort queries across the pseudonymisation boundary
Some questions are about a population rather than a patient: the blood pressures of everyone in a city, the medication history of an age band, the readmissions of the people one organisation cares for. Answering them normally means someone producing a list of patient identifiers first, which is exactly the disclosure the split between clinical and demographic storage exists to avoid.
POST /ferroehr/rest/extensions/v1/query/cohort answers them without producing that list. It is a FerroEHR
extension — no openEHR spec governs it: AQL is defined over the clinical domain
and has no demographic source, and ITS-REST publishes an ad-hoc and a stored
query and nothing else.
How it runs. Three steps, each on its own database credential:
- A predicate from the deployment’s allow-list selects parties in the demographic domain. It reads one archetype leaf and returns identifiers, nothing else — no name, address or national identifier leaves that domain.
- The linkage domain resolves those parties to the EHRs they are the subject of. Identifiers in, identifiers out.
- Your AQL runs on the clinical pool, scoped to exactly those EHRs.
The three statements never meet. With [storage.party] url and [storage.linkage] url
set, each pool authenticates as a role revoked from the other two, and the
server refuses to boot if one can read across. You never receive a party
identifier: the response carries what your AQL projected, plus counts.
The allow-list. A cohort predicate can only name something the deployment
declared, under [cohort.predicates] — the surface is off until at least one is
bound, and the route answers 404 until then. Five keys are bindable:
city, postcode_area, sex, age_band and organisation. Each binding
names the archetype of the leaf’s nearest archetyped ancestor, the leaf’s
at-code, and how a value is matched. See
Privacy & data minimisation for the
keys and their defaults.
Naming subjects by identifier. With [cohort] subjects = true a request
may carry subjects, a list of { "id": …, "namespace": … } identifiers in
any identifier domain (an optional type defaults to PERSON). Each one is
resolved to its EHRs through the [ehr_index] provider, and the result
intersects with what cohort selects. Under the local map the map answers.
Under an IHE patient identity source FerroEHR asks the PIX Manager to translate
the identifier forward into its own clinical domain (ITI-83 with
targetSystem set to that domain) and finds the EHR whose EHR_STATUS
carries that pseudonym. A subject no EHR belongs to adds nothing. A subject
that cannot be resolved, because the identity source fails or no provider is
installed, fails the whole query rather than shrinking the cohort.
Under an IHE patient identity source, cohorts name their subjects. That
provider keeps no party-to-EHR map, and FerroEHR never asks it to translate a
pseudonym back into another domain (openEHR RM EHR_STATUS.subject; GDPR
Art. 4(5) and Art. 25). A demographic predicate would need one or the other,
so a request with a non-empty cohort is refused 400 there, before any
domain or the PIX Manager is asked. The cohort digest takes each subject by
its namespace alone, so the access record carries no identifier value.
Running one.
curl -u ferroehr:ferroehr -X POST \
-H 'Content-Type: application/json' \
-d '{
"q": "SELECT c/uid/value FROM EHR e CONTAINS COMPOSITION c",
"cohort": [{ "name": "city", "value": "Groningen" }],
"purpose": "secondary-use",
"fetch": 100
}' \
http://localhost:8080/ferroehr/rest/extensions/v1/query/cohort
q, offset, fetch and query_parameters mean what they mean on the ad-hoc
body. There is no ehr_id: the cohort is the scope, and supplying one is a
400. Several cohort entries intersect — city and sex, not city or
sex.
The response is an ordinary RESULT_SET with one extra block:
{
"meta": {
"_type": "RESULTSET",
"cohort": {
"size": 412,
"served_ehrs": 388,
"suppressed": false,
"definition": "9f2c…"
}
},
"rows": [["8849fd1e-…::local::1"]]
}
size is how many EHRs the cohort resolved to, served_ehrs how many the rows
actually came from, and definition a SHA-256 digest of the predicate list —
the cohort’s identity, carrying none of its values.
Small-cell suppression. A result set narrow enough to name one person
re-identifies that person out of content you hold no individual entitlement to.
When fewer EHRs are served than cohort.small_cell_threshold (default 5), the
rows are withheld and suppressed is true. The response says so rather than
looking like an empty cohort: a caller who cannot tell the two apart will keep
widening the predicate until the boundary leaks. Set the threshold to 0 to
disable suppression.
The access record. Every execution — suppressed and failed ones included —
records one linkage-domain access event naming the cohort by its digest, the
purpose you declared, and how many EHRs were served. A crossing of this boundary
that nobody can reconstruct afterwards is what the access log exists to prevent.
The predicate values are never recorded: they are the cohort, and a trail
carrying them would be a second copy of it.
Limits. A predicate matching more than cohort.max_cohort_size parties
(default 100000) is refused 422 rather than truncated — a silently shortened
cohort is a wrong denominator with nothing on the wire to say so. More named
subjects than that ceiling are refused the same way. An unbound predicate
name, a malformed age band (the form is <min>-<max> whole years, both
inclusive), a predicate under an IHE identity source, or a request with
neither a predicate nor a subject is a 400. An empty
cohort runs no AQL and answers an empty result set.
Measured. The committed record docs/benchmarks/cohort/record.json
in the repository (produced by the ignored cohort_bench test, not by the
conformance instrument) measures the whole call, predicate to result set, over
a corpus of 100 000 parties, each the subject of one EHR holding one
composition, seeded through the service API, with the database in a local
container. Selecting one composition path per EHR:
| Cohort | p50 | p95 | Demographic statement | Clinical statement |
|---|---|---|---|---|
| 100 EHRs | 71 ms | 116 ms | 54 ms | 1 ms |
| 1 000 EHRs | 102 ms | 145 ms | 51 ms | 11 ms |
| 10 000 EHRs | 543 ms | 820 ms | 64 ms | 325 ms |
| 100 000 EHRs | 2.6 s | 2.9 s | 260 ms | 702 ms |
Measured over 100 000 parties, 100 000 EHRs and 100 000 compositions at commit 54495c274ebf on a consumer-laptop (8 cores, 16 GB, nvme); the two statement columns are EXPLAIN (ANALYZE) times.
The difference between the statement times and the wall clock is result
assembly and the access records. Both statements run on indexes (idx_dem_node_archetype on the demographic side, the current-version
and EHR indexes on the clinical side); no sequential scan appears in any of the
recorded plans. The record names the commit it was measured at; a re-run
replaces it rather than appending.
Queries rooted at a demographic class
The Demographic API reads a party by its identifier and has no search. To find
a person, an organisation or a role by what it holds, such as an identifier in
its details, FerroEHR serves a subset of the experimental AQL demographic
extension on the same POST /query/aql and GET /query/aql routes. No openEHR
specification governs it: QUERY 1.1 lets FROM name any Reference Model class
but gives semantics only to the clinical repository, so everything below is
FerroEHR’s own design, and the extension may change.
SELECT p/uid/value
FROM PERSON p
WHERE p/details[at0001]/items[at0002]/value/id = $identifier
ORDER BY p/uid/value
The grammar served. The query is ordinary AQL 1.1 syntax with these rules on
the FROM clause:
- The outermost class is
PERSON,ORGANISATIONorROLE, optionally under aVERSIONclass. It is the only demographic class at the root of the query. - Below it,
CONTAINSmay name the content classes a party holds:CLUSTER,ELEMENT, the item structures, and the party containersPARTY_IDENTITY,CONTACT,ADDRESSandCAPABILITY, with archetype and node predicates as usual, for exampleCONTAINS CLUSTER c[openEHR-DEMOGRAPHIC-CLUSTER.person_identifier.v2]. SELECT,WHEREandORDER BYtake paths inside the party: itsdetailstree, itsidentities,contactsandcapabilities,p/uid/value, and the whole party (SELECT p). Named parameters,LIMIT/OFFSET, the RESToffsetandfetch, and the functions and aggregates of the clinical envelope apply unchanged.
What a row is. The query reads the latest trunk version of each party. A
logically deleted party, and a party under a restriction of processing, answers
no row. p/uid/value is the version identifier the Demographic API returns.
What is refused. Each of these is a 400 Bad Request naming the construct, so you can tell a refusal from an empty result:
GROUP,AGENT,PARTY_RELATIONSHIP, and the abstractPARTYandACTOR;- a demographic class anywhere but the root, or a second one;
- a query that names
EHRor a clinical versioned object such asCOMPOSITIONanywhere but directly under the root (see the join below); - an
ehr_idscope (query parameter oropenehr-ehr-idheader), because a party is in no EHR.
Joining a party to its EHR. An EHR operand directly under the root
reaches the EHR the party is the subject of:
SELECT p/uid/value, c/name/value
FROM PERSON p CONTAINS EHR e CONTAINS COMPOSITION c[openEHR-EHR-COMPOSITION.encounter.v1]
WHERE p/details[at0001]/items[at0002]/value/id = $identifier
ORDER BY c/context/start_time/value DESC
A party is the subject of an EHR when either of these holds:
- the EHR’s current
EHR_STATUS.subjectis aPARTY_SELFwhoseexternal_refnames the party: itsid.valueis the party’s uid without::<system>::<version>, and itstypeis the root class or one of its abstract RM ancestors (ACTORforPERSONandORGANISATION,PARTYfor all three). This is the referencePOST /ehrstores, so an EHR created with that subject joins with no other call; - the linkage service’s party-to-EHR map holds the pair.
An EHR that both name answers once. The namespace of the reference is not
compared: BASE OBJECT_REF.namespace takes any name a deployment chooses
(local, demographic, or your own), and the id and the class already name
one party. A deployment that pseudonymises the subject stores a pseudonym in
id.value, and a pseudonym is not a party uid, so its EHRs join only through
the map.
Party classes may sit beside the EHR in an AND
(PERSON p CONTAINS (CLUSTER id[…] AND EHR e CONTAINS COMPOSITION c)). A row
is a party row together with a clinical row of an EHR that party is the subject
of, so a party with no EHR, and an EHR whose subject has no party, answer no
row. FerroEHR runs the query as four statements, each on one database
credential: the party half, the map, the EHRs whose subject names
those parties, and the clinical half scoped to the EHRs the two reads name. It
then joins, orders and pages the rows. The rules that follow:
- each column, each
WHEREcondition joined byAND, and eachORDER BYkey reads one side only, the party or the EHR; a condition comparing the two, or anORacross them, is a 400; - aggregates and
SELECT DISTINCTare a 400; - each half, and the joined rows, may hold at most
query.max_result_rowsrows; above that the query is a 400 asking you to narrow it, never a shortened answer; - the clinical half carries your per-EHR access decision and patient scope as any query does, and the party read and the crossing to the EHRs each leave an access record in their own domain.
Who may run it. The query runs under the authorisation of a Demographic API
read: the role check that admits a party read admits the query, and the per-EHR
access decision and the attribute-based patient scope, which govern EHR
content, do not apply to parties. The SMART aql-… scopes that govern every
query still apply. The query runs on the demographic database credential, and
its access record is filed under the demographic domain with the number of rows
served.
Version scope: LATEST_VERSION and ALL_VERSIONS
By default a query sees the latest version of each object. FerroEHR also
supports querying the entire version history, a capability many CDRs lack.
Wrap a source in VERSION and choose the scope:
SELECT v/commit_audit/time_committed, c/name/value
FROM EHR e
CONTAINS VERSION v[ALL_VERSIONS]
CONTAINS COMPOSITION c
LATEST_VERSION (the default) reads only current trunk versions;
ALL_VERSIONS reads across history (including branch versions, where a version
tree has any) so you can see how a record changed over time. A version
predicate on commit_audit/time_committed reads the trunk version current at
that instant. The VERSION variable also exposes commit metadata: the audit, the
committed time, and the version uid.
A VERSIONED_OBJECT operand between the EHR and the version names the
versioned object itself. Its variable answers uid/value, the object id every
version of it shares, and owner_id/id/value, the EHR that owns it; any other
path on it is refused. It sets no version scope of its own: the VERSION
below it does.
SELECT vo/uid/value, v/uid/value, c/name/value
FROM EHR e
CONTAINS VERSIONED_OBJECT vo
CONTAINS VERSION v[ALL_VERSIONS]
CONTAINS COMPOSITION c
Terminology in queries
Value filters can be backed by terminology in three ways:
TERMINOLOGY('expand', …)as (or inside) amatchesoperand expands a value set so a coded field matches any code in it, rather than listing codes by hand;TERMINOLOGY('validate'|'subsumes', …) = trueas a boolean condition evaluates a code-membership or subsumption test once per query;- a terminology URI operand (
matches { terminology://… }) expands the set the URI identifies.
These require a terminology source; if no terminology server is configured, the in-process openEHR bundle is used. See Terminology servers for wiring the FHIR terminology server.
Two rejections to expect here: only expand may stand as a matches operand
(the other operations have no value-list meaning), and only validate and
subsumes have boolean semantics, so TERMINOLOGY('lookup', …) = true is
refused rather than guessed at. A value set the configured server does not know
is a 400 naming it; a terminology server that fails mid-query is a 500,
kept distinct from a bad query on purpose.
Pagination and limits
Combine LIMIT/OFFSET in the AQL with the fetch/offset request parameters
to page through large result sets. Three bounds interact, and it is worth knowing
which one you hit:
| Bound | Where it comes from | Effect |
|---|---|---|
LIMIT/OFFSET, or fetch/offset | your query or request | Exactly the window you asked for. |
query.max_result_rows | server config, default 10000 | The largest page one execution serves: the page of a query nothing else bounds, and the maximum a LIMIT or fetch may ask for. 0 means unbounded. |
query.timeout_ms | server config, default 30000 | Per-query database execution budget. On by default; 0 disables it. |
Two bounds on the shape of the query text are fixed in the server. Brackets
and CONTAINS may nest at most 64 levels, and a WHERE expression may be at
most 512 levels deep, where each AND, OR, NOT, CONTAINS and bracket on
a path counts one level. A query beyond either returns 400 Bad Request
naming the bound, and a stored query beyond either is refused when you store
it. AND and OR chain one level per operator, so a long list of
alternatives over one path belongs in MATCHES {…}, which the bound does not
count:
WHERE c/name/value MATCHES {'Blood pressure', 'Pulse', 'Body weight'}
A query that exceeds the time budget returns 408 Request Timeout; narrow it
(add archetype constraints, an ehr_id scope, or a WHERE filter) rather than
retrying it unchanged. A LIMIT or fetch larger than the row ceiling returns
400 Bad Request naming the ceiling; the page is never silently shortened,
because a client paging with its own fetch as the stride would skip rows
without noticing. Page with offset and a fetch at or below the ceiling. A
result that stops at the ceiling without either bound set is the default page:
page explicitly to read past it.
Always order a query you intend to page
OFFSET skips rows of a result, and without ORDER BY a result has no defined
row order. Two requests for consecutive pages can then repeat a row or miss one
entirely, and nothing in the response says so. Give any query you page an
ORDER BY on something unique, such as c/uid/value.
Ordering also changes what paging costs, in the direction that helps you. An
ordered query sorts the whole matched set to answer any page, so the first page
already pays most of the work and a deep page costs little more than a shallow
one. An unordered query skips rows one at a time, so its cost grows with the
offset, from almost nothing on page one. Either way what dominates is how much
your FROM/CONTAINS matched, not how deep you paged.
One more reason to order a paged query, if the deployment runs attribute-based authorization: the authorization decision is made over every EHR and template the query would touch, not over the page it served, so that set is collected whatever the page size. An ordered query pays nothing extra for it, because it was producing the whole matched set anyway. An unordered page that looks cheap carries the whole collection on top of it. Narrow the query, and order it.
Tip
The more specific your
FROM/CONTAINS(name the archetype, scope byehr_id), the faster the query: those constraints map to indexed columns, while broad “everything that contains anything” queries do the most work. Repeated identical query text reuses a cached plan, so parameterising a recurring query beats rewriting its literals.
What is supported
FerroEHR implements the core AQL 1.1 envelope and rejects out-of-envelope constructs with an explicit, typed error rather than silently returning wrong results. Supported today includes:
SELECTof paths, literals, aliases,DISTINCT, and the aggregatesCOUNT(includingCOUNT(DISTINCT)),MIN,MAX,SUM,AVG.MINandMAXorder their operand by type (a quantity by its openEHR magnitude, a date/time chronologically, text lexically) so they work over non-numeric leaves;FROMoverEHR,VERSIONED_OBJECT(itsuidandowner_id),VERSION(LATEST_VERSION/ALL_VERSIONS), and the RM structure classes, with archetype and name predicates, and overPERSON,ORGANISATIONandROLEas the experimental demographic extension;CONTAINStrees withAND,OR, andNOT CONTAINS;WHEREcomparisons on typed leaves (with openEHR magnitude ordering for quantities),EXISTS,LIKE,MATCHESvalue lists, and range predicates;ORDER BYtyped leaves,LIMIT/OFFSET,TOP n, named query parameters, and theehr_id,offset, andfetchrequest parameters;- the single-row functions:
LENGTH,SUBSTRING,POSITION, the stringCONTAINS,CONCAT/CONCAT_WS,ABS,MOD,CEIL,FLOOR,ROUND, andCURRENT_DATE/CURRENT_TIME/CURRENT_DATE_TIME/NOW/CURRENT_TIMEZONE; - terminology-backed
TERMINOLOGY()operands and terminology-URImatchesoperands (see above).
What is refused, and why
Every refusal below is a typed error naming the construct, so you never get a silently incorrect answer:
- Demographic forms outside the served subset.
GROUP,AGENT,PARTY_RELATIONSHIP,PARTYandACTOR, a demographic class below the root, and a query mixing a demographic root withEHRor clinical content (see Queries rooted at a demographic class). TOP … BACKWARD.TOPis deprecated as of AQL 1.1.0 and the direction variant is not implemented; the error carries the specification’s own rewrite (ORDER BY <path> DESC LIMIT n).TOPandLIMITin one query are also refused; pick one.- Regex and
ORnode predicates.[{/…/}]is archetype-definition syntax, not AQL value matching, and a disjunctive node predicate is outside the accepted subset. - Branch version addressing. A version predicate may address trunk versions;
reading branch content is done by scoping
ALL_VERSIONS, not by naming a branch. - Reading a variable declared under
NOT CONTAINS.NOT CONTAINSstates that no such node exists, so a path through its variable has nothing to read. SELECT DISTINCTordered by an unselected expression. De-duplication and sorting by something the projection dropped have no defined meaning together; sort by a selected column.- Analysis failures, which are precise: an unknown RM
class or attribute, an unbound
$parameter, a duplicateFROMvariable name,LIMIT 0, a negativeOFFSET, wrong function arity,SUM/AVGover a non-numeric path, and anarchetype_node_idcriterion that is neither an archetype identifier nor a node code. Variable names are case-insensitive, as the specification requires.
The specification generation is an acceptance boundary
Which Reference Model classes and attributes a query may name depends on the
deployment’s spec_profile. On the
default development profile the query surface is the full RM 1.2.0 model. On
stable (RM 1.1.0), a FROM class or a path attribute that only a newer
generation defines is refused with 400 Bad Request, and the message names
both the offending class or attribute and the active profile. A server of that
generation would answer “unknown”, so returning rows instead would silently
overclaim the profile the deployment advertises.
Nothing else about querying changes with the profile: paths, predicates, and the result-set shape are identical.