Tutorial 3 — querying with SQL
The point of relational storage is that you can use SQL. Not JSON path expressions dressed up as SQL — actual columns, actual joins, actual statistics for the planner.
Assumes tutorial 2.
The shape you are querying#
patient one row per resource
patient_name one row per name (rid, ords)
patient_name_given one row per given name (rid, ords)
patient_telecom one row per telecom (rid, ords)
patient_ext one row per extension leaf (rid, path, ords, …)
patient_history one row per version
Every child table has rid (the resource id) and ords (the position path).
Join on rid; filter on ords when you want a specific instance.
Basics#
-- Everyone born in 1974, using the derived sort column
SELECT id, birth_date
FROM patient
WHERE birth_date_sort >= DATE '1974-01-01'
AND birth_date_sort < DATE '1975-01-01';
Note birth_date_sort, not birth_date. The stored column holds the lexical
form — possibly "1974", possibly "1974-12-25" — and the derived column is
the typed, indexed one. Filter on the derived; display the stored.
-- Primary family names
SELECT p.id, n.family
FROM patient p
JOIN patient_name n ON n.rid = p.id AND n.ords = '{1}'
WHERE p.active;
ords = '{1}' is "the first name". This is the idiom you will use most.
Joining across resources#
References are already parsed into columns, so this is an ordinary join:
SELECT n.family, count(o.id) AS observations
FROM patient p
JOIN patient_name n
ON n.rid = p.id AND n.ords = '{1}'
LEFT JOIN observation o
ON o.subject_ref_type = 'Patient'
AND o.subject_ref_id = p.id
GROUP BY n.family
ORDER BY observations DESC
LIMIT 20;
There is no foreign key behind that join (M3.10) — FHIR permits dangling
references, so enforcing one would make load order matter and reject real data.
The join still uses the index on (ref_type, ref_id) that the generator emits
for every reference column (P6.4).
Nested repeating elements#
-- All given names of every patient's first name, in order
SELECT g.rid, g.ords, g.value
FROM patient_name_given g
WHERE g.ords LIKE '{1,%'
ORDER BY g.rid, g.ords;
Read {1,2} as "second given name of the first name". The path is the ordering
information, which is why array order survives a table where row order does not.
The one idiom that differs per engine#
PostgreSQL stores ords as smallint[], so it supports subscripting:
-- PostgreSQL only
SELECT * FROM patient_name_given WHERE ords[1] = 1;
Every other port stores the array literal as TEXT (M3.4b), where the
equality form works verbatim but the subscript does not. The portable
equivalent:
-- everywhere
SELECT * FROM patient_name_given WHERE ords LIKE '{1,%';
That is a prefix match on the text image rather than a typed subscript, and it
is the only place in the whole schema where the difference is visible to a query
author (M14.9 in the SQLite annex). If you want your SQL to run on all six
engines, use the LIKE form.
Case- and accent-insensitive matching#
Do not write LOWER() or UNACCENT(). Use the _norm columns:
SELECT rid FROM patient_name WHERE family_norm = 'aero'; -- finds 'Ærø'
The value in family_norm was folded in Rust at write time (P6.6), and the
fold is the system's single definition of "the same string". Rolling your own in
SQL gives you a second definition that must agree with the first for every
codepoint in Unicode, and it will not.
For a prefix search, use a range, not LIKE:
SELECT rid FROM patient_name
WHERE family_norm >= 'smit' AND family_norm < 'smiu';
LIKE $1 || '%' looks equivalent and is not. A planner extracts a prefix from a
constant pattern only, so a LIKE against a bound parameter degrades to a
sequential scan in the generic plan — while looking perfectly indexed in any
hand-run EXPLAIN with a literal (P6.6a).
Extensions#
-- Everyone carrying the US Core race extension
SELECT DISTINCT rid
FROM patient_ext
WHERE url = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-race';
-- Its text value
SELECT rid, v_text
FROM patient_ext
WHERE url = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-race'
AND leaf = 'extension.1.valueString';
url is denormalized onto every leaf row precisely so this query is a simple
one (M3.11). leaf addresses the scalar inside the extension's content;
all-digit segments are 0-based array indexes.
Numeric extension values are in v_num (queryable) and v_text (lexical):
SELECT rid, v_num FROM observation_ext
WHERE url = '…/some-numeric-ext' AND v_kind = 'n' AND v_num > 100;
History#
SELECT version_id, op, last_updated, actor, actor_source, reason
FROM patient_history
WHERE id = 'example'
ORDER BY version_id;
op is C, U, or D. The resource column holds the whole version as text.
The audit envelope columns (actor, actor_source, client, request_id,
reason) were written by the same statement that appended the row, in the same
transaction as the data change (M3.15) — an audit record that can be lost
independently of the change it describes is not an audit record.
Do not UPDATE or DELETE these tables. A trigger raises an exception
(M3.17), and it is there so that escaping append-only is a deliberate DBA act
rather than an application bug.
Reads must be one snapshot#
If you are reconstructing a resource by hand rather than through get, wrap
the reads in one transaction at an isolation level that gives a stable snapshot
(R4.5):
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY; -- PostgreSQL
SELECT … FROM patient WHERE id = 'example';
SELECT … FROM patient_name WHERE rid = 'example';
SELECT … FROM patient_name_given WHERE rid = 'example';
COMMIT;
Issued as independent statements, a concurrent write between them reconstructs a
resource that never existed — base columns from one version, child rows from
the next. This is a correctness requirement, not a tuning knob, and READ COMMITTED (the default on several engines) is exactly the failure mode.
Names differ: REPEATABLE READ READ ONLY on PostgreSQL, REPEATABLE READ on
MySQL and MariaDB, SNAPSHOT on SQL Server (which must be enabled on the
database), BEGIN DEFERRED under WAL on SQLite.
Performance notes#
The generator emits indexes for every base-table search column, every child
table's (rid, ords), every reference (ref_type, ref_id) pair, and every
token (system, code) pair (P6.4). If your query is slow, check whether it is
filtering on a _sort or _norm column — the typed and folded ones are the
indexed ones.
Where an engine cannot index a column as bound — SQL Server's NVARCHAR(MAX)
token columns, for instance — the port is required to narrow the type or add an
indexable derived column rather than silently drop the index and scan
(P6.4a). SQL Server's is an open item.
Next#
- Tutorial 4 — FHIR search, which compiles to exactly this kind of SQL, generated
- Examples for more recipes