fhirsmith 0.16.1 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +130 -0
- package/README.md +105 -30
- package/config-template.json +130 -4
- package/extension-tracker/extension-tracker.js +111 -3
- package/extension-tracker/readme.md +3 -0
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +422 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/html.js +9 -0
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +222 -0
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +8 -2
- package/packages/package-crawler.js +189 -82
- package/packages/packages.js +36 -63
- package/packages/utilities/dedupe-versions.js +22 -8
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +48 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +247 -54
- package/registry/uptime.js +154 -0
- package/release.sh +80 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/readme.md +1 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +9 -0
- package/tx/README.md +52 -0
- package/tx/cm/cm-database.js +62 -0
- package/tx/cm/cm-package.js +20 -3
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +142 -19
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +79 -39
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +107 -6
- package/tx/ocl/README.md +62 -6
- package/tx/ocl/cm-ocl.cjs +166 -44
- package/tx/ocl/cs-ocl.cjs +163 -13
- package/tx/ocl/resolve/public-access.js +143 -0
- package/tx/ocl/resolve/reference-resolver.js +458 -0
- package/tx/ocl/vs-ocl.cjs +167 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/problems.js +7 -5
- package/tx/provider.js +21 -5
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +37 -8
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +148 -5
- package/tx/vs/vs-database.js +26 -0
- package/tx/vs/vs-package.js +42 -10
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +34 -7
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/metadata.js +6 -1
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/utilities/submit-test-report.js +1 -1
- package/xig/xig.js +120 -12
- package/gr.sh +0 -3
- package/registry/readme.md +0 -124
package/tx/cs/cs-snomed.js
CHANGED
|
@@ -46,6 +46,24 @@ const SCT_LANGUAGE_CODES = {
|
|
|
46
46
|
9: 'cs'
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
// ECL dialect aliases (ECL specification, appendix "Dialect aliases") to the
|
|
50
|
+
// language reference set they denote. Used by {{ D dialect = en-gb }}.
|
|
51
|
+
const ECL_DIALECT_ALIASES = {
|
|
52
|
+
'en-us': '900000000000509007',
|
|
53
|
+
'en-gb': '900000000000508004',
|
|
54
|
+
'en-au': '32570271000036106',
|
|
55
|
+
'en-ca': '19491000087109',
|
|
56
|
+
'en-nz': '271000210107',
|
|
57
|
+
'en-ie': '21000220103',
|
|
58
|
+
'fr-ca': '20581000087109',
|
|
59
|
+
'fr-be': '21000172104',
|
|
60
|
+
'nl-be': '31000172101',
|
|
61
|
+
'nl-nl': '31000146106',
|
|
62
|
+
'es-es': '448879004',
|
|
63
|
+
'sv-se': '46011000052107',
|
|
64
|
+
'da-dk': '554461000005103'
|
|
65
|
+
};
|
|
66
|
+
|
|
49
67
|
// Context kinds matching Pascal enum
|
|
50
68
|
const SnomedProviderContextKind = {
|
|
51
69
|
CODE: 0,
|
|
@@ -568,6 +586,122 @@ class SnomedServices {
|
|
|
568
586
|
}
|
|
569
587
|
}
|
|
570
588
|
|
|
589
|
+
/**
|
|
590
|
+
* The module this edition is published under, as a concept index (0 if it cannot be found),
|
|
591
|
+
* taken from the edition URI http://snomed.info/[x]sct/<module>[/version/<date>]
|
|
592
|
+
*/
|
|
593
|
+
editionModule() {
|
|
594
|
+
const m = /^http:\/\/snomed\.info\/x?sct\/(\d+)/.exec(this.getVersion() || '');
|
|
595
|
+
if (!m) return 0;
|
|
596
|
+
const r = this.concepts.findConcept(m[1]);
|
|
597
|
+
return r.found ? r.index : 0;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* The module dependency reference set (900000000000534007). Found by its defining concept
|
|
602
|
+
* when that is present; a subontology extract may carry the members without the concept,
|
|
603
|
+
* so the filename-derived refset name is the fallback.
|
|
604
|
+
* @returns {object|null} the reference set record
|
|
605
|
+
*/
|
|
606
|
+
moduleDependencyRefSet() {
|
|
607
|
+
if (this._mdrs !== undefined) return this._mdrs;
|
|
608
|
+
this._mdrs = null;
|
|
609
|
+
const r = this.concepts.findConcept('900000000000534007');
|
|
610
|
+
for (let i = 0; i < this.refSetIndex.count(); i++) {
|
|
611
|
+
const rs = this.refSetIndex.getReferenceSet(i);
|
|
612
|
+
if ((r.found && rs.definition === r.index) || this.strings.getEntry(rs.name).startsWith('ModuleDependency')) {
|
|
613
|
+
this._mdrs = rs;
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return this._mdrs;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The modules that the given module depends on, per the module dependency reference set:
|
|
622
|
+
* [{module: <concept index>, sourceEffectiveTime, targetEffectiveTime}]. Only the direct
|
|
623
|
+
* dependencies - the dependencies of those modules are their own rows.
|
|
624
|
+
*/
|
|
625
|
+
moduleDependencies(moduleIndex) {
|
|
626
|
+
const rs = this.moduleDependencyRefSet();
|
|
627
|
+
if (!rs || !moduleIndex) return [];
|
|
628
|
+
const result = [];
|
|
629
|
+
for (const m of this.refSetMembers.getMembers(rs.membersByRef)) {
|
|
630
|
+
if (m.module !== moduleIndex || !m.ref) continue;
|
|
631
|
+
// values are [value, type, value, type...]; both fields here are strings
|
|
632
|
+
const vals = m.values ? this.refs.getReferences(m.values) || [] : [];
|
|
633
|
+
const str = (i) => vals.length > i && vals[i + 1] === 5 ? this.strings.getEntry(vals[i]) : null;
|
|
634
|
+
result.push({ module: m.ref, sourceEffectiveTime: str(0), targetEffectiveTime: str(2) });
|
|
635
|
+
}
|
|
636
|
+
return result;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* The concept indexes of all the modules that make up the edition published under the
|
|
641
|
+
* given module: the module itself and, transitively, everything it depends on.
|
|
642
|
+
*/
|
|
643
|
+
editionModules(moduleIndex) {
|
|
644
|
+
const result = new Set();
|
|
645
|
+
const todo = [moduleIndex];
|
|
646
|
+
while (todo.length) {
|
|
647
|
+
const m = todo.pop();
|
|
648
|
+
if (!m || result.has(m)) continue;
|
|
649
|
+
result.add(m);
|
|
650
|
+
for (const d of this.moduleDependencies(m)) todo.push(d.module);
|
|
651
|
+
}
|
|
652
|
+
return result;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Translation from this edition to another edition it depends on (see
|
|
657
|
+
* SnomedProvider.translateToEdition). The target is an edition URI,
|
|
658
|
+
* http://snomed.info/sct/<module>[/version/<date>]; the result says which module and
|
|
659
|
+
* version that is, and which modules' content belongs to it, or null if this edition
|
|
660
|
+
* does not depend on it (or not on that version of it).
|
|
661
|
+
*/
|
|
662
|
+
editionDependency(targetEdition) {
|
|
663
|
+
const m = /^http:\/\/snomed\.info\/x?sct\/(\d+)(?:\/version\/(\d{8}))?$/.exec(targetEdition || '');
|
|
664
|
+
if (!m) return null;
|
|
665
|
+
const mine = this.editionModule();
|
|
666
|
+
const target = this.concepts.findConcept(m[1]);
|
|
667
|
+
if (!mine || !target.found) return null;
|
|
668
|
+
for (const d of this.moduleDependencies(mine)) {
|
|
669
|
+
if (d.module === target.index && (!m[2] || m[2] === d.targetEffectiveTime)) {
|
|
670
|
+
const version = d.targetEffectiveTime ? `http://snomed.info/sct/${m[1]}/version/${d.targetEffectiveTime}` : `http://snomed.info/sct/${m[1]}`;
|
|
671
|
+
return { moduleId: m[1], moduleIndex: target.index, version, modules: this.editionModules(target.index) };
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* The concept whose normal form is the given expression, if there is one. The index is
|
|
679
|
+
* built on first use: every defined concept's stored normal form, rendered in the minimal
|
|
680
|
+
* form so that two expressions compare as strings.
|
|
681
|
+
* @param {string} rendered - the expression, rendered with SnomedServicesRenderOption.Minimal
|
|
682
|
+
* @returns {number} the concept index, or -1
|
|
683
|
+
*/
|
|
684
|
+
conceptWithNormalForm(rendered) {
|
|
685
|
+
if (!this._normalFormIndex) {
|
|
686
|
+
const index = new Map();
|
|
687
|
+
const total = this.concepts.count();
|
|
688
|
+
for (let ord = 0; ord < total; ord++) {
|
|
689
|
+
const c = this.concepts.getConceptByCount(ord);
|
|
690
|
+
if ((c.flags & 0x0F) !== 0 || this.concepts.getNormalForm(c.index) === 0) continue;
|
|
691
|
+
try {
|
|
692
|
+
const nf = this.expressionServices.createNormalForm(c.index);
|
|
693
|
+
const key = this.expressionServices.renderExpression(nf, SnomedServicesRenderOption.Minimal);
|
|
694
|
+
if (!index.has(key)) index.set(key, c.index);
|
|
695
|
+
} catch (e) {
|
|
696
|
+
// a normal form that cannot be built is simply not indexed
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
this._normalFormIndex = index;
|
|
700
|
+
}
|
|
701
|
+
const r = this._normalFormIndex.get(rendered);
|
|
702
|
+
return r === undefined ? -1 : r;
|
|
703
|
+
}
|
|
704
|
+
|
|
571
705
|
getConceptRefSet(conceptIndex, byName = false) {
|
|
572
706
|
for (let i = 0; i < this.refSetIndex.count(); i++) {
|
|
573
707
|
const refSet = this.refSetIndex.getReferenceSet(i);
|
|
@@ -583,13 +717,8 @@ class SnomedServices {
|
|
|
583
717
|
// members" (returns a numeric membersByRef, which may be 0 or the
|
|
584
718
|
// MAGIC_NO_CHILDREN sentinel). Used by memberOf to reject non-refset operands.
|
|
585
719
|
_findRefSetMembersRef(conceptIndex) {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
if (refSet.definition === conceptIndex) {
|
|
589
|
-
return refSet.membersByRef;
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
return null;
|
|
720
|
+
const refSet = this._findRefSetRecord(conceptIndex);
|
|
721
|
+
return refSet ? refSet.membersByRef : null;
|
|
593
722
|
}
|
|
594
723
|
|
|
595
724
|
// Filter support methods
|
|
@@ -724,12 +853,21 @@ class SnomedServices {
|
|
|
724
853
|
* >> (ancestor-or-self-of)
|
|
725
854
|
* >! (strict ancestor-of)
|
|
726
855
|
* > (parent-of)
|
|
727
|
-
* ^ (member-of refset) —
|
|
856
|
+
* ^ (member-of refset) — a concept or any bracketed expression, with
|
|
857
|
+
* {{ M ... }} member filters
|
|
728
858
|
* * (wildcard)
|
|
859
|
+
* hierarchy operators on *, ^ and bracketed sets (applied per member)
|
|
729
860
|
* AND / OR / MINUS compound expressions
|
|
861
|
+
* !!> / !!< (top / bottom of set)
|
|
862
|
+
* refinements (= against an expression, attribute AND/OR/",", groups,
|
|
863
|
+
* attribute and group cardinality, attribute names that are expressions),
|
|
864
|
+
* dotted expressions, {{ C ... }} concept filters, {{ D ... }} description
|
|
865
|
+
* filters, comments
|
|
730
866
|
*
|
|
731
|
-
* Everything else (
|
|
732
|
-
*
|
|
867
|
+
* Everything else (reverse attributes, != and concrete (numeric/string/
|
|
868
|
+
* boolean) values in refinements, alternate identifiers, reference set field
|
|
869
|
+
* selection on ^, {{ + ... }} history supplements) throws an informative
|
|
870
|
+
* error which is reported as UNSUPPORTED_ECL.
|
|
733
871
|
*/
|
|
734
872
|
|
|
735
873
|
/**
|
|
@@ -760,8 +898,11 @@ class SnomedServices {
|
|
|
760
898
|
// be valid) from an *unsupported* one (a well-formed expression using a
|
|
761
899
|
// feature this server does not implement). The tx-ecosystem test suite
|
|
762
900
|
// expects INVALID_ECL for the former, UNSUPPORTED_ECL for the latter.
|
|
901
|
+
// The parser has accepted the expression by now, so an evaluation error
|
|
902
|
+
// means an unsupported feature - unless it is explicitly about a concept
|
|
903
|
+
// that does not exist (err.eclInvalid, or the legacy "is not known" text).
|
|
763
904
|
const emsg = (err && err.message) ? err.message : String(err);
|
|
764
|
-
const invalid =
|
|
905
|
+
const invalid = (err && err.eclInvalid) || /is not known/.test(emsg);
|
|
765
906
|
const msgId = invalid ? 'INVALID_ECL' : 'UNSUPPORTED_ECL';
|
|
766
907
|
throw new Issue('error', 'invalid', null, msgId, opContext.i18n.translate(msgId, opContext.langs, [eclExpression, err.message]), 'vs-invalid').handleAsOO(400);
|
|
767
908
|
}
|
|
@@ -870,20 +1011,35 @@ class SnomedServices {
|
|
|
870
1011
|
const operator = node.operator; // an ECLTokenType string, or null
|
|
871
1012
|
const focus = node.focus;
|
|
872
1013
|
|
|
873
|
-
//
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1014
|
+
// History supplements are parsed but not implemented. Member filters
|
|
1015
|
+
// ({{ M ... }}) are handled in _evalMemberOf; concept filters ({{ C ... }})
|
|
1016
|
+
// and description filters ({{ D ... }}) are applied to the result below.
|
|
1017
|
+
if (node.filters && node.filters.length > 0) {
|
|
1018
|
+
if (node.filters.some(fc => fc.kind === '+')) {
|
|
1019
|
+
throw new Error('ECL history supplements ({{ + ... }}) are not supported');
|
|
877
1020
|
}
|
|
878
|
-
|
|
1021
|
+
const inner = { ...node, filters: undefined };
|
|
1022
|
+
let ctx = this._evalSubExpression(inner, opContext);
|
|
1023
|
+
const conceptFilters = node.filters.filter(fc => fc.kind === 'C');
|
|
1024
|
+
if (conceptFilters.length > 0) {
|
|
1025
|
+
ctx = this._applyConceptFilters(ctx, conceptFilters, opContext);
|
|
1026
|
+
}
|
|
1027
|
+
for (const fc of node.filters.filter(fc => fc.kind === 'D')) {
|
|
1028
|
+
ctx = this._applyDescriptionFilter(ctx, fc.filters, opContext);
|
|
1029
|
+
}
|
|
1030
|
+
return ctx;
|
|
879
1031
|
}
|
|
880
1032
|
|
|
881
|
-
//
|
|
882
|
-
if (focus.type === ECLNodeType.
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1033
|
+
// Alternate identifiers (ECL 2.1 "scheme#code")
|
|
1034
|
+
if (focus.type === ECLNodeType.ALT_IDENTIFIER) {
|
|
1035
|
+
throw new Error(`ECL alternate identifiers (${focus.scheme}#${focus.code}) are not supported - no alternate identifier reference sets are loaded`);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Top / bottom of set: the members of the focus set with no ancestor
|
|
1039
|
+
// (respectively descendant) in that set
|
|
1040
|
+
if (operator === ECLTokenType.TOP_OF_SET || operator === ECLTokenType.BOTTOM_OF_SET) {
|
|
1041
|
+
const set = this._eclResolveSet(this._evalECLNode(focus, opContext), opContext);
|
|
1042
|
+
return this._eclTopOrBottom(set, operator === ECLTokenType.TOP_OF_SET, opContext);
|
|
887
1043
|
}
|
|
888
1044
|
|
|
889
1045
|
// Plain concept reference
|
|
@@ -891,8 +1047,119 @@ class SnomedServices {
|
|
|
891
1047
|
return this._evalConceptWithOperator(focus.conceptId, operator, opContext);
|
|
892
1048
|
}
|
|
893
1049
|
|
|
894
|
-
//
|
|
895
|
-
|
|
1050
|
+
// Wildcard, member-of, or a parenthesised sub-expression: evaluate the
|
|
1051
|
+
// focus to a set, then apply the operator to each member and union the
|
|
1052
|
+
// results ("< (A OR B)" is "< A OR < B"; "<< ^ refset" is the descendants
|
|
1053
|
+
// of every member). Without an operator the set is the answer.
|
|
1054
|
+
let base;
|
|
1055
|
+
if (focus.type === ECLNodeType.WILDCARD) {
|
|
1056
|
+
base = this._eclWildcard();
|
|
1057
|
+
} else if (focus.type === ECLNodeType.MEMBER_OF) {
|
|
1058
|
+
base = this._evalMemberOf(focus, opContext);
|
|
1059
|
+
} else {
|
|
1060
|
+
base = this._evalECLNode(focus, opContext);
|
|
1061
|
+
}
|
|
1062
|
+
if (!operator) {
|
|
1063
|
+
return base;
|
|
1064
|
+
}
|
|
1065
|
+
return this._eclApplyOperatorToSet(base, operator, opContext);
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Apply a hierarchy operator to every concept in a set and union the results.
|
|
1070
|
+
* The wildcard is special-cased: its self-inclusive forms are the wildcard
|
|
1071
|
+
* again, and the others are "every concept that has a parent / child".
|
|
1072
|
+
*/
|
|
1073
|
+
_eclApplyOperatorToSet = function (ctx, operator, opContext) {
|
|
1074
|
+
if (ctx.eclWildcard && (!ctx.descendants || ctx.descendants.length === 0)) {
|
|
1075
|
+
switch (operator) {
|
|
1076
|
+
case ECLTokenType.DESCENDANT_OR_SELF_OF:
|
|
1077
|
+
case ECLTokenType.ANCESTOR_OR_SELF_OF:
|
|
1078
|
+
case ECLTokenType.CHILD_OR_SELF_OF:
|
|
1079
|
+
case ECLTokenType.PARENT_OR_SELF_OF:
|
|
1080
|
+
return this._eclWildcard();
|
|
1081
|
+
case ECLTokenType.DESCENDANT_OF:
|
|
1082
|
+
case ECLTokenType.CHILD_OF: {
|
|
1083
|
+
const result = new SnomedFilterContext();
|
|
1084
|
+
result.descendants = this._eclEnumerateAllConcepts(opContext).filter(idx => this.getConceptParents(idx).length > 0);
|
|
1085
|
+
return result;
|
|
1086
|
+
}
|
|
1087
|
+
case ECLTokenType.ANCESTOR_OF:
|
|
1088
|
+
case ECLTokenType.PARENT_OF: {
|
|
1089
|
+
const result = new SnomedFilterContext();
|
|
1090
|
+
result.descendants = this._eclEnumerateAllConcepts(opContext).filter(idx => this.getConceptChildren(idx).length > 0);
|
|
1091
|
+
return result;
|
|
1092
|
+
}
|
|
1093
|
+
default:
|
|
1094
|
+
throw new Error(`Unsupported ECL hierarchy operator: ${operator}`);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
const union = new Set();
|
|
1098
|
+
for (const idx of this._eclToIndexArray(ctx)) {
|
|
1099
|
+
if (opContext) opContext.deadCheck('ecl:operatorOnSet');
|
|
1100
|
+
const part = this._evalConceptWithOperator(this.concepts.getConceptId(idx), operator, opContext);
|
|
1101
|
+
for (const r of this._eclToIndexArray(part)) {
|
|
1102
|
+
union.add(r);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
const result = new SnomedFilterContext();
|
|
1106
|
+
result.descendants = [...union];
|
|
1107
|
+
return result;
|
|
1108
|
+
};
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* !!> / !!< : keep the concepts in `set` that have no ancestor (top) or no
|
|
1112
|
+
* descendant (bottom) that is also in `set`. Walks the stated parents of each
|
|
1113
|
+
* member within the set, so the cost is bounded by the set's own size.
|
|
1114
|
+
*/
|
|
1115
|
+
_eclTopOrBottom = function (set, top, opContext) {
|
|
1116
|
+
const members = new Set(set);
|
|
1117
|
+
// a member is "covered" (not top) when some ancestor is in the set; for
|
|
1118
|
+
// bottom, the ancestor is the one that is covered
|
|
1119
|
+
const covered = new Set();
|
|
1120
|
+
for (const idx of members) {
|
|
1121
|
+
if (opContext) opContext.deadCheck('ecl:topBottom');
|
|
1122
|
+
const seen = new Set();
|
|
1123
|
+
const stack = [...this.getConceptParents(idx)];
|
|
1124
|
+
while (stack.length > 0) {
|
|
1125
|
+
const p = stack.pop();
|
|
1126
|
+
if (seen.has(p)) continue;
|
|
1127
|
+
seen.add(p);
|
|
1128
|
+
if (members.has(p)) {
|
|
1129
|
+
covered.add(top ? idx : p);
|
|
1130
|
+
if (top) break;
|
|
1131
|
+
}
|
|
1132
|
+
stack.push(...this.getConceptParents(p));
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
const result = new SnomedFilterContext();
|
|
1136
|
+
result.descendants = [...members].filter(idx => !covered.has(idx));
|
|
1137
|
+
return result;
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Resolve an attribute name (the left side of a refinement or a dotted step)
|
|
1142
|
+
* to the set of relationship type indexes it denotes: a bare concept gives one
|
|
1143
|
+
* index, `*` any type, and an expression (<< 762705008, (A OR B), ^ refset)
|
|
1144
|
+
* whatever it evaluates to.
|
|
1145
|
+
* @returns {{any: boolean, types: Set<number>|null}}
|
|
1146
|
+
*/
|
|
1147
|
+
_eclAttributeTypes = function (nameNode, opContext) {
|
|
1148
|
+
if (nameNode.type === ECLNodeType.WILDCARD) {
|
|
1149
|
+
return { any: true, types: null };
|
|
1150
|
+
}
|
|
1151
|
+
if (nameNode.type === ECLNodeType.CONCEPT_REFERENCE) {
|
|
1152
|
+
const attrResult = this.concepts.findConcept(nameNode.conceptId);
|
|
1153
|
+
if (!attrResult.found) {
|
|
1154
|
+
throw new Error(`The SNOMED CT Concept ${nameNode.conceptId} is not known`);
|
|
1155
|
+
}
|
|
1156
|
+
return { any: false, types: new Set([attrResult.index]) };
|
|
1157
|
+
}
|
|
1158
|
+
const ctx = this._evalECLNode(nameNode, opContext);
|
|
1159
|
+
if (ctx.eclWildcard && (!ctx.descendants || ctx.descendants.length === 0)) {
|
|
1160
|
+
return { any: true, types: null };
|
|
1161
|
+
}
|
|
1162
|
+
return { any: false, types: new Set(this._eclToIndexArray(ctx)) };
|
|
896
1163
|
};
|
|
897
1164
|
|
|
898
1165
|
/**
|
|
@@ -988,12 +1255,21 @@ class SnomedServices {
|
|
|
988
1255
|
* @returns {SnomedFilterContext}
|
|
989
1256
|
*/
|
|
990
1257
|
_evalMemberOf = function (memberOfNode, opContext) {
|
|
1258
|
+
// ^ [field, ...] refset selects which field of the member rows to return
|
|
1259
|
+
// instead of the referenced component. Not supported: the referenced
|
|
1260
|
+
// components, and the fields, may be descriptions or other non-concept
|
|
1261
|
+
// components, which an expansion cannot carry.
|
|
1262
|
+
if (memberOfNode.fields) {
|
|
1263
|
+
throw new Error(`ECL reference set field selection (^ [${memberOfNode.fields.join(', ')}] ...) is not supported`);
|
|
1264
|
+
}
|
|
991
1265
|
const refsetConcepts = this._eclResolveSet(this._evalECLNode(memberOfNode.refSet, opContext), opContext);
|
|
1266
|
+
const memberFilters = this._eclPrepareMemberFilters(memberOfNode.memberFilters, opContext);
|
|
992
1267
|
|
|
993
1268
|
const members = new Set();
|
|
994
1269
|
for (const refsetIdx of refsetConcepts) {
|
|
995
1270
|
if (opContext) opContext.deadCheck('ecl:memberOf');
|
|
996
|
-
const
|
|
1271
|
+
const refSet = this._findRefSetRecord(refsetIdx);
|
|
1272
|
+
const membersRef = refSet ? refSet.membersByRef : null;
|
|
997
1273
|
if (membersRef === null) {
|
|
998
1274
|
// Operand concept is not a reference set: yield no members for it
|
|
999
1275
|
// (empty result) rather than erroring. Applies to both a bare concept
|
|
@@ -1003,15 +1279,21 @@ class SnomedServices {
|
|
|
1003
1279
|
if (membersRef === 0 || membersRef === 0xFFFFFFFF) {
|
|
1004
1280
|
continue; // a reference set with no members
|
|
1005
1281
|
}
|
|
1006
|
-
const memberList = this.refSetMembers.getMembers(membersRef);
|
|
1007
|
-
|
|
1282
|
+
const memberList = this.refSetMembers.getMembers(membersRef) || [];
|
|
1283
|
+
// A reference set whose members are descriptions (a language reference
|
|
1284
|
+
// set, say) has no concept members to return. Say so rather than
|
|
1285
|
+
// answering with an empty set, which would read as "no members".
|
|
1286
|
+
if (memberList.length > 0 && !memberList.some(m => m.kind === 0)) {
|
|
1287
|
+
throw new Error(`The members of reference set ${this.concepts.getConceptId(refsetIdx)} are descriptions, not concepts, so ^ cannot be evaluated for it`);
|
|
1288
|
+
}
|
|
1289
|
+
for (const m of memberList) {
|
|
1008
1290
|
// Concept referenced components only (kind 0). Description/other members
|
|
1009
1291
|
// are excluded, which also prevents the no-component sentinel (0xFFFFFFFF)
|
|
1010
1292
|
// from leaking in. The referenced concept may itself be INACTIVE and is
|
|
1011
1293
|
// still returned — "active" in the spec qualifies the membership row (and
|
|
1012
1294
|
// inactive rows are already dropped at import), not the referenced
|
|
1013
1295
|
// concept. The expansion marks/handles inactivity (e.g. activeOnly).
|
|
1014
|
-
if (m.kind === 0) {
|
|
1296
|
+
if (m.kind === 0 && this._eclMemberMatches(m, refSet, memberFilters)) {
|
|
1015
1297
|
members.add(m.ref);
|
|
1016
1298
|
}
|
|
1017
1299
|
}
|
|
@@ -1021,6 +1303,380 @@ class SnomedServices {
|
|
|
1021
1303
|
return result;
|
|
1022
1304
|
};
|
|
1023
1305
|
|
|
1306
|
+
/**
|
|
1307
|
+
* Apply {{ C ... }} concept filters to a result: each concept is tested on
|
|
1308
|
+
* its own active flag, module, effectiveTime and definition status. Several
|
|
1309
|
+
* filter constraints, and the comma-separated filters within one, all AND.
|
|
1310
|
+
*/
|
|
1311
|
+
_applyConceptFilters = function (ctx, filterConstraints, opContext) {
|
|
1312
|
+
const filters = [];
|
|
1313
|
+
for (const fc of filterConstraints) {
|
|
1314
|
+
for (const f of fc.filters) {
|
|
1315
|
+
if (f.valueType === 'expression') {
|
|
1316
|
+
const vctx = this._evalECLNode(f.value, opContext);
|
|
1317
|
+
if (vctx.eclWildcard && (!vctx.descendants || vctx.descendants.length === 0)) {
|
|
1318
|
+
filters.push({ ...f, wildcard: true });
|
|
1319
|
+
} else {
|
|
1320
|
+
filters.push({ ...f, set: new Set(this._eclToIndexArray(vctx)) });
|
|
1321
|
+
}
|
|
1322
|
+
} else {
|
|
1323
|
+
filters.push(f);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
// The definition status concepts, if this edition has them
|
|
1328
|
+
const statusIndex = (id) => {
|
|
1329
|
+
const r = this.concepts.findConcept(id);
|
|
1330
|
+
return r.found ? r.index : null;
|
|
1331
|
+
};
|
|
1332
|
+
const primitiveIdx = statusIndex('900000000000074008');
|
|
1333
|
+
const definedIdx = statusIndex('900000000000073002');
|
|
1334
|
+
|
|
1335
|
+
const matching = [];
|
|
1336
|
+
for (const idx of this._eclResolveSet(ctx, opContext)) {
|
|
1337
|
+
if (opContext) opContext.deadCheck('ecl:conceptFilter');
|
|
1338
|
+
let ok = true;
|
|
1339
|
+
for (const f of filters) {
|
|
1340
|
+
const field = f.field.toLowerCase();
|
|
1341
|
+
let r;
|
|
1342
|
+
if (field === 'active') {
|
|
1343
|
+
r = this.isActive(idx) === f.value;
|
|
1344
|
+
if (f.operator === '!=') r = !r;
|
|
1345
|
+
} else if (field === 'effectivetime') {
|
|
1346
|
+
const concept = this.concepts.getConcept(idx);
|
|
1347
|
+
r = this._eclCompareValues(this._snomedDateToString(concept.effectiveTime), f.operator, f.values, (a, b) => a.localeCompare(b));
|
|
1348
|
+
} else if (field === 'moduleid') {
|
|
1349
|
+
const inSet = f.wildcard || f.set.has(this.concepts.getModuleId(idx));
|
|
1350
|
+
r = f.operator === '=' ? inSet : !inSet;
|
|
1351
|
+
} else if (field === 'definitionstatusid') {
|
|
1352
|
+
const statusIdx = this.isPrimitive(idx) ? primitiveIdx : definedIdx;
|
|
1353
|
+
const inSet = f.wildcard || (statusIdx !== null && f.set.has(statusIdx));
|
|
1354
|
+
r = f.operator === '=' ? inSet : !inSet;
|
|
1355
|
+
} else if (field === 'definitionstatus') {
|
|
1356
|
+
const status = this.isPrimitive(idx) ? 'primitive' : 'defined';
|
|
1357
|
+
const inSet = f.values.includes(status);
|
|
1358
|
+
r = f.operator === '=' ? inSet : !inSet;
|
|
1359
|
+
} else {
|
|
1360
|
+
throw new Error(`Unsupported concept filter ${f.field}`);
|
|
1361
|
+
}
|
|
1362
|
+
if (!r) {
|
|
1363
|
+
ok = false;
|
|
1364
|
+
break;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
if (ok) {
|
|
1368
|
+
matching.push(idx);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
const result = new SnomedFilterContext();
|
|
1372
|
+
result.descendants = matching;
|
|
1373
|
+
return result;
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Apply one {{ D ... }} block: a concept is kept when at least one of its
|
|
1378
|
+
* descriptions satisfies every filter in the block. Only active descriptions
|
|
1379
|
+
* are considered unless the block has an `active` filter. Several blocks
|
|
1380
|
+
* AND at the concept level (each may be satisfied by a different description).
|
|
1381
|
+
*/
|
|
1382
|
+
_applyDescriptionFilter = function (ctx, filters, opContext) {
|
|
1383
|
+
const K = this._displayConstants();
|
|
1384
|
+
const idx = (id) => { const r = this.concepts.findConcept(id); return r.found ? r.index : -1; };
|
|
1385
|
+
const typeTokens = { syn: K.synonym, fsn: K.fsn, def: idx('900000000000550004') };
|
|
1386
|
+
const acceptabilityIdx = { preferred: K.preferred, acceptable: idx('900000000000549004') };
|
|
1387
|
+
const resolveSet = (expr) => {
|
|
1388
|
+
const vctx = this._evalECLNode(expr, opContext);
|
|
1389
|
+
if (vctx.eclWildcard && (!vctx.descendants || vctx.descendants.length === 0)) return null; // any
|
|
1390
|
+
return new Set(this._eclToIndexArray(vctx));
|
|
1391
|
+
};
|
|
1392
|
+
let activeFilter = null;
|
|
1393
|
+
const prepared = filters.map(f => {
|
|
1394
|
+
const field = f.field.toLowerCase();
|
|
1395
|
+
switch (f.valueType) {
|
|
1396
|
+
case 'term':
|
|
1397
|
+
return { ...f, field, matchers: f.values.map(v => this._eclTermMatcher(v)) };
|
|
1398
|
+
case 'expression':
|
|
1399
|
+
return { ...f, field, set: resolveSet(f.value) };
|
|
1400
|
+
case 'type':
|
|
1401
|
+
return { ...f, field, set: new Set(f.values.map(t => typeTokens[t]).filter(i => i >= 0)) };
|
|
1402
|
+
case 'dialectId':
|
|
1403
|
+
case 'dialect':
|
|
1404
|
+
return { ...f, field, dialects: f.dialects.map(d => {
|
|
1405
|
+
let set;
|
|
1406
|
+
if (d.alias) {
|
|
1407
|
+
const id = ECL_DIALECT_ALIASES[d.alias];
|
|
1408
|
+
if (!id) throw new Error(`Unknown dialect alias ${d.alias} in description filter`);
|
|
1409
|
+
const i = idx(id);
|
|
1410
|
+
set = new Set(i >= 0 ? [i] : []);
|
|
1411
|
+
} else {
|
|
1412
|
+
set = resolveSet(d.expression);
|
|
1413
|
+
}
|
|
1414
|
+
const acceptability = d.acceptability
|
|
1415
|
+
? new Set(d.acceptability.map(a => acceptabilityIdx[a] !== undefined ? acceptabilityIdx[a] : idx(a)))
|
|
1416
|
+
: null;
|
|
1417
|
+
return { set, acceptability };
|
|
1418
|
+
}) };
|
|
1419
|
+
case 'boolean':
|
|
1420
|
+
activeFilter = f;
|
|
1421
|
+
return { ...f, field };
|
|
1422
|
+
case 'id':
|
|
1423
|
+
return { ...f, field, ids: new Set(f.values.map(v => BigInt(v))) };
|
|
1424
|
+
default:
|
|
1425
|
+
return { ...f, field };
|
|
1426
|
+
}
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
const matching = [];
|
|
1430
|
+
for (const conceptIdx of this._eclResolveSet(ctx, opContext)) {
|
|
1431
|
+
if (opContext) opContext.deadCheck('ecl:descriptionFilter');
|
|
1432
|
+
const concept = this.concepts.getConcept(conceptIdx);
|
|
1433
|
+
const descIndices = concept.descriptions ? (this.refs.getReferences(concept.descriptions) || []) : [];
|
|
1434
|
+
let found = false;
|
|
1435
|
+
for (const descIndex of descIndices) {
|
|
1436
|
+
const d = this.descriptions.getDescription(descIndex);
|
|
1437
|
+
if (!activeFilter && !d.active) continue;
|
|
1438
|
+
if (prepared.every(f => this._eclDescriptionMatches(d, f))) {
|
|
1439
|
+
found = true;
|
|
1440
|
+
break;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
if (found) matching.push(conceptIdx);
|
|
1444
|
+
}
|
|
1445
|
+
const result = new SnomedFilterContext();
|
|
1446
|
+
result.descendants = matching;
|
|
1447
|
+
return result;
|
|
1448
|
+
};
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* Does one description satisfy one prepared description filter?
|
|
1452
|
+
*/
|
|
1453
|
+
_eclDescriptionMatches = function (d, f) {
|
|
1454
|
+
const neg = f.operator === '!=';
|
|
1455
|
+
const eq = (r) => neg ? !r : r;
|
|
1456
|
+
switch (f.field) {
|
|
1457
|
+
case 'term':
|
|
1458
|
+
return eq(f.matchers.some(m => m(this.strings.getEntry(d.iDesc))));
|
|
1459
|
+
case 'language':
|
|
1460
|
+
return eq(f.values.includes(SCT_LANGUAGE_CODES[d.lang] || 'en'));
|
|
1461
|
+
case 'typeid':
|
|
1462
|
+
return eq(f.set === null || f.set.has(d.kind));
|
|
1463
|
+
case 'type':
|
|
1464
|
+
return eq(f.set.has(d.kind));
|
|
1465
|
+
case 'moduleid':
|
|
1466
|
+
return eq(f.set === null || f.set.has(d.module));
|
|
1467
|
+
case 'dialectid':
|
|
1468
|
+
case 'dialect': {
|
|
1469
|
+
const refsets = d.refsets ? (this.refs.getReferences(d.refsets) || []) : [];
|
|
1470
|
+
const inDialect = f.dialects.some(dl => refsets.some(r => {
|
|
1471
|
+
if (dl.set !== null && !dl.set.has(r)) return false;
|
|
1472
|
+
if (!dl.acceptability) return true;
|
|
1473
|
+
return dl.acceptability.has(this._descAcceptability(d, r));
|
|
1474
|
+
}));
|
|
1475
|
+
return eq(inDialect);
|
|
1476
|
+
}
|
|
1477
|
+
case 'effectivetime':
|
|
1478
|
+
return this._eclCompareValues(this._snomedDateToString(d.date), f.operator, f.values, (a, b) => a.localeCompare(b));
|
|
1479
|
+
case 'active':
|
|
1480
|
+
return eq(d.active === f.value);
|
|
1481
|
+
case 'id':
|
|
1482
|
+
return eq(f.ids.has(d.id));
|
|
1483
|
+
default:
|
|
1484
|
+
throw new Error(`Unsupported description filter ${f.field}`);
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* A matcher for one typed search term. "match" semantics (the default): every
|
|
1490
|
+
* word of the search term is a prefix of some word of the description, case
|
|
1491
|
+
* insensitively. "wild" semantics: the whole description matches the pattern,
|
|
1492
|
+
* with * standing for any characters.
|
|
1493
|
+
*/
|
|
1494
|
+
_eclTermMatcher = function (v) {
|
|
1495
|
+
if (v.kind === 'wild') {
|
|
1496
|
+
const re = new RegExp('^' + v.value.split('*').map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$', 'i');
|
|
1497
|
+
return (term) => re.test(term);
|
|
1498
|
+
}
|
|
1499
|
+
const words = v.value.toLowerCase().split(/\s+/).filter(w => w.length > 0);
|
|
1500
|
+
return (term) => {
|
|
1501
|
+
const termWords = term.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(w => w.length > 0);
|
|
1502
|
+
return words.every(w => termWords.some(tw => tw.startsWith(w)));
|
|
1503
|
+
};
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
/**
|
|
1507
|
+
* Resolve the expression-valued member filters (moduleId = ..., <conceptField> = ...)
|
|
1508
|
+
* once, before the members are walked. Returns the filters with a `set` of
|
|
1509
|
+
* concept indexes attached (or `wildcard: true`), or null when there are none.
|
|
1510
|
+
*/
|
|
1511
|
+
_eclPrepareMemberFilters = function (filters, opContext) {
|
|
1512
|
+
if (!filters || filters.length === 0) {
|
|
1513
|
+
return null;
|
|
1514
|
+
}
|
|
1515
|
+
return filters.map(f => {
|
|
1516
|
+
if (f.valueType !== 'expression') {
|
|
1517
|
+
return f;
|
|
1518
|
+
}
|
|
1519
|
+
const ctx = this._evalECLNode(f.value, opContext);
|
|
1520
|
+
if (ctx.eclWildcard && (!ctx.descendants || ctx.descendants.length === 0)) {
|
|
1521
|
+
return { ...f, wildcard: true };
|
|
1522
|
+
}
|
|
1523
|
+
return { ...f, set: new Set(this._eclToIndexArray(ctx)) };
|
|
1524
|
+
});
|
|
1525
|
+
};
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* Does a reference set member row satisfy every member filter?
|
|
1529
|
+
*
|
|
1530
|
+
* Member rows are only stored when active, so `active = false` matches
|
|
1531
|
+
* nothing. effectiveTime and moduleId come from the row itself (present when
|
|
1532
|
+
* the reference set stores ids); any other field name is looked up in the
|
|
1533
|
+
* reference set's additional fields (e.g. targetComponentId, mapTarget,
|
|
1534
|
+
* acceptabilityId), typed by the refset file name (c / i / s).
|
|
1535
|
+
*/
|
|
1536
|
+
_eclMemberMatches = function (m, refSet, filters) {
|
|
1537
|
+
if (!filters) {
|
|
1538
|
+
return true;
|
|
1539
|
+
}
|
|
1540
|
+
for (const f of filters) {
|
|
1541
|
+
const field = f.field.toLowerCase();
|
|
1542
|
+
let ok;
|
|
1543
|
+
if (field === 'active') {
|
|
1544
|
+
ok = (f.operator === '=') === f.value;
|
|
1545
|
+
} else if (field === 'effectivetime') {
|
|
1546
|
+
if (m.date == null) {
|
|
1547
|
+
return false;
|
|
1548
|
+
}
|
|
1549
|
+
ok = this._eclCompareValues(this._snomedDateToString(m.date), f.operator, f.values, (a, b) => a.localeCompare(b));
|
|
1550
|
+
} else if (field === 'moduleid') {
|
|
1551
|
+
if (m.module == null) {
|
|
1552
|
+
return false;
|
|
1553
|
+
}
|
|
1554
|
+
const inSet = f.wildcard || f.set.has(m.module);
|
|
1555
|
+
ok = f.operator === '=' ? inSet : !inSet;
|
|
1556
|
+
} else {
|
|
1557
|
+
const fv = this._eclMemberFieldValue(m, refSet, f.field);
|
|
1558
|
+
if (fv === undefined) {
|
|
1559
|
+
throw new Error(`Reference set ${this.concepts.getConceptId(refSet.definition)} has no field named ${f.field}`);
|
|
1560
|
+
}
|
|
1561
|
+
if (fv === null) {
|
|
1562
|
+
return false;
|
|
1563
|
+
}
|
|
1564
|
+
switch (f.valueType) {
|
|
1565
|
+
case 'expression': {
|
|
1566
|
+
if (fv.type !== 'concept') {
|
|
1567
|
+
throw new Error(`Reference set field ${f.field} is not a concept field`);
|
|
1568
|
+
}
|
|
1569
|
+
const inSet = f.wildcard || f.set.has(fv.value);
|
|
1570
|
+
ok = f.operator === '=' ? inSet : !inSet;
|
|
1571
|
+
break;
|
|
1572
|
+
}
|
|
1573
|
+
case 'numeric': {
|
|
1574
|
+
if (fv.type !== 'integer') {
|
|
1575
|
+
throw new Error(`Reference set field ${f.field} is not a numeric field`);
|
|
1576
|
+
}
|
|
1577
|
+
ok = this._eclCompareValues(Number(fv.value), f.operator, [Number(f.value)], (a, b) => a - b);
|
|
1578
|
+
break;
|
|
1579
|
+
}
|
|
1580
|
+
case 'string': {
|
|
1581
|
+
if (fv.type !== 'string') {
|
|
1582
|
+
throw new Error(`Reference set field ${f.field} is not a string field`);
|
|
1583
|
+
}
|
|
1584
|
+
ok = this._eclCompareValues(fv.value, f.operator, f.values, (a, b) => a.localeCompare(b));
|
|
1585
|
+
break;
|
|
1586
|
+
}
|
|
1587
|
+
case 'time': {
|
|
1588
|
+
if (fv.type !== 'string') {
|
|
1589
|
+
throw new Error(`Reference set field ${f.field} is not a time field`);
|
|
1590
|
+
}
|
|
1591
|
+
ok = this._eclCompareValues(fv.value, f.operator, f.values, (a, b) => a.localeCompare(b));
|
|
1592
|
+
break;
|
|
1593
|
+
}
|
|
1594
|
+
case 'boolean': {
|
|
1595
|
+
if (fv.type !== 'string' && fv.type !== 'integer') {
|
|
1596
|
+
throw new Error(`Reference set field ${f.field} is not a boolean field`);
|
|
1597
|
+
}
|
|
1598
|
+
const b = String(fv.value) === '1' || String(fv.value).toLowerCase() === 'true';
|
|
1599
|
+
ok = (f.operator === '=') === (b === f.value);
|
|
1600
|
+
break;
|
|
1601
|
+
}
|
|
1602
|
+
default:
|
|
1603
|
+
throw new Error(`Unsupported member filter on ${f.field}`);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
if (!ok) {
|
|
1607
|
+
return false;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
return true;
|
|
1611
|
+
};
|
|
1612
|
+
|
|
1613
|
+
/**
|
|
1614
|
+
* Compare one value against a value set with an ECL comparison operator.
|
|
1615
|
+
* "=" is true when any set value matches, "!=" when none does; the ordering
|
|
1616
|
+
* operators compare against the single value.
|
|
1617
|
+
*/
|
|
1618
|
+
_eclCompareValues = function (actual, operator, values, cmp) {
|
|
1619
|
+
switch (operator) {
|
|
1620
|
+
case '=': return values.some(v => cmp(actual, v) === 0);
|
|
1621
|
+
case '!=': return !values.some(v => cmp(actual, v) === 0);
|
|
1622
|
+
case '<': return cmp(actual, values[0]) < 0;
|
|
1623
|
+
case '<=': return cmp(actual, values[0]) <= 0;
|
|
1624
|
+
case '>': return cmp(actual, values[0]) > 0;
|
|
1625
|
+
case '>=': return cmp(actual, values[0]) >= 0;
|
|
1626
|
+
default: throw new Error(`Unsupported comparison operator ${operator}`);
|
|
1627
|
+
}
|
|
1628
|
+
};
|
|
1629
|
+
|
|
1630
|
+
/**
|
|
1631
|
+
* Read a named additional field from a member row. Returns undefined when the
|
|
1632
|
+
* reference set has no such field, null when the row has no value for it, and
|
|
1633
|
+
* otherwise { type: 'concept'|'description'|'integer'|'string', value }.
|
|
1634
|
+
*/
|
|
1635
|
+
_eclMemberFieldValue = function (m, refSet, fieldName) {
|
|
1636
|
+
if (!refSet.fieldNames) {
|
|
1637
|
+
return undefined;
|
|
1638
|
+
}
|
|
1639
|
+
const names = this.refs.getReferences(refSet.fieldNames) || [];
|
|
1640
|
+
const idx = names.findIndex(n => this.strings.getEntry(n).trim().toLowerCase() === fieldName.toLowerCase());
|
|
1641
|
+
if (idx < 0) {
|
|
1642
|
+
return undefined;
|
|
1643
|
+
}
|
|
1644
|
+
const values = m.values ? this.refs.getReferences(m.values) : null;
|
|
1645
|
+
if (!values || values.length < (idx + 1) * 2) {
|
|
1646
|
+
return null;
|
|
1647
|
+
}
|
|
1648
|
+
const value = values[idx * 2];
|
|
1649
|
+
const type = values[idx * 2 + 1];
|
|
1650
|
+
switch (type) {
|
|
1651
|
+
case 1: return { type: 'concept', value };
|
|
1652
|
+
case 2: return { type: 'description', value };
|
|
1653
|
+
case 4: return { type: 'integer', value };
|
|
1654
|
+
case 5: return { type: 'string', value: this.strings.getEntry(value) };
|
|
1655
|
+
default: return null;
|
|
1656
|
+
}
|
|
1657
|
+
};
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* Stored SNOMED dates are days since the Pascal TDateTime epoch (1899-12-30);
|
|
1661
|
+
* render as YYYYMMDD for comparison with an ECL time value.
|
|
1662
|
+
*/
|
|
1663
|
+
_snomedDateToString = function (days) {
|
|
1664
|
+
const d = new Date(Date.UTC(1899, 11, 30) + days * 86400000);
|
|
1665
|
+
return d.toISOString().slice(0, 10).replace(/-/g, '');
|
|
1666
|
+
};
|
|
1667
|
+
|
|
1668
|
+
_findRefSetRecord = function (conceptIndex) {
|
|
1669
|
+
for (let i = 0; i < this.refSetIndex.count(); i++) {
|
|
1670
|
+
const refSet = this.refSetIndex.getReferenceSet(i);
|
|
1671
|
+
// definition 0 means the importer could not resolve the reference set's
|
|
1672
|
+
// concept (it is outside the subset) - not "the concept at offset 0"
|
|
1673
|
+
if (refSet.definition === conceptIndex && refSet.definition !== 0) {
|
|
1674
|
+
return refSet;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
return null;
|
|
1678
|
+
};
|
|
1679
|
+
|
|
1024
1680
|
/**
|
|
1025
1681
|
* Wildcard — every concept, active or not. The eclWildcard flag tells filterCheck /
|
|
1026
1682
|
* filterLocate to accept every active concept without enumeration.
|
|
@@ -1046,14 +1702,7 @@ class SnomedServices {
|
|
|
1046
1702
|
let current = this._eclResolveSet(this._evalECLNode(node.base, opContext), opContext);
|
|
1047
1703
|
|
|
1048
1704
|
for (const attr of node.attributes || []) {
|
|
1049
|
-
|
|
1050
|
-
throw new Error('ECL dotted expressions only support plain concept-reference attribute names');
|
|
1051
|
-
}
|
|
1052
|
-
const attrResult = this.concepts.findConcept(attr.conceptId);
|
|
1053
|
-
if (!attrResult.found) {
|
|
1054
|
-
throw new Error(`The SNOMED CT Concept ${attr.conceptId} is not known`);
|
|
1055
|
-
}
|
|
1056
|
-
const attrTypeIdx = attrResult.index;
|
|
1705
|
+
const types = this._eclAttributeTypes(attr, opContext);
|
|
1057
1706
|
|
|
1058
1707
|
const next = new Set();
|
|
1059
1708
|
for (const conceptIdx of current) {
|
|
@@ -1061,7 +1710,7 @@ class SnomedServices {
|
|
|
1061
1710
|
const relIdxs = this.getConceptRelationships(conceptIdx);
|
|
1062
1711
|
for (const relIdx of relIdxs) {
|
|
1063
1712
|
const rel = this.relationships.getRelationship(relIdx);
|
|
1064
|
-
if (rel.active && rel.relType
|
|
1713
|
+
if (rel.active && (types.any || types.types.has(rel.relType))) {
|
|
1065
1714
|
next.add(rel.target);
|
|
1066
1715
|
}
|
|
1067
1716
|
}
|
|
@@ -1080,7 +1729,7 @@ class SnomedServices {
|
|
|
1080
1729
|
* Evaluate a refined expression: `<baseConstraint> : <refinement>`.
|
|
1081
1730
|
* Supported refinement shapes:
|
|
1082
1731
|
* - ATTRIBUTE attr = valueExpr
|
|
1083
|
-
* - ATTRIBUTE_SET attr1 = v1, attr2 = v2 (
|
|
1732
|
+
* - ATTRIBUTE_SET attr1 = v1, attr2 = v2 / attr1 = v1 AND|OR attr2 = v2 (operator 'AND'|'OR')
|
|
1084
1733
|
* - ATTRIBUTE_GROUP { attr1 = v1, attr2 = v2 } (same relationship group)
|
|
1085
1734
|
* Reverse attributes, cardinality, `!=`, and non-concept attribute names
|
|
1086
1735
|
* throw informative errors.
|
|
@@ -1113,10 +1762,11 @@ class SnomedServices {
|
|
|
1113
1762
|
case ECLNodeType.ATTRIBUTE:
|
|
1114
1763
|
return this._attributeMatches(rels, refinement, null, opContext);
|
|
1115
1764
|
case ECLNodeType.ATTRIBUTE_SET:
|
|
1116
|
-
|
|
1117
|
-
|
|
1765
|
+
// operator is 'AND' (also for ",") or 'OR'
|
|
1766
|
+
if (refinement.operator === 'OR') {
|
|
1767
|
+
return refinement.attributes.some(a => this._refinementMatches(rels, a, opContext));
|
|
1118
1768
|
}
|
|
1119
|
-
return
|
|
1769
|
+
return refinement.attributes.every(a => this._refinementMatches(rels, a, opContext));
|
|
1120
1770
|
case ECLNodeType.ATTRIBUTE_GROUP:
|
|
1121
1771
|
return this._attributeGroupMatches(rels, refinement, opContext);
|
|
1122
1772
|
default:
|
|
@@ -1124,6 +1774,23 @@ class SnomedServices {
|
|
|
1124
1774
|
}
|
|
1125
1775
|
};
|
|
1126
1776
|
|
|
1777
|
+
/**
|
|
1778
|
+
* The same, restricted to the relationships in one role group: an attribute
|
|
1779
|
+
* set inside { } may itself be an AND/OR tree of attributes.
|
|
1780
|
+
*/
|
|
1781
|
+
_refinementMatchesInGroup = function (rels, node, g, opContext) {
|
|
1782
|
+
if (node.type === ECLNodeType.ATTRIBUTE_SET) {
|
|
1783
|
+
if (node.operator === 'OR') {
|
|
1784
|
+
return node.attributes.some(a => this._refinementMatchesInGroup(rels, a, g, opContext));
|
|
1785
|
+
}
|
|
1786
|
+
return node.attributes.every(a => this._refinementMatchesInGroup(rels, a, g, opContext));
|
|
1787
|
+
}
|
|
1788
|
+
if (node.type !== ECLNodeType.ATTRIBUTE) {
|
|
1789
|
+
throw new Error(`Unsupported node type in attribute group: ${node.type}`);
|
|
1790
|
+
}
|
|
1791
|
+
return this._attributeMatches(rels, node, g, opContext);
|
|
1792
|
+
};
|
|
1793
|
+
|
|
1127
1794
|
/**
|
|
1128
1795
|
* Check whether a concept has at least one active relationship whose
|
|
1129
1796
|
* `relType` matches the attribute name and whose `target` is in the value
|
|
@@ -1142,14 +1809,11 @@ class SnomedServices {
|
|
|
1142
1809
|
throw new Error('ECL attribute without a comparison is not supported');
|
|
1143
1810
|
}
|
|
1144
1811
|
if (attr.comparison.type !== ECLNodeType.EXPRESSION_COMPARISON) {
|
|
1145
|
-
throw new Error(
|
|
1812
|
+
throw new Error('ECL concrete values (numeric, string and boolean attribute values) are not supported - concrete value relationships are not loaded');
|
|
1146
1813
|
}
|
|
1147
1814
|
if (attr.comparison.operator !== ECLTokenType.EQUALS) {
|
|
1148
1815
|
throw new Error('ECL != in refinements is not yet supported');
|
|
1149
1816
|
}
|
|
1150
|
-
if (attr.name.type !== ECLNodeType.CONCEPT_REFERENCE) {
|
|
1151
|
-
throw new Error('ECL refinements only support plain concept-reference attribute names');
|
|
1152
|
-
}
|
|
1153
1817
|
|
|
1154
1818
|
const count = this._countAttributeMatches(rels, attr, groupFilter, opContext);
|
|
1155
1819
|
|
|
@@ -1178,17 +1842,13 @@ class SnomedServices {
|
|
|
1178
1842
|
// fresh per request, so memoising on the node is safe within a request.
|
|
1179
1843
|
let resolved = attr._eclResolved;
|
|
1180
1844
|
if (!resolved) {
|
|
1181
|
-
const attrResult = this.concepts.findConcept(attr.name.conceptId);
|
|
1182
|
-
if (!attrResult.found) {
|
|
1183
|
-
throw new Error(`The SNOMED CT Concept ${attr.name.conceptId} is not known`);
|
|
1184
|
-
}
|
|
1185
1845
|
resolved = {
|
|
1186
|
-
|
|
1846
|
+
types: this._eclAttributeTypes(attr.name, opContext),
|
|
1187
1847
|
valueSet: new Set(this._eclResolveSet(this._evalECLNode(attr.comparison.value, opContext), opContext))
|
|
1188
1848
|
};
|
|
1189
1849
|
attr._eclResolved = resolved;
|
|
1190
1850
|
}
|
|
1191
|
-
const
|
|
1851
|
+
const types = resolved.types;
|
|
1192
1852
|
const valueSet = resolved.valueSet;
|
|
1193
1853
|
|
|
1194
1854
|
// ECL cardinality counts OCCURRENCES of a matching attribute, per role group
|
|
@@ -1203,7 +1863,7 @@ class SnomedServices {
|
|
|
1203
1863
|
for (const rel of rels) {
|
|
1204
1864
|
if (opContext) opContext.deadCheck('ecl:countAttributeMatches');
|
|
1205
1865
|
if (!rel.active) continue;
|
|
1206
|
-
if (rel.relType
|
|
1866
|
+
if (!types.any && !types.types.has(rel.relType)) continue;
|
|
1207
1867
|
if (groupFilter !== null && rel.group !== groupFilter) continue;
|
|
1208
1868
|
if (valueSet.has(rel.target)) matched.add(rel.group + ':' + rel.target);
|
|
1209
1869
|
}
|
|
@@ -1248,7 +1908,7 @@ class SnomedServices {
|
|
|
1248
1908
|
if (opContext) opContext.deadCheck('ecl:attributeGroup');
|
|
1249
1909
|
let allMatch = true;
|
|
1250
1910
|
for (const attr of group.attributes) {
|
|
1251
|
-
if (!this.
|
|
1911
|
+
if (!this._refinementMatchesInGroup(rels, attr, g, opContext)) {
|
|
1252
1912
|
allMatch = false;
|
|
1253
1913
|
break;
|
|
1254
1914
|
}
|
|
@@ -1690,6 +2350,15 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1690
2350
|
return this.sct.getDescription();
|
|
1691
2351
|
}
|
|
1692
2352
|
|
|
2353
|
+
/**
|
|
2354
|
+
* $lookup's display name: the edition description (e.g. "SNOMED CT International
|
|
2355
|
+
* Edition"), which says more than the bare code system title.
|
|
2356
|
+
*/
|
|
2357
|
+
// eslint-disable-next-line no-unused-vars
|
|
2358
|
+
lookupName(languages) {
|
|
2359
|
+
return this.sct.getDescription();
|
|
2360
|
+
}
|
|
2361
|
+
|
|
1693
2362
|
description() {
|
|
1694
2363
|
return this.sct.getDescription();
|
|
1695
2364
|
}
|
|
@@ -2673,7 +3342,13 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2673
3342
|
}
|
|
2674
3343
|
|
|
2675
3344
|
async getTranslations(map, coding, target, reverse) {
|
|
2676
|
-
if (!map ||
|
|
3345
|
+
if (!map || reverse) {
|
|
3346
|
+
return [];
|
|
3347
|
+
}
|
|
3348
|
+
if (map.jsonObj.editionTranslation) {
|
|
3349
|
+
return this.translateToEdition(map, coding);
|
|
3350
|
+
}
|
|
3351
|
+
if (target && target !== this.system()) {
|
|
2677
3352
|
return [];
|
|
2678
3353
|
}
|
|
2679
3354
|
let ref = this.sct.concepts.findConcept(map.id);
|
|
@@ -2730,6 +3405,133 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2730
3405
|
return result;
|
|
2731
3406
|
}
|
|
2732
3407
|
|
|
3408
|
+
/**
|
|
3409
|
+
* Translate a concept of this edition to the edition it depends on (typically a national
|
|
3410
|
+
* extension to the International Edition). There is no map: the answer comes from the
|
|
3411
|
+
* definitions.
|
|
3412
|
+
* - a concept in one of the target edition's modules is its own translation
|
|
3413
|
+
* - an inactive concept translates via its historical associations (REPLACED BY, SAME AS,
|
|
3414
|
+
* POSSIBLY EQUIVALENT TO, ALTERNATIVE) to concepts in the target edition
|
|
3415
|
+
* - a fully defined extension concept translates to its normal form, provided every concept
|
|
3416
|
+
* in it belongs to the target edition; if the target edition has a concept with that same
|
|
3417
|
+
* normal form, to that concept
|
|
3418
|
+
* - otherwise (a primitive extension concept, or a definition that uses other extension
|
|
3419
|
+
* content) to its proximal ancestors in the target edition, which it is narrower than -
|
|
3420
|
+
* unless the only such ancestors are top-level hierarchies (or the root), which carry no
|
|
3421
|
+
* information: that is reported as a reason rather than a translation
|
|
3422
|
+
*/
|
|
3423
|
+
async translateToEdition(map, coding) {
|
|
3424
|
+
const ed = map.jsonObj.editionTranslation;
|
|
3425
|
+
const sct = this.sct;
|
|
3426
|
+
const src = sct.concepts.findConcept(coding.code);
|
|
3427
|
+
if (!src.found) {
|
|
3428
|
+
return [];
|
|
3429
|
+
}
|
|
3430
|
+
const inTarget = (ref) => ed.modules.has(sct.concepts.getModuleId(ref));
|
|
3431
|
+
const mk = (code, display, relationship) => ({
|
|
3432
|
+
map: map.vurl, system: this.system(), version: ed.version, code, display, relationship
|
|
3433
|
+
});
|
|
3434
|
+
const named = (ref) => `${sct.getConceptId(ref)} |${sct.getDisplayName(ref, sct.defaultLanguage)}|`;
|
|
3435
|
+
|
|
3436
|
+
if (inTarget(src.index)) {
|
|
3437
|
+
return [mk(sct.getConceptId(src.index), sct.getDisplayName(src.index, sct.defaultLanguage), 'equivalent')];
|
|
3438
|
+
}
|
|
3439
|
+
|
|
3440
|
+
if (!sct.isActive(src.index)) {
|
|
3441
|
+
const result = [];
|
|
3442
|
+
for (const [refset, relationship] of [['900000000000527005', 'equivalent'], ['900000000000526001', 'equivalent'],
|
|
3443
|
+
['900000000000523009', 'related-to'], ['900000000000530003', 'related-to']]) {
|
|
3444
|
+
for (const tgt of this.#historicalAssociations(refset, src.index)) {
|
|
3445
|
+
if (inTarget(tgt)) {
|
|
3446
|
+
result.push(mk(sct.getConceptId(tgt), sct.getDisplayName(tgt, sct.defaultLanguage), relationship));
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
return result;
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
if (!sct.isPrimitive(src.index)) {
|
|
3454
|
+
const nf = sct.expressionServices.createNormalForm(src.index);
|
|
3455
|
+
if (this.#expressionConcepts(nf).every(inTarget)) {
|
|
3456
|
+
const same = sct.conceptWithNormalForm(sct.expressionServices.renderExpression(nf, SnomedServicesRenderOption.Minimal));
|
|
3457
|
+
if (same >= 0 && same !== src.index && inTarget(same)) {
|
|
3458
|
+
return [mk(sct.getConceptId(same), sct.getDisplayName(same, sct.defaultLanguage), 'equivalent')];
|
|
3459
|
+
}
|
|
3460
|
+
// the canonical form self-groups every attribute; for output, never-grouped ones go back ungrouped
|
|
3461
|
+
const out = sct.expressionServices.ungroupNeverGroupedRefinements(nf);
|
|
3462
|
+
return [mk(sct.expressionServices.renderExpression(out, SnomedServicesRenderOption.Minimal),
|
|
3463
|
+
sct.expressionServices.renderExpression(out, SnomedServicesRenderOption.FillMissing), 'equivalent')];
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
const ancestors = this.#proximalAncestorsIn(src.index, inTarget);
|
|
3468
|
+
if (ancestors.length === 0) {
|
|
3469
|
+
return [];
|
|
3470
|
+
}
|
|
3471
|
+
const useful = ancestors.filter(a => sct.concepts.getDepth(a) >= 2);
|
|
3472
|
+
if (useful.length === 0) {
|
|
3473
|
+
const list = ancestors.map(named);
|
|
3474
|
+
const msgId = list.length === 1 ? 'TRANSLATE_SCT_EDITION_TOP_LEVEL_ONE' : 'TRANSLATE_SCT_EDITION_TOP_LEVEL_MANY';
|
|
3475
|
+
return [{ message: this.opContext.i18n.translate(msgId, this.opContext.langs,
|
|
3476
|
+
[getEditionName(ed.moduleId), named(src.index), list.join(', ')]) }];
|
|
3477
|
+
}
|
|
3478
|
+
return useful.map(a => mk(sct.getConceptId(a), sct.getDisplayName(a, sct.defaultLanguage), 'source-is-narrower-than-target'));
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
/** The target components of the given association reference set for a concept */
|
|
3482
|
+
#historicalAssociations(refsetId, conceptIndex) {
|
|
3483
|
+
const sct = this.sct;
|
|
3484
|
+
const rs = sct.concepts.findConcept(refsetId);
|
|
3485
|
+
if (!rs.found) return [];
|
|
3486
|
+
const rref = sct.refSetIndex.getRefSetByConcept(rs.index);
|
|
3487
|
+
if (rref === -1) return [];
|
|
3488
|
+
const members = sct.refSetMembers.getMembers(sct.refSetIndex.getReferenceSet(rref).membersByRef);
|
|
3489
|
+
const result = [];
|
|
3490
|
+
for (const m of members) {
|
|
3491
|
+
if (m.ref !== conceptIndex) continue;
|
|
3492
|
+
const values = m.values ? sct.refs.getReferences(m.values) : null;
|
|
3493
|
+
if (values && values.length >= 1 && values[0]) {
|
|
3494
|
+
result.push(values[0]);
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
return result;
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3500
|
+
/** Every concept reference used anywhere in an expression */
|
|
3501
|
+
#expressionConcepts(exp, into = []) {
|
|
3502
|
+
for (const c of exp.concepts) {
|
|
3503
|
+
if (c.reference !== NO_REFERENCE) into.push(c.reference);
|
|
3504
|
+
}
|
|
3505
|
+
const refinements = [...exp.refinements, ...exp.refinementGroups.flatMap(g => g.refinements)];
|
|
3506
|
+
for (const r of refinements) {
|
|
3507
|
+
if (r.name && r.name.reference !== NO_REFERENCE) into.push(r.name.reference);
|
|
3508
|
+
if (r.value) this.#expressionConcepts(r.value, into);
|
|
3509
|
+
}
|
|
3510
|
+
return into;
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
/**
|
|
3514
|
+
* The nearest ancestors of a concept that satisfy the test: a parent that does is taken
|
|
3515
|
+
* and not looked beyond; one that does not is replaced by its own parents.
|
|
3516
|
+
*/
|
|
3517
|
+
#proximalAncestorsIn(conceptIndex, test) {
|
|
3518
|
+
const result = [];
|
|
3519
|
+
const seen = new Set();
|
|
3520
|
+
const todo = [...this.sct.expressionServices.getConceptParents(conceptIndex)];
|
|
3521
|
+
while (todo.length) {
|
|
3522
|
+
const p = todo.shift();
|
|
3523
|
+
if (seen.has(p)) continue;
|
|
3524
|
+
seen.add(p);
|
|
3525
|
+
if (test(p)) {
|
|
3526
|
+
result.push(p);
|
|
3527
|
+
} else {
|
|
3528
|
+
todo.push(...this.sct.expressionServices.getConceptParents(p));
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
// an ancestor that is itself subsumed by another in the list is not proximal
|
|
3532
|
+
return result.filter(a => !result.some(b => b !== a && this.sct.subsumes(a, b)));
|
|
3533
|
+
}
|
|
3534
|
+
|
|
2733
3535
|
hasMultiHierarchy() {
|
|
2734
3536
|
return true;
|
|
2735
3537
|
}
|
|
@@ -2979,11 +3781,72 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
2979
3781
|
return edition + ' ' + formatDateMMDDYYYY(match[2].substring(4, 6) + match[2].substring(6, 8) + match[2].substring(0, 4));
|
|
2980
3782
|
}
|
|
2981
3783
|
|
|
3784
|
+
/**
|
|
3785
|
+
* A translation from this edition to an edition it depends on needs no map: the answer
|
|
3786
|
+
* comes from the definitions (see SnomedProvider.translateToEdition). The request names
|
|
3787
|
+
* the source by its edition URI (with or without a version) and the target as
|
|
3788
|
+
* system|edition URI (with or without a version); the map's url is the versioned source
|
|
3789
|
+
* edition URI plus ?fhir_cm=edition/<target edition module>
|
|
3790
|
+
*/
|
|
3791
|
+
async findImplicitConceptMaps(conceptMaps, source, sourceVersion, dest) {
|
|
3792
|
+
if (source !== this.system() || !sourceVersion || !dest || !this.isMyEdition(sourceVersion)) {
|
|
3793
|
+
return;
|
|
3794
|
+
}
|
|
3795
|
+
const bar = dest.indexOf('|');
|
|
3796
|
+
if (bar === -1 || dest.substring(0, bar) !== this.system()) {
|
|
3797
|
+
return;
|
|
3798
|
+
}
|
|
3799
|
+
const dep = this.snomedServices.editionDependency(dest.substring(bar + 1));
|
|
3800
|
+
if (dep) {
|
|
3801
|
+
conceptMaps.push(this.editionTranslationMap(dep));
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
/**
|
|
3806
|
+
* The implicit map from this edition to an edition it depends on. Its url is the one
|
|
3807
|
+
* agreed with SNOMED International: <source edition>/version/<date>?fhir_cm=edition/<target module>
|
|
3808
|
+
* (the source always versioned, whatever the request said)
|
|
3809
|
+
*/
|
|
3810
|
+
editionTranslationMap(dep) {
|
|
3811
|
+
return new ConceptMap({
|
|
3812
|
+
resourceType: 'ConceptMap',
|
|
3813
|
+
internalSource: this,
|
|
3814
|
+
editionTranslation: dep,
|
|
3815
|
+
url: `${this.version()}?fhir_cm=edition/${dep.moduleId}`,
|
|
3816
|
+
name: `SNOMED CT ${this.describeVersion(this.version())} to ${getEditionName(dep.moduleId)} Concept Map`,
|
|
3817
|
+
description: `The translation implied by the concept definitions of the edition ${this.version()} to the ${getEditionName(dep.moduleId)} that it depends on`,
|
|
3818
|
+
status: 'active',
|
|
3819
|
+
group: [{
|
|
3820
|
+
source: this.system(),
|
|
3821
|
+
target: this.system()
|
|
3822
|
+
}]
|
|
3823
|
+
});
|
|
3824
|
+
}
|
|
3825
|
+
|
|
3826
|
+
/** Whether a source version names this edition: exactly, or the edition without a version */
|
|
3827
|
+
isMyEdition(version) {
|
|
3828
|
+
const mine = this.version();
|
|
3829
|
+
return version === mine || mine.startsWith(version + '/version/');
|
|
3830
|
+
}
|
|
3831
|
+
|
|
2982
3832
|
async findImplicitConceptMap(url, version) {
|
|
2983
3833
|
if (version && (version !== this.version())) {
|
|
2984
3834
|
return null;
|
|
2985
3835
|
}
|
|
2986
|
-
if (!url
|
|
3836
|
+
if (!url) {
|
|
3837
|
+
return null;
|
|
3838
|
+
}
|
|
3839
|
+
// <edition[/version/date]>?fhir_cm=edition/<module>: this edition's definition-based
|
|
3840
|
+
// translation to an edition it depends on, nominated by url
|
|
3841
|
+
const q = url.indexOf('?fhir_cm=edition/');
|
|
3842
|
+
if (q !== -1) {
|
|
3843
|
+
if (!this.isMyEdition(url.substring(0, q))) {
|
|
3844
|
+
return null;
|
|
3845
|
+
}
|
|
3846
|
+
const dep = this.snomedServices.editionDependency(`http://snomed.info/sct/${url.substring(q + '?fhir_cm=edition/'.length)}`);
|
|
3847
|
+
return dep ? this.editionTranslationMap(dep) : null;
|
|
3848
|
+
}
|
|
3849
|
+
if (!url.startsWith(this.system()+"?fhir_cm=")) {
|
|
2987
3850
|
return null;
|
|
2988
3851
|
}
|
|
2989
3852
|
let id = url.substring(url.indexOf("=")+1);
|