From 1d4973e2a9949f76c6cbf94f13c4c37f6adee92f Mon Sep 17 00:00:00 2001 From: Elsa Date: Mon, 25 Mar 2024 11:30:06 -0400 Subject: [PATCH 01/17] Initial _elements param work --- src/services/export.service.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/services/export.service.js b/src/services/export.service.js index 499f4e7..48ce20c 100644 --- a/src/services/export.service.js +++ b/src/services/export.service.js @@ -214,6 +214,25 @@ function validateExportParams(parameters, reply) { } } + // add validation for the _elements query param + if (parameters._elements) { + const elementsArray = parameters._elements.split(','); + const unsupportedResourceTypes = []; + const unsupportedElementTypes = []; + elementsArray.forEach(line => { + let resourceType = null; + let elementName; + if (line.includes('.')) { + resourceType = line.split('.')[0]; + elementName = line.split('.')[1]; + if (!supportedResources.includes(resourceType)) { + unsupportedResourceTypes.push(resourceType); + } + } else { + } + }); + } + if (parameters.patient) { const referenceFormat = /^Patient\/[\w-]+$/; const errorMessage = 'All patient references must be of the format "Patient/{id}" for the "patient" parameter.'; From ad4029f37830fa3da69614e06455e4c4cbcf0e20 Mon Sep 17 00:00:00 2001 From: Elsa Date: Wed, 27 Mar 2024 15:01:54 -0400 Subject: [PATCH 02/17] Majority _elements parameter functionality --- src/server/exportWorker.js | 4 +- src/services/export.service.js | 52 ++++++++++++++++++++--- src/util/exportToNDJson.js | 71 +++++++++++++++++++++++++------- src/util/mongo.controller.js | 5 ++- test/util/exportToNDJson.test.js | 14 +++++++ 5 files changed, 124 insertions(+), 22 deletions(-) diff --git a/src/server/exportWorker.js b/src/server/exportWorker.js index ec01cc8..7365a58 100644 --- a/src/server/exportWorker.js +++ b/src/server/exportWorker.js @@ -15,12 +15,12 @@ const exportQueue = new Queue('export', { // This handler pulls down the jobs on Redis to handle exportQueue.process(async job => { // Payload of createJob exists on job.data - const { clientEntry, types, typeFilter, patient, systemLevelExport, patientIds } = job.data; + const { clientEntry, types, typeFilter, patient, systemLevelExport, patientIds, elements } = job.data; console.log(`export-worker-${process.pid}: Processing Request: ${clientEntry}`); await client.connect(); // Call the existing export ndjson function that writes the files - const result = await exportToNDJson(clientEntry, types, typeFilter, patient, systemLevelExport, patientIds); + const result = await exportToNDJson(clientEntry, types, typeFilter, patient, systemLevelExport, patientIds, elements); if (result) { console.log(`export-worker-${process.pid}: Completed Export Request: ${clientEntry}`); } else { diff --git a/src/services/export.service.js b/src/services/export.service.js index 48ce20c..c774e9a 100644 --- a/src/services/export.service.js +++ b/src/services/export.service.js @@ -26,12 +26,15 @@ const bulkExport = async (request, reply) => { const types = request.query._type?.split(',') || parameters._type?.split(','); + const elements = request.query._elements?.split(',') || parameters._elements?.split(','); + // Enqueue a new job into Redis for handling const job = { clientEntry: clientEntry, types: types, typeFilter: request.query._typeFilter, - systemLevelExport: true + systemLevelExport: true, + elements: elements }; await exportQueue.createJob(job).save(); reply.code(202).header('Content-location', `${process.env.BULK_BASE_URL}/bulkstatus/${clientEntry}`).send(); @@ -70,13 +73,16 @@ const patientBulkExport = async (request, reply) => { types = patientResourceTypes; } + const elements = request.query._elements?.split(',') || parameters._elements?.split(','); + // Enqueue a new job into Redis for handling const job = { clientEntry: clientEntry, types: types, typeFilter: parameters._typeFilter, patient: parameters.patient, - systemLevelExport: false + systemLevelExport: false, + elements: elements }; await exportQueue.createJob(job).save(); reply.code(202).header('Content-location', `${process.env.BULK_BASE_URL}/bulkstatus/${clientEntry}`).send(); @@ -123,6 +129,8 @@ const groupBulkExport = async (request, reply) => { types = patientResourceTypes; } + const elements = request.query._elements?.split(',') || parameters._elements?.split(','); + // Enqueue a new job into Redis for handling const job = { clientEntry: clientEntry, @@ -130,7 +138,8 @@ const groupBulkExport = async (request, reply) => { typeFilter: parameters._typeFilter, patient: parameters.patient, systemLevelExport: false, - patientIds: patientIds + patientIds: patientIds, + elements: elements }; await exportQueue.createJob(job).save(); reply.code(202).header('Content-location', `${process.env.BULK_BASE_URL}/bulkstatus/${clientEntry}`).send(); @@ -220,17 +229,50 @@ function validateExportParams(parameters, reply) { const unsupportedResourceTypes = []; const unsupportedElementTypes = []; elementsArray.forEach(line => { - let resourceType = null; + // split each of the elements up by a '.' if it has one. If it does, the first part is the resourceType and the second is the element name + // if there is no '.', we assume that the element is just the element name + // TODO: add some sort of check for unsupported elements + let resourceType = 'all'; let elementName; if (line.includes('.')) { resourceType = line.split('.')[0]; elementName = line.split('.')[1]; if (!supportedResources.includes(resourceType)) { unsupportedResourceTypes.push(resourceType); + } else { + // TODO: do we need to check if an element name exists on the given resourceType ? } } else { + elementName = line; + // TODO: go through all the supported resources and make sure that the element name exists on the resource ? + // do we need to do this ? I don't think it's done for other parts of this server } }); + if (unsupportedResourceTypes.length > 0) { + reply + .code(400) + .send( + createOperationOutcome( + `The following resourceTypes are not supported for _element param for $export: ${unsupportedResourceTypes.join( + ', ' + )}.`, + { issueCode: 400, severity: 'error' } + ) + ); + return false; + } else if (unsupportedElementTypes.length > 0) { + reply + .code(400) + .send( + createOperationOutcome( + `The following resourceType and element names are not supported for _element param for $export: ${unsupportedResourceTypes.join( + ', ' + )}.`, + { issueCode: 400, severity: 'error' } + ) + ); + return false; + } } if (parameters.patient) { @@ -246,7 +288,7 @@ function validateExportParams(parameters, reply) { let unrecognizedParams = []; Object.keys(parameters).forEach(param => { - if (!['_outputFormat', '_type', '_typeFilter', 'patient'].includes(param)) { + if (!['_outputFormat', '_type', '_typeFilter', 'patient', '_elements'].includes(param)) { unrecognizedParams.push(param); } }); diff --git a/src/util/exportToNDJson.js b/src/util/exportToNDJson.js index f43409a..5ea6db1 100644 --- a/src/util/exportToNDJson.js +++ b/src/util/exportToNDJson.js @@ -27,14 +27,18 @@ const qb = new QueryBuilder({ implementationParameters: { archivedParamPath: '_i const buildSearchParamList = resourceType => { const searchParams = {}; // get search parameters for FHIR Version 4.0.1 - const searchParameterList = getSearchParameters(resourceType, '4_0_1'); - searchParameterList.forEach(paramDef => { - // map xpath to parameter description - { - searchParams[paramDef.xpath.substring(paramDef.xpath.indexOf('.') + 1)] = paramDef; - } - }); - return searchParams; + try { + const searchParameterList = getSearchParameters(resourceType, '4_0_1'); + searchParameterList.forEach(paramDef => { + // map xpath to parameter description + { + searchParams[paramDef.xpath.substring(paramDef.xpath.indexOf('.') + 1)] = paramDef; + } + }); + return searchParams; + } catch (e) { + return {}; + } }; /** @@ -50,7 +54,7 @@ const buildSearchParamList = resourceType => { * @param {boolean} systemLevelExport boolean flag from job that signals whether request is for system-level export (determines filtering) * @param {Array} patientIds Array of patient ids for patients relevant to this export (undefined if all patients) */ -const exportToNDJson = async (clientId, types, typeFilter, patient, systemLevelExport, patientIds) => { +const exportToNDJson = async (clientId, types, typeFilter, patient, systemLevelExport, patientIds, elements) => { try { const dirpath = './tmp/'; fs.mkdirSync(dirpath, { recursive: true }); @@ -114,6 +118,34 @@ const exportToNDJson = async (clientId, types, typeFilter, patient, systemLevelE } }); } + + const elementsQueries = {}; + // create lookup object for _elements parameter + if (elements) { + elements.forEach(e => { + let resourceType = 'all'; + let elementName; + if (e.includes('.')) { + resourceType = e.split('.')[0]; + elementName = e.split('.')[1]; + if (elementsQueries[resourceType]) { + elementsQueries[resourceType].push(elementName); + } else { + elementsQueries[resourceType] = [elementName]; + } + } else { + elementName = e; + supportedResources.forEach(resourceType => { + if (elementsQueries[resourceType]) { + elementsQueries[resourceType].push(elementName); + } else { + elementsQueries[resourceType] = [elementName]; + } + }); + } + }); + } + const exportTypes = systemLevelExport ? requestTypes.filter(t => t !== 'ValueSet') : requestTypes; // if 'patient' parameter is present, apply additional filtering on the resources related to these patients @@ -128,7 +160,8 @@ const exportToNDJson = async (clientId, types, typeFilter, patient, systemLevelE collectionName, searchParameterQueries[collectionName], valueSetQueries[collectionName], - patientParamIds || patientIds + patientParamIds || patientIds, + elementsQueries[collectionName] ); }); docs = await Promise.all(docs); @@ -168,9 +201,10 @@ const exportToNDJson = async (clientId, types, typeFilter, patient, systemLevelE * @param {Object} searchParameterQueries The _typeFilter search parameter queries for the given resource type * @param {Object} valueSetQueries list of ValueSet-related queries for the given resource type * @param {Array} patientIds Array of patient ids for which the returned documents should have references + * @param {Array} elements Array of elements queries for the given resource type * @returns {Object} An object containing all data from the given collection name as well as the collection name */ -const getDocuments = async (collectionName, searchParameterQueries, valueSetQueries, patientIds) => { +const getDocuments = async (collectionName, searchParameterQueries, valueSetQueries, patientIds, elements) => { let docs = []; let patQuery = {}; let vsQuery = {}; @@ -193,6 +227,15 @@ const getDocuments = async (collectionName, searchParameterQueries, valueSetQuer vsQuery = await processVSTypeFilter(valueSetQueries); } + // create elements projection + // TODO: add mandatory elements based on the resource type to the projection + const projection = { _id: 0 }; + if (elements) { + elements.forEach(elem => { + projection[elem] = 1; + }); + } + if (searchParameterQueries) { docs = searchParameterQueries.map(async q => { let query = q; @@ -204,7 +247,7 @@ const getDocuments = async (collectionName, searchParameterQueries, valueSetQuer query.filter(q => '$match' in q).forEach(q => (q['$match'] = { $and: [q['$match'], patQuery] })); } // grab the results from aggregation - has metadata about counts and data with resources in the first array position - const results = await findResourcesWithAggregation(query, collectionName, { projection: { _id: 0 } }); + const results = await findResourcesWithAggregation(query, collectionName, projection); return results || []; }); // use flatMap to flatten the output from aggregation @@ -213,9 +256,9 @@ const getDocuments = async (collectionName, searchParameterQueries, valueSetQuer const query = { $and: [vsQuery, patQuery] }; - docs = await findResourcesWithQuery(query, collectionName, { projection: { _id: 0 } }); + docs = await findResourcesWithQuery(query, collectionName, { projection: projection }); } else { - docs = await findResourcesWithQuery({}, collectionName, { projection: { _id: 0 } }); + docs = await findResourcesWithQuery({}, collectionName, { projection: projection }); } return { document: docs, collectionName: collectionName }; }; diff --git a/src/util/mongo.controller.js b/src/util/mongo.controller.js index 85afda3..c46327b 100644 --- a/src/util/mongo.controller.js +++ b/src/util/mongo.controller.js @@ -84,15 +84,18 @@ const removeResource = async (id, resourceType) => { * Run an aggregation query on the database. * @param {*[]} query Mongo aggregation pipeline array. * @param {*} resourceType The resource type (collection) to aggregate on. + * @param options Passed in aggregation options, in the case of aggregation, + * elements that we want to use in a $project * @returns Array promise of results. */ -const findResourcesWithAggregation = async (query, resourceType) => { +const findResourcesWithAggregation = async (query, resourceType, options = {}) => { /* Asymmetrik includes a $facet object to provide user-friendly pagination, which is not relevant here since we are applying the Asymmetrik query to the _typeFilter parameter. The query is sliced to remove the $facet object from the aggregation pipeline. */ const queryWithoutFacet = query.slice(0, -1); + queryWithoutFacet.push({ $project: options }); const collection = db.collection(resourceType); return (await collection.aggregate(queryWithoutFacet)).toArray(); }; diff --git a/test/util/exportToNDJson.test.js b/test/util/exportToNDJson.test.js index 58c5b55..418566e 100644 --- a/test/util/exportToNDJson.test.js +++ b/test/util/exportToNDJson.test.js @@ -50,6 +50,12 @@ describe('check export logic', () => { const results = buildSearchParamList('Encounter'); expect(results).toBeDefined(); }); + + test('returns empty record of valid search params for invalid resource type', () => { + const results = buildSearchParamList('BiologicallyDerivedProduct'); + console.log(results); + expect(results).toBeDefined(); + }); }); describe('exportToNDJson', () => { @@ -180,6 +186,14 @@ describe('check export logic', () => { expect(docObj.document.length).toEqual(0); }); }); + + describe('_elements tests', () => { + test('returns Condition document with only the resourceType when _elements=Condition.resourceType', async () => { + const docObj = await getDocuments('Condition', undefined, undefined, undefined, ['resourceType']); + expect(docObj.document.length).toEqual(1); + expect(docObj.document[0]).toEqual({ resourceType: 'Condition' }); + }); + }); }); afterAll(async () => { From cef2578f29fbafba9c5283d92e95394be683252a Mon Sep 17 00:00:00 2001 From: Elsa Date: Wed, 27 Mar 2024 15:16:41 -0400 Subject: [PATCH 03/17] Add subsetted tag to meta.tag on resources returned by _elements param --- src/util/exportToNDJson.js | 18 ++++++++++++++++++ test/util/exportToNDJson.test.js | 14 ++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/util/exportToNDJson.js b/src/util/exportToNDJson.js index 5ea6db1..a58ee0d 100644 --- a/src/util/exportToNDJson.js +++ b/src/util/exportToNDJson.js @@ -260,6 +260,24 @@ const getDocuments = async (collectionName, searchParameterQueries, valueSetQuer } else { docs = await findResourcesWithQuery({}, collectionName, { projection: projection }); } + + // add the SUBSETTED tag to the resources returned when the _elements parameter is used + if (elements) { + docs.map(doc => { + if (doc.meta) { + if (doc.meta.tag) { + doc.meta.tag.push({ code: 'SUBSETTED', system: 'http://terminology.hl7.org/CodeSystem/v3-ObservationValue' }); + } else { + doc.meta.tag = [{ code: 'SUBSETTED', system: 'http://terminology.hl7.org/CodeSystem/v3-ObservationValue' }]; + } + } else { + doc.meta = { + tag: [{ code: 'SUBSETTED', system: 'http://terminology.hl7.org/CodeSystem/v3-ObservationValue' }] + }; + } + }); + } + return { document: docs, collectionName: collectionName }; }; diff --git a/test/util/exportToNDJson.test.js b/test/util/exportToNDJson.test.js index 418566e..26a2f92 100644 --- a/test/util/exportToNDJson.test.js +++ b/test/util/exportToNDJson.test.js @@ -188,10 +188,20 @@ describe('check export logic', () => { }); describe('_elements tests', () => { - test('returns Condition document with only the resourceType when _elements=Condition.resourceType', async () => { + test('returns Condition document with only the resourceType and the SUBSETTED tag when _elements=Condition.resourceType', async () => { const docObj = await getDocuments('Condition', undefined, undefined, undefined, ['resourceType']); expect(docObj.document.length).toEqual(1); - expect(docObj.document[0]).toEqual({ resourceType: 'Condition' }); + expect(docObj.document[0]).toEqual({ + resourceType: 'Condition', + meta: { + tag: [ + { + code: 'SUBSETTED', + system: 'http://terminology.hl7.org/CodeSystem/v3-ObservationValue' + } + ] + } + }); }); }); }); From 45358b61e773d0e0c826c2d977cdbb6a161312ee Mon Sep 17 00:00:00 2001 From: Elsa Date: Wed, 27 Mar 2024 16:15:00 -0400 Subject: [PATCH 04/17] Comment out elementName variable for now --- src/services/export.service.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/export.service.js b/src/services/export.service.js index c774e9a..aed083c 100644 --- a/src/services/export.service.js +++ b/src/services/export.service.js @@ -236,14 +236,14 @@ function validateExportParams(parameters, reply) { let elementName; if (line.includes('.')) { resourceType = line.split('.')[0]; - elementName = line.split('.')[1]; + // elementName = line.split('.')[1]; if (!supportedResources.includes(resourceType)) { unsupportedResourceTypes.push(resourceType); } else { // TODO: do we need to check if an element name exists on the given resourceType ? } } else { - elementName = line; + // elementName = line; // TODO: go through all the supported resources and make sure that the element name exists on the resource ? // do we need to do this ? I don't think it's done for other parts of this server } From 13b65b3ad7ea6b11c9f9a0ada3e6e8658b593695 Mon Sep 17 00:00:00 2001 From: Elsa Date: Wed, 27 Mar 2024 16:21:52 -0400 Subject: [PATCH 05/17] Actually remove elementName for now --- src/services/export.service.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/services/export.service.js b/src/services/export.service.js index aed083c..bf5bf3e 100644 --- a/src/services/export.service.js +++ b/src/services/export.service.js @@ -233,17 +233,14 @@ function validateExportParams(parameters, reply) { // if there is no '.', we assume that the element is just the element name // TODO: add some sort of check for unsupported elements let resourceType = 'all'; - let elementName; if (line.includes('.')) { resourceType = line.split('.')[0]; - // elementName = line.split('.')[1]; if (!supportedResources.includes(resourceType)) { unsupportedResourceTypes.push(resourceType); } else { // TODO: do we need to check if an element name exists on the given resourceType ? } } else { - // elementName = line; // TODO: go through all the supported resources and make sure that the element name exists on the resource ? // do we need to do this ? I don't think it's done for other parts of this server } From 4c79b8b09e82b726274a7483e10a883c511bfa90 Mon Sep 17 00:00:00 2001 From: Elsa Date: Wed, 3 Apr 2024 11:55:28 -0400 Subject: [PATCH 06/17] Add mandatory elements support and script --- .../mandatory-elements.json | 566 ++++++++++++++++++ src/scripts/parseStructureDefinitions.js | 48 ++ src/util/exportToNDJson.js | 8 +- 3 files changed, 621 insertions(+), 1 deletion(-) create mode 100644 src/compartment-definition/mandatory-elements.json create mode 100644 src/scripts/parseStructureDefinitions.js diff --git a/src/compartment-definition/mandatory-elements.json b/src/compartment-definition/mandatory-elements.json new file mode 100644 index 0000000..f02fa80 --- /dev/null +++ b/src/compartment-definition/mandatory-elements.json @@ -0,0 +1,566 @@ +{ + "Account": [ + "status" + ], + "ActivityDefinition": [ + "status" + ], + "AdverseEvent": [ + "actuality", + "subject" + ], + "AllergyIntolerance": [ + "patient" + ], + "Appointment": [ + "status", + "participant" + ], + "AppointmentResponse": [ + "appointment", + "participantStatus" + ], + "AuditEvent": [ + "type", + "recorded", + "agent", + "source" + ], + "Basic": [ + "code" + ], + "Binary": [ + "contentType" + ], + "BiologicallyDerivedProduct": [], + "BodyStructure": [ + "patient" + ], + "Bundle": [ + "type" + ], + "CapabilityStatement": [ + "status", + "date", + "kind", + "fhirVersion", + "format" + ], + "CarePlan": [ + "status", + "intent", + "subject" + ], + "CareTeam": [], + "CatalogEntry": [ + "orderable", + "referencedItem" + ], + "ChargeItem": [ + "status", + "code", + "subject" + ], + "ChargeItemDefinition": [ + "url", + "status" + ], + "Claim": [ + "status", + "type", + "use", + "patient", + "created", + "provider", + "priority", + "insurance" + ], + "ClaimResponse": [ + "status", + "type", + "use", + "patient", + "created", + "insurer", + "outcome" + ], + "ClinicalImpression": [ + "status", + "subject" + ], + "CodeSystem": [ + "status", + "content" + ], + "Communication": [ + "status" + ], + "CommunicationRequest": [ + "status" + ], + "CompartmentDefinition": [ + "url", + "name", + "status", + "code", + "search" + ], + "Composition": [ + "status", + "type", + "date", + "author", + "title" + ], + "ConceptMap": [ + "status" + ], + "Condition": [ + "subject" + ], + "Consent": [ + "status", + "scope", + "category" + ], + "Contract": [], + "Coverage": [ + "status", + "beneficiary", + "payor" + ], + "CoverageEligibilityRequest": [ + "status", + "purpose", + "patient", + "created", + "insurer" + ], + "CoverageEligibilityResponse": [ + "status", + "purpose", + "patient", + "created", + "request", + "outcome", + "insurer" + ], + "DetectedIssue": [ + "status" + ], + "Device": [], + "DeviceDefinition": [], + "DeviceMetric": [ + "type", + "category" + ], + "DeviceRequest": [ + "intent", + "code[x]", + "subject" + ], + "DeviceUseStatement": [ + "status", + "subject", + "device" + ], + "DiagnosticReport": [ + "status", + "code" + ], + "DocumentManifest": [ + "status", + "content" + ], + "DocumentReference": [ + "status", + "content" + ], + "EffectEvidenceSynthesis": [ + "status", + "population", + "exposure", + "exposureAlternative", + "outcome" + ], + "Encounter": [ + "status", + "class" + ], + "Endpoint": [ + "status", + "connectionType", + "payloadType", + "address" + ], + "EnrollmentRequest": [], + "EnrollmentResponse": [], + "EpisodeOfCare": [ + "status", + "patient" + ], + "EventDefinition": [ + "status", + "trigger" + ], + "Evidence": [ + "status", + "exposureBackground" + ], + "EvidenceVariable": [ + "status", + "characteristic" + ], + "ExampleScenario": [ + "status" + ], + "ExplanationOfBenefit": [ + "status", + "type", + "use", + "patient", + "created", + "insurer", + "provider", + "outcome", + "insurance" + ], + "FamilyMemberHistory": [ + "status", + "patient", + "relationship" + ], + "Flag": [ + "status", + "code", + "subject" + ], + "Goal": [ + "lifecycleStatus", + "description", + "subject" + ], + "GraphDefinition": [ + "name", + "status", + "start" + ], + "Group": [ + "type", + "actual" + ], + "GuidanceResponse": [ + "module[x]", + "status" + ], + "HealthcareService": [], + "ImagingStudy": [ + "status", + "subject" + ], + "Immunization": [ + "status", + "vaccineCode", + "patient", + "occurrence[x]" + ], + "ImmunizationEvaluation": [ + "status", + "patient", + "targetDisease", + "immunizationEvent", + "doseStatus" + ], + "ImmunizationRecommendation": [ + "patient", + "date", + "recommendation" + ], + "ImplementationGuide": [ + "url", + "name", + "status", + "packageId", + "fhirVersion" + ], + "InsurancePlan": [], + "Invoice": [ + "status" + ], + "Library": [ + "status", + "type" + ], + "Linkage": [ + "item" + ], + "List": [ + "status", + "mode" + ], + "Location": [], + "Measure": [ + "status" + ], + "MeasureReport": [ + "status", + "type", + "measure", + "period" + ], + "Media": [ + "status", + "content" + ], + "Medication": [], + "MedicationAdministration": [ + "status", + "medication[x]", + "subject", + "effective[x]" + ], + "MedicationDispense": [ + "status", + "medication[x]" + ], + "MedicationKnowledge": [], + "MedicationRequest": [ + "status", + "intent", + "medication[x]", + "subject" + ], + "MedicationStatement": [ + "status", + "medication[x]", + "subject" + ], + "MedicinalProduct": [ + "name" + ], + "MedicinalProductAuthorization": [], + "MedicinalProductContraindication": [], + "MedicinalProductIndication": [], + "MedicinalProductIngredient": [ + "role" + ], + "MedicinalProductInteraction": [], + "MedicinalProductManufactured": [ + "manufacturedDoseForm", + "quantity" + ], + "MedicinalProductPackaged": [ + "packageItem" + ], + "MedicinalProductPharmaceutical": [ + "administrableDoseForm", + "routeOfAdministration" + ], + "MedicinalProductUndesirableEffect": [], + "MessageDefinition": [ + "status", + "date", + "event[x]" + ], + "MessageHeader": [ + "event[x]", + "source" + ], + "MolecularSequence": [ + "coordinateSystem" + ], + "NamingSystem": [ + "name", + "status", + "kind", + "date", + "uniqueId" + ], + "NutritionOrder": [ + "status", + "intent", + "patient", + "dateTime" + ], + "Observation": [ + "status", + "code" + ], + "ObservationDefinition": [ + "code" + ], + "OperationDefinition": [ + "name", + "status", + "kind", + "code", + "system", + "type", + "instance" + ], + "OperationOutcome": [ + "issue" + ], + "Organization": [], + "OrganizationAffiliation": [], + "Parameters": [], + "Patient": [], + "PaymentNotice": [ + "status", + "created", + "payment", + "recipient", + "amount" + ], + "PaymentReconciliation": [ + "status", + "created", + "paymentDate", + "paymentAmount" + ], + "Person": [], + "PlanDefinition": [ + "status" + ], + "Practitioner": [], + "PractitionerRole": [], + "Procedure": [ + "status", + "subject" + ], + "Provenance": [ + "target", + "recorded", + "agent" + ], + "Questionnaire": [ + "status" + ], + "QuestionnaireResponse": [ + "status" + ], + "RelatedPerson": [ + "patient" + ], + "RequestGroup": [ + "status", + "intent" + ], + "ResearchDefinition": [ + "status", + "population" + ], + "ResearchElementDefinition": [ + "status", + "type", + "characteristic" + ], + "ResearchStudy": [ + "status" + ], + "ResearchSubject": [ + "status", + "study", + "individual" + ], + "RiskAssessment": [ + "status", + "subject" + ], + "RiskEvidenceSynthesis": [ + "status", + "population", + "outcome" + ], + "Schedule": [ + "actor" + ], + "SearchParameter": [ + "url", + "name", + "status", + "description", + "code", + "base", + "type" + ], + "ServiceRequest": [ + "status", + "intent", + "subject" + ], + "Slot": [ + "schedule", + "status", + "start", + "end" + ], + "Specimen": [], + "SpecimenDefinition": [], + "StructureDefinition": [ + "url", + "name", + "status", + "kind", + "abstract", + "type" + ], + "StructureMap": [ + "url", + "name", + "status", + "group" + ], + "Subscription": [ + "status", + "reason", + "criteria", + "channel" + ], + "Substance": [ + "code" + ], + "SubstancePolymer": [], + "SubstanceProtein": [], + "SubstanceReferenceInformation": [], + "SubstanceSourceMaterial": [], + "SubstanceSpecification": [], + "SupplyDelivery": [], + "SupplyRequest": [ + "item[x]", + "quantity" + ], + "Task": [ + "status", + "intent" + ], + "TerminologyCapabilities": [ + "status", + "date", + "kind" + ], + "TestReport": [ + "status", + "testScript", + "result" + ], + "TestScript": [ + "url", + "name", + "status" + ], + "ValueSet": [ + "status" + ], + "VerificationResult": [ + "status" + ], + "VisionPrescription": [ + "status", + "created", + "patient", + "dateWritten", + "prescriber", + "lensSpecification" + ] +} \ No newline at end of file diff --git a/src/scripts/parseStructureDefinitions.js b/src/scripts/parseStructureDefinitions.js new file mode 100644 index 0000000..bbe5b42 --- /dev/null +++ b/src/scripts/parseStructureDefinitions.js @@ -0,0 +1,48 @@ +const fs = require('fs'); +const path = require('path'); + +const STRUCTURE_DEFINITIONS_BASE_PATH = path.join(__dirname, '../resource-definitions'); +const mandatoryElemsOutputPath = path.resolve( + path.join(__dirname, '../compartment-definition/mandatory-elements.json') +); + +/** + * Parse the StructureDefinition of resource types supported by this server for mandatory elements + * @returns {Object} object whose keys are resourceTypes and values are arrays of strings that are mandatory elements + */ +async function main() { + const files = fs.readdirSync(STRUCTURE_DEFINITIONS_BASE_PATH).map(f => ({ + shortName: f.split('.profile')[0], + fullPath: path.join(STRUCTURE_DEFINITIONS_BASE_PATH, f) + })); + + const mandatoryElementsResults = {}; + + files.forEach(f => { + let mandatoryElements = []; + + // read the contents of the file + const structureDef = JSON.parse(fs.readFileSync(f.fullPath, 'utf8')); + // QUESTION: should I be using snapshot or differential ? + structureDef.snapshot.element.forEach(e => { + const elem = e.id.split('.'); + if (elem.length === 2) { + if (e.min === 1) { + mandatoryElements.push(elem[1]); + } + } + }); + mandatoryElementsResults[structureDef.id] = mandatoryElements; + }); + + return mandatoryElementsResults; +} + +main() + .then(mandatoryElementsResults => { + fs.writeFileSync(mandatoryElemsOutputPath, JSON.stringify(mandatoryElementsResults, null, 2), 'utf8'); + console.log(`Wrote file to ${mandatoryElemsOutputPath}`); + }) + .catch(e => { + console.error(e); + }); diff --git a/src/util/exportToNDJson.js b/src/util/exportToNDJson.js index a58ee0d..6a72c45 100644 --- a/src/util/exportToNDJson.js +++ b/src/util/exportToNDJson.js @@ -12,6 +12,7 @@ const { findResourcesWithAggregation } = require('./mongo.controller'); const patientRefs = require('../compartment-definition/patient-references'); +const mandatoryElements = require('../compartment-definition/mandatory-elements'); const QueryBuilder = require('@asymmetrik/fhir-qb'); const { getSearchParameters } = require('@projecttacoma/node-fhir-server-core'); @@ -228,12 +229,17 @@ const getDocuments = async (collectionName, searchParameterQueries, valueSetQuer } // create elements projection - // TODO: add mandatory elements based on the resource type to the projection const projection = { _id: 0 }; if (elements) { elements.forEach(elem => { projection[elem] = 1; }); + + // add a projection of 1 for mandatory elements for the resourceType as defined by the StructureDefinition of the resourceType + // mandatory elements are elements that have min cardinality of 1 + mandatoryElements[collectionName].forEach(elem => { + projection[elem] = 1; + }); } if (searchParameterQueries) { From 633e82abec95582bd274bf7e05b7a24b15f20190 Mon Sep 17 00:00:00 2001 From: Elsa Date: Wed, 3 Apr 2024 12:00:21 -0400 Subject: [PATCH 07/17] Update unit test --- test/util/exportToNDJson.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/util/exportToNDJson.test.js b/test/util/exportToNDJson.test.js index 26a2f92..143b8f2 100644 --- a/test/util/exportToNDJson.test.js +++ b/test/util/exportToNDJson.test.js @@ -188,11 +188,14 @@ describe('check export logic', () => { }); describe('_elements tests', () => { - test('returns Condition document with only the resourceType and the SUBSETTED tag when _elements=Condition.resourceType', async () => { + test('returns Condition document with only the resourceType, subject (mandatory element for Condition), and the SUBSETTED tag when _elements=Condition.resourceType', async () => { const docObj = await getDocuments('Condition', undefined, undefined, undefined, ['resourceType']); expect(docObj.document.length).toEqual(1); expect(docObj.document[0]).toEqual({ resourceType: 'Condition', + subject: { + reference: 'Patient/testPatient' + }, meta: { tag: [ { From de1341e15ac636087598a84ee17352ecc87499bc Mon Sep 17 00:00:00 2001 From: Elsa Date: Thu, 11 Apr 2024 16:53:32 -0400 Subject: [PATCH 08/17] Remove comments and TODOs and change min check to >= 1 --- src/scripts/parseStructureDefinitions.js | 3 +-- src/services/export.service.js | 6 ------ 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/scripts/parseStructureDefinitions.js b/src/scripts/parseStructureDefinitions.js index bbe5b42..43774a5 100644 --- a/src/scripts/parseStructureDefinitions.js +++ b/src/scripts/parseStructureDefinitions.js @@ -23,11 +23,10 @@ async function main() { // read the contents of the file const structureDef = JSON.parse(fs.readFileSync(f.fullPath, 'utf8')); - // QUESTION: should I be using snapshot or differential ? structureDef.snapshot.element.forEach(e => { const elem = e.id.split('.'); if (elem.length === 2) { - if (e.min === 1) { + if (e.min >= 1) { mandatoryElements.push(elem[1]); } } diff --git a/src/services/export.service.js b/src/services/export.service.js index bf5bf3e..63fcb1b 100644 --- a/src/services/export.service.js +++ b/src/services/export.service.js @@ -231,18 +231,12 @@ function validateExportParams(parameters, reply) { elementsArray.forEach(line => { // split each of the elements up by a '.' if it has one. If it does, the first part is the resourceType and the second is the element name // if there is no '.', we assume that the element is just the element name - // TODO: add some sort of check for unsupported elements let resourceType = 'all'; if (line.includes('.')) { resourceType = line.split('.')[0]; if (!supportedResources.includes(resourceType)) { unsupportedResourceTypes.push(resourceType); - } else { - // TODO: do we need to check if an element name exists on the given resourceType ? } - } else { - // TODO: go through all the supported resources and make sure that the element name exists on the resource ? - // do we need to do this ? I don't think it's done for other parts of this server } }); if (unsupportedResourceTypes.length > 0) { From 6281a9515b145f08d2ef53d4480b16f91fa41d90 Mon Sep 17 00:00:00 2001 From: Elsa Date: Thu, 11 Apr 2024 16:57:33 -0400 Subject: [PATCH 09/17] Add link to where structuredef can be found --- src/scripts/parseStructureDefinitions.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/scripts/parseStructureDefinitions.js b/src/scripts/parseStructureDefinitions.js index 43774a5..ec239ec 100644 --- a/src/scripts/parseStructureDefinitions.js +++ b/src/scripts/parseStructureDefinitions.js @@ -8,6 +8,8 @@ const mandatoryElemsOutputPath = path.resolve( /** * Parse the StructureDefinition of resource types supported by this server for mandatory elements + * The StructureDefinitions of each resource type is found in the FHIR R4 spec + * For example, the StructureDefinition for Encounter was found here: https://hl7.org/fhir/R4/encounter.profile.json.html * @returns {Object} object whose keys are resourceTypes and values are arrays of strings that are mandatory elements */ async function main() { From 8d13fdd70462f63ef9756f2fbb266d6bdb194281 Mon Sep 17 00:00:00 2001 From: Elsa Date: Fri, 12 Apr 2024 11:20:09 -0400 Subject: [PATCH 10/17] Add resourceType as a mandatory element for resources --- src/util/exportToNDJson.js | 4 ++++ test/util/exportToNDJson.test.js | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/util/exportToNDJson.js b/src/util/exportToNDJson.js index 6a72c45..05c8f2b 100644 --- a/src/util/exportToNDJson.js +++ b/src/util/exportToNDJson.js @@ -240,6 +240,10 @@ const getDocuments = async (collectionName, searchParameterQueries, valueSetQuer mandatoryElements[collectionName].forEach(elem => { projection[elem] = 1; }); + + // add a projection of 1 for resourceType which we have determined to be a mandatory element for each FHIR resource even though + // it is not included in the StructureDefinition + projection['resourceType'] = 1; } if (searchParameterQueries) { diff --git a/test/util/exportToNDJson.test.js b/test/util/exportToNDJson.test.js index 143b8f2..4599901 100644 --- a/test/util/exportToNDJson.test.js +++ b/test/util/exportToNDJson.test.js @@ -188,11 +188,12 @@ describe('check export logic', () => { }); describe('_elements tests', () => { - test('returns Condition document with only the resourceType, subject (mandatory element for Condition), and the SUBSETTED tag when _elements=Condition.resourceType', async () => { - const docObj = await getDocuments('Condition', undefined, undefined, undefined, ['resourceType']); + test('returns Condition document with only the id, resourceType and subject (mandatory elements for Condition), and the SUBSETTED tag when _elements=Condition.resourceType', async () => { + const docObj = await getDocuments('Condition', undefined, undefined, undefined, ['id']); expect(docObj.document.length).toEqual(1); expect(docObj.document[0]).toEqual({ resourceType: 'Condition', + id: 'test-condition', subject: { reference: 'Patient/testPatient' }, From 95e7f0c837fcb6beffda8e199ff173274da12d0a Mon Sep 17 00:00:00 2001 From: Elsa Date: Wed, 24 Apr 2024 10:03:23 -0400 Subject: [PATCH 11/17] Add _elements description to README --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 9287bc0..e1e6fe0 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,27 @@ The server supports the following query parameters: - `_outputFormat`: The server supports the following formats: `application/fhir+ndjson`, `application/ndjson+fhir`, `application/ndjson`, `ndjson` - `_typeFilter`: Filters the response to only include resources that meet the criteria of the specified comma-delimited FHIR REST queries. Returns an error for queries specified by the client that are unsupported by the server. Supports queries on the ValueSets (`type:in`, `code:in`, etc.) of a given resource type. - `patient`: Only applicable to POST requests for group-level and patient-level requests. When provided, the server SHALL NOT return resources in the patient compartment definition belonging to patients outside the list. Can support multiple patient references in a single request. +- `_elements`: Filters the content of the responses to omit unlisted, non-mandatory elements form the resources returned. These elements should be provided in the form `[resource type].[element name]` (e.g., `Patient.id`) which only filters the contents of those specified resources or in the form `[element name]` (e.g., `id`) which filters the contents of all of the returned resources. + +#### `_elements` Query Parameter + +The server supports the optional and experimental query parameter `_elements` as defined by the Bulk Data Access IG (here)[https://build.fhir.org/ig/HL7/bulk-data/export.html#query-parameters]. The `_elements` parameter is a string of comma-delimited HL7® FHIR® Elements filter the returned resources to only include listed elements and mandatory elements. Mandatory elements are defined as elements in the StructureDefinition of a resource type have a minimum cardinality of 1. + +The returned resources are only filtered by elements that are applicable to them. For example, if a request looks like the following: + +``` +GET http://localhost:3000/$export?_elements=Condition.id +``` + +Then the returned resources should contain everything on them except the returned Conditions should only contain an `id` (if applicable) and any mandatory elements. + +If a request does not specify a resource type, such as the following: + +``` +GET http://localhost:3000/$export?_elements=id +``` + +Then the returned resources should only contain an `id` (if applicable) and any mandatory elements. ## License From dff935d448138c3023347246cac33bf186175f7b Mon Sep 17 00:00:00 2001 From: Elsa Date: Thu, 25 Apr 2024 16:37:30 -0400 Subject: [PATCH 12/17] README fixes --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e1e6fe0..ebaa114 100644 --- a/README.md +++ b/README.md @@ -154,11 +154,11 @@ The server supports the following query parameters: - `_outputFormat`: The server supports the following formats: `application/fhir+ndjson`, `application/ndjson+fhir`, `application/ndjson`, `ndjson` - `_typeFilter`: Filters the response to only include resources that meet the criteria of the specified comma-delimited FHIR REST queries. Returns an error for queries specified by the client that are unsupported by the server. Supports queries on the ValueSets (`type:in`, `code:in`, etc.) of a given resource type. - `patient`: Only applicable to POST requests for group-level and patient-level requests. When provided, the server SHALL NOT return resources in the patient compartment definition belonging to patients outside the list. Can support multiple patient references in a single request. -- `_elements`: Filters the content of the responses to omit unlisted, non-mandatory elements form the resources returned. These elements should be provided in the form `[resource type].[element name]` (e.g., `Patient.id`) which only filters the contents of those specified resources or in the form `[element name]` (e.g., `id`) which filters the contents of all of the returned resources. +- `_elements`: Filters the content of the responses to omit unlisted, non-mandatory elements from the resources returned. These elements should be provided in the form `[resource type].[element name]` (e.g., `Patient.id`) which only filters the contents of those specified resources or in the form `[element name]` (e.g., `id`) which filters the contents of all of the returned resources. #### `_elements` Query Parameter -The server supports the optional and experimental query parameter `_elements` as defined by the Bulk Data Access IG (here)[https://build.fhir.org/ig/HL7/bulk-data/export.html#query-parameters]. The `_elements` parameter is a string of comma-delimited HL7® FHIR® Elements filter the returned resources to only include listed elements and mandatory elements. Mandatory elements are defined as elements in the StructureDefinition of a resource type have a minimum cardinality of 1. +The server supports the optional and experimental query parameter `_elements` as defined by the Bulk Data Access IG (here)[https://build.fhir.org/ig/HL7/bulk-data/export.html#query-parameters]. The `_elements` parameter is a string of comma-delimited HL7® FHIR® Elements filter the returned resources to only include listed elements and mandatory elements. Mandatory elements are defined as elements in the StructureDefinition of a resource type have a minimum cardinality of 1. Because this server provides json-formatted data, `resourceType` is also an implied mandatory element for all Resources. The returned resources are only filtered by elements that are applicable to them. For example, if a request looks like the following: @@ -174,7 +174,7 @@ If a request does not specify a resource type, such as the following: GET http://localhost:3000/$export?_elements=id ``` -Then the returned resources should only contain an `id` (if applicable) and any mandatory elements. +Then all returned resources should only contain an `id` (if applicable) and any mandatory elements. ## License From 680d427fbd687963cb8a4a7fb31089734a74174a Mon Sep 17 00:00:00 2001 From: Elsa Date: Thu, 25 Apr 2024 16:39:00 -0400 Subject: [PATCH 13/17] Additional README changes --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ebaa114..0e1206f 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ The server supports the following query parameters: #### `_elements` Query Parameter -The server supports the optional and experimental query parameter `_elements` as defined by the Bulk Data Access IG (here)[https://build.fhir.org/ig/HL7/bulk-data/export.html#query-parameters]. The `_elements` parameter is a string of comma-delimited HL7® FHIR® Elements filter the returned resources to only include listed elements and mandatory elements. Mandatory elements are defined as elements in the StructureDefinition of a resource type have a minimum cardinality of 1. Because this server provides json-formatted data, `resourceType` is also an implied mandatory element for all Resources. +The server supports the optional and experimental query parameter `_elements` as defined by the Bulk Data Access IG (here)[https://build.fhir.org/ig/HL7/bulk-data/export.html#query-parameters]. The `_elements` parameter is a string of comma-delimited HL7® FHIR® Elements used to filter the returned resources to only include listed elements and mandatory elements. Mandatory elements are defined as elements in the StructureDefinition of a resource type which have a minimum cardinality of 1. Because this server provides json-formatted data, `resourceType` is also an implied mandatory element for all Resources. The returned resources are only filtered by elements that are applicable to them. For example, if a request looks like the following: From 57d6f41b4bd50173d443aab7803bf438666d90cd Mon Sep 17 00:00:00 2001 From: Elsa Date: Tue, 14 May 2024 10:02:29 -0400 Subject: [PATCH 14/17] Add functionality for choice types as elements parameter inputs --- src/compartment-definition/choice-types.json | 473 +++++++++++++++++++ src/scripts/getChoiceTypesForResource.js | 50 ++ src/util/exportToNDJson.js | 29 +- 3 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 src/compartment-definition/choice-types.json create mode 100644 src/scripts/getChoiceTypesForResource.js diff --git a/src/compartment-definition/choice-types.json b/src/compartment-definition/choice-types.json new file mode 100644 index 0000000..dfd75c7 --- /dev/null +++ b/src/compartment-definition/choice-types.json @@ -0,0 +1,473 @@ +{ + "Account": {}, + "ActivityDefinition": { + "subject[x]": [ + "CodeableConcept", + "Reference" + ], + "timing[x]": [ + "Timing", + "dateTime", + "Age", + "Period", + "Range", + "Duration" + ], + "product[x]": [ + "Reference", + "CodeableConcept" + ] + }, + "AdverseEvent": {}, + "AllergyIntolerance": { + "onset[x]": [ + "dateTime", + "Age", + "Period", + "Range", + "string" + ] + }, + "Appointment": {}, + "AppointmentResponse": {}, + "AuditEvent": {}, + "Basic": {}, + "Binary": {}, + "BiologicallyDerivedProduct": {}, + "BodyStructure": {}, + "Bundle": {}, + "CapabilityStatement": {}, + "CarePlan": {}, + "CareTeam": {}, + "CatalogEntry": {}, + "ChargeItem": { + "occurrence[x]": [ + "dateTime", + "Period", + "Timing" + ], + "product[x]": [ + "Reference", + "CodeableConcept" + ] + }, + "ChargeItemDefinition": {}, + "Claim": {}, + "ClaimResponse": {}, + "ClinicalImpression": { + "effective[x]": [ + "dateTime", + "Period" + ] + }, + "CodeSystem": {}, + "Communication": {}, + "CommunicationRequest": { + "occurrence[x]": [ + "dateTime", + "Period" + ] + }, + "CompartmentDefinition": {}, + "Composition": {}, + "ConceptMap": { + "source[x]": [ + "uri", + "canonical" + ], + "target[x]": [ + "uri", + "canonical" + ] + }, + "Condition": { + "onset[x]": [ + "dateTime", + "Age", + "Period", + "Range", + "string" + ], + "abatement[x]": [ + "dateTime", + "Age", + "Period", + "Range", + "string" + ] + }, + "Consent": { + "source[x]": [ + "Attachment", + "Reference" + ] + }, + "Contract": { + "topic[x]": [ + "CodeableConcept", + "Reference" + ], + "legallyBinding[x]": [ + "Attachment", + "Reference" + ] + }, + "Coverage": {}, + "CoverageEligibilityRequest": { + "serviced[x]": [ + "date", + "Period" + ] + }, + "CoverageEligibilityResponse": { + "serviced[x]": [ + "date", + "Period" + ] + }, + "DetectedIssue": { + "identified[x]": [ + "dateTime", + "Period" + ] + }, + "Device": {}, + "DeviceDefinition": { + "manufacturer[x]": [ + "string", + "Reference" + ] + }, + "DeviceMetric": {}, + "DeviceRequest": { + "code[x]": [ + "Reference", + "CodeableConcept" + ], + "occurrence[x]": [ + "dateTime", + "Period", + "Timing" + ] + }, + "DeviceUseStatement": { + "timing[x]": [ + "Timing", + "Period", + "dateTime" + ] + }, + "DiagnosticReport": { + "effective[x]": [ + "dateTime", + "Period" + ] + }, + "DocumentManifest": {}, + "DocumentReference": {}, + "EffectEvidenceSynthesis": {}, + "Encounter": {}, + "Endpoint": {}, + "EnrollmentRequest": {}, + "EnrollmentResponse": {}, + "EpisodeOfCare": {}, + "EventDefinition": { + "subject[x]": [ + "CodeableConcept", + "Reference" + ] + }, + "Evidence": {}, + "EvidenceVariable": {}, + "ExampleScenario": {}, + "ExplanationOfBenefit": {}, + "FamilyMemberHistory": { + "born[x]": [ + "Period", + "date", + "string" + ], + "age[x]": [ + "Age", + "Range", + "string" + ], + "deceased[x]": [ + "boolean", + "Age", + "Range", + "date", + "string" + ] + }, + "Flag": {}, + "Goal": { + "start[x]": [ + "date", + "CodeableConcept" + ] + }, + "GraphDefinition": {}, + "Group": {}, + "GuidanceResponse": { + "module[x]": [ + "uri", + "canonical", + "CodeableConcept" + ] + }, + "HealthcareService": {}, + "ImagingStudy": {}, + "Immunization": { + "occurrence[x]": [ + "dateTime", + "string" + ] + }, + "ImmunizationEvaluation": { + "doseNumber[x]": [ + "positiveInt", + "string" + ], + "seriesDoses[x]": [ + "positiveInt", + "string" + ] + }, + "ImmunizationRecommendation": {}, + "ImplementationGuide": {}, + "InsurancePlan": {}, + "Invoice": {}, + "Library": { + "subject[x]": [ + "CodeableConcept", + "Reference" + ] + }, + "Linkage": {}, + "List": {}, + "Location": {}, + "Measure": { + "subject[x]": [ + "CodeableConcept", + "Reference" + ] + }, + "MeasureReport": {}, + "Media": { + "created[x]": [ + "dateTime", + "Period" + ] + }, + "Medication": {}, + "MedicationAdministration": { + "medication[x]": [ + "CodeableConcept", + "Reference" + ], + "effective[x]": [ + "dateTime", + "Period" + ] + }, + "MedicationDispense": { + "statusReason[x]": [ + "CodeableConcept", + "Reference" + ], + "medication[x]": [ + "CodeableConcept", + "Reference" + ] + }, + "MedicationKnowledge": {}, + "MedicationRequest": { + "reported[x]": [ + "boolean", + "Reference" + ], + "medication[x]": [ + "CodeableConcept", + "Reference" + ] + }, + "MedicationStatement": { + "medication[x]": [ + "CodeableConcept", + "Reference" + ], + "effective[x]": [ + "dateTime", + "Period" + ] + }, + "MedicinalProduct": {}, + "MedicinalProductAuthorization": {}, + "MedicinalProductContraindication": {}, + "MedicinalProductIndication": {}, + "MedicinalProductIngredient": {}, + "MedicinalProductInteraction": {}, + "MedicinalProductManufactured": {}, + "MedicinalProductPackaged": {}, + "MedicinalProductPharmaceutical": {}, + "MedicinalProductUndesirableEffect": {}, + "MessageDefinition": { + "event[x]": [ + "Coding", + "uri" + ] + }, + "MessageHeader": { + "event[x]": [ + "Coding", + "uri" + ] + }, + "MolecularSequence": {}, + "NamingSystem": {}, + "NutritionOrder": {}, + "Observation": { + "effective[x]": [ + "dateTime", + "Period", + "Timing", + "instant" + ], + "value[x]": [ + "Quantity", + "CodeableConcept", + "string", + "boolean", + "integer", + "Range", + "Ratio", + "SampledData", + "time", + "dateTime", + "Period" + ] + }, + "ObservationDefinition": {}, + "OperationDefinition": {}, + "OperationOutcome": {}, + "Organization": {}, + "OrganizationAffiliation": {}, + "Parameters": {}, + "Patient": { + "deceased[x]": [ + "boolean", + "dateTime" + ], + "multipleBirth[x]": [ + "boolean", + "integer" + ] + }, + "PaymentNotice": {}, + "PaymentReconciliation": {}, + "Person": {}, + "PlanDefinition": { + "subject[x]": [ + "CodeableConcept", + "Reference" + ] + }, + "Practitioner": {}, + "PractitionerRole": {}, + "Procedure": { + "performed[x]": [ + "dateTime", + "Period", + "string", + "Age", + "Range" + ] + }, + "Provenance": { + "occurred[x]": [ + "Period", + "dateTime" + ] + }, + "Questionnaire": {}, + "QuestionnaireResponse": {}, + "RelatedPerson": {}, + "RequestGroup": {}, + "ResearchDefinition": { + "subject[x]": [ + "CodeableConcept", + "Reference" + ] + }, + "ResearchElementDefinition": { + "subject[x]": [ + "CodeableConcept", + "Reference" + ] + }, + "ResearchStudy": {}, + "ResearchSubject": {}, + "RiskAssessment": { + "occurrence[x]": [ + "dateTime", + "Period" + ] + }, + "RiskEvidenceSynthesis": {}, + "Schedule": {}, + "SearchParameter": {}, + "ServiceRequest": { + "quantity[x]": [ + "Quantity", + "Ratio", + "Range" + ], + "occurrence[x]": [ + "dateTime", + "Period", + "Timing" + ], + "asNeeded[x]": [ + "boolean", + "CodeableConcept" + ] + }, + "Slot": {}, + "Specimen": {}, + "SpecimenDefinition": {}, + "StructureDefinition": {}, + "StructureMap": {}, + "Subscription": {}, + "Substance": {}, + "SubstancePolymer": {}, + "SubstanceProtein": {}, + "SubstanceReferenceInformation": {}, + "SubstanceSourceMaterial": {}, + "SubstanceSpecification": {}, + "SupplyDelivery": { + "occurrence[x]": [ + "dateTime", + "Period", + "Timing" + ] + }, + "SupplyRequest": { + "item[x]": [ + "CodeableConcept", + "Reference" + ], + "occurrence[x]": [ + "dateTime", + "Period", + "Timing" + ] + }, + "Task": {}, + "TerminologyCapabilities": {}, + "TestReport": {}, + "TestScript": {}, + "ValueSet": {}, + "VerificationResult": {}, + "VisionPrescription": {} +} \ No newline at end of file diff --git a/src/scripts/getChoiceTypesForResource.js b/src/scripts/getChoiceTypesForResource.js new file mode 100644 index 0000000..43da355 --- /dev/null +++ b/src/scripts/getChoiceTypesForResource.js @@ -0,0 +1,50 @@ +const fs = require('fs'); +const path = require('path'); + +const STRUCTURE_DEFINITIONS_BASE_PATH = path.join(__dirname, '../resource-definitions'); +const choiceTypesOutputPath = path.resolve(path.join(__dirname, '../compartment-definition/choice-types.json')); + +/** + * Parse the StructureDefinition of resource types supported by this server for choice type elements + * The StructureDefinitions of each resource type is found in the FHIR R4 spec + * * For example, the StructureDefinition for Encounter was found here: https://hl7.org/fhir/R4/encounter.profile.json.html + * @returns {Object} object whose keys are resourceTypes and values are objects whose keys are choice types + * whose values are an array of all the types it can be + */ +async function main() { + const files = fs.readdirSync(STRUCTURE_DEFINITIONS_BASE_PATH).map(f => ({ + shortName: f.split('.profile')[0], + fullPath: path.join(STRUCTURE_DEFINITIONS_BASE_PATH, f) + })); + + const choiceTypeElementsResults = {}; + + files.forEach(f => { + let choiceTypeElements = {}; + + // read the contents of the file + const structureDef = JSON.parse(fs.readFileSync(f.fullPath, 'utf8')); + structureDef.snapshot.element.forEach(e => { + if (e.path.endsWith('[x]') && e.path.split('.').length <= 2) { + const choiceType = e.id.split('.')[1]; + let choiceTypeTypes = []; + e.type.forEach(type => { + choiceTypeTypes.push(type.code); + }); + choiceTypeElements[choiceType] = choiceTypeTypes; + } + }); + choiceTypeElementsResults[structureDef.id] = choiceTypeElements; + }); + + return choiceTypeElementsResults; +} + +main() + .then(choiceTypeElementsResults => { + fs.writeFileSync(choiceTypesOutputPath, JSON.stringify(choiceTypeElementsResults, null, 2), 'utf8'); + console.log(`Wrote file to ${choiceTypesOutputPath}`); + }) + .catch(e => { + console.error(e); + }); diff --git a/src/util/exportToNDJson.js b/src/util/exportToNDJson.js index 05c8f2b..6b74d31 100644 --- a/src/util/exportToNDJson.js +++ b/src/util/exportToNDJson.js @@ -13,6 +13,7 @@ const { } = require('./mongo.controller'); const patientRefs = require('../compartment-definition/patient-references'); const mandatoryElements = require('../compartment-definition/mandatory-elements'); +const choiceTypeElements = require('../compartment-definition/choice-types.json'); const QueryBuilder = require('@asymmetrik/fhir-qb'); const { getSearchParameters } = require('@projecttacoma/node-fhir-server-core'); @@ -129,7 +130,20 @@ const exportToNDJson = async (clientId, types, typeFilter, patient, systemLevelE if (e.includes('.')) { resourceType = e.split('.')[0]; elementName = e.split('.')[1]; - if (elementsQueries[resourceType]) { + if (Object.keys(choiceTypeElements[resourceType]).length !== 0) { + console.log('hello'); + if (Object.keys(choiceTypeElements[resourceType]).includes(`${elementName}[x]`)) { + console.log('hi hi hi'); + choiceTypeElements[resourceType][`${elementName}[x]`].forEach(e => { + const rootElem = elementName.split('[x]')[0]; + if (elementsQueries[resourceType]) { + elementsQueries[resourceType].push(`${rootElem}${e}`); + } else { + elementsQueries[resourceType] = [`${rootElem}${e}`]; + } + }); + } + } else if (elementsQueries[resourceType]) { elementsQueries[resourceType].push(elementName); } else { elementsQueries[resourceType] = [elementName]; @@ -137,7 +151,18 @@ const exportToNDJson = async (clientId, types, typeFilter, patient, systemLevelE } else { elementName = e; supportedResources.forEach(resourceType => { - if (elementsQueries[resourceType]) { + if (Object.keys(choiceTypeElements[resourceType]).length !== 0) { + if (Object.keys(choiceTypeElements[resourceType]).includes(`${elementName}[x]`)) { + choiceTypeElements[resourceType][`${elementName}[x]`].forEach(e => { + const rootElem = elementName.split('[x]')[0]; + if (elementsQueries[resourceType]) { + elementsQueries[resourceType].push(`${rootElem}${e}`); + } else { + elementsQueries[resourceType] = [`${rootElem}${e}`]; + } + }); + } + } else if (elementsQueries[resourceType]) { elementsQueries[resourceType].push(elementName); } else { elementsQueries[resourceType] = [elementName]; From e806fa5f966d12d7138717a7ecf71cf32fa78a10 Mon Sep 17 00:00:00 2001 From: Elsa Date: Tue, 14 May 2024 11:32:08 -0400 Subject: [PATCH 15/17] Remove console.logs, fix capitalization problem --- src/util/exportToNDJson.js | 13 +++++++------ test/util/exportToNDJson.test.js | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/util/exportToNDJson.js b/src/util/exportToNDJson.js index 6b74d31..7df5251 100644 --- a/src/util/exportToNDJson.js +++ b/src/util/exportToNDJson.js @@ -131,15 +131,15 @@ const exportToNDJson = async (clientId, types, typeFilter, patient, systemLevelE resourceType = e.split('.')[0]; elementName = e.split('.')[1]; if (Object.keys(choiceTypeElements[resourceType]).length !== 0) { - console.log('hello'); if (Object.keys(choiceTypeElements[resourceType]).includes(`${elementName}[x]`)) { - console.log('hi hi hi'); choiceTypeElements[resourceType][`${elementName}[x]`].forEach(e => { const rootElem = elementName.split('[x]')[0]; + const type = e.charAt(0).toUpperCase() + e.slice(1); + console.log(type); if (elementsQueries[resourceType]) { - elementsQueries[resourceType].push(`${rootElem}${e}`); + elementsQueries[resourceType].push(`${rootElem}${type}`); } else { - elementsQueries[resourceType] = [`${rootElem}${e}`]; + elementsQueries[resourceType] = [`${rootElem}${type}`]; } }); } @@ -155,10 +155,11 @@ const exportToNDJson = async (clientId, types, typeFilter, patient, systemLevelE if (Object.keys(choiceTypeElements[resourceType]).includes(`${elementName}[x]`)) { choiceTypeElements[resourceType][`${elementName}[x]`].forEach(e => { const rootElem = elementName.split('[x]')[0]; + const type = e.charAt(0).toUpperCase() + e.slice(1); if (elementsQueries[resourceType]) { - elementsQueries[resourceType].push(`${rootElem}${e}`); + elementsQueries[resourceType].push(`${rootElem}${type}`); } else { - elementsQueries[resourceType] = [`${rootElem}${e}`]; + elementsQueries[resourceType] = [`${rootElem}${type}`]; } }); } diff --git a/test/util/exportToNDJson.test.js b/test/util/exportToNDJson.test.js index 4599901..e329316 100644 --- a/test/util/exportToNDJson.test.js +++ b/test/util/exportToNDJson.test.js @@ -188,7 +188,7 @@ describe('check export logic', () => { }); describe('_elements tests', () => { - test('returns Condition document with only the id, resourceType and subject (mandatory elements for Condition), and the SUBSETTED tag when _elements=Condition.resourceType', async () => { + test('returns Condition document with only the id, resourceType and subject (mandatory elements for Condition), and the SUBSETTED tag when _elements=Condition.id', async () => { const docObj = await getDocuments('Condition', undefined, undefined, undefined, ['id']); expect(docObj.document.length).toEqual(1); expect(docObj.document[0]).toEqual({ From 1905d5ae254cb30ba08049129161c8ecccc26438 Mon Sep 17 00:00:00 2001 From: Elsa Date: Tue, 14 May 2024 13:45:59 -0400 Subject: [PATCH 16/17] Condense code, fix bugs found through Insomnia testing --- src/util/exportToNDJson.js | 75 ++++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/src/util/exportToNDJson.js b/src/util/exportToNDJson.js index 7df5251..6a092c8 100644 --- a/src/util/exportToNDJson.js +++ b/src/util/exportToNDJson.js @@ -125,49 +125,52 @@ const exportToNDJson = async (clientId, types, typeFilter, patient, systemLevelE // create lookup object for _elements parameter if (elements) { elements.forEach(e => { - let resourceType = 'all'; - let elementName; + let elementNames = []; if (e.includes('.')) { - resourceType = e.split('.')[0]; - elementName = e.split('.')[1]; - if (Object.keys(choiceTypeElements[resourceType]).length !== 0) { - if (Object.keys(choiceTypeElements[resourceType]).includes(`${elementName}[x]`)) { - choiceTypeElements[resourceType][`${elementName}[x]`].forEach(e => { - const rootElem = elementName.split('[x]')[0]; - const type = e.charAt(0).toUpperCase() + e.slice(1); - console.log(type); - if (elementsQueries[resourceType]) { - elementsQueries[resourceType].push(`${rootElem}${type}`); - } else { - elementsQueries[resourceType] = [`${rootElem}${type}`]; - } - }); - } - } else if (elementsQueries[resourceType]) { - elementsQueries[resourceType].push(elementName); + const resourceType = e.split('.')[0]; + const elementName = e.split('.')[1]; + if ( + Object.keys(choiceTypeElements[resourceType]).length !== 0 && + Object.keys(choiceTypeElements[resourceType]).includes(`${elementName}[x]`) + ) { + const rootElem = elementName.split('[x]')[0]; + choiceTypeElements[resourceType][rootElem].forEach(e => { + const type = e.charAt(0).toUpperCase() + e.slice(1); + elementNames.push(`${rootElem}${type}`); + }); } else { - elementsQueries[resourceType] = [elementName]; + elementNames.push(elementName); } + + elementNames.forEach(e => { + if (elementsQueries[resourceType]) { + elementsQueries[resourceType].push(e); + } else { + elementsQueries[resourceType] = [e]; + } + }); } else { - elementName = e; supportedResources.forEach(resourceType => { - if (Object.keys(choiceTypeElements[resourceType]).length !== 0) { - if (Object.keys(choiceTypeElements[resourceType]).includes(`${elementName}[x]`)) { - choiceTypeElements[resourceType][`${elementName}[x]`].forEach(e => { - const rootElem = elementName.split('[x]')[0]; - const type = e.charAt(0).toUpperCase() + e.slice(1); - if (elementsQueries[resourceType]) { - elementsQueries[resourceType].push(`${rootElem}${type}`); - } else { - elementsQueries[resourceType] = [`${rootElem}${type}`]; - } - }); - } - } else if (elementsQueries[resourceType]) { - elementsQueries[resourceType].push(elementName); + if ( + Object.keys(choiceTypeElements[resourceType]).length !== 0 && + Object.keys(choiceTypeElements[resourceType]).includes(`${e}[x]`) + ) { + const rootElem = e.split('[x]')[0]; + choiceTypeElements[resourceType][`${e}[x]`].forEach(e => { + const type = e.charAt(0).toUpperCase() + e.slice(1); + elementNames.push(`${rootElem}${type}`); + }); } else { - elementsQueries[resourceType] = [elementName]; + elementNames.push(e); } + + elementNames.forEach(e => { + if (elementsQueries[resourceType]) { + elementsQueries[resourceType].push(e); + } else { + elementsQueries[resourceType] = [e]; + } + }); }); } }); From 3ea331f52110706a7d07d5499956647d1d8fda5e Mon Sep 17 00:00:00 2001 From: Elsa Date: Wed, 15 May 2024 10:15:34 -0400 Subject: [PATCH 17/17] Fix DiagnosticReport.effective error --- src/util/exportToNDJson.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/exportToNDJson.js b/src/util/exportToNDJson.js index 6a092c8..f50398c 100644 --- a/src/util/exportToNDJson.js +++ b/src/util/exportToNDJson.js @@ -134,7 +134,7 @@ const exportToNDJson = async (clientId, types, typeFilter, patient, systemLevelE Object.keys(choiceTypeElements[resourceType]).includes(`${elementName}[x]`) ) { const rootElem = elementName.split('[x]')[0]; - choiceTypeElements[resourceType][rootElem].forEach(e => { + choiceTypeElements[resourceType][`${elementName}[x]`].forEach(e => { const type = e.charAt(0).toUpperCase() + e.slice(1); elementNames.push(`${rootElem}${type}`); });