From b082f9827aaadd5c20312ab4e11a7215ba25a551 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Sun, 1 Nov 2026 08:36:14 +1100 Subject: [PATCH] Add support for extension-codes, and fix timezone import bug in SCT importer --- package-lock.json | 136 +++++++++++++-------------- tests/cs/cs-extension-codes.test.js | 141 ++++++++++++++++++++++++++++ tests/tx/batch-validate.test.js | 78 +++++++++++++++ tests/tx/worker.test.js | 65 ++++++++++++- tx/cs/cs-api.js | 36 ++++++- tx/cs/cs-country.js | 16 +++- tx/cs/cs-cs.js | 35 ++++++- tx/cs/cs-icf.js | 25 ++++- tx/cs/cs-snomed.js | 35 ++++++- tx/fixtures/test-cases.yml | 2 +- tx/importers/import-sct.module.js | 13 +-- tx/library/codesystem.js | 90 +++++++++++++++++- tx/openapi-operations.js | 10 +- tx/provider.js | 2 +- tx/tx.fhir.org.yml | 10 +- tx/workers/batch-validate.js | 25 +++-- tx/workers/validate.js | 9 +- tx/workers/worker.js | 4 +- 18 files changed, 614 insertions(+), 118 deletions(-) create mode 100644 tests/cs/cs-extension-codes.test.js create mode 100644 tests/tx/batch-validate.test.js diff --git a/package-lock.json b/package-lock.json index 6054daac..7cba8c10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2238,17 +2238,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", - "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz", + "integrity": "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/type-utils": "8.65.0", - "@typescript-eslint/utils": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/type-utils": "8.72.1", + "@typescript-eslint/utils": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2261,22 +2261,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.65.0", + "@typescript-eslint/parser": "^8.72.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", - "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.1.tgz", + "integrity": "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", "debug": "^4.4.3" }, "engines": { @@ -2292,14 +2292,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", - "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz", + "integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.65.0", - "@typescript-eslint/types": "^8.65.0", + "@typescript-eslint/tsconfig-utils": "^8.72.1", + "@typescript-eslint/types": "^8.72.1", "debug": "^4.4.3" }, "engines": { @@ -2314,14 +2314,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", - "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz", + "integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0" + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2332,9 +2332,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", - "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz", + "integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==", "dev": true, "license": "MIT", "engines": { @@ -2349,15 +2349,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", - "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz", + "integrity": "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/utils": "8.72.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2374,9 +2374,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", - "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz", + "integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==", "dev": true, "license": "MIT", "engines": { @@ -2388,16 +2388,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", - "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz", + "integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.65.0", - "@typescript-eslint/tsconfig-utils": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", + "@typescript-eslint/project-service": "8.72.1", + "@typescript-eslint/tsconfig-utils": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2416,16 +2416,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", - "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz", + "integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0" + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2440,13 +2440,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", - "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz", + "integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/types": "8.72.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3043,9 +3043,9 @@ } }, "node_modules/babel-plugin-istanbul": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz", - "integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.2.tgz", + "integrity": "sha512-bXWZeWQvvCZcD2uHLWVbitr4s1+YqILBbWB/szXKtHXt6crLlD6mqxHZ0qWy2X/Xx5RtQvqqW80JHAXMlp+y1Q==", "dev": true, "license": "BSD-3-Clause", "workspaces": [ @@ -4924,9 +4924,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", - "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz", + "integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==", "funding": [ { "type": "individual", @@ -6923,13 +6923,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -8285,12 +8285,12 @@ } }, "node_modules/re2js": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/re2js/-/re2js-2.8.6.tgz", - "integrity": "sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/re2js/-/re2js-2.9.1.tgz", + "integrity": "sha512-JIT3bEEUZTOR2YS1walNEIfH4cF/cQeZcBUFq7ldqO6lR6g0X5CBq/S8rsa9omzZcsNf8qc36jCkWpKKtncrZg==", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/readable-stream": { diff --git a/tests/cs/cs-extension-codes.test.js b/tests/cs/cs-extension-codes.test.js new file mode 100644 index 00000000..9870082c --- /dev/null +++ b/tests/cs/cs-extension-codes.test.js @@ -0,0 +1,141 @@ +/** + * Extension-codes supplements and reserved code spaces (codesystem-reservedCodeSpace) in the FHIR CodeSystem + * provider. The tx-ecosystem extension-codes suites cover the operations end to end; these check the + * provider: which codes it locates, and the displays and designations it gives them. + */ + +const {CodeSystem} = require('../../tx/library/codesystem'); +const {FhirCodeSystemFactory} = require('../../tx/cs/cs-cs'); +const {OperationContext} = require('../../tx/operation-context'); +const {Designations} = require('../../tx/library/designations'); +const {Languages} = require('../../library/languages'); +const {TestUtilities} = require('../test-utilities'); + +const BASE = 'http://example.org/fhir/CodeSystem/ec-base'; + +function baseCS(pattern = 'X.+') { + return new CodeSystem({ + resourceType: 'CodeSystem', url: BASE, version: '1.0.0', status: 'active', content: 'complete', language: 'en', + extension: [{url: 'http://hl7.org/fhir/StructureDefinition/codesystem-reservedCodeSpace', extension: [ + {url: 'reservedCodeSpace', valueString: pattern}, + {url: 'description', valueMarkdown: 'Codes starting with X'}]}, + {url: 'http://hl7.org/fhir/StructureDefinition/codesystem-reservedCodeSpace', extension: [ + {url: 'reservedCodeSpace', valueString: 'Z.+'}, + {url: 'default-display', valueString: 'Local Code'}]}], + concept: [{code: 'A1', display: 'Code A1'}] + }); +} + +function supplement(url, global, concepts) { + const extension = [{url: 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-type', valueCode: 'extension-codes'}]; + if (global) { + extension.push({url: 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-global', valueBoolean: true}); + } + return new CodeSystem({resourceType: 'CodeSystem', url, version: '1.0.0', status: 'active', content: 'supplement', + language: 'en', supplements: BASE + '|1.0.0', extension, concept: concepts}); +} + +describe('Extension-codes supplements', () => { + let factory, opContext, languages; + + beforeEach(async () => { + languages = await TestUtilities.loadLanguageDefinitions(); + const i18n = await TestUtilities.loadTranslations(languages); + factory = new FhirCodeSystemFactory(i18n); + opContext = new OperationContext(Languages.fromAcceptLanguage('en-US', languages), i18n); + }); + + describe('reserved code space', () => { + test('a code must match the expression in full', () => { + const cs = baseCS(); + expect(cs.isInReservedCodeSpace('X1')).toBe(true); + expect(cs.isInReservedCodeSpace('AX1')).toBe(false); + expect(cs.isInReservedCodeSpace('X')).toBe(false); + expect(cs.isInReservedCodeSpace('')).toBe(false); + }); + + test('a code system with no reserved code space reserves nothing', () => { + const cs = new CodeSystem({resourceType: 'CodeSystem', url: BASE, status: 'active', content: 'complete'}); + expect(cs.isInReservedCodeSpace('X1')).toBe(false); + }); + + test('an expression that does not compile reserves nothing', () => { + expect(baseCS('X(').isInReservedCodeSpace('X1')).toBe(false); + }); + + test('isExtensionCodes reads codesystem-supplement-type', () => { + expect(supplement('http://example.org/s', false, []).isExtensionCodes()).toBe(true); + expect(baseCS().isExtensionCodes()).toBe(false); + }); + }); + + describe('provider', () => { + test('locates a code in the reserved code space that nothing defines, with the default display', async () => { + const provider = factory.build(opContext, [], baseCS()); + const res = await provider.locate('X99'); + expect(res.context).toBeTruthy(); + expect(res.context.reserved).toBe(true); + expect(await provider.display(res.context)).toBe('User-assigned'); + const designations = new Designations(languages); + await provider.designations(res.context, designations); + expect(designations.designations.map(d => d.value)).toEqual(['User-assigned']); + // a placeholder: another display is not wrong + expect(designations.userDefined).toBe(true); + }); + + test('a code space can name its own default display (default-display)', async () => { + const provider = factory.build(opContext, [], baseCS()); + const res = await provider.locate('Z1'); + expect(await provider.display(res.context)).toBe('Local Code'); + }); + + test('does not locate a code outside the reserved code space', async () => { + const provider = factory.build(opContext, [], baseCS()); + expect((await provider.locate('Y1')).context).toBeNull(); + }); + + test('a supplement in scope gives a reserved code its display', async () => { + const supp = supplement('http://example.org/global', true, [{code: 'XG1', display: 'Global Code One'}]); + const provider = factory.build(opContext, [supp], baseCS()); + const ctxt = (await provider.locate('XG1')).context; + expect(await provider.display(ctxt)).toBe('Global Code One'); + const designations = new Designations(languages); + await provider.designations(ctxt, designations); + expect(designations.extensionCodeDisplay).toBe('global'); + // the default display is not one of its displays + expect(designations.designations.map(d => d.value)).toEqual(['Global Code One']); + expect(designations.userDefined).toBeFalsy(); + }); + + test('a display from a supplement that is not global is marked as such', async () => { + const supp = supplement('http://example.org/local', false, [{code: 'XL1', display: 'Local Code One'}]); + const provider = factory.build(opContext, [supp], baseCS()); + const ctxt = (await provider.locate('XL1')).context; + const designations = new Designations(languages); + await provider.designations(ctxt, designations); + expect(designations.extensionCodeDisplay).toBe('referenced'); + }); + + test('a supplement display for a code the code system defines is not an extension-code display', async () => { + const supp = supplement('http://example.org/global', true, [{code: 'A1', display: 'Another A1'}]); + const provider = factory.build(opContext, [supp], baseCS()); + const ctxt = (await provider.locate('A1')).context; + const designations = new Designations(languages); + await provider.designations(ctxt, designations); + expect(designations.extensionCodeDisplay).toBeUndefined(); + expect(designations.designations.map(d => d.value).sort()).toEqual(['Another A1', 'Code A1']); + }); + + test('codes from supplements are not iterated with the code system', async () => { + const supp = supplement('http://example.org/global', true, [{code: 'XG1', display: 'Global Code One'}]); + const provider = factory.build(opContext, [supp], baseCS()); + const iter = await provider.iteratorAll(); + const codes = []; + let c; + while ((c = await provider.nextContext(iter))) { + codes.push(await provider.code(c)); + } + expect(codes).toEqual(['A1']); + }); + }); +}); diff --git a/tests/tx/batch-validate.test.js b/tests/tx/batch-validate.test.js new file mode 100644 index 00000000..72a020f8 --- /dev/null +++ b/tests/tx/batch-validate.test.js @@ -0,0 +1,78 @@ +/** + * CodeSystem/$batch-validate-code + * + * On the CodeSystem route `url` names the code system (as it does for + * CodeSystem/$validate-code): a top-level url is shared by every entry, and an + * entry carrying a url is still a CodeSystem validation, not a ValueSet one. + * (The tx-ecosystem cs-batch-validate tests cover the full responses.) + */ + +const request = require('supertest'); +const { getTestApp, shutdownTestApp } = require('./setup'); + +const CS_URL = 'http://example.org/fhir/CodeSystem/batch-cs'; +const CS = { + resourceType: 'CodeSystem', url: CS_URL, version: '1.0.0', status: 'active', content: 'complete', + concept: [{ code: 'a', display: 'Alpha' }, { code: 'b', display: 'Beta' }] +}; + +function entry(parameter) { + return { name: 'validation', resource: { resourceType: 'Parameters', parameter } }; +} + +function param(r, name) { + return (r.resource.parameter || []).find(p => p.name === name); +} + +describe('CodeSystem/$batch-validate-code', () => { + let app; + + beforeAll(async () => { + app = await getTestApp(); + }, 60000); + + afterAll(async () => { + await shutdownTestApp(); + }); + + async function batch(parameter) { + const res = await request(app).post('/tx/r5/CodeSystem/$batch-validate-code') + .set('Content-Type', 'application/json') + .send({ resourceType: 'Parameters', parameter }); + expect(res.status).toBe(200); + return res.body.parameter; + } + + test('a top-level url is the code system for every entry', async () => { + const rs = await batch([ + { name: 'tx-resource', resource: CS }, + { name: 'url', valueUri: CS_URL }, + entry([{ name: 'code', valueCode: 'a' }]), + entry([{ name: 'code', valueCode: 'zz' }]), + entry([{ name: 'code', valueCode: 'b' }, { name: 'display', valueString: 'Beta' }]) + ]); + expect(rs.length).toBe(3); + expect(rs.map(r => r.resource.resourceType)).toEqual(['Parameters', 'Parameters', 'Parameters']); + expect(rs.map(r => param(r, 'result').valueBoolean)).toEqual([true, false, true]); + expect(param(rs[0], 'system').valueUri).toBe(CS_URL); + }); + + test('an entry with its own url is validated against that code system, not as a value set', async () => { + const rs = await batch([ + { name: 'tx-resource', resource: CS }, + entry([{ name: 'url', valueUri: CS_URL }, { name: 'code', valueCode: 'b' }]) + ]); + expect(rs.length).toBe(1); + expect(param(rs[0], 'result').valueBoolean).toBe(true); + expect(param(rs[0], 'system').valueUri).toBe(CS_URL); + }); + + test('entries with no url are validated against the system their coding names', async () => { + const rs = await batch([ + { name: 'tx-resource', resource: CS }, + entry([{ name: 'coding', valueCoding: { system: CS_URL, code: 'a' } }]), + entry([{ name: 'coding', valueCoding: { system: CS_URL, code: 'q' } }]) + ]); + expect(rs.map(r => param(r, 'result').valueBoolean)).toEqual([true, false]); + }); +}); diff --git a/tests/tx/worker.test.js b/tests/tx/worker.test.js index 5731146a..37c83dc9 100644 --- a/tests/tx/worker.test.js +++ b/tests/tx/worker.test.js @@ -247,7 +247,10 @@ describe('TerminologyWorker', () => { name: 'SupplementCS', status: 'active', supplements: 'http://main.com', - extension : [{url: 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-type', valueCode: "global-lang-pack"}] + extension : [ + {url: 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-type', valueCode: 'lang-pack'}, + {url: 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-global', valueBoolean: true} + ] }); worker.additionalResources = [mainCS, supplementCS]; @@ -264,7 +267,10 @@ describe('TerminologyWorker', () => { name: 'SupplementCS', status: 'active', supplements: 'http://main.com|1.0.0', - extension : [{url: 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-type', valueCode: "lang-pack"}] + extension : [ + {url: 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-type', valueCode: 'lang-pack'}, + {url: 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-global', valueBoolean: true} + ] }); worker.additionalResources = [supplementCS]; @@ -273,6 +279,61 @@ describe('TerminologyWorker', () => { expect(supplements).toEqual([supplementCS]); }); + test('should only use a lang-pack that is not global when it is referenced', () => { + const supplementCS = new CodeSystem({ + resourceType: 'CodeSystem', + url: 'http://supplement.com', + version: '1.0.0', + name: 'SupplementCS', + status: 'active', + supplements: 'http://main.com', + extension : [{url: 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-type', valueCode: 'lang-pack'}] + }); + + worker.additionalResources = [supplementCS]; + + expect(worker.loadSupplements('http://main.com')).toEqual([]); + expect(worker.loadSupplements('http://main.com', '', new Set(['http://supplement.com']))).toEqual([supplementCS]); + }); + + test('should not treat codesystem-supplement-global as global on a properties supplement', () => { + const supplementCS = new CodeSystem({ + resourceType: 'CodeSystem', + url: 'http://supplement.com', + version: '1.0.0', + name: 'SupplementCS', + status: 'active', + supplements: 'http://main.com', + extension : [ + {url: 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-type', valueCode: 'properties'}, + {url: 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-global', valueBoolean: true} + ] + }); + + worker.additionalResources = [supplementCS]; + + expect(worker.loadSupplements('http://main.com')).toEqual([]); + }); + + test('should use a global extension-codes supplement without it being referenced', () => { + const supplementCS = new CodeSystem({ + resourceType: 'CodeSystem', + url: 'http://supplement.com', + version: '1.0.0', + name: 'SupplementCS', + status: 'active', + supplements: 'http://main.com', + extension : [ + {url: 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-type', valueCode: 'extension-codes'}, + {url: 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-global', valueBoolean: true} + ] + }); + + worker.additionalResources = [supplementCS]; + + expect(worker.loadSupplements('http://main.com')).toEqual([supplementCS]); + }); + test('should ignore non-CodeSystem resources', () => { const valueSet = new ValueSet({ resourceType: 'ValueSet', diff --git a/tx/cs/cs-api.js b/tx/cs/cs-api.js index eb3698dc..b3795410 100644 --- a/tx/cs/cs-api.js +++ b/tx/cs/cs-api.js @@ -326,11 +326,12 @@ class CodeSystemProvider { } /** - * @param {boolean} langPacks - whether to include language packs + * @param {boolean} globals - whether to include global supplements (which $expand only reports + * when they contribute something) * @returns {string[]} all supplements in scope */ - listSupplements(langPacks) { - return this.supplements ? this.supplements.filter(s => langPacks || !s.isLangPack()).map(s => s.vurl) : []; + listSupplements(globals) { + return this.supplements ? this.supplements.filter(s => globals || !s.isGlobalSupplement()).map(s => s.vurl) : []; } /** @@ -377,6 +378,22 @@ class CodeSystemProvider { * @param {String} code * @returns {string} the best display given the languages in the operation context */ + /** + * @param {string} code - a code users define (one in the code system's reserved code space) + * @returns {string|null} the display an extension-codes supplement gives it, if one does + */ + _extensionCodeDisplay(code) { + for (const supplement of this.supplements || []) { + if (supplement.isExtensionCodes()) { + const concept = supplement.getConceptByCode(code); + if (concept && concept.display) { + return concept.display; + } + } + } + return null; + } + _displayFromSupplements(code) { assert(typeof code === 'string', 'code must be string'); if (this.supplements) { @@ -499,13 +516,24 @@ class CodeSystemProvider { */ async designations(code, displays, significantOnly = false) { return null; } - _listSupplementDesignations(code, displays) { + /** + * @param {string} code - the code + * @param {ConceptDesignations} displays - the designations being built for the code + * @param {boolean} extensionCode - true if the code is one users define (it is in the code system's + * reserved code space), so that an extension-codes supplement's display is the code's display. The + * designations record where it came from (displays.extensionCodeDisplay): 'global' if a global + * supplement gave it - as if the code system defined it - otherwise 'referenced' + */ + _listSupplementDesignations(code, displays, extensionCode = false) { assert(typeof code === 'string', 'code must be string'); if (this.supplements) { for (const supplement of this.supplements) { const concept= supplement.getConceptByCode(code); if (concept) { + if (extensionCode && concept.display && supplement.isExtensionCodes()) { + displays.extensionCodeDisplay = supplement.isGlobalSupplement() || displays.extensionCodeDisplay === 'global' ? 'global' : 'referenced'; + } if (concept.display) { // sometimes the display is just repeated from the base code system if (!displays.hasAnyDisplay(concept.display)) { diff --git a/tx/cs/cs-country.js b/tx/cs/cs-country.js index a93f2fea..25e75cea 100644 --- a/tx/cs/cs-country.js +++ b/tx/cs/cs-country.js @@ -73,6 +73,14 @@ class CountryCodeServices extends CodeSystemProvider { if (!ctxt) { return null; } + // a user-assigned code's display is a placeholder ('User-assigned'): an extension-codes + // supplement's display for it comes first + if (ctxt.userDefined) { + const ext = this._extensionCodeDisplay(ctxt.code); + if (ext) { + return ext; + } + } if (ctxt.display && this.opContext.langs.isEnglishOrNothing()) { return ctxt.display; } @@ -116,11 +124,15 @@ class CountryCodeServices extends CodeSystemProvider { const ctxt = await this.#ensureContext(code); if (ctxt != null) { displays.userDefined = ctxt.userDefined; - displays.addDesignation( true, 'active', 'en', CodeSystem.makeUseForDisplay(), ctxt.display); + // when an extension-codes supplement gives a user-assigned code a display, the placeholder + // ('User-assigned') isn't one of its displays + if (!(ctxt.userDefined && this._extensionCodeDisplay(ctxt.code))) { + displays.addDesignation( true, 'active', 'en', CodeSystem.makeUseForDisplay(), ctxt.display); + } if (ctxt.french) { displays.addDesignation(true, 'active', 'fr', CodeSystem.makeUseForDisplay(), ctxt.french); } - this._listSupplementDesignations(ctxt.code, displays); + this._listSupplementDesignations(ctxt.code, displays, ctxt.userDefined); } } diff --git a/tx/cs/cs-cs.js b/tx/cs/cs-cs.js index 74461246..37b14bfb 100644 --- a/tx/cs/cs-cs.js +++ b/tx/cs/cs-cs.js @@ -83,9 +83,16 @@ function wordsRatio(words, text) { * Context class for FHIR CodeSystem provider concepts */ class FhirCodeSystemProviderContext { - constructor(code, concept) { + /** + * @param {string} code - the code + * @param {Object} concept - its definition in the code system + * @param {boolean} reserved - true if the code system doesn't define the code, but it is in its + * reserved code space (so the concept is just the code, and supplements may define it) + */ + constructor(code, concept, reserved = false) { this.code = code; this.concept = concept; + this.reserved = reserved; } } @@ -464,6 +471,16 @@ class FhirCodeSystemProvider extends BaseCSServices { }; } + // a code the code system doesn't define, in a code space it reserves for users to define their own + // codes, is a valid code. An extension-codes supplement in scope may define it (display etc); if + // none does, it has no display + if (this.codeSystem.isInReservedCodeSpace(code)) { + return { + context: new FhirCodeSystemProviderContext(code, { code: code }, true), + message: null + }; + } + return { context: null, message: undefined @@ -511,6 +528,11 @@ class FhirCodeSystemProvider extends BaseCSServices { if (supplementDisplay) { return supplementDisplay; } + // a code in the reserved code space has the display an extension-codes supplement gives it, + // whatever languages were asked for - or, if none does, the code space's default display + if (ctxt.reserved) { + return this._extensionCodeDisplay(ctxt.code) || this.codeSystem.reservedCodeSpaceFor(ctxt.code).defaultDisplay; + } // Use language-aware display logic if (this.opContext.langs && !this.opContext.langs.isEnglishOrNothing()) { @@ -774,8 +796,17 @@ class FhirCodeSystemProvider extends BaseCSServices { } } + // a code in the reserved code space that no extension-codes supplement in scope gives a display has + // its code space's default display ('User-assigned'). It is a placeholder, so (as for ISO 3166's + // user-assigned codes) another display is not wrong - see validate.js checkDisplays + if (ctxt.reserved && !this._extensionCodeDisplay(ctxt.code)) { + displays.userDefined = true; + const displayLang = this.defaultLanguage ? this.defaultLanguage.toString() : null; + displays.addDesignation(true, 'active', displayLang, CodeSystem.makeUseForDisplay(), this.codeSystem.reservedCodeSpaceFor(ctxt.code).defaultDisplay); + } + // Add supplement designations - this._listSupplementDesignations(ctxt.code, displays); + this._listSupplementDesignations(ctxt.code, displays, ctxt.reserved); } /** diff --git a/tx/cs/cs-icf.js b/tx/cs/cs-icf.js index 02afe1a0..21498b07 100644 --- a/tx/cs/cs-icf.js +++ b/tx/cs/cs-icf.js @@ -195,9 +195,23 @@ class IcfCodeSystemProvider extends FhirCodeSystemProvider { return ctxt instanceof IcfQualifiedContext ? ctxt.qualifiedCode : super.code(ctxt); } - /** The category's display, then the qualifiers' displays, leaving out any 8 (not specified). */ + /** + * A qualified code's display is the one an extension-codes supplement in scope gives it, if one + * does. Otherwise it is generated: the category's display, then the qualifiers' displays, leaving + * out any 8 (not specified). + */ async display(context) { const ctxt = await this.#resolve(context); + if (ctxt instanceof IcfQualifiedContext) { + const ext = this._extensionCodeDisplay(ctxt.qualifiedCode); + if (ext) { + return ext; + } + } + return this.#generatedDisplay(ctxt); + } + + async #generatedDisplay(ctxt) { const display = await super.display(ctxt); if (!(ctxt instanceof IcfQualifiedContext)) { return display; @@ -207,14 +221,19 @@ class IcfCodeSystemProvider extends FhirCodeSystemProvider { return parts.length > 0 ? display + ': ' + parts.join(', ') : display; } - /** A qualified code has one designation: its display. The category's translations do not describe it. */ + /** + * A qualified code's designations: the display an extension-codes supplement in scope gives it + * (first, so it is the preferred display), and its generated display, which stays a valid one. The + * category's translations do not describe it. + */ async designations(context, displays) { const ctxt = await this.#resolve(context); if (!(ctxt instanceof IcfQualifiedContext)) { return super.designations(ctxt, displays); } + this._listSupplementDesignations(ctxt.qualifiedCode, displays, true); const lang = this.defaultLanguage ? this.defaultLanguage.toString() : null; - displays.addDesignation(true, 'active', lang, CodeSystem.makeUseForDisplay(), await this.display(ctxt)); + displays.addDesignation(true, 'active', lang, CodeSystem.makeUseForDisplay(), await this.#generatedDisplay(ctxt)); } /** diff --git a/tx/cs/cs-snomed.js b/tx/cs/cs-snomed.js index c1bddbf2..232ce853 100644 --- a/tx/cs/cs-snomed.js +++ b/tx/cs/cs-snomed.js @@ -2691,6 +2691,10 @@ class SnomedProvider extends BaseCSServices { if (disp) return disp; if (ctxt.isComplex()) { + // an extension-codes supplement in scope can give a post-coordinated expression a display; + // otherwise it is generated from the expression + const ext = this.#extensionCodeDisplay(ctxt); + if (ext) return ext; return this.sct.expressionServices.renderExpression(ctxt.expression, SnomedServicesRenderOption.FillMissing); } else { return this.sct.getDisplayName(ctxt.getReference(), this.sct.defaultLanguage); @@ -2739,8 +2743,11 @@ class SnomedProvider extends BaseCSServices { if (ctxt.isComplex()) { - // For complex expressions, just add the display - const display = await this.display(context); + // For complex expressions: the display an extension-codes supplement in scope gives it (first, + // so it is the preferred display), and the display generated from the expression, which stays + // a valid display + this._listSupplementDesignations(this.#extensionCodeKey(ctxt), displays, true); + const display = this.sct.expressionServices.renderExpression(ctxt.expression, SnomedServicesRenderOption.FillMissing); if (display) { displays.addDesignation(true, 'active', 'en-US', DesignationUse.PREFERRED, display); } @@ -2857,6 +2864,30 @@ class SnomedProvider extends BaseCSServices { } } + /** + * The code an extension-codes supplement gives a post-coordinated expression under: the expression + * as it was written, or, if no supplement in scope has that, the expression in its minimal rendering + * (so that a supplement written without spaces or terms matches an expression written with them). + * + * @param {SnomedExpressionContext} ctxt - a complex expression + * @returns {string} the code to look for in the supplements + */ + #extensionCodeKey(ctxt) { + const source = ctxt.getCode(); + if (source && this._extensionCodeDisplay(source)) { + return source; + } + return this.sct.expressionServices.renderExpression(ctxt.expression, SnomedServicesRenderOption.Minimal); + } + + /** + * @param {SnomedExpressionContext} ctxt - a complex expression + * @returns {string|null} the display an extension-codes supplement in scope gives it, if one does + */ + #extensionCodeDisplay(ctxt) { + return this._extensionCodeDisplay(this.#extensionCodeKey(ctxt)); + } + getLanguageCode(langIndex) { return SCT_LANGUAGE_CODES[langIndex] || 'en'; } diff --git a/tx/fixtures/test-cases.yml b/tx/fixtures/test-cases.yml index 2659ef0e..9f1cf525 100644 --- a/tx/fixtures/test-cases.yml +++ b/tx/fixtures/test-cases.yml @@ -12,7 +12,7 @@ sources: - internal:urls - ucum:tx/data/ucum-essence.xml - loinc!:Loinc_2.83.db - - snomed!:sct_intl_20250201.cache + - snomed!:Snomed_Intl_20260701.cache - snomed:sct_test_20250909e.cache - omop:omop_v20250227.db - icd11:icd11-2026-01-b.db diff --git a/tx/importers/import-sct.module.js b/tx/importers/import-sct.module.js index 4aa2eda5..ada04b32 100644 --- a/tx/importers/import-sct.module.js +++ b/tx/importers/import-sct.module.js @@ -1528,14 +1528,15 @@ class SnomedImporter { const month = parseInt(dateStr.substring(4, 6)); const day = parseInt(dateStr.substring(6, 8)); - // Create target date - const targetDate = new Date(year, month - 1, day); - - // Pascal TDateTime epoch: December 30, 1899 - const pascalEpoch = new Date(1899, 11, 30); // Month is 0-based in JS + // Days since the Pascal TDateTime epoch (December 30, 1899), counted in UTC. Local dates are + // not whole days apart: 1899 local time is the zone's historical mean time (e.g. +9:39:52 in + // Melbourne) and today's has daylight saving, so the difference came out a day short in zones + // ahead of UTC, and every effectiveTime in a cache built there was a day early + const targetDate = Date.UTC(year, month - 1, day); + const pascalEpoch = Date.UTC(1899, 11, 30); // Month is 0-based in JS // Calculate days difference - const daysDiff = Math.floor((targetDate - pascalEpoch) / (1000 * 60 * 60 * 24)); + const daysDiff = Math.round((targetDate - pascalEpoch) / (1000 * 60 * 60 * 24)); // Ensure it fits in 16 bits (0-65535) and is positive if (daysDiff < 0 || daysDiff > 65535) { diff --git a/tx/library/codesystem.js b/tx/library/codesystem.js index 80aca617..31b543a3 100644 --- a/tx/library/codesystem.js +++ b/tx/library/codesystem.js @@ -4,6 +4,7 @@ const {codeSystemFromR5, codeSystemToR5} = require("../xversion/xv-codesystem"); const {getValuePrimitive} = require("../../library/utilities"); const {VersionUtilities} = require("../../library/version-utilities"); const {countNested} = require("./nesting-limit"); +const regexUtilities = require("../../library/regex-utilities"); const CodeSystemContentMode = Object.freeze({ Complete: 'complete', @@ -644,15 +645,94 @@ class CodeSystem extends CanonicalResource { this.jsonObj.id = value; } + /** + * @returns {string|null} the supplement's type, from the codesystem-supplement-type extension + * (lang-pack, properties, extension-codes...), or null if it doesn't say + */ + supplementType() { + const ext = (this.jsonObj.extension || []).find(x => x.url == 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-type'); + return ext ? getValuePrimitive(ext) : null; + } + + /** + * @returns {boolean} true if this is a language pack (codesystem-supplement-type = lang-pack). A + * language pack is only used when it is referenced, unless it is also global (isGlobalSupplement) + */ isLangPack() { - // todo: this is a temporary work around until fhir.tx.support.r4#0.37.0 is released that properly marks this as a language pack + return this.supplementType() === 'lang-pack'; + } + + /** + * A global supplement is used for the code system it supplements whether or not it is + * referenced: a lang-pack or extension-codes supplement marked with codesystem-supplement-global + * = true. Any other supplement is only used when it is referenced. + * + * @returns {boolean} true if this is a global supplement + */ + isGlobalSupplement() { + // todo: a work around until fhir.tx.support.r4 is re-released with this language pack marked global if (this.jsonObj.url === 'https://terminology.dhp.uz/fhir/CodeSystem/loinc-supplement-uz') { return true; } - // a global language pack is used whether or not it is referenced. The code was 'lang-pack' - // until Oct 2026; it is still accepted, so that supplements marked the old way keep working - return (this.jsonObj.extension || []).find(x => x.url == 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-type' - && ['global-lang-pack', 'lang-pack'].includes(getValuePrimitive(x))); + if (!['lang-pack', 'extension-codes'].includes(this.supplementType())) { + return false; + } + const ext = (this.jsonObj.extension || []).find(x => x.url == 'http://hl7.org/fhir/StructureDefinition/codesystem-supplement-global'); + return !!ext && getValuePrimitive(ext) === true; + } + + /** + * @returns {boolean} true if this is an extension-codes supplement (codesystem-supplement-type) - one + * that defines codes in the reserved code space of the code system it supplements + */ + isExtensionCodes() { + return this.supplementType() === 'extension-codes'; + } + + /** + * The code spaces the code system's steward has reserved for users to define their own codes in (the + * codesystem-reservedCodeSpace extension: reservedCodeSpace is a regular expression that a code must + * match in full, and default-display is the display of a code in the space that nothing defines - + * 'User-assigned' if it doesn't say). Compiled with re2, since the expressions can come from a request + * + * @returns {{regex: Object, defaultDisplay: string}[]} the code spaces (empty if there are none) + */ + reservedCodeSpaces() { + if (this._reservedCodeSpaces === undefined) { + this._reservedCodeSpaces = []; + for (const ext of (this.jsonObj.extension || []).filter(x => x.url == 'http://hl7.org/fhir/StructureDefinition/codesystem-reservedCodeSpace')) { + const rcs = (ext.extension || []).find(x => x.url == 'reservedCodeSpace'); + const pattern = rcs ? getValuePrimitive(rcs) : null; + if (pattern) { + const dd = (ext.extension || []).find(x => x.url == 'default-display'); + try { + this._reservedCodeSpaces.push({ + regex: regexUtilities.compile('^(?:' + pattern + ')$', this.caseInsensitive() ? 'i' : ''), + defaultDisplay: (dd && getValuePrimitive(dd)) || 'User-assigned' + }); + } catch (e) { + // an expression that doesn't compile reserves nothing + } + } + } + } + return this._reservedCodeSpaces; + } + + /** + * @param {string} code - the code + * @returns {{regex: Object, defaultDisplay: string}|null} the reserved code space the code is in, if it is in one + */ + reservedCodeSpaceFor(code) { + return code ? (this.reservedCodeSpaces().find(rcs => rcs.regex.test(code)) || null) : null; + } + + /** + * @param {string} code - the code + * @returns {boolean} true if the code is in a code space reserved for users to define their own codes + */ + isInReservedCodeSpace(code) { + return this.reservedCodeSpaceFor(code) != null; } isPropUri(code, uri) { diff --git a/tx/openapi-operations.js b/tx/openapi-operations.js index c5f2ca58..feab8d99 100644 --- a/tx/openapi-operations.js +++ b/tx/openapi-operations.js @@ -183,13 +183,15 @@ const OPERATIONS = [ methods: ['post'], summary: 'Validate many codes against code systems', description: 'A batch of CodeSystem $validate-code requests: each `validation` is a ' + - 'Parameters resource with the parameters of one. system, codeSystem, tx-resource and ' + - 'lenient-display-validation at the top level apply to every validation that doesn\'t ' + - 'have its own.', + 'Parameters resource with the parameters of one. url (the code system), version, system, ' + + 'codeSystem, tx-resource and lenient-display-validation at the top level apply to every ' + + 'validation that doesn\'t have its own. Every validation is against a code system.', postBody: ['Parameters'], in: [ p('validation', 'Parameters', '*', 'The parameters of one $validate-code', { min: 1 }), - p('system', 'uri', '1', 'The code system, for every validation'), + p('url', 'uri', '1', 'The code system, for every validation'), + p('version', 'string', '1', 'The code system version, for every validation'), + p('system', 'uri', '1', 'The code system, if url isn\'t given, for every validation'), p('codeSystem', 'CodeSystem', '1', 'The code system, for every validation'), p('lenient-display-validation', 'boolean', '1', 'For every validation'), p('tx-resource', 'Resource', '*', 'Resources for every validation') diff --git a/tx/provider.js b/tx/provider.js index a9056c47..c551072d 100644 --- a/tx/provider.js +++ b/tx/provider.js @@ -117,7 +117,7 @@ class Provider { } for (let cs of this.codeSystems.values()) { if (cs.isSupplementFor(url, version)) { - if (cs.isLangPack() || this.isStatedSupplement(cs, statedSupplements)) { + if (cs.isGlobalSupplement() || this.isStatedSupplement(cs, statedSupplements)) { supplements.set(cs.vurl, cs); } } diff --git a/tx/tx.fhir.org.yml b/tx/tx.fhir.org.yml index e8b46833..c8473be0 100644 --- a/tx/tx.fhir.org.yml +++ b/tx/tx.fhir.org.yml @@ -24,14 +24,14 @@ sources: - rxnorm:rxnorm_03022026.db - ndc:ndc-20211101.db - unii:unii_20240622.db - - snomed:sct_intl_20240201.cache - - snomed!:sct_intl_20250201.cache - - snomed:sct_ips_20241216.cache - - snomed:sct_nl_20240930.cache + - snomed:sct_intl_20250201.cache + - snomed!:Snomed_Intl_20260701.cache + - snomed:snomed-nl-20260930.cache - snomed:sct_uk_20230412.cache - snomed:sct_us_20230301.cache - snomed:sct_us_20240301.cache - - snomed:sct_us_20250901.cache + - snomed:sct_us_20250901b.cache + - snomed:Snomed_loinc_20261021.cache - snomed:sct_test_20250909e.cache - cpt:CodeSystem-cpt.db|cpt-2023-fragment-0.1.db - omop:omop_v20250227.db diff --git a/tx/workers/batch-validate.js b/tx/workers/batch-validate.js index 02363baf..e209a8a0 100644 --- a/tx/workers/batch-validate.js +++ b/tx/workers/batch-validate.js @@ -11,6 +11,11 @@ // system/codeSystem entry validates against a code system), so both batch routes // share one implementation and even a mixed batch is handled correctly. // +// The exception is `url`, which names a value set to ValueSet/$validate-code but a +// code system to CodeSystem/$validate-code. So on the CodeSystem route `url` is a +// shared global (the code system), and every entry is a CodeSystem validation; +// only the ValueSet route dispatches by shape. +// const { TerminologyWorker } = require('./worker'); const { OperationOutcome, Issue, buildOperationOutcome, outcomeFromError } = require('../library/operation-outcome'); @@ -33,14 +38,15 @@ class BatchValidateWorker extends TerminologyWorker { super(opContext, log, provider, languages, i18n); // "Global" parameters may be supplied once at the top level and are applied to // every entry that doesn't override them. tx-resource is always shared. The - // primary differs by operation: a ValueSet batch shares url/valueSet, a - // CodeSystem batch shares system/codeSystem. + // primary differs by operation: a ValueSet batch shares url/valueSet (the value + // set), a CodeSystem batch shares url/version/codeSystem (the code system), as + // CodeSystem/$validate-code names them - and system, which it also accepts. this.valueSetGlobalNames = new Set([ "tx-resource", "url", "valueSet", "lenient-display-validation", "__Accept-Language", "__Content-Language" ]); this.codeSystemGlobalNames = new Set([ - "tx-resource", "system", "codeSystem", "lenient-display-validation", + "tx-resource", "url", "version", "system", "codeSystem", "lenient-display-validation", "__Accept-Language", "__Content-Language" ]); // Back-compat: `globalNames` was the (ValueSet) set before CodeSystem batches. @@ -57,12 +63,12 @@ class BatchValidateWorker extends TerminologyWorker { /** ValueSet/$batch-validate-code: shared globals are url/valueSet. */ async handleValueSet(req, res) { - return this.processBatch(req, res, this.valueSetGlobalNames); + return this.processBatch(req, res, this.valueSetGlobalNames, false); } - /** CodeSystem/$batch-validate-code: shared globals are system/codeSystem. */ + /** CodeSystem/$batch-validate-code: shared globals are url/version/system/codeSystem. */ async handleCodeSystem(req, res) { - return this.processBatch(req, res, this.codeSystemGlobalNames); + return this.processBatch(req, res, this.codeSystemGlobalNames, true); } /** @@ -74,8 +80,11 @@ class BatchValidateWorker extends TerminologyWorker { * @param {express.Request} req * @param {express.Response} res * @param {Set} globalNames - which top-level params are shared globals + * @param {boolean} codeSystemRoute - true for CodeSystem/$batch-validate-code: every + * entry is a CodeSystem validation (`url` there is the code system). On the + * ValueSet route, an entry with no url/valueSet is a CodeSystem validation */ - async processBatch(req, res, globalNames) { + async processBatch(req, res, globalNames, codeSystemRoute = false) { try { let params = req.body; this.addHttpParams(req, params); @@ -123,7 +132,7 @@ class BatchValidateWorker extends TerminologyWorker { let worker = new ValidateWorker(this.opContext.copy(), this.log, this.provider, this.languages, this.i18n); try { let p; - if (this.hasValueSet(op.jsonObj.parameter)) { + if (!codeSystemRoute && this.hasValueSet(op.jsonObj.parameter)) { p = await worker.handleValueSetInner(op.jsonObj); } else { p = await worker.handleCodeSystemInner(op.jsonObj); diff --git a/tx/workers/validate.js b/tx/workers/validate.js index 55636955..8fc01f0b 100644 --- a/tx/workers/validate.js +++ b/tx/workers/validate.js @@ -1225,7 +1225,7 @@ class ValueSetChecker { if (!pdisp) { pdisp = list.preferredDisplay(null); } - let severity = this.dispWarning(); + let severity = list.extensionCodeDisplay === 'referenced' ? 'warning' : this.dispWarning(); if (c.display && list.designations.length > 0 && !list.hasDisplay(this.params.workingLanguages(), defLang.value, c.display, false, DisplayCheckingStyle.CASE_INSENSITIVE).found) { let baseMsg; if (list.hasDisplay(this.params.workingLanguages(), defLang.value, c.display, false, DisplayCheckingStyle.CASE_INSENSITIVE).difference === DisplayDifference.Normalized) { @@ -1471,8 +1471,11 @@ class ValueSetChecker { let hd = list.hasDisplay(this.params.workingLanguages(), null, c.display, false, DisplayCheckingStyle.CASE_INSENSITIVE) if (!hd.found) { let baseMsg; - let severity = this.dispWarning(); - if (list.userDefined) { + // a display an extension-codes supplement that is not global gives a user-defined code is not + // the only correct one, so another display is a warning. One from a global supplement is as if + // the code system defined it + let severity = list.extensionCodeDisplay === 'referenced' ? 'warning' : this.dispWarning(); + if (list.userDefined && !list.extensionCodeDisplay) { baseMsg = 'Display_Name_Not_Fixed_use_Supplement'; severity = 'information'; } else if (hd.difference === DisplayDifference.Normalized) { diff --git a/tx/workers/worker.js b/tx/workers/worker.js index 216a137d..b52e9056 100644 --- a/tx/workers/worker.js +++ b/tx/workers/worker.js @@ -304,8 +304,8 @@ class TerminologyWorker { continue; } - // we consider either language packs or specified supplements - if (!(cs.isLangPack() || (statedSupplements && (statedSupplements.has(cs.url) || statedSupplements.has(cs.vurl))))) { + // we consider either global supplements or specified supplements + if (!(cs.isGlobalSupplement() || (statedSupplements && (statedSupplements.has(cs.url) || statedSupplements.has(cs.vurl))))) { continue; } if (this.hasSupplement(cs, supplements)) {