fhirsmith 0.9.2 → 0.9.3
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 +19 -0
- package/package.json +1 -1
- package/translations/Messages.properties +3 -1
- package/tx/cs/cs-api.js +2 -1
- package/tx/cs/cs-areacode.js +1 -1
- package/tx/cs/cs-country.js +1 -1
- package/tx/cs/cs-cpt.js +1 -1
- package/tx/cs/cs-cs.js +1 -1
- package/tx/cs/cs-currency.js +1 -1
- package/tx/cs/cs-hgvs.js +1 -1
- package/tx/cs/cs-lang.js +1 -1
- package/tx/cs/cs-loinc.js +1 -1
- package/tx/cs/cs-ndc.js +1 -1
- package/tx/cs/cs-omop.js +1 -1
- package/tx/cs/cs-rxnorm.js +1 -1
- package/tx/cs/cs-snomed.js +603 -22
- package/tx/cs/cs-ucum.js +1 -1
- package/tx/sct/ecl.js +98 -49
- package/tx/tx.js +6 -2
- package/tx/vs/vs-vsac.js +38 -9
- package/tx/workers/expand.js +42 -24
- package/tx/workers/related.js +1 -1
- package/tx/workers/validate.js +21 -24
- package/tx/workers/worker.js +16 -1
package/tx/cs/cs-snomed.js
CHANGED
|
@@ -13,6 +13,9 @@ const {DesignationUse} = require("../library/designations");
|
|
|
13
13
|
const {BaseCSServices} = require("./cs-base");
|
|
14
14
|
const {formatDateMMDDYYYY} = require("../../library/utilities");
|
|
15
15
|
const {ConceptMap} = require("../library/conceptmap");
|
|
16
|
+
const {ECLLexer, ECLParser, ECLNodeType, ECLTokenType} = require("../sct/ecl");
|
|
17
|
+
const {Issue} = require("../library/operation-outcome");
|
|
18
|
+
const {debugLog} = require("../operation-context");
|
|
16
19
|
|
|
17
20
|
// Context kinds matching Pascal enum
|
|
18
21
|
const SnomedProviderContextKind = {
|
|
@@ -57,15 +60,15 @@ class SnomedExpressionContext {
|
|
|
57
60
|
|
|
58
61
|
getReference() {
|
|
59
62
|
return this.expression && this.expression.concepts.length > 0
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
? this.expression.concepts[0].reference
|
|
64
|
+
: NO_REFERENCE;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
getCode() {
|
|
65
68
|
if (this.source) return this.source;
|
|
66
69
|
return this.expression && this.expression.concepts.length > 0
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
? this.expression.concepts[0].code
|
|
71
|
+
: '';
|
|
69
72
|
}
|
|
70
73
|
}
|
|
71
74
|
|
|
@@ -445,6 +448,561 @@ class SnomedServices {
|
|
|
445
448
|
|
|
446
449
|
}
|
|
447
450
|
|
|
451
|
+
/**
|
|
452
|
+
* Supported ECL subset:
|
|
453
|
+
* Plain concept ref 404684003
|
|
454
|
+
* << (descendant-or-self-of)
|
|
455
|
+
* <! (strict descendant-of)
|
|
456
|
+
* < (child-of)
|
|
457
|
+
* >> (ancestor-or-self-of)
|
|
458
|
+
* >! (strict ancestor-of)
|
|
459
|
+
* > (parent-of)
|
|
460
|
+
* ^ (member-of refset) — refset must be a plain concept ID
|
|
461
|
+
* * (wildcard)
|
|
462
|
+
* AND / OR / MINUS compound expressions
|
|
463
|
+
*
|
|
464
|
+
* Everything else (refinements, dotted expressions, cardinality,
|
|
465
|
+
* reverse attributes, numeric/string comparisons) throws an informative error.
|
|
466
|
+
*/
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Parse an ECL expression string and return a SnomedFilterContext whose
|
|
470
|
+
* `descendants` array contains the resolved concept indexes.
|
|
471
|
+
*
|
|
472
|
+
* Throws an Error for syntax errors, unknown concepts, or unsupported features.
|
|
473
|
+
*
|
|
474
|
+
* @param {string} eclExpression
|
|
475
|
+
* @returns {SnomedFilterContext}
|
|
476
|
+
*/
|
|
477
|
+
filterECL = function (eclExpression, forIteration, opContext) {
|
|
478
|
+
let ast;
|
|
479
|
+
try {
|
|
480
|
+
const tokens = new ECLLexer(eclExpression).tokenize();
|
|
481
|
+
ast = new ECLParser(tokens).parse();
|
|
482
|
+
} catch (err) {
|
|
483
|
+
debugLog(err);
|
|
484
|
+
throw new Issue('error', 'invalid', null, 'INVALID_ECL', opContext.i18n.translate('INVALID_ECL', opContext.langs, [eclExpression, err.message]), 'vs-invalid').handleAsOO(400);
|
|
485
|
+
}
|
|
486
|
+
let result;
|
|
487
|
+
try {
|
|
488
|
+
result = this._evalECLNode(ast);
|
|
489
|
+
} catch (err) {
|
|
490
|
+
debugLog(err);
|
|
491
|
+
throw new Issue('error', 'invalid', null, 'UNSUPPORTED_ECL', opContext.i18n.translate('UNSUPPORTED_ECL', opContext.langs, [eclExpression, err.message]), 'vs-invalid').handleAsOO(400);
|
|
492
|
+
}
|
|
493
|
+
// Wildcard + iteration: the `eclWildcard` flag is only consulted by the
|
|
494
|
+
// per-concept membership checks (filterCheck/filterLocate). For an $expand
|
|
495
|
+
// we actually need the full concept list, otherwise filterSize returns 0
|
|
496
|
+
// and the iteration yields nothing. Materialise active concepts now.
|
|
497
|
+
if (forIteration && result.eclWildcard && (!result.descendants || result.descendants.length === 0)) {
|
|
498
|
+
result.descendants = this._eclEnumerateActiveConcepts();
|
|
499
|
+
delete result.eclWildcard;
|
|
500
|
+
}
|
|
501
|
+
return result;
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Return every active concept's index. Used to materialise wildcard results
|
|
506
|
+
* when the filter needs to be iterated over (e.g. $expand).
|
|
507
|
+
* @returns {number[]}
|
|
508
|
+
*/
|
|
509
|
+
_eclEnumerateActiveConcepts = function () {
|
|
510
|
+
const all = [];
|
|
511
|
+
const n = this.concepts.count();
|
|
512
|
+
for (let i = 0; i < n; i++) {
|
|
513
|
+
const concept = this.concepts.getConceptByCount(i);
|
|
514
|
+
if ((concept.flags & 0x0F) === 0) { // active
|
|
515
|
+
all.push(concept.index);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return all;
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Recursive ECL AST evaluator.
|
|
523
|
+
* @param {object} node
|
|
524
|
+
* @returns {SnomedFilterContext}
|
|
525
|
+
*/
|
|
526
|
+
_evalECLNode = function (node) {
|
|
527
|
+
if (!node) {
|
|
528
|
+
throw new Error('ECL evaluation error: null AST node');
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
switch (node.type) {
|
|
532
|
+
|
|
533
|
+
case ECLNodeType.SUB_EXPRESSION_CONSTRAINT:
|
|
534
|
+
return this._evalSubExpression(node);
|
|
535
|
+
|
|
536
|
+
case ECLNodeType.COMPOUND_EXPRESSION_CONSTRAINT: {
|
|
537
|
+
const left = this._evalECLNode(node.left);
|
|
538
|
+
const right = this._evalECLNode(node.right);
|
|
539
|
+
switch (node.operator) {
|
|
540
|
+
case ECLNodeType.CONJUNCTION:
|
|
541
|
+
return this._eclIntersect(left, right);
|
|
542
|
+
case ECLNodeType.DISJUNCTION:
|
|
543
|
+
return this._eclUnion(left, right);
|
|
544
|
+
case ECLNodeType.EXCLUSION:
|
|
545
|
+
return this._eclMinus(left, right);
|
|
546
|
+
default:
|
|
547
|
+
throw new Error(`Unsupported ECL compound operator: ${node.operator}`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
case ECLNodeType.REFINED_EXPRESSION_CONSTRAINT:
|
|
552
|
+
return this._evalRefined(node);
|
|
553
|
+
|
|
554
|
+
case ECLNodeType.DOTTED_EXPRESSION_CONSTRAINT:
|
|
555
|
+
return this._evalDotted(node);
|
|
556
|
+
|
|
557
|
+
default:
|
|
558
|
+
// Could be a bare concept reference or wildcard passed in directly
|
|
559
|
+
// (e.g. when a parenthesised expression resolves to one of these).
|
|
560
|
+
if (node.type === ECLNodeType.CONCEPT_REFERENCE ||
|
|
561
|
+
node.type === ECLNodeType.WILDCARD ||
|
|
562
|
+
node.type === ECLNodeType.MEMBER_OF) {
|
|
563
|
+
// Wrap it as if it came from a no-operator SubExpressionConstraint
|
|
564
|
+
return this._evalSubExpression({type: ECLNodeType.SUB_EXPRESSION_CONSTRAINT, operator: null, focus: node});
|
|
565
|
+
}
|
|
566
|
+
throw new Error(`Unsupported ECL node type: ${node.type}`);
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Evaluate a SUB_EXPRESSION_CONSTRAINT node, which combines an optional
|
|
572
|
+
* hierarchy operator with a focus (concept ref, wildcard, or member-of).
|
|
573
|
+
* @param {object} node
|
|
574
|
+
* @returns {SnomedFilterContext}
|
|
575
|
+
*/
|
|
576
|
+
_evalSubExpression = function (node) {
|
|
577
|
+
const operator = node.operator; // an ECLTokenType string, or null
|
|
578
|
+
const focus = node.focus;
|
|
579
|
+
|
|
580
|
+
// Wildcard
|
|
581
|
+
if (focus.type === ECLNodeType.WILDCARD) {
|
|
582
|
+
if (operator) {
|
|
583
|
+
throw new Error('ECL hierarchy operators combined with wildcard (*) are not supported');
|
|
584
|
+
}
|
|
585
|
+
return this._eclWildcard();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Member-of (^)
|
|
589
|
+
if (focus.type === ECLNodeType.MEMBER_OF) {
|
|
590
|
+
if (operator) {
|
|
591
|
+
throw new Error('ECL hierarchy operators combined with ^ (member-of) are not yet supported');
|
|
592
|
+
}
|
|
593
|
+
return this._evalMemberOf(focus);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Plain concept reference
|
|
597
|
+
if (focus.type === ECLNodeType.CONCEPT_REFERENCE) {
|
|
598
|
+
return this._evalConceptWithOperator(focus.conceptId, operator);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Parenthesised sub-expression: focus is itself a full constraint node
|
|
602
|
+
return this._evalECLNode(focus);
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Resolve a concept ID + hierarchy operator.
|
|
607
|
+
* @param {string} conceptId
|
|
608
|
+
* @param {string|null} operator ECLTokenType constant
|
|
609
|
+
* @returns {SnomedFilterContext}
|
|
610
|
+
*/
|
|
611
|
+
_evalConceptWithOperator = function (conceptId, operator) {
|
|
612
|
+
switch (operator) {
|
|
613
|
+
case null:
|
|
614
|
+
case undefined:
|
|
615
|
+
return this.filterEquals(conceptId);
|
|
616
|
+
|
|
617
|
+
// ── Descendants ────────────────────────────────────────────────────────
|
|
618
|
+
case ECLTokenType.DESCENDANT_OR_SELF_OF: { // << self + all transitive descendants
|
|
619
|
+
return this.filterIsA(conceptId, true);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
case ECLTokenType.DESCENDANT_OF: { // < all transitive descendants, no self
|
|
623
|
+
return this.filterIsA(conceptId, false);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
case ECLTokenType.CHILD_OR_SELF_OF: { // <<! self + direct children only
|
|
627
|
+
const conceptResult = this.concepts.findConcept(conceptId);
|
|
628
|
+
if (!conceptResult.found) {
|
|
629
|
+
throw new Error(`The SNOMED CT Concept ${conceptId} is not known`);
|
|
630
|
+
}
|
|
631
|
+
const result = new SnomedFilterContext();
|
|
632
|
+
const children = this.getConceptChildren(conceptResult.index);
|
|
633
|
+
result.descendants = [conceptResult.index, ...children];
|
|
634
|
+
return result;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
case ECLTokenType.CHILD_OF: { // <! direct children only
|
|
638
|
+
return this.filterChildOf(conceptId);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ── Ancestors ──────────────────────────────────────────────────────────
|
|
642
|
+
case ECLTokenType.ANCESTOR_OR_SELF_OF: { // >> self + all transitive ancestors
|
|
643
|
+
const result = this.filterGeneralizes(conceptId);
|
|
644
|
+
const self = this.concepts.findConcept(conceptId);
|
|
645
|
+
if (self.found && !result.descendants.includes(self.index)) {
|
|
646
|
+
result.descendants.push(self.index);
|
|
647
|
+
}
|
|
648
|
+
return result;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
case ECLTokenType.ANCESTOR_OF: { // > all transitive ancestors, no self
|
|
652
|
+
return this.filterGeneralizes(conceptId);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
case ECLTokenType.PARENT_OR_SELF_OF: { // >>! self + direct parents only
|
|
656
|
+
const conceptResult = this.concepts.findConcept(conceptId);
|
|
657
|
+
if (!conceptResult.found) {
|
|
658
|
+
throw new Error(`The SNOMED CT Concept ${conceptId} is not known`);
|
|
659
|
+
}
|
|
660
|
+
const result = new SnomedFilterContext();
|
|
661
|
+
const parents = this.getConceptParents(conceptResult.index);
|
|
662
|
+
result.descendants = [conceptResult.index, ...parents];
|
|
663
|
+
return result;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
case ECLTokenType.PARENT_OF: { // >! direct parents only
|
|
667
|
+
const conceptResult = this.concepts.findConcept(conceptId);
|
|
668
|
+
if (!conceptResult.found) {
|
|
669
|
+
throw new Error(`The SNOMED CT Concept ${conceptId} is not known`);
|
|
670
|
+
}
|
|
671
|
+
const result = new SnomedFilterContext();
|
|
672
|
+
result.descendants = this.getConceptParents(conceptResult.index);
|
|
673
|
+
return result;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
default:
|
|
677
|
+
throw new Error(`Unsupported ECL hierarchy operator: ${operator}`);
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Evaluate a MEMBER_OF node. Only plain concept-reference refsets are
|
|
683
|
+
* supported; complex expressions inside ^ are not yet supported.
|
|
684
|
+
* @param {object} memberOfNode
|
|
685
|
+
* @returns {SnomedFilterContext}
|
|
686
|
+
*/
|
|
687
|
+
_evalMemberOf = function (memberOfNode) {
|
|
688
|
+
const refSet = memberOfNode.refSet;
|
|
689
|
+
if (refSet.type !== ECLNodeType.CONCEPT_REFERENCE) {
|
|
690
|
+
throw new Error('ECL ^ (member-of) with a non-concept-reference refset is not yet supported');
|
|
691
|
+
}
|
|
692
|
+
// filterIn accepts a comma-separated string; a single ID works fine
|
|
693
|
+
return this.filterIn(refSet.conceptId);
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Wildcard — all active concepts. The eclWildcard flag tells filterCheck /
|
|
698
|
+
* filterLocate to accept every active concept without enumeration.
|
|
699
|
+
* @returns {SnomedFilterContext}
|
|
700
|
+
*/
|
|
701
|
+
_eclWildcard = function () {
|
|
702
|
+
const result = new SnomedFilterContext();
|
|
703
|
+
result.eclWildcard = true;
|
|
704
|
+
return result;
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
// ── Dotted expressions ───────────────────────────────────────────────────────
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Evaluate a dotted expression: `<baseConstraint> . attrA . attrB`.
|
|
711
|
+
* For each chained attribute, replaces the current set with the set of
|
|
712
|
+
* active relationship targets whose `relType` matches the attribute.
|
|
713
|
+
* Only plain concept-reference attribute names are supported.
|
|
714
|
+
* @param {object} node
|
|
715
|
+
* @returns {SnomedFilterContext}
|
|
716
|
+
*/
|
|
717
|
+
_evalDotted = function (node) {
|
|
718
|
+
let current = this._eclResolveSet(this._evalECLNode(node.base));
|
|
719
|
+
|
|
720
|
+
for (const attr of node.attributes || []) {
|
|
721
|
+
if (attr.type !== ECLNodeType.CONCEPT_REFERENCE) {
|
|
722
|
+
throw new Error('ECL dotted expressions only support plain concept-reference attribute names');
|
|
723
|
+
}
|
|
724
|
+
const attrResult = this.concepts.findConcept(attr.conceptId);
|
|
725
|
+
if (!attrResult.found) {
|
|
726
|
+
throw new Error(`The SNOMED CT Concept ${attr.conceptId} is not known`);
|
|
727
|
+
}
|
|
728
|
+
const attrTypeIdx = attrResult.index;
|
|
729
|
+
|
|
730
|
+
const next = new Set();
|
|
731
|
+
for (const conceptIdx of current) {
|
|
732
|
+
const relIdxs = this.getConceptRelationships(conceptIdx);
|
|
733
|
+
for (const relIdx of relIdxs) {
|
|
734
|
+
const rel = this.relationships.getRelationship(relIdx);
|
|
735
|
+
if (rel.active && rel.relType === attrTypeIdx) {
|
|
736
|
+
next.add(rel.target);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
current = [...next];
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const result = new SnomedFilterContext();
|
|
744
|
+
result.descendants = current;
|
|
745
|
+
return result;
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
// ── Refinements ──────────────────────────────────────────────────────────────
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Evaluate a refined expression: `<baseConstraint> : <refinement>`.
|
|
752
|
+
* Supported refinement shapes:
|
|
753
|
+
* - ATTRIBUTE attr = valueExpr
|
|
754
|
+
* - ATTRIBUTE_SET attr1 = v1, attr2 = v2 (conjunction)
|
|
755
|
+
* - ATTRIBUTE_GROUP { attr1 = v1, attr2 = v2 } (same relationship group)
|
|
756
|
+
* Reverse attributes, cardinality, `!=`, and non-concept attribute names
|
|
757
|
+
* throw informative errors.
|
|
758
|
+
* @param {object} node
|
|
759
|
+
* @returns {SnomedFilterContext}
|
|
760
|
+
*/
|
|
761
|
+
_evalRefined = function (node) {
|
|
762
|
+
const baseSet = this._eclResolveSet(this._evalECLNode(node.base));
|
|
763
|
+
const matching = [];
|
|
764
|
+
for (const conceptIdx of baseSet) {
|
|
765
|
+
if (this._refinementMatches(conceptIdx, node.refinement)) {
|
|
766
|
+
matching.push(conceptIdx);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const result = new SnomedFilterContext();
|
|
770
|
+
result.descendants = matching;
|
|
771
|
+
return result;
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Check whether a single concept satisfies a refinement node (ATTRIBUTE,
|
|
776
|
+
* ATTRIBUTE_SET, or ATTRIBUTE_GROUP).
|
|
777
|
+
* @param {number} conceptIdx
|
|
778
|
+
* @param {object} refinement
|
|
779
|
+
* @returns {boolean}
|
|
780
|
+
*/
|
|
781
|
+
_refinementMatches = function (conceptIdx, refinement) {
|
|
782
|
+
switch (refinement.type) {
|
|
783
|
+
case ECLNodeType.ATTRIBUTE:
|
|
784
|
+
return this._attributeMatches(conceptIdx, refinement, null);
|
|
785
|
+
case ECLNodeType.ATTRIBUTE_SET:
|
|
786
|
+
for (const a of refinement.attributes) {
|
|
787
|
+
if (!this._refinementMatches(conceptIdx, a)) return false;
|
|
788
|
+
}
|
|
789
|
+
return true;
|
|
790
|
+
case ECLNodeType.ATTRIBUTE_GROUP:
|
|
791
|
+
return this._attributeGroupMatches(conceptIdx, refinement);
|
|
792
|
+
default:
|
|
793
|
+
throw new Error(`Unsupported refinement node type: ${refinement.type}`);
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Check whether a concept has at least one active relationship whose
|
|
799
|
+
* `relType` matches the attribute name and whose `target` is in the value
|
|
800
|
+
* expression's result set. If `groupFilter` is not null, the relationship
|
|
801
|
+
* must also have that exact `group` number (used by group matching).
|
|
802
|
+
* @param {number} conceptIdx
|
|
803
|
+
* @param {object} attr
|
|
804
|
+
* @param {number|null} groupFilter
|
|
805
|
+
* @returns {boolean}
|
|
806
|
+
*/
|
|
807
|
+
_attributeMatches = function (conceptIdx, attr, groupFilter) {
|
|
808
|
+
if (attr.reverse) {
|
|
809
|
+
throw new Error('ECL reverse attributes (R) are not yet supported');
|
|
810
|
+
}
|
|
811
|
+
if (!attr.comparison) {
|
|
812
|
+
throw new Error('ECL attribute without a comparison is not supported');
|
|
813
|
+
}
|
|
814
|
+
if (attr.comparison.type !== ECLNodeType.EXPRESSION_COMPARISON) {
|
|
815
|
+
throw new Error(`ECL ${attr.comparison.type} in refinements is not yet supported`);
|
|
816
|
+
}
|
|
817
|
+
if (attr.comparison.operator !== ECLTokenType.EQUALS) {
|
|
818
|
+
throw new Error('ECL != in refinements is not yet supported');
|
|
819
|
+
}
|
|
820
|
+
if (attr.name.type !== ECLNodeType.CONCEPT_REFERENCE) {
|
|
821
|
+
throw new Error('ECL refinements only support plain concept-reference attribute names');
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const count = this._countAttributeMatches(conceptIdx, attr, groupFilter);
|
|
825
|
+
|
|
826
|
+
if (attr.cardinality) {
|
|
827
|
+
return this._cardinalityAccepts(attr.cardinality, count);
|
|
828
|
+
}
|
|
829
|
+
return count >= 1;
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Count the number of active relationships on the concept whose `relType`
|
|
834
|
+
* matches the attribute name and whose `target` is in the value expression's
|
|
835
|
+
* result set. Honours an optional group filter.
|
|
836
|
+
* @param {number} conceptIdx
|
|
837
|
+
* @param {object} attr
|
|
838
|
+
* @param {number|null} groupFilter
|
|
839
|
+
* @returns {number}
|
|
840
|
+
*/
|
|
841
|
+
_countAttributeMatches = function (conceptIdx, attr, groupFilter) {
|
|
842
|
+
const attrResult = this.concepts.findConcept(attr.name.conceptId);
|
|
843
|
+
if (!attrResult.found) {
|
|
844
|
+
throw new Error(`The SNOMED CT Concept ${attr.name.conceptId} is not known`);
|
|
845
|
+
}
|
|
846
|
+
const attrTypeIdx = attrResult.index;
|
|
847
|
+
|
|
848
|
+
const valueSet = new Set(this._eclResolveSet(this._evalECLNode(attr.comparison.value)));
|
|
849
|
+
|
|
850
|
+
const relIdxs = this.getConceptRelationships(conceptIdx);
|
|
851
|
+
let count = 0;
|
|
852
|
+
for (const relIdx of relIdxs) {
|
|
853
|
+
const rel = this.relationships.getRelationship(relIdx);
|
|
854
|
+
if (!rel.active) continue;
|
|
855
|
+
if (rel.relType !== attrTypeIdx) continue;
|
|
856
|
+
if (groupFilter !== null && rel.group !== groupFilter) continue;
|
|
857
|
+
if (valueSet.has(rel.target)) count++;
|
|
858
|
+
}
|
|
859
|
+
return count;
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Test a count against a parsed cardinality `{min, max}` where `max` is
|
|
864
|
+
* either an integer or the string `'*'` (unbounded).
|
|
865
|
+
* @param {{min: number, max: number|'*'}} cardinality
|
|
866
|
+
* @param {number} count
|
|
867
|
+
* @returns {boolean}
|
|
868
|
+
*/
|
|
869
|
+
_cardinalityAccepts = function (cardinality, count) {
|
|
870
|
+
const { min, max } = cardinality;
|
|
871
|
+
if (min != null && count < min) return false;
|
|
872
|
+
if (max != null && max !== '*' && count > max) return false;
|
|
873
|
+
return true;
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Check whether any single relationship group on the concept satisfies all
|
|
878
|
+
* attributes in an ATTRIBUTE_GROUP. Ungrouped relationships (group === 0)
|
|
879
|
+
* are not eligible — an attribute group must match within a real group.
|
|
880
|
+
*
|
|
881
|
+
* If the group itself carries cardinality (e.g. `[1..1] {…}`), the match
|
|
882
|
+
* requires the count of matching groups to fall within the specified range.
|
|
883
|
+
* @param {number} conceptIdx
|
|
884
|
+
* @param {object} group
|
|
885
|
+
* @returns {boolean}
|
|
886
|
+
*/
|
|
887
|
+
_attributeGroupMatches = function (conceptIdx, group) {
|
|
888
|
+
const relIdxs = this.getConceptRelationships(conceptIdx);
|
|
889
|
+
const groupNumbers = new Set();
|
|
890
|
+
for (const relIdx of relIdxs) {
|
|
891
|
+
const rel = this.relationships.getRelationship(relIdx);
|
|
892
|
+
if (rel.active && rel.group > 0) {
|
|
893
|
+
groupNumbers.add(rel.group);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
let matchingGroupCount = 0;
|
|
898
|
+
for (const g of groupNumbers) {
|
|
899
|
+
let allMatch = true;
|
|
900
|
+
for (const attr of group.attributes) {
|
|
901
|
+
if (!this._attributeMatches(conceptIdx, attr, g)) {
|
|
902
|
+
allMatch = false;
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
if (allMatch) {
|
|
907
|
+
matchingGroupCount++;
|
|
908
|
+
// With no cardinality, short-circuit on the first matching group.
|
|
909
|
+
if (!group.cardinality) return true;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (group.cardinality) {
|
|
914
|
+
return this._cardinalityAccepts(group.cardinality, matchingGroupCount);
|
|
915
|
+
}
|
|
916
|
+
return false;
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
// ── Set operation helpers ────────────────────────────────────────────────────
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Flatten a SnomedFilterContext to a plain array of concept indexes,
|
|
923
|
+
* handling the three different storage slots used by the existing filters.
|
|
924
|
+
* @param {SnomedFilterContext} ctx
|
|
925
|
+
* @returns {number[]}
|
|
926
|
+
*/
|
|
927
|
+
_eclToIndexArray = function (ctx) {
|
|
928
|
+
if (ctx.descendants && ctx.descendants.length > 0) return ctx.descendants;
|
|
929
|
+
if (ctx.members && ctx.members.length > 0) return ctx.members.map(m => m.ref);
|
|
930
|
+
if (ctx.matches && ctx.matches.length > 0) return ctx.matches.map(m => m.index);
|
|
931
|
+
return [];
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Like _eclToIndexArray, but if the context is a bare wildcard (no
|
|
936
|
+
* descendants populated) it materialises the full active-concept list
|
|
937
|
+
* via _eclEnumerateActiveConcepts. Used by dotted/refined evaluation,
|
|
938
|
+
* which need an explicit concept set to iterate over.
|
|
939
|
+
* @param {SnomedFilterContext} ctx
|
|
940
|
+
* @returns {number[]}
|
|
941
|
+
*/
|
|
942
|
+
_eclResolveSet = function (ctx) {
|
|
943
|
+
if (ctx.eclWildcard && (!ctx.descendants || ctx.descendants.length === 0)) {
|
|
944
|
+
return this._eclEnumerateActiveConcepts();
|
|
945
|
+
}
|
|
946
|
+
return this._eclToIndexArray(ctx);
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* AND: concepts present in both sets.
|
|
951
|
+
*/
|
|
952
|
+
_eclIntersect = function (left, right) {
|
|
953
|
+
if (left.eclWildcard) return right;
|
|
954
|
+
if (right.eclWildcard) return left;
|
|
955
|
+
const leftSet = new Set(this._eclToIndexArray(left));
|
|
956
|
+
const result = new SnomedFilterContext();
|
|
957
|
+
result.descendants = this._eclToIndexArray(right).filter(idx => leftSet.has(idx));
|
|
958
|
+
return result;
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* OR: concepts present in either set.
|
|
963
|
+
*/
|
|
964
|
+
_eclUnion = function (left, right) {
|
|
965
|
+
if (left.eclWildcard || right.eclWildcard) return this._eclWildcard();
|
|
966
|
+
const combined = new Set([
|
|
967
|
+
...this._eclToIndexArray(left),
|
|
968
|
+
...this._eclToIndexArray(right)
|
|
969
|
+
]);
|
|
970
|
+
const result = new SnomedFilterContext();
|
|
971
|
+
result.descendants = [...combined];
|
|
972
|
+
return result;
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* MINUS: concepts in left that are not in right.
|
|
977
|
+
*/
|
|
978
|
+
_eclMinus = function (left, right) {
|
|
979
|
+
const result = new SnomedFilterContext();
|
|
980
|
+
|
|
981
|
+
if (right.eclWildcard) {
|
|
982
|
+
result.descendants = [];
|
|
983
|
+
return result;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const rightSet = new Set(this._eclToIndexArray(right));
|
|
987
|
+
|
|
988
|
+
if (left.eclWildcard) {
|
|
989
|
+
// Enumerate all active concepts minus the right set
|
|
990
|
+
const all = [];
|
|
991
|
+
for (let i = 0; i < this.concepts.count(); i++) {
|
|
992
|
+
const concept = this.concepts.getConceptByCount(i);
|
|
993
|
+
if (this.isActive(concept.index) && !rightSet.has(concept.index)) {
|
|
994
|
+
all.push(concept.index);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
result.descendants = all;
|
|
998
|
+
return result;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
result.descendants = this._eclToIndexArray(left).filter(idx => !rightSet.has(idx));
|
|
1002
|
+
return result;
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
|
|
448
1006
|
searchFilter(searchText, includeInactive = false, exactMatch = false) {
|
|
449
1007
|
const result = new SnomedFilterContext();
|
|
450
1008
|
|
|
@@ -575,7 +1133,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
575
1133
|
|
|
576
1134
|
// Core concept methods
|
|
577
1135
|
async code(context) {
|
|
578
|
-
|
|
1136
|
+
|
|
579
1137
|
const ctxt = await this.#ensureContext(context);
|
|
580
1138
|
|
|
581
1139
|
if (!ctxt) return null;
|
|
@@ -588,7 +1146,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
588
1146
|
}
|
|
589
1147
|
|
|
590
1148
|
async display(context) {
|
|
591
|
-
|
|
1149
|
+
|
|
592
1150
|
const ctxt = await this.#ensureContext(context);
|
|
593
1151
|
|
|
594
1152
|
if (!ctxt) return null;
|
|
@@ -615,7 +1173,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
615
1173
|
}
|
|
616
1174
|
|
|
617
1175
|
async isInactive(context) {
|
|
618
|
-
|
|
1176
|
+
|
|
619
1177
|
const ctxt = await this.#ensureContext(context);
|
|
620
1178
|
|
|
621
1179
|
if (!ctxt || ctxt.isComplex()) return false;
|
|
@@ -630,7 +1188,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
630
1188
|
}
|
|
631
1189
|
|
|
632
1190
|
async getStatus(context) {
|
|
633
|
-
|
|
1191
|
+
|
|
634
1192
|
const ctxt = await this.#ensureContext(context);
|
|
635
1193
|
|
|
636
1194
|
if (!ctxt || ctxt.isComplex()) return null;
|
|
@@ -639,7 +1197,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
639
1197
|
}
|
|
640
1198
|
|
|
641
1199
|
async designations(context, displays) {
|
|
642
|
-
|
|
1200
|
+
|
|
643
1201
|
const ctxt = await this.#ensureContext(context);
|
|
644
1202
|
|
|
645
1203
|
if (ctxt) {
|
|
@@ -752,7 +1310,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
752
1310
|
}
|
|
753
1311
|
|
|
754
1312
|
async locateIsA(code, parent, disallowParent = false) {
|
|
755
|
-
|
|
1313
|
+
|
|
756
1314
|
|
|
757
1315
|
const childId = this.sct.stringToIdOrZero(code);
|
|
758
1316
|
const parentId = this.sct.stringToIdOrZero(parent);
|
|
@@ -783,7 +1341,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
783
1341
|
|
|
784
1342
|
// Iterator methods
|
|
785
1343
|
async iterator(context) {
|
|
786
|
-
|
|
1344
|
+
|
|
787
1345
|
|
|
788
1346
|
if (!context) {
|
|
789
1347
|
// Iterate all active root concepts
|
|
@@ -891,7 +1449,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
891
1449
|
}
|
|
892
1450
|
}
|
|
893
1451
|
|
|
894
|
-
|
|
1452
|
+
// Filter support
|
|
895
1453
|
async doesFilter(prop, op, value) {
|
|
896
1454
|
if (prop === 'concept') {
|
|
897
1455
|
const id = this.sct.stringToIdOrZero(value);
|
|
@@ -918,6 +1476,9 @@ class SnomedProvider extends BaseCSServices {
|
|
|
918
1476
|
const id = this.sct.stringToIdOrZero(value);
|
|
919
1477
|
return id !== 0n && op === '=';
|
|
920
1478
|
}
|
|
1479
|
+
if (prop === 'constraint') {
|
|
1480
|
+
return op === '=';
|
|
1481
|
+
}
|
|
921
1482
|
|
|
922
1483
|
if (prop == 'expressions' && op == '=' && ['true', 'false'].includes(value)) {
|
|
923
1484
|
return true;
|
|
@@ -934,11 +1495,11 @@ class SnomedProvider extends BaseCSServices {
|
|
|
934
1495
|
|
|
935
1496
|
// eslint-disable-next-line no-unused-vars
|
|
936
1497
|
async getPrepContext(iterate) {
|
|
937
|
-
|
|
1498
|
+
|
|
938
1499
|
return new SnomedPrep(); // Simple filter context
|
|
939
1500
|
}
|
|
940
1501
|
|
|
941
|
-
async filter(filterContext, prop, op, value) {
|
|
1502
|
+
async filter(filterContext, forIteration, prop, op, value) {
|
|
942
1503
|
|
|
943
1504
|
if (prop === 'concept') {
|
|
944
1505
|
const id = this.sct.stringToIdOrZero(value);
|
|
@@ -991,6 +1552,11 @@ class SnomedProvider extends BaseCSServices {
|
|
|
991
1552
|
}
|
|
992
1553
|
}
|
|
993
1554
|
|
|
1555
|
+
if (prop === 'constraint' && op === '=') {
|
|
1556
|
+
filterContext.filters.push(await this.sct.filterECL(value, forIteration, this.opContext));
|
|
1557
|
+
return null;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
994
1560
|
if (prop === 'moduleId') {
|
|
995
1561
|
const id = this.sct.stringToIdOrZero(value);
|
|
996
1562
|
if (id === 0n) {
|
|
@@ -1035,6 +1601,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1035
1601
|
throw new Error(`Unsupported filter property: ${prop}`);
|
|
1036
1602
|
}
|
|
1037
1603
|
|
|
1604
|
+
|
|
1038
1605
|
async executeFilters(filterContext) {
|
|
1039
1606
|
return filterContext.filters;
|
|
1040
1607
|
}
|
|
@@ -1096,6 +1663,9 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1096
1663
|
return conceptResult.message;
|
|
1097
1664
|
}
|
|
1098
1665
|
|
|
1666
|
+
if (set.eclWildcard) {
|
|
1667
|
+
return this.sct.isActive(reference) ? ctxt : null;
|
|
1668
|
+
}
|
|
1099
1669
|
const ctxt = conceptResult.context;
|
|
1100
1670
|
const reference = ctxt.getReference();
|
|
1101
1671
|
let found = false;
|
|
@@ -1168,7 +1738,9 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1168
1738
|
} else if (set.descendants && set.descendants.length > 0) {
|
|
1169
1739
|
return set.descendants.includes(reference);
|
|
1170
1740
|
}
|
|
1171
|
-
|
|
1741
|
+
if (set.eclWildcard) {
|
|
1742
|
+
return this.sct.isActive(reference);
|
|
1743
|
+
}
|
|
1172
1744
|
return false;
|
|
1173
1745
|
}
|
|
1174
1746
|
|
|
@@ -1218,7 +1790,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1218
1790
|
|
|
1219
1791
|
// Subsumption testing
|
|
1220
1792
|
async subsumesTest(codeA, codeB) {
|
|
1221
|
-
|
|
1793
|
+
|
|
1222
1794
|
|
|
1223
1795
|
try {
|
|
1224
1796
|
const exprA = new SnomedExpressionParser(this.sct.concepts).parse(codeA);
|
|
@@ -1287,7 +1859,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1287
1859
|
|
|
1288
1860
|
isDisplay(cd) {
|
|
1289
1861
|
return cd.use.system === this.system() &&
|
|
1290
|
-
|
|
1862
|
+
(cd.use.code === '900000000000013009' || cd.use.code === '900000000000003001');
|
|
1291
1863
|
}
|
|
1292
1864
|
|
|
1293
1865
|
async getTranslations(map, coding, target, reverse) {
|
|
@@ -1418,8 +1990,8 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
1418
1990
|
}
|
|
1419
1991
|
|
|
1420
1992
|
if (url.startsWith('http://snomed.info/sct?fhir_vs') ||
|
|
1421
|
-
|
|
1422
|
-
|
|
1993
|
+
url.startsWith(`http://snomed.info/sct/${this.edition}?fhir_vs`) ||
|
|
1994
|
+
url.startsWith(`http://snomed.info/sct/${this.edition}/version/${this.version}?fhir_vs`)) {
|
|
1423
1995
|
id = url.substring(qIdx);
|
|
1424
1996
|
} else {
|
|
1425
1997
|
return null;
|
|
@@ -1564,9 +2136,12 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
1564
2136
|
this.uses++;
|
|
1565
2137
|
}
|
|
1566
2138
|
|
|
1567
|
-
|
|
1568
2139
|
name() {
|
|
1569
|
-
|
|
2140
|
+
if (this.version().includes("xsct")) {
|
|
2141
|
+
return "SNOMED CT Test Set";
|
|
2142
|
+
} else {
|
|
2143
|
+
return `SCT ${getEditionCode(this._sharedData.edition)}`;
|
|
2144
|
+
}
|
|
1570
2145
|
}
|
|
1571
2146
|
|
|
1572
2147
|
nameBase() {
|
|
@@ -1574,7 +2149,13 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
1574
2149
|
}
|
|
1575
2150
|
|
|
1576
2151
|
id() {
|
|
1577
|
-
|
|
2152
|
+
let match = this.version().match(/^http:\/\/snomed\.info\/sct\/(\d+)(?:\/version\/(\d{8}))?$/);
|
|
2153
|
+
if (!match) {
|
|
2154
|
+
match = this.version().match(/^http:\/\/snomed\.info\/xsct\/(\d+)(?:\/version\/(\d{8}))?$/);
|
|
2155
|
+
if (match) {
|
|
2156
|
+
match = "x"+match;
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
1578
2159
|
return match && match[1] && match[2] ? "SCT-"+match[1]+"-"+match[2] : null;
|
|
1579
2160
|
}
|
|
1580
2161
|
|