fhirsmith 0.11.2 → 0.12.0
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 +72 -2
- package/config-template.json +18 -1
- package/package.json +1 -1
- package/publisher/publisher.js +153 -5
- package/publisher/validation.js +198 -0
- package/server.js +12 -12
- package/stats-db.js +127 -0
- package/stats.js +172 -6
- package/translations/Messages.properties +4 -1
- package/tx/cs/cs-api.md +1 -1
- package/tx/cs/cs-snomed.js +103 -54
- package/tx/library/conceptmap.js +39 -0
- package/tx/library/parameter-check.js +139 -0
- package/tx/operation-context.js +220 -10
- package/tx/params.js +226 -175
- package/tx/sct/expressions.js +485 -0
- package/tx/tx.fhir.org.yml +1 -1
- package/tx/tx.js +57 -50
- package/tx/workers/batch-validate.js +5 -2
- package/tx/workers/cache-control.js +82 -6
- package/tx/workers/expand.js +80 -23
- package/tx/workers/lookup.js +14 -1
- package/tx/workers/translate.js +461 -30
- package/tx/workers/validate.js +7 -1
- package/tx/workers/worker.js +22 -10
- package/tx/xml/xml-base.js +21 -1
- package/tx/xversion/xv-conceptmap.js +81 -0
- package/tx/xversion/xv-parameters.js +8 -0
- package/utilities/merge-audit.py +178 -0
- package/utilities/why-merge-audit.md +39 -0
- package/tx/data/snomed-subset.txt +0 -84
package/tx/workers/translate.js
CHANGED
|
@@ -12,8 +12,19 @@ const { TxParameters } = require('../params');
|
|
|
12
12
|
const { Parameters } = require('../library/parameters');
|
|
13
13
|
const { Issue, OperationOutcome } = require('../library/operation-outcome');
|
|
14
14
|
const {ConceptMap} = require("../library/conceptmap");
|
|
15
|
+
const {Extensions} = require("../library/extensions");
|
|
16
|
+
const {VersionUtilities} = require("../../library/version-utilities");
|
|
15
17
|
const {debugLog} = require("../operation-context");
|
|
16
18
|
|
|
19
|
+
// ConceptMap.group.element.comment is R6; on an R5 ConceptMap it is carried by the
|
|
20
|
+
// cross-version extension. Read both, so a preadopted R5 resource and a native R6 one
|
|
21
|
+
// behave the same.
|
|
22
|
+
const EXT_ELEMENT_COMMENT = 'http://hl7.org/fhir/6.0/StructureDefinition/extension-ConceptMap.group.element.comment';
|
|
23
|
+
|
|
24
|
+
function elementComment(em) {
|
|
25
|
+
return em.comment || Extensions.readString(em, EXT_ELEMENT_COMMENT);
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
class TranslateWorker extends TerminologyWorker {
|
|
18
29
|
/**
|
|
19
30
|
* @param {OperationContext} opContext - Operation context
|
|
@@ -114,6 +125,7 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
114
125
|
let sourceScope = null;
|
|
115
126
|
let targetSystem = null;
|
|
116
127
|
let reverse = false;
|
|
128
|
+
let reverseFromTargetConcept = false; // R5 style: the target concept was named directly
|
|
117
129
|
|
|
118
130
|
// Get the source coding
|
|
119
131
|
// Accept both R5 names (sourceCoding, sourceCodeableConcept, sourceCode/sourceSystem)
|
|
@@ -149,9 +161,11 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
149
161
|
coding = {system, version, code};
|
|
150
162
|
} else if (params.has('targetCoding')) {
|
|
151
163
|
reverse = true;
|
|
164
|
+
reverseFromTargetConcept = true;
|
|
152
165
|
coding = params.get('targetCoding');
|
|
153
166
|
} else if (params.has('targetCodeableConcept')) {
|
|
154
167
|
reverse = true;
|
|
168
|
+
reverseFromTargetConcept = true;
|
|
155
169
|
const cc = params.get('targetCodeableConcept');
|
|
156
170
|
if (cc.coding && cc.coding.length > 0) {
|
|
157
171
|
coding = cc.coding[0]; // Use first coding
|
|
@@ -159,10 +173,12 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
159
173
|
throw new Issue('error', 'invalid', null, null,
|
|
160
174
|
'sourceCodeableConcept must contain at least one coding', null, 400);
|
|
161
175
|
}
|
|
162
|
-
} else if (params.has('targetCode')) {
|
|
176
|
+
} else if (params.has('targetCode') || params.has('targetcode')) {
|
|
177
|
+
// targetcode is accepted alongside targetCode, as targetsystem is alongside targetSystem
|
|
163
178
|
reverse = true;
|
|
164
|
-
|
|
165
|
-
const
|
|
179
|
+
reverseFromTargetConcept = true;
|
|
180
|
+
const code = params.has('targetCode') ? params.get('targetCode') : params.get('targetcode');
|
|
181
|
+
const system = params.has('targetSystem') ? params.get('targetSystem') : params.get('targetsystem');
|
|
166
182
|
if (!system) {
|
|
167
183
|
throw new Issue('error', 'invalid', null, null,
|
|
168
184
|
'targetSystem parameter is required when using targetCode', null, 400);
|
|
@@ -178,7 +194,7 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
178
194
|
if (params.has('url')) {
|
|
179
195
|
const url = params.get('url');
|
|
180
196
|
const cmVersion = params.get('conceptMapVersion');
|
|
181
|
-
let conceptMap = await this.
|
|
197
|
+
let conceptMap = await this.findConceptMapByUrl(url, cmVersion);
|
|
182
198
|
if (!conceptMap) {
|
|
183
199
|
const msg = cmVersion
|
|
184
200
|
? `ConceptMap not found: ${url} version ${cmVersion}`
|
|
@@ -189,20 +205,61 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
189
205
|
}
|
|
190
206
|
}
|
|
191
207
|
|
|
192
|
-
// Get scope parameters
|
|
208
|
+
// Get scope parameters. As with the source concept above, the R4 names are
|
|
209
|
+
// accepted as aliases for the R5 ones: R4 calls the scopes 'source' and
|
|
210
|
+
// 'target', and spells the target system 'targetsystem' (all lower case).
|
|
193
211
|
if (params.has('sourceScope')) {
|
|
194
212
|
sourceScope = params.get('sourceScope');
|
|
213
|
+
} else if (params.has('source')) {
|
|
214
|
+
sourceScope = params.get('source');
|
|
195
215
|
}
|
|
196
216
|
if (params.has('targetScope')) {
|
|
197
217
|
targetScope = params.get('targetScope');
|
|
218
|
+
} else if (params.has('target')) {
|
|
219
|
+
targetScope = params.get('target');
|
|
220
|
+
}
|
|
221
|
+
if (params.has('targetSystem')) {
|
|
222
|
+
targetSystem = params.get('targetSystem');
|
|
223
|
+
} else if (params.has('targetsystem')) {
|
|
224
|
+
targetSystem = params.get('targetsystem');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// R4's 'reverse' parameter: "this parameter reverses the meaning of the source and
|
|
228
|
+
// target parameters". So do literally that - swap the two sides here, once the
|
|
229
|
+
// parameters have all been read, and let the rest of the operation run as usual.
|
|
230
|
+
// R5+ dropped it in favour of naming the target concept directly (targetCode,
|
|
231
|
+
// targetCoding, targetCodeableConcept), so there it is an error rather than
|
|
232
|
+
// something to interpret.
|
|
233
|
+
if (params.has('reverse')) {
|
|
234
|
+
const fhirVersion = this.provider.getFhirVersion();
|
|
235
|
+
if (!VersionUtilities.isR4Ver(fhirVersion) && !VersionUtilities.isR3Ver(fhirVersion)) {
|
|
236
|
+
throw new Issue('error', 'not-supported', null, null,
|
|
237
|
+
`The 'reverse' parameter is not defined in ${fhirVersion}: name the target concept with targetCode, targetCoding or targetCodeableConcept instead`, null, 400);
|
|
238
|
+
}
|
|
239
|
+
const rev = params.get('reverse');
|
|
240
|
+
if (rev === true || rev === 'true') {
|
|
241
|
+
if (reverseFromTargetConcept) {
|
|
242
|
+
throw new Issue('error', 'invalid', null, null,
|
|
243
|
+
"The 'reverse' parameter cannot be combined with targetCode, targetCoding or targetCodeableConcept", null, 400);
|
|
244
|
+
}
|
|
245
|
+
reverse = true;
|
|
246
|
+
// the concept named by system/code is now the target concept, the two value set
|
|
247
|
+
// scopes swap roles, and targetsystem names the system the answers come from
|
|
248
|
+
const scope = sourceScope;
|
|
249
|
+
sourceScope = targetScope;
|
|
250
|
+
targetScope = scope;
|
|
251
|
+
}
|
|
198
252
|
}
|
|
199
|
-
|
|
253
|
+
|
|
254
|
+
// The R5-shaped reverse names the target concept directly; the source system then
|
|
255
|
+
// says which system the answers should come from. R5 spells that 'system' (it is
|
|
256
|
+
// the system of the source concept); 'sourceSystem' is accepted as an alias, as it
|
|
257
|
+
// is for the forward direction.
|
|
258
|
+
if (reverseFromTargetConcept) {
|
|
200
259
|
if (params.has('sourceSystem')) {
|
|
201
260
|
targetSystem = params.get('sourceSystem');
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (params.has('targetSystem')) {
|
|
205
|
-
targetSystem = params.get('targetSystem');
|
|
261
|
+
} else if (params.has('system')) {
|
|
262
|
+
targetSystem = params.get('system');
|
|
206
263
|
}
|
|
207
264
|
}
|
|
208
265
|
// If no explicit concept map, we need to find one based on source/target
|
|
@@ -215,7 +272,23 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
215
272
|
await this.provider.findConceptMapForTranslation(this.opContext, conceptMaps, coding.system, sourceScope, targetScope, targetSystem, coding.code);
|
|
216
273
|
}
|
|
217
274
|
if (conceptMaps.length == 0) {
|
|
218
|
-
|
|
275
|
+
// The client did not nominate a map, and this server knows none that covers this
|
|
276
|
+
// source and target. That is an answer - there is no translation - not an error;
|
|
277
|
+
// a nominated map that cannot be found is the error case, and is reported when
|
|
278
|
+
// the url parameter is resolved above.
|
|
279
|
+
const from = reverse ? (targetSystem || sourceScope) : coding.system;
|
|
280
|
+
const to = reverse ? coding.system : (targetSystem || targetScope);
|
|
281
|
+
return res.status(200).json({
|
|
282
|
+
resourceType: 'Parameters',
|
|
283
|
+
parameter: [{
|
|
284
|
+
name: 'message',
|
|
285
|
+
valueString: `No ConceptMap is available to translate from '${from || '(unspecified)'}' to '${to || '(unspecified)'}'`
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
name: 'result',
|
|
289
|
+
valueBoolean: false
|
|
290
|
+
}]
|
|
291
|
+
});
|
|
219
292
|
}
|
|
220
293
|
}
|
|
221
294
|
|
|
@@ -320,22 +393,64 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
320
393
|
return result;
|
|
321
394
|
}
|
|
322
395
|
|
|
323
|
-
translateUsingGroupsForwards(cm, coding, targetScope, targetSystem, params, output) {
|
|
396
|
+
async translateUsingGroupsForwards(cm, coding, targetScope, targetSystem, params, output, origin, visited) {
|
|
324
397
|
let result = false;
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
398
|
+
// originMap names where the chain of maps started, not the map that happened to
|
|
399
|
+
// supply the answer; any other map consulted along the way is a used-conceptmap.
|
|
400
|
+
const originMap = origin || cm.vurl;
|
|
401
|
+
// Work group by group. group.unmapped says what this group does with a concept it
|
|
402
|
+
// has no element for, so it is decided per group: a mapping in one group does not
|
|
403
|
+
// speak for another group, and does not suppress another group's fallback.
|
|
404
|
+
for (const g of cm.listGroupsInScope(coding, targetScope, targetSystem)) {
|
|
405
|
+
let groupResult = false;
|
|
406
|
+
for (const em of (g.element || []).filter(e => e.code === coding.code)) {
|
|
407
|
+
// ConceptMap.group.element.noMap says, positively, that this concept has no
|
|
408
|
+
// mapping in this map - which is an answer, not the absence of one. Report it
|
|
409
|
+
// as a match that states the source concept and the map it came from, with no
|
|
410
|
+
// concept and no relationship, and nothing to say about a target.
|
|
411
|
+
if (em.noMap === true) {
|
|
412
|
+
groupResult = true;
|
|
413
|
+
if (this.hasNoMap(output, g.source, em.code)) {
|
|
414
|
+
continue; // another map has already said this; saying it twice adds nothing
|
|
415
|
+
}
|
|
416
|
+
const noMapParts = [];
|
|
417
|
+
noMapParts.push({
|
|
418
|
+
name: 'noMap',
|
|
419
|
+
valueBoolean: true
|
|
420
|
+
});
|
|
421
|
+
const noMapComment = elementComment(em);
|
|
422
|
+
if (noMapComment) {
|
|
423
|
+
noMapParts.push({
|
|
424
|
+
name: 'sourceComment',
|
|
425
|
+
valueString: noMapComment
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
noMapParts.push({
|
|
429
|
+
name: 'sourceConcept',
|
|
430
|
+
valueCoding: {
|
|
431
|
+
system: g.source,
|
|
432
|
+
code: em.code
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
noMapParts.push({
|
|
436
|
+
name: 'originMap',
|
|
437
|
+
valueCanonical: originMap
|
|
438
|
+
});
|
|
439
|
+
output.push({
|
|
440
|
+
name: 'match',
|
|
441
|
+
part: noMapParts
|
|
442
|
+
});
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
330
445
|
for (const map of em.target || []) {
|
|
331
446
|
let ok = false;
|
|
332
447
|
if (map.equivalence) { // R4 mode
|
|
333
|
-
ok = ['null', 'relatedto', 'equivalent', 'equal', 'wider', 'subsumes', 'narrower', 'specializes', 'inexact'].includes(map.equivalence);
|
|
448
|
+
ok = ['null', 'relatedto', 'equivalent', 'equal', 'wider', 'subsumes', 'narrower', 'specializes', 'inexact', 'unmatched', 'disjoint'].includes(map.equivalence);
|
|
334
449
|
} else {
|
|
335
|
-
ok = ['null', 'related-to', 'equivalent', 'source-is-narrower-than-target', 'source-is-broader-than-target'].includes(map.relationship);
|
|
450
|
+
ok = ['null', 'related-to', 'equivalent', 'source-is-narrower-than-target', 'source-is-broader-than-target', 'not-related-to'].includes(map.relationship);
|
|
336
451
|
}
|
|
337
452
|
if (ok) {
|
|
338
|
-
|
|
453
|
+
groupResult = true;
|
|
339
454
|
|
|
340
455
|
const outcome = {
|
|
341
456
|
system: g.target,
|
|
@@ -348,6 +463,13 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
348
463
|
name: 'concept',
|
|
349
464
|
valueCoding: outcome
|
|
350
465
|
});
|
|
466
|
+
matchParts.push({
|
|
467
|
+
name: 'sourceConcept',
|
|
468
|
+
valueCoding: {
|
|
469
|
+
system: g.source,
|
|
470
|
+
code: em.code
|
|
471
|
+
}
|
|
472
|
+
});
|
|
351
473
|
matchParts.push({
|
|
352
474
|
name: 'relationship',
|
|
353
475
|
valueCode: map.relationship
|
|
@@ -359,9 +481,18 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
359
481
|
valueCode: map.equivalence
|
|
360
482
|
});
|
|
361
483
|
}
|
|
484
|
+
// ConceptMap.group.element.comment is about the source concept,
|
|
485
|
+
// ConceptMap.group.element.target.comment is about this particular map
|
|
486
|
+
const srcComment = elementComment(em);
|
|
487
|
+
if (srcComment) {
|
|
488
|
+
matchParts.push({
|
|
489
|
+
name: 'sourceComment',
|
|
490
|
+
valueString: srcComment
|
|
491
|
+
});
|
|
492
|
+
}
|
|
362
493
|
if (map.comment) {
|
|
363
494
|
matchParts.push({
|
|
364
|
-
name: '
|
|
495
|
+
name: 'targetComment',
|
|
365
496
|
valueString: map.comment
|
|
366
497
|
});
|
|
367
498
|
}
|
|
@@ -385,7 +516,7 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
385
516
|
}
|
|
386
517
|
matchParts.push({
|
|
387
518
|
name: 'originMap',
|
|
388
|
-
valueCanonical:
|
|
519
|
+
valueCanonical: originMap
|
|
389
520
|
});
|
|
390
521
|
output.push({
|
|
391
522
|
name: 'match',
|
|
@@ -395,6 +526,11 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
395
526
|
}
|
|
396
527
|
}
|
|
397
528
|
}
|
|
529
|
+
if (!groupResult) {
|
|
530
|
+
groupResult = await this.translateUsingUnmapped(cm, g, coding, targetScope, targetSystem, params, output,
|
|
531
|
+
visited || new Set([cm.vurl]), originMap);
|
|
532
|
+
}
|
|
533
|
+
result = groupResult || result;
|
|
398
534
|
}
|
|
399
535
|
return result;
|
|
400
536
|
}
|
|
@@ -409,9 +545,9 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
409
545
|
const map = match.target;
|
|
410
546
|
let ok = false;
|
|
411
547
|
if (map.equivalence) { // R4 mode
|
|
412
|
-
ok = ['null', 'relatedto', 'equivalent', 'equal', 'wider', 'subsumes', 'narrower', 'specializes', 'inexact'].includes(map.equivalence);
|
|
548
|
+
ok = ['null', 'relatedto', 'equivalent', 'equal', 'wider', 'subsumes', 'narrower', 'specializes', 'inexact', 'unmatched', 'disjoint'].includes(map.equivalence);
|
|
413
549
|
} else {
|
|
414
|
-
ok = ['null', 'related-to', 'equivalent', 'source-is-narrower-than-target', 'source-is-broader-than-target'].includes(map.relationship);
|
|
550
|
+
ok = ['null', 'related-to', 'equivalent', 'source-is-narrower-than-target', 'source-is-broader-than-target', 'not-related-to'].includes(map.relationship);
|
|
415
551
|
}
|
|
416
552
|
if (ok) {
|
|
417
553
|
result = true;
|
|
@@ -428,7 +564,7 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
428
564
|
if (!this.hasMatch(output, outcome)) {
|
|
429
565
|
const matchParts = [];
|
|
430
566
|
matchParts.push({
|
|
431
|
-
name: '
|
|
567
|
+
name: 'sourceConcept',
|
|
432
568
|
valueCoding: outcome
|
|
433
569
|
});
|
|
434
570
|
matchParts.push({
|
|
@@ -446,9 +582,18 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
446
582
|
valueCode: map.equivalence
|
|
447
583
|
});
|
|
448
584
|
}
|
|
585
|
+
// as above: element.comment describes the source concept, and
|
|
586
|
+
// element.target.comment describes this particular map
|
|
587
|
+
const srcComment = elementComment(em);
|
|
588
|
+
if (srcComment) {
|
|
589
|
+
matchParts.push({
|
|
590
|
+
name: 'sourceComment',
|
|
591
|
+
valueString: srcComment
|
|
592
|
+
});
|
|
593
|
+
}
|
|
449
594
|
if (map.comment) {
|
|
450
595
|
matchParts.push({
|
|
451
|
-
name: '
|
|
596
|
+
name: 'targetComment',
|
|
452
597
|
valueString: map.comment
|
|
453
598
|
});
|
|
454
599
|
}
|
|
@@ -485,6 +630,218 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
485
630
|
return result;
|
|
486
631
|
}
|
|
487
632
|
|
|
633
|
+
/**
|
|
634
|
+
* Order the candidate maps for translation. url and version play no part:
|
|
635
|
+
*
|
|
636
|
+
* - dependency first: a map that defers to another (group.unmapped.otherMap) is
|
|
637
|
+
* consulted before the map it defers to
|
|
638
|
+
* - then by date, most recent first, with dateless maps last
|
|
639
|
+
*
|
|
640
|
+
* A dependency edge only counts when the map it names is itself a candidate. Any map
|
|
641
|
+
* caught in a dependency cycle keeps its place at the end, in date order - the cycle
|
|
642
|
+
* itself is reported when the translation actually follows it.
|
|
643
|
+
* @param {ConceptMap[]} conceptMaps
|
|
644
|
+
* @returns {ConceptMap[]} a new, ordered array
|
|
645
|
+
*/
|
|
646
|
+
sortConceptMaps(conceptMaps) {
|
|
647
|
+
if (!conceptMaps || conceptMaps.length < 2) {
|
|
648
|
+
return conceptMaps; // nothing to decide
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const dateOf = (cm) => {
|
|
652
|
+
const d = cm.jsonObj.date;
|
|
653
|
+
if (!d) {
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
const t = Date.parse(d);
|
|
657
|
+
return isNaN(t) ? null : t; // an unusable date is treated as no date
|
|
658
|
+
};
|
|
659
|
+
const byDate = (a, b) => {
|
|
660
|
+
const da = dateOf(a), db = dateOf(b);
|
|
661
|
+
if (da === null && db === null) return 0; // stable: input order is kept
|
|
662
|
+
if (da === null) return 1;
|
|
663
|
+
if (db === null) return -1;
|
|
664
|
+
return db - da;
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// a dependency can name its target with or without a version
|
|
668
|
+
const byRef = new Map();
|
|
669
|
+
for (const cm of conceptMaps) {
|
|
670
|
+
if (cm.url && !byRef.has(cm.url)) {
|
|
671
|
+
byRef.set(cm.url, cm);
|
|
672
|
+
}
|
|
673
|
+
if (cm.vurl) {
|
|
674
|
+
byRef.set(cm.vurl, cm);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const dependsOn = new Map();
|
|
679
|
+
const waitingOn = new Map();
|
|
680
|
+
for (const cm of conceptMaps) {
|
|
681
|
+
dependsOn.set(cm, new Set());
|
|
682
|
+
waitingOn.set(cm, 0);
|
|
683
|
+
}
|
|
684
|
+
for (const cm of conceptMaps) {
|
|
685
|
+
for (const ref of cm.listDependencies()) {
|
|
686
|
+
const dep = byRef.get(ref) || byRef.get(ref.split('|')[0]);
|
|
687
|
+
if (dep && dep !== cm && !dependsOn.get(cm).has(dep)) {
|
|
688
|
+
dependsOn.get(cm).add(dep);
|
|
689
|
+
waitingOn.set(dep, waitingOn.get(dep) + 1);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const ready = conceptMaps.filter(cm => waitingOn.get(cm) === 0).sort(byDate);
|
|
695
|
+
const ordered = [];
|
|
696
|
+
while (ready.length > 0) {
|
|
697
|
+
const cm = ready.shift();
|
|
698
|
+
ordered.push(cm);
|
|
699
|
+
for (const dep of dependsOn.get(cm)) {
|
|
700
|
+
waitingOn.set(dep, waitingOn.get(dep) - 1);
|
|
701
|
+
if (waitingOn.get(dep) === 0) {
|
|
702
|
+
ready.push(dep);
|
|
703
|
+
ready.sort(byDate);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (ordered.length < conceptMaps.length) {
|
|
708
|
+
const seen = new Set(ordered);
|
|
709
|
+
ordered.push(...conceptMaps.filter(cm => !seen.has(cm)).sort(byDate));
|
|
710
|
+
}
|
|
711
|
+
return ordered;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* ConceptMap.group.unmapped: what to do when the group is in scope but has no element
|
|
716
|
+
* for this concept. Only consulted once the map itself has produced nothing.
|
|
717
|
+
*
|
|
718
|
+
* use-source-code the source code is also the target code
|
|
719
|
+
* fixed a nominated code (and display) is the target
|
|
720
|
+
* other-map defer to another ConceptMap entirely
|
|
721
|
+
*
|
|
722
|
+
* `visited` guards against a cycle in other-map chains.
|
|
723
|
+
* @returns {Promise<boolean>} whether anything was added
|
|
724
|
+
*/
|
|
725
|
+
async translateUsingUnmapped(cm, g, coding, targetScope, targetSystem, params, output, visited, origin) {
|
|
726
|
+
let result = false;
|
|
727
|
+
const originMap = origin || cm.vurl;
|
|
728
|
+
{
|
|
729
|
+
const u = g.unmapped;
|
|
730
|
+
if (!u || !u.mode) {
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
if (u.valueSet) {
|
|
734
|
+
// "all the codes in the specified value set" - that needs an expansion, which
|
|
735
|
+
// this operation does not do. Better to say so than to silently return nothing.
|
|
736
|
+
throw new Issue('error', 'not-supported', null, null,
|
|
737
|
+
`ConceptMap ${cm.vurl} uses group.unmapped.valueSet, which this server does not support`, null, 422);
|
|
738
|
+
}
|
|
739
|
+
switch (u.mode) {
|
|
740
|
+
case 'use-source-code' :
|
|
741
|
+
result = this._addUnmappedMatch(output, originMap, coding, {system: g.target, code: coding.code}, u.relationship) || result;
|
|
742
|
+
break;
|
|
743
|
+
case 'fixed' :
|
|
744
|
+
if (u.code) {
|
|
745
|
+
const outcome = {system: g.target, code: u.code};
|
|
746
|
+
if (u.display) {
|
|
747
|
+
outcome.display = u.display;
|
|
748
|
+
}
|
|
749
|
+
result = this._addUnmappedMatch(output, originMap, coding, outcome, u.relationship) || result;
|
|
750
|
+
}
|
|
751
|
+
break;
|
|
752
|
+
case 'other-map' :
|
|
753
|
+
if (u.otherMap) {
|
|
754
|
+
const other = await this.findConceptMapByUrl(u.otherMap);
|
|
755
|
+
if (!other) {
|
|
756
|
+
throw new Issue('error', 'not-found', null, null,
|
|
757
|
+
`The ConceptMap ${u.otherMap} named in group.unmapped.otherMap was not found`, null, 422);
|
|
758
|
+
}
|
|
759
|
+
// Key the cycle check on the map that was actually resolved, not on the
|
|
760
|
+
// reference as written: the same map can be named with or without a
|
|
761
|
+
// version, or by a version that resolves to the same instance.
|
|
762
|
+
const key = other.vurl;
|
|
763
|
+
if (visited.has(key)) {
|
|
764
|
+
throw new Issue('error', 'business-rule', null, null,
|
|
765
|
+
`ConceptMap ${cm.vurl} has a circular group.unmapped.otherMap reference to ${other.vurl}`
|
|
766
|
+
+ (key === u.otherMap ? '' : ` (referenced as ${u.otherMap})`), null, 422);
|
|
767
|
+
}
|
|
768
|
+
// `visited` is the chain currently being followed, not every map seen: two
|
|
769
|
+
// groups (or two maps) legitimately deferring to the same other map is a
|
|
770
|
+
// diamond, not a cycle, so the entry is removed again on the way back out.
|
|
771
|
+
visited.add(key);
|
|
772
|
+
try {
|
|
773
|
+
// we are about to look in this map, and it is not where the chain started
|
|
774
|
+
this.recordUsedConceptMap(output, other.vurl);
|
|
775
|
+
result = await this.translateUsingGroupsForwards(other, coding, targetScope, targetSystem, params, output, originMap, visited) || result;
|
|
776
|
+
} finally {
|
|
777
|
+
visited.delete(key);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
break;
|
|
781
|
+
default :
|
|
782
|
+
throw new Issue('error', 'not-supported', null, null,
|
|
783
|
+
`ConceptMap ${cm.vurl} uses an unknown group.unmapped.mode '${u.mode}'`, null, 422);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return result;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Build the match for an unmapped fallback. The source concept is the one that was
|
|
791
|
+
* asked for - it is, by definition, not an element of the map.
|
|
792
|
+
*/
|
|
793
|
+
_addUnmappedMatch(output, originMap, coding, outcome, relationship) {
|
|
794
|
+
if (this.hasMatch(output, outcome)) {
|
|
795
|
+
return false;
|
|
796
|
+
}
|
|
797
|
+
const matchParts = [];
|
|
798
|
+
matchParts.push({
|
|
799
|
+
name: 'concept',
|
|
800
|
+
valueCoding: outcome
|
|
801
|
+
});
|
|
802
|
+
matchParts.push({
|
|
803
|
+
name: 'sourceConcept',
|
|
804
|
+
valueCoding: {
|
|
805
|
+
system: coding.system,
|
|
806
|
+
code: coding.code
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
if (relationship) {
|
|
810
|
+
matchParts.push({
|
|
811
|
+
name: 'relationship',
|
|
812
|
+
valueCode: relationship
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
matchParts.push({
|
|
816
|
+
name: 'originMap',
|
|
817
|
+
valueCanonical: originMap
|
|
818
|
+
});
|
|
819
|
+
output.push({
|
|
820
|
+
name: 'match',
|
|
821
|
+
part: matchParts
|
|
822
|
+
});
|
|
823
|
+
return true;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Report a ConceptMap that was consulted but is not where the chain started. Reported
|
|
828
|
+
* once per map, at the top level of the response rather than per match.
|
|
829
|
+
*/
|
|
830
|
+
recordUsedConceptMap(output, vurl) {
|
|
831
|
+
if (!vurl) {
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
for (const o of output) {
|
|
835
|
+
if (o.name === 'used-conceptmap' && (o.valueCanonical === vurl || o.valueUri === vurl)) {
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
output.push({
|
|
840
|
+
name: 'used-conceptmap',
|
|
841
|
+
valueUri: vurl
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
488
845
|
async translateUsingCodeSystem(cm, coding, target, params, output, reverse) {
|
|
489
846
|
let result = false;
|
|
490
847
|
const factory = cm.jsonObj.internalSource;
|
|
@@ -521,6 +878,13 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
521
878
|
name: 'concept',
|
|
522
879
|
valueCoding: outcome
|
|
523
880
|
});
|
|
881
|
+
matchParts.push({
|
|
882
|
+
name: 'sourceConcept',
|
|
883
|
+
valueCoding: {
|
|
884
|
+
system: coding.system,
|
|
885
|
+
code: coding.code
|
|
886
|
+
}
|
|
887
|
+
});
|
|
524
888
|
matchParts.push({
|
|
525
889
|
name: 'relationship',
|
|
526
890
|
valueCode: t.relationship
|
|
@@ -560,14 +924,20 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
560
924
|
const result = [];
|
|
561
925
|
|
|
562
926
|
try {
|
|
927
|
+
// The maps are consulted in order (see sortConceptMaps), and each group within
|
|
928
|
+
// them decides for itself: a group with a mapping for this concept reports it, a
|
|
929
|
+
// group without one falls back to its own group.unmapped. Nothing is remembered
|
|
930
|
+
// across groups - one group having mapped the concept does not stop another group
|
|
931
|
+
// from saying what it does with it.
|
|
932
|
+
const maps = this.sortConceptMaps(conceptMaps);
|
|
563
933
|
let added = false;
|
|
564
|
-
for (const cm of
|
|
934
|
+
for (const cm of maps) {
|
|
565
935
|
if (cm.jsonObj.internalSource) {
|
|
566
936
|
added = await this.translateUsingCodeSystem(cm, coding, targetSystem, params, result, reverse) || added;
|
|
567
937
|
} else if (reverse) {
|
|
568
938
|
added = this.translateUsingGroupsReverse(cm, coding, targetScope, targetSystem, params, result) || added;
|
|
569
|
-
} else{
|
|
570
|
-
added = this.translateUsingGroupsForwards(cm, coding, targetScope, targetSystem, params, result) || added;
|
|
939
|
+
} else {
|
|
940
|
+
added = await this.translateUsingGroupsForwards(cm, coding, targetScope, targetSystem, params, result) || added;
|
|
571
941
|
}
|
|
572
942
|
}
|
|
573
943
|
result.push({
|
|
@@ -581,6 +951,12 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
581
951
|
});
|
|
582
952
|
}
|
|
583
953
|
} catch (error) {
|
|
954
|
+
if (error instanceof Issue) {
|
|
955
|
+
// A coded problem with the request or with the maps themselves. That is not a
|
|
956
|
+
// translation outcome, so it must not be flattened into result=false plus a
|
|
957
|
+
// message - let it out, to be reported as an OperationOutcome with its status.
|
|
958
|
+
throw error;
|
|
959
|
+
}
|
|
584
960
|
this.log.error(error);
|
|
585
961
|
debugLog(error);
|
|
586
962
|
result.push({
|
|
@@ -654,6 +1030,33 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
654
1030
|
};
|
|
655
1031
|
}
|
|
656
1032
|
|
|
1033
|
+
/**
|
|
1034
|
+
* Resolve a ConceptMap by canonical reference. Resources supplied with the request
|
|
1035
|
+
* (tx-resource, or a front-loaded cache) come first: they are part of the caller's
|
|
1036
|
+
* context, and a map named by group.unmapped.otherMap or by the url parameter may
|
|
1037
|
+
* well be one of them rather than something the server already knows.
|
|
1038
|
+
* @param {string} ref - canonical reference, possibly url|version
|
|
1039
|
+
* @param {string} [version] - version, when supplied separately
|
|
1040
|
+
* @returns {Promise<ConceptMap|null>}
|
|
1041
|
+
*/
|
|
1042
|
+
async findConceptMapByUrl(ref, version) {
|
|
1043
|
+
if (!ref) {
|
|
1044
|
+
return null;
|
|
1045
|
+
}
|
|
1046
|
+
let url = ref;
|
|
1047
|
+
let ver = version;
|
|
1048
|
+
const bar = ref.indexOf('|');
|
|
1049
|
+
if (!ver && bar !== -1) {
|
|
1050
|
+
url = ref.substring(0, bar);
|
|
1051
|
+
ver = ref.substring(bar + 1);
|
|
1052
|
+
}
|
|
1053
|
+
const supplied = this.findInAdditionalResources(ver ? url : ref, ver || '', 'ConceptMap', false);
|
|
1054
|
+
if (supplied) {
|
|
1055
|
+
return supplied;
|
|
1056
|
+
}
|
|
1057
|
+
return await this.provider.findConceptMap(this.opContext, url, ver);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
657
1060
|
async findConceptMapsInAdditionalResources(conceptMaps, system, sourceScope, targetScope, targetSystem) {
|
|
658
1061
|
for (let res of this.additionalResources || []) {
|
|
659
1062
|
if (res instanceof ConceptMap) {
|
|
@@ -664,10 +1067,38 @@ class TranslateWorker extends TerminologyWorker {
|
|
|
664
1067
|
}
|
|
665
1068
|
}
|
|
666
1069
|
|
|
1070
|
+
/**
|
|
1071
|
+
* Has some map already stated that this source concept has no mapping? Two maps that
|
|
1072
|
+
* agree there is no map are making the same statement, so it is reported once - the
|
|
1073
|
+
* same way two maps that agree on a target concept report one match. (A noMap match
|
|
1074
|
+
* carries no concept, so hasMatch can never see it.)
|
|
1075
|
+
*/
|
|
1076
|
+
hasNoMap(output, system, code) {
|
|
1077
|
+
for (let o of output) {
|
|
1078
|
+
if (o.name !== 'match' || !Array.isArray(o.part)) {
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
const n = o.part.find(x => x.name === 'noMap');
|
|
1082
|
+
const sc = o.part.find(x => x.name === 'sourceConcept');
|
|
1083
|
+
if (n && n.valueBoolean === true && sc && sc.valueCoding
|
|
1084
|
+
&& sc.valueCoding.system === system && sc.valueCoding.code === code) {
|
|
1085
|
+
return true;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
return false;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
667
1091
|
hasMatch(output, outcome) {
|
|
668
1092
|
for (let o of output) {
|
|
1093
|
+
// output is not all matches: translateUsingCodeSystem also pushes used-system and
|
|
1094
|
+
// used-conceptmap, which have no parts at all. And a noMap match has parts but no
|
|
1095
|
+
// concept - it is the statement that there is no coded outcome, so it can never
|
|
1096
|
+
// collide with one.
|
|
1097
|
+
if (o.name !== 'match' || !Array.isArray(o.part)) {
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
669
1100
|
let c = o.part.find(x => x.name === 'concept');
|
|
670
|
-
if (c.valueCoding.code === outcome.code && c.valueCoding.system === outcome.system) {
|
|
1101
|
+
if (c && c.valueCoding && c.valueCoding.code === outcome.code && c.valueCoding.system === outcome.system) {
|
|
671
1102
|
return true;
|
|
672
1103
|
}
|
|
673
1104
|
}
|
package/tx/workers/validate.js
CHANGED
|
@@ -293,6 +293,8 @@ class ValueSetChecker {
|
|
|
293
293
|
}
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
|
+
// R6's ValueSet.compose.property
|
|
297
|
+
this.params.seeCompose(this.valueSet.jsonObj.compose);
|
|
296
298
|
if (!this.params.HTTPLanguages && this.valueSet.jsonObj.language) {
|
|
297
299
|
this.params.HTTPLanguages = Languages.fromAcceptLanguage(this.valueSet.jsonObj.language, this.worker.languages, false);
|
|
298
300
|
}
|
|
@@ -1478,7 +1480,11 @@ class ValueSetChecker {
|
|
|
1478
1480
|
list.allowedDisplays(ts2, null, defLang.value);
|
|
1479
1481
|
let mid = 'INACTIVE_DISPLAY_FOUND';
|
|
1480
1482
|
let m = this.worker.i18n.translatePlural(ts2.length, mid, this.params.HTTPLanguages, [c.display, c.code, ts2.join(','), list.status(c.display)]);
|
|
1481
|
-
|
|
1483
|
+
// The display IS a designation of this concept, just not a current one,
|
|
1484
|
+
// so lenient-display-validation decides whether that fails the code:
|
|
1485
|
+
// a warning (and result true) when lenient, an error when not - the
|
|
1486
|
+
// same rule the other display checks use.
|
|
1487
|
+
op.addIssue(new Issue(this.dispWarning(), 'invalid', addToPath(path, 'display'), mid, m, 'display-comment'));
|
|
1482
1488
|
}
|
|
1483
1489
|
}
|
|
1484
1490
|
}
|