fhirsmith 0.12.0 → 0.13.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 +305 -0
- package/README.md +1 -1
- package/config-template.json +13 -0
- package/library/languages.js +191 -34
- package/library/path-safety.js +51 -0
- package/library/request-token.js +56 -0
- package/package.json +2 -2
- package/packages/packages.js +17 -118
- package/publisher/publisher.js +60 -30
- package/publisher/validation.js +15 -3
- package/server.js +12 -3
- package/translations/Messages.properties +1 -0
- package/tx/README.md +21 -0
- package/tx/cs/cs-api.js +30 -1
- package/tx/cs/cs-cs.js +1 -1
- package/tx/cs/cs-icd11.js +805 -0
- package/tx/cs/cs-lang.js +331 -85
- package/tx/cs/cs-loinc.js +45 -4
- package/tx/cs/cs-mimetypes.js +492 -21
- package/tx/cs/cs-snomed.js +38 -4
- package/tx/cs/cs-ucum.js +33 -3
- package/tx/cs/icd11-expressions.js +243 -0
- package/tx/importers/export-icd11-codesystem.js +823 -0
- package/tx/importers/icd11-schema.md +285 -0
- package/tx/importers/import-icd11.js +861 -0
- package/tx/importers/import-loinc.module.js +14 -7
- package/tx/importers/import-sct.module.js +10 -1
- package/tx/library/codesystem.js +21 -27
- package/tx/library/nesting-limit.js +79 -0
- package/tx/library/operation-outcome.js +97 -3
- package/tx/library/valueset.js +16 -20
- package/tx/library.js +23 -1
- package/tx/ocl/cache/cache-utils.cjs +5 -3
- package/tx/ocl/cs-ocl.cjs +73 -17
- package/tx/ocl/jobs/background-queue.cjs +6 -3
- package/tx/ocl/mappers/concept-mapper.cjs +9 -1
- package/tx/ocl/shared/patches.cjs +48 -1
- package/tx/ocl/vs-ocl.cjs +15 -13
- package/tx/sct/expressions.js +194 -17
- package/tx/tx.fhir.org.yml +2 -1
- package/tx/tx.js +15 -42
- package/tx/workers/batch-validate.js +6 -16
- package/tx/workers/batch.js +16 -28
- package/tx/workers/cache-control.js +6 -12
- package/tx/workers/closure.js +3 -8
- package/tx/workers/compare.js +6 -34
- package/tx/workers/expand.js +36 -45
- package/tx/workers/lookup.js +21 -39
- package/tx/workers/read.js +6 -40
- package/tx/workers/search.js +2 -8
- package/tx/workers/subsumes.js +58 -41
- package/tx/workers/translate.js +11 -17
- package/tx/workers/validate.js +76 -77
- package/tx/workers/worker.js +70 -1
- package/tx/xml/xml-base.js +243 -76
- package/tx/xversion/xv-valueset.js +39 -1
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* icd11-expressions.js -- postcoordinated ICD-11 expressions: parse, bind to axes, render.
|
|
3
|
+
*
|
|
4
|
+
* ICD-11 postcoordination has two levels of punctuation and one spelling per linearization:
|
|
5
|
+
*
|
|
6
|
+
* 1A00&XN8P1 MMS: a stem, then a value on one of its axes
|
|
7
|
+
* NC53.Y&XK8G&XJ778/PA71&XE5UF&XE4TZ a cluster: two stems, each with its own values
|
|
8
|
+
* 1D01.0Y/1G41/1G40 a cluster whose later members are values of the first
|
|
9
|
+
* d5409.qp3 ICF spells the same thing with a dot
|
|
10
|
+
* http://.../257068234 & http://.../194483911 any of it may be written as entity uris
|
|
11
|
+
*
|
|
12
|
+
* `&` says "this is a postcoordination value of the stem to my left": if it is not a value
|
|
13
|
+
* on any axis of that stem, the expression is wrong and says so. `/` says only "this is
|
|
14
|
+
* another element of the cluster": a member that happens to be a value on an unfilled axis
|
|
15
|
+
* of the current stem is taken as one (this is what the ICD Browser does, and it is what
|
|
16
|
+
* makes 1D01.0Y/1G41/1G40 mean what a coder means by it), and a member that is not simply
|
|
17
|
+
* starts a new stem.
|
|
18
|
+
*
|
|
19
|
+
* The syntax and the semantics are kept apart on purpose. The display renders the syntax --
|
|
20
|
+
* cluster members joined by " / ", values in brackets -- because that is what the coder
|
|
21
|
+
* wrote. The properties report the semantics: which value went on which axis. The two
|
|
22
|
+
* disagree for 1D01.0Y/1G41/1G40, and both are right.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const { Issue } = require('../library/operation-outcome');
|
|
28
|
+
|
|
29
|
+
const SCHEMA_NS = 'http://id.who.int/icd/schema';
|
|
30
|
+
|
|
31
|
+
/** One element of the written expression, with the separator that introduced it. */
|
|
32
|
+
class ExpressionToken {
|
|
33
|
+
constructor(text, sep) {
|
|
34
|
+
this.text = text;
|
|
35
|
+
this.sep = sep; // null for the first token, otherwise '&', '/' or '.'
|
|
36
|
+
this.concept = null; // the ICD11Concept it names, once resolved
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A parsed expression. `tokens` and `members` are the syntax; `stems` is the semantics.
|
|
42
|
+
* The context object the provider hands around for a postcoordinated code.
|
|
43
|
+
*/
|
|
44
|
+
class ICD11Expression {
|
|
45
|
+
constructor(raw, tokens, members, stems) {
|
|
46
|
+
this.raw = raw;
|
|
47
|
+
this.tokens = tokens;
|
|
48
|
+
this.members = members; // [{ head: Token, tail: [Token] }] -- as written
|
|
49
|
+
this.stems = stems; // [{ stem: Token, bindings: [{ axis, token }] }] -- as meant
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The short-code spelling: the form $lookup echoes back, whichever form came in. */
|
|
53
|
+
get canonicalCode() {
|
|
54
|
+
return this.tokens.map((t, i) => (i ? t.sep : '') + t.concept.canonicalCode).join('');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The entity-uri spelling. ICF's dot is the same operator as MMS's `&`, so it renders as
|
|
59
|
+
* `&` here -- the uri form has only the one spelling.
|
|
60
|
+
*/
|
|
61
|
+
get uriForm() {
|
|
62
|
+
return this.tokens
|
|
63
|
+
.map((t, i) => (i ? ` ${t.sep === '.' ? '&' : t.sep} ` : '') + t.concept.uri)
|
|
64
|
+
.join('');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True when nothing was postcoordinated after all -- a bare code in expression clothing. */
|
|
68
|
+
get isTrivial() {
|
|
69
|
+
return this.tokens.length === 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Split the written form into tokens. A uri is one token including its slashes -- the `/`
|
|
75
|
+
* inside `http://id.who.int/icd/release/11/mms/257068234` is not a cluster separator -- so
|
|
76
|
+
* a uri runs to the next `&` or space. That leaves `/` between two uris ambiguous, which is
|
|
77
|
+
* a property of WHO's syntax rather than of this parser; the uri form in practice uses `&`.
|
|
78
|
+
*
|
|
79
|
+
* Returns null if the text is not shaped like an expression at all.
|
|
80
|
+
*/
|
|
81
|
+
function tokenize(text) {
|
|
82
|
+
const tokens = [];
|
|
83
|
+
const n = text.length;
|
|
84
|
+
let i = 0, sep = null;
|
|
85
|
+
while (i < n) {
|
|
86
|
+
while (i < n && text[i] === ' ') i++;
|
|
87
|
+
if (i >= n) return null; // trailing separator
|
|
88
|
+
const start = i;
|
|
89
|
+
const isUri = /^https?:\/\//.test(text.slice(i));
|
|
90
|
+
while (i < n && text[i] !== '&' && text[i] !== ' ' && (isUri || text[i] !== '/')) i++;
|
|
91
|
+
tokens.push(new ExpressionToken(text.slice(start, i), sep));
|
|
92
|
+
while (i < n && text[i] === ' ') i++;
|
|
93
|
+
if (i >= n) break;
|
|
94
|
+
if (text[i] !== '&' && text[i] !== '/') return null;
|
|
95
|
+
sep = text[i];
|
|
96
|
+
i++;
|
|
97
|
+
}
|
|
98
|
+
return tokens.length ? tokens : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* ICF writes `d5409.qp3` where MMS writes `d5409&qp3`. A dot is only a separator when the
|
|
103
|
+
* whole token is not itself a code -- `1D01.0Y` is one code, and `BD11.2` is another -- so
|
|
104
|
+
* the longest prefix that resolves wins, and what is left becomes dot-separated values.
|
|
105
|
+
*
|
|
106
|
+
* Returns the tokens this one expands to, or null if nothing resolves.
|
|
107
|
+
*/
|
|
108
|
+
async function resolveToken(ctx, token) {
|
|
109
|
+
const whole = await ctx.resolve(token.text);
|
|
110
|
+
if (whole) {
|
|
111
|
+
token.concept = whole;
|
|
112
|
+
return [token];
|
|
113
|
+
}
|
|
114
|
+
if (!token.text.includes('.')) return null;
|
|
115
|
+
const parts = token.text.split('.');
|
|
116
|
+
for (let take = parts.length - 1; take >= 1; take--) {
|
|
117
|
+
const head = parts.slice(0, take).join('.');
|
|
118
|
+
const concept = await ctx.resolve(head);
|
|
119
|
+
if (!concept) continue;
|
|
120
|
+
const out = [new ExpressionToken(head, token.sep)];
|
|
121
|
+
out[0].concept = concept;
|
|
122
|
+
for (const rest of parts.slice(take)) {
|
|
123
|
+
const value = await ctx.resolve(rest);
|
|
124
|
+
if (!value) return null; // e.g. the pre-2026 ICF form d5409.3
|
|
125
|
+
const t = new ExpressionToken(rest, '.');
|
|
126
|
+
t.concept = value;
|
|
127
|
+
out.push(t);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The postcoordination axes a stem declares. Required axes come first, then WHO's own
|
|
136
|
+
* order. That ordering is what decides where an ambiguous value lands: 1D01.0Y declares
|
|
137
|
+
* hasManifestation (optional, second) and hasCausingCondition (required, fourth), 1G40 and
|
|
138
|
+
* 1G41 are both legal on either, and the ICD Browser reads 1D01.0Y/1G41/1G40 as causing
|
|
139
|
+
* condition then manifestation. An axis that must be filled is the one a coder is most
|
|
140
|
+
* likely to have been filling, so it gets first refusal.
|
|
141
|
+
*/
|
|
142
|
+
async function axesOf(ctx, concept) {
|
|
143
|
+
return ctx.all(
|
|
144
|
+
`SELECT id, axis, axis_name, required, allow_multiple, value_set_uri
|
|
145
|
+
FROM pc_scale WHERE concept = ? ORDER BY required DESC, seq`, [concept.id]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Is `value` one of the permitted values of this axis -- a scale root or a descendant? */
|
|
149
|
+
async function permitted(ctx, axis, value) {
|
|
150
|
+
const row = await ctx.get(
|
|
151
|
+
`SELECT 1 AS ok FROM pc_scale_entity e
|
|
152
|
+
JOIN concept_closure cl ON cl.ancestor = e.target
|
|
153
|
+
WHERE e.scale = ? AND cl.descendant = ? LIMIT 1`, [axis.id, value.id]);
|
|
154
|
+
return !!row;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Put a value on an axis of the stem. An axis not yet used is always preferred over one
|
|
159
|
+
* that is, which is what keeps the two values of 1D01.0Y/1G41/1G40 apart: both are legal on
|
|
160
|
+
* hasCausingCondition and on hasManifestation, and coalescing them onto the first is the
|
|
161
|
+
* thing the ICD-API gets wrong (see doco.txt item 13). The same rule sends the second
|
|
162
|
+
* XM4SL9 of PB20&XM4SL9&XM4SL9&... to a second axis rather than dropping it.
|
|
163
|
+
*
|
|
164
|
+
* Returns the axis, or null when the value is on none of them.
|
|
165
|
+
*/
|
|
166
|
+
async function bind(ctx, stem, token) {
|
|
167
|
+
let fallback = null;
|
|
168
|
+
for (const axis of stem.axes) {
|
|
169
|
+
if (!await permitted(ctx, axis, token.concept)) continue;
|
|
170
|
+
if (!stem.used.has(axis.id)) {
|
|
171
|
+
stem.used.add(axis.id);
|
|
172
|
+
stem.bindings.push({ axis, token });
|
|
173
|
+
return axis;
|
|
174
|
+
}
|
|
175
|
+
if (fallback === null) fallback = axis;
|
|
176
|
+
}
|
|
177
|
+
if (fallback === null) return null;
|
|
178
|
+
// every axis this value fits is already carrying one. allowMultipleValues decides whether
|
|
179
|
+
// that is legal; nothing in the suite pins it yet, so the value is kept rather than lost.
|
|
180
|
+
stem.bindings.push({ axis: fallback, token });
|
|
181
|
+
return fallback;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Parse and bind. Returns an ICD11Expression, or null when the text does not resolve (which
|
|
186
|
+
* the caller reports as an ordinary unknown code). Throws an Issue when the text does
|
|
187
|
+
* resolve but says something that is not true -- a value on no axis of its stem -- because
|
|
188
|
+
* "not found" would hide the reason.
|
|
189
|
+
*/
|
|
190
|
+
async function parseExpression(ctx, text) {
|
|
191
|
+
const raw = tokenize(text);
|
|
192
|
+
if (!raw) return null;
|
|
193
|
+
|
|
194
|
+
const tokens = [];
|
|
195
|
+
for (const t of raw) {
|
|
196
|
+
const resolved = await resolveToken(ctx, t);
|
|
197
|
+
if (!resolved) return null;
|
|
198
|
+
tokens.push(...resolved);
|
|
199
|
+
}
|
|
200
|
+
if (tokens.length === 1) return null; // a plain code, already handled
|
|
201
|
+
|
|
202
|
+
// syntax: cluster members, split at '/'
|
|
203
|
+
const members = [];
|
|
204
|
+
for (const t of tokens) {
|
|
205
|
+
if (t.sep === '/' || members.length === 0) members.push({ head: t, tail: [] });
|
|
206
|
+
else members[members.length - 1].tail.push(t);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// semantics: which member is a stem, and which value went on which axis
|
|
210
|
+
const stems = [];
|
|
211
|
+
let cur = null;
|
|
212
|
+
for (let k = 0; k < members.length; k++) {
|
|
213
|
+
const m = members[k];
|
|
214
|
+
if (k > 0 && m.tail.length === 0 && cur && await bind(ctx, cur, m.head)) {
|
|
215
|
+
continue; // a cluster member that is a value
|
|
216
|
+
}
|
|
217
|
+
cur = { stem: m.head, axes: await axesOf(ctx, m.head.concept), bindings: [], used: new Set() };
|
|
218
|
+
stems.push(cur);
|
|
219
|
+
for (const t of m.tail) {
|
|
220
|
+
if (!await bind(ctx, cur, t)) {
|
|
221
|
+
throw notOnAnyAxis(ctx, cur, t);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return new ICD11Expression(text, tokens, members, stems);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* `&` asserted that this value belongs on an axis of the stem, and it does not. Say which
|
|
230
|
+
* value, and which axes the stem actually has: "could not be found in the CodeSystem" is
|
|
231
|
+
* the answer that sends people looking in the wrong place (doco.txt item 15).
|
|
232
|
+
*/
|
|
233
|
+
function notOnAnyAxis(ctx, stem, token) {
|
|
234
|
+
const axes = stem.axes.map(a => a.axis_name).join(', ');
|
|
235
|
+
const msg = axes
|
|
236
|
+
? `'${token.text}' is not a permitted value on any postcoordination axis of `
|
|
237
|
+
+ `'${stem.stem.text}'. That stem declares: ${axes}`
|
|
238
|
+
: `'${token.text}' cannot be postcoordinated onto '${stem.stem.text}': that concept `
|
|
239
|
+
+ `declares no postcoordination axes`;
|
|
240
|
+
return new Issue('error', 'code-invalid', null, null, msg, 'invalid-code', 400).handleAsOO(400);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
module.exports = { ICD11Expression, ExpressionToken, parseExpression, tokenize, SCHEMA_NS };
|