Il existe deux mondes matures qui ne se parlent presque pas.
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-first stockent nativement les données cliniques sous forme de ressources. De l'autre côté, un écosystème très mature d'outils analytiques : bases de données modernes, plateformes d'intelligence d'affaires, cadres de données, toute la pile de données moderne.
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 visait exactement cela : interroger les données FHIR imbriquées directement dans la base de données. Ç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.
En dessous se trouve le vieux problème du décalage objet-relationnel : les ressources FHIR sont des documents imbriqués, alors que le monde analytique — SQL, outils d'intelligence d'affaires, cadres de données — exige toujours des tables plates. La solution naïve, générer une table pour chaque élément FHIR, ne fonctionne tout simplement pas : on obtient un millier de tables que personne ne peut appréhender. Tout le monde finit donc par écrire un ETL spécifique à son cas d'usage 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 à choix multiples 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. Le projet a débuté comme une ébauche communautaire ; aujourd'hui c'est une spécification complète développée en mode ouvert, intégrée au processus de vote HL7, publiée sous licence CC0 — avec un article évalué par les pairs dans npj Digital Medicine qui valide l'approche sur ~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 voie de devenir une ressource FHIR standard — une ressource additionnelle, le mécanisme de R6 pour faire croître la spécification de base en modules.
Ce que la norme apporte réellement, c'est la séparation : l'authoring est séparé 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 ce n'est plus seulement des définitions de vues que vous rédigez. Vous décrivez votre analytique entière sous forme d'artefacts portables et standard — 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 ensembles de données, contre des serveurs de différents fournisseurs — et distribuez même le tout 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 : les vues, les requêtes et l'API.
ViewDefinition : un langage pour aplatir
Une ViewDefinition est, pour le dire simplement, 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"}
]
}
]
}
Parce que la définition est déclarative et indépendante du moteur, la même vue s'exécute dans un programme ETL JavaScript, à l'intérieur de PostgreSQL, sur Spark, ou sur un ensemble de fichiers ndjson provenant d'une exportation en vrac. Le modèle entier se réduit à cinq fonctions composables, et un programme d'exécution complet est suffisamment petit pour être lu en une après-midi — l'implémentation de référence en JavaScript fait environ 400 lignes :
| Fonction | Ce qu'elle fait | Analogie SQL |
|---|---|---|
column | Extrait des éléments par FHIRPath en 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 sur une table imbriquée |
select | Produit le 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 |
Un avertissement 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 par conception spécifiques à un cas d'usage — 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ù se trouvent les données dans la ressource ; la vue traduit cette connaissance en colonnes. Chaque 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ù vous ne connaissez pas la profondeur à 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 lors de 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.
Jointures sans jointures
Une seule ViewDefinition ne joint jamais des ressources — par conception. Au lieu de cela, deux fonctions émettent des clés sur lesquelles votre base de données effectue des jointures : getResourceKey() pour la clé primaire de la ligne, et subject.getReferenceKey(Patient) pour la clé étrangère (avec un filtre de type qui retourne une collection vide — un null dans la sortie — si la référence pointe ailleurs). Une vue Condition porte un patient_id ; joindre les conditions aux patients est une simple jointure SQL dans le moteur de votre choix.
La façon dont les clés sont effectivement dérivées — id simple, identifiant primaire, 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 des invariants de données différents.
Et ce que ViewDefinition ne fait délibérément pas — pas de jointures inter-ressources, pas d'agrégation, pas de tri, pas de formats de sortie — n'est pas des lacunes. Chacun de ces points était un compromis que nous avons débattu : faut-il compliquer chaque programme 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 de la ressource. Cette retenue est ce qui permet d'implémenter un programme d'exécution partout, d'une bibliothèque de 400 lignes à un moteur SQL distribué.
Les programmes d'exécution eux-mêmes existent en deux variantes. Les programmes ETL prennent un flux de ressources et produisent des lignes — trivial à écrire si vous disposez d'un moteur FHIRPath (des implémenteurs ont rapporté en avoir écrit un en Rust en environ un mois). Les programmes ELT ressemblent davantage à des transpileurs qu'à des programmes d'exécution : ils compilent une ViewDefinition en une requête SQL sophistiquée sur une base de données native FHIR, de sorte que la vue devient une vraie vue de base de données et que rien n'est dupliqué — 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, non de haut en bas : des implémentations existaient avant la première version. 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 constituée d'implémenteurs exécutant les mêmes tests et rapportant les 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 constituent la moitié du pont. L'autre moitié : où vivent les requêtes ? La définition de cohorte, la mesure de qualité, la requête du tableau de bord — ce sont les artefacts les moins portables de l'analytique en santé aujourd'hui, dispersés dans des wikis et des carnets, liés au schéma d'un seul établissement.
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 se décompose en trois idées :
- Les dépendances comme alias. Vous déclarez de quelles définitions de vues la requête dépend, 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ésoutptetdgvers ce que les vues sont matérialisées. - Des paramètres sûrs. Les paramètres sont déclarés dans la Library et référencés comme espaces réservés
:from_date. La spécification est claire : l'interpolation de chaînes NE DOIT PAS être utilisée — uniquement une liaison réelle. - Variantes de dialecte. Une Library peut porter un
application/sqlportable par défaut ainsi que des pièces jointes;dialect=postgresqlou;dialect=spark, dans la mesure où elles sont fonctionnellement équivalentes.
Il y a aussi une commodité d'authoring que la spécification décrit pour l'outillage : 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. C'est presque identique à SQLQuery mais sans paramètres — et l'intention est différente. Une requête est quelque chose que vous exécutez ; une vue décrit une table. Quand vous dites « j'ai besoin d'une vue dans une base de données », les gens comprennent de quoi vous parlez — 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 » par-dessus 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. Parce que 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' -- type 2 diabetes (SNOMED)
Et une SQLQuery paramétrée se place 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é, et vous le gardez acyclique — comme les vues dans n'importe quelle base de données.
Empilez suffisamment de ces éléments et vous avez 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 constituent la couche de transit : les données FHIR brutes chargées sous forme de tables plates. Les SQLViews sont les modèles intermédiaires : blocs de construction nettoyés, filtrés et joints. Le sommet du graphe est l'entrepôt de données : 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 de façon autonome : les vues de transit ne font que restructurer, chaque modèle intermédiaire ajoute une seule étape de transformation, les requêtes ne font que paramétrer la coupe finale. La complexité réside dans la composition, non dans un seul artefact — ce qui est exactement la façon dont les systèmes analytiques matures sont construits dans les entrepôts de données 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 des faits et des dimensions, une conversion de cent tables — tout ce que vous pouvez exprimer comme du SQL en couches sur des vues plates, vous pouvez l'exprimer sous forme de ce graphe.
Deux avantages supplémentaires se dégagent naturellement. Le graphe de dépendances est votre lignée de données : pour n'importe quelle colonne de l'entrepôt, 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 mandate délibérément pas, c'est l'exécution : que ActivePatientsView devienne une CTE inline, une vue de base de données ou une table matérialisée est à la discrétion du 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 les données brutes en premier, les transformer en couches à l'intérieur du moteur. Si vous connaissez dbt, vous connaissez déjà cette forme. Le moment où nous avons ajouté la possibilité de construire des requêtes sur d'autres requêtes, SQL on FHIR est devenu un DSL complet pour décrire des pipelines ELT : ViewDefinition vous donne le EL — extraire FHIR, charger des tables plates — et SQLView avec SQLQuery ajoutent le T. La boucle est bouclée.
Alors qu'est-ce qui est vraiment nouveau ici, par rapport à l'outillage 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 sur n'importe quelle pile conforme. Et parce que les artefacts sont neutres par rapport à la technologie, des gens écriront des traducteurs pour les convertir en actifs spécifiques à une 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 l'entrepôt de données une fois ; la technologie cible est un détail de compilation.
Cela complète également le récit de l'authoring. Reprenez 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 pack livrable. Nous croyons que SQL on FHIR fait partie de l'authoring lui-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 à un 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 carnet — tout ce qui parle l'API — fonctionne contre n'importe quel serveur conforme. Changez le serveur, gardez le flux de travail. Les clients découvrent ce qu'un serveur prend en charge grâce au CapabilityStatement standard.
L'API comporte 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 standardise 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 transit brute, d'un modèle intermédiaire ou d'une requête finale paramétrée. Et materialize s'applique à la fois aux ViewDefinitions et aux SQLViews — tout nœud non paramétré peut devenir une table gérée.
run — pour l'authoring et le temps réel
$viewdefinition-run — invoqué comme $run sur la ressource ViewDefinition — est une opération synchrone conçue pour l'authoring et les cas d'usage avec de petits ensembles de données. L'appel le plus simple est un GET sur une vue stocké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
Quand la vue n'est pas encore sauvegardée, vous la POSTez en ligne comme corps Parameters — éventuellement avec les ressources à transformer. C'est la boucle d'authoring : 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'intelligence artificielle 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, non des propriétés de vue — la même définition de vue sert l'exportation en population complète et la requête sur un seul patient.
$sqlquery-run est le même verbe une couche plus haut : exécuter une SQLQuery stockée ou inline 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écuritaire 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 : évaluer n'importe quelle SQLView intermédiaire — avec l'ensemble de son sous-graphe de dépendances résolu par le serveur — et retourner les lignes en flux. Très pratique quand vous déboguez un nœud d'un pipeline profond.
export — exportation en vrac, améliorée
Considérez $viewdefinition-export comme une version améliorée de FHIR Bulk Export. Avec l'exportation en vrac 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 compartiment est déjà plat — csv, ndjson ou parquet, prêt à être lu par Spark, DuckDB, Athena, ou chargé dans un entrepôt.
Le flux comporte quatre étapes :
- Lancer. POSTez une liste de vues avec 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 ticket. Le serveur répond
202 Acceptedavec un en-têteContent-Location— votre URL de statut. -
Sonder. L'URL de statut retourne
202pendant l'exécution du travail (avec une progression optionnelle). Quand le travail se termine, elle répond303 See Otheravec un en-têteLocationpointant vers le résultat. -
Récupérer les fichiers. GET l'URL de 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 des tables prêtes à l'analyse plutôt que des ressources brutes. Il y a moins de choses à exporter, vous sautez complètement l'étape ETL, et un serveur qui prend en charge l'exportation nativement peut optimiser considérablement sous le capot.
Les mêmes filtres s'appliquent qu'avec 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 transit pour votre lac de données, ou exportez l'entrepôt final — à vous de choisir le point de coupe.
materialize — « 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 d'elle, 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 dès lors :
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 — dépend de l'implémentation, avec targetName comme handle 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 d'intelligence d'affaires 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 son actualisation. 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 vers ClickHouse, BigQuery et Databricks : un chargement initial très efficace — des millions de ressources en secondes — puis la table est maintenue à jour en quasi temps réel grâce aux abonnements. La même définition de vue, quatre moteurs différents — ce qui est précisément l'idée. L'opération est maintenant en cours de standardisation pour que « garde cette table à jour » devienne une requête portable, non une fonctionnalité propriétaire.
En résumé : run pour le développement et l'accès en temps réel, export pour alimenter des moteurs externes, materialize pour l'analytique en base de données — tous des points d'accès standard, tous neutres par rapport au fournisseur.
Vers quoi cela tend : 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 exécutez simplement vos rapports. Et puisque 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 : mettre le DSL à l'épreuve
Voilà donc la trousse d'outils complète : un DSL pour le transit (ViewDefinition), un DSL pour les transformations (SQLView/SQLQuery), et une API pour tout exécuter. La meilleure façon de savoir si une trousse d'outils est réelle est de lui soumettre la conversion la plus difficile que nous connaissons — 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 partie de la 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 qu'il est extrêmement pragmatique : un ensemble fixe de tables, tous les codes normalisés en concepts standards, toute l'infrastructure — la terminologie 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-first 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 faisons de même — la conversion est un DAG en couches composé exactement des artefacts décrits ci-dessus :
Les bases, couche par couche.
Les ViewDefinitions de transit 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 mappage contiennent la sémantique : les vocabulaires OMOP Athena sont chargés directement dans la base de données, et la résolution des identifiants de concept est une jointure, non 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 paraît complexe, mais c'est en fait assez simple : ça effectue le mappage, ça fait les jointures, ça traduit à la volée. Et les jointures gèrent naturellement les aspects vraiment difficiles — la dispersion Maps to (un code ICD devenant plusieurs lignes SNOMED), le routage par domaine (une Condition FHIR atterrissant dans condition_occurrence, observation ou measurement selon le domaine du concept cible), et les règles de rejet pour les enregistrements non mappables.
Les SQLQueries de chargement lisent les vues mappées et peuplent 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 plutôt que FHIRPath ou le FHIR Mapping Language ? Parce que pour ce travail, vous auriez besoin de recherches dans des tables de mappage, de diviser un enregistrement en plusieurs, d'une logique conditionnelle à travers des vocabulaires. On pourrait en principe acheminer chaque code vers le $translate d'un serveur de terminologie — mais ce n'est pas viable pour la transformation en vrac ; les gens brûleront des cycles de calcul à le faire ligne par ligne. Et cela doit être efficace à l'échelle d'une population — des milliards d'enregistrements, non des milliers. Pour nous, c'est simplement du SQL, et les jointures ensemblistes sur des milliards de lignes est le problème que les bases de données ont passé cinquante ans à maîtriser.
Un avertissement complet : 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 un profil (une ressource se convertit en table OMOP si et seulement si elle se valide contre un profil FHIR de conditionnement), 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 les modules de juridiction comme US Core vers OMOP ou ICD allemand vers OMOP que la communauté peut étendre.
Mais l'objectif est plus ambitieux qu'un seul convertisseur. FHIR vers OMOP est la façon dont nous mettons SQL on FHIR à l'épreuve : c'est la conversion la plus difficile qui soit, et si le DSL peut l'exprimer — le transit, les jointures de vocabulaires, le routage par domaine, tout sous forme d'artefacts portables — alors tout ce qui est plus simple vient automatiquement. Nous invitons donc tout le monde : les gens d'OMOP, les gens de FHIR, les ingénieurs de données. Apportez vos mappages, 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 terrain de jeu pour un avant-goût en cinq minutes, ou l'atelier complet DevDays — PostgreSQL, Grafana, Jupyter, données Synthea — pour une mise en main pratique. Et notez à votre agenda FHIR Analytics — notre conférence en ligne gratuite dédiée exactement à cet espace.
Et si vous voulez tout exécuter dès aujourd'hui : Aidbox est un serveur FHIR transactionnel avec de l'analytique en temps réel intégrée sur SQL on FHIR — et le premier serveur FHIR à réussir tous les tests de SQL on FHIR. ViewDefinitions sur PostgreSQL, un constructeur visuel de ViewDefinition et des gestionnaires de vues et 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 réunis sur un même 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 forger ces idées en une norme.





