fhirsmith 0.11.1 → 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 +85 -0
- package/config-template.json +18 -1
- package/library/folder-content-loader.js +111 -21
- package/package.json +3 -3
- 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.js +9 -3
- package/tx/cs/cs-api.md +1 -1
- package/tx/cs/cs-snomed.js +131 -65
- package/tx/library/conceptmap.js +39 -0
- package/tx/library/designations.js +9 -2
- package/tx/library/parameter-check.js +139 -0
- package/tx/library.js +62 -9
- 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/cs/cs-api.md
CHANGED
|
@@ -105,7 +105,7 @@ All are `async`.
|
|
|
105
105
|
| `parent(code)` | `null` | The parent concept, for hierarchical code systems. |
|
|
106
106
|
| `extensions(code)` | `null` | FHIR extensions on the concept, if any. |
|
|
107
107
|
| `properties(code)` | `[]` | The defined properties for the concept. |
|
|
108
|
-
| `incompleteValidationMessage(code)` | `null` | A message explaining why validation may be incomplete
|
|
108
|
+
| `incompleteValidationMessage(code)` | `null` | A message explaining why validation may be incomplete. No provider overrides this: SNOMED expressions are checked against the concept model, and the only things it cannot check are ones SNOMED itself publishes no rule for. |
|
|
109
109
|
| `sameConcept(a, b)` | `false` | Returns `true` if `a` and `b` refer to the same concept (e.g. different expression forms). |
|
|
110
110
|
| `isDisplay(designation)` | `false` | Called for designations not already marked with a standard display use code; return `true` to treat the designation as a display. |
|
|
111
111
|
| `subsumesTest(codeA, codeB)` | `'not-subsumed'` | Returns one of `equivalent`, `subsumes`, `subsumed-by`, or `not-subsumed`. |
|
package/tx/cs/cs-snomed.js
CHANGED
|
@@ -148,6 +148,58 @@ class SnomedServices {
|
|
|
148
148
|
refSetIndex: this.refSetIndex
|
|
149
149
|
}, this.isAIndex);
|
|
150
150
|
|
|
151
|
+
// The expression services check the MRCM attribute ranges, but those are ECL
|
|
152
|
+
// and the ECL engine lives here, so hand them an evaluator. Returning null
|
|
153
|
+
// means "could not decide", and leaves the value unchecked.
|
|
154
|
+
this.expressionServices.eclConstraintMatcher = (constraint, value) => this.mrcmRangeSatisfied(constraint, value);
|
|
155
|
+
this._mrcmRangeCache = new Map();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Does this refinement value satisfy an MRCM range constraint?
|
|
160
|
+
*
|
|
161
|
+
* @param {string} constraint - the rangeConstraint ECL, e.g. "<< 182353008 |Side|"
|
|
162
|
+
* @param {SnomedExpression} value - the refinement's value expression
|
|
163
|
+
* @returns {boolean|null} null when the constraint cannot be evaluated
|
|
164
|
+
*/
|
|
165
|
+
mrcmRangeSatisfied(constraint, value) {
|
|
166
|
+
let entry = this._mrcmRangeCache.get(constraint);
|
|
167
|
+
if (entry === undefined) {
|
|
168
|
+
entry = null;
|
|
169
|
+
try {
|
|
170
|
+
const ast = new ECLParser(new ECLLexer(constraint).tokenize()).parse();
|
|
171
|
+
const ctxt = this._evalECLNode(ast, null);
|
|
172
|
+
entry = {
|
|
173
|
+
ast: ast,
|
|
174
|
+
wildcard: !!ctxt.eclWildcard,
|
|
175
|
+
members: new Set(this._eclToIndexArray(ctxt))
|
|
176
|
+
};
|
|
177
|
+
} catch (error) {
|
|
178
|
+
// Concrete value ranges (dec/int/str) and anything else the ECL engine
|
|
179
|
+
// does not support: remembered as unusable so it is parsed only once.
|
|
180
|
+
entry = null;
|
|
181
|
+
}
|
|
182
|
+
this._mrcmRangeCache.set(constraint, entry);
|
|
183
|
+
}
|
|
184
|
+
if (!entry) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
if (entry.wildcard) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const concepts = (value.concepts || []).filter(c => c.reference != null && c.reference !== NO_REFERENCE);
|
|
192
|
+
const refined = value.hasRefinements() || value.hasRefinementGroups();
|
|
193
|
+
if (!refined) {
|
|
194
|
+
if (concepts.length === 0) {
|
|
195
|
+
return null; // a concrete value, or nothing we can resolve
|
|
196
|
+
}
|
|
197
|
+
// Every focus concept of a plain value must be in the constraint's set.
|
|
198
|
+
return concepts.every(c => entry.members.has(c.reference));
|
|
199
|
+
}
|
|
200
|
+
// A refined value: ask the ECL engine whether the expression satisfies the
|
|
201
|
+
// constraint (it knows that a refinement of X is still << X).
|
|
202
|
+
return this._eclExpressionSatisfies({ expression: value }, entry.ast, null);
|
|
151
203
|
}
|
|
152
204
|
|
|
153
205
|
close() {
|
|
@@ -664,7 +716,7 @@ class SnomedServices {
|
|
|
664
716
|
// we actually need the full concept list, otherwise filterSize returns 0
|
|
665
717
|
// and the iteration yields nothing. Materialise active concepts now.
|
|
666
718
|
if (forIteration && result.eclWildcard && (!result.descendants || result.descendants.length === 0)) {
|
|
667
|
-
result.descendants = this.
|
|
719
|
+
result.descendants = this._eclEnumerateAllConcepts(opContext);
|
|
668
720
|
delete result.eclWildcard;
|
|
669
721
|
// The materialised wildcard form differs from the lazy (eclWildcard) form,
|
|
670
722
|
// so mark it: the filter cache must not share it across iteration modes.
|
|
@@ -677,31 +729,29 @@ class SnomedServices {
|
|
|
677
729
|
};
|
|
678
730
|
|
|
679
731
|
/**
|
|
680
|
-
* Return every
|
|
681
|
-
*
|
|
732
|
+
* Return every concept's index. Used to materialise wildcard results when the
|
|
733
|
+
* filter needs to be iterated over (e.g. $expand).
|
|
734
|
+
*
|
|
735
|
+
* `*` means every concept the server knows, so this deliberately walks the
|
|
736
|
+
* concept table rather than descending from 138875005 |SNOMED CT Concept|.
|
|
737
|
+
* Two kinds of concept are in the table but not reachable from that root:
|
|
738
|
+
* inactive concepts (their is-a relationships are inactive too), and the
|
|
739
|
+
* foundation-metadata concepts that a subset carries without their is-a
|
|
740
|
+
* relationships - module concepts above all. Both belong in `*`, and both are
|
|
741
|
+
* counted by countAllCodes, so descending from the root made $expand of a
|
|
742
|
+
* `constraint = *` filter disagree with $expand of the whole code system.
|
|
743
|
+
*
|
|
744
|
+
* Inactive concepts are not filtered out here: activeOnly is applied by the
|
|
745
|
+
* expansion itself (see ValueSetExpander.processCodes), which is what lets one
|
|
746
|
+
* enumeration serve both activeOnly=true and activeOnly=false.
|
|
682
747
|
* @returns {number[]}
|
|
683
748
|
*/
|
|
684
|
-
|
|
685
|
-
// The wildcard `*` enumerates the SNOMED CT concept hierarchy: the root
|
|
686
|
-
// 138875005 |SNOMED CT Concept| and its descendants. This excludes the
|
|
687
|
-
// foundation-metadata concepts that appear in a subset's concept table
|
|
688
|
-
// (referenced by the RF2 files as module / type / acceptability ... ids)
|
|
689
|
-
// without their is-a relationships, so they are not reachable from the root
|
|
690
|
-
// and are not returned by `*` (matching Snowstorm). On a full edition every
|
|
691
|
-
// real concept is under the root, so nothing is lost.
|
|
692
|
-
const rootRes = this.concepts.findConcept(138875005n);
|
|
693
|
-
if (rootRes && rootRes.found) {
|
|
694
|
-
return this.filterIsA(138875005n, true).descendants;
|
|
695
|
-
}
|
|
696
|
-
// Fallback (no recognisable root): every active concept.
|
|
749
|
+
_eclEnumerateAllConcepts = function (opContext) {
|
|
697
750
|
const all = [];
|
|
698
751
|
const n = this.concepts.count();
|
|
699
752
|
for (let i = 0; i < n; i++) {
|
|
700
|
-
if (opContext) opContext.deadCheck('ecl:
|
|
701
|
-
|
|
702
|
-
if ((concept.flags & 0x0F) === 0) {
|
|
703
|
-
all.push(concept.index);
|
|
704
|
-
}
|
|
753
|
+
if (opContext) opContext.deadCheck('ecl:enumerateAllConcepts');
|
|
754
|
+
all.push(this.concepts.getConceptByCount(i).index);
|
|
705
755
|
}
|
|
706
756
|
return all;
|
|
707
757
|
};
|
|
@@ -918,7 +968,7 @@ class SnomedServices {
|
|
|
918
968
|
};
|
|
919
969
|
|
|
920
970
|
/**
|
|
921
|
-
* Wildcard —
|
|
971
|
+
* Wildcard — every concept, active or not. The eclWildcard flag tells filterCheck /
|
|
922
972
|
* filterLocate to accept every active concept without enumeration.
|
|
923
973
|
* @returns {SnomedFilterContext}
|
|
924
974
|
*/
|
|
@@ -1312,15 +1362,15 @@ class SnomedServices {
|
|
|
1312
1362
|
|
|
1313
1363
|
/**
|
|
1314
1364
|
* Like _eclToIndexArray, but if the context is a bare wildcard (no
|
|
1315
|
-
* descendants populated) it materialises the full
|
|
1316
|
-
* via
|
|
1365
|
+
* descendants populated) it materialises the full concept list
|
|
1366
|
+
* via _eclEnumerateAllConcepts. Used by dotted/refined evaluation,
|
|
1317
1367
|
* which need an explicit concept set to iterate over.
|
|
1318
1368
|
* @param {SnomedFilterContext} ctx
|
|
1319
1369
|
* @returns {number[]}
|
|
1320
1370
|
*/
|
|
1321
1371
|
_eclResolveSet = function (ctx, opContext) {
|
|
1322
1372
|
if (ctx.eclWildcard && (!ctx.descendants || ctx.descendants.length === 0)) {
|
|
1323
|
-
return this.
|
|
1373
|
+
return this._eclEnumerateAllConcepts(opContext);
|
|
1324
1374
|
}
|
|
1325
1375
|
return this._eclToIndexArray(ctx);
|
|
1326
1376
|
};
|
|
@@ -1369,9 +1419,9 @@ class SnomedServices {
|
|
|
1369
1419
|
const rightSet = new Set(this._eclToIndexArray(right));
|
|
1370
1420
|
|
|
1371
1421
|
if (left.eclWildcard) {
|
|
1372
|
-
// Wildcard minus the right set: use the shared
|
|
1373
|
-
//
|
|
1374
|
-
result.descendants = this.
|
|
1422
|
+
// Wildcard minus the right set: use the shared enumeration so `*` behaves
|
|
1423
|
+
// consistently everywhere.
|
|
1424
|
+
result.descendants = this._eclEnumerateAllConcepts(opContext)
|
|
1375
1425
|
.filter(idx => !rightSet.has(idx));
|
|
1376
1426
|
return result;
|
|
1377
1427
|
}
|
|
@@ -1605,13 +1655,25 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1605
1655
|
const K = this.sct._displayConstants();
|
|
1606
1656
|
|
|
1607
1657
|
if (significantOnly) {
|
|
1608
|
-
// $expand: emit the "significant" designations only — the FSN
|
|
1609
|
-
// the preferred synonym
|
|
1610
|
-
//
|
|
1611
|
-
//
|
|
1612
|
-
//
|
|
1613
|
-
//
|
|
1614
|
-
|
|
1658
|
+
// $expand: emit the "significant" designations only — the FSN and
|
|
1659
|
+
// the preferred synonym, active descriptions only. Acceptable
|
|
1660
|
+
// synonyms are the bulk of a SNOMED concept's descriptions and are
|
|
1661
|
+
// not needed to render an expansion, so they are dropped.
|
|
1662
|
+
//
|
|
1663
|
+
// One per LANGUAGE, though, not one overall: the display for a
|
|
1664
|
+
// request is chosen downstream by
|
|
1665
|
+
// Designations.preferredDesignation(displayLanguage), so a concept
|
|
1666
|
+
// that emits only its English terms has nothing for a
|
|
1667
|
+
// displayLanguage=de request to match and silently falls back to
|
|
1668
|
+
// English. Keeping the preferred synonym in every language the
|
|
1669
|
+
// concept has is what makes localized expansions work at all
|
|
1670
|
+
// (e.g. the Swiss edition, whose German terms are preferred in the
|
|
1671
|
+
// Swiss German language refset 2041000195100).
|
|
1672
|
+
//
|
|
1673
|
+
// The edition-default display is also added as the display
|
|
1674
|
+
// designation (which the expand worker selects for `display` when
|
|
1675
|
+
// no language was asked for, and skips from the emitted list).
|
|
1676
|
+
const fsns = new Map(), prefSyns = new Map();
|
|
1615
1677
|
for (const descIndex of descriptionIndices) {
|
|
1616
1678
|
const description = this.sct.descriptions.getDescription(descIndex);
|
|
1617
1679
|
if (!description.active) continue;
|
|
@@ -1620,15 +1682,20 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1620
1682
|
const kind = this.sct.concepts.getConcept(description.kind);
|
|
1621
1683
|
const use = { system: 'http://snomed.info/sct', code: String(kind.identity), display: this.sct.getDisplayName(description.kind) };
|
|
1622
1684
|
if (description.kind === K.fsn) {
|
|
1623
|
-
if (!
|
|
1685
|
+
if (!fsns.has(langCode)) fsns.set(langCode, { langCode, use, term });
|
|
1624
1686
|
} else if (this.sct._synonymIsPreferred(description)) {
|
|
1625
|
-
if (!
|
|
1687
|
+
if (!prefSyns.has(langCode)) prefSyns.set(langCode, { langCode, use, term });
|
|
1626
1688
|
}
|
|
1627
1689
|
}
|
|
1628
1690
|
const display = this.sct.getDisplayName(ctxt.getReference());
|
|
1629
1691
|
if (display) displays.addDesignation(true, 'active', 'en-US', null, display);
|
|
1630
|
-
|
|
1631
|
-
|
|
1692
|
+
// FSNs before synonyms, matching the designation order the published
|
|
1693
|
+
// tx-ecosystem expectations were written against (sct/expand-inactive).
|
|
1694
|
+
// Which of the two is chosen as the display is settled by
|
|
1695
|
+
// Designations.preferredDesignation(), which ranks the SNOMED synonym
|
|
1696
|
+
// above the FSN - not by the order they are emitted in.
|
|
1697
|
+
for (const d of fsns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
|
|
1698
|
+
for (const d of prefSyns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
|
|
1632
1699
|
} else {
|
|
1633
1700
|
// $lookup: emit every description (preferred synonym first so the
|
|
1634
1701
|
// display resolves correctly; order is not otherwise significant).
|
|
@@ -1717,19 +1784,6 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1717
1784
|
}
|
|
1718
1785
|
}
|
|
1719
1786
|
|
|
1720
|
-
async incompleteValidationMessage(context) {
|
|
1721
|
-
|
|
1722
|
-
const ctxt = await this.#ensureContext(context);
|
|
1723
|
-
|
|
1724
|
-
if (!ctxt) return null;
|
|
1725
|
-
|
|
1726
|
-
if (ctxt.isComplex()) {
|
|
1727
|
-
return "The expression is grammatically correct and the concepts are valid, but the expression has not been checked against the SNOMED CT concept model (MRCM)";
|
|
1728
|
-
} else {
|
|
1729
|
-
return null;
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
1787
|
async locateIsA(code, parent, disallowParent = false) {
|
|
1734
1788
|
|
|
1735
1789
|
|
|
@@ -1864,25 +1918,37 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1864
1918
|
if (ctxt instanceof SnomedExpressionContext) {
|
|
1865
1919
|
// ignore concepts for now, but list refinements and refinement groups
|
|
1866
1920
|
for (const refinement of ctxt.expression.refinements) {
|
|
1867
|
-
|
|
1868
|
-
const codeB = refinement.value.describe();
|
|
1869
|
-
const description = await this.display(codeB);
|
|
1870
|
-
let p = this._addCodeProperty(params, 'property', codeA, codeB, null, description);
|
|
1871
|
-
p.part.push({name: 'code-display', valueString: await this.display(codeA)});
|
|
1921
|
+
await this._addRefinementProperty(params, refinement);
|
|
1872
1922
|
}
|
|
1873
1923
|
for (const refinementGroup of ctxt.expression.refinementGroups) {
|
|
1874
1924
|
for (const refinement of refinementGroup.refinements) {
|
|
1875
|
-
|
|
1876
|
-
const codeB = refinement.value.describe();
|
|
1877
|
-
const description = await this.display(codeB);
|
|
1878
|
-
let p = this._addCodeProperty(params, 'property', codeA, codeB, null, description);
|
|
1879
|
-
p.part.push({name: 'code-display', valueString: await this.display(codeA)});
|
|
1925
|
+
await this._addRefinementProperty(params, refinement);
|
|
1880
1926
|
}
|
|
1881
1927
|
}
|
|
1882
1928
|
}
|
|
1883
1929
|
}
|
|
1884
1930
|
}
|
|
1885
1931
|
|
|
1932
|
+
/**
|
|
1933
|
+
* Report one refinement of a post-coordinated expression as a lookup property.
|
|
1934
|
+
*
|
|
1935
|
+
* The value is handled as an expression, not as a string: SnomedExpression's
|
|
1936
|
+
* describe() is a human-readable rendering that joins concepts and refinements
|
|
1937
|
+
* with commas ("85562004,272741003=24028007"), so round-tripping it through
|
|
1938
|
+
* display() parsed it back as SCG and failed on any refined value.
|
|
1939
|
+
*
|
|
1940
|
+
* @param {Object} params - the Parameters being built
|
|
1941
|
+
* @param {SnomedRefinement} refinement
|
|
1942
|
+
*/
|
|
1943
|
+
async _addRefinementProperty(params, refinement) {
|
|
1944
|
+
const codeA = refinement.name.code;
|
|
1945
|
+
const value = SnomedExpressionContext.fromExpression('', refinement.value);
|
|
1946
|
+
const codeB = await this.code(value);
|
|
1947
|
+
const description = await this.display(value);
|
|
1948
|
+
const p = this._addCodeProperty(params, 'property', codeA, codeB, null, description);
|
|
1949
|
+
p.part.push({name: 'code-display', valueString: await this.display(codeA)});
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1886
1952
|
// Filter support
|
|
1887
1953
|
async doesFilter(prop, op, value) {
|
|
1888
1954
|
if (prop === 'concept') {
|
|
@@ -2114,7 +2180,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2114
2180
|
// A wildcard (`*`) set is lazy - it carries the eclWildcard flag rather than a
|
|
2115
2181
|
// materialised descendant list - so count the active concepts it denotes.
|
|
2116
2182
|
if (set.eclWildcard && (!set.descendants || set.descendants.length === 0)) {
|
|
2117
|
-
return this.sct.
|
|
2183
|
+
return this.sct._eclEnumerateAllConcepts(this.opContext).length;
|
|
2118
2184
|
}
|
|
2119
2185
|
if (set.matches && set.matches.length > 0) {
|
|
2120
2186
|
return set.matches.length;
|
|
@@ -2166,7 +2232,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2166
2232
|
const reference = ctxt.getReference();
|
|
2167
2233
|
|
|
2168
2234
|
if (set.eclWildcard) {
|
|
2169
|
-
return
|
|
2235
|
+
return ctxt; // `*` is every concept; inactive ones included
|
|
2170
2236
|
}
|
|
2171
2237
|
|
|
2172
2238
|
// A post-coordinated expression validated against an ECL value set: test the
|
|
@@ -2249,7 +2315,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2249
2315
|
return this.#descendantsHas(set, reference);
|
|
2250
2316
|
}
|
|
2251
2317
|
if (set.eclWildcard) {
|
|
2252
|
-
return
|
|
2318
|
+
return true; // `*` is every concept; inactive ones included
|
|
2253
2319
|
}
|
|
2254
2320
|
return false;
|
|
2255
2321
|
}
|
package/tx/library/conceptmap.js
CHANGED
|
@@ -173,6 +173,45 @@ class ConceptMap extends CanonicalResource {
|
|
|
173
173
|
return result;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The canonical references this map defers to. Only group.unmapped.otherMap creates
|
|
178
|
+
* such a dependency: a map that names another map cannot be resolved without it, so
|
|
179
|
+
* it has to be consulted first.
|
|
180
|
+
* @returns {string[]} canonical references, as written (may or may not be versioned)
|
|
181
|
+
*/
|
|
182
|
+
listDependencies() {
|
|
183
|
+
const result = [];
|
|
184
|
+
for (const g of this.jsonObj.group || []) {
|
|
185
|
+
if (g.unmapped && g.unmapped.mode === 'other-map' && g.unmapped.otherMap) {
|
|
186
|
+
if (!result.includes(g.unmapped.otherMap)) {
|
|
187
|
+
result.push(g.unmapped.otherMap);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The groups that would have been consulted for this coding, whether or not any of
|
|
196
|
+
* their elements matched. group.unmapped only applies to a group that was in scope
|
|
197
|
+
* for the translation, so the caller needs the same source/target test that
|
|
198
|
+
* listTranslations applies - without the per-element filter.
|
|
199
|
+
* @returns {Object[]} the matching group objects
|
|
200
|
+
*/
|
|
201
|
+
listGroupsInScope(coding, targetScope, targetSystem) {
|
|
202
|
+
const result = [];
|
|
203
|
+
const vurl = VersionUtilities.vurl(coding.system, coding.version);
|
|
204
|
+
const all = this.canonicalMatches(targetScope, this.targetScope);
|
|
205
|
+
for (const g of this.jsonObj.group || []) {
|
|
206
|
+
const sourceOk = this.canonicalMatches(vurl, g.source);
|
|
207
|
+
const targetOk = !targetSystem || this.canonicalMatches(targetSystem, g.target);
|
|
208
|
+
if (all || (sourceOk && targetOk)) {
|
|
209
|
+
result.push(g);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
|
|
176
215
|
listTranslationsReverse(coding, targetScope, sourceSystem) {
|
|
177
216
|
let result = [];
|
|
178
217
|
let vurl = VersionUtilities.vurl(coding.system, coding.version);
|
|
@@ -610,8 +610,15 @@ class Designations {
|
|
|
610
610
|
|
|
611
611
|
for (const activeOnly of [true, false]) {
|
|
612
612
|
for (const matchType of matchTypes) {
|
|
613
|
+
// _isPreferred before isDisplay, matching the no-language branch above.
|
|
614
|
+
// The two sets differ only where a provider's own isDisplay() accepts a
|
|
615
|
+
// designation that isn't a preferred term - today that is the SNOMED FSN,
|
|
616
|
+
// which isDisplay() accepts and _isPreferred() deliberately does not. Testing
|
|
617
|
+
// isDisplay first made a request for a language whose concept has both an FSN
|
|
618
|
+
// and a preferred synonym display the "(disorder)"-suffixed FSN, purely
|
|
619
|
+
// because the FSN was emitted first.
|
|
613
620
|
for (const cd of this.designations) {
|
|
614
|
-
if (this._langMatches(lang, cd.language, matchType) && this.
|
|
621
|
+
if (this._langMatches(lang, cd.language, matchType) && this._isPreferred(cd) && (!activeOnly || cd.isActive())) {
|
|
615
622
|
if (supplements && cd.source) {
|
|
616
623
|
supplements.add(cd.source);
|
|
617
624
|
}
|
|
@@ -619,7 +626,7 @@ class Designations {
|
|
|
619
626
|
}
|
|
620
627
|
}
|
|
621
628
|
for (const cd of this.designations) {
|
|
622
|
-
if (this._langMatches(lang, cd.language, matchType) && this.
|
|
629
|
+
if (this._langMatches(lang, cd.language, matchType) && this.isDisplay(cd) && (!activeOnly || cd.isActive())) {
|
|
623
630
|
if (supplements && cd.source) {
|
|
624
631
|
supplements.add(cd.source);
|
|
625
632
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Checking what a code system provider adds to a $lookup response
|
|
3
|
+
//
|
|
4
|
+
// extendLookup() hands the provider the response parameter list and lets it
|
|
5
|
+
// push whatever it likes. Most providers push values they built themselves out
|
|
6
|
+
// of curated content, where the shape is known good. Some pass data through
|
|
7
|
+
// from a remote API, where a value can be any JSON at all - an object, an
|
|
8
|
+
// array, a null - and a structure like that in a FHIR primitive produces a
|
|
9
|
+
// response that is not valid FHIR.
|
|
10
|
+
//
|
|
11
|
+
// That's a bug in the provider, not something the client did, so it fails the
|
|
12
|
+
// operation (500/exception) rather than shipping a malformed response and
|
|
13
|
+
// leaving someone downstream to work out where it came from.
|
|
14
|
+
//
|
|
15
|
+
|
|
16
|
+
// value[x] elements carried by a JSON string
|
|
17
|
+
const STRING_VALUES = new Set([
|
|
18
|
+
'valueString', 'valueCode', 'valueUri', 'valueUrl', 'valueCanonical',
|
|
19
|
+
'valueId', 'valueOid', 'valueUuid', 'valueMarkdown', 'valueBase64Binary',
|
|
20
|
+
'valueDate', 'valueDateTime', 'valueTime', 'valueInstant'
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
// value[x] elements carried by a JSON integer
|
|
24
|
+
const INTEGER_VALUES = new Set(['valueInteger', 'valuePositiveInt', 'valueUnsignedInt']);
|
|
25
|
+
|
|
26
|
+
// The one complex type a lookup property is allowed to carry. Its own members
|
|
27
|
+
// are all primitives (system, version, code, display, userSelected), so an
|
|
28
|
+
// object or array inside one is just as wrong as it would be at the top.
|
|
29
|
+
const CODING_VALUE = 'valueCoding';
|
|
30
|
+
|
|
31
|
+
function describe(value) {
|
|
32
|
+
if (value === null) {
|
|
33
|
+
return 'null';
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(value)) {
|
|
36
|
+
return 'an array';
|
|
37
|
+
}
|
|
38
|
+
return 'a ' + typeof value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fail(source, path, message) {
|
|
42
|
+
throw new Error(`Code system provider ${source} produced an invalid $lookup response at ${path}: ${message}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function checkCoding(value, source, path) {
|
|
46
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
47
|
+
fail(source, path, `valueCoding must be an object, not ${describe(value)}`);
|
|
48
|
+
}
|
|
49
|
+
for (const [key, member] of Object.entries(value)) {
|
|
50
|
+
if (member === null || member === undefined) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const type = typeof member;
|
|
54
|
+
if (type !== 'string' && type !== 'number' && type !== 'boolean') {
|
|
55
|
+
fail(source, path, `valueCoding.${key} must be a primitive, not ${describe(member)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function checkValue(key, value, source, path) {
|
|
61
|
+
if (STRING_VALUES.has(key)) {
|
|
62
|
+
if (typeof value !== 'string') {
|
|
63
|
+
fail(source, path, `${key} must be a string, not ${describe(value)}`);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (INTEGER_VALUES.has(key)) {
|
|
68
|
+
if (!Number.isInteger(value)) {
|
|
69
|
+
fail(source, path, `${key} must be an integer, not ${describe(value)}`);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (key === 'valueDecimal') {
|
|
74
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
75
|
+
fail(source, path, `${key} must be a finite number, not ${describe(value)}`);
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (key === 'valueBoolean') {
|
|
80
|
+
if (typeof value !== 'boolean') {
|
|
81
|
+
fail(source, path, `${key} must be a boolean, not ${describe(value)}`);
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (key === CODING_VALUE) {
|
|
86
|
+
checkCoding(value, source, path);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
fail(source, path, `${key} is not a value type a lookup property may carry`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function checkParameter(param, source, path) {
|
|
93
|
+
if (param === null || typeof param !== 'object' || Array.isArray(param)) {
|
|
94
|
+
fail(source, path, `a parameter must be an object, not ${describe(param)}`);
|
|
95
|
+
}
|
|
96
|
+
if (typeof param.name !== 'string' || param.name === '') {
|
|
97
|
+
fail(source, path, `a parameter must have a name, not ${describe(param.name)}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const name = param.name;
|
|
101
|
+
const valueKeys = Object.keys(param).filter(key => key.startsWith('value'));
|
|
102
|
+
if (valueKeys.length > 1) {
|
|
103
|
+
fail(source, `${path}.${name}`, `a parameter may carry one value, not ${valueKeys.length} (${valueKeys.join(', ')})`);
|
|
104
|
+
}
|
|
105
|
+
for (const key of valueKeys) {
|
|
106
|
+
checkValue(key, param[key], source, `${path}.${name}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (param.part !== undefined) {
|
|
110
|
+
if (!Array.isArray(param.part)) {
|
|
111
|
+
fail(source, `${path}.${name}`, `part must be an array, not ${describe(param.part)}`);
|
|
112
|
+
}
|
|
113
|
+
for (const part of param.part) {
|
|
114
|
+
checkParameter(part, source, `${path}.${name}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Check the parameters a provider added to a lookup response.
|
|
121
|
+
*
|
|
122
|
+
* @param {Array} params - the whole response parameter list
|
|
123
|
+
* @param {number} fromIndex - where the provider's own additions start, so
|
|
124
|
+
* that only what extendLookup() added is checked
|
|
125
|
+
* @param {string} source - the code system, for the error message
|
|
126
|
+
* @throws {Error} if the provider produced something that isn't valid FHIR
|
|
127
|
+
*/
|
|
128
|
+
function checkAddedParameters(params, fromIndex, source) {
|
|
129
|
+
if (!Array.isArray(params)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
for (let i = fromIndex; i < params.length; i++) {
|
|
133
|
+
checkParameter(params[i], source || 'unknown', 'parameter');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = {
|
|
138
|
+
checkAddedParameters
|
|
139
|
+
};
|
package/tx/library.js
CHANGED
|
@@ -3,7 +3,7 @@ const path = require('path');
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const yaml = require('yaml'); // npm install yaml
|
|
5
5
|
const { PackageManager, PackageContentLoader } = require('../library/package-manager');
|
|
6
|
-
const { FolderContentLoader } = require('../library/folder-content-loader');
|
|
6
|
+
const { FolderContentLoader, FileContentLoader } = require('../library/folder-content-loader');
|
|
7
7
|
const { CodeSystem } = require("./library/codesystem");
|
|
8
8
|
const {CountryCodeFactoryProvider} = require("./cs/cs-country");
|
|
9
9
|
const {Iso4217FactoryProvider} = require("./cs/cs-currency");
|
|
@@ -301,6 +301,10 @@ class Library {
|
|
|
301
301
|
await this.loadFolder(details, isDefault, mode);
|
|
302
302
|
break;
|
|
303
303
|
|
|
304
|
+
case 'file':
|
|
305
|
+
await this.loadFile(details, isDefault, mode);
|
|
306
|
+
break;
|
|
307
|
+
|
|
304
308
|
default:
|
|
305
309
|
throw new Error(`Unknown source type: ${type}`);
|
|
306
310
|
}
|
|
@@ -679,19 +683,68 @@ class Library {
|
|
|
679
683
|
return;
|
|
680
684
|
}
|
|
681
685
|
|
|
682
|
-
const folderPath =
|
|
683
|
-
|
|
684
|
-
|
|
686
|
+
const folderPath = this.#resolveLocalSourcePath(details);
|
|
687
|
+
const cacheSubdir = await this.#prepareLocalSourceCache(folderPath);
|
|
688
|
+
await this.#loadLocalSource(new FolderContentLoader(folderPath, cacheSubdir));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Loads a single nominated JSON file holding a CodeSystem, ValueSet or ConceptMap
|
|
693
|
+
* (source syntax: `file:<path>`). The resource is routed to the appropriate provider
|
|
694
|
+
* based on its resourceType.
|
|
695
|
+
*
|
|
696
|
+
* Unlike a folder source, the file must exist and must be one of the three terminology
|
|
697
|
+
* resource types - anything else is an error, since the config named it explicitly.
|
|
698
|
+
*
|
|
699
|
+
* Relative paths are resolved against the project root (same convention as loadUcum etc.).
|
|
700
|
+
*
|
|
701
|
+
* @param {string} details - The file to load
|
|
702
|
+
* @param {boolean} isDefault - Unused; file sources don't register factories
|
|
703
|
+
* @param {string} mode - One of "fetch", "cs", "npm"
|
|
704
|
+
*/
|
|
705
|
+
// eslint-disable-next-line no-unused-vars
|
|
706
|
+
async loadFile(details, isDefault, mode) {
|
|
707
|
+
if (mode === "fetch" || mode === "cs") {
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const filePath = this.#resolveLocalSourcePath(details);
|
|
712
|
+
const cacheSubdir = await this.#prepareLocalSourceCache(filePath);
|
|
713
|
+
await this.#loadLocalSource(new FileContentLoader(filePath, cacheSubdir));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Relative paths in folder:/file: sources are resolved against the project root.
|
|
718
|
+
* @private
|
|
719
|
+
*/
|
|
720
|
+
#resolveLocalSourcePath(details) {
|
|
721
|
+
const text = String(details || '').trim();
|
|
722
|
+
if (!text) {
|
|
723
|
+
throw new Error('A local source requires a path, e.g. file:tx/data/my-valueset.json');
|
|
724
|
+
}
|
|
725
|
+
return path.isAbsolute(text) ? text : path.resolve(path.join(__dirname, '..', text));
|
|
726
|
+
}
|
|
685
727
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
728
|
+
/**
|
|
729
|
+
* Parks the Package*Provider SQLite caches under the terminology cache rather
|
|
730
|
+
* than polluting the user's source folder, and wipes between runs so edits to
|
|
731
|
+
* the source are reliably picked up.
|
|
732
|
+
* @private
|
|
733
|
+
*/
|
|
734
|
+
async #prepareLocalSourceCache(sourcePath) {
|
|
735
|
+
const hash = crypto.createHash('sha1').update(sourcePath).digest('hex').substring(0, 16);
|
|
690
736
|
const cacheSubdir = path.join(this.cacheFolder, 'folder-source-' + hash);
|
|
691
737
|
await fs.rm(cacheSubdir, { recursive: true, force: true });
|
|
692
738
|
await fs.mkdir(cacheSubdir, { recursive: true });
|
|
739
|
+
return cacheSubdir;
|
|
740
|
+
}
|
|
693
741
|
|
|
694
|
-
|
|
742
|
+
/**
|
|
743
|
+
* Shared loader for folder- and file-sourced content: registers the loader's
|
|
744
|
+
* CodeSystems, ValueSets and ConceptMaps with this library.
|
|
745
|
+
* @private
|
|
746
|
+
*/
|
|
747
|
+
async #loadLocalSource(contentLoader) {
|
|
695
748
|
await contentLoader.initialize();
|
|
696
749
|
|
|
697
750
|
this.packageSources.push(contentLoader.id() + "#" + contentLoader.version());
|