From c4b7672e851e6ef2e345b46957614c3c78d235a0 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Tue, 3 Nov 2026 21:34:21 +1100 Subject: [PATCH 1/2] big bug hunt #2 - various issues per Josh's report --- CHANGELOG.md | 8 ++ tests/cs/cs-cpt.test.js | 13 +++ tests/cs/cs-cs.test.js | 36 ++++++- tests/cs/cs-loinc.test.js | 14 +++ tests/cs/cs-ndc.test.js | 8 ++ tests/cs/cs-omop.test.js | 10 ++ tests/cs/cs-rxnorm-filters.test.js | 9 ++ tests/cs/cs-snomed.test.js | 15 +++ tests/cs/cs-ucum.test.js | 21 ++++ tests/tx/supplement-filters.test.js | 55 ++++++++++ tx/cs/cs-api.js | 138 ++++++++++++++++++++++++- tx/cs/cs-country.js | 4 +- tx/cs/cs-cpt.js | 9 ++ tx/cs/cs-cs.js | 86 +++++++++++++-- tx/cs/cs-loinc.js | 63 +++++++++-- tx/cs/cs-ndc.js | 8 ++ tx/cs/cs-omop.js | 18 +++- tx/cs/cs-rxnorm.js | 8 ++ tx/cs/cs-snomed.js | 116 ++++++++++++++++----- tx/cs/cs-ucum.js | 18 +++- tx/library/designations.js | 20 +++- tx/sct/text-index.js | 6 +- tx/workers/expand.js | 155 ++++++++++++++++++++++------ tx/workers/supplement-filters.js | 145 ++++++++++++++++++++++++++ tx/workers/validate.js | 97 ++++++++++++----- tx/workers/worker.js | 96 +++++++++++++---- tx/xversion/xv-valueset.js | 11 +- 27 files changed, 1046 insertions(+), 141 deletions(-) create mode 100644 tests/tx/supplement-filters.test.js create mode 100644 tx/workers/supplement-filters.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e0f78bb..71ff3bb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SNOMED CT importer: effectiveTimes were stored a day early when a cache was built in a time zone ahead of UTC (the day count to the 1899 epoch used local dates), so `$lookup` reported e.g. 2002-01-30 for 20020131. Caches built in such a zone need rebuilding - `CodeSystem/$batch-validate-code`: a top-level `url` (and `version`) is the code system for every entry, as for `CodeSystem/$validate-code`, and every entry is validated against a code system - an entry with its own `url` was validated as a value set +- `regex` filters match the whole value, for every code system: the pattern is `^(?:pattern)$`. Code systems from CodeSystem resources, ISO 3166 and filters on supplement properties anchored it as `^pattern$`, so an alternation went wrong (`code2|code3` matched every code starting with code2); LOINC matched anywhere in the value (a CLASS regex of `CYTO` matched PANEL.HL7.CYTOGEN). LOINC regex filters may now select fewer codes +- ValueSet `$validate-code` with `activeOnly=true`: an inactive code was only rejected when the value set includes all of its code system; when the value set lists the code, or selects it with a filter, it was valid (with an inactive warning). It is now not in the value set whatever the include, for every code system +- Only one version of a supplement applies: when the server has one version and the request passes another in `tx-resource` (or a value set names one and the request another), the version the request asks for by version (`useSupplement url|version`, or the value set's `valueset-supplement`) is used - else the request's own, else the latest - and `used-supplement` reports that exact version. Both were applied before, and the server's version was the one reported (and whose displays won) +- ValueSet `$validate-code`: a code found through a value set filter didn't report a status the code has while active (`deprecated`: no `status` parameter and no `DEPRECATED_CONCEPT_FOUND` warning), as it does when the value set lists the code +- `$expand` text filters (`filter`) now search one language: the first language the request asks for (`displayLanguage`, else `Accept-Language`) that the code system has text in, else the code system's own language - the same rule as the SNOMED CT display filter. All the displays and designations in that language take part, including those from supplements in use; designations in other languages no longer match. LOINC searches its names in other languages (`filter=Volumen&displayLanguage=de` finds Glucose [Masse/Volumen] in Serum oder Plasma) and supplement designations (Uzbek); in English it still searches the displays. SNOMED CT builds a text index per language on editions with more than one (the first search in a language takes a second or two), and a national edition with no language asked for searches its default language +- A value set filter on a property that a supplement in use defines (`catalog-group = renal-diabetes` on LOINC or SNOMED CT, with a supplement giving codes a `catalog-group`) failed (500 for LOINC and SNOMED CT, 'not understood' for code systems from CodeSystem resources). Such filters are now evaluated from the supplement, for any code system, in `$expand` (includes and excludes) and `$validate-code`, alone or with the code system's own filters (=, in, regex and exists = true can select codes on their own; not-in and exists = false only narrow other filters). A filter the code system does itself still goes to the code system +- Supplements pinned to a version of their code system (`supplements` = `http://loinc.org|2.80`) were applied to whichever version a request without a version resolved to (LOINC 2.83), and reported as used; a value set requiring one didn't fail. Which supplements apply is now decided by the version actually used: one pinned to another version is not applied (so requiring it fails with `VALUESET_SUPPLEMENT_MISSING`), and one from the server's own content pinned to that version now applies when the request doesn't name a version +- `$expand` returned no properties for codes from LOINC, SNOMED CT, RxNorm, UCUM, OMOP, CPT or NDC, whether asked for with `property`, `ValueSet.compose.property` or `*` (`$lookup` returned them): these code systems now report the requested properties on each code, as `$lookup` does, and only compute them when some are asked for. UCUM's `canonical` property is returned when asked for (`$lookup` never returned it). An R4 expansion declared a property with no uri with an empty uri extension. OMOP codes from a filter were JSON numbers in an expansion ## [0.18.2] - 2026-10-29 diff --git a/tests/cs/cs-cpt.test.js b/tests/cs/cs-cpt.test.js index 88cc1192..de0f0a01 100644 --- a/tests/cs/cs-cpt.test.js +++ b/tests/cs/cs-cpt.test.js @@ -207,6 +207,19 @@ describe('CPT Provider', () => { }); describe('Extended Lookup', () => { + // $expand reports properties from properties(), which is built from extendLookup - but not + // the copyright, which is about the code system + test('properties() for $expand: only the named ones, never the copyright', async () => { + for (const [code, expectedProps] of Object.entries(testData.properties)) { + expect(await provider.properties(code, [])).toEqual([]); + const names = Object.keys(expectedProps); + const got = await provider.properties(code, names); + expect(got.length).toBeGreaterThan(0); + expect(got.every(p => names.includes(p.code))).toBe(true); + expect((await provider.properties(code, ['*'])).some(p => p.code === 'copyright')).toBe(false); + } + }); + test('should extend lookup with properties and designations', async () => { for (const code of testData.allCodes) { const paramSet = []; diff --git a/tests/cs/cs-cs.test.js b/tests/cs/cs-cs.test.js index a3380c22..7bc95f68 100644 --- a/tests/cs/cs-cs.test.js +++ b/tests/cs/cs-cs.test.js @@ -1548,8 +1548,30 @@ describe('FHIR CodeSystem Provider', () => { }); test('should match a search whose words all start words of one designation', async () => { - expect(codes(await ptProvider.searchFilter(filterContext, new SearchFilterText('myocardial acute'), true))).toEqual(['I21']); - expect(codes(await ptProvider.searchFilter(filterContext, new SearchFilterText('acute miocardio'), true))).toEqual([]); + // the English designation is searched when English is asked for (a text filter + // searches one language: the request's, else the code system's - Portuguese here) + const english = new Languages(); + english.add(new Language('en')); + const enContext = await ptProvider.getPrepContext(false, english); + expect(codes(await ptProvider.searchFilter(enContext, new SearchFilterText('myocardial acute'), true))).toEqual(['I21']); + expect(codes(await ptProvider.searchFilter(enContext, new SearchFilterText('acute miocardio'), true))).toEqual([]); + expect(codes(await ptProvider.searchFilter(filterContext, new SearchFilterText('myocardial acute'), true))).toEqual([]); + }); + + test('should search a supplement\'s designations in the language searched', async () => { + const supplement = new CodeSystem({ + resourceType: 'CodeSystem', url: 'http://example.org/icd-10-cm-pt-de', status: 'active', + content: 'supplement', supplements: 'http://example.org/icd-10-cm-pt', + concept: [{code: 'A00', designation: [{language: 'de', value: 'Cholera'}]}] + }); + const withSupplement = factory.build(opContext, [supplement], ptProvider.codeSystem); + const german = new Languages(); + german.add(new Language('de')); + const deContext = await withSupplement.getPrepContext(false, german); + expect(codes(await withSupplement.searchFilter(deContext, new SearchFilterText('cholera'), true))).toEqual(['A00']); + // with no language asked for, the code system's own (Portuguese) is searched + expect(codes(await withSupplement.searchFilter(filterContext, new SearchFilterText('cholera'), true))).toEqual([]); + expect(codes(await withSupplement.searchFilter(filterContext, new SearchFilterText('colera'), true))).toEqual(['A00']); }); test('should not match a word that is only inside another word', async () => { @@ -1651,10 +1673,18 @@ describe('FHIR CodeSystem Provider', () => { expect(results.findConceptByCode('code1')).toBeNull(); }); + test('a regex matches the whole code, alternation included', async () => { + const results = await simpleProvider.filter(filterContext, true, 'code', 'regex', 'code2|code3'); + expect(results.findConceptByCode('code2')).toBeDefined(); + expect(results.findConceptByCode('code3')).toBeDefined(); + expect(results.findConceptByCode('code2a')).toBeNull(); // ^code2|code3$ would have matched it + expect(results.size()).toBe(2); + }); + test('should handle invalid regex gracefully', async () => { await expect( simpleProvider.filter(filterContext, true, 'code', 'regex', '[invalid') - ).rejects.toThrow('The regex \'[invalid\' is not valid: error parsing regexp: missing closing ]: `[invalid$`'); + ).rejects.toThrow('The regex \'[invalid\' is not valid: error parsing regexp: missing closing ]: `[invalid)$`'); }); }); diff --git a/tests/cs/cs-loinc.test.js b/tests/cs/cs-loinc.test.js index 44931935..64038168 100644 --- a/tests/cs/cs-loinc.test.js +++ b/tests/cs/cs-loinc.test.js @@ -1083,6 +1083,20 @@ describe('LOINC Provider', () => { }); describe('Extended Lookup', () => { + // $expand reports properties from properties(), which is built from extendLookup + test('properties() for $expand: only the named ones, none when none are named', async () => { + expect(await provider.properties('100007-4', [])).toEqual([]); + const got = await provider.properties('100007-4', ['COMPONENT', 'SCALE_TYP']); + expect(got.sort((a, b) => a.code.localeCompare(b.code))).toEqual([ + { code: 'COMPONENT', valueCode: 'LP430726-2' }, + { code: 'SCALE_TYP', valueCode: 'LP7751-3' } + ]); + // Coding-valued (a mapping), without the equivalence subproperty + expect(await provider.properties('LP14355-9', ['sct'])).toEqual([ + { code: 'sct', valueCoding: { system: 'http://snomed.info/sct', version: 'http://snomed.info/sct/900000000000207008/version/20260101', code: '15373003', display: 'Creatinine (substance)' } } + ]); + }); + test('should extend lookup with properties and relationships', async () => { const testCode = expectedResults.basic.knownCodes[0]; const paramSet = []; diff --git a/tests/cs/cs-ndc.test.js b/tests/cs/cs-ndc.test.js index 00a366cd..585ac1a9 100644 --- a/tests/cs/cs-ndc.test.js +++ b/tests/cs/cs-ndc.test.js @@ -498,6 +498,14 @@ describe('NDC Provider', () => { }); describe('Extended Lookup', () => { + // $expand reports properties from properties(), which is built from extendLookup + test('properties() for $expand', async () => { + expect(await provider.properties('0002-0152', [])).toEqual([]); + const got = await provider.properties('0002-0152', ['code-type', 'active']); + expect(got).toContainEqual({ code: 'code-type', valueString: 'product' }); + expect(got.map(p => p.code)).toContain('active'); + }); + test('should extend lookup for product with properties', async () => { const paramSet = []; await provider.extendLookup('0002-0152', [], paramSet); diff --git a/tests/cs/cs-omop.test.js b/tests/cs/cs-omop.test.js index b05cb996..96763272 100644 --- a/tests/cs/cs-omop.test.js +++ b/tests/cs/cs-omop.test.js @@ -233,6 +233,16 @@ describe('OMOP Provider', () => { }); describe('Extended Lookup', () => { + // $expand reports properties from properties(), which is built from extendLookup + test('properties() for $expand: only the named ones, none when none are named', async () => { + if (testData.sampleConcepts.length > 0) { + const conceptId = testData.sampleConcepts[0]; + expect(await provider.properties(conceptId, [])).toEqual([]); + const got = await provider.properties(conceptId, ['domain-id', 'vocabulary-id']); + expect(got.map(p => p.code).sort()).toEqual(['domain-id', 'vocabulary-id']); + } + }); + test('should extend lookup with basic properties', async () => { if (testData.sampleConcepts.length > 0) { const conceptId = testData.sampleConcepts[0]; diff --git a/tests/cs/cs-rxnorm-filters.test.js b/tests/cs/cs-rxnorm-filters.test.js index 2d7c775e..c45cdf3a 100644 --- a/tests/cs/cs-rxnorm-filters.test.js +++ b/tests/cs/cs-rxnorm-filters.test.js @@ -250,6 +250,15 @@ describe('RxNorm filters, totals, status and lookup', () => { expect(params.map(p => p.part[0].valueCode).sort()).toEqual(['STY', 'has_dose_form']); }); + // $expand reports properties from properties(), which is built from extendLookup + test('properties() for $expand: only the named ones, none when none are named', async () => { + expect(await provider.properties('197589', [])).toEqual([]); + expect(await provider.properties('197589', ['has_dose_form', 'STY'])).toEqual([ + { code: 'STY', valueCode: 'T200' }, + { code: 'has_dose_form', valueCode: '317541' } + ]); + }); + test('a relationship property value finds the code again as a filter', async () => { // 197589 has_dose_form 317541 (as a lookup property) <=> has_dose_form = CUI:317541 (as a filter) expect(await codes(['has_dose_form', '=', 'CUI:317541'])).toContain('197589'); diff --git a/tests/cs/cs-snomed.test.js b/tests/cs/cs-snomed.test.js index cb913c53..45e9b11a 100644 --- a/tests/cs/cs-snomed.test.js +++ b/tests/cs/cs-snomed.test.js @@ -1970,4 +1970,19 @@ describe('SNOMED CT $lookup properties', () => { const expr = await props('367430006:{405813007=(85562004:272741003=24028007)}', ['normalFormTerse']); expect(expr.normalFormTerse).toEqual(['387713003:{260686004=257903006,405813007=(118632007:{272741003=182353008})},{405813007=(85562004:{272741003=24028007})}']); }); + + // $expand reports properties from properties(), which is built from extendLookup + test('properties() for $expand: only the named ones, none when none are named', async () => { + const { context } = await provider.locate('367430006'); + expect(await provider.properties(context, [])).toEqual([]); + const got = await provider.properties(context, ['moduleId', 'sufficientlyDefined', '260686004']); + expect(got).toEqual([ + { code: 'moduleId', valueCode: '900000000000207008' }, + { code: 'sufficientlyDefined', valueBoolean: true }, + { code: '260686004', valueCode: '257903006' } + ]); + // the same values $lookup returns, without the lookup-only parts (description, code-display) + const lookup = await props('367430006', ['moduleId', 'sufficientlyDefined', '260686004']); + expect(got.map(p => p.code).sort()).toEqual(Object.keys(lookup).sort()); + }); }); diff --git a/tests/cs/cs-ucum.test.js b/tests/cs/cs-ucum.test.js index 6532f78f..0f69af1a 100644 --- a/tests/cs/cs-ucum.test.js +++ b/tests/cs/cs-ucum.test.js @@ -357,6 +357,27 @@ describe('UCUM Provider Integration Tests', () => { } }); }); + + describe('Properties', () => { + test('$lookup returns canonical when asked for it, not by default', async () => { + const { context } = await provider.locate('mg/dL'); + let params = []; + await provider.extendLookup(context, [], params); + expect(params.filter(p => p.name === 'property')).toEqual([]); + params = []; + await provider.extendLookup(context, ['canonical'], params); + expect(params).toEqual([{ name: 'property', part: [ + { name: 'code', valueCode: 'canonical' }, { name: 'value', valueString: ucumService.getCanonicalUnits('mg/dL') }] }]); + }); + + // $expand reports properties from properties(), which is built from extendLookup + test('properties() for $expand', async () => { + const { context } = await provider.locate('mg/dL'); + expect(await provider.properties(context, [])).toEqual([]); + expect(await provider.properties(context, ['canonical'])).toEqual([ + { code: 'canonical', valueString: ucumService.getCanonicalUnits('mg/dL') }]); + }); + }); }); // Usage example for external integration diff --git a/tests/tx/supplement-filters.test.js b/tests/tx/supplement-filters.test.js new file mode 100644 index 00000000..fbc254d0 --- /dev/null +++ b/tests/tx/supplement-filters.test.js @@ -0,0 +1,55 @@ +// Filters on properties that a supplement defines (catalog X2): evaluated from the supplement, +// whatever the code system +const { SupplementPropertyFilter, splitFilters } = require('../../tx/workers/supplement-filters'); + +const supplement = { + url: 'http://example.org/supp', + jsonObj: { + property: [{ code: 'group', type: 'code' }], + concept: [ + { code: 'a', property: [{ code: 'group', valueCode: 'red' }] }, + { code: 'b', property: [{ code: 'group', valueCode: 'blue' }, { code: 'group', valueCode: 'red' }] }, + { code: 'c', property: [{ code: 'other', valueString: 'x' }], concept: [ + { code: 'c1', property: [{ code: 'group', valueCoding: { system: 'http://x', code: 'green' } }] } + ] } + ] + } +}; + +const f = (op, value) => new SupplementPropertyFilter('group', op, value, [supplement]); + +describe('SupplementPropertyFilter', () => { + test('= matches any of a code\'s values, including nested concepts and Codings', () => { + expect(f('=', 'red').codes()).toEqual(['a', 'b']); + expect(f('=', 'green').codes()).toEqual(['c1']); + expect(f('=', 'red').matches('c')).toBe(false); + }); + + test('in, regex and exists', () => { + expect(f('in', 'blue,green').codes()).toEqual(['b', 'c1']); + expect(f('regex', 'r.*').codes()).toEqual(['a', 'b']); + expect(f('exists', 'true').codes()).toEqual(['a', 'b', 'c1']); + }); + + test('not-in and exists = false select codes the supplement says nothing about, so cannot drive', () => { + expect(f('not-in', 'red').enumerable).toBe(false); + expect(f('not-in', 'red').matches('zzz')).toBe(true); + expect(f('not-in', 'red').matches('b')).toBe(false); + expect(f('exists', 'false').enumerable).toBe(false); + expect(f('exists', 'false').matches('c')).toBe(true); + }); + + test('splitFilters: the code system\'s own filters stay with it, even on a property a supplement also defines', async () => { + const cs = { supplements: [supplement], doesFilter: async (prop) => prop === 'concept' }; + const split = await splitFilters(cs, [ + { property: 'concept', op: 'is-a', value: 'x' }, + { property: 'group', op: '=', value: 'red' }, + { property: 'unknown', op: '=', value: 'y' } + ]); + expect(split.base.map(fc => fc.property)).toEqual(['concept', 'unknown']); + expect(split.supplement.map(sf => sf.toString())).toEqual(['group = red']); + + const owns = { supplements: [supplement], doesFilter: async () => true }; + expect((await splitFilters(owns, [{ property: 'group', op: '=', value: 'red' }])).supplement).toEqual([]); + }); +}); diff --git a/tx/cs/cs-api.js b/tx/cs/cs-api.js index e1496903..911b1ff4 100644 --- a/tx/cs/cs-api.js +++ b/tx/cs/cs-api.js @@ -279,6 +279,95 @@ class CodeSystemProvider { } } + /** + * The language a text filter ($expand filter) searches. A text filter matches a code's + * displays and designations in one language only (the same rule as for the SNOMED CT display + * filter): the first language the request names (displayLanguage, else Accept-Language) that + * the code system - or a supplement in use - has displays in, else the code system's default + * language. Matching is at the language subtag level: de-DE searches all German. + * + * @param {Languages|null} languages - what the request asked for + * @returns {Language} + */ + searchLanguage(languages) { + for (const lang of (languages && languages.languages) || []) { + if (!lang || !lang.language || lang.code === '*') { + continue; + } + const one = new Languages(); + one.add(lang); + if (this.hasAnyDisplays(one) || this._hasSupplementText(lang)) { + return lang; + } + } + return new Language(this.defLang()); + } + + /** + * Whether a supplement in use has any display or designation (of any use) in the language + * + * @param {Language} lang + * @returns {boolean} + */ + _hasSupplementText(lang) { + for (const supplement of this.supplements || []) { + const suppLang = supplement.jsonObj.language || this.defLang(); + for (const concept of supplement.getAllConcepts()) { + if (concept.display && CodeSystemProvider.inSearchLanguage(suppLang, lang)) { + return true; + } + if ((concept.designation || []).some(d => d.value && CodeSystemProvider.inSearchLanguage(d.language, lang, suppLang))) { + return true; + } + } + } + return false; + } + + /** + * Whether text in language `code` (a tag; empty means `fallback`) is in the search language + * + * @param {string|Language|null} code + * @param {Language} searchLang - from searchLanguage() + * @param {string} [fallback] - the language of text that doesn't say + * @returns {boolean} + */ + static inSearchLanguage(code, searchLang, fallback = 'en') { + let lang = code instanceof Language ? code : (code ? new Language(code) : null); + if (!lang || !lang.language) { + lang = new Language(fallback || 'en'); + } + return lang.language === searchLang.language; + } + + /** + * The codes whose displays or designations in a supplement in use, in the search language, + * pass a text filter - a supplement's designations are searched like the code system's own + * + * @param {SearchFilterText} filter + * @param {Language} searchLang - from searchLanguage() + * @returns {Set} + */ + _supplementSearchCodes(filter, searchLang) { + const codes = new Set(); + for (const supplement of this.supplements || []) { + const suppLang = supplement.jsonObj.language || this.defLang(); + for (const concept of supplement.getAllConcepts()) { + if (concept.display && CodeSystemProvider.inSearchLanguage(suppLang, searchLang) && filter.passes(concept.display)) { + codes.add(concept.code); + continue; + } + for (const d of concept.designation || []) { + if (d.value && CodeSystemProvider.inSearchLanguage(d.language, searchLang, suppLang) && filter.passes(d.value)) { + codes.add(concept.code); + break; + } + } + } + } + return codes; + } + _hasAnySupplementDisplays(languages) { // Check if any supplements have displays in the requested languages if (this.supplements) { @@ -570,11 +659,50 @@ class CodeSystemProvider { async extensions(code) { return null; } /** - + * The properties of a concept, in CodeSystem.concept.property form ({code, value[x]}). + * $expand reports them on expansion.contains, so a provider that has properties must + * override this, not just extendLookup - otherwise $lookup returns them and $expand never does + * * @param {string | CodeSystemProviderContext} code + * @param {string[]} [names] the properties the caller wants ('*' for all of them). A provider + * that has to compute its properties should compute only these (none when the list is + * empty: an expansion that asks for no properties must not pay for them); one that holds + * them in memory may return the lot, and the caller picks what it asked for * @returns {CodeSystem.concept.property[]} list of properties (may be empty) */ - async properties(code) { return []; } + // eslint-disable-next-line no-unused-vars + async properties(code, names) { return []; } + + /** + * properties() for a provider whose properties are only built by extendLookup: run it for + * just the named properties, and turn the $lookup property parameters it adds into + * CodeSystem.concept.property form. Lookup-only parts (subproperty, description, language, + * code-display...) are dropped; designations are not properties + * + * @param {string | CodeSystemProviderContext} context + * @param {string[]} names - the properties wanted ('*' for all); none means none + * @returns {Promise} + */ + async _propertiesFromLookup(context, names) { + if (!names || names.length === 0) { + return []; + } + const params = []; + await this.extendLookup(context, names, params); + const result = []; + for (const p of params) { + if (p.name !== 'property' || !Array.isArray(p.part)) { + continue; + } + const code = p.part.find(x => x.name === 'code')?.valueCode; + const value = p.part.find(x => x.name === 'value'); + const vn = value ? Object.keys(value).find(k => k.startsWith('value')) : null; + if (code && vn && value[vn] !== undefined && value[vn] !== null) { + result.push({ code, [vn]: value[vn] }); + } + } + return result; + } /** @@ -711,7 +839,11 @@ class CodeSystemProvider { * **/ // eslint-disable-next-line no-unused-vars - async getPrepContext(iterate, languages = null) { return new FilterExecutionContext(iterate); } + async getPrepContext(iterate, languages = null) { + const result = new FilterExecutionContext(iterate); + result.languages = languages; // what the request asked for (a text filter searches one language) + return result; + } /** diff --git a/tx/cs/cs-country.js b/tx/cs/cs-country.js index 25e75cea..823174b1 100644 --- a/tx/cs/cs-country.js +++ b/tx/cs/cs-country.js @@ -211,8 +211,8 @@ class CountryCodeServices extends CodeSystemProvider { const result = new CountryCodeConceptFilter(); try { - // Create regex with anchors to match the Pascal implementation (^value$) - const regex = regexUtilities.compile('^' + value + '$'); + // anchored to the whole code + const regex = regexUtilities.compile('^(?:' + value + ')$'); // the whole value: a|b is ^(?:a|b)$, not ^a|b$ for (const concept of this.codes) { if (regex.test(concept.code)) { diff --git a/tx/cs/cs-cpt.js b/tx/cs/cs-cpt.js index 13debf53..ff8433fb 100644 --- a/tx/cs/cs-cpt.js +++ b/tx/cs/cs-cpt.js @@ -228,6 +228,15 @@ class CPTServices extends BaseCSServices { isNotClosed() { return true; } + /** + * The concept's properties for $expand: built by extendLookup, so that an expansion reports + * the same properties $lookup does (see CodeSystemProvider._propertiesFromLookup). Not the + * copyright, which extendLookup always adds: it is about the code system, not the concept + */ + async properties(context, names) { + return (await this._propertiesFromLookup(context, names)).filter(p => p.code !== 'copyright'); + } + async extendLookup(ctxt, props, params) { validateArrayParameter(props, 'props', String); validateArrayParameter(params, 'params', Object); diff --git a/tx/cs/cs-cs.js b/tx/cs/cs-cs.js index 0f78291e..9b9e89c7 100644 --- a/tx/cs/cs-cs.js +++ b/tx/cs/cs-cs.js @@ -1,5 +1,5 @@ const { CodeSystem} = require("../library/codesystem"); -const { CodeSystemFactoryProvider, FilterExecutionContext } = require( "./cs-api"); +const { CodeSystemFactoryProvider, FilterExecutionContext, CodeSystemProvider } = require( "./cs-api"); const { VersionUtilities } = require("../../library/version-utilities"); const { Language } = require ("../../library/languages"); const { validateOptionalParameter, getValuePrimitive, validateArrayParameter} = require("../../library/utilities"); @@ -56,7 +56,8 @@ function conceptSearchTexts(concept) { display: concept.display ? searchText(concept.display) : null, definition: concept.definition ? foldSearchText(concept.definition) : null, designations: Array.isArray(concept.designation) - ? concept.designation.filter(designation => designation.value).map(designation => searchText(designation.value)) + ? concept.designation.filter(designation => designation.value) + .map(designation => ({ ...searchText(designation.value), language: designation.language || null })) : [] }; SEARCH_TEXTS.set(concept, texts); @@ -1173,9 +1174,10 @@ class FhirCodeSystemProvider extends BaseCSServices { * @param {boolean} iterate - True if results will be iterated * @returns {FilterExecutionContext} Filter context */ - async getPrepContext(iterate) { - - return new FilterExecutionContext(iterate); + async getPrepContext(iterate, languages = null) { + const result = new FilterExecutionContext(iterate); + result.languages = languages; // what the request asked for (a text filter searches one language) + return result; } /** @@ -1345,13 +1347,16 @@ class FhirCodeSystemProvider extends BaseCSServices { const results = new FhirCodeSystemProviderFilterContext(); const searchTerm = foldSearchText(filter.filter); const searchWords = searchTerm.split(/[^\p{L}\p{N}]+/u).filter(word => word !== ''); + // the display, definition and designations in one language only (see searchLanguage), + // with the designations that supplements in use give in that language + const lang = this.searchLanguage(filterContext.languages); // Search through all concepts const allConcepts = this.codeSystem.getAllConcepts(); for (const concept of allConcepts) { this.opContext.deadCheck('cs:searchFilter'); - const rating = this._calculateSearchRating(concept, searchTerm, searchWords); + const rating = this._calculateSearchRating(concept, searchTerm, searchWords, lang); if (rating > 0) { results.add(concept, rating); } @@ -1378,8 +1383,20 @@ class FhirCodeSystemProvider extends BaseCSServices { * @returns {number} Rating (0 = no match, higher = better match) * @private */ - _calculateSearchRating(concept, searchTerm, searchWords) { - const texts = conceptSearchTexts(concept); + _calculateSearchRating(concept, searchTerm, searchWords, lang = null) { + const all = conceptSearchTexts(concept); + let texts = all; + if (lang) { + const csLang = this.defLang(); + const inLang = CodeSystemProvider.inSearchLanguage(csLang, lang); + texts = { + code: all.code, + display: inLang ? all.display : null, + definition: inLang ? all.definition : null, + designations: all.designations.filter(d => CodeSystemProvider.inSearchLanguage(d.language, lang, csLang)) + .concat(this.#supplementSearchTexts(concept.code, lang)) + }; + } const display = texts.display; let rating = 0; @@ -1430,6 +1447,55 @@ class FhirCodeSystemProvider extends BaseCSServices { return rating; } + /** + * As for any code system (CodeSystemProvider.searchLanguage), but whether there is text in + * a language is decided by what the code system actually has: its own language, a designation + * in it, or a supplement's (hasAnyDisplays says yes to English whatever the code system's + * language is) + */ + searchLanguage(languages) { + for (const lang of (languages && languages.languages) || []) { + if (lang && lang.language && lang.code !== '*' && this.#hasSearchText(lang)) { + return lang; + } + } + return new Language(this.defLang()); + } + + #hasSearchText(lang) { + const csLang = this.defLang(); + if (CodeSystemProvider.inSearchLanguage(csLang, lang)) { + return true; + } + for (const concept of this.codeSystem.getAllConcepts()) { + if ((concept.designation || []).some(d => d.value && CodeSystemProvider.inSearchLanguage(d.language, lang, csLang))) { + return true; + } + } + return this._hasSupplementText(lang); + } + + // the displays and designations that supplements in use give a code, in the search language + #supplementSearchTexts(code, lang) { + const result = []; + for (const supplement of this.supplements || []) { + const concept = supplement.getConceptByCode(code); + if (!concept) { + continue; + } + const suppLang = supplement.jsonObj.language || this.defLang(); + if (concept.display && CodeSystemProvider.inSearchLanguage(suppLang, lang)) { + result.push(searchText(concept.display)); + } + for (const d of concept.designation || []) { + if (d.value && CodeSystemProvider.inSearchLanguage(d.language, lang, suppLang)) { + result.push(searchText(d.value)); + } + } + } + return result; + } + /** * Execute a value set filter * @param {FilterExecutionContext} filterContext - Filter context @@ -1611,7 +1677,7 @@ class FhirCodeSystemProvider extends BaseCSServices { else if (op === 'regex') { // Regular expression match try { - const regex = regexUtilities.compile('^' + value + '$'); + const regex = regexUtilities.compile('^(?:' + value + ')$'); // the whole value: a|b is ^(?:a|b)$, not ^a|b$ const allCodes = this.codeSystem.getAllCodes(); for (const code of allCodes) { this.opContext.deadCheck('cs:conceptFilter:regex'); @@ -1759,7 +1825,7 @@ class FhirCodeSystemProvider extends BaseCSServices { } else if (op === 'regex') { try { - const regex = regexUtilities.compile('^' + value + '$'); + const regex = regexUtilities.compile('^(?:' + value + ')$'); // the whole value: a|b is ^(?:a|b)$, not ^a|b$ return properties.some(p => regex.test(this._getPropertyValue(p))); } catch (error) { return false; diff --git a/tx/cs/cs-loinc.js b/tx/cs/cs-loinc.js index 1b898ccf..33a3a664 100644 --- a/tx/cs/cs-loinc.js +++ b/tx/cs/cs-loinc.js @@ -2,7 +2,7 @@ const sqlite3 = require('sqlite3').verbose(); const assert = require('assert'); const { CodeSystem } = require('../library/codesystem'); const { Language, Languages} = require('../../library/languages'); -const { CodeSystemFactoryProvider} = require('./cs-api'); +const { CodeSystemFactoryProvider, CodeSystemProvider } = require('./cs-api'); const { validateOptionalParameter, validateArrayParameter} = require("../../library/utilities"); const {BaseCSServices} = require("./cs-base"); const {sqlEscapeString} = require("../../xig/xig"); @@ -145,8 +145,9 @@ class LoincFilterHolder { } class LoincPrep { - constructor() { + constructor(languages = null) { this.filters = []; + this.languages = languages; // what the request asked for (a text filter searches one language) } } @@ -339,6 +340,14 @@ class LoincServices extends BaseCSServices { } + /** + * The concept's properties for $expand: built by extendLookup, so that an expansion reports + * the same properties $lookup does (see CodeSystemProvider._propertiesFromLookup) + */ + async properties(context, names) { + return this._propertiesFromLookup(context, names); + } + async extendLookup(ctxt, props, params) { validateArrayParameter(props, 'props', String); validateArrayParameter(params, 'params', Object); @@ -828,8 +837,8 @@ class LoincServices extends BaseCSServices { return false; } - async getPrepContext(iterate) { - return new LoincPrep(iterate); + async getPrepContext(iterate, languages = null) { + return new LoincPrep(languages); } async filter(filterContext, forIteration, prop, op, value) { @@ -838,10 +847,49 @@ class LoincServices extends BaseCSServices { filterContext.filters.push(filter); } + /** + * A text filter matches the codes with a name in the search language (see searchLanguage: + * the request's language if LOINC has it, else English) that contains the text, and the + * codes that a supplement in use gives a matching designation in that language. In English + * the name is the display (scanning every English description - short names, consumer + * names - takes 2+ seconds, against 0.2 for the displays); in another language it is any + * description in it but RELATEDNAMES2, which is a list of keywords (a property), not a name. + * LOINC does not rate its matches, so sorted or not they come in display order + */ async searchFilter(filterContext, filterText, sort) { // eslint-disable-line no-unused-vars const filter = new LoincFilterHolder(); - // LOINC does not rate its matches, so sorted or not they come in description order - await this.#executeFilterQuery('$text', '<', filterText.filter, filter); + const lang = this.searchLanguage(filterContext.languages); + const english = CodeSystemProvider.inSearchLanguage(null, lang); + const langKeys = []; + for (const [code, key] of this.langs) { + if (!english && CodeSystemProvider.inSearchLanguage(code, lang)) { + langKeys.push(key); + } + } + const text = sqlEscapeString(filterText.filter || ''); + const conds = []; + if (english) { + conds.push(`Codes.Description like '%${text}%'`); // the display, which is English + } + if (langKeys.length > 0) { + conds.push(`Codes.CodeKey IN (SELECT CodeKey FROM Descriptions WHERE LanguageKey IN (${langKeys.join(',')}) + AND DescriptionTypeKey != 4 AND Value like '%${text}%')`); + } + if (conds.length > 0) { + await this.#executeSQL(`SELECT Codes.CodeKey as Key FROM Codes WHERE ${conds.join(' OR ')} ORDER BY Codes.Description ASC`, filter); + } else { + filter.keys = []; + } + const have = new Set(filter.keys); + for (const code of this._supplementSearchCodes(filterText, lang)) { + const ctxt = this.codes.get(code); + if (ctxt && !have.has(ctxt.key)) { + filter.keys.push(ctxt.key); + have.add(ctxt.key); + } + } + filter.lsql = 'text'; // checked by key (filterLocate needs it set) + filter.keySet = new Set(filter.keys); // in display order, not key order, so hasKey can't binary search filterContext.filters.push(filter); } @@ -1150,7 +1198,8 @@ class LoincServices extends BaseCSServices { // Helper method for regex matching async #findRegexMatches(sql, pattern, valueColumn, keyColumn = 'Key') { return new Promise((resolve, reject) => { - const regex = regexUtilities.compile(pattern); + // the whole value, as for every regex filter: a|b is ^(?:a|b)$ + const regex = regexUtilities.compile('^(?:' + pattern + ')$'); const matchingKeys = []; this.db.all(sql, (err, rows) => { diff --git a/tx/cs/cs-ndc.js b/tx/cs/cs-ndc.js index 3ee95ab6..4d1285ad 100644 --- a/tx/cs/cs-ndc.js +++ b/tx/cs/cs-ndc.js @@ -134,6 +134,14 @@ class NdcServices extends CodeSystemProvider { } } + /** + * The concept's properties for $expand: built by extendLookup, so that an expansion reports + * the same properties $lookup does (see CodeSystemProvider._propertiesFromLookup) + */ + async properties(context, names) { + return this._propertiesFromLookup(context, names); + } + async extendLookup(ctxt, props, params) { validateArrayParameter(props, 'props', String); validateArrayParameter(params, 'params', Object); diff --git a/tx/cs/cs-omop.js b/tx/cs/cs-omop.js index 334deec7..e9935197 100644 --- a/tx/cs/cs-omop.js +++ b/tx/cs/cs-omop.js @@ -259,6 +259,22 @@ class OMOPServices extends BaseCSServices { }); } + /** + * The concept's properties for $expand: built by extendLookup, so that an expansion reports + * the same properties $lookup does (see CodeSystemProvider._propertiesFromLookup) + */ + async properties(context, names) { + // a concept from a filter is partial (domain_id is the domain's concept id, no concept class + // or vocabulary): the properties are those of the concept itself + if (names && names.length > 0 && context instanceof OMOPConcept && context.conceptClass === '') { + const located = await this.locate(String(context.code)); + if (located.context) { + context = located.context; + } + } + return this._propertiesFromLookup(context, names); + } + async extendLookup(ctxt, props, params) { validateArrayParameter(props, 'props', String); validateArrayParameter(params, 'params', Object); @@ -605,7 +621,7 @@ class OMOPServices extends BaseCSServices { set.cursor++; return new OMOPConcept( - row.concept_id, + String(row.concept_id), // a code is a string (the expansion's code was a JSON number) row.concept_name, row.domain_id, '', // concept_class not in basic filter query diff --git a/tx/cs/cs-rxnorm.js b/tx/cs/cs-rxnorm.js index a02cbde3..ab513e80 100644 --- a/tx/cs/cs-rxnorm.js +++ b/tx/cs/cs-rxnorm.js @@ -709,6 +709,14 @@ class RxNormServices extends BaseCSServices { return 'not-subsumed'; // Not implemented yet } + /** + * The concept's properties for $expand: built by extendLookup, so that an expansion reports + * the same properties $lookup does (see CodeSystemProvider._propertiesFromLookup) + */ + async properties(context, names) { + return this._propertiesFromLookup(context, names); + } + // Extension for lookup operation // // TTY, STY and SAB are returned unless other properties are asked for; the relationships diff --git a/tx/cs/cs-snomed.js b/tx/cs/cs-snomed.js index 858e0843..de742e15 100644 --- a/tx/cs/cs-snomed.js +++ b/tx/cs/cs-snomed.js @@ -19,6 +19,7 @@ const {debugLog} = require("../operation-context"); const {editionName, editionCode} = require("../sct/editions"); const {SnomedTextIndex, rankMatches, filterTokens} = require("../sct/text-index"); const Logger = require("../../library/logger"); +const {Language} = require("../../library/languages"); const regexUtilities = require("../../library/regex-utilities"); // Symbol key under which a resolved ECL filter analysis is memoised directly on @@ -2493,16 +2494,20 @@ class SnomedServices { * matches for a typical multi-word filter, with the event loop blocked for * the whole scan (tx.fhir.org incident 2026-09-17). * + * Only descriptions in `lang` take part, when it is given (a text filter searches one + * language: see SnomedProvider.searchFilter). + * * @param {SearchFilterText} searchText * @param {boolean} includeInactive - include inactive concepts * @param {OperationContext|null} opContext - for yielding / deadline checks + * @param {number|null} lang - the description language byte, or null for all * @returns {Promise} */ - async searchFilter(searchText, includeInactive = false, opContext = null) { + async searchFilter(searchText, includeInactive = false, opContext = null, lang = null) { const filterText = (searchText && searchText.filter ? searchText.filter : '').toLowerCase().trim(); const tokens = filterTokens(filterText); if (tokens.length > 0) { - const index = await this.getTextIndex(opContext); + const index = await this.getTextIndex(opContext, lang); if (index) { const result = new SnomedFilterContext(); const ordinals = index.search(tokens, opContext); @@ -2513,7 +2518,16 @@ class SnomedServices { return result; } } - return await this.scanSearch(filterText, includeInactive, opContext); + return await this.scanSearch(filterText, includeInactive, opContext, lang); + } + + // whether lang is the only language the edition has active descriptions in (then there is + // nothing to restrict - the International Edition is all English) + #onlyLanguage(lang) { + if (!this._activeDescLanguages) { + this._activeDescLanguages = this.descriptions.activeLanguages(); + } + return this._activeDescLanguages.size === 1 && this._activeDescLanguages.has(lang); } /** @@ -2524,28 +2538,53 @@ class SnomedServices { * @param {OperationContext|null} opContext * @returns {Promise} */ - async getTextIndex(opContext = null) { - if (this._textIndex) { - return this._textIndex; - } - if (!this._textIndexPromise) { - const started = Date.now(); - // Logger fetched lazily: importers and tests require this module too - const textIndexLog = Logger.getInstance().child({module: 'tx-sct'}); - this._textIndexPromise = SnomedTextIndex.build(this).then(index => { - this._textIndex = index; - textIndexLog.info(`SNOMED text index for ${this.versionUri}: ${index.words.length} words, built in ${Date.now() - started}ms`); - return index; - }, error => { - this._textIndexPromise = null; - textIndexLog.error(`SNOMED text index build failed for ${this.versionUri}: ${error.message}`); - return null; - }); - } + async getTextIndex(opContext = null, lang = null) { + // One index per language the edition is searched in; an edition with only one language + // (the International Edition) has just the one, for all its descriptions + if (lang === null || this.#onlyLanguage(lang)) { + if (this._textIndex) { + return this._textIndex; + } + if (!this._textIndexPromise) { + this._textIndexPromise = this.#buildTextIndex(null, index => { this._textIndex = index; }, () => { this._textIndexPromise = null; }); + } + return await this.#awaitTextIndex(this._textIndexPromise, opContext); + } + if (!this._textIndexes) { + this._textIndexes = new Map(); + this._textIndexPromises = new Map(); + } + if (this._textIndexes.has(lang)) { + return this._textIndexes.get(lang); + } + if (!this._textIndexPromises.has(lang)) { + this._textIndexPromises.set(lang, this.#buildTextIndex(lang, index => { this._textIndexes.set(lang, index); }, + () => { this._textIndexPromises.delete(lang); })); + } + return await this.#awaitTextIndex(this._textIndexPromises.get(lang), opContext); + } + + #buildTextIndex(lang, onBuilt, onFailed) { + const started = Date.now(); + // Logger fetched lazily: importers and tests require this module too + const textIndexLog = Logger.getInstance().child({module: 'tx-sct'}); + const which = lang === null ? '' : ` (language ${lang})`; + return SnomedTextIndex.build(this, lang).then(index => { + onBuilt(index); + textIndexLog.info(`SNOMED text index for ${this.versionUri}${which}: ${index.words.length} words, built in ${Date.now() - started}ms`); + return index; + }, error => { + onFailed(); + textIndexLog.error(`SNOMED text index build failed for ${this.versionUri}${which}: ${error.message}`); + return null; + }); + } + + async #awaitTextIndex(promise, opContext) { if (opContext && typeof opContext.waitFor === 'function') { - return await opContext.waitFor(this._textIndexPromise, 'sct:textIndex'); + return await opContext.waitFor(promise, 'sct:textIndex'); } - return await this._textIndexPromise; + return await promise; } /** @@ -2559,7 +2598,7 @@ class SnomedServices { * @param {OperationContext|null} opContext * @returns {Promise} */ - async scanSearch(filterText, includeInactive = false, opContext = null) { + async scanSearch(filterText, includeInactive = false, opContext = null, lang = null) { const result = new SnomedFilterContext(); const searchTerms = filterText.split(/\s+/).filter(t => t.length > 0); const matches = []; @@ -2591,7 +2630,7 @@ class SnomedServices { for (const descIndex of descriptionIndices) { const description = this.descriptions.getDescription(descIndex); - if (description.active) { + if (description.active && (lang === null || description.lang === lang)) { const term = this.strings.getEntry(description.iDesc).toLowerCase(); if (searchTerms.every(searchTerm => term.includes(searchTerm))) { matchFound = true; @@ -3088,6 +3127,14 @@ class SnomedProvider extends BaseCSServices { } } + /** + * The concept's properties for $expand: built by extendLookup, so that an expansion reports + * the same properties $lookup does (see CodeSystemProvider._propertiesFromLookup) + */ + async properties(context, names) { + return this._propertiesFromLookup(context, names); + } + async extendLookup(context, props, params) { const ctxt = await this.#ensureContext(context); if (ctxt) { @@ -3941,8 +3988,23 @@ class SnomedProvider extends BaseCSServices { // Search filter // eslint-disable-next-line no-unused-vars async searchFilter(filterContext, filter, sort) { - // `sort` is not a match-mode flag: results are always ranked. - let f = await this.sct.searchFilter(filter, false, this.opContext); + // `sort` is not a match-mode flag: results are always ranked. Only descriptions in one + // language take part, chosen as for the display filter: the first language the request + // names that the edition has descriptions in, else the edition's default; and the codes + // whose designations in that language, in a supplement in use, match + const lang = this.#filterLanguage(filterContext.languages); + let f = await this.sct.searchFilter(filter, false, this.opContext, lang); + if (this.supplements && this.supplements.length > 0) { + const have = new Set(f.matches.map(m => m.index)); + for (const code of this._supplementSearchCodes(filter, new Language(SCT_LANGUAGE_CODES[lang] || 'en'))) { + const id = this.sct.stringToIdOrZero(code); + const r = id !== 0n ? this.sct.concepts.findConcept(id) : null; + if (r && r.found && !have.has(r.index) && this.sct.isActive(r.index)) { + f.matches.push({ index: r.index, term: this.sct.concepts.getConceptId(r.index), priority: 10 }); + have.add(r.index); + } + } + } filterContext.filters.push(f); return f; } diff --git a/tx/cs/cs-ucum.js b/tx/cs/cs-ucum.js index e733ecae..fbeaea07 100644 --- a/tx/cs/cs-ucum.js +++ b/tx/cs/cs-ucum.js @@ -440,17 +440,27 @@ class UcumCodeSystemProvider extends BaseCSServices { return 'not-subsumed'; } + /** + * The concept's properties for $expand: built by extendLookup, so that an expansion reports + * the same properties $lookup does (see CodeSystemProvider._propertiesFromLookup) + */ + async properties(context, names) { + return this._propertiesFromLookup(context, names); + } + async extendLookup(ctxt, props, params) { validateArrayParameter(props, 'props', String); validateArrayParameter(params, 'params', Object); - if (this._hasProp(props, 'canonical', true)) { + // canonical: the unit's canonical form. Only when asked for (by name or *) - $lookup never + // returned it by default (the old code looked for an addProperty method that params, an + // array, doesn't have, so it never returned it at all) + if (this._hasProp(props, 'canonical', false)) { try { const canonical = this.ucumService.getCanonicalUnits(ctxt.code); - // Add canonical property to params - implementation depends on your Parameters class - if (params && typeof params.addProperty === 'function') { - params.addProperty('canonical', canonical); + if (canonical) { + this._addStringProperty(params, 'property', 'canonical', canonical); } } catch (error) { // Ignore errors in canonical form calculation diff --git a/tx/library/designations.js b/tx/library/designations.js index 3ca98a41..bbac6bbf 100644 --- a/tx/library/designations.js +++ b/tx/library/designations.js @@ -85,7 +85,13 @@ class SearchFilterText { /** * Check if designations pass the filter */ - passesDesignations(cds) { + /** + * Whether any of the designations passes the filter. With a search language (see + * CodeSystemProvider.searchLanguage), only designations in that language take part, + * compared at the language subtag level; one that doesn't say is in `fallback` (the + * code system's language) + */ + passesDesignations(cds, searchLang = null, fallback = 'en') { validateOptionalParameter(cds, 'cds', Designations); if (this.isNull) { @@ -95,13 +101,23 @@ class SearchFilterText { if (!cds) return false; for (const cd of cds.designations) { - if (cd.value && this.passes(cd.value)) { + if (cd.value && (!searchLang || SearchFilterText.#languageOf(cd.language, fallback) === searchLang.language) && this.passes(cd.value)) { return true; } } return false; } + static #languageOf(lang, fallback) { + if (lang && lang.language) { + return lang.language; + } + if (typeof lang === 'string' && lang) { + return lang.split('-')[0].toLowerCase(); + } + return (fallback || 'en').split('-')[0].toLowerCase(); + } + /** * Calculate match score against stems */ diff --git a/tx/sct/text-index.js b/tx/sct/text-index.js index a9043f9d..bbe39e6f 100644 --- a/tx/sct/text-index.js +++ b/tx/sct/text-index.js @@ -89,9 +89,10 @@ class SnomedTextIndex { * Build the index for a loaded edition. Yields to the event loop every * BUILD_SLICE ms of work. * @param {object} sct - SnomedServices + * @param {number|null} lang - only descriptions in this language (byte), or null for all * @returns {Promise} */ - static async build(sct) { + static async build(sct, lang = null) { const concepts = sct.concepts; const conceptSize = concepts.constructor.CONCEPT_SIZE; const conceptCount = concepts.count(); @@ -117,6 +118,9 @@ class SnomedTextIndex { if (descMaster.readUInt8(descIndex + 4) === 0) { continue; // inactive description } + if (lang !== null && descMaster.readUInt8(descIndex + 31) !== lang) { + continue; // another language + } const term = sct.strings.getEntry(descMaster.readUInt32LE(descIndex)); for (const word of tokenize(term)) { if (word.length < MIN_WORD_LENGTH) { diff --git a/tx/workers/expand.js b/tx/workers/expand.js index fdd6a7f9..a9124096 100644 --- a/tx/workers/expand.js +++ b/tx/workers/expand.js @@ -19,6 +19,7 @@ const crypto = require('crypto'); const ValueSet = require("../library/valueset"); const {VersionUtilities} = require("../../library/version-utilities"); const {debugLog} = require("../operation-context"); +const { splitFilters } = require('./supplement-filters'); // Expansion limits (from Pascal constants) const EXTERNAL_DEFAULT_LIMIT = 1000; @@ -867,7 +868,7 @@ class ValueSetExpander { if (cs.isNotClosed(filter)) { notClosed.value = true; } - const prep = await cs.getPrepContext(true); + const prep = await cs.getPrepContext(true, this.params.requestedLanguages()); // the text filter's language const ctxt = await cs.searchFilter(prep, filter, true); let set = await cs.executeFilters(prep); if (this.count === 0 && valueSets.length === 0) { @@ -891,7 +892,7 @@ class ValueSetExpander { } else { await this.listDisplaysFromProvider(cds, cs, c); added = await this.includeCode(cs, null, await cs.system(), await cs.version(), await cs.code(c), await cs.isAbstract(c), await cs.isInactive(c), await cs.isDeprecated(c), await cs.getStatus(c), - cds, await cs.definition(c), await cs.itemWeight(c), expansion, valueSets, await cs.extensions(c), null, await cs.properties(c), null, excludeInactive, vsSrc.url); + cds, await cs.definition(c), await cs.itemWeight(c), expansion, valueSets, await cs.extensions(c), null, await this.#conceptProperties(cs, c), null, excludeInactive, vsSrc.url); } if (added) { this.addToTotal(); @@ -914,6 +915,8 @@ class ValueSetExpander { if (cset.concept) { this.worker.opContext.log('iterate concepts'); const cds = new Designations(this.worker.i18n.languageDefinitions); + // a text filter matches designations in one language (see CodeSystemProvider.searchLanguage) + const searchLang = filter.isNull ? null : cs.searchLanguage(this.params.requestedLanguages()); for (const cc of cset.concept) { await this.worker.checkAndYield('processCodes#3'); @@ -929,13 +932,13 @@ class ValueSetExpander { } else { await this.listDisplaysFromProvider(cds, cs, cctxt.context); this.listDisplaysFromIncludeConcept(cds, cc, vsSrc); - if (filter.passesDesignations(cds) || filter.passes(cc.code)) { + if (filter.passesDesignations(cds, searchLang, cs.defLang()) || filter.passes(cc.code)) { let ov = Extensions.readString(cc, 'http://hl7.org/fhir/StructureDefinition/itemWeight'); if (!ov) { ov = await cs.itemWeight(cctxt.context); } added = await this.includeCode(cs, null, cs.system(), cs.version(), ecode, await cs.isAbstract(cctxt.context), await cs.isInactive(cctxt.context), await cs.isDeprecated(cctxt.context), await cs.getStatus(cctxt.context), cds, - await cs.definition(cctxt.context), ov, expansion, valueSets, await cs.extensions(cctxt.context), cc.extension, await cs.properties(cctxt.context), null, excludeInactive, vsSrc.url); + await cs.definition(cctxt.context), ov, expansion, valueSets, await cs.extensions(cctxt.context), cc.extension, await this.#conceptProperties(cs, cctxt.context), null, excludeInactive, vsSrc.url); } if (added) { this.addToTotal(); @@ -962,17 +965,23 @@ class ValueSetExpander { } for (let i = 0; i < fcl.length; i++) { - await this.worker.checkAndYield('processCodes#4a'); const fc = fcl[i]; if (!fc.value) { throw new Issue('error', 'invalid', path + ".filter[" + i + "]", 'UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.worker.i18n.translate('UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.params.httpLanguages, [cs.system(), fc.property, fc.op]), 'vs-invalid', 400); } Extensions.checkNoModifiers(fc, 'ValueSetExpander.processCodes', 'filter', vsSrc.vurl); + } + // filters on properties that supplements in use define are evaluated from the + // supplements; the code system does the rest + const { base: baseFilters, supplement: suppFilters } = await splitFilters(cs, fcl); + for (let i = 0; i < baseFilters.length; i++) { + await this.worker.checkAndYield('processCodes#4a'); + const fc = baseFilters[i]; await cs.filter(prep, i == 0, fc.property, fc.op, fc.value, vsSrc.isCached ? fc : null); } - const fset = await cs.executeFilters(prep); - const filtersOpen = await cs.filtersNotClosed(prep); + const fset = baseFilters.length > 0 ? await cs.executeFilters(prep) : []; + const filtersOpen = baseFilters.length > 0 && await cs.filtersNotClosed(prep); if (filtersOpen) { notClosed.value = true; } @@ -982,7 +991,7 @@ class ValueSetExpander { // total is the set's size, and walking it would only end in VALUESET_TOO_COSTLY once // the set is bigger than the limit. Anything less certain is counted by walking, as // before: an earlier version added filterSize *and* walked, and double counted (#91, #93). - const countOnly = this.count === 0 && !filtersOpen && fset.length === 1 && filter.isNull + const countOnly = this.count === 0 && !filtersOpen && fset.length === 1 && suppFilters.length === 0 && filter.isNull && !excludeInactive && !this.params.activeOnly && !this.hasExclusions && !(cset.concept && cset.concept.length) && !(cset.valueSet && cset.valueSet.length) && (compose.include || []).length === 1 && (compose.exclude || []).length === 0; @@ -994,11 +1003,8 @@ class ValueSetExpander { this.worker.opContext.log('iterate filters'); this.addToTotal(0); const cds = new Designations(this.worker.i18n.languageDefinitions); - while (!countOnly && await cs.filterMore(prep, fset[0])) { - await this.worker.checkAndYield('processCodes#5'); - const c = await cs.filterConcept(prep, fset[0]); - const ok = (!this.params.activeOnly || !await cs.isInactive(c)) && (await this.passesFilters(cs, c, prep, fset, 1)); - if (ok) { + const addFound = async (c) => { + { cds.clear(); // count++; if (this.passesImports(valueSets, cs.system(), await cs.code(c), 0)) { @@ -1018,13 +1024,32 @@ class ValueSetExpander { } added = await this.includeCode(cs, parent, await cs.system(), await cs.version(), await cs.code(c), await cs.isAbstract(c), await cs.isInactive(c), await cs.isDeprecated(c), await cs.getStatus(c), cds, await cs.definition(c), await cs.itemWeight(c), - expansion, null, await cs.extensions(c), null, await cs.properties(c), null, excludeInactive, vsSrc.url); + expansion, null, await cs.extensions(c), null, await this.#conceptProperties(cs, c), null, excludeInactive, vsSrc.url); } if (added) { this.addToTotal(); } } } + }; + if (baseFilters.length > 0) { + while (!countOnly && await cs.filterMore(prep, fset[0])) { + await this.worker.checkAndYield('processCodes#5'); + const c = await cs.filterConcept(prep, fset[0]); + const ok = (!this.params.activeOnly || !await cs.isInactive(c)) && (await this.passesFilters(cs, c, prep, fset, 1)) + && (await this.#passesSupplementFilters(cs, c, suppFilters)); + if (ok) { + await addFound(c); + } + } + } else { + // only filters on supplement properties: the codes are those the supplements list + for (const c of await this.#supplementFilterConcepts(cs, suppFilters, path)) { + await this.worker.checkAndYield('processCodes#5'); + if ((!this.params.activeOnly || !await cs.isInactive(c)) && (filter.isNull || filter.passes(await cs.code(c)) || filter.passes(await cs.display(c)))) { + await addFound(c); + } + } } this.worker.opContext.log('iterate filters done'); } @@ -1032,6 +1057,41 @@ class ValueSetExpander { } } + async #passesSupplementFilters(cs, c, suppFilters) { + if (suppFilters.length === 0) { + return true; + } + const code = await cs.code(c); + return suppFilters.every(sf => sf.matches(code)); + } + + /** + * The concepts selected by filters that are all on supplement properties: the codes a + * supplement lists that pass them all, and that the code system has (a supplement can list + * a code the code system doesn't) + */ + async #supplementFilterConcepts(cs, suppFilters, path) { + const driver = suppFilters.find(sf => sf.enumerable); + if (!driver) { + // not-in / exists = false select codes by what the supplement doesn't say, which needs the + // rest of the code system to select from + const sf = suppFilters[0]; + throw new Issue('error', 'not-supported', path + '.filter', 'FILTER_NOT_UNDERSTOOD', + this.worker.i18n.translate('FILTER_NOT_UNDERSTOOD', this.params.HTTPLanguages, [sf.property, sf.op, sf.value]), 'vs-invalid', 422); + } + const result = []; + for (const code of driver.codes()) { + await this.worker.checkAndYield('supplementFilterConcepts'); + if (suppFilters.every(sf => sf === driver || sf.matches(code))) { + const loc = await cs.locate(code); + if (loc && loc.context) { + result.push(loc.context); + } + } + } + return result; + } + async passesFilters(cs, c, prep, filters, offset) { for (let j = offset; j < filters.length; j++) { const f = filters[j]; @@ -1142,13 +1202,15 @@ class ValueSetExpander { if (cset.concept) { this.worker.opContext.log('iterate concepts'); const cds = new Designations(this.worker.i18n.languageDefinitions); + // a text filter matches designations in one language (see CodeSystemProvider.searchLanguage) + const searchLang = filter.isNull ? null : cs.searchLanguage(this.params.requestedLanguages()); for (const cc of cset.concept) { await this.worker.checkAndYield('processCodes#3'); cds.clear(); Extensions.checkNoModifiers(cc, 'ValueSetExpander.processCodes', 'set concept reference', vsSrc.vurl); const cctxt = await cs.locate(cc.code, this.allAltCodes); if (cctxt && cctxt.context && (!this.params.activeOnly || !await cs.isInactive(cctxt.context)) && await this.passesFilters(cs, cctxt.context, prep, filters, 0)) { - if (filter.passesDesignations(cds) || filter.passes(cc.code)) { + if (filter.passesDesignations(cds, searchLang, cs.defLang()) || filter.passes(cc.code)) { let ov = Extensions.readString(cc, 'http://hl7.org/fhir/StructureDefinition/itemWeight'); if (!ov) { ov = await cs.itemWeight(cctxt.context); @@ -1172,30 +1234,42 @@ class ValueSetExpander { Extensions.addString(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed-reason", "The code System '" + cs.system() + "' has a grammar and so has infinite members. This extension is based on " + cs.specialEnumeration()); } - let first = true; for (let fc of cset.filter) { - await this.worker.checkAndYield('processCodes#4a'); Extensions.checkNoModifiers(fc, 'ValueSetExpander.processCodes', 'filter', vsSrc.vurl); + } + // as for an include: filters on properties that supplements define are evaluated from them + const { base: baseFilters, supplement: suppFilters } = await splitFilters(cs, cset.filter); + let first = true; + for (let fc of baseFilters) { + await this.worker.checkAndYield('processCodes#4a'); await cs.filter(prep, first, fc.property, fc.op, fc.value, vsSrc.isCached ? fc : null); first = false; } this.worker.opContext.log('iterate filters'); - const fset = await cs.executeFilters(prep); - if (await cs.filtersNotClosed(prep)) { - notClosed.value = true; - } - //let count = 0; - while (await cs.filterMore(prep, fset[0])) { - await this.worker.checkAndYield('processCodes#5'); - const c = await cs.filterConcept(prep, fset[0]); - const ok = (!this.params.activeOnly || !await cs.isInactive(c)) && (await this.passesFilters(cs, c, prep, fset, 1)); - if (ok) { - //count++; - if (this.passesImports(valueSets, await cs.system(), await cs.code(c), 0)) { - this.excludeCode(cs, await cs.system(), await cs.version(), await cs.code(c), expansion, null, vsSrc.url); + const excludeFound = async (c) => { + if (this.passesImports(valueSets, await cs.system(), await cs.code(c), 0)) { + this.excludeCode(cs, await cs.system(), await cs.version(), await cs.code(c), expansion, null, vsSrc.url); + } + }; + if (baseFilters.length > 0) { + const fset = await cs.executeFilters(prep); + if (await cs.filtersNotClosed(prep)) { + notClosed.value = true; + } + while (await cs.filterMore(prep, fset[0])) { + await this.worker.checkAndYield('processCodes#5'); + const c = await cs.filterConcept(prep, fset[0]); + const ok = (!this.params.activeOnly || !await cs.isInactive(c)) && (await this.passesFilters(cs, c, prep, fset, 1)) + && (await this.#passesSupplementFilters(cs, c, suppFilters)); + if (ok) { + await excludeFound(c); } } + } else { + for (const c of await this.#supplementFilterConcepts(cs, suppFilters, 'ValueSet.compose.exclude')) { + await excludeFound(c); + } } this.worker.opContext.log('iterate filters finished'); } @@ -1248,7 +1322,7 @@ class ValueSetExpander { } else { await this.listDisplaysFromProvider(cds, cs, context); t = await this.includeCode(cs, parent, await cs.system(), await cs.version(), code, await cs.isAbstract(context), await cs.isInactive(context), await cs.isDeprecated(context), await cs.getStatus(context), cds, await cs.definition(context), - await cs.itemWeight(context), expansion, imports, await cs.extensions(context), null, await cs.properties(context), null, excludeInactive, srcUrl); + await cs.itemWeight(context), expansion, imports, await cs.extensions(context), null, await this.#conceptProperties(cs, context), null, excludeInactive, srcUrl); } if (t != null) { result++; @@ -1936,11 +2010,28 @@ class ValueSetExpander { this.totalStatus = 'off'; } + /** + * The properties of a concept that this expansion might report: providers that compute their + * properties (from a database, from a lookup) are told which ones are wanted - the requested + * names, and those the code system always reports - so that an expansion that asks for none + * does not compute any + */ + async #conceptProperties(cs, context) { + const names = [...this.params.properties]; + for (const pn of cs.mandatoryExpansionProperties()) { + if (!names.includes(pn)) { + names.push(pn); + } + } + return await cs.properties(context, names); + } + getPropUrl(cs, pn, cp) { if (cp.definition?.uri) { return cp.definition.uri; } - for (let p of cs.propertyDefinitions()) { + // a provider need not declare its properties (propertyDefinitions() is null by default) + for (let p of cs.propertyDefinitions() || []) { if (pn == p.code) { return p.uri; } diff --git a/tx/workers/supplement-filters.js b/tx/workers/supplement-filters.js new file mode 100644 index 00000000..3692ced3 --- /dev/null +++ b/tx/workers/supplement-filters.js @@ -0,0 +1,145 @@ +const regexUtilities = require('../../library/regex-utilities'); + +/** + * A value set filter on a property that a supplement in use defines, and the code system + * itself doesn't (catalog-group = renal-diabetes on LOINC, with a supplement that gives + * LOINC codes a catalog-group). The supplement says which codes have which values, so the + * filter is evaluated from the supplement, the same way whatever the code system and + * whichever provider serves it - the provider never sees the filter. + * + * A filter that selects codes the supplement lists (=, in, regex, exists = true) can drive + * an expansion on its own; one that selects codes by what the supplement doesn't say + * (not-in, exists = false) can only narrow what other filters select. + */ +class SupplementPropertyFilter { + /** + * @param {string} property + * @param {string} op + * @param {string} value + * @param {CodeSystem[]} supplements - the supplements in use that define the property + */ + constructor(property, op, value, supplements) { + this.property = property; + this.op = op; + this.value = value; + this.values = new Map(); // code -> string[] (the property's values on that code) + for (const supplement of supplements) { + this.#scan(supplement.jsonObj.concept || []); + } + if (op === 'regex') { + this.regex = regexUtilities.compile('^(?:' + value + ')$'); // the whole value: a|b is ^(?:a|b)$, not ^a|b$ + } else if (op === 'in' || op === 'not-in') { + this.list = value.split(',').map(s => s.trim()); + } + } + + #scan(concepts) { + for (const concept of concepts) { + for (const p of concept.property || []) { + if (p.code === this.property) { + const v = SupplementPropertyFilter.valueOf(p); + if (v !== null) { + if (!this.values.has(concept.code)) { + this.values.set(concept.code, []); + } + this.values.get(concept.code).push(v); + } + } + } + this.#scan(concept.concept || []); + } + } + + /** the value of a CodeSystem.concept.property, as a string to compare with a filter value */ + static valueOf(p) { + for (const [k, v] of Object.entries(p)) { + if (k.startsWith('value') && v !== undefined && v !== null) { + if (typeof v === 'object') { + return v.code !== undefined ? String(v.code) : null; // Coding + } + return String(v); + } + } + return null; + } + + static ops() { + return ['=', 'in', 'not-in', 'regex', 'exists']; + } + + /** whether the codes this filter selects are all codes the supplement lists */ + get enumerable() { + return this.op !== 'not-in' && !(this.op === 'exists' && this.value === 'false'); + } + + #valueMatches(v) { + switch (this.op) { + case '=': return v === this.value; + case 'in': return this.list.includes(v); + case 'regex': return this.regex.test(v); + default: return false; + } + } + + /** whether the code passes this filter */ + matches(code) { + const vals = this.values.get(code) || []; + switch (this.op) { + case 'exists': return (vals.length > 0) === (this.value === 'true'); + case 'not-in': return !vals.some(v => this.list.includes(v)); + default: return vals.some(v => this.#valueMatches(v)); + } + } + + /** the codes this filter selects (only for an enumerable filter), in the supplement's order */ + codes() { + return [...this.values.keys()].filter(code => this.matches(code)); + } + + toString() { + return this.property + ' ' + this.op + ' ' + this.value; + } +} + +/** + * The supplement filter for a ValueSet filter on this code system, or null when the filter is + * the code system's own (it isn't on a property a supplement in use defines, or the code system + * does the filter itself, which takes precedence) + * + * @param {CodeSystemProvider} cs + * @param {Object} fc - the ValueSet.compose.include.filter + * @returns {Promise} + */ +async function supplementFilter(cs, fc) { + const supplements = (cs.supplements || []).filter(s => (s.jsonObj.property || []).some(p => p.code === fc.property)); + if (supplements.length === 0 || !SupplementPropertyFilter.ops().includes(fc.op) || !fc.value) { + return null; + } + if (await cs.doesFilter(fc.property, fc.op, fc.value)) { + return null; + } + return new SupplementPropertyFilter(fc.property, fc.op, fc.value, supplements); +} + +/** + * Split a ValueSet include's filters into the code system's own, and those on properties + * that supplements define + * + * @param {CodeSystemProvider} cs + * @param {Object[]} filters + * @returns {Promise<{base: Object[], supplement: SupplementPropertyFilter[]}>} + */ +async function splitFilters(cs, filters) { + const result = { base: [], supplement: [] }; + for (const fc of filters || []) { + const sf = await supplementFilter(cs, fc); + if (sf) { + result.supplement.push(sf); + } else { + result.base.push(fc); + } + } + return result; +} + +module.exports = { SupplementPropertyFilter, supplementFilter, splitFilters }; diff --git a/tx/workers/validate.js b/tx/workers/validate.js index 0779cf54..d4b5a8d6 100644 --- a/tx/workers/validate.js +++ b/tx/workers/validate.js @@ -12,6 +12,7 @@ // const { TerminologyWorker, Unknown_Code_in_VersionSCT, SCTVersion } = require('./worker'); +const { splitFilters, supplementFilter } = require('./supplement-filters'); const {Languages, Language} = require("../../library/languages"); const {Extensions} = require("../library/extensions"); const {validateParameter, isAbsoluteUrl, validateOptionalParameter, getValuePrimitive} = require("../../library/utilities"); @@ -400,7 +401,7 @@ class ValueSetChecker { throw new Issue('error', 'invalid', "ValueSet.compose."+desc+".filter["+i+"]", 'UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.worker.i18n.translate('UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.params.HTTPLanguages, [cs.system(), ccf.property, ccf.op]), "vs-invalid").handleAsOO(400); } - if (!(ccf.property === 'concept' && ['is-a', 'descendent-of'].includes(ccf.op))) { + if (!(ccf.property === 'concept' && ['is-a', 'descendent-of'].includes(ccf.op)) && !(await supplementFilter(cs, ccf))) { if (!(await cs.doesFilter(ccf.property, ccf.op, ccf.value))) { throw new Issue('error', 'not-supported', "ValueSet.compose."+desc+".filter["+i+"]", 'FILTER_NOT_UNDERSTOOD', this.worker.i18n.translate('FILTER_NOT_UNDERSTOOD', this.params.HTTPLanguages, [ccf.property, ccf.op, ccf.value, this.valueSet.url, cs.system()]), "vs-invalid").handleAsOO(400); @@ -1697,15 +1698,7 @@ class ValueSetChecker { } else if (this.params.activeOnly && await cs.isInactive(loc.context)) { this.worker.opContext.addNote(this.valueSet, 'Code "' + code + '" found in ' + this.worker.renderer.displayCoded(cs) + ' but is inactive', this.indentCount); result = false; - if (role !== 'not in') { - inactive.value = true; - inactive.path = path; - inactive.code = code; - vstatus.value = await cs.getStatus(loc.context); - let msg = this.worker.i18n.translate('STATUS_CODE_WARNING_CODE', this.params.HTTPLanguages, ['not active', code]); - messages.push(msg); - op.addIssue(new Issue('error', 'business-rule', addToPath(path, 'code'), 'STATUS_CODE_WARNING_CODE', msg, 'code-rule')); - } + await this.#excludedByActiveOnly(cs, loc.context, code, path, role, op, messages, inactive, vstatus); } else { result = true; if (role !== 'not in') { @@ -1764,6 +1757,10 @@ class ValueSetChecker { inactive.code = code; vstatus.value = await cs.getStatus(loc); } + } else if (this.params.activeOnly && await cs.isInactive(loc)) { + this.worker.opContext.addNote(this.valueSet, 'Code "' + code + '" found in ' + this.worker.renderer.displayCoded(cs) + ' but is inactive', this.indentCount); + result = false; + await this.#excludedByActiveOnly(cs, loc, code, path, role, op, messages, inactive, vstatus); } else { if (vcc !== null) { if (!vcc.coding) {vcc.coding = [];} @@ -1795,29 +1792,43 @@ class ValueSetChecker { if (cset.filter) { let cfl = cset.filter; - let prep = await cs.getPrepContext(false, this.params.requestedLanguages()); for (let fc of cfl) { - this.worker.deadCheck('checkConceptSet#2'); if (!fc.value) { throw new Issue('error', 'invalid', null, 'UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.worker.i18n.translate('UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.params.HTTPLanguages, [cs.system(), fc.property, fc.op])); } - await cs.filter(prep, false, fc.property, fc.op, fc.value, (vs && vs.isCached) ? fc : null); - // if (f === null) { - // throw new Issue('error', 'not-supported', null, 'FILTER_NOT_UNDERSTOOD', this.worker.i18n.translate('FILTER_NOT_UNDERSTOOD', this.params.HTTPLanguages, [fc.property, fc.op, fc.value, vs.vurl, cs.system()]) + ' (2)', 'vs-invalid'); - // } - } - let filters = await cs.executeFilters(prep); - if (!filters || filters.length == 0) { - throw new Error("executing filters failed for " + cs.name()); - } - let loc = await cs.filterLocate(prep, filters[0], code); - if (loc != null && !(typeof loc === 'string') && filters.length > 1) { - for (let i = 1; i < filters.length; i++) { - if ((await cs.filterCheck(prep, filters[i], loc)) != true) { // a failing check may return a message, which is truthy - loc = null; - break; + } + // filters on properties that supplements in use define are evaluated from the supplements; + // the code system does the rest + const { base: baseFilters, supplement: suppFilters } = await splitFilters(cs, cfl); + let loc; + if (baseFilters.length > 0) { + let prep = await cs.getPrepContext(false, this.params.requestedLanguages()); + for (let fc of baseFilters) { + this.worker.deadCheck('checkConceptSet#2'); + await cs.filter(prep, false, fc.property, fc.op, fc.value, (vs && vs.isCached) ? fc : null); + } + let filters = await cs.executeFilters(prep); + if (!filters || filters.length == 0) { + throw new Error("executing filters failed for " + cs.name()); + } + loc = await cs.filterLocate(prep, filters[0], code); + if (loc != null && !(typeof loc === 'string') && filters.length > 1) { + for (let i = 1; i < filters.length; i++) { + if ((await cs.filterCheck(prep, filters[i], loc)) != true) { // a failing check may return a message, which is truthy + loc = null; + break; + } } } + } else { + const located = await cs.locate(code); + loc = located ? located.context : null; + } + if (loc != null && !(typeof loc === 'string') && suppFilters.length > 0) { + const lcode = await cs.code(loc); + if (!suppFilters.every(sf => sf.matches(lcode))) { + loc = null; + } } if (loc != null && !(typeof loc === 'string')) { await this.worker.listDisplaysFromCodeSystem(displays, cs, loc); @@ -1835,6 +1846,10 @@ class ValueSetChecker { inactive.code = code; vstatus.value = await cs.getStatus(loc); } + } else if (this.params.activeOnly && await cs.isInactive(loc)) { + result = false; + this.worker.opContext.addNote(this.valueSet, 'Filter ' + this.filterSummary(cset) + ': Code "' + code + '" found in ' + this.worker.renderer.displayCoded(cs) + ' but is inactive', this.indentCount); + await this.#excludedByActiveOnly(cs, loc, code, path, role, op, messages, inactive, vstatus); } else { this.worker.opContext.addNote(this.valueSet, 'Filter ' + this.filterSummary(cset) + ': Code "' + code + '" found in ' + this.worker.renderer.displayCoded(cs), this.indentCount); if (vcc !== null) { @@ -1853,6 +1868,13 @@ class ValueSetChecker { if (st) { vstatus.value = st; } + } else if (!vstatus.value) { + // an active code can still have a status worth reporting (deprecated), as it is + // when the value set lists the code + const st = await cs.getStatus(loc); + if (st) { + vstatus.value = st; + } } } if (role !== 'not in') { @@ -1899,6 +1921,27 @@ class ValueSetChecker { } + /** + * activeOnly: the code is checked against the value set as expanded with activeOnly, which has no + * inactive codes - whether the value set includes all of the code system, lists the code or + * selects it with a filter - so an inactive code is not in it + */ + async #excludedByActiveOnly(cs, loc, code, path, role, op, messages, inactive, vstatus) { + if (role === 'not in') { + return; + } + inactive.value = true; + inactive.path = path; + inactive.code = code; + const st = await cs.getStatus(loc); + if (st) { + vstatus.value = st; + } + const msg = this.worker.i18n.translate('STATUS_CODE_WARNING_CODE', this.params.HTTPLanguages, ['not active', code]); + messages.push(msg); + op.addIssue(new Issue('error', 'business-rule', addToPath(path, 'code'), 'STATUS_CODE_WARNING_CODE', msg, 'code-rule')); + } + excludeInactives() { return this.valueSet.jsonObj.compose && this.valueSet.jsonObj.compose.inactive != undefined && !this.valueSet.jsonObj.compose.inactive; } diff --git a/tx/workers/worker.js b/tx/workers/worker.js index dd3d27b0..8f9f3cfd 100644 --- a/tx/workers/worker.js +++ b/tx/workers/worker.js @@ -174,28 +174,42 @@ class TerminologyWorker { if (!noVParams) { version = this.determineVersionBase(url, version, params); } - let codeSystemResource = null; - let provider = null; - const supplements = this.loadSupplements(url, version, statedSupplements); - // First check additional resources - codeSystemResource = this.findInAdditionalResources(url, version, 'CodeSystem', !nullOk); + const codeSystemResource = this.findInAdditionalResources(url, version, 'CodeSystem', !nullOk); - if (codeSystemResource) { - if (codeSystemResource.content === 'complete') { + const build = async (supplements) => { + let provider = null; + if (codeSystemResource && codeSystemResource.content === 'complete') { // Create provider from complete code system provider = await this.provider.createCodeSystemProvider(this.opContext, codeSystemResource, supplements); } - } - - // If no provider from additional resources, try main provider - if (!provider) { - provider = await this.provider.getCodeSystemProvider(this.opContext, url, version, supplements); - } + // If no provider from additional resources, try main provider + if (!provider) { + provider = await this.provider.getCodeSystemProvider(this.opContext, url, version, supplements); + } + // If still no provider but we have a code system with allowed content mode + if (!provider && codeSystemResource && kinds.includes(codeSystemResource.content)) { + provider = await this.provider.createCodeSystemProvider(this.opContext, codeSystemResource, supplements); + } + return provider; + }; - // If still no provider but we have a code system with allowed content mode - if (!provider && codeSystemResource && kinds.includes(codeSystemResource.content)) { - provider = await this.provider.createCodeSystemProvider(this.opContext, codeSystemResource, supplements); + const supplements = this.loadSupplements(url, version, statedSupplements); + let provider = await build(supplements); + + // The supplements were chosen for the version asked for. When no version was asked for (or + // only part of one), it's the version the provider turned out to be that decides which apply: + // a supplement pinned to another version of the code system (supplements = url|2.80 on + // LOINC 2.83) is not used - so a value set that requires it fails - and one pinned to this + // version is + const actual = provider ? provider.version() : null; + if (actual && actual !== version) { + const fitting = this.loadSupplements(url, actual, statedSupplements); + const key = (list) => list.map(cs => cs.vurl || cs.url).sort().join(' '); + if (key(fitting) !== key(supplements)) { + this.opContext.registerProvider(provider); // released at the end of the request, like the rest + provider = await build(fitting); + } } if (!provider && !nullOk) { @@ -284,10 +298,52 @@ class TerminologyWorker { * @returns {Array} Supplement code systems */ loadSupplements(url, version = '', statedSupplements) { + return this.#oneVersionEach(this.#findSupplements(url, version, statedSupplements), statedSupplements); + } + + /** + * Only one version of a supplement applies. When more than one is in scope (the server has + * one, and the request passes another in tx-resource), the one the request names by version + * (useSupplement url|version, or a value set's supplement extension) wins; failing that, the + * request's own over the server's; failing that, the latest + * + * @param {CodeSystem[]} supplements - server ones first, then the request's + * @param {Set} statedSupplements + * @returns {CodeSystem[]} + */ + #oneVersionEach(supplements, statedSupplements) { + const byUrl = new Map(); + for (const cs of supplements) { + if (!byUrl.has(cs.url)) { + byUrl.set(cs.url, []); + } + byUrl.get(cs.url).push(cs); + } + if ([...byUrl.values()].every(list => list.length === 1)) { + return supplements; + } + const result = []; + for (const list of byUrl.values()) { + if (list.length === 1) { + result.push(list[0]); + continue; + } + let chosen = statedSupplements ? list.find(cs => cs.version && statedSupplements.has(cs.vurl)) : null; + if (!chosen) { + chosen = list.find(cs => (this.additionalResources || []).includes(cs)); + } + if (!chosen) { + chosen = list.reduce((a, b) => (VersionUtilities.compareVersionsGeneral(b.version, a.version) > 0 ? b : a)); + } + result.push(chosen); + } + return result; + } + + #findSupplements(url, version, statedSupplements) { const supplements = []; supplements.push(...this.provider.loadSupplements(url, version, statedSupplements)); - // todo: look in provider for supplements if (!this.additionalResources) { return supplements; @@ -325,7 +381,11 @@ class TerminologyWorker { } else { // Version specified, check if it matches the tail of supplements URL const supplementsVersion = supplementsUrl.substring(`${url}|`.length); - if (supplementsVersion === version || VersionUtilities.versionMatches(supplementsVersion, version)) { + // not every code system's versions are semver (SNOMED CT's are URIs, ICF's are dates): + // versionMatches throws on those, so match by the version's own algorithm, as + // CodeSystem.isSupplementFor does for the server's own supplements + if (supplementsVersion === version || VersionUtilities.versionMatchesByAlgorithm(supplementsVersion, version, + VersionUtilities.guessVersionAlgorithmFromVersion(version))) { supplements.push(cs); } } diff --git a/tx/xversion/xv-valueset.js b/tx/xversion/xv-valueset.js index 01bd8498..161184f6 100644 --- a/tx/xversion/xv-valueset.js +++ b/tx/xversion/xv-valueset.js @@ -160,12 +160,15 @@ function valueSetR5ToR4(r5Obj) { if (exp.property && exp.property.length > 0) { exp.extension = exp.extension || []; for (let prop of exp.property) { + // uri is 0..1: a property without one (a provider that doesn't declare its properties) + // must not produce an empty uri extension, which is not valid + const ext = [{ url: "code", valueCode: prop.code }]; + if (prop.uri) { + ext.push({ url: "uri", valueUri: prop.uri }); + } exp.extension.push({ url: "http://hl7.org/fhir/5.0/StructureDefinition/extension-ValueSet.expansion.property", - extension: [ - { url: "code", valueCode: prop.code }, - { url: "uri", valueUri: prop.uri } - ] + extension: ext }); } delete exp.property; From 565039c8c58f078aae3cbbad25f3d32c44e5468b Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Tue, 3 Nov 2026 22:14:19 +1100 Subject: [PATCH 2/2] update for new tests --- tests/tx/test-cases.test.js | 300 ++++++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) diff --git a/tests/tx/test-cases.test.js b/tests/tx/test-cases.test.js index 4c81c65d..86f143d8 100644 --- a/tests/tx/test-cases.test.js +++ b/tests/tx/test-cases.test.js @@ -169,6 +169,14 @@ describe('simple-cases', () => { await runTest({"suite":"simple-cases","test":"simple-expand-regex"}, "4.0"); }); + it("simple-expand-regex-alt" + 'R5', async () => { + await runTest({"suite":"simple-cases","test":"simple-expand-regex-alt"}, "5.0"); + }); + + it("simple-expand-regex-alt" + 'R4', async () => { + await runTest({"suite":"simple-cases","test":"simple-expand-regex-alt"}, "4.0"); + }); + it("simple-expand-regex2" + 'R5', async () => { await runTest({"suite":"simple-cases","test":"simple-expand-regex2"}, "5.0"); }); @@ -1253,6 +1261,22 @@ describe('global-lang-pack', () => { await runTest({"suite":"global-lang-pack","test":"glp-own-validate-pack-display-de"}, "4.0"); }); + it("glp-expand-pinned-other-version" + 'R5', async () => { + await runTest({"suite":"global-lang-pack","test":"glp-expand-pinned-other-version"}, "5.0"); + }); + + it("glp-expand-pinned-other-version" + 'R4', async () => { + await runTest({"suite":"global-lang-pack","test":"glp-expand-pinned-other-version"}, "4.0"); + }); + + it("glp-expand-pinned-other-version-required" + 'R5', async () => { + await runTest({"suite":"global-lang-pack","test":"glp-expand-pinned-other-version-required"}, "5.0"); + }); + + it("glp-expand-pinned-other-version-required" + 'R4', async () => { + await runTest({"suite":"global-lang-pack","test":"glp-expand-pinned-other-version-required"}, "4.0"); + }); + }); describe('extension-codes', () => { @@ -1644,6 +1668,46 @@ describe('extensions', () => { await runTest({"suite":"extensions","test":"validate-supplement-nolang-other-lang"}, "4.0"); }); + it("expand-supplement-prop" + 'R5', async () => { + await runTest({"suite":"extensions","test":"expand-supplement-prop"}, "5.0"); + }); + + it("expand-supplement-prop" + 'R4', async () => { + await runTest({"suite":"extensions","test":"expand-supplement-prop"}, "4.0"); + }); + + it("expand-supplement-prop-base" + 'R5', async () => { + await runTest({"suite":"extensions","test":"expand-supplement-prop-base"}, "5.0"); + }); + + it("expand-supplement-prop-base" + 'R4', async () => { + await runTest({"suite":"extensions","test":"expand-supplement-prop-base"}, "4.0"); + }); + + it("validate-supplement-prop-base-code5" + 'R5', async () => { + await runTest({"suite":"extensions","test":"validate-supplement-prop-base-code5"}, "5.0"); + }); + + it("validate-supplement-prop-base-code5" + 'R4', async () => { + await runTest({"suite":"extensions","test":"validate-supplement-prop-base-code5"}, "4.0"); + }); + + it("validate-supplement-prop-base-code4" + 'R5', async () => { + await runTest({"suite":"extensions","test":"validate-supplement-prop-base-code4"}, "5.0"); + }); + + it("validate-supplement-prop-base-code4" + 'R4', async () => { + await runTest({"suite":"extensions","test":"validate-supplement-prop-base-code4"}, "4.0"); + }); + + it("expand-supplement-version" + 'R5', async () => { + await runTest({"suite":"extensions","test":"expand-supplement-version"}, "5.0"); + }); + + it("expand-supplement-version" + 'R4', async () => { + await runTest({"suite":"extensions","test":"expand-supplement-version"}, "4.0"); + }); + }); describe('validation', () => { @@ -4974,6 +5038,30 @@ describe('inactive', () => { await runTest({"suite":"inactive","test":"inactive-5-validate"}, "4.0"); }); + it("inactive-enum-validate" + 'R5', async () => { + await runTest({"suite":"inactive","test":"inactive-enum-validate"}, "5.0"); + }); + + it("inactive-enum-validate" + 'R4', async () => { + await runTest({"suite":"inactive","test":"inactive-enum-validate"}, "4.0"); + }); + + it("inactive-enum-activeonly-validate" + 'R5', async () => { + await runTest({"suite":"inactive","test":"inactive-enum-activeonly-validate"}, "5.0"); + }); + + it("inactive-enum-activeonly-validate" + 'R4', async () => { + await runTest({"suite":"inactive","test":"inactive-enum-activeonly-validate"}, "4.0"); + }); + + it("inactive-filter-activeonly-validate" + 'R5', async () => { + await runTest({"suite":"inactive","test":"inactive-filter-activeonly-validate"}, "5.0"); + }); + + it("inactive-filter-activeonly-validate" + 'R4', async () => { + await runTest({"suite":"inactive","test":"inactive-filter-activeonly-validate"}, "4.0"); + }); + }); describe('case', () => { @@ -5564,6 +5652,54 @@ describe('search', () => { await runTest({"suite":"search","test":"search-enum-no"}, "4.0"); }); + it("search-all-de-yes" + 'R5', async () => { + await runTest({"suite":"search","test":"search-all-de-yes"}, "5.0"); + }); + + it("search-all-de-yes" + 'R4', async () => { + await runTest({"suite":"search","test":"search-all-de-yes"}, "4.0"); + }); + + it("search-all-de-no-language" + 'R5', async () => { + await runTest({"suite":"search","test":"search-all-de-no-language"}, "5.0"); + }); + + it("search-all-de-no-language" + 'R4', async () => { + await runTest({"suite":"search","test":"search-all-de-no-language"}, "4.0"); + }); + + it("search-enum-de-yes" + 'R5', async () => { + await runTest({"suite":"search","test":"search-enum-de-yes"}, "5.0"); + }); + + it("search-enum-de-yes" + 'R4', async () => { + await runTest({"suite":"search","test":"search-enum-de-yes"}, "4.0"); + }); + + it("search-filter-de-yes" + 'R5', async () => { + await runTest({"suite":"search","test":"search-filter-de-yes"}, "5.0"); + }); + + it("search-filter-de-yes" + 'R4', async () => { + await runTest({"suite":"search","test":"search-filter-de-yes"}, "4.0"); + }); + + it("search-all-supplement-fr-yes" + 'R5', async () => { + await runTest({"suite":"search","test":"search-all-supplement-fr-yes"}, "5.0"); + }); + + it("search-all-supplement-fr-yes" + 'R4', async () => { + await runTest({"suite":"search","test":"search-all-supplement-fr-yes"}, "4.0"); + }); + + it("search-all-supplement-fr-no-language" + 'R5', async () => { + await runTest({"suite":"search","test":"search-all-supplement-fr-no-language"}, "5.0"); + }); + + it("search-all-supplement-fr-no-language" + 'R4', async () => { + await runTest({"suite":"search","test":"search-all-supplement-fr-no-language"}, "4.0"); + }); + }); describe('default-valueset-version', () => { @@ -5987,6 +6123,46 @@ describe('loinc', () => { await runTest({"suite":"loinc","test":"loinc-expand-enum"}, "4.0"); }); + it("loinc-expand-property" + 'R5', async () => { + await runTest({"suite":"loinc","test":"loinc-expand-property"}, "5.0"); + }); + + it("loinc-expand-property" + 'R4', async () => { + await runTest({"suite":"loinc","test":"loinc-expand-property"}, "4.0"); + }); + + it("loinc-expand-compose-property" + 'R5', async () => { + await runTest({"suite":"loinc","test":"loinc-expand-compose-property"}, "5.0"); + }); + + it("loinc-expand-compose-property" + 'R4', async () => { + await runTest({"suite":"loinc","test":"loinc-expand-compose-property"}, "4.0"); + }); + + it("loinc-expand-search-de" + 'R5', async () => { + await runTest({"suite":"loinc","test":"loinc-expand-search-de"}, "5.0"); + }); + + it("loinc-expand-search-de" + 'R4', async () => { + await runTest({"suite":"loinc","test":"loinc-expand-search-de"}, "4.0"); + }); + + it("loinc-expand-search-no-language" + 'R5', async () => { + await runTest({"suite":"loinc","test":"loinc-expand-search-no-language"}, "5.0"); + }); + + it("loinc-expand-search-no-language" + 'R4', async () => { + await runTest({"suite":"loinc","test":"loinc-expand-search-no-language"}, "4.0"); + }); + + it("loinc-expand-search-filter-de" + 'R5', async () => { + await runTest({"suite":"loinc","test":"loinc-expand-search-filter-de"}, "5.0"); + }); + + it("loinc-expand-search-filter-de" + 'R4', async () => { + await runTest({"suite":"loinc","test":"loinc-expand-search-filter-de"}, "4.0"); + }); + it("loinc-expand-all" + 'R5', async () => { await runTest({"suite":"loinc","test":"loinc-expand-all"}, "5.0"); }); @@ -7448,6 +7624,14 @@ describe('snomed', () => { await runTest({"suite":"snomed","test":"snomed-expand-concept-in"}, "4.0"); }); + it("snomed-expand-property" + 'R5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-property"}, "5.0"); + }); + + it("snomed-expand-property" + 'R4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-property"}, "4.0"); + }); + it("snomed-validate-concept-in" + 'R5', async () => { await runTest({"suite":"snomed","test":"snomed-validate-concept-in"}, "5.0"); }); @@ -9732,6 +9916,14 @@ describe('omop', () => { await runTest({"suite":"omop","test":"omop-expand-explicit"}, "4.0"); }); + it("omop-expand-property" + 'R5', async () => { + await runTest({"suite":"omop","test":"omop-expand-property"}, "5.0"); + }); + + it("omop-expand-property" + 'R4', async () => { + await runTest({"suite":"omop","test":"omop-expand-property"}, "4.0"); + }); + it("translate-loinc-implicit" + 'R5', async () => { await runTest({"suite":"omop","test":"translate-loinc-implicit"}, "5.0"); }); @@ -9785,6 +9977,14 @@ describe('UCUM', () => { await runTest({"suite":"UCUM","test":"expand-ucum-canonical"}, "4.0"); }); + it("expand-ucum-property" + 'R5', async () => { + await runTest({"suite":"UCUM","test":"expand-ucum-property"}, "5.0"); + }); + + it("expand-ucum-property" + 'R4', async () => { + await runTest({"suite":"UCUM","test":"expand-ucum-property"}, "4.0"); + }); + it("validate-ucum-canonical-good" + 'R5', async () => { await runTest({"suite":"UCUM","test":"validate-ucum-canonical-good"}, "5.0"); }); @@ -13611,6 +13811,10 @@ describe('simple-cases', () => { await runTest({"suite":"simple-cases","test":"simple-expand-regex"}, "5.0"); }); + it("simple-expand-regex-alt" + 'R5-cached', async () => { + await runTest({"suite":"simple-cases","test":"simple-expand-regex-alt"}, "5.0"); + }); + it("simple-expand-regex2" + 'R5-cached', async () => { await runTest({"suite":"simple-cases","test":"simple-expand-regex2"}, "5.0"); }); @@ -14163,6 +14367,14 @@ describe('global-lang-pack', () => { await runTest({"suite":"global-lang-pack","test":"glp-own-validate-pack-display-de"}, "5.0"); }); + it("glp-expand-pinned-other-version" + 'R5-cached', async () => { + await runTest({"suite":"global-lang-pack","test":"glp-expand-pinned-other-version"}, "5.0"); + }); + + it("glp-expand-pinned-other-version-required" + 'R5-cached', async () => { + await runTest({"suite":"global-lang-pack","test":"glp-expand-pinned-other-version-required"}, "5.0"); + }); + }); describe('extension-codes', () => { @@ -14366,6 +14578,26 @@ describe('extensions', () => { await runTest({"suite":"extensions","test":"validate-supplement-nolang-other-lang"}, "5.0"); }); + it("expand-supplement-prop" + 'R5-cached', async () => { + await runTest({"suite":"extensions","test":"expand-supplement-prop"}, "5.0"); + }); + + it("expand-supplement-prop-base" + 'R5-cached', async () => { + await runTest({"suite":"extensions","test":"expand-supplement-prop-base"}, "5.0"); + }); + + it("validate-supplement-prop-base-code5" + 'R5-cached', async () => { + await runTest({"suite":"extensions","test":"validate-supplement-prop-base-code5"}, "5.0"); + }); + + it("validate-supplement-prop-base-code4" + 'R5-cached', async () => { + await runTest({"suite":"extensions","test":"validate-supplement-prop-base-code4"}, "5.0"); + }); + + it("expand-supplement-version" + 'R5-cached', async () => { + await runTest({"suite":"extensions","test":"expand-supplement-version"}, "5.0"); + }); + }); describe('validation', () => { @@ -16056,6 +16288,18 @@ describe('inactive', () => { await runTest({"suite":"inactive","test":"inactive-5-validate"}, "5.0"); }); + it("inactive-enum-validate" + 'R5-cached', async () => { + await runTest({"suite":"inactive","test":"inactive-enum-validate"}, "5.0"); + }); + + it("inactive-enum-activeonly-validate" + 'R5-cached', async () => { + await runTest({"suite":"inactive","test":"inactive-enum-activeonly-validate"}, "5.0"); + }); + + it("inactive-filter-activeonly-validate" + 'R5-cached', async () => { + await runTest({"suite":"inactive","test":"inactive-filter-activeonly-validate"}, "5.0"); + }); + }); describe('case', () => { @@ -16382,6 +16626,30 @@ describe('search', () => { await runTest({"suite":"search","test":"search-enum-no"}, "5.0"); }); + it("search-all-de-yes" + 'R5-cached', async () => { + await runTest({"suite":"search","test":"search-all-de-yes"}, "5.0"); + }); + + it("search-all-de-no-language" + 'R5-cached', async () => { + await runTest({"suite":"search","test":"search-all-de-no-language"}, "5.0"); + }); + + it("search-enum-de-yes" + 'R5-cached', async () => { + await runTest({"suite":"search","test":"search-enum-de-yes"}, "5.0"); + }); + + it("search-filter-de-yes" + 'R5-cached', async () => { + await runTest({"suite":"search","test":"search-filter-de-yes"}, "5.0"); + }); + + it("search-all-supplement-fr-yes" + 'R5-cached', async () => { + await runTest({"suite":"search","test":"search-all-supplement-fr-yes"}, "5.0"); + }); + + it("search-all-supplement-fr-no-language" + 'R5-cached', async () => { + await runTest({"suite":"search","test":"search-all-supplement-fr-no-language"}, "5.0"); + }); + }); describe('default-valueset-version', () => { @@ -16601,6 +16869,26 @@ describe('loinc', () => { await runTest({"suite":"loinc","test":"loinc-expand-enum"}, "5.0"); }); + it("loinc-expand-property" + 'R5-cached', async () => { + await runTest({"suite":"loinc","test":"loinc-expand-property"}, "5.0"); + }); + + it("loinc-expand-compose-property" + 'R5-cached', async () => { + await runTest({"suite":"loinc","test":"loinc-expand-compose-property"}, "5.0"); + }); + + it("loinc-expand-search-de" + 'R5-cached', async () => { + await runTest({"suite":"loinc","test":"loinc-expand-search-de"}, "5.0"); + }); + + it("loinc-expand-search-no-language" + 'R5-cached', async () => { + await runTest({"suite":"loinc","test":"loinc-expand-search-no-language"}, "5.0"); + }); + + it("loinc-expand-search-filter-de" + 'R5-cached', async () => { + await runTest({"suite":"loinc","test":"loinc-expand-search-filter-de"}, "5.0"); + }); + it("loinc-expand-all" + 'R5-cached', async () => { await runTest({"suite":"loinc","test":"loinc-expand-all"}, "5.0"); }); @@ -17334,6 +17622,10 @@ describe('snomed', () => { await runTest({"suite":"snomed","test":"snomed-expand-concept-in"}, "5.0"); }); + it("snomed-expand-property" + 'R5-cached', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-property"}, "5.0"); + }); + it("snomed-validate-concept-in" + 'R5-cached', async () => { await runTest({"suite":"snomed","test":"snomed-validate-concept-in"}, "5.0"); }); @@ -18486,6 +18778,10 @@ describe('omop', () => { await runTest({"suite":"omop","test":"omop-expand-explicit"}, "5.0"); }); + it("omop-expand-property" + 'R5-cached', async () => { + await runTest({"suite":"omop","test":"omop-expand-property"}, "5.0"); + }); + it("translate-loinc-implicit" + 'R5-cached', async () => { await runTest({"suite":"omop","test":"translate-loinc-implicit"}, "5.0"); }); @@ -18519,6 +18815,10 @@ describe('UCUM', () => { await runTest({"suite":"UCUM","test":"expand-ucum-canonical"}, "5.0"); }); + it("expand-ucum-property" + 'R5-cached', async () => { + await runTest({"suite":"UCUM","test":"expand-ucum-property"}, "5.0"); + }); + it("validate-ucum-canonical-good" + 'R5-cached', async () => { await runTest({"suite":"UCUM","test":"validate-ucum-canonical-good"}, "5.0"); });