Was Interoperable Analytics bedeutet
Es gibt zwei ausgereifte Welten, die kaum miteinander kommunizieren.
Auf der einen Seite FHIR. Wir haben inzwischen sehr viele FHIR-Daten — die große Mehrheit der US-Krankenhäuser stellt FHIR-APIs bereit, der Anteil wächst jedes Jahr, während FHIR-first-Systeme klinische Daten nativ in Ressourcen speichern. Auf der anderen Seite ein sehr ausgereiftes Ökosystem analytischer Werkzeuge: moderne Datenbanken, BI-Plattformen, Dataframes — der gesamte moderne Data Stack. Dies ist die Interoperabilitätslücke, die FHIR-Analytics schließen muss — und bisher hat sie jeder privat, in maßgeschneidertem ETL, geschlossen.
Diese Daten lassen sich bereits in solche Werkzeuge laden — moderne Datenbanken verarbeiten verschachtelte Daten gut: Postgres mit Binary JSON, BigQuery, Spark, DuckDB. Tatsächlich war die erste Version von SQL on FHIR genau das: verschachteltes FHIR direkt in der Datenbank abfragen. Es funktionierte — und es standardisierte nichts. Jede Engine hat ihren eigenen Dialekt für verschachtelte Daten; eine für BigQuery geschriebene Abfrage hat nichts gemein mit derselben Abfrage in Postgres. Nichts zu standardisieren.
Darunter liegt das alte objekt-relationale Impedance-Mismatch-Problem: FHIR-Ressourcen sind verschachtelte Dokumente, und die Analysewelt — SQL, BI-Tools, Dataframes — will nach wie vor flache Tabellen. Die naive Lösung, eine Tabelle für jedes FHIR-Element zu generieren, funktioniert schlicht nicht: Man erhält tausend Tabellen, die niemand mehr überblickt. Also schreibt jeder use-case-spezifisches ETL, um Ressourcen zu flachen — wiederholt dieselbe Arbeit, leicht abgewandelt, mit denselben subtilen Fehlern rund um Arrays, Choice-Types und Referenzen.
SQL on FHIR Version zwei ist unser Versuch, eine standardisierte Brücke zwischen diesen beiden Welten zu bauen — diesmal durch die Standardisierung der flachen Views statt der verschachtelten Abfragen. Es begann als Community-Entwurf; heute ist es eine vollständige Spezifikation, die offen entwickelt und in den HL7-Ballot-Prozess aufgenommen wurde, unter CC0 veröffentlicht — mit einem peer-reviewten Artikel in npj Digital Medicine, der den Ansatz an ~300.000 Patienten validiert und eine publizierte klinische Studie auf zwei unabhängigen Implementierungs-Stacks repliziert. Und in FHIR R6 ist ViewDefinition auf dem Weg, eine Standard-FHIR-Ressource zu werden — eine Additional Resource, R6s Mechanismus zur modularen Erweiterung der Kernspezifikation.
Was der Standard wirklich bringt, ist Trennung: Authoring ist von der Implementierung getrennt, und Implementierung von der Nutzung. Eine Person beschreibt die Analytics; jede beliebige Engine führt sie aus; jedes Werkzeug konsumiert das Ergebnis.
Und es sind nicht mehr nur View-Definitionen, die man schreibt. Sie beschreiben Ihre gesamte Analytics als portable, standardisierte Artefakte — flache Views, die darüber liegenden Transformationsschichten, Abfragen, komplette Data Marts und Konversionspipelines. Führen Sie diese auf verschiedenen Engines aus, über verschiedene Datensätze, gegen Server verschiedener Anbieter — und verteilen Sie das Ganze sogar als Implementation Guide, wie jedes andere Stück des FHIR-Ökosystems. Das ist es, was „Interoperable Analytics" bedeutet, und die Spezifikation liefert es jetzt auf drei Ebenen: Views, Queries und die API.
ViewDefinition: eine Sprache zum Flachklopfen
Eine ViewDefinition ist, vereinfacht gesagt, eine Sprache, mit der man einem Server erklärt, wie Ressourcen in eine flache Tabelle umzuwandeln sind. Sie beschreibt eine tabellarische Sicht auf genau einen Ressourcentyp — Spalten, Filter und Entschachtelung — unter Verwendung einer minimalen FHIRPath-Teilmenge:
{
"resourceType": "ViewDefinition",
"resource": "Patient",
"name": "patient_demographics",
"select": [
{
"column": [
{"name": "patient_id", "path": "getResourceKey()"},
{"name": "gender", "path": "gender"},
{"name": "dob", "path": "birthDate"},
{"name": "active", "path": "active", "type": "boolean"}
]
},
{
"forEach": "name.where(use = 'official').first()",
"column": [
{"path": "given.join(' ')", "name": "given_name"},
{"path": "family", "name": "family_name"}
]
}
]
}
Da die Definition deklarativ und engine-neutral ist, läuft dieselbe View auf einem JavaScript-ETL-Runner, innerhalb von PostgreSQL, auf Spark oder über einen Stapel ndjson-Dateien aus einem Bulk Export. Das gesamte Modell reduziert sich auf fünf kombinierbare Funktionen, und ein vollständiger Runner ist klein genug, um ihn an einem Nachmittag zu lesen — die JavaScript-Referenzimplementierung umfasst rund 400 Zeilen:
| Funktion | Was sie tut | SQL-Analogie |
|---|---|---|
column | Elemente per FHIRPath in Spalten extrahieren | SELECT |
where | Ressourcen filtern (z. B. nach Profil oder Status) | WHERE |
forEach / forEachOrNull | Eine Collection in Zeilen entschachteln | INNER JOIN / LEFT OUTER JOIN gegen eine verschachtelte Tabelle |
select | Übergeordnete Spalten mit entschachtelten Zeilen kreuzverbinden | Join von Subselects |
unionAll | Zeilen aus verschiedenen Zweigen zusammenführen | UNION ALL |
Ein ehrlicher Hinweis vorab: Dieses Flachklopfen ist verlustbehaftet, und das ist uns bewusst. Es gibt keine universelle tabellarische Darstellung von FHIR, daher sind Views by Design use-case-spezifisch — ich scherze manchmal, dass eine ViewDefinition der CSV-Modus von FHIR ist. Das ist keine Schwäche; es ist der Vertrag: Ein Ingenieur definiert die View einmal, alle anderen Ingenieure und Werkzeuge nutzen einfach die flache Tabelle.
Eine natürliche Einheit für eine View ist ein Profil. Stellen Sie sich ein Blutdruckprofil vor — und darüber eine schöne blood_pressure-Tabelle mit den Spalten systolic und diastolic, eine Zeile pro Messung. Das Profil legt fest, wo die Daten in der Ressource liegen; die View überführt dieses Wissen in Spalten. Jedes wohlgeformte Profil ist eine flache Tabelle, die nur noch deklariert werden muss.
Zwei neuere Ergänzungen sind erwähnenswert:
repeatverarbeitet wirklich rekursive Strukturen — QuestionnaireResponse-Items, CodeSystem-Konzepte — bei denen die Tiefe nicht im Voraus bekannt ist. Geben Sie die zu durchlaufenden Pfade an, und jedes verschachtelte Element wird zu einer Zeile, unabhängig von seiner Ebene.%rowIndexerfasst die Position eines Elements während der Iteration. SQL-Ergebnismengen sind ungeordnet, FHIR-Arrays aber nicht — der erstenameist nicht dasselbe wie der dritte. Der Index ermöglicht es, die FHIR-Reihenfolge beizubehalten und Surrogatschlüssel zu erstellen.
Joins ohne Joins
Eine einzelne ViewDefinition verbindet niemals Ressourcen — by Design. Stattdessen geben zwei Funktionen Schlüssel aus, über die Ihre Datenbank dann joined: getResourceKey() für den Primärschlüssel der Zeile und subject.getReferenceKey(Patient) für den Fremdschlüssel (mit einem Typfilter, der eine leere Collection — ein Null im Output — zurückgibt, wenn die Referenz auf etwas anderes zeigt). Eine Condition-View enthält patient_id; das Verbinden von Conditions mit Patienten ist ein einfacher SQL-Join in der Engine Ihrer Wahl.
Wie Schlüssel tatsächlich abgeleitet werden — einfache id, primäre Kennung, Hash — bleibt der Implementierung überlassen. Das ist bewusst so: Es macht dieselbe View auf Systemen mit unterschiedlichen Dateninvarianten portabel.
Und was ViewDefinition bewusst nicht tut — keine ressourcenübergreifenden Joins, keine Aggregation, keine Sortierung, keine Ausgabeformate — sind keine Lücken. Jede dieser Entscheidungen war ein Trade-off, den wir diskutiert haben: Sollen wir jeden View-Runner komplizierter machen oder an die Datenbank delegieren? Datenbanken sind auf Joins spezialisiert; wir überschreiten die Ressourcengrenze nicht. Diese Zurückhaltung ist es, die einen Runner überall implementierbar hält — von einer 400-Zeilen-Bibliothek bis hin zu einer verteilten SQL-Engine.
Runner selbst gibt es in zwei Varianten. ETL-Runner nehmen einen Stream von Ressourcen und erzeugen Zeilen — trivial zu schreiben, wenn Sie eine FHIRPath-Engine haben (Implementierer haben berichtet, einen in Rust in etwa einem Monat geschrieben zu haben). ELT-Runner ähneln eher Transpilern als Runnern: Sie kompilieren eine ViewDefinition in eine ausgeklügelte SQL-Abfrage über eine FHIR-native Datenbank, sodass die View zu einer echten Datenbankview wird und nichts dupliziert wird — Hunderte von View-Definitionen können über derselben Ressourcentabelle laufen.
Die Spezifikation wurde von unten nach oben gebaut, nicht von oben nach unten: Implementierungen existierten vor dem ersten Release. Eine gemeinsame Test-Suite ist Teil der Spezifikation — Datensatz, View-Definition, erwartetes Ergebnis — und die Konformanzmatrix entsteht dadurch, dass Implementierer dieselben Tests ausführen und ihre Ergebnisse zurückmelden. So stellen wir sicher, dass die Implementierungen miteinander kompatibel bleiben.
SQLQuery und SQLView: Abfragen werden teilbar
Flache Tabellen sind die halbe Brücke. Die andere Hälfte: Wo leben die Abfragen? Die Kohortendefiniton, das Qualitätsmerkmal, die Dashboard-Abfrage — das sind die am wenigsten portablen Artefakte in der heutigen Healthcare-Analytics, verstreut über Wikis und Notebooks, an das Schema einer einzigen Site gebunden.
Daher haben wir die View-Definition mit einer Abfrage-Ressource kombiniert, und jetzt können Sie das Ganze teilen. SQLQuery ist ein Profil auf der FHIR-Library-Ressource, das eine einzelne logische SQL-Abfrage verpackt:
{
"resourceType": "Library",
"meta": {"profile": ["https://sql-on-fhir.org/ig/StructureDefinition/SQLQuery"]},
"type": {"coding": [{"system": "https://sql-on-fhir.org/ig/CodeSystem/LibraryTypesCodes", "code": "sql-query"}]},
"name": "DiagnosisByAgeSummary",
"status": "active",
"relatedArtifact": [
{"type": "depends-on", "resource": "https://example.org/ViewDefinition/patient_demographics", "label": "pt"},
{"type": "depends-on", "resource": "https://example.org/ViewDefinition/diagnoses_view", "label": "dg"}
],
"parameter": [
{"name": "from_date", "type": "date", "use": "in"}
],
"content": [{
"contentType": "application/sql",
"extension": [{
"url": "https://sql-on-fhir.org/ig/StructureDefinition/sql-text",
"valueString": "SELECT pt.gender, dg.code, count(*) FROM pt JOIN dg USING (patient_id) WHERE dg.onset >= :from_date GROUP BY 1, 2"
}],
"data": "..."
}]
}
Die Struktur lässt sich auf drei Ideen herunterbrechen:
- Abhängigkeiten als Aliase. Sie deklarieren, von welchen View-Definitionen die Abfrage abhängt, und das
labelwird zum Tabellennamen in Ihrem SQL. Die Abfrage enthält niemals physische Tabellennamen — die Ausführungsumgebung löstptunddgzu dem auf, was die Views materialisiert als vorliegen. - Sichere Parameter. Parameter werden in der Library deklariert und als
:from_date-Platzhalter referenziert. Die Spezifikation ist unmissverständlich: String-Interpolation DARF NICHT verwendet werden — ausschließlich echtes Binding. - Dialektvarianten. Eine Library kann ein portables
application/sql-Standard-Attachment sowie;dialect=postgresql- oder;dialect=spark-Attachments tragen, sofern diese funktional äquivalent sind.
Für Werkzeuge beschreibt die Spezifikation auch eine Authoring-Vereinfachung: Schreiben Sie eine einfache .sql-Datei mit einigen Annotationskommentaren (@name, @param, @relatedDependency), und ein Builder wandelt sie in die Library-Ressource um. SQL bleibt die Quelle der Wahrheit; FHIR wird zur Verpackung.
SQLView ist das neueste Profil und existiert aus einem einzigen Grund: damit Abfragen aufeinandergestapelt werden können. Es ist SQLQuery sehr ähnlich, aber ohne Parameter — und die Intention ist eine andere. Eine Abfrage ist etwas, das man ausführt; eine View beschreibt eine Tabelle. Wenn man sagt „Ich brauche eine View in einer Datenbank", weiß jeder, worum es geht — deshalb ist es ein eigenes Profil und nicht bloß ein Flag auf SQLQuery.
Wie SQLViews sich stapeln
Ein SQLView ist eine Library mit type = sql-view, einer kanonischen URL, Abhängigkeiten und SQL. Hier ist eine, die „aktive Patienten" auf Basis der ViewDefinition patient_demographics definiert:
{
"resourceType": "Library",
"meta": {"profile": ["https://sql-on-fhir.org/ig/StructureDefinition/SQLView"]},
"type": {"coding": [{"system": "https://sql-on-fhir.org/ig/CodeSystem/LibraryTypesCodes", "code": "sql-view"}]},
"url": "https://example.org/Library/ActivePatientsView",
"name": "ActivePatientsView",
"status": "active",
"relatedArtifact": [
{"type": "depends-on", "resource": "https://example.org/ViewDefinition/patient_demographics", "label": "pt"}
],
"content": [{
"contentType": "application/sql",
"extension": [{
"url": "https://sql-on-fhir.org/ig/StructureDefinition/sql-text",
"valueString": "SELECT patient_id, gender, dob FROM pt WHERE active = true"
}],
"data": "..."
}]
}
Der entscheidende Teil ist die url. Weil die View eine kanonische URL hat, kann alles nun genauso davon abhängen, wie es von einer ViewDefinition abhängen würde — einfach einen relatedArtifact-Eintrag hinzufügen und das label als Tabellenname verwenden. Eine zweite View baut auf der ersten auf:
-- SQLView: DiabeticPatientsView
-- depends on: .../Library/ActivePatientsView as ap
-- depends on: .../ViewDefinition/diagnoses_view as dg
SELECT ap.patient_id, ap.gender, ap.dob, dg.onset
FROM ap
JOIN dg USING (patient_id)
WHERE dg.code = '44054006' -- Typ-2-Diabetes (SNOMED)
Und eine parametrisierte SQLQuery sitzt auf beiden Schichten für den abschließenden Bericht. Die Abhängigkeitsregeln sind einfach:
- Ein SQLView darf von ViewDefinitions und anderen SQLViews abhängen — niemals von SQLQueries.
- Eine SQLQuery darf von ViewDefinitions und SQLViews abhängen.
- Die Referenzen bilden einen gerichteten Graphen, der zyklenfrei bleiben muss — wie Views in jeder Datenbank.
Stapeln Sie genug davon, und Sie haben ein vollständiges Analysesystem beschrieben — in denselben Begriffen, die ein Data-Team für jedes Warehouse verwenden würde. ViewDefinitions sind die Staging-Schicht: Rohes FHIR als flache Tabellen gelandet. SQLViews sind die Zwischenmodelle: bereinigte, gefilterte, verknüpfte Bausteine. Die Spitze des Graphen ist der Data Mart: die Kohorten-Register, Faktentabellen und parametrisierten Berichte, die Analysten tatsächlich verwenden.
Jeder Knoten ist klein, lesbar und für sich allein testbar: Staging-Views formen nur um, jedes Zwischenmodell fügt einen Transformationsschritt hinzu, Queries parametrisieren nur den abschließenden Schnitt. Die Komplexität liegt in der Komposition, nicht in einem einzelnen Artefakt — genau so, wie ausgereifte Analysesysteme heute in Warehouses gebaut werden. Es gibt hier keine Obergrenze: Ein Krankheitsregister, eine Qualitätsmerkmal-Suite, ein dimensionales Modell mit Fakten und Dimensionen, eine Konvertierung mit hundert Tabellen — alles, was sich als geschichtetes SQL über flachen Views ausdrücken lässt, lässt sich als dieser Graph ausdrücken.
Zwei Boni ergeben sich dabei kostenlos. Der Abhängigkeitsgraph ist Ihre Data Lineage: Für jede Spalte im Mart können Sie artefaktweise den Weg zurück zum FHIRPath-Ausdruck nachverfolgen, der sie erzeugt hat. Und der gesamte Graph wird als FHIR-Ressourcen ausgeliefert.
Was die Spezifikation bewusst nicht vorschreibt, ist die Ausführung: ob ActivePatientsView zu einem eingebetteten CTE, einer Datenbankview oder einer materialisierten Tabelle wird, entscheidet die Engine — der Graph beschreibt die Logik, die Engine wählt die physische Umsetzung.
Eine vollständige DSL für ELT
Sagen Sie einem Data-Engineer „wir haben hier einen DAG" — und er versteht sofort. Der Graph, den Sie gerade gesehen haben, ist ELT, das dominante Muster des modernen Data Stacks: Rohdaten zuerst laden, sie dann in Schichten innerhalb der Engine transformieren. Wenn Sie dbt kennen, kennen Sie diese Form bereits. In dem Moment, in dem wir die Möglichkeit hinzugefügt haben, Abfragen auf Abfragen aufzubauen, wurde SQL on FHIR zu einer vollständigen DSL zur Beschreibung von ELT-Pipelines: ViewDefinition liefert das EL — FHIR extrahieren, flache Tabellen laden — und SQLView mit SQLQuery fügt das T hinzu. Der Kreis ist geschlossen.
Was ist also wirklich neu im Vergleich zu den ELT-Werkzeugen, die Data-Teams bereits einsetzen? Das Distributionsmodell. Ein Transformationsprojekt ist normalerweise Code in Ihrem Repository, der Ihr Warehouse voraussetzt. Eine SQL on FHIR-Pipeline ist ein Satz von Ressourcen mit kanonischen URLs, die Sie in einem Implementation Guide veröffentlichen, versionieren und gegen jeden konformen Stack ausführen können. Und weil die Artefakte technologieneutral sind, werden Menschen Übersetzer von ihnen in stack-spezifische Assets schreiben — eine Warehouse-View, einen Schritt in Ihrem Orchestrator, ein Modell in dem Transformations-Framework, das Ihr Team einsetzt. Wir beschreiben den Data Mart einmal; die Zieltechnologie ist ein Kompilierungsdetail.
Das vervollständigt auch die Authoring-Geschichte. Nehmen Sie das Blutdruckbeispiel von früher — das Profil mit seiner systolic/diastolic-View — und fügen Sie jetzt einen Satz nützlicher Abfragen hinzu: Hypertonie-Berichte, Trend-Dashboards. Profil, View, Abfragen — ein auslieferbares Paket. Wir glauben, dass SQL on FHIR Teil des Authorings selbst wird: Ein IG, der Daten definiert, sollte die Views und Abfragen zur Analyse mitliefern.
Die API: portable Clients, kein Vendor-Lock-in
Das dritte Element ist eine standardisierte HTTP-API, und sie ist aus demselben Grund wichtig wie die Artefakte: Ohne sie ist Ihr Werkzeug an einen Anbieter gebunden, auch wenn Ihre Views es nicht sind. Mit ihr funktioniert ein Dashboard, eine Pipeline, ein Notebook — alles, was die API spricht — gegen jeden konformen Server. Backends lassen sich austauschen, Workflows bleiben erhalten. Clients erkennen, was ein Server unterstützt, über das standardisierte CapabilityStatement.
Die API besteht aus drei Verben. Jedes beantwortet eine andere Frage:
| Verb | Beantwortete Frage | Operationen | Modus |
|---|---|---|---|
| run | „Gib mir die Zeilen, jetzt" | $viewdefinition-run, $sqlview-run*, $sqlquery-run | Synchron, gestreamt |
| export | „Erstelle die Dateien, melde mir, wenn fertig" | $viewdefinition-export, $sqlview-export*, $sqlquery-export | Asynchron, in Dateispeicher |
| materialize | „Halte mir eine Tabelle aktuell" | $materialize* | Asynchron, serverseitig verwaltet |
* $sqlview-run, $sqlview-export und $materialize kommen noch in die Spezifikation — die Arbeitsgruppe standardisiert sie derzeit.
Beachten Sie die Symmetrie: Jedes Artefakt im Abhängigkeitsgraphen — ViewDefinition, SQLView, SQLQuery — bekommt dieselben Verben. Sie können jeden Knoten Ihrer Pipeline ausführen oder exportieren, ob es sich um eine rohe Staging-View, ein Zwischenmodell oder eine abschließende parametrisierte Abfrage handelt. Und materialize gilt sowohl für ViewDefinitions als auch für SQLViews — jeder nicht-parametrisierte Knoten kann zu einer verwalteten Tabelle werden.
run — für Authoring und Echtzeit
$viewdefinition-run — aufgerufen als $run auf der ViewDefinition-Ressource — ist eine synchrone Operation, die für Authoring und Anwendungsfälle mit kleinen Datensätzen konzipiert ist. Der einfachste Aufruf ist ein einzelnes GET auf einer gespeicherten View:
GET /ViewDefinition/patient-demographics/$run?_format=csv&_limit=100
Accept: text/csv
id,birthDate,family,given
pt-1,1990-01-15,Smith,John
pt-2,1985-03-22,Johnson,Mary
Wenn die View noch nicht gespeichert ist, können Sie sie inline als Parameters-Body per POST übermitteln — optional zusammen mit den zu transformierenden Ressourcen. Das ist der Authoring-Loop: Definition bearbeiten, POSTen, Zeilen ansehen, wiederholen. Es ist auch der Echtzeit-Pfad: ein Dashboard-Widget oder ein KI-Agent, der die Conditions eines Patienten als flache Tabelle statt als Ressourcengraph möchte. Laufzeitbelange bleiben zur Laufzeit: patient, group, _since und _limit sind Operationsparameter, keine View-Eigenschaften — dieselbe View-Definition bedient den Gesamtpopulations-Export und die Einzelpatienten-Abfrage.
$sqlquery-run ist dasselbe Verb eine Ebene höher: eine gespeicherte oder inline übergebene SQLQuery gegen die materialisierten Views ausführen, Abfrageparameter namentlich übergeben (eine verschachtelte Parameters-Ressource, sicher an die deklarierten Library.parameter-Einträge gebunden), mit _format und _limit zur Ausgabesteuerung. Und $sqlview-run (kommt in die Spezifikation) füllt die Mitte: eine beliebige SQLView auswerten — mit ihrem vollständigen Abhängigkeitsgraphen, den der Server auflöst — und die Zeilen zurückstreamen. Sehr praktisch, wenn man einen Knoten in einer tiefen Pipeline debuggt.
export — Bulk Export, aufgewertet
Denken Sie an $viewdefinition-export als aufgewerteten FHIR Bulk Export. Mit dem klassischen Bulk Export erhalten Sie alle Ressourcen als rohes ndjson — und das Flachklopfen ist dann Ihr Problem: Sie stehen eine ETL-Pipeline auf, nur um die Daten abfragbar zu machen. Mit SQL on FHIR-Export fragen Sie nach den Views, die Sie wirklich brauchen, und was in Ihrem Bucket landet, ist bereits flach — CSV, ndjson oder Parquet, bereit für Spark, DuckDB, Athena oder das Laden in ein Warehouse.
Der Ablauf besteht aus vier Schritten:
- Anstoßen. POST einer Liste von Views mit dem Async-Header:
POST /ViewDefinition/$viewdefinition-export HTTP/1.1
Prefer: respond-async
Content-Type: application/fhir+json
{
"resourceType": "Parameters",
"parameter": [
{"name": "view", "part": [{"name": "viewReference",
"valueReference": {"reference": "ViewDefinition/patient-demographics"}}]},
{"name": "view", "part": [{"name": "viewReference",
"valueReference": {"reference": "ViewDefinition/diagnoses"}}]},
{"name": "_format", "valueCode": "parquet"}
]
}
-
Ein Ticket erhalten. Der Server antwortet mit
202 Acceptedund einemContent-Location-Header — Ihrer Status-URL. -
Pollen. Die Status-URL gibt
202zurück, während der Job läuft (mit optionalem Fortschritt). Wenn der Job abgeschlossen ist, antwortet er mit303 See Otherund einemLocation-Header, der auf das Ergebnis verweist. -
Dateien abholen. GET auf die Ergebnis-URL — die Antwort listet eine Ausgabe-URL pro View auf:
{
"resourceType": "Parameters",
"parameter": [
{"name": "exportId", "valueString": "job-42"},
{"name": "status", "valueCode": "completed"},
{"name": "output", "part": [
{"name": "name", "valueString": "patient_demographics"},
{"name": "location", "valueUri": "https://storage.example.org/exports/patient_demographics.parquet"}
]},
{"name": "output", "part": [
{"name": "name", "valueString": "diagnoses"},
{"name": "location", "valueUri": "https://storage.example.org/exports/diagnoses.parquet"}
]}
]
}
Wenn Sie Bulk Data Export implementiert haben, kennen Sie diesen Ablauf — dieselbe asynchrone Choreografie, aber die Nutzlast sind analysebereite Tabellen statt roher Ressourcen. Es gibt weniger zu exportieren, der ETL-Schritt entfällt vollständig, und ein Server, der Export nativ unterstützt, kann intern stark optimieren.
Dieselben Filter gelten wie für run — patient, group, _since — sodass eine Krankenkasse die Views eines einzelnen Mitglieds genauso einfach exportieren kann wie eine ganze Population. Und das Verb erstreckt sich aufwärts durch den Graphen: $sqlview-export (kommt in die Spezifikation) materialisiert ein Zwischenmodell in Dateien, $sqlquery-export tut dasselbe für Abfrageergebnisse, die zu groß für eine synchrone Antwort sind. Exportieren Sie die Staging-Schicht für Ihren Lake oder den fertigen Mart — Ihr Wahl des Schnittpunkts.
materialize — „Hey Server, halte es aktuell"
$materialize ist die Operation, bei der Sie dem Server sagen: Hier ist meine View-Definition — erstelle daraus eine verwaltete View und halte sie aktuell, wenn sich die Daten ändern. Es ist eine asynchrone Operation: Sie geben ihr einen Zielnamen und eine Aktualisierungsrichtlinie (manual oder scheduled mit einem Cron-Ausdruck), der Server erstellt die View im Hintergrund, und wenn der Job abgeschlossen ist, erhalten Sie eine Referenz auf die materialisierte View, die Sie von da an abfragen können:
POST /ViewDefinition/patient-demographics/$materialize HTTP/1.1
Prefer: respond-async
Content-Type: application/fhir+json
{
"resourceType": "Parameters",
"parameter": [
{"name": "targetName", "valueString": "patient_demographics"},
{"name": "updatePolicy", "valueCode": "scheduled"},
{"name": "schedule", "valueString": "0 0 * * *"}
]
}
Wenn der Job abgeschlossen ist, erhalten Sie eine Referenz auf die materialisierte View; wie sie für Abfragen zugänglich gemacht wird — Schema, Benennung, Zugang — liegt bei der Implementierung, wobei targetName als gewünschter Handle dient. Von da an besitzt der Server die Aktualität (in diesem Beispiel nächtlich), und jedes SQL-Werkzeug fragt einfach das Ergebnis ab:
SELECT * FROM patient_demographics WHERE dob > '1990-01-01';
Ihr BI-Tool verbindet sich mit einer Tabelle und weiß nie, dass FHIR im Spiel war.
Dasselbe Verb gilt für SQLViews. Ein Zwischenmodell zu materialisieren ist genau das, was man in einem Warehouse tut, wenn eine View „heiß" wird: Der Server löst den Abhängigkeitsgraphen auf, erstellt die Tabelle und übernimmt deren Aktualisierung. Welche Knoten Ihres DAG materialisiert werden und welche virtuell bleiben, wird zu einer Laufzeit-Tuning-Entscheidung — die Pipeline-Definition ändert sich nicht.
Dieses Muster ist bereits in der Produktion erprobt. In Aidbox haben wir $materialize über PostgreSQL implementiert, plus Adapter für ClickHouse, BigQuery und Databricks: ein sehr effizienter initialer Ladevorgang — Millionen von Ressourcen in Sekunden — und dann wird die Tabelle mithilfe von Subscriptions nahezu in Echtzeit aktuell gehalten. Dieselbe View-Definition, vier verschiedene Engines — was eben genau der Punkt ist. Die Operation wird jetzt standardisiert, damit „halte diese Tabelle aktuell" zu einer portablen Anforderung wird und nicht zu einem Anbietermerkmal.
Zusammengefasst: run für Entwicklung und Echtzeitzugriff, export für die Versorgung externer Engines, materialize für In-Database-Analytics — alles standardisierte Endpunkte, alle anbieterneutral.
Wohin das führt: Aus Nutzerperspektive wird der Server zu einer Black Box. Sie senden ihm View-Definitionen und Abfragen; die Views bleiben aktuell; Sie führen einfach Ihre Berichte aus. Und da LLMs bereits sehr gut darin sind, SQL und View-Definitionen zu schreiben, ist die nächste Schnittstelle auf dieser Box Klartext — mit der Standard-API als das, was der Agent darunter steuert.
FHIR to OMOP: die DSL im Härtetest
Das ist also das vollständige Toolkit: eine DSL für das Staging (ViewDefinition), eine DSL für Transformationen (SQLView/SQLQuery) und eine API, um alles auszuführen. Der beste Weg herauszufinden, ob ein Toolkit praxistauglich ist, ist, die schwierigste Konvertierung, die wir kennen, darauf loszulassen — und das ist FHIR to OMOP. Ehrlich gesagt hat dieses Projekt bereits Teile des Designs vorgegeben: Wir brauchten Views von Views, um es auszudrücken, und dieser Bedarf ist ein wesentlicher Grund, warum SQLView existiert.
OMOP CDM ist der OHDSI-Standard für Beobachtungsforschung, und was ich an OMOP schätze, ist seine extreme Pragmatik: ein fester Satz von Tabellen, alle Codes zu Standardkonzepten normiert, die gesamte Infrastruktur — einschließlich Terminology — direkt in der Datenbank. FHIR ist das transaktionale Modell; OMOP ist das analytische. Je mehr sich FHIR-first-Systeme verbreiten, desto mehr wird „FHIR für OLTP, OMOP für OLAP" zur Standardarchitektur, und die Konversionsschicht zwischen beiden wird zu kritischer Infrastruktur.
Die OMOP-Community selbst baut Pipelines üblicherweise im ELT-Stil. Das tun wir auch — die Konvertierung ist ein geschichteter DAG aus genau den oben beschriebenen Artefakten:
Die Grundlagen, Schicht für Schicht.
Staging ViewDefinitions flachen jede Ressource auf genau das ab, was OMOP braucht — Schlüssel, Daten und Quell-Codings, eine Zeile pro Coding:
{
"resourceType": "ViewDefinition",
"name": "condition_staging",
"resource": "Condition",
"select": [
{
"column": [
{"name": "condition_id", "path": "getResourceKey()", "type": "string"},
{"name": "person_id", "path": "subject.getReferenceKey(Patient)", "type": "string"},
{"name": "start_date", "path": "onset.ofType(dateTime)", "type": "dateTime"}
]
},
{
"forEach": "code.coding",
"column": [
{"name": "source_system", "path": "system", "type": "uri"},
{"name": "source_code", "path": "code", "type": "code"}
]
}
]
}
Mapping SQLViews tragen die Semantik: Die OMOP-Athena-Vokabulare werden direkt in die Datenbank geladen, und die Concept-ID-Auflösung ist ein JOIN, kein Terminology-Server-Aufruf:
-- SQLView: ConditionMappedView
-- depends on: .../ViewDefinition/condition_staging as cs
SELECT cs.condition_id,
cs.person_id,
std.concept_id_2 AS condition_concept_id,
cs.start_date,
src.concept_id AS condition_source_concept_id,
cs.source_code AS condition_source_value,
std_c.domain_id AS target_domain
FROM cs
JOIN concept src
ON src.concept_code = cs.source_code AND src.vocabulary_id = 'ICD10CM'
JOIN concept_relationship std
ON std.concept_id_1 = src.concept_id AND std.relationship_id = 'Maps to'
JOIN concept std_c
ON std_c.concept_id = std.concept_id_2
Es sieht knifflig aus, ist aber im Grunde trivial: Es führt das Mapping durch, führt die Joins aus und übersetzt on the fly. Und die Joins verarbeiten auf natürliche Weise die wirklich schwierigen Teile — Maps to-Fan-out (ein ICD-Code wird zu mehreren SNOMED-Zeilen), Domain-Routing (eine FHIR-Condition landet je nach Domain des Zielkonzepts in condition_occurrence, observation oder measurement) und Verwerfungsregeln für nicht abbildbare Datensätze.
Load SQLQueries lesen die gemappten Views und befüllen die CDM-Tabellen:
INSERT INTO condition_occurrence
SELECT condition_id, person_id, condition_concept_id,
start_date, condition_source_concept_id, condition_source_value
FROM cm
WHERE target_domain = 'Condition'
Warum SQL und nicht FHIRPath oder die FHIR Mapping Language? Weil man für diese Aufgabe Lookups in Mapping-Tabellen, die Aufteilung eines Datensatzes in viele sowie bedingte Logik über Vokabulare hinweg braucht. Man könnte jeden Code prinzipiell durch den $translate-Endpunkt eines Terminology-Servers routen — aber das ist für Massentransformationen nicht praktikabel; die Leute werden CPUs damit verbrennen, es Zeile für Zeile zu tun. Und das muss bei Populationsgröße effizient sein — Milliarden von Datensätzen, nicht Tausende. Für uns ist das einfach SQL, und mengenbasierte Joins über Milliarden von Zeilen sind genau das Problem, das Datenbanken fünfzig Jahre lang perfektioniert haben.
Ein vollständiger Disclaimer: Dieses Projekt — fhir2omop — befindet sich in einem sehr frühen Stadium, es ist ein offenes Work in Progress. Die Ideen, die wir erkunden: profilgesteuerte Konvertierung (eine Ressource wird genau dann in eine OMOP-Tabelle konvertiert, wenn sie gegen ein steuerndes FHIR-Profil validiert), goldene Testfälle (eine FHIR-Ressource rein, die genauen OMOP-Zeilen raus — denn Grenzfälle sind genau das, was Beispiele sichtbar machen), und Jurisdiktionsmodule wie US Core to OMOP oder German ICD to OMOP, die die Community erweitern kann.
Das Ziel ist aber größer als ein einzelner Konverter. FHIR-to-OMOP ist, wie wir SQL on FHIR einem Härtetest unterziehen: Es ist die schwierigste Konvertierung, die wir kennen, und wenn die DSL sie ausdrücken kann — das Staging, die Vokabular-Joins, das Domain-Routing, alles als portable Artefakte — dann kommt alles Einfachere gratis dazu. Daher laden wir alle ein: OMOP-Menschen, FHIR-Menschen, Data-Engineers. Bringen Sie Ihre Mappings, Ihre Grenzfälle, Ihren Skeptizismus — die Arbeitsgruppe widmet OMOP eigene Sessions, und der Raum ist offen.
Bauen Sie mit uns
Treten Sie dem #analytics-on-FHIR-Stream auf chat.fhir.org und den wöchentlichen Arbeitsgruppen-Calls bei. Probieren Sie den Playground für einen fünfminütigen Einstieg, oder den vollständigen DevDays-Workshop — PostgreSQL, Grafana, Jupyter, Synthea-Daten — für einen praxisnahen Start. Und besuchen Sie die SQL on FHIR Conference — unsere kostenlose Online-Konferenz, die genau diesem Thema gewidmet ist.
Und wenn Sie das alles heute schon ausführen möchten: Aidbox ist ein transaktionaler FHIR-Server mit integrierter Echtzeit-Analytics auf SQL on FHIR — und der erste FHIR-Server, der alle SQL on FHIR-Tests besteht. ViewDefinitions über PostgreSQL, ein visueller ViewDefinition-Builder sowie SQL-View-/Query-Manager in der UI, $materialize und Adapter, die Ihre Tabellen in ClickHouse, BigQuery und Databricks aktuell halten. Die transaktionale und die analytische Welt — endlich auf einer gemeinsamen Brücke.
Das ist nicht die Arbeit einer einzigen Person. Besonderer Dank gilt John Grimes, Arjun Sanyal, Gino Canessa und Steve Munini — sowie der gesamten SQL on FHIR-Arbeitsgruppe, die Woche für Woche erscheint, um diese Ideen zu einem Standard auszuarbeiten.




