Ce que signifie l'analytique interopérable
Il existe deux mondes matures qui se parlent à peine.
D'un côté, FHIR. Nous disposons enfin d'une grande quantité de données FHIR — la grande majorité des hôpitaux américains exposent des API FHIR et cette proportion croît chaque année, tandis que les systèmes FHIR-natifs stockent les données cliniques directement sous forme de ressources. De l'autre côté, un écosystème très mature d'outils analytiques : bases de données modernes, plateformes BI, cadres de données, toute la pile de données moderne. C'est le fossé d'interopérabilité que l'analytique FHIR doit combler — et jusqu'ici, chacun l'a comblé en privé, avec des ETL sur mesure.
Il est déjà possible de charger des données FHIR dans ces outils — les bases de données modernes gèrent bien les données imbriquées : Postgres avec JSON binaire, BigQuery, Spark, DuckDB. En fait, la première version de SQL on FHIR consistait exactement en cela : interroger des données FHIR imbriquées directement dans la base. Ça fonctionnait — et ça ne standardisait pas. Chaque moteur a son propre dialecte pour les données imbriquées ; une requête écrite pour BigQuery n'a rien en commun avec la même requête dans Postgres. Rien à standardiser.
Sous tout cela se cache le vieux problème de l'inadéquation objet-relationnel : les ressources FHIR sont des documents imbriqués, et le monde analytique — SQL, outils BI, cadres de données — veut toujours des tables plates. La solution naïve, qui consiste à générer une table pour chaque élément FHIR, ne fonctionne tout simplement pas : on se retrouve avec un millier de tables incompréhensibles. Tout le monde finit donc par écrire des ETL spécifiques à chaque cas d'utilisation pour aplatir les ressources — en répétant le même travail, légèrement différemment, avec les mêmes bogues subtils autour des tableaux, des types alternatifs et des références.
SQL on FHIR version deux est notre tentative de construire un pont standard entre ces deux mondes — cette fois en standardisant les vues plates plutôt que les requêtes imbriquées. Il a débuté comme projet de spécification communautaire ; aujourd'hui, c'est une spécification complète développée ouvertement, adoptée dans le processus de scrutin HL7, publiée sous licence CC0 — avec un article évalué par des pairs dans npj Digital Medicine qui valide l'approche sur environ 300 000 patients et reproduit une étude clinique publiée sur deux piles d'implémentation indépendantes. Et dans FHIR R6, ViewDefinition est en bonne voie pour devenir une ressource FHIR standard — une ressource additionnelle, le mécanisme de R6 pour faire croître la spécification centrale en modules.
Ce que la norme vous apporte vraiment, c'est la séparation : la création est séparée de l'implémentation, et l'implémentation de l'utilisation. Une personne décrit l'analytique ; le moteur de n'importe qui l'exécute ; n'importe quel outil consomme le résultat.
Et il ne s'agit plus seulement de définitions de vues que vous rédigez. Vous décrivez l'ensemble de votre analytique sous forme d'artefacts portables et normalisés — vues plates, couches de transformation par-dessus, requêtes, entrepôts de données complets et pipelines de conversion. Exécutez-les sur différents moteurs, sur différents jeux de données, contre des serveurs de différents fournisseurs — et distribuez même l'ensemble sous forme de guide d'implémentation, comme n'importe quel autre élément de l'écosystème FHIR. C'est ce que signifie « analytique interopérable », et la spécification le réalise maintenant à trois niveaux : vues, requêtes et API.
ViewDefinition : un langage pour l'aplatissement
Une ViewDefinition est, en termes simples, un langage pour expliquer à un serveur comment aplatir des ressources en une table plate. Elle décrit une vue tabulaire d'exactement un type de ressource — colonnes, filtres et dénormalisation — en utilisant un sous-ensemble minimal de FHIRPath :
{
"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"}
]
}
]
}
Comme la définition est déclarative et indépendante du moteur, la même vue s'exécute dans un outil ETL JavaScript, à l'intérieur de PostgreSQL, sur Spark, ou sur un ensemble de fichiers ndjson issus d'une exportation en masse. L'ensemble du modèle se réduit à cinq fonctions composables, et un outil d'exécution complet est suffisamment petit pour être lu en une après-midi — l'implémentation de référence JavaScript fait environ 400 lignes :
| Fonction | Ce qu'elle fait | Analogie SQL |
|---|---|---|
column | Extrait des éléments via FHIRPath dans des colonnes | SELECT |
where | Filtre les ressources (p. ex. par profil ou statut) | WHERE |
forEach / forEachOrNull | Dénormalise une collection en lignes | INNER JOIN / LEFT OUTER JOIN contre une table imbriquée |
select | Produit un produit cartésien des colonnes parentes avec les lignes dénormalisées | jointure de sous-sélections |
unionAll | Concatène les lignes de différentes branches | UNION ALL |
Une mise en garde honnête : cet aplatissement est avec perte, et nous en sommes conscients. Il n'existe pas de représentation tabulaire universelle de FHIR, donc les vues sont conçues pour être spécifiques à un cas d'utilisation — je plaisante parfois en disant qu'une ViewDefinition est le mode CSV de FHIR. Ce n'est pas une faiblesse ; c'est le contrat : un ingénieur définit la vue une fois, et les autres ingénieurs et outils utilisent simplement la table plate.
Une unité naturelle pour une vue est un profil. Imaginez un profil de tension artérielle — et par-dessus, une belle table blood_pressure avec des colonnes systolic et diastolic, une ligne par mesure. Le profil précise où résident les données dans la ressource ; la vue transforme cette connaissance en colonnes. Tout profil bien défini est une table plate qui n'attend qu'à être déclarée.
Deux ajouts récents méritent d'être connus :
repeatgère les structures véritablement récursives — les éléments de QuestionnaireResponse, les concepts de CodeSystem — où la profondeur n'est pas connue à l'avance. Fournissez-lui les chemins à parcourir et chaque élément imbriqué devient une ligne, quel que soit son niveau.%rowIndexcapture la position d'un élément pendant l'itération. Les ensembles de résultats SQL ne sont pas ordonnés, mais les tableaux FHIR le sont — le premiernamen'est pas le même que le troisième. L'index vous permet de préserver l'ordre FHIR et de construire des clés de substitution.
Des jointures sans jointures
Une seule ViewDefinition ne joint jamais des ressources — par conception. À la place, deux fonctions émettent des clés que votre base de données joint : getResourceKey() pour la clé primaire de la ligne, et subject.getReferenceKey(Patient) pour la clé étrangère (avec un filtre de type qui renvoie une collection vide — une valeur nulle dans la sortie — si la référence pointe ailleurs). Une vue Condition porte un patient_id ; la jointure des conditions aux patients est une jointure SQL ordinaire dans le moteur de votre choix.
La façon dont les clés sont réellement dérivées — id simple, identifiant principal, hachage — est laissée à l'implémentation. C'est délibéré : c'est ce qui rend la même vue portable entre des systèmes avec différentes invariantes de données.
Et les choses que ViewDefinition ne fait délibérément pas — pas de jointures entre ressources, pas d'agrégation, pas de tri, pas de formats de sortie — ne sont pas des lacunes. Chacune d'elles était un compromis que nous avons débattu : faut-il compliquer chaque outil d'exécution de vues, ou déléguer à la base de données ? Les bases de données sont spécialisées dans les jointures ; nous ne franchissons pas la frontière des ressources. Cette retenue est ce qui maintient un outil d'exécution implémentable partout, d'une bibliothèque de 400 lignes à un moteur SQL distribué.
Les outils d'exécution eux-mêmes se présentent en deux variantes. Les outils ETL prennent un flux de ressources et produisent des lignes — faciles à écrire si vous disposez d'un moteur FHIRPath (des implémenteurs ont rapporté en avoir écrit un en Rust en environ un mois). Les outils ELT ressemblent davantage à des transpileurs qu'à des exécuteurs : ils compilent une ViewDefinition en une requête SQL sophistiquée sur une base de données FHIR-native, de sorte que la vue devient une vraie vue de base de données sans duplication — des centaines de définitions de vues peuvent s'exécuter sur la même table de ressources.
Et la spécification a été construite de bas en haut, pas de haut en bas : des implémentations existaient avant la première publication. Une suite de tests partagée fait partie de la spécification — jeu de données, définition de vue, résultat attendu — et la matrice de conformité est alimentée par les implémenteurs qui exécutent les mêmes tests et rapportent leurs résultats. C'est ainsi que nous garantissons que les implémentations restent compatibles entre elles.
SQLQuery et SQLView : les requêtes deviennent partageables
Les tables plates représentent la moitié du pont. L'autre moitié : où vivent les requêtes ? La définition de cohorte, la mesure de qualité, la requête de tableau de bord — ce sont les artefacts les moins portables en analytique de santé aujourd'hui, éparpillés dans des wikis et des blocs-notes, liés au schéma d'un seul site.
Nous avons donc associé la définition de vue à une ressource de requête, et vous pouvez maintenant partager l'ensemble. SQLQuery est un profil sur la ressource Library FHIR qui encapsule une requête SQL logique :
{
"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": "..."
}]
}
La structure repose sur trois idées :
- Les dépendances comme alias. Vous déclarez les définitions de vues dont dépend la requête, et le
labeldevient le nom de la table dans votre SQL. La requête ne code jamais en dur les noms de tables physiques — l'environnement d'exécution résoutptetdgen ce que les vues sont matérialisées. - Des paramètres sécurisés. Les paramètres sont déclarés dans la Library et référencés comme des espaces réservés
:from_date. La spécification est explicite : l'interpolation de chaînes NE DOIT PAS être utilisée — uniquement de vrais paramètres liés. - Des variantes de dialecte. Une Library peut porter un
application/sqlportable par défaut ainsi que des pièces jointes;dialect=postgresqlou;dialect=spark, à condition qu'elles soient fonctionnellement équivalentes.
La spécification décrit également une commodité de création pour les outils : rédigez un fichier .sql ordinaire avec quelques commentaires d'annotation (@name, @param, @relatedDependency) et laissez un générateur le transformer en ressource Library. Le SQL reste la source de vérité ; FHIR devient l'emballage.
SQLView est le profil le plus récent, et il existe pour une seule raison : pouvoir empiler des requêtes les unes sur les autres. Il est presque identique à SQLQuery, mais sans paramètres — et l'intention est différente. Une requête est quelque chose qu'on exécute ; une vue décrit une table. Quand on dit « j'ai besoin d'une vue dans une base de données », tout le monde comprend de quoi on parle — c'est pourquoi c'est un profil séparé plutôt qu'un indicateur sur SQLQuery.
Comment les SQLViews s'empilent
Une SQLView est une Library avec type = sql-view, une URL canonique, des dépendances et du SQL. En voici une qui définit les « patients actifs » au-dessus de la ViewDefinition patient_demographics :
{
"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": "..."
}]
}
L'élément crucial est l'url. Comme la vue possède une URL canonique, n'importe quoi peut maintenant en dépendre de la même façon qu'il dépendrait d'une ViewDefinition — il suffit d'ajouter une entrée relatedArtifact et d'utiliser le label comme nom de table. Une deuxième vue s'appuie sur la première :
-- 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' -- diabète de type 2 (SNOMED)
Et une SQLQuery paramétrée se trouve au-dessus des deux couches pour le rapport final. Les règles de dépendance sont simples :
- Une SQLView peut dépendre de ViewDefinitions et d'autres SQLViews — jamais de SQLQueries.
- Une SQLQuery peut dépendre de ViewDefinitions et de SQLViews.
- Les références forment un graphe orienté, que vous maintenez acyclique — comme les vues dans n'importe quelle base de données.
Empilez suffisamment de ces éléments et vous aurez décrit un système analytique complet — dans les mêmes termes qu'une équipe de données utiliserait pour n'importe quel entrepôt. Les ViewDefinitions sont la couche de mise en scène : les données FHIR brutes chargées sous forme de tables plates. Les SQLViews sont les modèles intermédiaires : des blocs de construction nettoyés, filtrés, joints. Le sommet du graphe est le data mart : les registres de cohortes, les tables de faits et les rapports paramétrés que les analystes utilisent réellement.
Chaque nœud est petit, lisible et testable indépendamment : les vues de mise en scène ne font que remodeler, chaque modèle intermédiaire ajoute une étape de transformation, les requêtes ne font que paramétrer la coupe finale. La complexité réside dans la composition, pas dans un seul artefact — c'est exactement ainsi que les systèmes analytiques matures sont construits dans les entrepôts aujourd'hui. Il n'y a pas de plafond ici : un registre de maladies, une suite de mesures de qualité, un modèle dimensionnel avec faits et dimensions, une conversion en centaines de tables — tout ce qu'on peut exprimer comme du SQL en couches sur des vues plates peut être exprimé sous forme de ce graphe.
Deux avantages s'obtiennent gratuitement. Le graphe de dépendances est votre lignée de données : pour n'importe quelle colonne du data mart, vous pouvez retracer, artefact par artefact, le chemin jusqu'à l'expression FHIRPath qui l'a produite. Et l'ensemble du graphe se distribue sous forme de ressources FHIR.
Ce que la spécification ne prescrit délibérément pas, c'est l'exécution : que ActivePatientsView devienne une CTE inlinée, une vue de base de données ou une table matérialisée est laissé au moteur — le graphe décrit la logique, le moteur choisit la physique.
Un DSL complet pour l'ELT
Dites à un ingénieur de données « nous avons un DAG ici » — et il comprendra immédiatement. Le graphe que vous venez de voir est de l'ELT, le modèle dominant de la pile de données moderne : charger d'abord les données brutes, les transformer en couches à l'intérieur du moteur. Si vous connaissez dbt, vous reconnaissez déjà cette forme. Dès le moment où nous avons ajouté la capacité de construire des requêtes sur des requêtes, SQL on FHIR est devenu un DSL complet pour décrire des pipelines ELT : ViewDefinition vous donne l'EL — extraire FHIR, charger des tables plates — et SQLView avec SQLQuery ajoutent le T. La boucle est fermée.
Alors qu'est-ce qui est réellement nouveau ici, par rapport aux outils ELT que les équipes de données utilisent déjà ? Le modèle de distribution. Un projet de transformation est normalement du code dans votre dépôt, supposant votre entrepôt. Un pipeline SQL on FHIR est un ensemble de ressources avec des URL canoniques que vous pouvez publier dans un guide d'implémentation, versionner et exécuter contre n'importe quelle pile conforme. Et comme les artefacts sont neutres vis-à-vis de la technologie, des gens écriront des traducteurs pour les convertir en actifs spécifiques à leur pile — une vue d'entrepôt, une étape dans votre orchestrateur, un modèle dans le cadre de transformation que votre équipe utilise. Nous décrivons le data mart une fois ; la technologie cible est un détail de compilation.
Cela complète également l'histoire de la création. Reprenons l'exemple de la tension artérielle mentionné plus tôt — le profil et sa vue systolic/diastolic — et ajoutez maintenant un ensemble de requêtes utiles par-dessus : rapports d'hypertension, tableaux de bord de tendances. Profil, vue, requêtes — un ensemble livrable. Nous croyons que SQL on FHIR devient partie intégrante de la création elle-même : un IG qui définit des données devrait livrer les vues et les requêtes pour les analyser.
L'API : clients portables, sans dépendance fournisseur
Le troisième élément est une API HTTP standard, et elle importe pour la même raison que les artefacts : sans elle, vos outils sont liés à un fournisseur même si vos vues ne le sont pas. Avec elle, un tableau de bord, un pipeline, un bloc-notes — tout ce qui parle l'API — fonctionne contre n'importe quel serveur conforme. Changez le serveur, conservez le flux de travail. Les clients découvrent ce qu'un serveur prend en charge grâce au CapabilityStatement standard.
L'API comprend trois verbes. Chacun répond à une question différente :
| Verbe | Question à laquelle il répond | Opérations | Mode |
|---|---|---|---|
| run | « Donnez-moi les lignes, maintenant » | $viewdefinition-run, $sqlview-run*, $sqlquery-run | Synchrone, en flux |
| export | « Construisez les fichiers, dites-moi quand c'est fait » | $viewdefinition-export, $sqlview-export*, $sqlquery-export | Asynchrone, vers le stockage de fichiers |
| materialize | « Gardez une table à jour pour moi » | $materialize* | Asynchrone, géré par le serveur |
* $sqlview-run, $sqlview-export et $materialize arrivent dans la spécification — le groupe de travail les normalise actuellement.
Notez la symétrie : chaque artefact dans le graphe de dépendances — ViewDefinition, SQLView, SQLQuery — reçoit les mêmes verbes. Vous pouvez exécuter ou exporter n'importe quel nœud de votre pipeline, qu'il s'agisse d'une vue de mise en scène brute, d'un modèle intermédiaire ou d'une requête paramétrée finale. Et materialize s'applique aussi bien aux ViewDefinitions qu'aux SQLViews — tout nœud non paramétré peut devenir une table gérée.
run — pour la création et le temps réel
$viewdefinition-run — invoqué sous la forme $run sur la ressource ViewDefinition — est une opération synchrone conçue pour la création et les cas d'utilisation avec de petits jeux de données. L'appel le plus simple est un GET sur une vue enregistrée :
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
Lorsque la vue n'est pas encore enregistrée, vous la POSTez en ligne dans un corps Parameters — éventuellement accompagnée des ressources à transformer. C'est la boucle de création : modifiez la définition, POSTez-la, voyez les lignes, recommencez. C'est aussi le chemin en temps réel : un widget de tableau de bord ou un agent d'IA qui veut les conditions d'un patient sous forme de table plate plutôt que d'un graphe de ressources. Les préoccupations d'exécution restent à l'exécution : patient, group, _since et _limit sont des paramètres d'opération, pas des propriétés de la vue — la même définition de vue sert l'exportation de toute la population et la requête sur un seul patient.
$sqlquery-run est le même verbe une couche au-dessus : exécutez une SQLQuery enregistrée ou en ligne contre les vues matérialisées, en passant les paramètres de requête par nom (une ressource Parameters imbriquée, liée de façon sécurisée aux entrées Library.parameter déclarées), avec _format et _limit pour le contrôle de la sortie. Et $sqlview-run (à venir dans la spécification) comble le milieu : évaluez n'importe quelle SQLView intermédiaire — avec l'ensemble de son sous-graphe de dépendances résolu par le serveur — et diffusez les lignes en retour. Très pratique lorsque vous déboguez un nœud d'un pipeline profond.
export — l'exportation en masse, améliorée
Pensez à $viewdefinition-export comme à une exportation FHIR en masse améliorée. Avec l'exportation en masse classique, vous obtenez toutes les ressources, en ndjson brut — et l'aplatissement est votre problème : vous montez un pipeline ETL juste pour rendre les données interrogeables. Avec l'exportation SQL on FHIR, vous demandez les vues dont vous avez réellement besoin, et ce qui atterrit dans votre espace de stockage est déjà plat — csv, ndjson ou parquet, prêt à être lu par Spark, DuckDB, Athena ou chargé dans un entrepôt.
Le flux se déroule en quatre étapes :
- Lancer. Envoyez un POST avec une liste de vues et l'en-tête asynchrone :
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"}
]
}
-
Obtenir un jeton. Le serveur répond
202 Acceptedavec un en-têteContent-Location— votre URL de statut. -
Interroger. L'URL de statut renvoie
202pendant l'exécution du travail (avec une progression optionnelle). Lorsque le travail se termine, il répond303 See Otheravec un en-têteLocationpointant vers le résultat. -
Collecter les fichiers. Effectuez un GET sur l'URL du résultat — la réponse liste une URL de sortie par vue :
{
"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"}
]}
]
}
Si vous avez implémenté Bulk Data Export, vous connaissez déjà cette chorégraphie — même orchestration asynchrone, mais la charge utile est constituée de tables prêtes à l'analyse plutôt que de ressources brutes. Il y a moins d'éléments à exporter, vous sautez entièrement l'étape ETL, et un serveur qui prend en charge l'exportation nativement peut optimiser fortement en coulisses.
Les mêmes filtres s'appliquent qu'à run — patient, group, _since — donc un payeur peut exporter les vues d'un seul membre aussi facilement que celles d'une population entière. Et le verbe s'étend vers le haut du graphe : $sqlview-export (à venir dans la spécification) matérialise un modèle intermédiaire dans des fichiers, $sqlquery-export fait de même pour les résultats de requêtes trop volumineux pour une réponse synchrone. Exportez la couche de mise en scène pour votre lac de données, ou exportez le data mart terminé — c'est votre choix du point de coupe.
materialize — « hé serveur, garde ça à jour »
$materialize est l'opération par laquelle vous dites au serveur : voici ma définition de vue — construis une vue gérée à partir de celle-ci, et garde-la à jour à mesure que les données changent. C'est une opération asynchrone : vous lui donnez un nom cible et une politique de mise à jour (manual, ou scheduled avec une expression cron), le serveur construit la vue en arrière-plan, et quand le travail se termine, vous obtenez une référence à la vue matérialisée que vous pouvez interroger à partir de ce moment :
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 * * *"}
]
}
Quand le travail se termine, vous récupérez une référence à la vue matérialisée ; la façon dont elle est exposée pour l'interrogation — schéma, nommage, accès — est laissée à l'implémentation, avec targetName comme identifiant demandé. À partir de ce moment, le serveur gère la fraîcheur (chaque nuit dans cet exemple), et n'importe quel outil SQL interroge simplement le résultat :
SELECT * FROM patient_demographics WHERE dob > '1990-01-01';
Votre outil BI se connecte à une table et ne sait jamais que FHIR était impliqué.
Le même verbe s'applique aux SQLViews. Matérialiser un modèle intermédiaire est exactement ce qu'on fait dans un entrepôt quand une vue devient très sollicitée : le serveur résout le sous-graphe de dépendances, construit la table et gère sa mise à jour. Quels nœuds de votre DAG sont matérialisés et lesquels restent virtuels devient une décision de réglage à l'exécution — la définition du pipeline ne change pas.
Ce modèle est déjà éprouvé en production. Dans Aidbox, nous avons implémenté $materialize sur PostgreSQL, ainsi que des adaptateurs pour ClickHouse, BigQuery et Databricks : un chargement initial très efficace — des millions de ressources en quelques secondes — puis la table est maintenue à jour en quasi temps réel grâce aux abonnements. Même définition de vue, quatre moteurs différents — c'est précisément le but. L'opération est maintenant en cours de normalisation pour que « garder cette table à jour » devienne une demande portable, pas une fonctionnalité propriétaire.
En résumé : run pour le développement et l'accès en temps réel, export pour alimenter les moteurs externes, materialize pour l'analytique en base de données — tous des points de terminaison standard, tous neutres vis-à-vis du fournisseur.
Vers où cela se dirige : du point de vue de l'utilisateur, le serveur devient une boîte magique. Vous lui envoyez des définitions de vues et des requêtes ; les vues restent à jour ; vous n'avez qu'à exécuter vos rapports. Et comme les LLM sont déjà assez bons pour rédiger du SQL et des définitions de vues, la prochaine interface au-dessus de cette boîte est le langage naturel — avec l'API standard comme ce que l'agent pilote en dessous.
FHIR vers OMOP : éprouver le DSL en conditions réelles
Voilà donc la trousse à outils complète : un DSL pour la mise en scène (ViewDefinition), un DSL pour les transformations (SQLView/SQLQuery), et une API pour tout exécuter. La meilleure façon de savoir si une trousse à outils est solide est de lui soumettre la conversion la plus difficile que nous connaissions — et c'est FHIR vers OMOP. Franchement, ce projet a déjà dicté des parties de la conception : nous avions besoin de vues sur des vues pour l'exprimer, et ce besoin est une grande raison pour laquelle SQLView existe.
OMOP CDM est la norme OHDSI pour la recherche observationnelle, et ce que j'apprécie dans OMOP, c'est son pragmatisme extrême : un ensemble fixe de tables, tous les codes normalisés en concepts standard, toute l'infrastructure — Terminology incluse — directement dans la base de données. FHIR est le modèle transactionnel ; OMOP est le modèle analytique. À mesure que les systèmes FHIR-natifs se répandent, « FHIR pour l'OLTP, OMOP pour l'OLAP » devient l'architecture par défaut, et la couche de conversion entre les deux devient une infrastructure critique.
La communauté OMOP elle-même construit habituellement des pipelines en style ELT. Nous aussi — la conversion est un DAG en couches composé exactement des artefacts décrits ci-dessus :
Les bases, couche par couche.
Les ViewDefinitions de mise en scène aplatissent chaque ressource en exactement ce dont OMOP a besoin — clés, dates et codages sources, une ligne par codage :
{
"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"}
]
}
]
}
Les SQLViews de correspondance portent la sémantique : les vocabulaires Athena d'OMOP sont chargés directement dans la base de données, et la résolution des concept-id est une JOINTURE, pas un appel à un serveur de terminologie :
-- 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
Ça peut sembler complexe, mais c'est en réalité assez simple : ça fait la correspondance, les jointures, la traduction à la volée. Et les jointures gèrent naturellement les parties vraiment difficiles — l'éventail de Maps to (un code ICD devenant plusieurs lignes SNOMED), le routage par domaine (une Condition FHIR aboutissant dans condition_occurrence, observation ou measurement selon le domaine du concept cible), et les règles d'exclusion pour les enregistrements sans correspondance.
Les SQLQueries de chargement lisent les vues correspondantes et alimentent les tables CDM :
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'
Pourquoi SQL et non FHIRPath ou le langage de correspondance FHIR ? Parce que pour ce travail, vous avez besoin de recherches dans des tables de correspondance, de scinder un enregistrement en plusieurs, d'une logique conditionnelle à travers les vocabulaires. On pourrait en principe router chaque code à travers $translate d'un serveur de terminologie — mais ce n'est pas viable pour une transformation en masse ; les gens brûleront des ressources CPU en le faisant ligne par ligne. Et cela doit être efficace à l'échelle d'une population — des milliards d'enregistrements, pas des milliers. Pour nous, c'est simplement du SQL, et les jointures par ensembles sur des milliards de lignes est le seul problème que les bases de données ont passé cinquante ans à maîtriser.
Une mise en garde complète : ce projet — fhir2omop — est à un stade très précoce, un travail en cours ouvert. Les idées que nous explorons : la conversion conditionnée par profil (une ressource se convertit en table OMOP si et seulement si elle valide contre un profil FHIR de contrôle), les cas de test de référence (une ressource FHIR en entrée, les lignes OMOP exactes en sortie — parce que les cas limites sont précisément ce que les exemples rendent visibles), et des modules de juridiction comme US Core vers OMOP ou ICD allemand vers OMOP que la communauté peut étendre.
Mais l'objectif est plus grand qu'un seul convertisseur. FHIR-vers-OMOP est la façon dont nous éprouvons SQL on FHIR en conditions réelles : c'est la conversion la plus difficile qui soit, et si le DSL peut l'exprimer — la mise en scène, les jointures de vocabulaire, le routage par domaine, tout sous forme d'artefacts portables — alors tout ce qui est plus simple s'ensuit naturellement. Nous invitons donc tout le monde : les gens d'OMOP, les gens de FHIR, les ingénieurs de données. Apportez vos correspondances, vos cas limites, votre scepticisme — le groupe de travail consacre des séances à OMOP, et la porte est ouverte.
Venez construire avec nous
Rejoignez le fil #analytics-on-FHIR sur chat.fhir.org et les appels hebdomadaires du groupe de travail. Essayez le bac à sable pour une mise en bouche de cinq minutes, ou l'atelier complet DevDays — PostgreSQL, Grafana, Jupyter, données Synthea — pour un démarrage pratique. Et participez à la Conférence SQL on FHIR — notre conférence en ligne gratuite entièrement dédiée à ce domaine.
Et si vous voulez exécuter tout cela dès aujourd'hui : Aidbox est un serveur FHIR transactionnel avec analytique en temps réel intégrée sur SQL on FHIR — et le premier serveur FHIR à réussir tous les tests SQL on FHIR. ViewDefinitions sur PostgreSQL, un constructeur visuel de ViewDefinitions et des gestionnaires de vues/requêtes SQL dans l'interface, $materialize, et des adaptateurs qui gardent vos tables à jour dans ClickHouse, BigQuery et Databricks. Le monde transactionnel et le monde analytique, enfin sur un seul pont.
Rien de tout cela n'est l'œuvre d'une seule personne. Remerciements particuliers à John Grimes, Arjun Sanyal, Gino Canessa et Steve Munini — et à l'ensemble du groupe de travail SQL on FHIR qui se présente semaine après semaine pour débattre de ces idées jusqu'à en faire une norme.




