Qué significa el análisis interoperable
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 estadounidenses exponen APIs FHIR y la proporción crece cada año, mientras que los sistemas FHIR-first almacenan datos clínicos de forma nativa en recursos. Por otro lado, un ecosistema de herramientas analíticas muy maduro: bases de datos modernas, plataformas de BI, dataframes, toda la pila de datos moderna. Esta es la brecha de interoperabilidad que el análisis sobre FHIR debe cerrar — y hasta ahora, cada cual la cerraba de forma privada, con ETL a medida.
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 estandarizó nada. 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 la vieja 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, generar una tabla por cada elemento FHIR, sencillamente 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, con los mismos errores sutiles relacionados con arrays, tipos de elección y referencias.
SQL on FHIR versión dos 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, incorporada al proceso de votación de HL7, publicada bajo CC0 — con un artículo revisado por pares en npj Digital Medicine que valida el enfoque sobre ~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 ampliar la especificación central 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 el análisis; el motor de cualquiera lo ejecuta; cualquier herramienta consume el resultado.
Y ya no se trata únicamente de definiciones de vistas. Se describe el análisis completo como artefactos portátiles 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 distintos conjuntos de datos, contra servidores de distintos proveedores — e incluso distribuya todo el conjunto como una guía de implementación, igual que cualquier otro elemento del ecosistema FHIR. Eso es lo que significa «análisis interoperable», y la especificación lo ofrece ahora en tres niveles: vistas, consultas y la API.
ViewDefinition: un lenguaje para aplanar
Una ViewDefinition es, en pocas palabras, un lenguaje para explicar 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 — mediante 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 puede ejecutarse 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 suficientemente pequeño como para leerlo en una tarde — la implementación de referencia en JavaScript tiene aproximadamente 400 líneas:
| Función | Qué hace | Analogía SQL |
|---|---|---|
column | Extrae elementos mediante FHIRPath en columnas | SELECT |
where | Filtra recursos (p. ej., por perfil o estado) | WHERE |
forEach / forEachOrNull | Desanida una colección en filas | INNER JOIN / LEFT OUTER JOIN contra una tabla anidada |
select | Hace un producto cartesiano entre columnas padre y filas desanidadas | join de subconsultas |
unionAll | Concatena filas de distintas ramas | UNION ALL |
Una advertencia honesta: este aplanamiento supone pérdida de información, 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 bromeo diciendo que una ViewDefinition es el modo CSV de FHIR. Esto no es una debilidad; es el contrato: un ingeniero define la vista una vez, y otros ingenieros y herramientas simplemente usan 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 convierte ese conocimiento en columnas. Todo perfil bien definido es una tabla plana esperando a ser declarada.
Merece la pena conocer dos incorporaciones recientes:
repeatgestiona estructuras verdaderamente recursivas — elementos de QuestionnaireResponse, conceptos de CodeSystem — donde no se conoce la profundidad de antemano. Se le indican las rutas a recorrer y cada elemento anidado se convierte en una fila, sea cual sea su nivel.%rowIndexcaptura la posición de un elemento durante la iteración. Los conjuntos de resultados SQL no tienen orden, pero los arrays FHIR sí — el primernameno es lo mismo que el tercero. El índice permite preservar el orden FHIR y construir claves sustitutas.
Joins sin joins
Una sola ViewDefinition nunca une recursos — por diseño. En su lugar, dos funciones emiten claves que su base de datos une: 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 nulo en la salida — si la referencia apunta a otro lugar). Una vista de Condition incluye patient_id; unir condiciones a pacientes es un simple JOIN SQL en el motor que se utilice.
La forma en que se derivan las claves — id simple, identificador primario, hash — queda a cargo de la implementación. Esto es deliberado: es lo que hace que la misma vista sea portable entre sistemas con distintos invariantes de datos.
Y las cosas que ViewDefinition deliberadamente no hace — ningún join entre recursos, ninguna agregación, ninguna ordenación, ningún formato de salida — no son carencias. Cada una de ellas fue una disyuntiva que debatimos: ¿complicamos 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 contención es lo que hace que un runner sea implementable en cualquier entorno, desde una biblioteca de 400 líneas hasta un motor SQL distribuido.
Los runners en sí mismos presentan dos variantes. Los runners ETL toman un flujo de recursos y producen filas — triviales de escribir si se dispone de un motor FHIRPath (hay implementadores que han reportado escribir uno en Rust en aproximadamente un mes). Los runners ELT se parecen más a transpiladores que a runners: compilan una ViewDefinition en una consulta SQL sofisticada sobre una base de datos nativa de FHIR, de modo que la vista se convierte en una vista de base de datos real sin duplicar nada — cientos de definiciones de vistas pueden ejecutarse sobre la misma tabla de recursos.
Y la especificación se construyó de abajo arriba, no de arriba 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 se basa en implementadores que ejecutan las mismas pruebas e informan de los resultados. Así es como garantizamos que las implementaciones se mantengan 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 panel de control — son los artefactos menos portátiles del análisis en salud hoy en día, dispersos en wikis y notebooks, vinculados al esquema de un único centro.
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 articula en torno a tres ideas:
- Dependencias como alias. Se declaran las definiciones de vistas de las que depende la consulta, y el
labelse convierte en el nombre de la tabla en el SQL. La consulta nunca codifica nombres físicos de tablas — el entorno de ejecución resuelveptydgen 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 tajante: la interpolación de cadenas NO DEBE utilizarse — solo binding real. - Variantes de dialecto. Una Library puede incluir un valor por defecto
application/sqlportable más adjuntos;dialect=postgresqlo;dialect=spark, siempre que sean funcionalmente equivalentes.
También existe una comodidad 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 builder lo convierta en el recurso Library. El SQL sigue siendo la fuente de verdad; FHIR se convierte en el empaquetado.
SQLView es el perfil más reciente, y existe por una razón: permitir apilar consultas unas sobre otras. Es casi igual que 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 en SQLQuery.
Cómo se apilan los SQLViews
Un SQLView es una Library con type = sql-view, una URL canónica, dependencias y SQL. A continuación se muestra 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. Como la vista tiene una URL canónica, cualquier cosa puede depender de ella del mismo modo 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 simples:
- Un SQLView puede depender de ViewDefinitions y otros SQLViews — nunca de SQLQueries.
- Una 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: los datos FHIR en bruto cargados como tablas planas. Los SQLViews son los modelos intermedios: bloques de construcción limpios, filtrados y unidos. La cima del grafo es el data mart: los registros de cohortes, tablas de hechos e informes parametrizados que los analistas utilizan directamente.
Cada nodo es pequeño, legible y verificable de forma independiente: las vistas de staging solo reestructuran, 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 ningún techo 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 se obtienen de forma gratuita. El grafo de dependencias es su linaje de datos: para cualquier columna del mart puede trazarse, artefacto por artefacto, el camino de regreso a la expresión FHIRPath que la produjo. Y todo el grafo se distribuye como recursos FHIR.
Lo que la especificación deliberadamente no prescribe es la ejecución: si ActivePatientsView se convierte en una 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 «aquí tenemos un DAG» — y lo entenderá de inmediato. El grafo que acaba de ver es ELT, el patrón dominante de la pila de datos moderna: 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 posibilidad de construir consultas sobre consultas, SQL on FHIR se convirtió en un DSL completo para describir pipelines ELT: ViewDefinition ofrece el EL — extraer FHIR, cargar tablas planas — y SQLView con SQLQuery añaden la T. El ciclo se cierra.
Entonces, ¿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, que asume su almacén. Un pipeline de SQL on FHIR es un conjunto de recursos con URLs canónicas que puede publicar en una guía de implementación, versionar y ejecutar contra cualquier pila conforme. Y como los artefactos son neutros respecto a la tecnología, la gente escribirá traductores desde ellos hacia activos específicos de cada pila — una vista de almacén, un paso en su orquestador, un modelo en el framework de transformación que utilice su equipo. Describimos el data mart una vez; la tecnología objetivo es un detalle de compilación.
Esto también completa la historia de la autoría. Tome el ejemplo de la presión arterial mencionado antes — el perfil y su vista systolic/diastolic — y añada ahora un conjunto de consultas útiles encima: informes de hipertensión, paneles de tendencias. Perfil, vista, consultas — un paquete listo para distribuir. Creemos que SQL on FHIR se convierte en parte de la propia autoría: un IG que define datos debería distribuir las vistas y consultas para analizarlos.
La API: clientes portátiles, sin dependencia del proveedor
La tercera pieza es una API HTTP estándar, y su importancia es la misma que la de los artefactos: sin ella, sus herramientas quedan atadas a un proveedor aunque sus vistas no lo estén. Con ella, un panel de control, un pipeline, un notebook — cualquier cosa que hable la API — funciona contra cualquier servidor conforme. Cambie el backend, mantenga el flujo de trabajo. Los clientes descubren qué soporta un servidor a través del CapabilityStatement estándar.
La API tiene tres verbos. Cada uno responde a una pregunta diferente:
| Verbo | Pregunta que responde | Operaciones | Modo |
|---|---|---|---|
| run | «Dame las filas, ahora» | $viewdefinition-run, $sqlview-run*, $sqlquery-run | Síncrono, en flujo |
| export | «Genera los archivos, avísame cuando estén» | $viewdefinition-export, $sqlview-export*, $sqlquery-export | Asíncrono, a 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.
Obsérvese la simetría: cada artefacto del grafo de dependencias — ViewDefinition, SQLView, SQLQuery — recibe los mismos verbos. Se puede ejecutar o exportar cualquier nodo del pipeline, ya sea una vista de staging en bruto, un modelo intermedio o una consulta parametrizada final. 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. Este es el ciclo de autoría: editar la definición, hacer POST, ver las filas, repetir. También es la ruta en tiempo real: un widget de panel de control o un agente de IA que quiere las condiciones de un paciente como tabla plana en lugar de un grafo de recursos. Las consideraciones de tiempo de ejecución se mantienen en 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 tanto para la exportación de población completa como para la consulta de un único paciente.
$sqlquery-run es el mismo verbo un nivel más arriba: ejecutar una SQLQuery almacenada o en línea contra las vistas materializadas, pasando los parámetros de la consulta por nombre (un recurso Parameters anidado, vinculado de forma segura a las entradas Library.parameter declaradas), con _format y _limit para controlar la salida. Y $sqlview-run (próximamente en la especificación) ocupa el nivel intermedio: evalúa cualquier SQLView intermedio — con todo su subgrafo de dependencias resuelto por el servidor — y devuelve las filas en flujo. Muy útil cuando se depura un nodo de un pipeline profundo.
export — exportación masiva, mejorada
Piense en $viewdefinition-export como una versión mejorada de FHIR Bulk Export. Con el Bulk Export clásico se obtienen todos los recursos en ndjson en bruto — y el aplanamiento es su problema: hay que montar un pipeline ETL solo para que los datos sean consultables. Con la exportación de SQL on FHIR se solicitan las vistas que realmente se necesitan, y lo que llega al bucket ya es plano — csv, ndjson o parquet, listo para ser leído por Spark, DuckDB, Athena o cargado en un almacén.
El flujo consta de cuatro pasos:
- Inicio. POST de 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"}
]
}
-
Obtención del ticket. El servidor responde con
202 Acceptedy una cabeceraContent-Location— su URL de estado. -
Sondeo. La URL de estado devuelve
202mientras se ejecuta el trabajo (con progreso opcional). Cuando el trabajo se completa, responde con303 See Othery una cabeceraLocationque apunta al resultado. -
Recogida de archivos. GET de 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 ya ha implementado Bulk Data Export, conoce esta coreografía — la misma orquestación asíncrona, pero la carga útil son tablas listas para el análisis en lugar de recursos en bruto. Hay menos datos que exportar, se omite el paso ETL por completo, y un servidor que soporta la exportación de forma nativa puede optimizar considerablemente por debajo.
Los mismos filtros aplicables a run también se aplican aquí — patient, group, _since — de modo que un pagador puede exportar las vistas de un solo 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 con resultados de consultas demasiado grandes para una respuesta síncrona. Exporte la capa de staging para su lake, o exporte el mart terminado — usted elige el punto de corte.
materialize — «servidor, mantenlo actualizado»
$materialize es la operación con la que se le dice al servidor: aquí está mi definición de vista — construye una vista gestionada a partir de ella y mantenla actualizada a medida que cambien los datos. Es una operación asíncrona: se le indica un nombre objetivo 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 se completa se obtiene una referencia a la vista materializada que se puede consultar 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 se completa, se obtiene una referencia a la vista materializada; cómo se expone para su consulta — esquema, nomenclatura, acceso — depende de la implementación, con targetName como identificador solicitado. A partir de ese momento el servidor se encarga de la frescura de los datos (nocturnamente 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 sin saber 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 solicitada: el servidor resuelve el subgrafo de dependencias, construye la tabla y gestiona su actualización. Qué nodos del 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 el objetivo. La operación se está estandarizando ahora para que «mantén esta tabla actualizada» se convierta en una petición 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 análisis dentro de la base de datos — todos endpoints estándar, todos neutros 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 LLM ya son bastante buenos en la autoría de 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 opera por debajo.
FHIR a OMOP: probando el DSL en condiciones reales
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 forma de comprobar si un conjunto de herramientas es real es lanzarle la conversión más difícil 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 medida 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 — Terminology incluida — alojada 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 ambos pasa a ser infraestructura crítica.
La propia comunidad OMOP suele construir pipelines en estilo ELT. Nosotros también — la conversión es un DAG en capas de exactamente los artefactos descritos anteriormente:
Los fundamentos, capa por capa.
Las ViewDefinitions de staging aplanan cada recurso en exactamente lo que OMOP necesita — claves, fechas y codificaciones fuente, 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
Puede parecer complejo, pero en realidad es bastante directo: realiza el mapeo, hace los joins y traduce al vuelo. Y los joins gestionan de forma natural las partes verdaderamente difíciles — el fan-out de Maps to (un código ICD que se convierte en varias filas SNOMED), el enrutamiento por dominio (una Condition FHIR que acaba en condition_occurrence, observation o measurement según el dominio del concepto destino), y las reglas de descarte para registros no mapeables.
Las SQLQueries de carga leen las vistas mapeadas y populan 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 FHIR Mapping Language? Porque para este trabajo se necesitan búsquedas en tablas de mapeo, dividir un registro en varios, lógica condicional entre vocabularios. En principio se podría enrutar cada código a través de $translate de un servidor de terminología — pero eso no es viable para la transformación masiva; la gente quemaría 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 son el problema que las bases de datos han dedicado cincuenta años a resolver eficientemente.
Una advertencia completa: este proyecto — fhir2omop — se encuentra en una fase muy temprana, un trabajo en progreso abierto. Las ideas que estamos explorando: conversión condicionada por perfil (un recurso se convierte en una tabla OMOP si y solo si valida contra un perfil FHIR de control), casos de prueba de referencia (un recurso FHIR de entrada, las filas OMOP exactas de salida — porque los casos límite son exactamente lo que los ejemplos hacen visible), y módulos jurisdiccionales como US Core a OMOP o CIE alemán a OMOP que la comunidad puede ampliar.
Pero el objetivo es más ambicioso que un simple convertidor. FHIR-a-OMOP es cómo probamos 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 por dominio, todo ello como artefactos portátiles — entonces todo lo más sencillo se obtiene gratuitamente. Así que invitamos a todos: personas del mundo OMOP, del mundo FHIR, ingenieros de datos. Traigan sus mapeos, sus casos límite, su escepticismo — el grupo de trabajo dedica sesiones a OMOP, y la sala está abierta.
Únase a nosotros para construirlo
Únase al canal #analytics-on-FHIR en chat.fhir.org y a las llamadas semanales del grupo de trabajo. Pruebe el playground para una introducción de cinco minutos, o el taller completo de DevDays — PostgreSQL, Grafana, Jupyter, datos de Synthea — para un comienzo práctico. Y asista a la Conferencia SQL on FHIR — nuestra conferencia online gratuita dedicada exactamente a este ámbito.
Y si desea ejecutar todo esto hoy mismo: Aidbox es un servidor FHIR transaccional con análisis en tiempo real integrado sobre SQL on FHIR — y el primer servidor FHIR en superar todas las pruebas de SQL on FHIR. ViewDefinitions sobre PostgreSQL, un constructor visual de ViewDefinitions y gestores de vistas y consultas SQL en la interfaz, $materialize, y adaptadores que mantienen sus tablas actualizadas en ClickHouse, BigQuery y Databricks. El mundo transaccional y el analítico, por fin sobre un mismo puente.
Nada de esto es obra de una sola persona. Un 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.




