|
22 min de lectura
|

SQL on FHIR: Analíticas interoperables

Resumen del artículo

SQL on FHIR comenzó como una especificación comunitaria para aplanar recursos FHIR. Hoy es un DSL completo para describir ELT y analíticas interoperables: ViewDefinition para la capa de staging, SQLQuery y SQLView para transformaciones en capas, y una API estándar run/export/materialize — con cada capa separando la autoría de la implementación y el uso. Próximos pasos: el núcleo de FHIR R6, donde ViewDefinition se convierte en un recurso estándar, y una guía de implementación abierta de FHIR a OMOP construida sobre este DSL.

Resumir este artículo con:
ChatGPTPerplexityClaudeGrok

Existen dos mundos maduros que apenas se comunican entre sí.

Por un lado, FHIR. Por fin disponemos de una gran cantidad de datos FHIR — la gran mayoría de los hospitales de EE. UU. exponen APIs FHIR y la proporción crece año tras año, mientras que los sistemas FHIR-first almacenan datos clínicos de forma nativa en recursos. Por otro lado, existe un ecosistema muy maduro de herramientas analíticas: bases de datos modernas, plataformas de BI, dataframes y todo el stack de datos moderno.

Ya es posible cargar FHIR en estas herramientas — las bases de datos modernas gestionan bien los datos anidados: Postgres con JSON binario, BigQuery, Spark, DuckDB. De hecho, la primera versión de SQL on FHIR era exactamente eso: consultar FHIR anidado directamente en la base de datos. Funcionaba — y no llegaba a estandarizarse. Cada motor tiene su propio dialecto para datos anidados; una consulta escrita para BigQuery no tiene nada en común con la misma consulta en Postgres. No había nada que estandarizar.

En el fondo subyace el viejo problema de la impedancia objeto-relacional: los recursos FHIR son documentos anidados, y el mundo analítico — SQL, herramientas de BI, dataframes — sigue necesitando tablas planas. La solución ingenua de generar una tabla para cada elemento FHIR simplemente no funciona: se obtienen mil tablas que nadie puede comprender. Así que todo el mundo acaba escribiendo ETL específico para cada caso de uso con el fin de aplanar los recursos — repitiendo el mismo trabajo, con ligeras variaciones, y con los mismos errores sutiles en torno a arrays, tipos de elección y referencias.

La versión dos de SQL on FHIR es nuestro intento de construir un puente estándar entre estos dos mundos — esta vez estandarizando las vistas planas en lugar de las consultas anidadas. Comenzó como un borrador comunitario; hoy es una especificación completa desarrollada de forma abierta, adoptada en el proceso de votación de HL7, publicada bajo CC0 — con un artículo revisado por pares en npj Digital Medicine que valida el enfoque en ~300.000 pacientes y replica un estudio clínico publicado en dos pilas de implementación independientes. Y en FHIR R6, ViewDefinition está en camino de convertirse en un recurso FHIR estándar — un additional resource, el mecanismo de R6 para hacer crecer la especificación núcleo en módulos.

Lo que el estándar realmente aporta es separación: la autoría se separa de la implementación, y la implementación del uso. Una persona describe las analíticas; el motor de cualquiera las ejecuta; cualquier herramienta consume el resultado.

Y ya no son solo definiciones de vistas lo que se puede crear. Usted describe sus analíticas completas como artefactos portables y estándar — vistas planas, las capas de transformación sobre ellas, consultas, data marts completos y pipelines de conversión. Ejecútelos en distintos motores, sobre diferentes conjuntos de datos, contra servidores de distintos fabricantes — e incluso distribúyalos como una guía de implementación, como cualquier otra pieza del ecosistema FHIR. Eso es lo que significa «analíticas interoperables», y la especificación ahora lo ofrece en tres niveles: vistas, consultas y la API.

ViewDefinition: un lenguaje para aplanar

Una ViewDefinition es, en términos simples, un lenguaje para explicarle a un servidor cómo aplanar recursos en una tabla plana. Describe una vista tabular de exactamente un tipo de recurso — columnas, filtros y desanidamiento — utilizando un subconjunto mínimo 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"}
      ]
    }
  ]
}

Como la definición es declarativa y neutral respecto al motor, la misma vista funciona en un runner ETL de JavaScript, dentro de PostgreSQL, en Spark o sobre un conjunto de archivos ndjson procedentes de un Bulk Export. El modelo completo se reduce a cinco funciones componibles, y un runner completo es lo suficientemente pequeño como para leerlo en una tarde — la implementación de referencia en JavaScript tiene aproximadamente 400 líneas:

FunciónQué haceAnalogía SQL
columnExtrae elementos mediante FHIRPath en columnasSELECT
whereFiltra recursos (p. ej., por perfil o estado)WHERE
forEach / forEachOrNullDesanida una colección en filasINNER JOIN / LEFT OUTER JOIN contra una tabla anidada
selectCombina columnas padre con filas desanidadas mediante producto cartesianojoin de subselects
unionAllConcatena filas de distintas ramasUNION ALL

Una aclaración honesta: este aplanamiento es con pérdida, y somos conscientes de ello. No existe una representación tabular universal de FHIR, por lo que las vistas son específicas para cada caso de uso por diseño — a veces bromo diciendo que una ViewDefinition es el modo CSV de FHIR. Eso no es una debilidad; es el contrato: un ingeniero define la vista una vez, y otros ingenieros y herramientas simplemente utilizan la tabla plana.

Una unidad natural para una vista es un perfil. Imagine un perfil de presión arterial — y sobre él una bonita tabla blood_pressure con columnas systolic y diastolic, una fila por medición. El perfil determina dónde viven los datos en el recurso; la vista transforma ese conocimiento en columnas. Todo perfil bien definido es una tabla plana esperando ser declarada.

Dos adiciones recientes merecen especial atención:

  • repeat gestiona estructuras verdaderamente recursivas — elementos de QuestionnaireResponse, conceptos de CodeSystem — donde no se conoce la profundidad de antemano. Indíquele las rutas a recorrer y cada elemento anidado se convierte en una fila, independientemente de su nivel.
  • %rowIndex captura la posición de un elemento durante la iteración. Los conjuntos de resultados SQL no tienen orden, pero los arrays de FHIR sí — el primer name no es igual al tercero. El índice permite preservar el orden de FHIR y construir claves sustitutas.

Joins sin joins

Una única ViewDefinition nunca une recursos — por diseño. En su lugar, dos funciones emiten claves sobre las que la base de datos realiza joins: getResourceKey() para la clave primaria de la fila, y subject.getReferenceKey(Patient) para la clave foránea (con un filtro de tipo que devuelve una colección vacía — un null en la salida — si la referencia apunta a otro lugar). Una vista de Condition incluye patient_id; unir condiciones con pacientes es un simple join SQL en cualquier motor que utilice.

La forma en que se derivan las claves — id simple, identificador primario, hash — queda a criterio de la implementación. Eso es deliberado: es lo que hace que la misma vista sea portable entre sistemas con diferentes invariantes de datos.

Y las cosas que ViewDefinition deliberadamente no hace — sin joins entre recursos, sin agregación, sin ordenación, sin formatos de salida — no son lagunas. Cada una de ellas fue una decisión que debatimos: ¿debemos complicar cada runner de vistas, o delegamos en la base de datos? Las bases de datos están especializadas en joins; nosotros no cruzamos el límite del recurso. Esa moderación es lo que hace que un runner sea implementable en cualquier lugar, desde una biblioteca de 400 líneas hasta un motor SQL distribuido.

Los runners en sí mismos se presentan en dos variantes. Los runners ETL toman un flujo de recursos y producen filas — triviales de escribir si se dispone de un motor FHIRPath (algunos implementadores han reportado haber escrito uno en Rust en aproximadamente un mes). Los runners ELT se parecen más a transpiladores que a runners: compilan una ViewDefinition en una sofisticada consulta SQL sobre una base de datos nativa FHIR, de modo que la vista se convierte en una vista de base de datos real y nada se duplica — cientos de definiciones de vistas pueden ejecutarse sobre la misma tabla de recursos.

Y la especificación se construyó de abajo hacia arriba, no de arriba hacia abajo: las implementaciones existían antes del primer lanzamiento. Una suite de pruebas compartida forma parte de la especificación — conjunto de datos, definición de vista, resultado esperado — y la matriz de conformidad son los propios implementadores ejecutando las mismas pruebas e informando de los resultados. Así es como garantizamos que las implementaciones permanezcan compatibles entre sí.

SQLQuery y SQLView: las consultas se vuelven compartibles

Las tablas planas son la mitad del puente. La otra mitad: ¿dónde viven las consultas? La definición de cohorte, la medida de calidad, la consulta del dashboard — estos son los artefactos menos portables en las analíticas de salud actuales, dispersos entre wikis y notebooks, vinculados al esquema de un único sitio.

Así que emparejamos la definición de vista con un recurso de consulta, y ahora es posible compartir el conjunto completo. SQLQuery es un perfil sobre el recurso Library de FHIR que empaqueta una consulta SQL lógica:

{
  "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 estructura se descompone en tres ideas:

  • Dependencias como alias. Se declaran las definiciones de vistas de las que depende la consulta, y el label se convierte en el nombre de la tabla en el SQL. La consulta nunca codifica nombres físicos de tablas — el entorno de ejecución resuelve pt y dg hacia lo que sea que las vistas estén materializadas.
  • Parámetros seguros. Los parámetros se declaran en la Library y se referencian como marcadores de posición :from_date. La especificación es contundente: NO DEBE utilizarse interpolación de cadenas — solo binding real.
  • Variantes de dialecto. Una Library puede incluir un valor por defecto application/sql portable junto con adjuntos ;dialect=postgresql o ;dialect=spark, siempre que sean funcionalmente equivalentes.

También existe una conveniencia de autoría que la especificación describe para las herramientas: escribir un archivo .sql plano con algunos comentarios de anotación (@name, @param, @relatedDependency) y dejar que un constructor lo transforme en el recurso Library. El SQL permanece como fuente de verdad; FHIR se convierte en el empaquetado.

SQLView es el perfil más reciente, y existe por una razón: para poder apilar consultas unas sobre otras. Es casi idéntico a SQLQuery pero sin parámetros — y la intención es diferente. Una consulta es algo que se ejecuta; una vista describe una tabla. Cuando se dice «necesito una vista en una base de datos», la gente entiende de qué se habla — por eso es un perfil separado en lugar de un indicador sobre SQLQuery.

Cómo se apilan los SQLViews

Un SQLView es una Library con type = sql-view, una URL canónica, dependencias y SQL. He aquí uno que define «pacientes activos» sobre 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": "..."
  }]
}

La parte crucial es la url. Dado que la vista tiene una URL canónica, cualquier cosa puede depender de ella de la misma manera que dependería de una ViewDefinition — basta con añadir una entrada relatedArtifact y usar el label como nombre de tabla. Una segunda vista se construye sobre la primera:

-- 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'   -- diabetes tipo 2 (SNOMED)

Y una SQLQuery parametrizada se sitúa sobre ambas capas para el informe final. Las reglas de dependencia son sencillas:

  • Un SQLView puede depender de ViewDefinitions y otros SQLViews — nunca de SQLQueries.
  • Un SQLQuery puede depender de ViewDefinitions y SQLViews.
  • Las referencias forman un grafo dirigido, que debe mantenerse acíclico — como las vistas en cualquier base de datos.

Apile suficientes de estos y habrá descrito un sistema analítico completo — en los mismos términos que un equipo de datos utilizaría para cualquier almacén. Las ViewDefinitions son la capa de staging: FHIR en bruto cargado como tablas planas. Los SQLViews son los modelos intermedios: bloques de construcción limpios, filtrados y unidos. La parte superior del grafo es el data mart: los registros de cohortes, tablas de hechos e informes parametrizados que los analistas utilizan directamente.

Capa de staging — ViewDefinitions Modelos intermedios — SQLViews Data mart — SQLQueries patient_demographics diagnoses_view observations_view ActivePatientsView DiabeticPatientsView LatestHbA1cView DiabetesRegisterView RegisterByAgeReport(:from_date) PatientDrilldown(:patient_id)

Cada nodo es pequeño, legible y comprobable por sí mismo: las vistas de staging solo remodelan, cada modelo intermedio añade un paso de transformación, las consultas solo parametrizan el corte final. La complejidad reside en la composición, no en ningún artefacto individual — que es exactamente cómo se construyen los sistemas analíticos maduros dentro de los almacenes hoy en día. No existe un límite aquí: un registro de enfermedades, una suite de medidas de calidad, un modelo dimensional con hechos y dimensiones, una conversión de cien tablas — cualquier cosa que pueda expresarse como SQL en capas sobre vistas planas puede expresarse como este grafo.

Dos ventajas adicionales surgen de forma natural. El grafo de dependencias es su linaje de datos: para cualquier columna del data mart puede trazar, artefacto por artefacto, el camino de vuelta hasta la expresión FHIRPath que la produjo. Y el grafo completo se distribuye como recursos FHIR.

Lo que la especificación deliberadamente no exige es la ejecución: si ActivePatientsView se convierte en un CTE en línea, una vista de base de datos o una tabla materializada es decisión del motor — el grafo describe la lógica, el motor elige la física.

Un DSL completo para ELT

Dígale a un ingeniero de datos «tenemos un DAG aquí» — y lo entenderá de inmediato. El grafo que acaba de ver es ELT, el patrón dominante del stack de datos moderno: cargar primero los datos en bruto, transformarlos en capas dentro del motor. Si conoce dbt, ya reconoce esta forma. En el momento en que añadimos la capacidad de construir consultas sobre consultas, SQL on FHIR se convirtió en un DSL completo para describir pipelines ELT: ViewDefinition proporciona el EL — extrae FHIR, carga tablas planas — y SQLView con SQLQuery añaden la T. El ciclo queda cerrado.

¿Qué hay realmente de nuevo aquí, en comparación con las herramientas ELT que los equipos de datos ya utilizan? El modelo de distribución. Un proyecto de transformación es normalmente código en su repositorio, asumiendo su almacén. Un pipeline SQL on FHIR es un conjunto de recursos con URLs canónicas que puede publicarse en una guía de implementación, versionarse y ejecutarse contra cualquier stack conforme. Y dado que los artefactos son tecnológicamente neutros, la gente escribirá traductores que los conviertan en activos específicos del stack — una vista de almacén, un paso en su orquestador, un modelo en el framework de transformación que use su equipo. Describimos el data mart una vez; la tecnología de destino es un detalle de compilación.

Esto también completa la historia de la autoría. Tome el ejemplo de la presión arterial de antes — el perfil y su vista systolic/diastolic — y añada ahora un conjunto de consultas útiles sobre él: informes de hipertensión, dashboards de tendencias. Perfil, vista, consultas — un paquete listo para distribuir. Creemos que SQL on FHIR pasa a formar parte de la autoría en sí: una IG que define datos debería incluir las vistas y consultas para analizarlos.

La API: clientes portables, sin dependencia del proveedor

La tercera pieza es una API HTTP estándar, y es importante por la misma razón que los artefactos: sin ella, sus herramientas quedan vinculadas a un proveedor aunque sus vistas no lo estén. Con ella, un dashboard, un pipeline, un notebook — cualquier cosa que hable la API — funciona contra cualquier servidor conforme. Cambie el backend, conserve el flujo de trabajo. Los clientes descubren lo que soporta un servidor a través del CapabilityStatement estándar.

La API tiene tres verbos. Cada uno responde a una pregunta diferente:

VerboPregunta que respondeOperacionesModo
run«Dame las filas, ahora»$viewdefinition-run, $sqlview-run*, $sqlquery-runSíncrono, en streaming
export«Construye los archivos, avísame cuando esté listo»$viewdefinition-export, $sqlview-export*, $sqlquery-exportAsíncrono, hacia almacenamiento de archivos
materialize«Mantén una tabla actualizada para mí»$materialize*Asíncrono, gestionado por el servidor

* $sqlview-run, $sqlview-export y $materialize se están incorporando a la especificación — el grupo de trabajo los está estandarizando ahora.

Nótese la simetría: cada artefacto en el grafo de dependencias — ViewDefinition, SQLView, SQLQuery — recibe los mismos verbos. Puede ejecutar o exportar cualquier nodo de su pipeline, ya sea una vista de staging en bruto, un modelo intermedio o una consulta final parametrizada. Y materialize se aplica tanto a ViewDefinitions como a SQLViews — cualquier nodo sin parámetros puede convertirse en una tabla gestionada.

run — para autoría y tiempo real

$viewdefinition-run — invocado como $run sobre el recurso ViewDefinition — es una operación síncrona diseñada para la autoría y casos de uso con conjuntos de datos pequeños. La llamada más sencilla es un GET sobre una vista almacenada:

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

Cuando la vista aún no está guardada, se hace un POST en línea como cuerpo Parameters — opcionalmente junto con los recursos a transformar. Ese es el ciclo de autoría: edite la definición, haga POST, vea las filas, repita. También es el camino en tiempo real: un widget de dashboard o un agente de IA que quiere las condiciones de un paciente como tabla plana en lugar de un grafo de recursos. Las consideraciones en tiempo de ejecución permanecen en el tiempo de ejecución: patient, group, _since y _limit son parámetros de operación, no propiedades de la vista — la misma definición de vista sirve para la exportación de población completa y la consulta de un único paciente.

$sqlquery-run es el mismo verbo una capa más arriba: ejecutar una SQLQuery almacenada o en línea contra las vistas materializadas, pasando los parámetros de consulta por nombre (un recurso Parameters anidado, vinculado de forma segura a las entradas Library.parameter declaradas), con _format y _limit para el control de la salida. Y $sqlview-run (próximamente en la especificación) ocupa el lugar intermedio: evalúa cualquier SQLView intermedio — con todo su subgrafo de dependencias resuelto por el servidor — y devuelve las filas en streaming. Muy útil cuando se está depurando un nodo de un pipeline profundo.

export — exportación masiva, mejorada

Piense en $viewdefinition-export como un FHIR Bulk Export mejorado. Con el Bulk Export clásico se obtienen todos los recursos, como ndjson en bruto — y el aplanamiento es su problema: hay que montar un pipeline ETL solo para hacer los datos consultables. Con la exportación SQL on FHIR se solicitan las vistas que realmente se necesitan, y lo que llega a su bucket ya está plano — csv, ndjson o parquet, listo para ser leído por Spark, DuckDB, Athena o cargado en un almacén.

El flujo tiene cuatro pasos:

  1. Inicio. Se hace un POST con una lista de vistas con la cabecera asíncrona:
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"}
  ]
}
  1. Obtención del ticket. El servidor responde con 202 Accepted y una cabecera Content-Location — su URL de estado.

  2. Polling. La URL de estado devuelve 202 mientras el trabajo se ejecuta (con progreso opcional). Cuando el trabajo finaliza, responde con 303 See Other con una cabecera Location que apunta al resultado.

  3. Recopilación de archivos. Se hace GET a la URL de resultado — la respuesta lista una URL de salida por vista:

{
  "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 ha implementado Bulk Data Export, ya conoce esta coreografía — la misma orquestación asíncrona, pero el resultado son tablas listas para análisis en lugar de recursos en bruto. Hay menos datos que exportar, se omite por completo el paso ETL, y un servidor que soporta la exportación de forma nativa puede optimizar considerablemente internamente.

Los mismos filtros se aplican que para run — patient, group, _since — de modo que un pagador puede exportar las vistas de un único miembro con la misma facilidad que las de toda una población. Y el verbo se extiende hacia arriba en el grafo: $sqlview-export (próximamente en la especificación) materializa un modelo intermedio en archivos, $sqlquery-export hace lo mismo para resultados de consultas demasiado grandes para una respuesta síncrona. Exporte la capa de staging para su lake, o exporte el data mart terminado — usted elige el punto de corte.

materialize — «servidor, mantenlo actualizado»

$materialize es la operación mediante la cual se le indica al servidor: aquí está mi definición de vista — construye una vista gestionada a partir de ella y mantenla actualizada conforme cambien los datos. Es una operación asíncrona: se le proporciona un nombre de destino y una política de actualización (manual, o scheduled con una expresión cron), el servidor construye la vista en segundo plano, y cuando el trabajo finaliza se obtiene una referencia a la vista materializada que puede consultarse desde ese momento:

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 * * *"}
  ]
}

Cuando el trabajo finaliza se obtiene una referencia a la vista materializada; cómo se expone para su consulta — esquema, nomenclatura, acceso — queda a criterio de la implementación, con targetName como identificador solicitado. A partir de ese punto el servidor es responsable de la actualización (nocturna en este ejemplo), y cualquier herramienta SQL simplemente consulta el resultado:

SELECT * FROM patient_demographics WHERE dob > '1990-01-01';

Su herramienta de BI se conecta a una tabla y nunca sabe que FHIR estaba implicado.

El mismo verbo se aplica a los SQLViews. Materializar un modelo intermedio es exactamente lo que se hace en un almacén cuando una vista se vuelve muy demandada: el servidor resuelve el subgrafo de dependencias, construye la tabla y se ocupa de su refresco. Qué nodos de su DAG están materializados y cuáles permanecen virtuales se convierte en una decisión de ajuste en tiempo de ejecución — la definición del pipeline no cambia.

Este patrón ya está probado en producción. En Aidbox implementamos $materialize sobre PostgreSQL, más adaptadores para ClickHouse, BigQuery y Databricks: una carga inicial muy eficiente — millones de recursos en segundos — y luego la tabla se mantiene actualizada en tiempo casi real mediante suscripciones. La misma definición de vista, cuatro motores diferentes — que es precisamente la cuestión. La operación se está estandarizando ahora para que «mantener esta tabla actualizada» se convierta en una solicitud portable, no en una característica de un proveedor.

En conjunto: run para desarrollo y acceso en tiempo real, export para alimentar motores externos, materialize para analíticas dentro de la base de datos — todos endpoints estándar, todos neutrales respecto al proveedor.

Hacia dónde se dirige esto: desde la perspectiva del usuario, el servidor se convierte en una caja mágica. Se le envían definiciones de vistas y consultas; las vistas se mantienen actualizadas; simplemente se ejecutan los informes. Y dado que los LLMs ya son bastante buenos creando SQL y definiciones de vistas, la siguiente interfaz sobre esta caja es el lenguaje natural — con la API estándar como lo que el agente maneja por debajo.

FHIR a OMOP: poniendo a prueba el DSL

Ese es el conjunto de herramientas completo: un DSL para staging (ViewDefinition), un DSL para transformaciones (SQLView/SQLQuery), y una API para ejecutarlo todo. La mejor manera de comprobar si un conjunto de herramientas es real es someterlo a la conversión más exigente que conocemos — y esa es FHIR a OMOP. Francamente, este proyecto ya dictó partes del diseño: necesitábamos vistas sobre vistas para expresarlo, y esa necesidad es en gran parte la razón por la que existe SQLView.

OMOP CDM es el estándar de OHDSI para la investigación observacional, y lo que me gusta de OMOP es que es extremadamente pragmático: un conjunto fijo de tablas, todos los códigos normalizados a conceptos estándar, toda la infraestructura — incluyendo la terminología — almacenada directamente en la base de datos. FHIR es el modelo transaccional; OMOP es el analítico. A medida que los sistemas FHIR-first se extienden, «FHIR para OLTP, OMOP para OLAP» se convierte en la arquitectura por defecto, y la capa de conversión entre ellos se convierte en infraestructura crítica.

La propia comunidad OMOP generalmente construye pipelines al estilo ELT. Nosotros también — la conversión es un DAG en capas de exactamente los artefactos descritos anteriormente:

ViewDefinitions(capa de staging) SQLViews(capa de mapeo+ vocabularios OMOP) SQLQueries(capa de carga) Recursos FHIR condition_stagingpatient_stagingobservation_staging condition_mappedperson_mappeddomain_routed OMOP CDMcondition_occurrenceperson, measurement Herramientas OHDSIAtlas, HADES

Lo básico, capa por capa.

Las ViewDefinitions de staging aplanan cada recurso exactamente en lo que OMOP necesita — claves, fechas y codificaciones de origen, una fila por codificación:

{
  "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"}
      ]
    }
  ]
}

Los SQLViews de mapeo contienen la semántica: los vocabularios Athena de OMOP se cargan directamente en la base de datos, y la resolución de concept-id es un JOIN, no una llamada a un servidor de terminología:

-- 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

Parece complicado, pero en realidad es bastante sencillo: realiza el mapeo, hace los joins y traduce al vuelo. Y los joins gestionan de forma natural las partes genuinamente difíciles — el fan-out de Maps to (un código ICD que se convierte en varias filas SNOMED), el enrutamiento de dominio (una Condition de FHIR que aterriza en condition_occurrence, observation o measurement según el dominio del concepto de destino), y las reglas de descarte para registros no mapeables.

Las SQLQueries de carga leen las vistas mapeadas y pueblan las tablas del 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'

¿Por qué SQL y no FHIRPath o el Lenguaje de Mapeo de FHIR? Porque para este trabajo se necesitan búsquedas en tablas de mapeo, división de un registro en muchos, lógica condicional entre vocabularios. En principio se podría enrutar cada código a través del $translate de un servidor de terminología — pero eso no es viable para la transformación masiva; la gente acabaría quemando CPUs procesándolo fila a fila. Y esto debe ser eficiente a escala poblacional — miles de millones de registros, no miles. Para nosotros esto es simplemente SQL, y los joins basados en conjuntos sobre miles de millones de filas es el problema en el que las bases de datos han pasado cincuenta años perfeccionándose.

Una aclaración completa: este proyecto — fhir2omop — se encuentra en una etapa muy temprana, un trabajo en curso desarrollado de forma abierta. Las ideas que exploramos: conversión con puerta de perfil (un recurso se convierte a una tabla OMOP si y solo si valida contra un perfil FHIR de control), casos de prueba dorados (un recurso FHIR como entrada, las filas OMOP exactas como salida — porque los casos extremos son exactamente lo que las muestras hacen visible), y módulos de jurisdicción como US Core to OMOP o German ICD to OMOP que la comunidad puede extender.

Pero el objetivo es más ambicioso que un solo convertidor. FHIR-a-OMOP es cómo ponemos a prueba SQL on FHIR en condiciones reales: es la conversión más exigente que existe, y si el DSL puede expresarla — el staging, los joins de vocabulario, el enrutamiento de dominio, todo ello como artefactos portables — entonces todo lo más sencillo viene de forma gratuita. Por eso invitamos a todos: personas del mundo OMOP, personas del mundo FHIR, ingenieros de datos. Traigan sus mapeos, sus casos extremos, su escepticismo — el grupo de trabajo está dedicando sesiones a OMOP, y la sala está abierta.

Únase a construirlo con nosotros

Únase al stream #analytics-on-FHIR en chat.fhir.org y a las llamadas semanales del grupo de trabajo. Pruebe el playground para una experiencia de cinco minutos, o el taller completo de DevDays — PostgreSQL, Grafana, Jupyter, datos de Synthea — para un inicio práctico. Y anote en su calendario FHIR Analytics — nuestra conferencia online gratuita dedicada exactamente a este ámbito.

Y si desea ejecutar todo esto hoy mismo: Aidbox es un servidor FHIR transaccional con analíticas en tiempo real integradas sobre SQL on FHIR — y el primer servidor FHIR en pasar todos los tests de SQL on FHIR. ViewDefinitions sobre PostgreSQL, un constructor visual de ViewDefinitions y gestores de vistas y consultas SQL en la interfaz de usuario, $materialize, y adaptadores que mantienen sus tablas actualizadas en ClickHouse, BigQuery y Databricks. El mundo transaccional y el analítico, finalmente sobre un mismo puente.


Nada de esto es obra de una sola persona. Agradecimiento especial a John Grimes, Arjun Sanyal, Gino Canessa y Steve Munini — y a todo el grupo de trabajo de SQL on FHIR que acude semana tras semana a debatir estas ideas hasta convertirlas en un estándar.

Compartir este artículo
Comments
Comments
Sign in
Loading comments...
Subscribe to our blog

Get the latest articles on FHIR, interoperability, and healthcare IT.