fhirsmith 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +323 -4
- 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 +14 -1
- package/tx/cs/cs-icd11.js +844 -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/provider.js +1 -1
- 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,844 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cs-icd11.js -- CodeSystemProvider for ICD-11, backed by the SQLite database built by
|
|
3
|
+
* tx/importers/import-icd11.js. See tx/importers/icd11-schema.md for the schema.
|
|
4
|
+
*
|
|
5
|
+
* One database carries three code systems -- the MMS and ICF linearizations and the
|
|
6
|
+
* Foundation -- so the factory is constructed per system and several factories share one
|
|
7
|
+
* file. `tx/library.js` registers one per system found in the database.
|
|
8
|
+
*
|
|
9
|
+
* The tx-ecosystem `icd-11` test suite is the specification -- it was written to assert
|
|
10
|
+
* correct behaviour, and it is what these are tested against; there is no unit test file
|
|
11
|
+
* of their own. Metadata, code location, displays, definitions, designations, properties,
|
|
12
|
+
* extendLookup, filters, iteration, subsumption and postcoordinated expressions are all
|
|
13
|
+
* implemented.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const sqlite3 = require('sqlite3').verbose();
|
|
17
|
+
const { CodeSystem } = require('../library/codesystem');
|
|
18
|
+
const { CodeSystemFactoryProvider, FilterExecutionContext } = require('./cs-api');
|
|
19
|
+
const { BaseCSServices } = require('./cs-base');
|
|
20
|
+
const { validateArrayParameter } = require('../../library/utilities');
|
|
21
|
+
const { ICD11Expression, parseExpression } = require('./icd11-expressions');
|
|
22
|
+
const { Issue } = require('../library/operation-outcome');
|
|
23
|
+
const assert = require('assert');
|
|
24
|
+
|
|
25
|
+
// WHO's own vocabulary for the kinds of text an entity carries, used as designation.use.
|
|
26
|
+
const SCHEMA_NS = 'http://id.who.int/icd/schema';
|
|
27
|
+
|
|
28
|
+
// designation.kind values that are not designations: `definition` is
|
|
29
|
+
// CodeSystem.concept.definition, and is returned by definition() instead.
|
|
30
|
+
const NOT_A_DESIGNATION = new Set(['definition']);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The context object handed back by locate() and passed to every accessor. It carries
|
|
34
|
+
* enough of the concept row that the cheap accessors never go back to the database.
|
|
35
|
+
*/
|
|
36
|
+
class ICD11Concept {
|
|
37
|
+
constructor(row) {
|
|
38
|
+
this.id = row.id; // internal rowid, used for joins
|
|
39
|
+
this.entityId = row.entity_id; // 257068234, or 1363559646/other for a residual
|
|
40
|
+
this.uri = row.uri; // unversioned canonical uri
|
|
41
|
+
this.code = row.code; // short code; null for groupers
|
|
42
|
+
this.classKind = row.class_kind; // chapter | block | window | category
|
|
43
|
+
this.selectable = !!row.selectable;
|
|
44
|
+
this.residual = row.residual; // null | other | unspecified
|
|
45
|
+
this.depth = row.depth;
|
|
46
|
+
this.blockId = row.block_id;
|
|
47
|
+
this.codeRange = row.code_range;
|
|
48
|
+
this.sourceUri = row.source_uri; // the Foundation entity this came from
|
|
49
|
+
this.browserUrl = row.browser_url;
|
|
50
|
+
this.lft = row.lft; // nested set; null for the Foundation (a DAG)
|
|
51
|
+
this.rgt = row.rgt;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What this concept answers to. WHO's server accepts both the short code and the entity
|
|
56
|
+
* uri, and the test suite asserts both address the same concept, so the provider echoes
|
|
57
|
+
* back whichever form is canonical for the concept: the short code where there is one,
|
|
58
|
+
* the uri otherwise. If the community settles the straw poll differently, this is the
|
|
59
|
+
* one place that changes.
|
|
60
|
+
*/
|
|
61
|
+
get canonicalCode() {
|
|
62
|
+
return this.code || this.uri;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One filter's worth of concepts. Two shapes, because they are answered two different ways:
|
|
68
|
+
* an `is-a` set is a range of the transitive closure and can be probed for one code without
|
|
69
|
+
* being built, while a text search has to be run before anything can be said about it.
|
|
70
|
+
*/
|
|
71
|
+
class ICD11FilterSet {
|
|
72
|
+
constructor(kind, props) {
|
|
73
|
+
this.kind = kind; // 'isa' | 'search'
|
|
74
|
+
this.rows = null; // materialised lazily, and only when iterated
|
|
75
|
+
this.cursor = 0;
|
|
76
|
+
Object.assign(this, props);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class ICD11Services extends BaseCSServices {
|
|
81
|
+
constructor(opContext, supplements, db, shared) {
|
|
82
|
+
super(opContext, supplements);
|
|
83
|
+
this.db = db;
|
|
84
|
+
this.shared = shared; // { systemCode, url, version, title, isTree, systemId, count }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
close() {
|
|
88
|
+
if (this.db) {
|
|
89
|
+
this.db.close();
|
|
90
|
+
this.db = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// -- small promise wrappers, so no query below carries callback boilerplate -----------
|
|
95
|
+
|
|
96
|
+
#get(sql, params = []) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
this.db.get(sql, params, (err, row) => err ? reject(err) : resolve(row));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#all(sql, params = []) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
this.db.all(sql, params, (err, rows) => err ? reject(err) : resolve(rows || []));
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// -- metadata -------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
system() { return this.shared.url; }
|
|
111
|
+
version() { return this.shared.version; }
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The default is system|version, but $lookup's `name` is meant to be a display name for
|
|
115
|
+
* the code system. OMOP overrides it the same way; WHO returns the linearization title.
|
|
116
|
+
*/
|
|
117
|
+
name() { return this.shared.title; }
|
|
118
|
+
description() { return this.shared.title; }
|
|
119
|
+
totalCount() { return this.shared.count; }
|
|
120
|
+
defLang() { return 'en'; }
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* WHO publishes the classification in the languages the database was crawled with, so
|
|
124
|
+
* the base class's English-only assumption is wrong here: it decides whether a failed
|
|
125
|
+
* display check says "no display in that language" or "none at all".
|
|
126
|
+
*/
|
|
127
|
+
hasAnyDisplays(languages) {
|
|
128
|
+
const langs = this._ensureLanguages(languages);
|
|
129
|
+
for (const l of langs) {
|
|
130
|
+
const base = l.language || String(l.code || '').split('-')[0];
|
|
131
|
+
if (base && this.shared.languages.includes(base)) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return super.hasAnyDisplays(langs);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Versions are WHO release ids -- '2026-01'. */
|
|
139
|
+
versionAlgorithm() { return 'date'; }
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The WHO CodeSystem says caseSensitive: false, but its own $validate-code rejects
|
|
143
|
+
* '1a00'. The codes are case-sensitive tokens; the declaration is the part that is
|
|
144
|
+
* wrong, and the test suite asserts the sensitive behaviour.
|
|
145
|
+
*/
|
|
146
|
+
isCaseSensitive() { return true; }
|
|
147
|
+
|
|
148
|
+
hasParents() { return true; }
|
|
149
|
+
|
|
150
|
+
/** MMS and ICF are trees; the Foundation is a DAG. */
|
|
151
|
+
hasMultiHierarchy() { return !this.shared.isTree; }
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* MMS and ICF admit postcoordinated expressions, so the set of valid codes is not
|
|
155
|
+
* enumerable. The Foundation has no postcoordination.
|
|
156
|
+
*/
|
|
157
|
+
isNotClosed() { return this.shared.systemCode !== 'foundation'; }
|
|
158
|
+
|
|
159
|
+
versionIsMoreDetailed() { return false; }
|
|
160
|
+
|
|
161
|
+
// -- locating -------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Accepts the short code and the unversioned entity uri, matching what WHO's server
|
|
165
|
+
* accepts. Returns { context, message }; a plain miss carries no message, which the
|
|
166
|
+
* validation worker treats as "not there" rather than "malformed".
|
|
167
|
+
*/
|
|
168
|
+
async locate(code) {
|
|
169
|
+
assert(!code || typeof code === 'string', 'code must be a string');
|
|
170
|
+
if (!code) return { context: null, message: 'Empty code' };
|
|
171
|
+
|
|
172
|
+
const concept = await this.#resolve(code);
|
|
173
|
+
if (concept) return { context: concept, message: null };
|
|
174
|
+
|
|
175
|
+
// Not a code as written. It may still be a postcoordinated expression -- but only in a
|
|
176
|
+
// linearization (the Foundation has no postcoordination), and only if it is punctuated
|
|
177
|
+
// like one, so an ordinary unknown code costs nothing extra. parseExpression returns
|
|
178
|
+
// null when the text does not resolve, and throws when it resolves but is wrong.
|
|
179
|
+
if (this.shared.systemCode !== 'foundation' && /[&/.]/.test(code)) {
|
|
180
|
+
const expr = await parseExpression(this.#expressionContext(), code);
|
|
181
|
+
if (expr) return { context: expr, message: null };
|
|
182
|
+
}
|
|
183
|
+
return { context: null, message: undefined };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A plain code or entity uri, as written; null if it is neither. */
|
|
187
|
+
async #resolve(code) {
|
|
188
|
+
const row = await this.#get(
|
|
189
|
+
`SELECT * FROM concept WHERE system = ? AND (code = ? OR uri = ?)`,
|
|
190
|
+
[this.shared.systemId, code, code]);
|
|
191
|
+
return row ? new ICD11Concept(row) : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The three things the expression parser needs from the database. */
|
|
195
|
+
#expressionContext() {
|
|
196
|
+
return {
|
|
197
|
+
get: (sql, params) => this.#get(sql, params),
|
|
198
|
+
all: (sql, params) => this.#all(sql, params),
|
|
199
|
+
resolve: (text) => this.#resolve(text),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The label an expression shows for one of its parts. Falls back to the default language
|
|
205
|
+
* the same way display() does, so a French lookup of a concept with no French title still
|
|
206
|
+
* renders something.
|
|
207
|
+
*/
|
|
208
|
+
async #title(concept) {
|
|
209
|
+
const row = await this.#get(
|
|
210
|
+
`SELECT value FROM designation
|
|
211
|
+
WHERE concept = ? AND kind = 'title' AND lang = ? ORDER BY seq LIMIT 1`,
|
|
212
|
+
[concept.id, this.#displayLang()]);
|
|
213
|
+
if (row) return row.value.trim();
|
|
214
|
+
const def = await this.#get(
|
|
215
|
+
`SELECT value FROM designation
|
|
216
|
+
WHERE concept = ? AND kind = 'title' AND lang = ? ORDER BY seq LIMIT 1`,
|
|
217
|
+
[concept.id, this.defLang()]);
|
|
218
|
+
return def ? def.value.trim() : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The display for an expression renders what was written, not what it means: cluster
|
|
223
|
+
* members joined by " / ", each postcoordinated value in brackets after its stem. So
|
|
224
|
+
* 1D01.0Y/1G41/1G40 reads as three things separated by slashes even though two of them
|
|
225
|
+
* are bound to axes of the first -- which is how the coder wrote it, and how the ICD
|
|
226
|
+
* Browser shows it back.
|
|
227
|
+
*/
|
|
228
|
+
async #expressionDisplay(expr) {
|
|
229
|
+
const parts = [];
|
|
230
|
+
for (const m of expr.members) {
|
|
231
|
+
let text = await this.#title(m.head.concept) || m.head.text;
|
|
232
|
+
for (const t of m.tail) {
|
|
233
|
+
text += ` [${await this.#title(t.concept) || t.text}]`;
|
|
234
|
+
}
|
|
235
|
+
parts.push(text);
|
|
236
|
+
}
|
|
237
|
+
return parts.join(' / ');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Accepts a code string or an existing context; throws on anything else. */
|
|
241
|
+
async #ensureContext(context) {
|
|
242
|
+
if (context === null || context === undefined) return null;
|
|
243
|
+
if (typeof context === 'string') {
|
|
244
|
+
const result = await this.locate(context);
|
|
245
|
+
if (result.context == null) {
|
|
246
|
+
throw new Error(result.message ?? `Code '${context}' not found in ${this.system()}`);
|
|
247
|
+
}
|
|
248
|
+
return result.context;
|
|
249
|
+
}
|
|
250
|
+
if (context instanceof ICD11Concept || context instanceof ICD11Expression) return context;
|
|
251
|
+
throw new Error(`Unknown Type at #ensureContext: ${typeof context}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async code(context) {
|
|
255
|
+
const ctxt = await this.#ensureContext(context);
|
|
256
|
+
return ctxt === null ? null : ctxt.canonicalCode;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async display(context) {
|
|
260
|
+
const ctxt = await this.#ensureContext(context);
|
|
261
|
+
if (ctxt === null) return null;
|
|
262
|
+
if (ctxt instanceof ICD11Expression) return this.#expressionDisplay(ctxt);
|
|
263
|
+
const row = await this.#get(
|
|
264
|
+
`SELECT value FROM designation
|
|
265
|
+
WHERE concept = ? AND kind = 'title' AND lang = ? ORDER BY seq LIMIT 1`,
|
|
266
|
+
[ctxt.id, this.#displayLang()]);
|
|
267
|
+
if (row) return row.value.trim();
|
|
268
|
+
// fall back to the default language rather than returning nothing
|
|
269
|
+
const def = await this.#get(
|
|
270
|
+
`SELECT value FROM designation
|
|
271
|
+
WHERE concept = ? AND kind = 'title' AND lang = ? ORDER BY seq LIMIT 1`,
|
|
272
|
+
[ctxt.id, this.defLang()]);
|
|
273
|
+
return def ? def.value.trim() : null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async definition(context) {
|
|
277
|
+
const ctxt = await this.#ensureContext(context);
|
|
278
|
+
if (ctxt === null || ctxt instanceof ICD11Expression) return null;
|
|
279
|
+
const row = await this.#get(
|
|
280
|
+
`SELECT value FROM designation
|
|
281
|
+
WHERE concept = ? AND kind = 'definition' AND lang = ? ORDER BY seq LIMIT 1`,
|
|
282
|
+
[ctxt.id, this.#displayLang()]);
|
|
283
|
+
return row ? row.value : null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Groupers -- blocks, windows, and chapters -- cannot be used to code with. */
|
|
287
|
+
async isAbstract(context) {
|
|
288
|
+
const ctxt = await this.#ensureContext(context);
|
|
289
|
+
if (ctxt === null || ctxt instanceof ICD11Expression) return false;
|
|
290
|
+
return !ctxt.selectable;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async isInactive() { return false; } // ICD-11 releases do not carry inactive codes
|
|
294
|
+
async isDeprecated() { return false; }
|
|
295
|
+
|
|
296
|
+
async parent(context) {
|
|
297
|
+
const ctxt = await this.#ensureContext(context);
|
|
298
|
+
// an expression has no place in the hierarchy: its stem does, but the expression is a
|
|
299
|
+
// narrower thing than any concept in the classification
|
|
300
|
+
if (ctxt === null || ctxt instanceof ICD11Expression) return null;
|
|
301
|
+
const row = await this.#get(
|
|
302
|
+
`SELECT c.code, c.uri FROM concept_parent cp JOIN concept c ON c.id = cp.parent
|
|
303
|
+
WHERE cp.child = ? ORDER BY cp.seq LIMIT 1`, [ctxt.id]);
|
|
304
|
+
return row ? (row.code || row.uri) : null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The best language for display, given what the request asked for. Languages is an
|
|
309
|
+
* iterable of Language, in quality order; the database holds translations keyed on the
|
|
310
|
+
* primary subtag, so that is what is matched.
|
|
311
|
+
*/
|
|
312
|
+
#displayLang() {
|
|
313
|
+
const langs = this.opContext?.langs;
|
|
314
|
+
if (langs) {
|
|
315
|
+
for (const l of langs) {
|
|
316
|
+
const base = l.language || String(l.code || '').split('-')[0];
|
|
317
|
+
if (base && this.shared.languages.includes(base)) return base;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return this.defLang();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// -- designations ----------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Every piece of text the entity carries, in every language the database holds. The
|
|
327
|
+
* title is the display; the rest -- index terms, inclusions, coding notes and so on --
|
|
328
|
+
* are designations tagged with WHO's own name for what they are.
|
|
329
|
+
*
|
|
330
|
+
* This is also where $validate-code gets its display from: the validation worker builds
|
|
331
|
+
* a Designations list and asks it for preferredDisplay(), rather than calling display().
|
|
332
|
+
*/
|
|
333
|
+
async designations(context, displays) {
|
|
334
|
+
const ctxt = await this.#ensureContext(context);
|
|
335
|
+
if (!ctxt || !displays) return;
|
|
336
|
+
if (ctxt instanceof ICD11Expression) {
|
|
337
|
+
// an expression has exactly one piece of text: the display built from its parts
|
|
338
|
+
const text = await this.#expressionDisplay(ctxt);
|
|
339
|
+
if (text) displays.addDesignation(true, 'active', this.#displayLang(),
|
|
340
|
+
CodeSystem.makeUseForDisplay(), text);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const rows = await this.#all(
|
|
344
|
+
`SELECT lang, kind, value FROM designation WHERE concept = ? ORDER BY lang, seq`,
|
|
345
|
+
[ctxt.id]);
|
|
346
|
+
for (const r of rows) {
|
|
347
|
+
if (NOT_A_DESIGNATION.has(r.kind)) continue;
|
|
348
|
+
const value = r.value.trim();
|
|
349
|
+
if (!value) continue;
|
|
350
|
+
if (r.kind === 'title') {
|
|
351
|
+
displays.addDesignation(true, 'active', r.lang, CodeSystem.makeUseForDisplay(), value);
|
|
352
|
+
} else {
|
|
353
|
+
displays.addDesignation(false, 'active', r.lang,
|
|
354
|
+
{ system: SCHEMA_NS, code: r.kind }, value);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
this._listSupplementDesignations(ctxt.canonicalCode, displays);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// -- properties ------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The properties this code system defines. Uri-valued things are declared as strings:
|
|
364
|
+
* CodeSystem.property.type has no `uri`, and concept.property.value[x] has no valueUri
|
|
365
|
+
* either. (The WHO server gets this wrong on expansions, where it returns valueUri and
|
|
366
|
+
* no conformant client can parse the result.)
|
|
367
|
+
*/
|
|
368
|
+
propertyDefinitions() {
|
|
369
|
+
const defs = [
|
|
370
|
+
// code, not string: the entity uri identifies a concept in this code system, and
|
|
371
|
+
// that is what the Geneva review concluded WHO gets wrong by returning valueString.
|
|
372
|
+
{ code: 'id', uri: `${SCHEMA_NS}/entityId`, type: 'code',
|
|
373
|
+
description: 'The stable WHO entity uri.' },
|
|
374
|
+
{ code: 'code', uri: `${SCHEMA_NS}/code`, type: 'string',
|
|
375
|
+
description: 'The short code, where the entity has one.' },
|
|
376
|
+
{ code: 'classKind', uri: `${SCHEMA_NS}/classKind`, type: 'code',
|
|
377
|
+
description: 'chapter | block | window | category.' },
|
|
378
|
+
{ code: 'notSelectable', uri: 'http://hl7.org/fhir/concept-properties#notSelectable',
|
|
379
|
+
type: 'boolean', description: 'The concept is a grouper and cannot be used to code with.' },
|
|
380
|
+
{ code: 'parent', uri: 'http://hl7.org/fhir/concept-properties#parent', type: 'code' },
|
|
381
|
+
{ code: 'child', uri: 'http://hl7.org/fhir/concept-properties#child', type: 'code' },
|
|
382
|
+
{ code: 'browserUrl', uri: `${SCHEMA_NS}/browserUrl`, type: 'string' },
|
|
383
|
+
];
|
|
384
|
+
if (this.shared.systemCode !== 'foundation') {
|
|
385
|
+
defs.push(
|
|
386
|
+
{ code: 'source', uri: `${SCHEMA_NS}/source`, type: 'string',
|
|
387
|
+
description: 'The Foundation entity this linearization entry came from.' },
|
|
388
|
+
{ code: 'blockId', uri: `${SCHEMA_NS}/blockId`, type: 'string' },
|
|
389
|
+
{ code: 'codeRange', uri: `${SCHEMA_NS}/codeRange`, type: 'string' },
|
|
390
|
+
{ code: 'postcoordinationScale', uri: `${SCHEMA_NS}/postcoordinationScale`, type: 'string',
|
|
391
|
+
description: 'A postcoordination axis of this concept; repeats once per axis.' },
|
|
392
|
+
// only ever returned for a postcoordinated expression, never for a plain concept
|
|
393
|
+
{ code: 'stem', uri: `${SCHEMA_NS}/stem`, type: 'string',
|
|
394
|
+
description: 'A concept in the expression that carries axes of its own.' },
|
|
395
|
+
{ code: 'postcoordinationValues', uri: `${SCHEMA_NS}/postcoordinationValues`, type: 'string',
|
|
396
|
+
description: 'A value in the expression, with the axis it was placed on.' });
|
|
397
|
+
}
|
|
398
|
+
return defs;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async properties(context) {
|
|
402
|
+
const ctxt = await this.#ensureContext(context);
|
|
403
|
+
if (!ctxt) return [];
|
|
404
|
+
if (ctxt instanceof ICD11Expression) {
|
|
405
|
+
return [{ code: 'id', valueCode: ctxt.uriForm }, { code: 'code', valueString: ctxt.canonicalCode }];
|
|
406
|
+
}
|
|
407
|
+
const out = [{ code: 'id', valueCode: ctxt.uri }];
|
|
408
|
+
if (ctxt.classKind) out.push({ code: 'classKind', valueCode: ctxt.classKind });
|
|
409
|
+
if (ctxt.code) out.push({ code: 'code', valueString: ctxt.code });
|
|
410
|
+
if (!ctxt.selectable) out.push({ code: 'notSelectable', valueBoolean: true });
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// -- lookup ----------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Adds the ICD-11 specific parts of a $lookup response. `props` is the caller's
|
|
418
|
+
* requested property list; _hasProp honours it, treating an empty list as "everything"
|
|
419
|
+
* and '*' as the same. WHO ignores the property parameter entirely and always returns
|
|
420
|
+
* the lot -- for 1A00 that is 7.5KB, mostly index terms -- which is one of the things
|
|
421
|
+
* the test suite pins.
|
|
422
|
+
*/
|
|
423
|
+
async extendLookup(ctxt, props, params) {
|
|
424
|
+
validateArrayParameter(props, 'props', String);
|
|
425
|
+
validateArrayParameter(params, 'params', Object);
|
|
426
|
+
const concept = await this.#ensureContext(ctxt);
|
|
427
|
+
if (!concept) return;
|
|
428
|
+
if (concept instanceof ICD11Expression) {
|
|
429
|
+
return this.#extendLookupExpression(concept, props, params);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (this._hasProp(props, 'id', true)) {
|
|
433
|
+
this._addCodeProperty(params, 'property', 'id', concept.uri);
|
|
434
|
+
}
|
|
435
|
+
if (concept.code && this._hasProp(props, 'code', true)) {
|
|
436
|
+
this._addCodeProperty(params, 'property', 'code', concept.code);
|
|
437
|
+
}
|
|
438
|
+
if (this._hasProp(props, 'classKind', true) && concept.classKind) {
|
|
439
|
+
this._addCodeProperty(params, 'property', 'classKind', concept.classKind);
|
|
440
|
+
}
|
|
441
|
+
if (!concept.selectable && this._hasProp(props, 'notSelectable', true)) {
|
|
442
|
+
// BaseCSServices._addProperty always writes valueString, and this one is a boolean.
|
|
443
|
+
params.push({ name: 'property', part: [
|
|
444
|
+
{ name: 'code', valueCode: 'notSelectable' },
|
|
445
|
+
{ name: 'value', valueBoolean: true }] });
|
|
446
|
+
}
|
|
447
|
+
if (concept.blockId && this._hasProp(props, 'blockId', true)) {
|
|
448
|
+
this._addStringProperty(params, 'property', 'blockId', concept.blockId);
|
|
449
|
+
}
|
|
450
|
+
if (concept.codeRange && this._hasProp(props, 'codeRange', true)) {
|
|
451
|
+
this._addStringProperty(params, 'property', 'codeRange', concept.codeRange);
|
|
452
|
+
}
|
|
453
|
+
if (concept.sourceUri && this._hasProp(props, 'source', false)) {
|
|
454
|
+
this._addStringProperty(params, 'property', 'source', concept.sourceUri);
|
|
455
|
+
}
|
|
456
|
+
if (concept.browserUrl && this._hasProp(props, 'browserUrl', false)) {
|
|
457
|
+
this._addStringProperty(params, 'property', 'browserUrl', concept.browserUrl);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// hierarchy. Parents and children are codes in this code system, so they use whichever
|
|
461
|
+
// form is canonical for the target -- the short code where there is one.
|
|
462
|
+
if (this._hasProp(props, 'parent', true)) {
|
|
463
|
+
for (const r of await this.#all(
|
|
464
|
+
`SELECT c.code, c.uri FROM concept_parent cp JOIN concept c ON c.id = cp.parent
|
|
465
|
+
WHERE cp.child = ? ORDER BY cp.seq`, [concept.id])) {
|
|
466
|
+
this._addCodeProperty(params, 'property', 'parent', r.code || r.uri);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (this._hasProp(props, 'child', false)) {
|
|
470
|
+
for (const r of await this.#all(
|
|
471
|
+
`SELECT c.code, c.uri FROM concept_parent cp JOIN concept c ON c.id = cp.child
|
|
472
|
+
WHERE cp.parent = ? ORDER BY cp.seq`, [concept.id])) {
|
|
473
|
+
this._addCodeProperty(params, 'property', 'child', r.code || r.uri);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// The postcoordination axes this concept declares, as the canonical of the ValueSet
|
|
478
|
+
// that enumerates each one. The cardinality rules live on those value sets.
|
|
479
|
+
if (this.shared.systemCode !== 'foundation' && this._hasProp(props, 'postcoordinationScale', true)) {
|
|
480
|
+
for (const r of await this.#all(
|
|
481
|
+
`SELECT value_set_uri FROM pc_scale WHERE concept = ? ORDER BY seq`, [concept.id])) {
|
|
482
|
+
this._addStringProperty(params, 'property', 'postcoordinationScale', r.value_set_uri);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* $lookup for a postcoordinated expression. Two properties carry what a client cannot
|
|
489
|
+
* work out from the code string:
|
|
490
|
+
*
|
|
491
|
+
* stem which parts of the expression are concepts in their own right
|
|
492
|
+
* postcoordinationValues which value went on which axis
|
|
493
|
+
*
|
|
494
|
+
* The second is the one that matters. `1D01.0Y/1G41/1G40` says nothing about whether
|
|
495
|
+
* 1G40 is the causing condition or the manifestation, and both are legal there -- so a
|
|
496
|
+
* client that only has the string cannot tell, and a server that reports both on the
|
|
497
|
+
* first axis has told it something untrue. Each (axis, value) pair is therefore kept
|
|
498
|
+
* distinct, and the axis is named by its schema uri, the same identifier the
|
|
499
|
+
* postcoordinationScale property uses on a stem.
|
|
500
|
+
*/
|
|
501
|
+
async #extendLookupExpression(expr, props, params) {
|
|
502
|
+
if (this._hasProp(props, 'code', true)) {
|
|
503
|
+
this._addCodeProperty(params, 'property', 'code', expr.canonicalCode);
|
|
504
|
+
}
|
|
505
|
+
if (this._hasProp(props, 'id', true)) {
|
|
506
|
+
this._addCodeProperty(params, 'property', 'id', expr.uriForm);
|
|
507
|
+
}
|
|
508
|
+
if (this._hasProp(props, 'stem', true)) {
|
|
509
|
+
for (const stem of expr.stems) {
|
|
510
|
+
const label = await this.#title(stem.stem.concept);
|
|
511
|
+
const part = [{ name: 'code', valueCode: 'stem' }];
|
|
512
|
+
if (label) {
|
|
513
|
+
part.push({ name: 'subproperty', part: [
|
|
514
|
+
{ name: 'code', valueCode: 'stemLabel' },
|
|
515
|
+
{ name: 'value', valueString: label }] });
|
|
516
|
+
}
|
|
517
|
+
part.push({ name: 'subproperty', part: [
|
|
518
|
+
{ name: 'code', valueCode: 'stemUri' },
|
|
519
|
+
{ name: 'value', valueUri: stem.stem.concept.uri }] });
|
|
520
|
+
part.push({ name: 'value', valueCode: stem.stem.concept.canonicalCode });
|
|
521
|
+
params.push({ name: 'property', part });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (this._hasProp(props, 'postcoordinationValues', true)) {
|
|
525
|
+
// no sorting here: the test harness normalises repeated `property` parameters by
|
|
526
|
+
// (code, value) before it compares, so these arrive ordered by axis whatever order
|
|
527
|
+
// they leave in. Left in expression order, which is the one a reader of the raw
|
|
528
|
+
// response would expect.
|
|
529
|
+
const bindings = expr.stems.flatMap(stem => stem.bindings);
|
|
530
|
+
for (const b of bindings) {
|
|
531
|
+
const label = await this.#title(b.token.concept);
|
|
532
|
+
const sub = [{ name: 'code', valueCode: b.token.concept.canonicalCode }];
|
|
533
|
+
if (label) sub.push({ name: 'description', valueString: label });
|
|
534
|
+
sub.push({ name: 'value', valueUri: b.token.concept.uri });
|
|
535
|
+
params.push({ name: 'property', part: [
|
|
536
|
+
{ name: 'code', valueCode: 'postcoordinationValues' },
|
|
537
|
+
{ name: 'subproperty', part: sub },
|
|
538
|
+
{ name: 'value', valueCode: b.axis.axis }] });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// -- subsumption -----------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* A single primary-key probe on the closure, each way. Expressions are not in the
|
|
547
|
+
* hierarchy, so nothing is claimed about them.
|
|
548
|
+
*/
|
|
549
|
+
async subsumesTest(codeA, codeB) {
|
|
550
|
+
const a = await this.#ensureContext(codeA);
|
|
551
|
+
const b = await this.#ensureContext(codeB);
|
|
552
|
+
if (!a || !b || a instanceof ICD11Expression || b instanceof ICD11Expression) return 'not-subsumed';
|
|
553
|
+
if (a.id === b.id) return 'equivalent';
|
|
554
|
+
if (await this.#isA(a.id, b.id)) return 'subsumed-by';
|
|
555
|
+
if (await this.#isA(b.id, a.id)) return 'subsumes';
|
|
556
|
+
return 'not-subsumed';
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Is `ancestorId` an ancestor of `descendantId` (or the same concept)? */
|
|
560
|
+
async #isA(descendantId, ancestorId) {
|
|
561
|
+
const row = await this.#get(
|
|
562
|
+
`SELECT 1 AS ok FROM concept_closure WHERE ancestor = ? AND descendant = ? LIMIT 1`,
|
|
563
|
+
[ancestorId, descendantId]);
|
|
564
|
+
return !!row;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// -- iteration -------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* The children of a concept, or the roots when asked for nothing. Iterating the whole
|
|
571
|
+
* classification is not offered: isNotClosed() already says the linearizations have a
|
|
572
|
+
* grammar, and 37,000 concepts is not something to walk by accident.
|
|
573
|
+
*/
|
|
574
|
+
async iterator(context) {
|
|
575
|
+
const ctxt = await this.#ensureContext(context);
|
|
576
|
+
if (ctxt instanceof ICD11Expression) return null;
|
|
577
|
+
const rows = ctxt
|
|
578
|
+
? await this.#all(
|
|
579
|
+
`SELECT c.* FROM concept_parent cp JOIN concept c ON c.id = cp.child
|
|
580
|
+
WHERE cp.parent = ? ORDER BY cp.seq`, [ctxt.id])
|
|
581
|
+
: await this.#all(
|
|
582
|
+
`SELECT c.* FROM concept c WHERE c.system = ?
|
|
583
|
+
AND NOT EXISTS (SELECT 1 FROM concept_parent WHERE child = c.id)
|
|
584
|
+
ORDER BY COALESCE(c.lft, c.id)`, [this.shared.systemId]);
|
|
585
|
+
return { rows, cursor: 0 };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async nextContext(iteratorContext) {
|
|
589
|
+
if (!iteratorContext || iteratorContext.cursor >= iteratorContext.rows.length) return null;
|
|
590
|
+
return new ICD11Concept(iteratorContext.rows[iteratorContext.cursor++]);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// -- filters ---------------------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* `concept is-a` and `concept descendent-of` are what WHO's own value sets use, and what
|
|
597
|
+
* a postcoordination scale is expressed with. The filter value may be a short code or an
|
|
598
|
+
* entity uri: WHO's published value sets mix the two, so both have to work.
|
|
599
|
+
*/
|
|
600
|
+
async doesFilter(prop, op, value) {
|
|
601
|
+
return prop === 'concept' && (op === 'is-a' || op === 'descendent-of') && !!value;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async getPrepContext(iterate) {
|
|
605
|
+
return new FilterExecutionContext(iterate);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async filter(filterContext, forIteration, prop, op, value) {
|
|
609
|
+
if (!await this.doesFilter(prop, op, value)) {
|
|
610
|
+
throw new Issue('error', 'not-supported', null, 'FILTER_NOT_UNDERSTOOD',
|
|
611
|
+
`The filter "${prop} ${op} ${value}" is not understood for ${this.system()}`,
|
|
612
|
+
'vs-invalid', 422);
|
|
613
|
+
}
|
|
614
|
+
const target = await this.#resolve(value);
|
|
615
|
+
if (!target) {
|
|
616
|
+
// a value set that names a code that is not there is broken, and saying so is more
|
|
617
|
+
// use than quietly expanding to nothing
|
|
618
|
+
throw new Issue('error', 'code-invalid', null, null,
|
|
619
|
+
`The filter value '${value}' is not a code in ${this.system()}`, 'invalid-code', 422);
|
|
620
|
+
}
|
|
621
|
+
filterContext.filters.push(new ICD11FilterSet('isa', {
|
|
622
|
+
ancestorId: target.id, minDepth: op === 'is-a' ? 0 : 1, value,
|
|
623
|
+
}));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* A text filter over the classification's own search surface: titles, index terms and
|
|
628
|
+
* inclusions, which are what the ICD Browser matches on. A LIKE scan is honest but not
|
|
629
|
+
* fast; the designation table is the right place for an FTS index when it matters.
|
|
630
|
+
*/
|
|
631
|
+
async searchFilter(filterContext, filter, sort) { // eslint-disable-line no-unused-vars
|
|
632
|
+
const text = (filter?.filter ?? filter ?? '').toString().toLowerCase();
|
|
633
|
+
const set = new ICD11FilterSet('search', { text });
|
|
634
|
+
filterContext.filters.push(set);
|
|
635
|
+
return set;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async executeFilters(filterContext) {
|
|
639
|
+
return filterContext.filters;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Build the rows of a set. Only ever needed when the set is going to be walked. */
|
|
643
|
+
async #materialise(set) {
|
|
644
|
+
if (set.rows) return set.rows;
|
|
645
|
+
if (set.kind === 'isa') {
|
|
646
|
+
set.rows = await this.#all(
|
|
647
|
+
`SELECT c.* FROM concept_closure cl JOIN concept c ON c.id = cl.descendant
|
|
648
|
+
WHERE cl.ancestor = ? AND cl.depth >= ?
|
|
649
|
+
ORDER BY COALESCE(c.lft, c.id)`, [set.ancestorId, set.minDepth]);
|
|
650
|
+
} else {
|
|
651
|
+
set.rows = await this.#all(
|
|
652
|
+
`SELECT DISTINCT c.* FROM designation d JOIN concept c ON c.id = d.concept
|
|
653
|
+
WHERE c.system = ? AND d.lang IN (?, ?)
|
|
654
|
+
AND d.kind IN ('title', 'indexTerm', 'inclusion', 'fullySpecifiedName')
|
|
655
|
+
AND LOWER(d.value) LIKE ?
|
|
656
|
+
ORDER BY COALESCE(c.lft, c.id)`,
|
|
657
|
+
[this.shared.systemId, this.#displayLang(), this.defLang(), `%${set.text}%`]);
|
|
658
|
+
}
|
|
659
|
+
return set.rows;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async filterSize(filterContext, set) {
|
|
663
|
+
return (await this.#materialise(set)).length;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async filterMore(filterContext, set) {
|
|
667
|
+
return set.cursor < (await this.#materialise(set)).length;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async filterConcept(filterContext, set) {
|
|
671
|
+
const rows = await this.#materialise(set);
|
|
672
|
+
if (set.cursor >= rows.length) return null;
|
|
673
|
+
return new ICD11Concept(rows[set.cursor++]);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async filterLocate(filterContext, set, code) {
|
|
677
|
+
const found = await this.locate(code);
|
|
678
|
+
if (!found.context) return found.message ?? `Code '${code}' is not valid in ${this.system()}`;
|
|
679
|
+
const ok = await this.filterCheck(filterContext, set, found.context);
|
|
680
|
+
return ok === true ? found.context : ok;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async filterCheck(filterContext, set, concept) {
|
|
684
|
+
// an expression is a narrower thing than any concept, so it is never a member of a set
|
|
685
|
+
// defined by is-a over the classification
|
|
686
|
+
if (!(concept instanceof ICD11Concept)) return `'${await this.code(concept)}' is not in the value set`;
|
|
687
|
+
if (set.kind === 'isa') {
|
|
688
|
+
const row = await this.#get(
|
|
689
|
+
`SELECT 1 AS ok FROM concept_closure
|
|
690
|
+
WHERE ancestor = ? AND descendant = ? AND depth >= ? LIMIT 1`,
|
|
691
|
+
[set.ancestorId, concept.id, set.minDepth]);
|
|
692
|
+
return row ? true : `'${concept.canonicalCode}' is not in the value set`;
|
|
693
|
+
}
|
|
694
|
+
const rows = await this.#materialise(set);
|
|
695
|
+
return rows.some(r => r.id === concept.id)
|
|
696
|
+
? true : `'${concept.canonicalCode}' is not in the value set`;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** The filter sets are closed, whatever the code system as a whole may be. */
|
|
700
|
+
async filtersNotClosed() { return false; }
|
|
701
|
+
|
|
702
|
+
async filterFinish() { }
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
class ICD11ServicesFactory extends CodeSystemFactoryProvider {
|
|
706
|
+
/**
|
|
707
|
+
* @param i18n translations
|
|
708
|
+
* @param dbPath the ICD-11 database
|
|
709
|
+
* @param systemCode which of the three code systems in it this factory serves
|
|
710
|
+
*/
|
|
711
|
+
constructor(i18n, dbPath, systemCode) {
|
|
712
|
+
super(i18n);
|
|
713
|
+
this.dbPath = dbPath;
|
|
714
|
+
this.systemCode = systemCode;
|
|
715
|
+
this.uses = 0;
|
|
716
|
+
this._loaded = false;
|
|
717
|
+
this._shared = null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
system() { return this._shared?.url; }
|
|
721
|
+
version() { return this._shared?.version; }
|
|
722
|
+
defaultVersion() { return this._shared?.version; }
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Short label for the home page and for code system links. Deliberately not the WHO
|
|
726
|
+
* title from the database (which the provider uses): this has to answer before load(),
|
|
727
|
+
* since the home page sorts factories by name.
|
|
728
|
+
*/
|
|
729
|
+
name() {
|
|
730
|
+
switch (this.systemCode) {
|
|
731
|
+
case 'mms': return 'ICD-11 MMS';
|
|
732
|
+
case 'icf': return 'ICD-11 ICF';
|
|
733
|
+
case 'foundation': return 'ICD-11 Foundation';
|
|
734
|
+
default: return `ICD-11 ${this.systemCode}`;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
id() { return `icd11-${this.systemCode}`; }
|
|
739
|
+
|
|
740
|
+
async load() {
|
|
741
|
+
if (this._loaded) return;
|
|
742
|
+
const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY);
|
|
743
|
+
try {
|
|
744
|
+
const get = (sql, p) => new Promise((res, rej) =>
|
|
745
|
+
db.get(sql, p, (e, r) => e ? rej(e) : res(r)));
|
|
746
|
+
const sys = await get('SELECT * FROM system WHERE code = ?', [this.systemCode]);
|
|
747
|
+
if (!sys) {
|
|
748
|
+
throw new Error(`ICD-11 database ${this.dbPath} has no '${this.systemCode}' code system`);
|
|
749
|
+
}
|
|
750
|
+
const count = await get('SELECT COUNT(*) n FROM concept WHERE system = ?', [sys.id]);
|
|
751
|
+
const langs = await get("SELECT value FROM meta WHERE key = 'languages'", []);
|
|
752
|
+
this._shared = {
|
|
753
|
+
systemCode: sys.code,
|
|
754
|
+
systemId: sys.id,
|
|
755
|
+
url: sys.url,
|
|
756
|
+
version: sys.version,
|
|
757
|
+
title: sys.title,
|
|
758
|
+
isTree: !!sys.is_tree,
|
|
759
|
+
count: count.n,
|
|
760
|
+
languages: (langs?.value || 'en').split(','),
|
|
761
|
+
};
|
|
762
|
+
} finally {
|
|
763
|
+
db.close();
|
|
764
|
+
}
|
|
765
|
+
this._loaded = true;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async build(opContext, supplements) {
|
|
769
|
+
if (!this._loaded) await this.load();
|
|
770
|
+
this.recordUse();
|
|
771
|
+
const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY);
|
|
772
|
+
return new ICD11Services(opContext, supplements, db, this._shared);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* The postcoordination scale value sets. WHO publishes one per axis per stem -- 25,354 of
|
|
777
|
+
* them for 2026-01 -- and $lookup hands out their canonicals as the postcoordinationScale
|
|
778
|
+
* property of a concept, so a client that follows one has to find something at the other
|
|
779
|
+
* end. They are built on demand from pc_scale rather than shipped: the compose is three
|
|
780
|
+
* or four is-a includes, and pre-expanding the lot would be 14.6 million rows.
|
|
781
|
+
*/
|
|
782
|
+
async buildKnownValueSet(url, version) {
|
|
783
|
+
if (!url || !url.includes('/postcoordinationScale/')) return null;
|
|
784
|
+
if (!this._loaded) await this.load();
|
|
785
|
+
if (version && version !== this._shared.version) return null;
|
|
786
|
+
|
|
787
|
+
const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY);
|
|
788
|
+
try {
|
|
789
|
+
const get = (sql, p) => new Promise((res, rej) => db.get(sql, p, (e, r) => e ? rej(e) : res(r)));
|
|
790
|
+
const all = (sql, p) => new Promise((res, rej) => db.all(sql, p, (e, r) => e ? rej(e) : res(r || [])));
|
|
791
|
+
const scale = await get(
|
|
792
|
+
`SELECT ps.id, ps.axis, ps.axis_name, ps.required, ps.allow_multiple,
|
|
793
|
+
c.entity_id, c.code AS stem_code, c.uri AS stem_uri
|
|
794
|
+
FROM pc_scale ps JOIN concept c ON c.id = ps.concept
|
|
795
|
+
WHERE ps.value_set_uri = ? AND c.system = ?`, [url, this._shared.systemId]);
|
|
796
|
+
if (!scale) return null;
|
|
797
|
+
const roots = await all(
|
|
798
|
+
`SELECT target_uri FROM pc_scale_entity WHERE scale = ? ORDER BY seq`, [scale.id]);
|
|
799
|
+
|
|
800
|
+
const slug = `${this._shared.systemCode}-${scale.entity_id}-${scale.axis_name}`.replace(/\//g, '-');
|
|
801
|
+
return {
|
|
802
|
+
resourceType: 'ValueSet',
|
|
803
|
+
id: `icd11-${slug}`.slice(0, 64),
|
|
804
|
+
url,
|
|
805
|
+
version: this._shared.version,
|
|
806
|
+
name: `ICD11_${slug.replace(/[^A-Za-z0-9_]/g, '_')}`,
|
|
807
|
+
title: `${scale.axis_name} for ${scale.stem_code || scale.entity_id}`,
|
|
808
|
+
status: 'active',
|
|
809
|
+
experimental: false,
|
|
810
|
+
date: this._shared.version,
|
|
811
|
+
publisher: 'World Health Organization',
|
|
812
|
+
description: `The values WHO permits on the ${scale.axis_name} postcoordination axis `
|
|
813
|
+
+ `of ${scale.stem_code || scale.stem_uri}.`,
|
|
814
|
+
compose: {
|
|
815
|
+
// one include per scale root: membership is the root and everything under it,
|
|
816
|
+
// which is exactly how WHO expresses it in its own published value sets
|
|
817
|
+
include: roots.map(r => ({
|
|
818
|
+
system: this._shared.url,
|
|
819
|
+
version: this._shared.version,
|
|
820
|
+
filter: [{ property: 'concept', op: 'is-a', value: r.target_uri }],
|
|
821
|
+
})),
|
|
822
|
+
},
|
|
823
|
+
};
|
|
824
|
+
} finally {
|
|
825
|
+
db.close();
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** Which code systems a given database holds, so the library can register one each. */
|
|
830
|
+
static listSystems(dbPath) {
|
|
831
|
+
return new Promise((resolve, reject) => {
|
|
832
|
+
const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY, (err) => {
|
|
833
|
+
if (err) { reject(new Error(`cannot open ICD-11 database ${dbPath}: ${err.message}`)); return; }
|
|
834
|
+
db.all('SELECT code, url, version, title FROM system ORDER BY code', (e, rows) => {
|
|
835
|
+
db.close();
|
|
836
|
+
if (e) reject(new Error(`${dbPath} is not an ICD-11 database: ${e.message}`));
|
|
837
|
+
else resolve(rows || []);
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
module.exports = { ICD11Services, ICD11ServicesFactory, ICD11Concept };
|