From 2c93be2dcf1f38e1cac9bfb8ced84dd14ede1146 Mon Sep 17 00:00:00 2001 From: Daniel Potter Date: Wed, 14 Feb 2024 17:07:53 -0500 Subject: [PATCH] Entirely replace passed studies --- package-lock.json | 10 ++-- package.json | 2 +- spec/clinicaltrialsgov.spec.ts | 56 ++++++--------------- spec/index.spec.ts | 2 + src/clinicaltrialsgov.ts | 89 ++++++++++++++-------------------- src/index.ts | 1 + src/study-fhir-converter.ts | 24 +++++++++ 7 files changed, 86 insertions(+), 98 deletions(-) diff --git a/package-lock.json b/package-lock.json index cf821fd..132b3a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clinical-trial-matching-service", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clinical-trial-matching-service", - "version": "0.1.0", + "version": "0.1.1", "license": "Apache-2.0", "dependencies": { "body-parser": "^1.19.0", @@ -892,9 +892,9 @@ } }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "engines": { "node": ">=6.0.0" diff --git a/package.json b/package.json index 25c4c9c..5d165cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clinical-trial-matching-service", - "version": "0.1.0", + "version": "0.1.1", "description": "Provides a core library for interacting with the clinical-trial-matching-engine", "homepage": "https://github.com/mcode/clinical-trial-matching-service", "bugs": "https://github.com/mcode/clinical-trial-matching-service/issues", diff --git a/spec/clinicaltrialsgov.spec.ts b/spec/clinicaltrialsgov.spec.ts index c29ca02..cfbebea 100644 --- a/spec/clinicaltrialsgov.spec.ts +++ b/spec/clinicaltrialsgov.spec.ts @@ -114,15 +114,15 @@ describe('.findNCTNumber', () => { identifier: [ { system: 'other', - value: 'ignoreme' + value: 'NCT12345678' }, { system: ctg.CLINICAL_TRIAL_IDENTIFIER_CODING_SYSTEM_URL, - value: 'test' + value: 'NCT87654321' } ] }) - ).toEqual('test'); + ).toEqual('NCT87654321'); }); it('finds an NCT number based on regexp', () => { expect( @@ -173,18 +173,17 @@ describe('.findNCTNumber', () => { }); describe('findNCTNumbers', () => { - it('builds a map', () => { + it('builds a set', () => { const studies: ResearchStudy[] = [ createResearchStudy('no-NCTID'), createResearchStudy('dupe1', 'NCT00000001'), createResearchStudy('singleton', 'NCT12345678'), createResearchStudy('dupe2', 'NCT00000001'), - createResearchStudy('dupe3', 'NCT00000001') + createResearchStudy('dupe3', 'NCT00000001'), + createResearchStudy('invalid', 'ignore this ID') ]; - const map = ctg.findNCTNumbers(studies); - expect(map.size).toEqual(2); - expect(map.get('NCT12345678')).toEqual(studies[2]); - expect(map.get('NCT00000001')).toEqual([studies[1], studies[3], studies[4]]); + const set = ctg.findNCTNumbers(studies); + expect(set).toEqual(new Set(['NCT12345678', 'NCT00000001'])); }); }); @@ -533,31 +532,26 @@ describe('ClinicalTrialsGovService', () => { }); // These tests basically are only to ensure that all trials are properly visited when given. - it('updates all the given studies', () => { + it('updates studies with proper IDs', async () => { // Our test studies contain the same NCT ID twice to make sure that works as expected, as well as a NCT ID that // download spy will return null for to indicate a failure. const testStudies: ResearchStudy[] = [ createResearchStudy('dupe1', 'NCT00000001'), createResearchStudy('missing', 'NCT00000002'), createResearchStudy('dupe2', 'NCT00000001'), - createResearchStudy('singleton', 'NCT00000003') + createResearchStudy('singleton', 'NCT00000003'), + createResearchStudy('invalidid', 'not an NCT id') ]; const testStudy = createClinicalStudy(); - const updateSpy = spyOn(service, 'updateResearchStudy'); const getTrialSpy = jasmine.createSpy('getCachedClinicalStudy').and.callFake((nctId: string) => { return Promise.resolve(nctId === 'NCT00000002' ? null : testStudy); }); service.getCachedClinicalStudy = getTrialSpy; - return expectAsync( - service.updateResearchStudies(testStudies).then(() => { - expect(downloadTrialsSpy).toHaveBeenCalledOnceWith(['NCT00000001', 'NCT00000002', 'NCT00000003']); - // Update should have been called three times: twice for the NCT00000001 studies, and once for the NCT00000003 study - expect(updateSpy).toHaveBeenCalledWith(testStudies[0], testStudy); - expect(updateSpy).not.toHaveBeenCalledWith(testStudies[1], testStudy); - expect(updateSpy).toHaveBeenCalledWith(testStudies[2], testStudy); - expect(updateSpy).toHaveBeenCalledWith(testStudies[3], testStudy); - }) - ).toBeResolved(); + const result = await service.updateResearchStudies(testStudies); + expect(downloadTrialsSpy).toHaveBeenCalledOnceWith(['NCT00000001', 'NCT00000002', 'NCT00000003']); + // Expect the invalid ID to be left unchanged + console.log(JSON.stringify(result, null, 2)); + expect(result[4]).toEqual(testStudies[4]); }); it('does nothing if no studies have NCT IDs', () => { @@ -578,7 +572,6 @@ describe('ClinicalTrialsGovService', () => { createResearchStudy('test4', 'NCT00000004') ]; const testStudy = createClinicalStudy(); - spyOn(service, 'updateResearchStudy'); const getTrialSpy = jasmine.createSpy('getCachedClinicalStudy').and.callFake(() => { return Promise.resolve(testStudy); }); @@ -619,7 +612,6 @@ describe('ClinicalTrialsGovService', () => { ]; const testStudy = createClinicalStudy(); - const updateSpy = spyOn(service, 'updateResearchStudy'); const getTrialSpy = jasmine.createSpy('getCachedClinicalStudy').and.callFake((nctId: string) => { return Promise.resolve(nctId === 'NCT00000002' ? null : testStudy); }); @@ -627,11 +619,6 @@ describe('ClinicalTrialsGovService', () => { service.getCachedClinicalStudy = getTrialSpy; await service.updateSearchSetEntries(testSearchSetEntries); expect(downloadTrialsSpy).toHaveBeenCalledOnceWith(['NCT00000001', 'NCT00000002', 'NCT00000003']); - // Update should have been called three times: twice for the NCT00000001 studies, and once for the NCT00000003 study - expect(updateSpy).toHaveBeenCalledWith(testSearchSetEntries[0].resource as ResearchStudy, testStudy); - expect(updateSpy).not.toHaveBeenCalledWith(testSearchSetEntries[1].resource as ResearchStudy, testStudy); - expect(updateSpy).toHaveBeenCalledWith(testSearchSetEntries[2].resource as ResearchStudy, testStudy); - expect(updateSpy).toHaveBeenCalledWith(testSearchSetEntries[3].resource as ResearchStudy, testStudy); }); it('does nothing if no studies have NCT IDs', async () => { @@ -652,7 +639,6 @@ describe('ClinicalTrialsGovService', () => { createSearchSetEntry('test4', 'NCT00000004', 0.5) ]; const testStudy = createClinicalStudy(); - spyOn(service, 'updateResearchStudy'); const getTrialSpy = jasmine.createSpy('getCachedClinicalStudy').and.callFake(() => { return Promise.resolve(testStudy); }); @@ -936,14 +922,4 @@ describe('ClinicalTrialsGovService', () => { expect(result?.study_json).toEqual(JSON.stringify(study)); }); }); - - describe('#updateResearchStudy', () => { - it('forwards to createResearchStudyFromClinicalStudy', () => { - const service = createMemoryCTGovService(); - const testResearchStudy = createResearchStudy('test'); - const testClinicalStudy = createClinicalStudy(); - service.updateResearchStudy(testResearchStudy, testClinicalStudy); - // There's no really good way to verify this worked. For now, it not blowing up is good enough. - }); - }); }); diff --git a/spec/index.spec.ts b/spec/index.spec.ts index e33fcbf..6171cbb 100644 --- a/spec/index.spec.ts +++ b/spec/index.spec.ts @@ -11,6 +11,8 @@ describe('index', () => { expect(ctms.CodeSystemEnum).toBeDefined(); expect(ctms.ClientError).toBeDefined(); expect(ctms.ServerError).toBeDefined(); + expect(ctms.FHIRDate).toBeDefined(); + expect(ctms.resourceContainsProfile).toBeDefined(); // This is dumb but otherwise Istanbul complains they were never called expect(new ctms.ClientError('example')).toBeInstanceOf(Error); expect(new ctms.ServerError('example')).toBeInstanceOf(Error); diff --git a/src/clinicaltrialsgov.ts b/src/clinicaltrialsgov.ts index b139c64..c1a3a59 100644 --- a/src/clinicaltrialsgov.ts +++ b/src/clinicaltrialsgov.ts @@ -84,7 +84,11 @@ export function formatNCTNumber(nctNumber: number): string { export function findNCTNumber(study: ResearchStudy): NCTNumber | null { if (study.identifier && Array.isArray(study.identifier) && study.identifier.length > 0) { for (const identifier of study.identifier) { - if (identifier.system === CLINICAL_TRIAL_IDENTIFIER_CODING_SYSTEM_URL && typeof identifier.value === 'string') + if ( + identifier.system === CLINICAL_TRIAL_IDENTIFIER_CODING_SYSTEM_URL && + typeof identifier.value === 'string' && + isValidNCTNumber(identifier.value) + ) return identifier.value; } // Fallback: regexp match @@ -98,26 +102,15 @@ export function findNCTNumber(study: ResearchStudy): NCTNumber | null { /** * Finds all the NCT numbers within the given list of studies. Returns a map of - * NCT numbers to the research studies they match. If multiple research studies - * have the same NCT ID, then an array will be used to contain all matching - * studies. (This should be very uncommon but is supported anyway.) + * NCT numbers to the research studies they match. * @param studies the NCT numbers found, if any */ -export function findNCTNumbers(studies: ResearchStudy[]): Map> { - const result = new Map>(); +export function findNCTNumbers(studies: ResearchStudy[]): Set { + const result = new Set(); for (const study of studies) { const nctId = findNCTNumber(study); if (nctId !== null) { - const existing = result.get(nctId); - if (existing === undefined) { - result.set(nctId, study); - } else { - if (Array.isArray(existing)) { - existing.push(study); - } else { - result.set(nctId, [existing, study]); - } - } + result.add(nctId); } } return result; @@ -455,40 +448,38 @@ export class ClinicalTrialsGovService { * occurs). * * @param studies the studies to attempt to update - * @returns a Promise that resolves when the studies are updated. It will resolve with the same array that was passed - * in - this updates the given objects, it does not clone them and create new ones. + * @returns a Promise that resolves with updated studies */ async updateResearchStudies(studies: ResearchStudy[]): Promise { - const nctIdMap = findNCTNumbers(studies); - if (nctIdMap.size === 0) { + const nctIds = Array.from(findNCTNumbers(studies)); + if (nctIds.length === 0) { // Nothing to do return studies; } - const nctIds = Array.from(nctIdMap.keys()); this.log('Updating research studies with NCT IDs %j', nctIds); // Make sure the NCT numbers are in the cache await this.ensureTrialsAvailable(nctIds); // Update the items in the list + const updatedTrials = new Map(); await Promise.all( - Array.from(nctIdMap.entries()).map(([nctId, study]) => this.updateResearchStudyFromCache(nctId, study)) + Array.from(nctIds).map(async (nctId) => { + const study = await this.getCachedClinicalStudy(nctId); + if (study) { + updatedTrials.set(nctId, createResearchStudyFromClinicalStudy(study)); + } + }) ); - return studies; - } - - private async updateResearchStudyFromCache(nctId: string, originals: ResearchStudy | ResearchStudy[]): Promise { - const clinicalStudy = await this.getCachedClinicalStudy(nctId); - // Make sure we have data to use - cache can be missing NCT IDs even after requesting them if the NCT is missing - // from the origin service - if (clinicalStudy !== null) { - // Update whatever trials we have, which will either be an array or single object - if (Array.isArray(originals)) { - for (const s of originals) { - this.updateResearchStudy(s, clinicalStudy); + return studies.map((study) => { + const id = findNCTNumber(study); + if (id) { + // It's possible some IDs may not exist and may be left alone + const updated = updatedTrials.get(id); + if (updated) { + return updated; } - } else { - this.updateResearchStudy(originals, clinicalStudy); } - } + return study; + }); } async updateSearchSetEntries(entries: SearchSetEntry[]): Promise { @@ -497,9 +488,15 @@ export class ClinicalTrialsGovService { await this.ensureTrialsAvailable(studies); await Promise.all( - entries.map((entry) => { - const nctId = findNCTNumber(entry.resource as ResearchStudy) || ''; - return this.updateResearchStudyFromCache(nctId, entry.resource as ResearchStudy); + entries.map(async (entry) => { + const resource = entry.resource as ResearchStudy; + const nctId = findNCTNumber(resource); + if (nctId) { + const study = await this.getCachedClinicalStudy(nctId); + if (study) { + entry.resource = createResearchStudyFromClinicalStudy(study); + } + } }) ); @@ -681,18 +678,6 @@ export class ClinicalTrialsGovService { return null; } - /** - * The provides a stub that handles updating the research study with data from a clinical study downloaded from the - * ClinicalTrials.gov website. This primarily exists as a stub to allow the exact process which updates a research - * study to be overridden if necessary. - * - * @param researchStudy the research study to update - * @param clinicalStudy the clinical study to update it with - */ - updateResearchStudy(researchStudy: ResearchStudy, clinicalStudy: Study): void { - createResearchStudyFromClinicalStudy(clinicalStudy, researchStudy); - } - /** * Creates and initializes a new service for retrieving data from http://clinicaltrials.gov/. * diff --git a/src/index.ts b/src/index.ts index a08df53..c66bc3c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export * from './research-study'; export * from './searchset'; export * from './clinicaltrialsgov'; +export * from './fhir-util'; export { CodeMapper, CodeSystemEnum } from './codeMapper'; export * from './mcodeextractor'; export { Study } from './ctg-api'; diff --git a/src/study-fhir-converter.ts b/src/study-fhir-converter.ts index 00cfad9..8650c60 100644 --- a/src/study-fhir-converter.ts +++ b/src/study-fhir-converter.ts @@ -12,6 +12,7 @@ import { } from 'fhir/r4'; import { isFhirDate } from './fhir-type-guards'; import { addContainedResource, addToContainer } from './research-study'; +import { CLINICAL_TRIAL_IDENTIFIER_CODING_SYSTEM_URL } from './clinicaltrialsgov'; const PHASE_MAP = new Map([ [Phase.NA, 'n-a'], @@ -103,6 +104,29 @@ export function createResearchStudyFromClinicalStudy(study: Study, result?: Rese return result; } + // Fill out basic information + const identificationModule = protocolSection.identificationModule; + if (identificationModule) { + const nctId = identificationModule.nctId; + if (nctId) { + result.identifier = [ + { + system: CLINICAL_TRIAL_IDENTIFIER_CODING_SYSTEM_URL, + value: nctId + } + ]; + } + const briefTitle = identificationModule.briefTitle; + if (briefTitle) { + result.title = briefTitle; + } else { + const officialTitle = identificationModule.officialTitle; + if (officialTitle) { + result.title = officialTitle; + } + } + } + const eligibility = protocolSection.eligibilityModule; if (eligibility) { const criteria = eligibility.eligibilityCriteria;