fhirsmith 0.10.1 → 0.11.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 +70 -0
- package/changelog-template.md +22 -0
- package/library/logger.js +8 -1
- package/library/package-manager.js +40 -2
- package/npmprojector/watcher.js +1 -1
- package/package.json +3 -2
- package/packages/packages.js +28 -15
- package/publisher/publisher.js +8 -8
- package/registry/api.js +92 -31
- package/registry/crawler.js +15 -1
- package/registry/model.js +158 -8
- package/registry/readme.md +5 -13
- package/registry/registry.js +87 -46
- package/registry/test-runner.js +1 -1
- package/server.js +8 -1
- package/stats.js +5 -1
- package/token/token.js +1 -1
- package/translations/Messages.properties +3 -1
- package/translations/rendering-phrases.properties +1227 -1218
- package/translations/rendering-phrases_ar.properties +1097 -1091
- package/translations/rendering-phrases_fr.properties +626 -624
- package/translations/rendering-phrases_nl.properties +976 -970
- package/translations/rendering-phrases_pt.properties +1026 -1020
- package/translations/rendering-phrases_ru.properties +1100 -1094
- package/tsconfig.eslint.json +9 -0
- package/tx/cs/cs-api.js +57 -4
- package/tx/cs/cs-cs.js +98 -25
- package/tx/cs/cs-hgvs.js +4 -0
- package/tx/cs/cs-lang.js +8 -0
- package/tx/cs/cs-mimetypes.js +4 -0
- package/tx/cs/cs-snomed.js +530 -75
- package/tx/cs/cs-uri.js +4 -0
- package/tx/data/snomed-subset.txt +40 -3
- package/tx/html/home-metrics.liquid +16 -1
- package/tx/importers/import-sct.module.js +18 -0
- package/tx/library/designations.js +3 -5
- package/tx/library/renderer.js +227 -30
- package/tx/library.js +5 -1
- package/tx/operation-context.js +131 -5
- package/tx/provider.js +38 -9
- package/tx/sct/ecl.js +30 -11
- package/tx/sct/expressions.js +28 -1
- package/tx/sct/structures.js +5 -5
- package/tx/tx-html.js +24 -15
- package/tx/tx.fhir.org.yml +1 -4
- package/tx/tx.js +18 -2
- package/tx/vs/vs-package.js +52 -0
- package/tx/workers/batch-validate.js +125 -18
- package/tx/workers/cache-control.js +31 -2
- package/tx/workers/expand.js +150 -76
- package/tx/workers/metadata.js +27 -6
- package/tx/workers/search.js +7 -1
- package/tx/workers/validate.js +30 -18
- package/tx/workers/worker.js +39 -7
- package/tx/xversion/xv-valueset.js +5 -2
- package/xig/xig.js +3 -3
package/tx/cs/cs-snomed.js
CHANGED
|
@@ -17,6 +17,17 @@ const {ECLLexer, ECLParser, ECLNodeType, ECLTokenType} = require("../sct/ecl");
|
|
|
17
17
|
const {Issue} = require("../library/operation-outcome");
|
|
18
18
|
const {debugLog} = require("../operation-context");
|
|
19
19
|
|
|
20
|
+
// Symbol key under which a resolved ECL filter analysis is memoised directly on
|
|
21
|
+
// the ValueSet compose.include.filter element (`fc`). Symbol-keyed and defined
|
|
22
|
+
// non-enumerable, so it never serialises, clones, or spreads into a response;
|
|
23
|
+
// it lives and dies with the filter element (and thus the cached ValueSet).
|
|
24
|
+
// Value: Map(codeSystemUrl|version -> {descendants, members, matches, eclAst}).
|
|
25
|
+
// SNOMED editions vary the url (and always have a version), so both url and
|
|
26
|
+
// version are part of the key. Only iteration-INSENSITIVE ECL is cached (see
|
|
27
|
+
// _cacheEcl): a wildcard's materialised form depends on forIteration and is not
|
|
28
|
+
// shared - it simply recomputes.
|
|
29
|
+
const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
|
|
30
|
+
|
|
20
31
|
// Context kinds matching Pascal enum
|
|
21
32
|
const SnomedProviderContextKind = {
|
|
22
33
|
CODE: 0,
|
|
@@ -199,6 +210,21 @@ class SnomedServices {
|
|
|
199
210
|
}
|
|
200
211
|
}
|
|
201
212
|
|
|
213
|
+
// Count of active concepts (status nibble == 0), computed once and cached.
|
|
214
|
+
// Concepts are fixed-size records addressed by byte offset, so iterate by
|
|
215
|
+
// ordinal via getConceptByCount. Used by the count=0 expansion fast path.
|
|
216
|
+
activeConceptCount() {
|
|
217
|
+
if (this._activeConceptCount != null) return this._activeConceptCount;
|
|
218
|
+
let n = 0;
|
|
219
|
+
const total = this.concepts.count();
|
|
220
|
+
for (let ord = 0; ord < total; ord++) {
|
|
221
|
+
const c = this.concepts.getConceptByCount(ord);
|
|
222
|
+
if ((c.flags & 0x0F) === 0) n++;
|
|
223
|
+
}
|
|
224
|
+
this._activeConceptCount = n;
|
|
225
|
+
return n;
|
|
226
|
+
}
|
|
227
|
+
|
|
202
228
|
isPrimitive(reference) {
|
|
203
229
|
try {
|
|
204
230
|
const concept = this.concepts.getConcept(reference);
|
|
@@ -223,12 +249,109 @@ class SnomedServices {
|
|
|
223
249
|
}
|
|
224
250
|
|
|
225
251
|
const descendants = this.refs.getReferences(closureRef);
|
|
226
|
-
|
|
252
|
+
if (!descendants) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
// The closure array is stored sorted ascending (see the importer's
|
|
256
|
+
// buildConceptClosure), so membership is a binary search rather than a
|
|
257
|
+
// linear scan - this is the hot path for is-a and ECL refinement checks.
|
|
258
|
+
let lo = 0;
|
|
259
|
+
let hi = descendants.length - 1;
|
|
260
|
+
while (lo <= hi) {
|
|
261
|
+
const mid = (lo + hi) >> 1;
|
|
262
|
+
const v = descendants[mid];
|
|
263
|
+
if (v === childRef) {
|
|
264
|
+
return true;
|
|
265
|
+
} else if (v < childRef) {
|
|
266
|
+
lo = mid + 1;
|
|
267
|
+
} else {
|
|
268
|
+
hi = mid - 1;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
227
272
|
} catch (error) {
|
|
228
273
|
return false;
|
|
229
274
|
}
|
|
230
275
|
}
|
|
231
276
|
|
|
277
|
+
// Key concept indexes used for preferred-term selection, resolved once per
|
|
278
|
+
// loaded edition. US English (900000000000509007) is the global default
|
|
279
|
+
// language reference set for display.
|
|
280
|
+
_displayConstants() {
|
|
281
|
+
if (this._dispConst) return this._dispConst;
|
|
282
|
+
const idx = (id) => { const r = this.concepts.findConcept(id); return r.found ? r.index : -1; };
|
|
283
|
+
this._dispConst = {
|
|
284
|
+
usRefset: idx(900000000000509007n), // US English language reference set
|
|
285
|
+
preferred: idx(900000000000548007n), // Preferred
|
|
286
|
+
synonym: idx(900000000000013009n), // Synonym
|
|
287
|
+
fsn: idx(900000000000003001n) // Fully specified name
|
|
288
|
+
};
|
|
289
|
+
return this._dispConst;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Ordered display language reference set(s) for the loaded edition. SNOMED's
|
|
293
|
+
// default display language differs by edition, and a concept can carry a
|
|
294
|
+
// preferred synonym in more than one dialect at once (the International
|
|
295
|
+
// edition ships both US English 509007 and GB English 508004, each marking a
|
|
296
|
+
// different synonym Preferred). The edition default decides which term is
|
|
297
|
+
// shown. Editions not listed here fall back to "preferred synonym in any
|
|
298
|
+
// language refset" (the historical behaviour), so no edition regresses.
|
|
299
|
+
_displayRefsetOrder() {
|
|
300
|
+
if (this._dispOrder) return this._dispOrder;
|
|
301
|
+
const idx = (id) => { const r = this.concepts.findConcept(id); return r.found ? r.index : -1; };
|
|
302
|
+
const US = 900000000000509007n; // US English
|
|
303
|
+
const GB = 900000000000508004n; // GB English
|
|
304
|
+
const byEdition = {
|
|
305
|
+
'900000000000207008': [US], // International Edition (US English is the default)
|
|
306
|
+
'31000003106': [US], // tx-ecosystem test edition (International-derived, US English default)
|
|
307
|
+
'731000124108': [US], // US Edition
|
|
308
|
+
'5991000124107': [US], // US Edition (with ICD-10-CM maps)
|
|
309
|
+
'83821000000107': [GB], // UK Edition
|
|
310
|
+
'999000021000000109': [GB] // UK Clinical Edition
|
|
311
|
+
};
|
|
312
|
+
const ids = byEdition[String(this.edition)] || [];
|
|
313
|
+
this._dispOrder = ids.map(idx).filter((i) => i >= 0);
|
|
314
|
+
return this._dispOrder;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Acceptability (a concept index, e.g. Preferred/Acceptable) of a description
|
|
318
|
+
// within the given language reference set, or -1 if it is not a member.
|
|
319
|
+
_descAcceptability(description, refsetIndex) {
|
|
320
|
+
if (!description.refsets || !description.valueses) return -1;
|
|
321
|
+
const refsets = this.refs.getReferences(description.refsets);
|
|
322
|
+
const valueses = this.refs.getReferences(description.valueses);
|
|
323
|
+
if (!refsets || !valueses) return -1;
|
|
324
|
+
for (let i = 0; i < refsets.length; i++) {
|
|
325
|
+
if (refsets[i] === refsetIndex) {
|
|
326
|
+
const vals = this.refs.getReferences(valueses[i]); // [acceptabilityIndex, type]
|
|
327
|
+
return (vals && vals.length > 0) ? vals[0] : -1;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return -1;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// True if the description is a synonym marked Preferred (900000000000548007)
|
|
334
|
+
// in ANY of its language reference sets. Editions use different English
|
|
335
|
+
// language refsets (International US-English 509007, GB 508004, US extension,
|
|
336
|
+
// ...), so we accept a preferred marking in any of them rather than a single
|
|
337
|
+
// hard-coded refset. (Multi-language dialect selection is a separate concern.)
|
|
338
|
+
_synonymIsPreferred(description) {
|
|
339
|
+
const K = this._displayConstants();
|
|
340
|
+
if (description.kind !== K.synonym) return false;
|
|
341
|
+
if (!description.valueses) return false;
|
|
342
|
+
const valueses = this.refs.getReferences(description.valueses);
|
|
343
|
+
if (!valueses) return false;
|
|
344
|
+
for (let i = 0; i < valueses.length; i++) {
|
|
345
|
+
const vals = this.refs.getReferences(valueses[i]); // [acceptabilityIndex, type]
|
|
346
|
+
if (vals && vals.length > 0 && vals[0] === K.preferred) return true;
|
|
347
|
+
}
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Return a concept's display: the synonym marked Preferred in the US English
|
|
352
|
+
// language reference set; failing that the FSN; failing that the first active
|
|
353
|
+
// description. Previously this returned the first active description outright,
|
|
354
|
+
// which is only the preferred term by accident of import order.
|
|
232
355
|
getDisplayName(reference = 0) {
|
|
233
356
|
try {
|
|
234
357
|
const concept = this.concepts.getConcept(reference);
|
|
@@ -239,17 +362,40 @@ class SnomedServices {
|
|
|
239
362
|
}
|
|
240
363
|
|
|
241
364
|
const descriptionIndices = this.refs.getReferences(descriptionsRef);
|
|
365
|
+
const K = this._displayConstants();
|
|
242
366
|
|
|
243
|
-
//
|
|
367
|
+
// 1. Preferred synonym in the edition's default display refset(s), tried
|
|
368
|
+
// in priority order. This is what disambiguates dialects: on the
|
|
369
|
+
// International edition a concept may be Preferred in both US and GB
|
|
370
|
+
// English, and the edition default (US English) must win.
|
|
371
|
+
for (const refsetIdx of this._displayRefsetOrder()) {
|
|
372
|
+
for (const descIndex of descriptionIndices) {
|
|
373
|
+
const description = this.descriptions.getDescription(descIndex);
|
|
374
|
+
if (!description.active || description.kind !== K.synonym) continue;
|
|
375
|
+
if (this._descAcceptability(description, refsetIdx) === K.preferred) {
|
|
376
|
+
return this.strings.getEntry(description.iDesc).trim();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// 2. Fallback: preferred synonym in ANY language refset; then FSN; then
|
|
382
|
+
// the first active description. Used for editions without a mapped
|
|
383
|
+
// default refset, and for concepts with no preferred synonym there.
|
|
384
|
+
let fsnTerm = '';
|
|
385
|
+
let firstActive = '';
|
|
244
386
|
for (const descIndex of descriptionIndices) {
|
|
245
387
|
const description = this.descriptions.getDescription(descIndex);
|
|
246
|
-
if (description.active)
|
|
247
|
-
|
|
248
|
-
|
|
388
|
+
if (!description.active) continue;
|
|
389
|
+
const term = this.strings.getEntry(description.iDesc).trim();
|
|
390
|
+
if (firstActive === '') firstActive = term;
|
|
391
|
+
if (this._synonymIsPreferred(description)) {
|
|
392
|
+
return term; // preferred synonym (any English language refset)
|
|
393
|
+
}
|
|
394
|
+
if (description.kind === K.fsn && fsnTerm === '') {
|
|
395
|
+
fsnTerm = term;
|
|
249
396
|
}
|
|
250
397
|
}
|
|
251
|
-
|
|
252
|
-
return '';
|
|
398
|
+
return fsnTerm || firstActive || '';
|
|
253
399
|
} catch (error) {
|
|
254
400
|
return '';
|
|
255
401
|
}
|
|
@@ -503,7 +649,15 @@ class SnomedServices {
|
|
|
503
649
|
result = this._evalECLNode(ast, opContext);
|
|
504
650
|
} catch (err) {
|
|
505
651
|
debugLog(err);
|
|
506
|
-
|
|
652
|
+
// Distinguish an *invalid* ECL expression (a malformed SCTID, or a
|
|
653
|
+
// reference to a concept that does not exist - the expression can never
|
|
654
|
+
// be valid) from an *unsupported* one (a well-formed expression using a
|
|
655
|
+
// feature this server does not implement). The tx-ecosystem test suite
|
|
656
|
+
// expects INVALID_ECL for the former, UNSUPPORTED_ECL for the latter.
|
|
657
|
+
const emsg = (err && err.message) ? err.message : String(err);
|
|
658
|
+
const invalid = /is not known/.test(emsg) || /Cannot convert .* to a BigInt/i.test(emsg);
|
|
659
|
+
const msgId = invalid ? 'INVALID_ECL' : 'UNSUPPORTED_ECL';
|
|
660
|
+
throw new Issue('error', 'invalid', null, msgId, opContext.i18n.translate(msgId, opContext.langs, [eclExpression, err.message]), 'vs-invalid').handleAsOO(400);
|
|
507
661
|
}
|
|
508
662
|
// Wildcard + iteration: the `eclWildcard` flag is only consulted by the
|
|
509
663
|
// per-concept membership checks (filterCheck/filterLocate). For an $expand
|
|
@@ -512,7 +666,13 @@ class SnomedServices {
|
|
|
512
666
|
if (forIteration && result.eclWildcard && (!result.descendants || result.descendants.length === 0)) {
|
|
513
667
|
result.descendants = this._eclEnumerateActiveConcepts(opContext);
|
|
514
668
|
delete result.eclWildcard;
|
|
669
|
+
// The materialised wildcard form differs from the lazy (eclWildcard) form,
|
|
670
|
+
// so mark it: the filter cache must not share it across iteration modes.
|
|
671
|
+
result.iterationSensitive = true;
|
|
515
672
|
}
|
|
673
|
+
// Keep the parsed AST so validate-code can test a post-coordinated
|
|
674
|
+
// expression's membership against the constraint (see _eclExpressionSatisfies).
|
|
675
|
+
result.eclAst = ast;
|
|
516
676
|
return result;
|
|
517
677
|
};
|
|
518
678
|
|
|
@@ -522,12 +682,24 @@ class SnomedServices {
|
|
|
522
682
|
* @returns {number[]}
|
|
523
683
|
*/
|
|
524
684
|
_eclEnumerateActiveConcepts = function (opContext) {
|
|
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.
|
|
525
697
|
const all = [];
|
|
526
698
|
const n = this.concepts.count();
|
|
527
699
|
for (let i = 0; i < n; i++) {
|
|
528
700
|
if (opContext) opContext.deadCheck('ecl:enumerateActiveConcepts');
|
|
529
701
|
const concept = this.concepts.getConceptByCount(i);
|
|
530
|
-
if ((concept.flags & 0x0F) === 0) {
|
|
702
|
+
if ((concept.flags & 0x0F) === 0) {
|
|
531
703
|
all.push(concept.index);
|
|
532
704
|
}
|
|
533
705
|
}
|
|
@@ -700,8 +872,9 @@ class SnomedServices {
|
|
|
700
872
|
* resolved to a set of candidate reference-set concepts, and the result is the
|
|
701
873
|
* union of their active concept members' referenced components.
|
|
702
874
|
*
|
|
703
|
-
* When the operand is a
|
|
704
|
-
*
|
|
875
|
+
* When the operand concept is not a reference set the result is empty (a
|
|
876
|
+
* non-refset concept simply has no members - this matches Snowstorm and the
|
|
877
|
+
* tx-ecosystem test suite). When the
|
|
705
878
|
* operand is a computed expression (e.g. ^(<<900000000000455006)), concepts in
|
|
706
879
|
* the resolved set that are not reference sets are simply skipped — the closure
|
|
707
880
|
* of "Reference set" necessarily includes the non-refset parent itself.
|
|
@@ -711,7 +884,6 @@ class SnomedServices {
|
|
|
711
884
|
* @returns {SnomedFilterContext}
|
|
712
885
|
*/
|
|
713
886
|
_evalMemberOf = function (memberOfNode, opContext) {
|
|
714
|
-
const operandIsBareRef = memberOfNode.refSet.type === ECLNodeType.CONCEPT_REFERENCE;
|
|
715
887
|
const refsetConcepts = this._eclResolveSet(this._evalECLNode(memberOfNode.refSet, opContext), opContext);
|
|
716
888
|
|
|
717
889
|
const members = new Set();
|
|
@@ -719,11 +891,10 @@ class SnomedServices {
|
|
|
719
891
|
if (opContext) opContext.deadCheck('ecl:memberOf');
|
|
720
892
|
const membersRef = this._findRefSetMembersRef(refsetIdx);
|
|
721
893
|
if (membersRef === null) {
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
continue; // computed operand: ignore concepts that aren't reference sets
|
|
894
|
+
// Operand concept is not a reference set: yield no members for it
|
|
895
|
+
// (empty result) rather than erroring. Applies to both a bare concept
|
|
896
|
+
// reference (e.g. "^ 10200004") and a computed operand.
|
|
897
|
+
continue;
|
|
727
898
|
}
|
|
728
899
|
if (membersRef === 0 || membersRef === 0xFFFFFFFF) {
|
|
729
900
|
continue; // a reference set with no members
|
|
@@ -817,7 +988,7 @@ class SnomedServices {
|
|
|
817
988
|
const matching = [];
|
|
818
989
|
for (const conceptIdx of baseSet) {
|
|
819
990
|
if (opContext) opContext.deadCheck('ecl:refined');
|
|
820
|
-
if (this._refinementMatches(conceptIdx, node.refinement, opContext)) {
|
|
991
|
+
if (this._refinementMatches(this._conceptRelRecords(conceptIdx), node.refinement, opContext)) {
|
|
821
992
|
matching.push(conceptIdx);
|
|
822
993
|
}
|
|
823
994
|
}
|
|
@@ -833,17 +1004,17 @@ class SnomedServices {
|
|
|
833
1004
|
* @param {object} refinement
|
|
834
1005
|
* @returns {boolean}
|
|
835
1006
|
*/
|
|
836
|
-
_refinementMatches = function (
|
|
1007
|
+
_refinementMatches = function (rels, refinement, opContext) {
|
|
837
1008
|
switch (refinement.type) {
|
|
838
1009
|
case ECLNodeType.ATTRIBUTE:
|
|
839
|
-
return this._attributeMatches(
|
|
1010
|
+
return this._attributeMatches(rels, refinement, null, opContext);
|
|
840
1011
|
case ECLNodeType.ATTRIBUTE_SET:
|
|
841
1012
|
for (const a of refinement.attributes) {
|
|
842
|
-
if (!this._refinementMatches(
|
|
1013
|
+
if (!this._refinementMatches(rels, a, opContext)) return false;
|
|
843
1014
|
}
|
|
844
1015
|
return true;
|
|
845
1016
|
case ECLNodeType.ATTRIBUTE_GROUP:
|
|
846
|
-
return this._attributeGroupMatches(
|
|
1017
|
+
return this._attributeGroupMatches(rels, refinement, opContext);
|
|
847
1018
|
default:
|
|
848
1019
|
throw new Error(`Unsupported refinement node type: ${refinement.type}`);
|
|
849
1020
|
}
|
|
@@ -859,7 +1030,7 @@ class SnomedServices {
|
|
|
859
1030
|
* @param {number|null} groupFilter
|
|
860
1031
|
* @returns {boolean}
|
|
861
1032
|
*/
|
|
862
|
-
_attributeMatches = function (
|
|
1033
|
+
_attributeMatches = function (rels, attr, groupFilter, opContext) {
|
|
863
1034
|
if (attr.reverse) {
|
|
864
1035
|
throw new Error('ECL reverse attributes (R) are not yet supported');
|
|
865
1036
|
}
|
|
@@ -876,7 +1047,7 @@ class SnomedServices {
|
|
|
876
1047
|
throw new Error('ECL refinements only support plain concept-reference attribute names');
|
|
877
1048
|
}
|
|
878
1049
|
|
|
879
|
-
const count = this._countAttributeMatches(
|
|
1050
|
+
const count = this._countAttributeMatches(rels, attr, groupFilter, opContext);
|
|
880
1051
|
|
|
881
1052
|
if (attr.cardinality) {
|
|
882
1053
|
return this._cardinalityAccepts(attr.cardinality, count);
|
|
@@ -893,7 +1064,7 @@ class SnomedServices {
|
|
|
893
1064
|
* @param {number|null} groupFilter
|
|
894
1065
|
* @returns {number}
|
|
895
1066
|
*/
|
|
896
|
-
_countAttributeMatches = function (
|
|
1067
|
+
_countAttributeMatches = function (rels, attr, groupFilter, opContext) {
|
|
897
1068
|
// The attribute type and the value set depend only on the (static) AST node,
|
|
898
1069
|
// not on the concept being tested, so resolve them once per refinement
|
|
899
1070
|
// attribute and reuse across the whole base set. Without this memo the value
|
|
@@ -916,21 +1087,23 @@ class SnomedServices {
|
|
|
916
1087
|
const attrTypeIdx = resolved.attrTypeIdx;
|
|
917
1088
|
const valueSet = resolved.valueSet;
|
|
918
1089
|
|
|
919
|
-
|
|
920
|
-
// ECL
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
|
|
925
|
-
|
|
1090
|
+
// ECL cardinality counts OCCURRENCES of a matching attribute, per role group
|
|
1091
|
+
// — not distinct values. ECL runs against the necessary normal form, where a
|
|
1092
|
+
// role group has no redundant attributes, so the same (type, value) in two
|
|
1093
|
+
// different role groups counts as two occurrences (confirmed by the SNOMED
|
|
1094
|
+
// Computable Languages Group; ECL 6.3 "non-redundant" refers to the normal
|
|
1095
|
+
// form, not to de-duplicating across groups). We therefore count distinct
|
|
1096
|
+
// (group, target) pairs: within a group filter this is the matching values
|
|
1097
|
+
// in that group; ungrouped it is the total occurrences across all groups.
|
|
1098
|
+
const matched = new Set();
|
|
1099
|
+
for (const rel of rels) {
|
|
926
1100
|
if (opContext) opContext.deadCheck('ecl:countAttributeMatches');
|
|
927
|
-
const rel = this.relationships.getRelationship(relIdx);
|
|
928
1101
|
if (!rel.active) continue;
|
|
929
1102
|
if (rel.relType !== attrTypeIdx) continue;
|
|
930
1103
|
if (groupFilter !== null && rel.group !== groupFilter) continue;
|
|
931
|
-
if (valueSet.has(rel.target))
|
|
1104
|
+
if (valueSet.has(rel.target)) matched.add(rel.group + ':' + rel.target);
|
|
932
1105
|
}
|
|
933
|
-
return
|
|
1106
|
+
return matched.size;
|
|
934
1107
|
};
|
|
935
1108
|
|
|
936
1109
|
/**
|
|
@@ -958,11 +1131,9 @@ class SnomedServices {
|
|
|
958
1131
|
* @param {object} group
|
|
959
1132
|
* @returns {boolean}
|
|
960
1133
|
*/
|
|
961
|
-
_attributeGroupMatches = function (
|
|
962
|
-
const relIdxs = this.getConceptRelationships(conceptIdx);
|
|
1134
|
+
_attributeGroupMatches = function (rels, group, opContext) {
|
|
963
1135
|
const groupNumbers = new Set();
|
|
964
|
-
for (const
|
|
965
|
-
const rel = this.relationships.getRelationship(relIdx);
|
|
1136
|
+
for (const rel of rels) {
|
|
966
1137
|
if (rel.active && rel.group > 0) {
|
|
967
1138
|
groupNumbers.add(rel.group);
|
|
968
1139
|
}
|
|
@@ -973,7 +1144,7 @@ class SnomedServices {
|
|
|
973
1144
|
if (opContext) opContext.deadCheck('ecl:attributeGroup');
|
|
974
1145
|
let allMatch = true;
|
|
975
1146
|
for (const attr of group.attributes) {
|
|
976
|
-
if (!this._attributeMatches(
|
|
1147
|
+
if (!this._attributeMatches(rels, attr, g, opContext)) {
|
|
977
1148
|
allMatch = false;
|
|
978
1149
|
break;
|
|
979
1150
|
}
|
|
@@ -991,6 +1162,139 @@ class SnomedServices {
|
|
|
991
1162
|
return false;
|
|
992
1163
|
};
|
|
993
1164
|
|
|
1165
|
+
// Materialise a concept's relationships as plain records {active, relType,
|
|
1166
|
+
// target, group} — the shape the refinement matchers consume. Sharing this
|
|
1167
|
+
// shape lets the same matchers run against a post-coordinated expression's
|
|
1168
|
+
// relationships (see _expressionRelRecords).
|
|
1169
|
+
_conceptRelRecords = function (conceptIdx) {
|
|
1170
|
+
const relIdxs = this.getConceptRelationships(conceptIdx);
|
|
1171
|
+
const out = [];
|
|
1172
|
+
for (const relIdx of relIdxs) {
|
|
1173
|
+
out.push(this.relationships.getRelationship(relIdx));
|
|
1174
|
+
}
|
|
1175
|
+
return out;
|
|
1176
|
+
};
|
|
1177
|
+
|
|
1178
|
+
// Turn one parsed expression refinement (name = attribute concept, value =
|
|
1179
|
+
// sub-expression) into a relationship record in the given group, or null if it
|
|
1180
|
+
// is not a plain concept = concept pair.
|
|
1181
|
+
_refinementToRel = function (refinement, group) {
|
|
1182
|
+
const relType = (refinement.name && refinement.name.reference != null)
|
|
1183
|
+
? refinement.name.reference : NO_REFERENCE;
|
|
1184
|
+
let target = NO_REFERENCE;
|
|
1185
|
+
const val = refinement.value;
|
|
1186
|
+
if (val && val.concepts && val.concepts.length > 0) {
|
|
1187
|
+
target = val.concepts[0].reference;
|
|
1188
|
+
}
|
|
1189
|
+
if (relType === NO_REFERENCE || target === NO_REFERENCE) return null;
|
|
1190
|
+
return { active: true, relType, target, group };
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1193
|
+
// Effective relationships of a post-coordinated expression: the focus
|
|
1194
|
+
// concept(s) stated relationships (inherited) plus the expression's own
|
|
1195
|
+
// refinements. Stated ungrouped refinements go in group 0; each stated
|
|
1196
|
+
// refinement group gets a fresh group number beyond any inherited group so the
|
|
1197
|
+
// two never collide. This is sufficient for the common single-focus
|
|
1198
|
+
// expressions; it is not a full necessary normal form (no redundancy pruning
|
|
1199
|
+
// across inherited and stated attributes).
|
|
1200
|
+
_expressionRelRecords = function (expression) {
|
|
1201
|
+
const rels = [];
|
|
1202
|
+
for (const c of expression.concepts || []) {
|
|
1203
|
+
if (c.reference != null && c.reference !== NO_REFERENCE && c.reference >= 0) {
|
|
1204
|
+
for (const rel of this._conceptRelRecords(c.reference)) rels.push(rel);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
let maxGroup = 0;
|
|
1208
|
+
for (const r of rels) if (r.group > maxGroup) maxGroup = r.group;
|
|
1209
|
+
for (const ref of expression.refinements || []) {
|
|
1210
|
+
const rec = this._refinementToRel(ref, 0);
|
|
1211
|
+
if (rec) rels.push(rec);
|
|
1212
|
+
}
|
|
1213
|
+
let g = maxGroup;
|
|
1214
|
+
for (const grp of expression.refinementGroups || []) {
|
|
1215
|
+
g += 1;
|
|
1216
|
+
for (const ref of grp.refinements || []) {
|
|
1217
|
+
const rec = this._refinementToRel(ref, g);
|
|
1218
|
+
if (rec) rels.push(rec);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
return rels;
|
|
1222
|
+
};
|
|
1223
|
+
|
|
1224
|
+
// Does a post-coordinated expression satisfy an ECL constraint? Unlike the
|
|
1225
|
+
// enumerating evaluator (_evalECLNode), this tests a single expression by its
|
|
1226
|
+
// focus subsumption and its (normal-form) relationships, so validate-code can
|
|
1227
|
+
// accept a composed expression that is a *member* of an ECL value set even
|
|
1228
|
+
// though it never appears in the (precoordinated) expansion.
|
|
1229
|
+
_eclExpressionSatisfies = function (exprContext, node, opContext) {
|
|
1230
|
+
const expr = exprContext.expression;
|
|
1231
|
+
const rels = this._expressionRelRecords(expr);
|
|
1232
|
+
return this._eclExprNode(expr, rels, node, opContext);
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
_eclExprNode = function (expr, rels, node, opContext) {
|
|
1236
|
+
if (!node) return false;
|
|
1237
|
+
if (opContext) opContext.deadCheck('ecl:exprSatisfies');
|
|
1238
|
+
switch (node.type) {
|
|
1239
|
+
case ECLNodeType.SUB_EXPRESSION_CONSTRAINT: {
|
|
1240
|
+
const focus = node.focus;
|
|
1241
|
+
if (focus.type === ECLNodeType.WILDCARD) return true;
|
|
1242
|
+
if (focus.type === ECLNodeType.MEMBER_OF) return false; // an expression is not a refset member
|
|
1243
|
+
if (focus.type === ECLNodeType.CONCEPT_REFERENCE) {
|
|
1244
|
+
return this._complexExprSatisfiesConcept(expr, focus.conceptId, node.operator);
|
|
1245
|
+
}
|
|
1246
|
+
return this._eclExprNode(expr, rels, focus, opContext); // parenthesised
|
|
1247
|
+
}
|
|
1248
|
+
case ECLNodeType.COMPOUND_EXPRESSION_CONSTRAINT: {
|
|
1249
|
+
const l = this._eclExprNode(expr, rels, node.left, opContext);
|
|
1250
|
+
const r = this._eclExprNode(expr, rels, node.right, opContext);
|
|
1251
|
+
switch (node.operator) {
|
|
1252
|
+
case ECLNodeType.CONJUNCTION: return l && r;
|
|
1253
|
+
case ECLNodeType.DISJUNCTION: return l || r;
|
|
1254
|
+
case ECLNodeType.EXCLUSION: return l && !r;
|
|
1255
|
+
default: throw new Error(`Unsupported ECL compound operator: ${node.operator}`);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
case ECLNodeType.REFINED_EXPRESSION_CONSTRAINT:
|
|
1259
|
+
return this._eclExprNode(expr, rels, node.base, opContext)
|
|
1260
|
+
&& this._refinementMatches(rels, node.refinement, opContext);
|
|
1261
|
+
case ECLNodeType.CONCEPT_REFERENCE:
|
|
1262
|
+
return this._complexExprSatisfiesConcept(expr, node.conceptId, null);
|
|
1263
|
+
case ECLNodeType.WILDCARD:
|
|
1264
|
+
return true;
|
|
1265
|
+
case ECLNodeType.MEMBER_OF:
|
|
1266
|
+
return false;
|
|
1267
|
+
default:
|
|
1268
|
+
throw new Error(`Unsupported ECL node type for expression membership: ${node.type}`);
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
// Whether a post-coordinated expression's focus stands in the given hierarchy
|
|
1273
|
+
// relation to conceptId. A refined expression is always strictly more specific
|
|
1274
|
+
// than its focus, so it can never be an ancestor/self/parent/child edge — only
|
|
1275
|
+
// the descendant operators (<, <<) hold, via focus subsumption.
|
|
1276
|
+
_complexExprSatisfiesConcept = function (expr, conceptId, operator) {
|
|
1277
|
+
const cr = this.concepts.findConcept(conceptId);
|
|
1278
|
+
if (!cr.found) {
|
|
1279
|
+
throw new Error(`The SNOMED CT Concept ${conceptId} is not known`);
|
|
1280
|
+
}
|
|
1281
|
+
const target = cr.index;
|
|
1282
|
+
const focusRefs = (expr.concepts || [])
|
|
1283
|
+
.map(c => c.reference)
|
|
1284
|
+
.filter(r => r != null && r !== NO_REFERENCE && r >= 0);
|
|
1285
|
+
if (focusRefs.length === 0) return false;
|
|
1286
|
+
const subsumed = focusRefs.every(f => this.subsumes(target, f));
|
|
1287
|
+
switch (operator) {
|
|
1288
|
+
case ECLTokenType.DESCENDANT_OR_SELF_OF: // <<
|
|
1289
|
+
case ECLTokenType.DESCENDANT_OF: // <
|
|
1290
|
+
return subsumed;
|
|
1291
|
+
// exact / ancestors / parents / children: a refined expression has no such
|
|
1292
|
+
// materialised standing, so it is not a member.
|
|
1293
|
+
default:
|
|
1294
|
+
return false;
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
|
|
994
1298
|
// ── Set operation helpers ────────────────────────────────────────────────────
|
|
995
1299
|
|
|
996
1300
|
/**
|
|
@@ -1065,16 +1369,10 @@ class SnomedServices {
|
|
|
1065
1369
|
const rightSet = new Set(this._eclToIndexArray(right));
|
|
1066
1370
|
|
|
1067
1371
|
if (left.eclWildcard) {
|
|
1068
|
-
//
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
const concept = this.concepts.getConceptByCount(i);
|
|
1073
|
-
if (this.isActive(concept.index) && !rightSet.has(concept.index)) {
|
|
1074
|
-
all.push(concept.index);
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
|
-
result.descendants = all;
|
|
1372
|
+
// Wildcard minus the right set: use the shared active-concept enumeration
|
|
1373
|
+
// (the root hierarchy) so `*` behaves consistently everywhere.
|
|
1374
|
+
result.descendants = this._eclEnumerateActiveConcepts(opContext)
|
|
1375
|
+
.filter(idx => !rightSet.has(idx));
|
|
1078
1376
|
return result;
|
|
1079
1377
|
}
|
|
1080
1378
|
|
|
@@ -1194,6 +1492,10 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1194
1492
|
return this.sct.totalCount;
|
|
1195
1493
|
}
|
|
1196
1494
|
|
|
1495
|
+
async countAllCodes(excludeInactive) {
|
|
1496
|
+
return excludeInactive ? this.sct.activeConceptCount() : this.sct.totalCount;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1197
1499
|
contentMode() {
|
|
1198
1500
|
return CodeSystemContentMode.Complete;
|
|
1199
1501
|
}
|
|
@@ -1279,7 +1581,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1279
1581
|
return this.sct.isActive(ctxt.getReference()) ? 'active' : 'inactive';
|
|
1280
1582
|
}
|
|
1281
1583
|
|
|
1282
|
-
async designations(context, displays) {
|
|
1584
|
+
async designations(context, displays, significantOnly = false) {
|
|
1283
1585
|
|
|
1284
1586
|
const ctxt = await this.#ensureContext(context);
|
|
1285
1587
|
|
|
@@ -1300,17 +1602,53 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1300
1602
|
|
|
1301
1603
|
if (descriptionsRef !== 0) {
|
|
1302
1604
|
const descriptionIndices = this.sct.refs.getReferences(descriptionsRef);
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1605
|
+
const K = this.sct._displayConstants();
|
|
1606
|
+
|
|
1607
|
+
if (significantOnly) {
|
|
1608
|
+
// $expand: emit the "significant" designations only — the FSN plus
|
|
1609
|
+
// the preferred synonym (US English), active descriptions only.
|
|
1610
|
+
// The preferred synonym is also the display, so it is added as the
|
|
1611
|
+
// display designation (which the expand worker selects for
|
|
1612
|
+
// `display` and skips from the emitted list) and additionally as a
|
|
1613
|
+
// Synonym designation so it still appears in the designation array.
|
|
1614
|
+
let fsn = null, prefSyn = null;
|
|
1615
|
+
for (const descIndex of descriptionIndices) {
|
|
1616
|
+
const description = this.sct.descriptions.getDescription(descIndex);
|
|
1617
|
+
if (!description.active) continue;
|
|
1618
|
+
const term = this.sct.strings.getEntry(description.iDesc).trim();
|
|
1619
|
+
const langCode = this.getLanguageCode(description.lang);
|
|
1620
|
+
const kind = this.sct.concepts.getConcept(description.kind);
|
|
1621
|
+
const use = { system: 'http://snomed.info/sct', code: String(kind.identity), display: this.sct.getDisplayName(description.kind) };
|
|
1622
|
+
if (description.kind === K.fsn) {
|
|
1623
|
+
if (!fsn) fsn = { langCode, use, term };
|
|
1624
|
+
} else if (this.sct._synonymIsPreferred(description)) {
|
|
1625
|
+
if (!prefSyn) prefSyn = { langCode, use, term };
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
const display = this.sct.getDisplayName(ctxt.getReference());
|
|
1629
|
+
if (display) displays.addDesignation(true, 'active', 'en-US', null, display);
|
|
1630
|
+
if (fsn) displays.addDesignation(false, 'active', fsn.langCode, fsn.use, fsn.term);
|
|
1631
|
+
if (prefSyn) displays.addDesignation(false, 'active', prefSyn.langCode, prefSyn.use, prefSyn.term);
|
|
1632
|
+
} else {
|
|
1633
|
+
// $lookup: emit every description (preferred synonym first so the
|
|
1634
|
+
// display resolves correctly; order is not otherwise significant).
|
|
1635
|
+
const orderedIndices = descriptionIndices.slice().sort((a, b) => {
|
|
1636
|
+
const da = this.sct.descriptions.getDescription(a);
|
|
1637
|
+
const db = this.sct.descriptions.getDescription(b);
|
|
1638
|
+
const pa = this.sct._synonymIsPreferred(da) ? 0 : 1;
|
|
1639
|
+
const pb = this.sct._synonymIsPreferred(db) ? 0 : 1;
|
|
1640
|
+
return pa - pb;
|
|
1641
|
+
});
|
|
1642
|
+
for (const descIndex of orderedIndices) {
|
|
1643
|
+
const description = this.sct.descriptions.getDescription(descIndex);
|
|
1644
|
+
const term = this.sct.strings.getEntry(description.iDesc).trim();
|
|
1645
|
+
const langCode = this.getLanguageCode(description.lang);
|
|
1646
|
+
const kind = this.sct.concepts.getConcept(description.kind);
|
|
1647
|
+
const kid = String(kind.identity);
|
|
1648
|
+
const kdesc = this.sct.getDisplayName(description.kind);
|
|
1649
|
+
let use = { system: 'http://snomed.info/sct', code: kid, display : kdesc};
|
|
1650
|
+
displays.addDesignation(false, description.active ? 'active' : 'inactive', langCode, use, term);
|
|
1651
|
+
}
|
|
1314
1652
|
}
|
|
1315
1653
|
}
|
|
1316
1654
|
} catch (error) {
|
|
@@ -1427,12 +1765,22 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1427
1765
|
|
|
1428
1766
|
|
|
1429
1767
|
if (!context) {
|
|
1430
|
-
// Iterate all active root concepts
|
|
1768
|
+
// Iterate all active root concepts; the walk descends into each root's
|
|
1769
|
+
// children via includeCodeAndDescendants. `total` reports the real concept
|
|
1770
|
+
// count (not the root count) so the expansion size cap is meaningful, while
|
|
1771
|
+
// `keys` are the roots and nextContext bounds on keys.length.
|
|
1772
|
+
// activeRoots holds concept *ids* (BigInt); map them to concept indices so
|
|
1773
|
+
// the contexts and the getConceptChildren descent work (which key on index).
|
|
1774
|
+
const rootKeys = [];
|
|
1775
|
+
for (const rootId of this.sct.activeRoots) {
|
|
1776
|
+
const r = this.sct.concepts.findConcept(rootId);
|
|
1777
|
+
if (r.found) rootKeys.push(r.index);
|
|
1778
|
+
}
|
|
1431
1779
|
return {
|
|
1432
1780
|
context: null,
|
|
1433
|
-
keys:
|
|
1781
|
+
keys: rootKeys,
|
|
1434
1782
|
current: 0,
|
|
1435
|
-
total: this.sct.
|
|
1783
|
+
total: this.sct.totalCount
|
|
1436
1784
|
};
|
|
1437
1785
|
} else {
|
|
1438
1786
|
const ctxt = await this.#ensureContext(context);
|
|
@@ -1452,7 +1800,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1452
1800
|
}
|
|
1453
1801
|
|
|
1454
1802
|
async nextContext(iteratorContext) {
|
|
1455
|
-
if (iteratorContext.current >= iteratorContext.
|
|
1803
|
+
if (iteratorContext.current >= iteratorContext.keys.length) {
|
|
1456
1804
|
return null;
|
|
1457
1805
|
}
|
|
1458
1806
|
|
|
@@ -1467,8 +1815,11 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1467
1815
|
if (ctxt) {
|
|
1468
1816
|
if (!(ctxt instanceof SnomedExpressionContext) || ctxt.expression?.concepts.length == 1) {
|
|
1469
1817
|
const time = this.sct.concepts.getConcept(ctxt.getReference()).effectiveTime;
|
|
1470
|
-
|
|
1471
|
-
|
|
1818
|
+
// Pascal TDateTime epoch (1899-12-30) computed in UTC so the formatted
|
|
1819
|
+
// date is timezone-independent. new Date(1899,11,30) is LOCAL midnight,
|
|
1820
|
+
// which in zones ahead of UTC rolls the ISO date back by one day.
|
|
1821
|
+
const pascalEpochUTC = Date.UTC(1899, 11, 30);
|
|
1822
|
+
const date = new Date(pascalEpochUTC + time * 86400000);
|
|
1472
1823
|
const dateStr = date.toISOString().slice(0, 10);
|
|
1473
1824
|
this._addDateTimeProperty(params, 'property', 'effectiveTime', dateStr);
|
|
1474
1825
|
|
|
@@ -1582,7 +1933,56 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1582
1933
|
return new SnomedPrep(); // Simple filter context
|
|
1583
1934
|
}
|
|
1584
1935
|
|
|
1585
|
-
|
|
1936
|
+
// Cache key pinning resolved analysis to the SNOMED edition url + version.
|
|
1937
|
+
// SNOMED always has both; the url varies across editions (incl. the test set).
|
|
1938
|
+
_snomedFilterCacheKey() {
|
|
1939
|
+
return this.system() + '|' + this.version();
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// Return a fresh filter context wrapping a cached ECL analysis, or null on
|
|
1943
|
+
// miss. The concept arrays are shared read-only (nothing mutates them for an
|
|
1944
|
+
// ECL set); only the cursor state is per-context.
|
|
1945
|
+
_getCachedEcl(fc) {
|
|
1946
|
+
if (!fc) {
|
|
1947
|
+
return null;
|
|
1948
|
+
}
|
|
1949
|
+
const byVersion = fc[SNOMED_FILTER_ANALYSIS];
|
|
1950
|
+
if (!byVersion) {
|
|
1951
|
+
return null;
|
|
1952
|
+
}
|
|
1953
|
+
const data = byVersion.get(this._snomedFilterCacheKey());
|
|
1954
|
+
if (!data) {
|
|
1955
|
+
return null;
|
|
1956
|
+
}
|
|
1957
|
+
const ctx = new SnomedFilterContext();
|
|
1958
|
+
ctx.descendants = data.descendants;
|
|
1959
|
+
ctx.members = data.members;
|
|
1960
|
+
ctx.matches = data.matches;
|
|
1961
|
+
ctx.eclAst = data.eclAst;
|
|
1962
|
+
return ctx;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
// Memoise an ECL analysis on fc, keyed by url+version. Skips iteration-
|
|
1966
|
+
// sensitive results (wildcards): their materialised form is not valid for the
|
|
1967
|
+
// other iteration mode, and their lazy form is cheap to recompute anyway.
|
|
1968
|
+
_cacheEcl(fc, resolved) {
|
|
1969
|
+
if (!fc || !resolved || resolved.eclWildcard || resolved.iterationSensitive) {
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
let byVersion = fc[SNOMED_FILTER_ANALYSIS];
|
|
1973
|
+
if (!byVersion) {
|
|
1974
|
+
byVersion = new Map();
|
|
1975
|
+
Object.defineProperty(fc, SNOMED_FILTER_ANALYSIS, { value: byVersion, enumerable: false, configurable: true, writable: true });
|
|
1976
|
+
}
|
|
1977
|
+
byVersion.set(this._snomedFilterCacheKey(), {
|
|
1978
|
+
descendants: resolved.descendants,
|
|
1979
|
+
members: resolved.members,
|
|
1980
|
+
matches: resolved.matches,
|
|
1981
|
+
eclAst: resolved.eclAst,
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
async filter(filterContext, forIteration, prop, op, value, fc) {
|
|
1586
1986
|
|
|
1587
1987
|
if (prop === 'concept') {
|
|
1588
1988
|
const id = this.sct.stringToIdOrZero(value);
|
|
@@ -1636,7 +2036,18 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1636
2036
|
}
|
|
1637
2037
|
|
|
1638
2038
|
if (prop === 'constraint' && op === '=') {
|
|
1639
|
-
|
|
2039
|
+
// Reuse a previously resolved ECL analysis for this same cached value set
|
|
2040
|
+
// filter against the same code system (url+version). ECL resolution is the
|
|
2041
|
+
// expensive SNOMED filter to prepare; the resolved concept list is shared
|
|
2042
|
+
// read-only, wrapped in a fresh context so the iteration cursor is per-use.
|
|
2043
|
+
const cached = this._getCachedEcl(fc);
|
|
2044
|
+
if (cached) {
|
|
2045
|
+
filterContext.filters.push(cached);
|
|
2046
|
+
return null;
|
|
2047
|
+
}
|
|
2048
|
+
const resolved = await this.sct.filterECL(value, forIteration, this.opContext);
|
|
2049
|
+
this._cacheEcl(fc, resolved);
|
|
2050
|
+
filterContext.filters.push(resolved);
|
|
1640
2051
|
return null;
|
|
1641
2052
|
}
|
|
1642
2053
|
|
|
@@ -1700,6 +2111,11 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1700
2111
|
}
|
|
1701
2112
|
|
|
1702
2113
|
async filterSize(filterContext, set) {
|
|
2114
|
+
// A wildcard (`*`) set is lazy - it carries the eclWildcard flag rather than a
|
|
2115
|
+
// materialised descendant list - so count the active concepts it denotes.
|
|
2116
|
+
if (set.eclWildcard && (!set.descendants || set.descendants.length === 0)) {
|
|
2117
|
+
return this.sct._eclEnumerateActiveConcepts(this.opContext).length;
|
|
2118
|
+
}
|
|
1703
2119
|
if (set.matches && set.matches.length > 0) {
|
|
1704
2120
|
return set.matches.length;
|
|
1705
2121
|
} else if (set.members && set.members.length > 0) {
|
|
@@ -1746,11 +2162,19 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1746
2162
|
return conceptResult.message;
|
|
1747
2163
|
}
|
|
1748
2164
|
|
|
2165
|
+
const ctxt = conceptResult.context;
|
|
2166
|
+
const reference = ctxt.getReference();
|
|
2167
|
+
|
|
1749
2168
|
if (set.eclWildcard) {
|
|
1750
2169
|
return this.sct.isActive(reference) ? ctxt : null;
|
|
1751
2170
|
}
|
|
1752
|
-
|
|
1753
|
-
|
|
2171
|
+
|
|
2172
|
+
// A post-coordinated expression validated against an ECL value set: test the
|
|
2173
|
+
// expression's membership against the constraint directly (subsumption +
|
|
2174
|
+
// refinements), rather than collapsing it to its focus concept.
|
|
2175
|
+
if (set.eclAst && ctxt.isComplex()) {
|
|
2176
|
+
return this.sct._eclExpressionSatisfies(ctxt, set.eclAst, this.opContext) ? ctxt : null;
|
|
2177
|
+
}
|
|
1754
2178
|
let found = false;
|
|
1755
2179
|
|
|
1756
2180
|
if (set.inactive !== undefined) {
|
|
@@ -1771,6 +2195,9 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1771
2195
|
}
|
|
1772
2196
|
}
|
|
1773
2197
|
} else if (set.matches && set.matches.length > 0) {
|
|
2198
|
+
// filterLocate is only ever called once per set (single code, validate
|
|
2199
|
+
// path), so a Set would cost O(n) to build for one lookup - a plain scan
|
|
2200
|
+
// is the right call here. filterCheck, which is looped, uses the Sets.
|
|
1774
2201
|
found = set.matches.some(m => m.index === reference);
|
|
1775
2202
|
} else if (set.members && set.members.length > 0) {
|
|
1776
2203
|
found = set.members.some(m => m.ref === reference);
|
|
@@ -1815,11 +2242,11 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1815
2242
|
}
|
|
1816
2243
|
|
|
1817
2244
|
if (set.matches && set.matches.length > 0) {
|
|
1818
|
-
return set
|
|
2245
|
+
return this.#matchesHas(set, reference);
|
|
1819
2246
|
} else if (set.members && set.members.length > 0) {
|
|
1820
|
-
return set
|
|
2247
|
+
return this.#membersHas(set, reference);
|
|
1821
2248
|
} else if (set.descendants && set.descendants.length > 0) {
|
|
1822
|
-
return set
|
|
2249
|
+
return this.#descendantsHas(set, reference);
|
|
1823
2250
|
}
|
|
1824
2251
|
if (set.eclWildcard) {
|
|
1825
2252
|
return this.sct.isActive(reference);
|
|
@@ -1827,6 +2254,34 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1827
2254
|
return false;
|
|
1828
2255
|
}
|
|
1829
2256
|
|
|
2257
|
+
// Lazily build and cache a membership Set on the filter context so repeated
|
|
2258
|
+
// filterLocate/filterCheck calls test membership in O(1) instead of scanning
|
|
2259
|
+
// the descendants/members/matches array each time. The length guard rebuilds
|
|
2260
|
+
// the Set if the underlying array was mutated (e.g. by #ensurePopulated).
|
|
2261
|
+
#descendantsHas(set, reference) {
|
|
2262
|
+
if (!set._descSet || set._descSetLen !== set.descendants.length) {
|
|
2263
|
+
set._descSet = new Set(set.descendants);
|
|
2264
|
+
set._descSetLen = set.descendants.length;
|
|
2265
|
+
}
|
|
2266
|
+
return set._descSet.has(reference);
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
#membersHas(set, reference) {
|
|
2270
|
+
if (!set._memberSet || set._memberSetLen !== set.members.length) {
|
|
2271
|
+
set._memberSet = new Set(set.members.map(m => m.ref));
|
|
2272
|
+
set._memberSetLen = set.members.length;
|
|
2273
|
+
}
|
|
2274
|
+
return set._memberSet.has(reference);
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
#matchesHas(set, reference) {
|
|
2278
|
+
if (!set._matchSet || set._matchSetLen !== set.matches.length) {
|
|
2279
|
+
set._matchSet = new Set(set.matches.map(m => m.index));
|
|
2280
|
+
set._matchSetLen = set.matches.length;
|
|
2281
|
+
}
|
|
2282
|
+
return set._matchSet.has(reference);
|
|
2283
|
+
}
|
|
2284
|
+
|
|
1830
2285
|
#ensurePopulated(set) {
|
|
1831
2286
|
if (set.populationDone) {
|
|
1832
2287
|
return;
|