// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
// SPDX-License-Identifier: Apache-2.0
import { evalFhirPathTyped, toTypedValue } from '@medplum/core';
import type { Resource } from '@medplum/fhirtypes';
import { XMLParser } from 'fast-xml-parser';
import fhirpath from 'fhirpath';
import { readFileSync, writeFileSync } from 'node:fs';
const root = './src/fhirpath/r4/';
const xmlData = readFileSync(root + 'tests-fhir-r4.xml', 'utf-8');
const resources: Record<string, Resource> = {
observation: JSON.parse(readFileSync(root + 'observation-example.json', 'utf-8')),
patient: JSON.parse(readFileSync(root + 'patient-example.json', 'utf-8')),
questionnaire: JSON.parse(readFileSync(root + 'questionnaire-example.json', 'utf-8')),
valueSet: JSON.parse(readFileSync(root + 'valueset-example-expansion.json', 'utf-8')),
};
const options = {
attributeNamePrefix: '@_',
attrNodeName: 'attr',
textNodeName: '#text',
ignoreAttributes: false,
ignoreNameSpace: false,
allowBooleanAttributes: true,
parseNodeValue: true,
parseAttributeValue: true,
trimValues: true,
};
const parser = new XMLParser(options);
const jsonObj = parser.parse(xmlData);
const lines = [
`
<html>
<head>
<title>FHIRPath Comparison</title>
<style>
* {
font-size: 10px;
font-family: monospace;
}
table {
width: 100%;
border-collapse: collapse;
border-top: 0.1px solid #888;
border-left: 0.1px solid #888;
table-layout: fixed;
}
td, th {
border-right: 0.1px solid #888;
border-bottom: 0.1px solid #888;
padding: 4px;
}
pre {
max-height: 100px;
overflow: hidden;
}
.good {
background-color: #efe;
color: #080;
}
.bad {
background-color: #fee;
color: #800;
}
.mixed {
background-color: #fffcee;
color: #860;
}
</style>
</head>
<body>
<h1>FHIRPath Comparison</h1>
<p>Comparing <a href="https://www.medplum.com">Medplum</a>'s FHIRPath implementation with <a href="https://github.com/hl7/fhirpath.js/">fhirpath.js</a></p>
<p>Generated by <a href="https://github.com/medplum/medplum/blob/main/packages/generator/src/compare.ts">https://github.com/medplum/medplum/blob/main/packages/generator/src/compare.ts</a></p>
<p>Test data from <a href="https://github.com/HL7/FHIRPath/tree/master/tests/r4">https://github.com/HL7/FHIRPath/tree/master/tests/r4</a></p>
<table>
<thead>
<tr>
<th width="14%">Name</th>
<th width="24%">Expression</th>
<th width="14%">Expected</th>
<th width="24%">FHIRPath.js</th>
<th width="24%">Medplum</th>
</tr>
</thead>
<tbody>
`,
];
const counts = [
[0, 0, 0],
[0, 0, 0],
];
function getName(obj: any): string {
return obj['@_description'] || obj['@_name'];
}
function processTests(tests: any): void {
if (tests.group) {
if (Array.isArray(tests.group)) {
tests.group.forEach(processGroup);
} else {
processGroup(tests.group);
}
}
}
function processGroup(group: any): void {
if (group.test) {
if (Array.isArray(group.test)) {
group.test.forEach(processTest);
} else {
processTest(group.test);
}
}
}
function processTest(test: any): void {
if (test['@_predicate'] === true) {
// Ignore predicate tests
// There is only one currently, and it is duplicative
return;
}
const name = getName(test);
lines.push('<tr>');
lines.push(`<td>${escapeXml(name)}</td>`);
let expr = '';
let valid = true;
if (typeof test.expression === 'string') {
expr = unescapeXml(test.expression);
} else if (typeof test.expression === 'object' && test.expression['#text']) {
expr = unescapeXml(test.expression['#text']);
valid = !test.expression?.['@_invalid'];
} else {
console.log('unknown test expression');
console.log(test);
console.log(test.expression);
}
lines.push(`<td>${escapeXml(JSON.stringify(expr))}</td>`);
const resourceType = test['@_inputfile'].split('-')[0];
const resource = resources[resourceType];
let outputStr;
let anyOutput = false;
if (test.output?.['#text'] === true) {
outputStr = JSON.stringify([true]);
} else if (test.output && Array.isArray(test.output)) {
outputStr = JSON.stringify(test.output.map((o: any) => o['#text']));
} else if (test.output) {
outputStr = JSON.stringify([test.output['#text']]);
} else {
anyOutput = true;
}
const specOutput = JSON.stringify(JSON.parse(outputStr ?? 'null'), undefined, 2);
const fhirpathOutput = getFhirpathOutput(resource, expr);
const medplumOutput = getMedplumOutput(resource, expr);
let fhirpathClassName;
if (fhirpathOutput === specOutput || (medplumOutput === fhirpathOutput && (anyOutput || !valid))) {
fhirpathClassName = 'good';
counts[0][0]++;
} else if (fhirpathOutput === specOutput || medplumOutput === fhirpathOutput || anyOutput || !valid) {
fhirpathClassName = 'mixed';
counts[0][1]++;
} else {
fhirpathClassName = 'bad';
counts[0][2]++;
}
let medplumClassName;
if (medplumOutput === specOutput || (medplumOutput === fhirpathOutput && (anyOutput || !valid))) {
medplumClassName = 'good';
counts[1][0]++;
} else if (medplumOutput === specOutput || medplumOutput === fhirpathOutput || anyOutput || !valid) {
medplumClassName = 'mixed';
counts[1][1]++;
} else {
medplumClassName = 'bad';
counts[1][2]++;
}
if (!valid) {
lines.push('<td>invalid</td>');
} else if (anyOutput) {
lines.push('<td>any</td>');
} else {
lines.push(`<td><pre>${escapeXml(specOutput)}</pre></td>`);
}
lines.push(`<td class="${fhirpathClassName}"><pre>${escapeXml(fhirpathOutput)}</pre></td>`);
lines.push(`<td class="${medplumClassName}"><pre>${escapeXml(medplumOutput)}</pre></td>`);
lines.push('</tr>');
}
function getFhirpathOutput(resource: Resource, expr: string): string {
try {
const result = fhirpath.evaluate(resource, expr);
return JSON.stringify(result, undefined, 2);
} catch (e) {
return (e as Error).message;
}
}
function getMedplumOutput(resource: Resource, expr: string): string {
try {
const result = evalFhirPathTyped(expr, [toTypedValue(resource)], {
'%sct': toTypedValue('http://snomed.info/sct'),
'%loinc': toTypedValue('http://loinc.org'),
'%ucum': toTypedValue('http://unitsofmeasure.org'),
'%`vs-administrative-gender`': toTypedValue('http://hl7.org/fhir/ValueSet/administrative-gender'),
});
return JSON.stringify(
result?.map((r) => r?.value),
undefined,
2
);
} catch (e) {
return (e as Error).message;
}
}
function escapeXml(str: string): string {
if (!str) {
return str;
}
return str
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function unescapeXml(str: string): string {
return str.replaceAll('<', '<').replaceAll('>', '>').replaceAll('&', '&');
}
processTests(jsonObj.tests);
lines.push(`<tr><td></td><td></td><td>Good</td><td>${counts[0][0]}</td><td>${counts[1][0]}</td></tr>`);
lines.push(`<tr><td></td><td></td><td>Mixed</td><td>${counts[0][1]}</td><td>${counts[1][1]}</td></tr>`);
lines.push(`<tr><td></td><td></td><td>Bad</td><td>${counts[0][2]}</td><td>${counts[1][2]}</td></tr>`);
lines.push('</tbody', '</table', '</body', '</html>');
writeFileSync('compare.html', lines.join('\n'));