fhirsmith 0.13.4 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -1
- package/README.md +16 -3
- package/config-template.json +81 -1
- package/library/cgroup-memory.js +113 -0
- package/library/folder-setup.js +8 -1
- package/library/languages.js +15 -0
- package/library/same-origin.js +62 -0
- package/nginx.md +399 -0
- package/package.json +3 -2
- package/publisher/git-remote.js +141 -0
- package/publisher/github-release.js +195 -0
- package/publisher/publisher-template.html +1 -0
- package/publisher/publisher.js +241 -56
- package/registry/registry.js +3 -1
- package/server.js +57 -15
- package/stats.js +19 -13
- package/testing/readme.md +144 -0
- package/testing/render.js +723 -0
- package/testing/search.js +378 -0
- package/testing/store.js +410 -0
- package/testing/testing-template.html +126 -0
- package/testing/testing.js +760 -0
- package/translations/Messages.properties +32 -0
- package/tx/closure/closure-store.js +396 -0
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-rxnorm.js +35 -8
- package/tx/cs/cs-snomed.js +19 -69
- package/tx/html/codesystem-operations.liquid +177 -0
- package/tx/html/ecl-panel.liquid +93 -0
- package/tx/html/home-metrics.liquid +39 -76
- package/tx/html/tx-template.html +1 -0
- package/tx/library/conceptmap.js +22 -6
- package/tx/library/designations.js +12 -3
- package/tx/library/operation-outcome.js +3 -1
- package/tx/library/renderer.js +42 -5
- package/tx/operation-context.js +80 -17
- package/tx/provider.js +85 -0
- package/tx/sct/editions.js +70 -0
- package/tx/sct/expressions.js +128 -44
- package/tx/tx-html.js +426 -44
- package/tx/tx.js +117 -9
- package/tx/workers/cache-control.js +2 -2
- package/tx/workers/closure.js +483 -13
- package/tx/workers/expand.js +2 -2
- package/tx/workers/metadata.js +8 -3
- package/tx/workers/read.js +18 -3
- package/tx/workers/subsumes.js +18 -2
- package/tx/workers/translate.js +192 -21
- package/tx/workers/validate.js +70 -187
- package/tx/workers/worker.js +12 -35
- package/tx/xversion/xv-conceptmap.js +17 -8
- package/utilities/submit-test-report.js +150 -0
package/tx/operation-context.js
CHANGED
|
@@ -4,6 +4,7 @@ const crypto = require("crypto");
|
|
|
4
4
|
const {Languages} = require("../library/languages");
|
|
5
5
|
const {Issue} = require("./library/operation-outcome");
|
|
6
6
|
const Logger = require("../library/logger");
|
|
7
|
+
const { readCgroupMemoryLimit } = require('../library/cgroup-memory');
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Check if running under a debugger
|
|
@@ -110,6 +111,63 @@ class ResourceCache {
|
|
|
110
111
|
// so a client polling $cache-control?mode=check can work out how often it needs
|
|
111
112
|
// to poll instead of guessing. Null = not advertised.
|
|
112
113
|
this.idleTimeoutMs = null;
|
|
114
|
+
// This server instance's code (modules.tx.instanceCode), prefixed onto every
|
|
115
|
+
// cache-id it issues as "<code>.<uuid>". A proxy in front of several instances
|
|
116
|
+
// routes on the prefix to get a cached request back to the one instance that
|
|
117
|
+
// holds the cache (see nginx.md). Null = unprefixed ids (a single server).
|
|
118
|
+
this.instanceCode = null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Set the instance code that is prefixed onto the cache-ids this cache issues.
|
|
123
|
+
* 1-16 letters and digits: the code is followed by a '.', a proxy has to be able
|
|
124
|
+
* to match it with a simple pattern, and the whole id must stay a valid FHIR id. Empty/null/undefined means no prefix.
|
|
125
|
+
*
|
|
126
|
+
* @param {string|null|undefined} code
|
|
127
|
+
* @throws {Error} if the code is not letters and digits
|
|
128
|
+
*/
|
|
129
|
+
setInstanceCode(code) {
|
|
130
|
+
this.instanceCode = ResourceCache.checkInstanceCode(code);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Validate an instance code from config.
|
|
135
|
+
* @param {string|null|undefined} code
|
|
136
|
+
* @returns {string|null} the code, or null for none (empty/null/undefined)
|
|
137
|
+
* @throws {Error} if the code is not letters and digits
|
|
138
|
+
*/
|
|
139
|
+
static checkInstanceCode(code) {
|
|
140
|
+
if (code === undefined || code === null || code === '') {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
// At most 16 characters: the cache-id goes back to the client as a FHIR id
|
|
144
|
+
// (valueId), which is capped at 64 - and "<code>." plus a 36-character UUID
|
|
145
|
+
// has to fit.
|
|
146
|
+
if (typeof code !== 'string' || !/^[A-Za-z0-9]{1,16}$/.test(code)) {
|
|
147
|
+
throw new Error(`tx instanceCode '${code}' is not valid: it must be 1-16 letters and digits`);
|
|
148
|
+
}
|
|
149
|
+
return code;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Mint a new cache-id: a UUID, prefixed with "<instanceCode>." when this server
|
|
154
|
+
* has an instance code. Minting only - the caller creates the entry with set().
|
|
155
|
+
* @returns {string}
|
|
156
|
+
*/
|
|
157
|
+
newCacheId() {
|
|
158
|
+
const uuid = crypto.randomUUID();
|
|
159
|
+
return this.instanceCode ? `${this.instanceCode}.${uuid}` : uuid;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The instance code in a cache-id, or null if it has none. Ids this server mints
|
|
164
|
+
* are either a bare UUID (no '.') or "<code>.<uuid>".
|
|
165
|
+
* @param {string} cacheId
|
|
166
|
+
* @returns {string|null}
|
|
167
|
+
*/
|
|
168
|
+
static issuerOf(cacheId) {
|
|
169
|
+
const dot = typeof cacheId === 'string' ? cacheId.indexOf('.') : -1;
|
|
170
|
+
return dot > 0 ? cacheId.substring(0, dot) : null;
|
|
113
171
|
}
|
|
114
172
|
|
|
115
173
|
/**
|
|
@@ -164,7 +222,9 @@ class ResourceCache {
|
|
|
164
222
|
|
|
165
223
|
/**
|
|
166
224
|
* The message to report for a cache-id that isn't here: which of the three
|
|
167
|
-
* fates it met, with the numbers that make it checkable
|
|
225
|
+
* fates it met, with the numbers that make it checkable - or, when there's no
|
|
226
|
+
* record of it and its prefix names another instance, that it was routed here
|
|
227
|
+
* by mistake. Both throw sites
|
|
168
228
|
* (worker.setupAdditionalResources and batchValidate.frontLoadBatch) use this
|
|
169
229
|
* so they can never drift apart.
|
|
170
230
|
*
|
|
@@ -175,6 +235,15 @@ class ResourceCache {
|
|
|
175
235
|
describeMissing(cacheId) {
|
|
176
236
|
const t = this.tombstone(cacheId);
|
|
177
237
|
if (!t) {
|
|
238
|
+
// No record of it here. If its prefix names a different instance, say so:
|
|
239
|
+
// that's a routing problem (proxy misconfigured, or the owning instance is
|
|
240
|
+
// down and the request fell through to a backup), not a lifecycle one.
|
|
241
|
+
const issuer = ResourceCache.issuerOf(cacheId);
|
|
242
|
+
if (issuer && issuer !== this.instanceCode) {
|
|
243
|
+
return this.instanceCode
|
|
244
|
+
? { messageId: 'CACHE_ID_OTHER_INSTANCE', params: [cacheId, issuer, this.instanceCode] }
|
|
245
|
+
: { messageId: 'CACHE_ID_OTHER_INSTANCE_UNNAMED', params: [cacheId, issuer] };
|
|
246
|
+
}
|
|
178
247
|
return { messageId: 'CACHE_ID_UNKNOWN', params: [cacheId] };
|
|
179
248
|
}
|
|
180
249
|
const ago = formatDuration(Date.now() - t.at);
|
|
@@ -752,18 +821,12 @@ class ExpansionCache {
|
|
|
752
821
|
* Returns the byte limit, or 0 if unavailable (disables the check).
|
|
753
822
|
*/
|
|
754
823
|
function readMemoryLimit() {
|
|
755
|
-
|
|
756
|
-
const raw = require('fs').readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim();
|
|
757
|
-
if (raw === 'max') return 0; // no cgroup limit
|
|
758
|
-
return parseInt(raw);
|
|
759
|
-
} catch {
|
|
760
|
-
return 0; // not on Linux / no cgroup
|
|
761
|
-
}
|
|
824
|
+
return readCgroupMemoryLimit().limit;
|
|
762
825
|
}
|
|
763
826
|
|
|
764
827
|
const MEMORY_LIMIT = readMemoryLimit();
|
|
765
828
|
const MEMORY_FRACTION = 0.98;
|
|
766
|
-
const MEMORY_THRESHOLD = MEMORY_LIMIT > 0 ? MEMORY_LIMIT * MEMORY_FRACTION : 0; //
|
|
829
|
+
const MEMORY_THRESHOLD = MEMORY_LIMIT > 0 ? MEMORY_LIMIT * MEMORY_FRACTION : 0; // MEMORY_FRACTION of cgroup limit
|
|
767
830
|
const CHECK_FREQUENCY = 100;
|
|
768
831
|
// How long an operation may compute without yielding the event loop (ms).
|
|
769
832
|
// Node runs all JS on one thread: while a long operation executes, no other
|
|
@@ -891,8 +954,8 @@ class OperationContext {
|
|
|
891
954
|
if (elapsed > this.timeLimit) {
|
|
892
955
|
const timeInSeconds = Math.round(this.timeLimit / 1000);
|
|
893
956
|
this.log(`Operation took too long @ ${place} (${this.constructor.name})`);
|
|
894
|
-
const error = new Issue("error", "too-costly", null,
|
|
895
|
-
`Operation exceeded time limit of ${timeInSeconds} seconds at ${place}
|
|
957
|
+
const error = new Issue("error", "too-costly", null, null,
|
|
958
|
+
`Operation exceeded time limit of ${timeInSeconds} seconds at ${place}`, "too-costly", 422);
|
|
896
959
|
error.diagnostics = this.diagnostics();
|
|
897
960
|
throw error;
|
|
898
961
|
}
|
|
@@ -904,8 +967,8 @@ class OperationContext {
|
|
|
904
967
|
const usedGB = (rss / 1024 / 1024 / 1024).toFixed(1);
|
|
905
968
|
const limitGB = (MEMORY_LIMIT / 1024 / 1024 / 1024).toFixed(1);
|
|
906
969
|
this.log(`Memory Limit: ${usedGB} GB of ${limitGB} GB limit @ ${place}`);
|
|
907
|
-
const error = new Issue("error", "too-costly", null,
|
|
908
|
-
`Operation aborted: server memory usage (${usedGB} GB) exceeds safe threshold (${MEMORY_FRACTION * 100}% of ${limitGB} GB limit) at ${place}
|
|
970
|
+
const error = new Issue("error", "too-costly", null, null,
|
|
971
|
+
`Operation aborted: server memory usage (${usedGB} GB) exceeds safe threshold (${MEMORY_FRACTION * 100}% of ${limitGB} GB limit) at ${place}`, "too-costly", 422);
|
|
909
972
|
error.diagnostics = this.diagnostics();
|
|
910
973
|
throw error;
|
|
911
974
|
}
|
|
@@ -963,8 +1026,8 @@ class OperationContext {
|
|
|
963
1026
|
this._clock.lastYield = resumed;
|
|
964
1027
|
if (this._clock.clientGone) {
|
|
965
1028
|
this.log(`Operation abandoned @ ${place}: client disconnected`);
|
|
966
|
-
const error = new Issue("error", "too-costly", null,
|
|
967
|
-
`Operation abandoned at ${place}: the client disconnected before the response was ready
|
|
1029
|
+
const error = new Issue("error", "too-costly", null, null,
|
|
1030
|
+
`Operation abandoned at ${place}: the client disconnected before the response was ready`, "too-costly", 422);
|
|
968
1031
|
error.abandoned = true;
|
|
969
1032
|
error.diagnostics = this.diagnostics();
|
|
970
1033
|
throw error;
|
|
@@ -996,8 +1059,8 @@ class OperationContext {
|
|
|
996
1059
|
}
|
|
997
1060
|
if (this._clock.clientGone) {
|
|
998
1061
|
this.log(`Operation abandoned @ ${place}: client disconnected`);
|
|
999
|
-
const error = new Issue("error", "too-costly", null,
|
|
1000
|
-
`Operation abandoned at ${place}: the client disconnected before the response was ready
|
|
1062
|
+
const error = new Issue("error", "too-costly", null, null,
|
|
1063
|
+
`Operation abandoned at ${place}: the client disconnected before the response was ready`, "too-costly", 422);
|
|
1001
1064
|
error.abandoned = true;
|
|
1002
1065
|
error.diagnostics = this.diagnostics();
|
|
1003
1066
|
throw error;
|
package/tx/provider.js
CHANGED
|
@@ -449,6 +449,91 @@ class Provider {
|
|
|
449
449
|
return null;
|
|
450
450
|
}
|
|
451
451
|
|
|
452
|
+
/**
|
|
453
|
+
* A checker for codes in one code system, so that a page can ask about many codes
|
|
454
|
+
* without paying to build the code system each time.
|
|
455
|
+
*
|
|
456
|
+
* This is for deciding whether to offer a link. A property value that came back from
|
|
457
|
+
* $lookup as a code is, by the definition of CodeSystem.property.type, a concept in the
|
|
458
|
+
* same code system - but a provider can declare a property that way and then put
|
|
459
|
+
* something else in it (ICD-11's classKind is chapter|block|window|category, none of
|
|
460
|
+
* which is an ICD-11 concept), and a link to a code that is not there is worse than no
|
|
461
|
+
* link. So the codes are checked, once each, against the code system itself.
|
|
462
|
+
*
|
|
463
|
+
* @param {OperationContext} opContext
|
|
464
|
+
* @param {string} system
|
|
465
|
+
* @param {string|null} version
|
|
466
|
+
* @returns {Promise<function(string): Promise<string|null>>|null} a function returning
|
|
467
|
+
* the code's display (possibly empty) if it exists and null if it does not, or null
|
|
468
|
+
* when this server does not have that code system at all
|
|
469
|
+
*/
|
|
470
|
+
async codeChecker(opContext, system, version) {
|
|
471
|
+
validateParameter(opContext, "opContext", OperationContext);
|
|
472
|
+
if (!system) {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
if (system.includes("|")) {
|
|
476
|
+
version = version || system.substring(system.indexOf("|") + 1);
|
|
477
|
+
system = system.substring(0, system.indexOf("|"));
|
|
478
|
+
}
|
|
479
|
+
const vurl = system + (version ? "|" + version : "");
|
|
480
|
+
const vurlMM = VersionUtilities.isSemVer(version) ? system + "|" + VersionUtilities.getMajMin(version) : null;
|
|
481
|
+
|
|
482
|
+
let factory = this.codeSystemFactories.get(vurl);
|
|
483
|
+
if (factory == null && vurlMM) {
|
|
484
|
+
factory = this.codeSystemFactories.get(vurlMM);
|
|
485
|
+
}
|
|
486
|
+
if (factory != null) {
|
|
487
|
+
const csp = await factory.build(opContext, []);
|
|
488
|
+
if (csp) {
|
|
489
|
+
opContext.registerProvider(csp);
|
|
490
|
+
return async (code) => {
|
|
491
|
+
const located = await csp.locate(code);
|
|
492
|
+
return located && located.context ? (await csp.display(located.context)) || '' : null;
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
let cs = this.codeSystems.get(vurl);
|
|
498
|
+
if (cs == null && vurlMM) {
|
|
499
|
+
cs = this.codeSystems.get(vurlMM);
|
|
500
|
+
}
|
|
501
|
+
if (cs != null) {
|
|
502
|
+
return async (code) => {
|
|
503
|
+
const c = cs.codeMap.get(code);
|
|
504
|
+
return c ? (c.display || '') : null;
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* A link to $lookup for a code on this server.
|
|
512
|
+
*
|
|
513
|
+
* resolveCode() answers "where does this code live" - a code system page, or the
|
|
514
|
+
* publisher's own browser - which is the right answer in prose. In an expansion the
|
|
515
|
+
* question is different: the reader has a list of codes in front of them and wants to
|
|
516
|
+
* know what one of them actually is, which is what $lookup answers, here, in the
|
|
517
|
+
* version the expansion used.
|
|
518
|
+
*
|
|
519
|
+
* @param {string} system
|
|
520
|
+
* @param {string|null} version - may also be carried on system as system|version
|
|
521
|
+
* @param {string} code
|
|
522
|
+
* @returns {string|null} the URL, or null if there is not enough to build one
|
|
523
|
+
*/
|
|
524
|
+
lookupLink(system, version, code) {
|
|
525
|
+
if (!system || !code) {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
if (system.includes("|")) {
|
|
529
|
+
version = version || system.substring(system.indexOf("|") + 1);
|
|
530
|
+
system = system.substring(0, system.indexOf("|"));
|
|
531
|
+
}
|
|
532
|
+
return this.path + "/CodeSystem/$lookup?system=" + encodeURIComponent(system)
|
|
533
|
+
+ (version ? "&version=" + encodeURIComponent(version) : "")
|
|
534
|
+
+ "&code=" + encodeURIComponent(code);
|
|
535
|
+
}
|
|
536
|
+
|
|
452
537
|
async hasCsVersion(system, version) {
|
|
453
538
|
for (let cs of this.codeSystems.values()) {
|
|
454
539
|
if (cs.url == system && cs.version == version) {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SNOMED CT editions FHIRsmith knows by name - the one table, used for the provider's
|
|
3
|
+
* description and name (cs-snomed.js) and for the edition named in unknown-code messages
|
|
4
|
+
* (SCTVersion in workers/worker.js). Keyed by the edition's module id, as it appears in a
|
|
5
|
+
* version URI: http://snomed.info/sct/[module]/version/[date]
|
|
6
|
+
*
|
|
7
|
+
* name - human readable edition name
|
|
8
|
+
* code - short code used in the provider name ("SCT US")
|
|
9
|
+
*
|
|
10
|
+
* (The importer and the registry keep their own tables: those carry import settings and
|
|
11
|
+
* registry codes, not display names.)
|
|
12
|
+
*/
|
|
13
|
+
const EDITIONS = {
|
|
14
|
+
'900000000000207008': { name: 'International Edition', code: 'Intl' },
|
|
15
|
+
// The terminology-ecosystem test distribution, published under xsct - see the
|
|
16
|
+
// tx-ecosystem IG, which requires a server claiming the snomed mode to load it.
|
|
17
|
+
'31000003106': { name: 'Test Edition', code: 'Test' },
|
|
18
|
+
'449081005': { name: 'International Spanish Edition', code: 'es' },
|
|
19
|
+
'11000221109': { name: 'Argentinian Edition', code: 'AR-es' },
|
|
20
|
+
'32506021000036107': { name: 'Australian Edition (with drug extension)', code: 'AU+' },
|
|
21
|
+
'11000234105': { name: 'Austrian Edition', code: 'AT' },
|
|
22
|
+
'11000172109': { name: 'Belgian Edition', code: 'BE' },
|
|
23
|
+
'20621000087109': { name: 'Canadian English Edition', code: 'CA-en' },
|
|
24
|
+
'20611000087101': { name: 'Canadian Canadian French Edition', code: 'CA-fr' },
|
|
25
|
+
'554471000005108': { name: 'Danish Edition', code: 'DK' },
|
|
26
|
+
'11000279109': { name: 'Czech Edition', code: 'CZ' },
|
|
27
|
+
'11000181102': { name: 'Estonian Edition', code: 'ET' },
|
|
28
|
+
'11000229106': { name: 'Finnish Edition', code: 'FI' },
|
|
29
|
+
'11000274103': { name: 'German Edition', code: 'DE' },
|
|
30
|
+
'1121000189102': { name: 'Indian Edition', code: 'IN' },
|
|
31
|
+
'827022005': { name: 'IPS Terminology', code: 'IPS' },
|
|
32
|
+
'11000220105': { name: 'Irish Edition', code: 'IE' },
|
|
33
|
+
'11000146104': { name: 'Netherlands Edition', code: 'NL' },
|
|
34
|
+
'21000210109': { name: 'New Zealand Edition', code: 'NZ' },
|
|
35
|
+
'51000202101': { name: 'Norwegian Edition', code: 'NO' },
|
|
36
|
+
'11000267109': { name: 'Republic of Korea Edition (South Korea)', code: 'KR' },
|
|
37
|
+
'900000001000122104': { name: 'Spanish National Edition', code: 'ES-es' },
|
|
38
|
+
'45991000052106': { name: 'Swedish Edition', code: 'SE' },
|
|
39
|
+
'2011000195101': { name: 'Swiss Edition', code: 'CH' },
|
|
40
|
+
'83821000000107': { name: 'UK Edition', code: 'UK' },
|
|
41
|
+
'999000021000000109': { name: 'UK Clinical Edition', code: 'UK-Clinical' },
|
|
42
|
+
'5631000179106': { name: 'Uruguay Edition', code: 'UY' },
|
|
43
|
+
'731000124108': { name: 'US Edition', code: 'US' },
|
|
44
|
+
'21000325107': { name: 'Chilean Edition', code: 'CL' },
|
|
45
|
+
'5991000124107': { name: 'US Edition (with ICD-10-CM maps)', code: 'US+' }
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} edition - edition module id
|
|
50
|
+
* @returns {string|null} the edition's name, or null if it isn't a known edition
|
|
51
|
+
*/
|
|
52
|
+
function editionName(edition) {
|
|
53
|
+
const e = EDITIONS[edition];
|
|
54
|
+
return e ? e.name : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} edition - edition module id
|
|
59
|
+
* @returns {string|null} the edition's short code, or null if it isn't a known edition
|
|
60
|
+
*/
|
|
61
|
+
function editionCode(edition) {
|
|
62
|
+
const e = EDITIONS[edition];
|
|
63
|
+
return e ? e.code : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
EDITIONS,
|
|
68
|
+
editionName,
|
|
69
|
+
editionCode
|
|
70
|
+
};
|
package/tx/sct/expressions.js
CHANGED
|
@@ -26,6 +26,8 @@ const MRCM_ATTRIBUTE_RANGE_REFSET = 723562003n;
|
|
|
26
26
|
// rules about authoring precoordinated concepts and do not constrain expressions.
|
|
27
27
|
const MRCM_CONTENT_TYPE_ALL = 723596005n;
|
|
28
28
|
const MRCM_CONTENT_TYPE_POSTCOORDINATED = 723594008n;
|
|
29
|
+
// ruleStrengthId value for an optional rule (the other value, 723597001, is mandatory).
|
|
30
|
+
const MRCM_RULE_STRENGTH_OPTIONAL = 723598006n;
|
|
29
31
|
|
|
30
32
|
// Expression status enumeration
|
|
31
33
|
const SnomedExpressionStatus = {
|
|
@@ -44,6 +46,17 @@ class SnomedExpressionBase {
|
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Whether an attribute value has to be written in brackets: the compositional grammar
|
|
51
|
+
* only allows a bare conceptReference as an attribute value, so anything with more than
|
|
52
|
+
* one focus concept, or with refinements of its own, is a bracketed subExpression.
|
|
53
|
+
*/
|
|
54
|
+
function needsBrackets(value) {
|
|
55
|
+
return !!value && ((value.concepts && value.concepts.length > 1) ||
|
|
56
|
+
(value.refinements && value.refinements.length > 0) ||
|
|
57
|
+
(value.refinementGroups && value.refinementGroups.length > 0));
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
/**
|
|
48
61
|
* Represents a SNOMED concept with optional code, description, literal, or decimal value
|
|
49
62
|
*/
|
|
@@ -161,7 +174,9 @@ class SnomedRefinement extends SnomedExpressionBase {
|
|
|
161
174
|
* Get a string description of this refinement
|
|
162
175
|
*/
|
|
163
176
|
describe() {
|
|
164
|
-
|
|
177
|
+
// A refined or conjoined value has to be bracketed - see attributeValue() in the parser
|
|
178
|
+
const v = this.value.describe();
|
|
179
|
+
return this.name.describe() + '=' + (needsBrackets(this.value) ? '(' + v + ')' : v);
|
|
165
180
|
}
|
|
166
181
|
|
|
167
182
|
/**
|
|
@@ -647,16 +662,33 @@ class SnomedExpressionParser {
|
|
|
647
662
|
|
|
648
663
|
/**
|
|
649
664
|
* Parse an attribute value
|
|
665
|
+
*
|
|
666
|
+
* The compositional grammar ABNF is
|
|
667
|
+
* attributeValue = expressionValue / QM stringValue QM / "#" numericValue / booleanValue
|
|
668
|
+
* expressionValue = ws ( conceptReference / "(" ws subExpression ws ")" ) ws
|
|
669
|
+
* so a value that is anything more than a single concept - a refined concept, or a
|
|
670
|
+
* conjunction - must be in brackets. This parser used to take an unbracketed
|
|
671
|
+
* sub-expression too (367430006:{405813007=85562004:272741003=24028007}), but that is not
|
|
672
|
+
* just non-conformant, it is ambiguous: in A:{B=C:D=E,F=G} nothing says whether F=G
|
|
673
|
+
* refines C or A, and the old parser silently picked C. Other parsers reject it, so we
|
|
674
|
+
* do too, and the renderer brackets nested values so that what we emit re-parses anywhere.
|
|
650
675
|
*/
|
|
651
676
|
attributeValue() {
|
|
652
677
|
this.ws();
|
|
653
678
|
|
|
654
679
|
if (this.gchar('(')) {
|
|
655
680
|
const result = this.expression();
|
|
681
|
+
this.ws();
|
|
656
682
|
this.fixed(')');
|
|
657
683
|
return result;
|
|
658
684
|
} else {
|
|
659
|
-
|
|
685
|
+
const result = new SnomedExpression();
|
|
686
|
+
result.start = this.cursor;
|
|
687
|
+
result.concepts.push(this.concept());
|
|
688
|
+
this.rule(this.peek() !== ':' && this.peek() !== '+',
|
|
689
|
+
'A refined or conjoined attribute value must be enclosed in brackets, e.g. 405813007=(85562004:272741003=24028007) (in "' + this.source + '")');
|
|
690
|
+
result.stop = this.cursor;
|
|
691
|
+
return result;
|
|
660
692
|
}
|
|
661
693
|
}
|
|
662
694
|
|
|
@@ -1625,7 +1657,7 @@ class SnomedExpressionServices {
|
|
|
1625
1657
|
this.checkRefinement(refinement);
|
|
1626
1658
|
this.checkRefinementDomain(expression, refinement);
|
|
1627
1659
|
this.checkRefinementRange(refinement);
|
|
1628
|
-
this.checkRefinementGrouped(refinement);
|
|
1660
|
+
this.checkRefinementGrouped(expression, refinement);
|
|
1629
1661
|
}
|
|
1630
1662
|
}
|
|
1631
1663
|
|
|
@@ -1644,12 +1676,8 @@ class SnomedExpressionServices {
|
|
|
1644
1676
|
|
|
1645
1677
|
/**
|
|
1646
1678
|
* Check one refinement against the MRCM attribute domain reference set: the
|
|
1647
|
-
* focus concept the attribute is applied to must be in the attribute's
|
|
1648
|
-
*
|
|
1649
|
-
* Every applicable rule must hold, mandatory or optional. Internationally
|
|
1650
|
-
* there is exactly one optional rule - Laterality in the lateralizable body
|
|
1651
|
-
* structure reference set - and it is the rule that stops laterality being
|
|
1652
|
-
* applied to a structure that cannot be lateralized, so it is enforced.
|
|
1679
|
+
* focus concept the attribute is applied to must be in one of the attribute's
|
|
1680
|
+
* domains (see applicableDomainRules for how several rules combine).
|
|
1653
1681
|
*
|
|
1654
1682
|
* Attributes with no rule in this edition's MRCM are not checked, and neither
|
|
1655
1683
|
* is cardinality (checkExpressionCardinality), grouping (checkRefinementGrouped)
|
|
@@ -1663,44 +1691,84 @@ class SnomedExpressionServices {
|
|
|
1663
1691
|
if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
|
|
1664
1692
|
return;
|
|
1665
1693
|
}
|
|
1666
|
-
const
|
|
1667
|
-
if (
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1694
|
+
const result = this.applicableDomainRules(expression, attribute);
|
|
1695
|
+
if (result.failed) {
|
|
1696
|
+
const attrDesc = this.describeConceptForMessage(attribute);
|
|
1697
|
+
const focusDesc = result.focus.map(f => this.describeConceptForMessage(f)).join(' + ');
|
|
1698
|
+
throw new Error(`The SNOMED CT concept model does not allow the attribute ${attrDesc} on ${focusDesc}`
|
|
1699
|
+
+ ` (the attribute is only valid ${result.failed})`);
|
|
1672
1700
|
}
|
|
1701
|
+
}
|
|
1673
1702
|
|
|
1674
|
-
|
|
1675
|
-
|
|
1703
|
+
/**
|
|
1704
|
+
* Work out which of an attribute's MRCM domain rules apply to an expression's
|
|
1705
|
+
* focus concept(s), and whether the attribute is allowed there at all.
|
|
1706
|
+
*
|
|
1707
|
+
* An attribute has one domain rule per domain it may be used in, so the
|
|
1708
|
+
* mandatory rules are ALTERNATIVES: |Due to| has one rule for |Clinical finding|
|
|
1709
|
+
* and one for |Event|, and is valid on a concept in either (issue #287 - these
|
|
1710
|
+
* were once ANDed, which rejected |Due to| everywhere).
|
|
1711
|
+
*
|
|
1712
|
+
* Optional rules are RESTRICTIONS layered on top: internationally the only one
|
|
1713
|
+
* is |Laterality| for the lateralizable body structure reference set, which
|
|
1714
|
+
* narrows the mandatory |Anatomical structure| rule, and it is what stops
|
|
1715
|
+
* laterality being applied to a structure that cannot be lateralized. So an
|
|
1716
|
+
* optional rule must also hold wherever a mandatory rule has admitted the
|
|
1717
|
+
* attribute. (An attribute with only optional rules treats them as
|
|
1718
|
+
* alternatives, since there is nothing for them to narrow.)
|
|
1719
|
+
*
|
|
1720
|
+
* The focus may be a conjunction of concepts; the expression is a subtype of
|
|
1721
|
+
* each of them, so a rule holds if it holds for any focus concept.
|
|
1722
|
+
*
|
|
1723
|
+
* @param {SnomedExpression} expression
|
|
1724
|
+
* @param {number} attribute - concept index of the attribute
|
|
1725
|
+
* @returns {{rules: Array, focus: number[], failed: string|null}} the rules that
|
|
1726
|
+
* apply (for cardinality and grouping - empty when the attribute is out of
|
|
1727
|
+
* domain), the focus concepts, and when the attribute is not allowed on this
|
|
1728
|
+
* focus, a description of the domain(s) it is restricted to
|
|
1729
|
+
*/
|
|
1730
|
+
applicableDomainRules(expression, attribute) {
|
|
1731
|
+
const rules = this.mrcmAttributeDomains().get(attribute);
|
|
1676
1732
|
const focus = [];
|
|
1677
1733
|
for (const concept of expression.concepts) {
|
|
1678
1734
|
if (concept.reference !== undefined && concept.reference !== null && concept.reference !== NO_REFERENCE) {
|
|
1679
1735
|
focus.push(concept.reference);
|
|
1680
1736
|
}
|
|
1681
1737
|
}
|
|
1682
|
-
if (focus.length === 0) {
|
|
1683
|
-
|
|
1738
|
+
if (!rules || rules.length === 0 || focus.length === 0) {
|
|
1739
|
+
// This edition's concept model says nothing about this attribute (3 active
|
|
1740
|
+
// attributes internationally have no domain rule and appear in no
|
|
1741
|
+
// postcoordination domain template), so there is nothing to check it against.
|
|
1742
|
+
return { rules: rules || [], focus, failed: null };
|
|
1684
1743
|
}
|
|
1685
1744
|
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
const
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1745
|
+
const holds = rule => focus.some(f => rule.isRefset
|
|
1746
|
+
? this.conceptInRefSet(rule.domain, f)
|
|
1747
|
+
: this.subsumes(rule.domain, f));
|
|
1748
|
+
const describe = rule => (rule.isRefset ? 'for concepts in ' : 'in the domain ')
|
|
1749
|
+
+ this.describeConceptForMessage(rule.domain);
|
|
1750
|
+
|
|
1751
|
+
const mandatory = rules.filter(rule => !rule.optional);
|
|
1752
|
+
const alternatives = mandatory.length > 0 ? mandatory : rules;
|
|
1753
|
+
const restrictions = mandatory.length > 0 ? rules.filter(rule => rule.optional) : [];
|
|
1754
|
+
|
|
1755
|
+
const matched = alternatives.filter(holds);
|
|
1756
|
+
const failedRestrictions = restrictions.filter(rule => !holds(rule));
|
|
1757
|
+
if (matched.length === 0) {
|
|
1758
|
+
// Name every domain the attribute could have been used in, and any
|
|
1759
|
+
// restriction that also fails: a laterality on a disorder is outside both
|
|
1760
|
+
// the anatomical structure domain and the lateralizable refset, and naming
|
|
1761
|
+
// only one of them misleads.
|
|
1762
|
+
const parts = [alternatives.map(describe).join(', or ')];
|
|
1763
|
+
for (const rule of failedRestrictions) {
|
|
1764
|
+
parts.push(describe(rule));
|
|
1696
1765
|
}
|
|
1766
|
+
return { rules: [], focus, failed: parts.join(', and ') };
|
|
1697
1767
|
}
|
|
1698
|
-
if (
|
|
1699
|
-
|
|
1700
|
-
const focusDesc = focus.map(f => this.describeConceptForMessage(f)).join(' + ');
|
|
1701
|
-
throw new Error(`The SNOMED CT concept model does not allow the attribute ${attrDesc} on ${focusDesc}`
|
|
1702
|
-
+ ` (the attribute is only valid ${failed.join(', and ')})`);
|
|
1768
|
+
if (failedRestrictions.length > 0) {
|
|
1769
|
+
return { rules: [], focus, failed: failedRestrictions.map(describe).join(', and ') };
|
|
1703
1770
|
}
|
|
1771
|
+
return { rules: matched.concat(restrictions), focus, failed: null };
|
|
1704
1772
|
}
|
|
1705
1773
|
|
|
1706
1774
|
/**
|
|
@@ -1721,17 +1789,19 @@ class SnomedExpressionServices {
|
|
|
1721
1789
|
* expression is not classifiable is honest, quietly guessing what they meant is not.
|
|
1722
1790
|
*
|
|
1723
1791
|
* As elsewhere in this check, an attribute with no rule in this edition's MRCM is
|
|
1724
|
-
* left alone
|
|
1792
|
+
* left alone. Only the rules for the domain(s) the focus concept is in count
|
|
1793
|
+
* (see applicableDomainRules), and where several of those apply the strictest wins.
|
|
1725
1794
|
*
|
|
1795
|
+
* @param {SnomedExpression} expression - the expression the refinement is on
|
|
1726
1796
|
* @param {SnomedRefinement} refinement - a refinement outside any group
|
|
1727
1797
|
*/
|
|
1728
|
-
checkRefinementGrouped(refinement) {
|
|
1798
|
+
checkRefinementGrouped(expression, refinement) {
|
|
1729
1799
|
const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
|
|
1730
1800
|
if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
|
|
1731
1801
|
return;
|
|
1732
1802
|
}
|
|
1733
|
-
const rules = this.
|
|
1734
|
-
if (
|
|
1803
|
+
const rules = this.applicableDomainRules(expression, attribute).rules;
|
|
1804
|
+
if (rules.length === 0) {
|
|
1735
1805
|
return;
|
|
1736
1806
|
}
|
|
1737
1807
|
if (!rules.some(rule => rule.grouped)) {
|
|
@@ -1747,7 +1817,7 @@ class SnomedExpressionServices {
|
|
|
1747
1817
|
* Built once per edition and cached; an edition with no MRCM yields an empty
|
|
1748
1818
|
* map, which turns the domain check off rather than failing.
|
|
1749
1819
|
*
|
|
1750
|
-
* @returns {Map<number, Array<{domain: number, isRefset: boolean, grouped: boolean, max: number, maxInGroup: number}>>}
|
|
1820
|
+
* @returns {Map<number, Array<{domain: number, isRefset: boolean, grouped: boolean, optional: boolean, max: number, maxInGroup: number}>>}
|
|
1751
1821
|
*/
|
|
1752
1822
|
mrcmAttributeDomains() {
|
|
1753
1823
|
if (this._mrcmAttributeDomains) {
|
|
@@ -1762,6 +1832,7 @@ class SnomedExpressionServices {
|
|
|
1762
1832
|
}
|
|
1763
1833
|
const contentAll = this.conceptIndexOf(MRCM_CONTENT_TYPE_ALL);
|
|
1764
1834
|
const contentPost = this.conceptIndexOf(MRCM_CONTENT_TYPE_POSTCOORDINATED);
|
|
1835
|
+
const strengthOptional = this.conceptIndexOf(MRCM_RULE_STRENGTH_OPTIONAL);
|
|
1765
1836
|
|
|
1766
1837
|
// Every concept that defines a reference set, so a domain that is itself a
|
|
1767
1838
|
// reference set (the lateralizable body structure refset) is tested by
|
|
@@ -1804,6 +1875,9 @@ class SnomedExpressionServices {
|
|
|
1804
1875
|
// The grouped column is a plain 0/1 (type 4), not a reference into the string
|
|
1805
1876
|
// table like the cardinalities beside it - reading it as one yields garbage.
|
|
1806
1877
|
grouped: values[2] === 1,
|
|
1878
|
+
// An optional rule narrows a mandatory one rather than adding a domain
|
|
1879
|
+
// (see applicableDomainRules).
|
|
1880
|
+
optional: values[8] === strengthOptional,
|
|
1807
1881
|
// Only the maximums are enforced. A minimum ("1..1") says a concept in
|
|
1808
1882
|
// this domain must have the attribute in its own definition; it does not
|
|
1809
1883
|
// say a refinement of that concept has to restate it.
|
|
@@ -2033,8 +2107,10 @@ class SnomedExpressionServices {
|
|
|
2033
2107
|
* a single relationship group - a maximum of 0 there is how the MRCM says an
|
|
2034
2108
|
* attribute is ungrouped (all 37 ungrouped attributes carry "0..0").
|
|
2035
2109
|
*
|
|
2036
|
-
*
|
|
2037
|
-
*
|
|
2110
|
+
* Only the rules for the domain(s) the focus concept is in count - |Procedure
|
|
2111
|
+
* device| is 0..* in a group on a procedure but 0..1 on an observable, and a
|
|
2112
|
+
* procedure must not get the observable's limit (see applicableDomainRules).
|
|
2113
|
+
* Where several of those rules apply, the tightest maximum wins. Nested value expressions are checked separately, when
|
|
2038
2114
|
* checkRefinement recurses into them.
|
|
2039
2115
|
*
|
|
2040
2116
|
* @param {SnomedExpression} expression
|
|
@@ -2072,10 +2148,14 @@ class SnomedExpressionServices {
|
|
|
2072
2148
|
perGroup.push(counts);
|
|
2073
2149
|
}
|
|
2074
2150
|
|
|
2151
|
+
const applicable = new Map();
|
|
2075
2152
|
const limit = (attribute, field) => {
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2153
|
+
if (!applicable.has(attribute)) {
|
|
2154
|
+
applicable.set(attribute, this.applicableDomainRules(expression, attribute).rules);
|
|
2155
|
+
}
|
|
2156
|
+
const rules = applicable.get(attribute);
|
|
2157
|
+
if (rules.length === 0) {
|
|
2158
|
+
return Infinity; // no rule, or out of domain (which checkRefinementDomain reports)
|
|
2079
2159
|
}
|
|
2080
2160
|
return rules.reduce((least, rule) => Math.min(least, rule[field]), Infinity);
|
|
2081
2161
|
};
|
|
@@ -2529,7 +2609,11 @@ class SnomedExpressionServices {
|
|
|
2529
2609
|
renderRefinement(parts, expr, option) {
|
|
2530
2610
|
this.renderConcept(parts, expr.name, option);
|
|
2531
2611
|
parts.push('=');
|
|
2612
|
+
// a refined or conjoined value must be bracketed, or no conformant parser will read it back
|
|
2613
|
+
const bracket = needsBrackets(expr.value);
|
|
2614
|
+
if (bracket) parts.push('(');
|
|
2532
2615
|
this.renderExpressionParts(parts, expr.value, option);
|
|
2616
|
+
if (bracket) parts.push(')');
|
|
2533
2617
|
}
|
|
2534
2618
|
|
|
2535
2619
|
getDisplayName(conceptIdOrReference) {
|