fhirsmith 0.11.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -2
- package/config-template.json +18 -1
- package/package.json +1 -1
- package/publisher/publisher.js +153 -5
- package/publisher/validation.js +198 -0
- package/server.js +12 -12
- package/stats-db.js +127 -0
- package/stats.js +172 -6
- package/translations/Messages.properties +4 -1
- package/tx/cs/cs-api.md +1 -1
- package/tx/cs/cs-snomed.js +103 -54
- package/tx/library/conceptmap.js +39 -0
- package/tx/library/parameter-check.js +139 -0
- package/tx/operation-context.js +220 -10
- package/tx/params.js +226 -175
- package/tx/sct/expressions.js +485 -0
- package/tx/tx.fhir.org.yml +1 -1
- package/tx/tx.js +57 -50
- package/tx/workers/batch-validate.js +5 -2
- package/tx/workers/cache-control.js +82 -6
- package/tx/workers/expand.js +80 -23
- package/tx/workers/lookup.js +14 -1
- package/tx/workers/translate.js +461 -30
- package/tx/workers/validate.js +7 -1
- package/tx/workers/worker.js +22 -10
- package/tx/xml/xml-base.js +21 -1
- package/tx/xversion/xv-conceptmap.js +81 -0
- package/tx/xversion/xv-parameters.js +8 -0
- package/utilities/merge-audit.py +178 -0
- package/utilities/why-merge-audit.md +39 -0
- package/tx/data/snomed-subset.txt +0 -84
package/stats-db.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const Database = require('better-sqlite3');
|
|
4
|
+
const folders = require('./library/folder-setup');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Persistent storage for the server's request statistics (issue #255).
|
|
8
|
+
*
|
|
9
|
+
* The in-memory counters in ServerStats reset every time the server restarts,
|
|
10
|
+
* which makes them useless for anything but "what has happened since the last
|
|
11
|
+
* deploy". This class writes them to a SQLite database that outlives restarts
|
|
12
|
+
* and upgrades.
|
|
13
|
+
*
|
|
14
|
+
* One row per (module, endpoint, operation) per collection interval, holding
|
|
15
|
+
* both numbers:
|
|
16
|
+
*
|
|
17
|
+
* - the *delta*: what happened in that interval. This is what you graph -
|
|
18
|
+
* plot count_delta against time and you have requests per interval without
|
|
19
|
+
* any differencing at the query end.
|
|
20
|
+
* - the *total*: the all-time cumulative count, carried across restarts by
|
|
21
|
+
* reloading it at startup. This is what you quote as "how much has this
|
|
22
|
+
* endpoint ever been asked to do", and it means a restart shows up as a
|
|
23
|
+
* flat spot in the graph rather than a cliff back to zero.
|
|
24
|
+
*
|
|
25
|
+
* Rows are only written for keys that saw traffic in the interval, so idle
|
|
26
|
+
* endpoints cost nothing. Nothing is pruned: at 10 minute intervals a
|
|
27
|
+
* continuously busy (endpoint, operation) pair produces ~52,000 rows a year,
|
|
28
|
+
* which SQLite does not notice.
|
|
29
|
+
*
|
|
30
|
+
* better-sqlite3 (rather than sqlite3) on purpose: the writes are synchronous,
|
|
31
|
+
* so the final flush in ServerStats.finishStats() lands on disk before
|
|
32
|
+
* server.js calls process.exit(). A handful of inserts every ten minutes is
|
|
33
|
+
* well under a millisecond, so this does not put the event loop at risk.
|
|
34
|
+
*/
|
|
35
|
+
class StatsDatabase {
|
|
36
|
+
|
|
37
|
+
constructor(config, log) {
|
|
38
|
+
this.log = log || console;
|
|
39
|
+
this.config = config || {};
|
|
40
|
+
this.db = null;
|
|
41
|
+
this.path = StatsDatabase.resolvePath(this.config);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Where the database lives: config.database if given (relative paths resolve
|
|
46
|
+
* under the data folder's databases directory), otherwise stats.db there.
|
|
47
|
+
*/
|
|
48
|
+
static resolvePath(config) {
|
|
49
|
+
const configured = config && config.database;
|
|
50
|
+
if (!configured) {
|
|
51
|
+
return path.join(folders.databasesDir(), 'stats.db');
|
|
52
|
+
}
|
|
53
|
+
return path.isAbsolute(configured) ? configured : path.join(folders.databasesDir(), configured);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Open the database, creating the file and the schema if they aren't there.
|
|
58
|
+
*/
|
|
59
|
+
open() {
|
|
60
|
+
fs.mkdirSync(path.dirname(this.path), {recursive: true});
|
|
61
|
+
this.db = new Database(this.path);
|
|
62
|
+
this.db.pragma('journal_mode = WAL');
|
|
63
|
+
this.db.exec(`
|
|
64
|
+
CREATE TABLE IF NOT EXISTS request_counts (
|
|
65
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
66
|
+
time INTEGER NOT NULL,
|
|
67
|
+
interval_ms INTEGER NOT NULL,
|
|
68
|
+
module TEXT NOT NULL,
|
|
69
|
+
endpoint TEXT NOT NULL,
|
|
70
|
+
operation TEXT NOT NULL,
|
|
71
|
+
count_delta INTEGER NOT NULL,
|
|
72
|
+
count_total INTEGER NOT NULL,
|
|
73
|
+
time_delta INTEGER NOT NULL,
|
|
74
|
+
time_total INTEGER NOT NULL
|
|
75
|
+
)
|
|
76
|
+
`);
|
|
77
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_request_counts_time ON request_counts(time)`);
|
|
78
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_request_counts_key ON request_counts(module, endpoint, operation, id)`);
|
|
79
|
+
|
|
80
|
+
this.insertStatement = this.db.prepare(`
|
|
81
|
+
INSERT INTO request_counts
|
|
82
|
+
(time, interval_ms, module, endpoint, operation, count_delta, count_total, time_delta, time_total)
|
|
83
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
84
|
+
`);
|
|
85
|
+
this.insertAll = this.db.transaction((time, intervalMs, rows) => {
|
|
86
|
+
for (const row of rows) {
|
|
87
|
+
this.insertStatement.run(time, intervalMs, row.module, row.endpoint, row.operation,
|
|
88
|
+
row.countDelta, row.countTotal, row.timeDelta, row.timeTotal);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return this;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The most recent cumulative total for each key, so counting resumes where
|
|
96
|
+
* the last run left off instead of starting again from zero.
|
|
97
|
+
*/
|
|
98
|
+
loadTotals() {
|
|
99
|
+
if (!this.db) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
return this.db.prepare(`
|
|
103
|
+
SELECT module, endpoint, operation, count_total AS countTotal, time_total AS timeTotal
|
|
104
|
+
FROM request_counts
|
|
105
|
+
WHERE id IN (SELECT MAX(id) FROM request_counts GROUP BY module, endpoint, operation)
|
|
106
|
+
`).all();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Write one interval's worth of rows. All or nothing - if the transaction
|
|
111
|
+
* fails the caller keeps its deltas and tries again next interval.
|
|
112
|
+
*/
|
|
113
|
+
write(time, intervalMs, rows) {
|
|
114
|
+
if (this.db && rows && rows.length > 0) {
|
|
115
|
+
this.insertAll(time, intervalMs, rows);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
close() {
|
|
120
|
+
if (this.db) {
|
|
121
|
+
this.db.close();
|
|
122
|
+
this.db = null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = StatsDatabase;
|
package/stats.js
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
const { monitorEventLoopDelay } = require('perf_hooks');
|
|
2
2
|
const {Utilities} = require("./library/utilities");
|
|
3
3
|
const escape = require('escape-html');
|
|
4
|
+
const StatsDatabase = require('./stats-db');
|
|
5
|
+
|
|
6
|
+
// Requests that aren't counted by a module (the root page, the dashboard) are
|
|
7
|
+
// attributed here, and requests that have no endpoint dimension (everything
|
|
8
|
+
// outside the tx module) carry NO_ENDPOINT.
|
|
9
|
+
const SERVER_MODULE = '(server)';
|
|
10
|
+
const NO_ENDPOINT = '';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A per-module view of the server statistics.
|
|
14
|
+
*
|
|
15
|
+
* Modules are handed one of these instead of the ServerStats itself, so that
|
|
16
|
+
* everything they count is attributed to them without each of the ~76 call
|
|
17
|
+
* sites having to name its own module. The API is deliberately the same shape
|
|
18
|
+
* as ServerStats, so a module can't tell the difference - except that
|
|
19
|
+
* countRequest takes an optional endpoint, which is how the tx module
|
|
20
|
+
* distinguishes /tx/r4 from /tx/r5.
|
|
21
|
+
*/
|
|
22
|
+
class ModuleStats {
|
|
23
|
+
|
|
24
|
+
constructor(parent, module) {
|
|
25
|
+
this.parent = parent;
|
|
26
|
+
this.module = module;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
countRequest(name, tat, endpoint = NO_ENDPOINT) {
|
|
30
|
+
this.parent.countRequest(name, tat, this.module, endpoint);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
addTask(name, frequency) { this.parent.addTask(name, frequency); }
|
|
34
|
+
task(name, state) { this.parent.task(name, state); }
|
|
35
|
+
taskDone(name, state) { this.parent.taskDone(name, state); }
|
|
36
|
+
taskError(name, state) { this.parent.taskError(name, state); }
|
|
37
|
+
|
|
38
|
+
// Pass-throughs for the handful of properties modules read off the stats
|
|
39
|
+
// object. These have to be getters, not copies, or a module would capture
|
|
40
|
+
// the value at construction time and never see it change.
|
|
41
|
+
get cachingModules() { return this.parent.cachingModules; }
|
|
42
|
+
get requestCount() { return this.parent.requestCount; }
|
|
43
|
+
get staticRequestCount() { return this.parent.staticRequestCount; }
|
|
44
|
+
get history() { return this.parent.history; }
|
|
45
|
+
get startTime() { return this.parent.startTime; }
|
|
46
|
+
}
|
|
4
47
|
|
|
5
48
|
class ServerStats {
|
|
6
49
|
started = false;
|
|
@@ -16,13 +59,53 @@ class ServerStats {
|
|
|
16
59
|
timer;
|
|
17
60
|
cachingModules = [];
|
|
18
61
|
taskMap = new Map();
|
|
62
|
+
// key (module/endpoint/operation) -> counter. See countRequest().
|
|
63
|
+
counters = new Map();
|
|
64
|
+
db = null;
|
|
19
65
|
|
|
20
|
-
constructor() {
|
|
66
|
+
constructor(config, log) {
|
|
67
|
+
this.config = config || {};
|
|
68
|
+
this.log = log || console;
|
|
69
|
+
if (this.config.intervalMinutes) {
|
|
70
|
+
this.intervalMs = this.config.intervalMinutes * 60 * 1000;
|
|
71
|
+
}
|
|
72
|
+
this.openDatabase();
|
|
21
73
|
this.timer = setInterval(() => {
|
|
22
74
|
this.recordMetrics();
|
|
23
75
|
}, this.intervalMs);
|
|
24
76
|
}
|
|
25
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Open the persistent counter store and pick up where the last run of the
|
|
80
|
+
* server left off. Statistics must never be the reason the server won't
|
|
81
|
+
* start, so a failure here is logged and then ignored: the counters carry
|
|
82
|
+
* on in memory, they just don't survive the next restart.
|
|
83
|
+
*/
|
|
84
|
+
openDatabase() {
|
|
85
|
+
if (this.config.enabled === false) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
this.db = new StatsDatabase(this.config, this.log).open();
|
|
90
|
+
for (const row of this.db.loadTotals()) {
|
|
91
|
+
const counter = this.counterFor(row.module, row.endpoint, row.operation);
|
|
92
|
+
counter.countTotal = row.countTotal;
|
|
93
|
+
counter.timeTotal = row.timeTotal;
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
this.log.error(`Unable to open the statistics database: ${e.message}. Statistics will not be persisted`);
|
|
97
|
+
this.db = null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A view of the statistics that attributes everything counted through it to
|
|
103
|
+
* the named module (see ModuleStats).
|
|
104
|
+
*/
|
|
105
|
+
forModule(module) {
|
|
106
|
+
return new ModuleStats(this, module);
|
|
107
|
+
}
|
|
108
|
+
|
|
26
109
|
recordMetrics() {
|
|
27
110
|
if (this.started) {
|
|
28
111
|
const now = Date.now();
|
|
@@ -61,9 +144,12 @@ class ServerStats {
|
|
|
61
144
|
this.requestTime = 0;
|
|
62
145
|
this.lastTime = now;
|
|
63
146
|
|
|
64
|
-
// Prune old data (keep 24 hours)
|
|
147
|
+
// Prune old data (keep 24 hours). Only the in-memory history is pruned -
|
|
148
|
+
// the per-endpoint counters are persisted and kept indefinitely.
|
|
65
149
|
const cutoff = now - (24 * 60 * 60 * 1000); // 24 hours ago
|
|
66
150
|
this.history = this.history.filter(m => m.time > cutoff);
|
|
151
|
+
|
|
152
|
+
this.flushCounters(now, this.intervalMs);
|
|
67
153
|
}
|
|
68
154
|
}
|
|
69
155
|
|
|
@@ -77,11 +163,78 @@ class ServerStats {
|
|
|
77
163
|
this.recordMetrics();
|
|
78
164
|
}
|
|
79
165
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
166
|
+
/**
|
|
167
|
+
* The counter for one (module, endpoint, operation), created on first use.
|
|
168
|
+
*
|
|
169
|
+
* countDelta/timeDelta accumulate within the current interval and are reset
|
|
170
|
+
* each time they're written out; countTotal/timeTotal are cumulative for the
|
|
171
|
+
* life of the database, not the life of the process.
|
|
172
|
+
*/
|
|
173
|
+
counterFor(module, endpoint, operation) {
|
|
174
|
+
const key = `${module}\u001f${endpoint}\u001f${operation}`;
|
|
175
|
+
let counter = this.counters.get(key);
|
|
176
|
+
if (!counter) {
|
|
177
|
+
counter = {
|
|
178
|
+
module: module, endpoint: endpoint, operation: operation,
|
|
179
|
+
countDelta: 0, timeDelta: 0, countTotal: 0, timeTotal: 0
|
|
180
|
+
};
|
|
181
|
+
this.counters.set(key, counter);
|
|
182
|
+
}
|
|
183
|
+
return counter;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Count one request. Modules go through their own ModuleStats, which fills
|
|
188
|
+
* in module (and, for tx, the endpoint) for them.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} name - the operation, e.g. '$expand' or 'login'
|
|
191
|
+
* @param {number} tat - turnaround time in ms
|
|
192
|
+
* @param {string} module - the module that served it
|
|
193
|
+
* @param {string} endpoint - the endpoint within the module, where the
|
|
194
|
+
* module has more than one (the tx module does; nothing else does yet)
|
|
195
|
+
*/
|
|
196
|
+
countRequest(name, tat, module = SERVER_MODULE, endpoint = NO_ENDPOINT) {
|
|
83
197
|
this.requestCount++;
|
|
84
198
|
this.requestTime = this.requestTime + tat;
|
|
199
|
+
|
|
200
|
+
const counter = this.counterFor(module || SERVER_MODULE, endpoint || NO_ENDPOINT, name || '(unnamed)');
|
|
201
|
+
counter.countDelta++;
|
|
202
|
+
counter.countTotal++;
|
|
203
|
+
counter.timeDelta = counter.timeDelta + tat;
|
|
204
|
+
counter.timeTotal = counter.timeTotal + tat;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Write out the counters that saw traffic this interval, and start the next
|
|
209
|
+
* one. The deltas are only cleared if the write succeeded, so a transient
|
|
210
|
+
* database problem delays the numbers rather than losing them (the interval
|
|
211
|
+
* they're eventually written against is longer, which is what interval_ms on
|
|
212
|
+
* the row is for).
|
|
213
|
+
*/
|
|
214
|
+
flushCounters(now, intervalMs) {
|
|
215
|
+
if (!this.db) {
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
const rows = [];
|
|
219
|
+
for (const counter of this.counters.values()) {
|
|
220
|
+
if (counter.countDelta > 0) {
|
|
221
|
+
rows.push({...counter});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (rows.length === 0) {
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
this.db.write(now, intervalMs, rows);
|
|
229
|
+
} catch (e) {
|
|
230
|
+
this.log.error(`Unable to write statistics: ${e.message}`);
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
for (const counter of this.counters.values()) {
|
|
234
|
+
counter.countDelta = 0;
|
|
235
|
+
counter.timeDelta = 0;
|
|
236
|
+
}
|
|
237
|
+
return rows.length;
|
|
85
238
|
}
|
|
86
239
|
|
|
87
240
|
addTask(name, frequency) {
|
|
@@ -144,6 +297,17 @@ class ServerStats {
|
|
|
144
297
|
|
|
145
298
|
finishStats() {
|
|
146
299
|
clearInterval(this.timer);
|
|
300
|
+
// Synchronous, so the last partial interval is on disk before server.js
|
|
301
|
+
// calls process.exit().
|
|
302
|
+
this.flushCounters(Date.now(), Date.now() - (this.lastTime || this.startTime));
|
|
303
|
+
if (this.db) {
|
|
304
|
+
try {
|
|
305
|
+
this.db.close();
|
|
306
|
+
} catch (e) {
|
|
307
|
+
this.log.error(`Unable to close the statistics database: ${e.message}`);
|
|
308
|
+
}
|
|
309
|
+
this.db = null;
|
|
310
|
+
}
|
|
147
311
|
}
|
|
148
312
|
|
|
149
313
|
// Live cache aggregates across all registered caching modules (defensive: a
|
|
@@ -174,4 +338,6 @@ class ServerStats {
|
|
|
174
338
|
}
|
|
175
339
|
}
|
|
176
340
|
}
|
|
177
|
-
module.exports = ServerStats;
|
|
341
|
+
module.exports = ServerStats;
|
|
342
|
+
module.exports.ModuleStats = ModuleStats;
|
|
343
|
+
module.exports.SERVER_MODULE = SERVER_MODULE;
|
|
@@ -1101,7 +1101,10 @@ Unable_to_resolve_system__value_set_has_multiple_matches = The System URI could
|
|
|
1101
1101
|
Unable_to_resolve_system__value_set_has_no_includes_or_expansion = The System URI could not be determined for the code ''{0}'' in the ValueSet ''{1}'': value set has no includes or expansion
|
|
1102
1102
|
UNABLE_TO_RESOLVE_SYSTEM_SYSTEM_IS_INDETERMINATE = The code system {1} referred to from value set {0} has a grammar, and the code might be valid in it
|
|
1103
1103
|
Unable_to_resolve_value_Set_ = A definition for the value Set ''{0}'' could not be found
|
|
1104
|
-
CACHE_ID_UNKNOWN = The cache ''{0}''
|
|
1104
|
+
CACHE_ID_UNKNOWN = The cache ''{0}'' was never issued by this server, and this server has no record of it ever existing. Caches are created with $cache-control?mode=start, and a cache-id is only valid on the server instance and endpoint that issued it - either it was issued somewhere else, or this server has restarted since it was issued
|
|
1105
|
+
CACHE_ID_CLOSED = The cache ''{0}'' was closed {1} ago by the client, using $cache-control?mode=end (it had been open for {2}). It did not expire and it was not idle: something in the client released this cache while this request was still using it
|
|
1106
|
+
CACHE_ID_EXPIRED = The cache ''{0}'' expired: it went {1} without being used, and this server closes caches that have been idle for more than {2} (this one was dropped {3} ago). Every request that uses a cache resets its idle clock, so a client that keeps using a cache will not see this
|
|
1107
|
+
CACHE_ID_CLEARED = The cache ''{0}'' was dropped {1} ago, when this server cleared all of its caches
|
|
1105
1108
|
Unable_to_validate_code_without_using_server = Unable to validate code without using server because: {0}
|
|
1106
1109
|
Undefined_attribute__on__for_type__properties__ = Undefined attribute ''@{0}'' on {1} for type {2}
|
|
1107
1110
|
Undefined_element_ = Undefined element ''{0}'' at {1}
|
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
|
}
|
|
@@ -1734,19 +1784,6 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1734
1784
|
}
|
|
1735
1785
|
}
|
|
1736
1786
|
|
|
1737
|
-
async incompleteValidationMessage(context) {
|
|
1738
|
-
|
|
1739
|
-
const ctxt = await this.#ensureContext(context);
|
|
1740
|
-
|
|
1741
|
-
if (!ctxt) return null;
|
|
1742
|
-
|
|
1743
|
-
if (ctxt.isComplex()) {
|
|
1744
|
-
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)";
|
|
1745
|
-
} else {
|
|
1746
|
-
return null;
|
|
1747
|
-
}
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
1787
|
async locateIsA(code, parent, disallowParent = false) {
|
|
1751
1788
|
|
|
1752
1789
|
|
|
@@ -1881,25 +1918,37 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1881
1918
|
if (ctxt instanceof SnomedExpressionContext) {
|
|
1882
1919
|
// ignore concepts for now, but list refinements and refinement groups
|
|
1883
1920
|
for (const refinement of ctxt.expression.refinements) {
|
|
1884
|
-
|
|
1885
|
-
const codeB = refinement.value.describe();
|
|
1886
|
-
const description = await this.display(codeB);
|
|
1887
|
-
let p = this._addCodeProperty(params, 'property', codeA, codeB, null, description);
|
|
1888
|
-
p.part.push({name: 'code-display', valueString: await this.display(codeA)});
|
|
1921
|
+
await this._addRefinementProperty(params, refinement);
|
|
1889
1922
|
}
|
|
1890
1923
|
for (const refinementGroup of ctxt.expression.refinementGroups) {
|
|
1891
1924
|
for (const refinement of refinementGroup.refinements) {
|
|
1892
|
-
|
|
1893
|
-
const codeB = refinement.value.describe();
|
|
1894
|
-
const description = await this.display(codeB);
|
|
1895
|
-
let p = this._addCodeProperty(params, 'property', codeA, codeB, null, description);
|
|
1896
|
-
p.part.push({name: 'code-display', valueString: await this.display(codeA)});
|
|
1925
|
+
await this._addRefinementProperty(params, refinement);
|
|
1897
1926
|
}
|
|
1898
1927
|
}
|
|
1899
1928
|
}
|
|
1900
1929
|
}
|
|
1901
1930
|
}
|
|
1902
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
|
+
|
|
1903
1952
|
// Filter support
|
|
1904
1953
|
async doesFilter(prop, op, value) {
|
|
1905
1954
|
if (prop === 'concept') {
|
|
@@ -2131,7 +2180,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2131
2180
|
// A wildcard (`*`) set is lazy - it carries the eclWildcard flag rather than a
|
|
2132
2181
|
// materialised descendant list - so count the active concepts it denotes.
|
|
2133
2182
|
if (set.eclWildcard && (!set.descendants || set.descendants.length === 0)) {
|
|
2134
|
-
return this.sct.
|
|
2183
|
+
return this.sct._eclEnumerateAllConcepts(this.opContext).length;
|
|
2135
2184
|
}
|
|
2136
2185
|
if (set.matches && set.matches.length > 0) {
|
|
2137
2186
|
return set.matches.length;
|
|
@@ -2183,7 +2232,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2183
2232
|
const reference = ctxt.getReference();
|
|
2184
2233
|
|
|
2185
2234
|
if (set.eclWildcard) {
|
|
2186
|
-
return
|
|
2235
|
+
return ctxt; // `*` is every concept; inactive ones included
|
|
2187
2236
|
}
|
|
2188
2237
|
|
|
2189
2238
|
// A post-coordinated expression validated against an ECL value set: test the
|
|
@@ -2266,7 +2315,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2266
2315
|
return this.#descendantsHas(set, reference);
|
|
2267
2316
|
}
|
|
2268
2317
|
if (set.eclWildcard) {
|
|
2269
|
-
return
|
|
2318
|
+
return true; // `*` is every concept; inactive ones included
|
|
2270
2319
|
}
|
|
2271
2320
|
return false;
|
|
2272
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);
|