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.
@@ -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
- const code = params.get('targetCode');
165
- const system = params.get('targetSystem');
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.provider.findConceptMap(this.opContext, url, cmVersion);
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
- if (reverse) {
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
- } else {
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
- throw new Issue('error', 'not-found', null, null, 'No suitable ConceptMaps found for the specified source and target', null, 404);
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
- const matches = cm.listTranslations(coding, targetScope, targetSystem);
326
- if (matches.length > 0) {
327
- for (let match of matches) {
328
- const g = match.group;
329
- const em = match.match;
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
- result = true;
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: 'message',
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: cm.vurl
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: 'source',
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: 'message',
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 conceptMaps) {
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
  }
@@ -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
- op.addIssue(new Issue('warning', 'invalid', addToPath(path, 'display'), mid, m, 'display-comment'));
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
  }