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.
@@ -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
- ? this.expression.concepts[0].reference
61
- : NO_REFERENCE;
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
- ? this.expression.concepts[0].code
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
- // Filter support
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
- (cd.use.code === '900000000000013009' || cd.use.code === '900000000000003001');
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
- url.startsWith(`http://snomed.info/sct/${this.edition}?fhir_vs`) ||
1422
- url.startsWith(`http://snomed.info/sct/${this.edition}/version/${this.version}?fhir_vs`)) {
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
- return `SCT ${getEditionCode(this._sharedData.edition)}`;
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
- const match = this.version().match(/^http:\/\/snomed\.info\/sct\/(\d+)(?:\/version\/(\d{8}))?$/);
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