fhirsmith 0.17.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +113 -0
- package/README.md +80 -13
- package/config-template.json +106 -1
- 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 +432 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +21 -2
- 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 -3
- package/packages/packages.js +34 -3
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +41 -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 +230 -44
- package/registry/uptime.js +154 -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/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 +10 -0
- package/tx/README.md +52 -0
- package/tx/closure/closure-store.js +14 -0
- package/tx/cs/cs-api.js +92 -14
- package/tx/cs/cs-cs.js +64 -3
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +431 -0
- package/tx/cs/cs-loinc.js +296 -27
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +1210 -80
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/csv-records.js +35 -0
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/importers/import-loinc.module.js +177 -41
- package/tx/importers/import-sct.module.js +66 -1
- package/tx/importers/subset-loinc.module.js +25 -6
- package/tx/library/codesystem.js +4 -1
- package/tx/library/designations.js +23 -30
- package/tx/library.js +98 -4
- package/tx/openapi-operations.js +6 -1
- package/tx/params.js +8 -1
- package/tx/provider.js +24 -6
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/sct/structures.js +8 -2
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +15 -1
- package/tx/tx.fhir.org.yml +3 -4
- package/tx/tx.js +144 -2
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/closure.js +20 -0
- package/tx/workers/expand.js +35 -8
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +138 -6
- package/utilities/make-sct-loinc-subset.js +250 -0
- package/utilities/make-sct-test-extension.js +264 -0
- package/xig/xig.js +32 -0
- package/registry/readme.md +0 -124
package/tx/cs/cs-snomed.js
CHANGED
|
@@ -31,6 +31,18 @@ const Logger = require("../../library/logger");
|
|
|
31
31
|
// shared - it simply recomputes.
|
|
32
32
|
const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Alternate identifier schemes (sct2_Identifier) that FHIRsmith knows how to present:
|
|
36
|
+
* the code system the identifiers are codes in, and the $lookup property they are
|
|
37
|
+
* returned as. An identifier in an unknown scheme is kept but not reported.
|
|
38
|
+
*/
|
|
39
|
+
const IDENTIFIER_SCHEMES = {
|
|
40
|
+
'30051010000102': { system: 'http://loinc.org', property: 'loinc' } // |LOINC code identifier| (LOINC Extension)
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** The $expand / ECL-free filter on alternate identifiers: mapped:<scheme> exists true|false */
|
|
44
|
+
const MAPPED_FILTER_PREFIX = 'mapped:';
|
|
45
|
+
|
|
34
46
|
// Description language index -> language code. The index is assigned at import
|
|
35
47
|
// time by SnomedImporter.mapLanguageCode(); this table is its inverse and must
|
|
36
48
|
// be kept in step with it.
|
|
@@ -46,6 +58,24 @@ const SCT_LANGUAGE_CODES = {
|
|
|
46
58
|
9: 'cs'
|
|
47
59
|
};
|
|
48
60
|
|
|
61
|
+
// ECL dialect aliases (ECL specification, appendix "Dialect aliases") to the
|
|
62
|
+
// language reference set they denote. Used by {{ D dialect = en-gb }}.
|
|
63
|
+
const ECL_DIALECT_ALIASES = {
|
|
64
|
+
'en-us': '900000000000509007',
|
|
65
|
+
'en-gb': '900000000000508004',
|
|
66
|
+
'en-au': '32570271000036106',
|
|
67
|
+
'en-ca': '19491000087109',
|
|
68
|
+
'en-nz': '271000210107',
|
|
69
|
+
'en-ie': '21000220103',
|
|
70
|
+
'fr-ca': '20581000087109',
|
|
71
|
+
'fr-be': '21000172104',
|
|
72
|
+
'nl-be': '31000172101',
|
|
73
|
+
'nl-nl': '31000146106',
|
|
74
|
+
'es-es': '448879004',
|
|
75
|
+
'sv-se': '46011000052107',
|
|
76
|
+
'da-dk': '554461000005103'
|
|
77
|
+
};
|
|
78
|
+
|
|
49
79
|
// Context kinds matching Pascal enum
|
|
50
80
|
const SnomedProviderContextKind = {
|
|
51
81
|
CODE: 0,
|
|
@@ -174,6 +204,47 @@ class SnomedServices {
|
|
|
174
204
|
// means "could not decide", and leaves the value unchecked.
|
|
175
205
|
this.expressionServices.eclConstraintMatcher = (constraint, value) => this.mrcmRangeSatisfied(constraint, value);
|
|
176
206
|
this._mrcmRangeCache = new Map();
|
|
207
|
+
|
|
208
|
+
this.#loadIdentifiers(sharedData.identifiers || []);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Index the alternate identifiers (sct2_Identifier rows, active only, as
|
|
213
|
+
* [scheme, module, code, conceptId]) both ways: concept -> identifiers, and
|
|
214
|
+
* scheme|code -> concepts. Also which modules carry identifiers, since each such
|
|
215
|
+
* module defines an implicit map (?fhir_cm=<module>).
|
|
216
|
+
*/
|
|
217
|
+
#loadIdentifiers(rows) {
|
|
218
|
+
this.identifiersByConcept = new Map();
|
|
219
|
+
this.conceptsByIdentifier = new Map();
|
|
220
|
+
this.identifierModules = new Map(); // module -> Set of schemes
|
|
221
|
+
for (const [scheme, module, code, conceptId] of rows) {
|
|
222
|
+
const entry = { scheme, module, code };
|
|
223
|
+
if (!this.identifiersByConcept.has(conceptId)) {
|
|
224
|
+
this.identifiersByConcept.set(conceptId, []);
|
|
225
|
+
}
|
|
226
|
+
this.identifiersByConcept.get(conceptId).push(entry);
|
|
227
|
+
const key = scheme + '|' + code;
|
|
228
|
+
if (!this.conceptsByIdentifier.has(key)) {
|
|
229
|
+
this.conceptsByIdentifier.set(key, []);
|
|
230
|
+
}
|
|
231
|
+
this.conceptsByIdentifier.get(key).push(conceptId);
|
|
232
|
+
if (!this.identifierModules.has(module)) {
|
|
233
|
+
this.identifierModules.set(module, new Set());
|
|
234
|
+
}
|
|
235
|
+
this.identifierModules.get(module).add(scheme);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The alternate identifiers of a concept (by id), optionally only those of one scheme */
|
|
240
|
+
getIdentifiers(conceptId, scheme = null) {
|
|
241
|
+
const list = this.identifiersByConcept.get(String(conceptId)) || [];
|
|
242
|
+
return scheme ? list.filter(i => i.scheme === scheme) : list;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The concept ids that have the given code in the given scheme */
|
|
246
|
+
getConceptsForIdentifier(scheme, code) {
|
|
247
|
+
return this.conceptsByIdentifier.get(scheme + '|' + code) || [];
|
|
177
248
|
}
|
|
178
249
|
|
|
179
250
|
/**
|
|
@@ -568,6 +639,122 @@ class SnomedServices {
|
|
|
568
639
|
}
|
|
569
640
|
}
|
|
570
641
|
|
|
642
|
+
/**
|
|
643
|
+
* The module this edition is published under, as a concept index (0 if it cannot be found),
|
|
644
|
+
* taken from the edition URI http://snomed.info/[x]sct/<module>[/version/<date>]
|
|
645
|
+
*/
|
|
646
|
+
editionModule() {
|
|
647
|
+
const m = /^http:\/\/snomed\.info\/x?sct\/(\d+)/.exec(this.getVersion() || '');
|
|
648
|
+
if (!m) return 0;
|
|
649
|
+
const r = this.concepts.findConcept(m[1]);
|
|
650
|
+
return r.found ? r.index : 0;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* The module dependency reference set (900000000000534007). Found by its defining concept
|
|
655
|
+
* when that is present; a subontology extract may carry the members without the concept,
|
|
656
|
+
* so the filename-derived refset name is the fallback.
|
|
657
|
+
* @returns {object|null} the reference set record
|
|
658
|
+
*/
|
|
659
|
+
moduleDependencyRefSet() {
|
|
660
|
+
if (this._mdrs !== undefined) return this._mdrs;
|
|
661
|
+
this._mdrs = null;
|
|
662
|
+
const r = this.concepts.findConcept('900000000000534007');
|
|
663
|
+
for (let i = 0; i < this.refSetIndex.count(); i++) {
|
|
664
|
+
const rs = this.refSetIndex.getReferenceSet(i);
|
|
665
|
+
if ((r.found && rs.definition === r.index) || this.strings.getEntry(rs.name).startsWith('ModuleDependency')) {
|
|
666
|
+
this._mdrs = rs;
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return this._mdrs;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* The modules that the given module depends on, per the module dependency reference set:
|
|
675
|
+
* [{module: <concept index>, sourceEffectiveTime, targetEffectiveTime}]. Only the direct
|
|
676
|
+
* dependencies - the dependencies of those modules are their own rows.
|
|
677
|
+
*/
|
|
678
|
+
moduleDependencies(moduleIndex) {
|
|
679
|
+
const rs = this.moduleDependencyRefSet();
|
|
680
|
+
if (!rs || !moduleIndex) return [];
|
|
681
|
+
const result = [];
|
|
682
|
+
for (const m of this.refSetMembers.getMembers(rs.membersByRef)) {
|
|
683
|
+
if (m.module !== moduleIndex || !m.ref) continue;
|
|
684
|
+
// values are [value, type, value, type...]; both fields here are strings
|
|
685
|
+
const vals = m.values ? this.refs.getReferences(m.values) || [] : [];
|
|
686
|
+
const str = (i) => vals.length > i && vals[i + 1] === 5 ? this.strings.getEntry(vals[i]) : null;
|
|
687
|
+
result.push({ module: m.ref, sourceEffectiveTime: str(0), targetEffectiveTime: str(2) });
|
|
688
|
+
}
|
|
689
|
+
return result;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* The concept indexes of all the modules that make up the edition published under the
|
|
694
|
+
* given module: the module itself and, transitively, everything it depends on.
|
|
695
|
+
*/
|
|
696
|
+
editionModules(moduleIndex) {
|
|
697
|
+
const result = new Set();
|
|
698
|
+
const todo = [moduleIndex];
|
|
699
|
+
while (todo.length) {
|
|
700
|
+
const m = todo.pop();
|
|
701
|
+
if (!m || result.has(m)) continue;
|
|
702
|
+
result.add(m);
|
|
703
|
+
for (const d of this.moduleDependencies(m)) todo.push(d.module);
|
|
704
|
+
}
|
|
705
|
+
return result;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Translation from this edition to another edition it depends on (see
|
|
710
|
+
* SnomedProvider.translateToEdition). The target is an edition URI,
|
|
711
|
+
* http://snomed.info/sct/<module>[/version/<date>]; the result says which module and
|
|
712
|
+
* version that is, and which modules' content belongs to it, or null if this edition
|
|
713
|
+
* does not depend on it (or not on that version of it).
|
|
714
|
+
*/
|
|
715
|
+
editionDependency(targetEdition) {
|
|
716
|
+
const m = /^http:\/\/snomed\.info\/x?sct\/(\d+)(?:\/version\/(\d{8}))?$/.exec(targetEdition || '');
|
|
717
|
+
if (!m) return null;
|
|
718
|
+
const mine = this.editionModule();
|
|
719
|
+
const target = this.concepts.findConcept(m[1]);
|
|
720
|
+
if (!mine || !target.found) return null;
|
|
721
|
+
for (const d of this.moduleDependencies(mine)) {
|
|
722
|
+
if (d.module === target.index && (!m[2] || m[2] === d.targetEffectiveTime)) {
|
|
723
|
+
const version = d.targetEffectiveTime ? `http://snomed.info/sct/${m[1]}/version/${d.targetEffectiveTime}` : `http://snomed.info/sct/${m[1]}`;
|
|
724
|
+
return { moduleId: m[1], moduleIndex: target.index, version, modules: this.editionModules(target.index) };
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* The concept whose normal form is the given expression, if there is one. The index is
|
|
732
|
+
* built on first use: every defined concept's stored normal form, rendered in the minimal
|
|
733
|
+
* form so that two expressions compare as strings.
|
|
734
|
+
* @param {string} rendered - the expression, rendered with SnomedServicesRenderOption.Minimal
|
|
735
|
+
* @returns {number} the concept index, or -1
|
|
736
|
+
*/
|
|
737
|
+
conceptWithNormalForm(rendered) {
|
|
738
|
+
if (!this._normalFormIndex) {
|
|
739
|
+
const index = new Map();
|
|
740
|
+
const total = this.concepts.count();
|
|
741
|
+
for (let ord = 0; ord < total; ord++) {
|
|
742
|
+
const c = this.concepts.getConceptByCount(ord);
|
|
743
|
+
if ((c.flags & 0x0F) !== 0 || this.concepts.getNormalForm(c.index) === 0) continue;
|
|
744
|
+
try {
|
|
745
|
+
const nf = this.expressionServices.createNormalForm(c.index);
|
|
746
|
+
const key = this.expressionServices.renderExpression(nf, SnomedServicesRenderOption.Minimal);
|
|
747
|
+
if (!index.has(key)) index.set(key, c.index);
|
|
748
|
+
} catch (e) {
|
|
749
|
+
// a normal form that cannot be built is simply not indexed
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
this._normalFormIndex = index;
|
|
753
|
+
}
|
|
754
|
+
const r = this._normalFormIndex.get(rendered);
|
|
755
|
+
return r === undefined ? -1 : r;
|
|
756
|
+
}
|
|
757
|
+
|
|
571
758
|
getConceptRefSet(conceptIndex, byName = false) {
|
|
572
759
|
for (let i = 0; i < this.refSetIndex.count(); i++) {
|
|
573
760
|
const refSet = this.refSetIndex.getReferenceSet(i);
|
|
@@ -583,13 +770,8 @@ class SnomedServices {
|
|
|
583
770
|
// members" (returns a numeric membersByRef, which may be 0 or the
|
|
584
771
|
// MAGIC_NO_CHILDREN sentinel). Used by memberOf to reject non-refset operands.
|
|
585
772
|
_findRefSetMembersRef(conceptIndex) {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
if (refSet.definition === conceptIndex) {
|
|
589
|
-
return refSet.membersByRef;
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
return null;
|
|
773
|
+
const refSet = this._findRefSetRecord(conceptIndex);
|
|
774
|
+
return refSet ? refSet.membersByRef : null;
|
|
593
775
|
}
|
|
594
776
|
|
|
595
777
|
// Filter support methods
|
|
@@ -724,12 +906,21 @@ class SnomedServices {
|
|
|
724
906
|
* >> (ancestor-or-self-of)
|
|
725
907
|
* >! (strict ancestor-of)
|
|
726
908
|
* > (parent-of)
|
|
727
|
-
* ^ (member-of refset) —
|
|
909
|
+
* ^ (member-of refset) — a concept or any bracketed expression, with
|
|
910
|
+
* {{ M ... }} member filters
|
|
728
911
|
* * (wildcard)
|
|
912
|
+
* hierarchy operators on *, ^ and bracketed sets (applied per member)
|
|
729
913
|
* AND / OR / MINUS compound expressions
|
|
914
|
+
* !!> / !!< (top / bottom of set)
|
|
915
|
+
* refinements (= against an expression, attribute AND/OR/",", groups,
|
|
916
|
+
* attribute and group cardinality, attribute names that are expressions),
|
|
917
|
+
* dotted expressions, {{ C ... }} concept filters, {{ D ... }} description
|
|
918
|
+
* filters, comments
|
|
730
919
|
*
|
|
731
|
-
* Everything else (
|
|
732
|
-
*
|
|
920
|
+
* Everything else (reverse attributes, != and concrete (numeric/string/
|
|
921
|
+
* boolean) values in refinements, alternate identifiers, reference set field
|
|
922
|
+
* selection on ^, {{ + ... }} history supplements) throws an informative
|
|
923
|
+
* error which is reported as UNSUPPORTED_ECL.
|
|
733
924
|
*/
|
|
734
925
|
|
|
735
926
|
/**
|
|
@@ -760,8 +951,11 @@ class SnomedServices {
|
|
|
760
951
|
// be valid) from an *unsupported* one (a well-formed expression using a
|
|
761
952
|
// feature this server does not implement). The tx-ecosystem test suite
|
|
762
953
|
// expects INVALID_ECL for the former, UNSUPPORTED_ECL for the latter.
|
|
954
|
+
// The parser has accepted the expression by now, so an evaluation error
|
|
955
|
+
// means an unsupported feature - unless it is explicitly about a concept
|
|
956
|
+
// that does not exist (err.eclInvalid, or the legacy "is not known" text).
|
|
763
957
|
const emsg = (err && err.message) ? err.message : String(err);
|
|
764
|
-
const invalid =
|
|
958
|
+
const invalid = (err && err.eclInvalid) || /is not known/.test(emsg);
|
|
765
959
|
const msgId = invalid ? 'INVALID_ECL' : 'UNSUPPORTED_ECL';
|
|
766
960
|
throw new Issue('error', 'invalid', null, msgId, opContext.i18n.translate(msgId, opContext.langs, [eclExpression, err.message]), 'vs-invalid').handleAsOO(400);
|
|
767
961
|
}
|
|
@@ -870,20 +1064,35 @@ class SnomedServices {
|
|
|
870
1064
|
const operator = node.operator; // an ECLTokenType string, or null
|
|
871
1065
|
const focus = node.focus;
|
|
872
1066
|
|
|
873
|
-
//
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1067
|
+
// History supplements are parsed but not implemented. Member filters
|
|
1068
|
+
// ({{ M ... }}) are handled in _evalMemberOf; concept filters ({{ C ... }})
|
|
1069
|
+
// and description filters ({{ D ... }}) are applied to the result below.
|
|
1070
|
+
if (node.filters && node.filters.length > 0) {
|
|
1071
|
+
if (node.filters.some(fc => fc.kind === '+')) {
|
|
1072
|
+
throw new Error('ECL history supplements ({{ + ... }}) are not supported');
|
|
877
1073
|
}
|
|
878
|
-
|
|
1074
|
+
const inner = { ...node, filters: undefined };
|
|
1075
|
+
let ctx = this._evalSubExpression(inner, opContext);
|
|
1076
|
+
const conceptFilters = node.filters.filter(fc => fc.kind === 'C');
|
|
1077
|
+
if (conceptFilters.length > 0) {
|
|
1078
|
+
ctx = this._applyConceptFilters(ctx, conceptFilters, opContext);
|
|
1079
|
+
}
|
|
1080
|
+
for (const fc of node.filters.filter(fc => fc.kind === 'D')) {
|
|
1081
|
+
ctx = this._applyDescriptionFilter(ctx, fc.filters, opContext);
|
|
1082
|
+
}
|
|
1083
|
+
return ctx;
|
|
879
1084
|
}
|
|
880
1085
|
|
|
881
|
-
//
|
|
882
|
-
if (focus.type === ECLNodeType.
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1086
|
+
// Alternate identifiers (ECL 2.1 "scheme#code")
|
|
1087
|
+
if (focus.type === ECLNodeType.ALT_IDENTIFIER) {
|
|
1088
|
+
throw new Error(`ECL alternate identifiers (${focus.scheme}#${focus.code}) are not supported - no alternate identifier reference sets are loaded`);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Top / bottom of set: the members of the focus set with no ancestor
|
|
1092
|
+
// (respectively descendant) in that set
|
|
1093
|
+
if (operator === ECLTokenType.TOP_OF_SET || operator === ECLTokenType.BOTTOM_OF_SET) {
|
|
1094
|
+
const set = this._eclResolveSet(this._evalECLNode(focus, opContext), opContext);
|
|
1095
|
+
return this._eclTopOrBottom(set, operator === ECLTokenType.TOP_OF_SET, opContext);
|
|
887
1096
|
}
|
|
888
1097
|
|
|
889
1098
|
// Plain concept reference
|
|
@@ -891,8 +1100,119 @@ class SnomedServices {
|
|
|
891
1100
|
return this._evalConceptWithOperator(focus.conceptId, operator, opContext);
|
|
892
1101
|
}
|
|
893
1102
|
|
|
894
|
-
//
|
|
895
|
-
|
|
1103
|
+
// Wildcard, member-of, or a parenthesised sub-expression: evaluate the
|
|
1104
|
+
// focus to a set, then apply the operator to each member and union the
|
|
1105
|
+
// results ("< (A OR B)" is "< A OR < B"; "<< ^ refset" is the descendants
|
|
1106
|
+
// of every member). Without an operator the set is the answer.
|
|
1107
|
+
let base;
|
|
1108
|
+
if (focus.type === ECLNodeType.WILDCARD) {
|
|
1109
|
+
base = this._eclWildcard();
|
|
1110
|
+
} else if (focus.type === ECLNodeType.MEMBER_OF) {
|
|
1111
|
+
base = this._evalMemberOf(focus, opContext);
|
|
1112
|
+
} else {
|
|
1113
|
+
base = this._evalECLNode(focus, opContext);
|
|
1114
|
+
}
|
|
1115
|
+
if (!operator) {
|
|
1116
|
+
return base;
|
|
1117
|
+
}
|
|
1118
|
+
return this._eclApplyOperatorToSet(base, operator, opContext);
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Apply a hierarchy operator to every concept in a set and union the results.
|
|
1123
|
+
* The wildcard is special-cased: its self-inclusive forms are the wildcard
|
|
1124
|
+
* again, and the others are "every concept that has a parent / child".
|
|
1125
|
+
*/
|
|
1126
|
+
_eclApplyOperatorToSet = function (ctx, operator, opContext) {
|
|
1127
|
+
if (ctx.eclWildcard && (!ctx.descendants || ctx.descendants.length === 0)) {
|
|
1128
|
+
switch (operator) {
|
|
1129
|
+
case ECLTokenType.DESCENDANT_OR_SELF_OF:
|
|
1130
|
+
case ECLTokenType.ANCESTOR_OR_SELF_OF:
|
|
1131
|
+
case ECLTokenType.CHILD_OR_SELF_OF:
|
|
1132
|
+
case ECLTokenType.PARENT_OR_SELF_OF:
|
|
1133
|
+
return this._eclWildcard();
|
|
1134
|
+
case ECLTokenType.DESCENDANT_OF:
|
|
1135
|
+
case ECLTokenType.CHILD_OF: {
|
|
1136
|
+
const result = new SnomedFilterContext();
|
|
1137
|
+
result.descendants = this._eclEnumerateAllConcepts(opContext).filter(idx => this.getConceptParents(idx).length > 0);
|
|
1138
|
+
return result;
|
|
1139
|
+
}
|
|
1140
|
+
case ECLTokenType.ANCESTOR_OF:
|
|
1141
|
+
case ECLTokenType.PARENT_OF: {
|
|
1142
|
+
const result = new SnomedFilterContext();
|
|
1143
|
+
result.descendants = this._eclEnumerateAllConcepts(opContext).filter(idx => this.getConceptChildren(idx).length > 0);
|
|
1144
|
+
return result;
|
|
1145
|
+
}
|
|
1146
|
+
default:
|
|
1147
|
+
throw new Error(`Unsupported ECL hierarchy operator: ${operator}`);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
const union = new Set();
|
|
1151
|
+
for (const idx of this._eclToIndexArray(ctx)) {
|
|
1152
|
+
if (opContext) opContext.deadCheck('ecl:operatorOnSet');
|
|
1153
|
+
const part = this._evalConceptWithOperator(this.concepts.getConceptId(idx), operator, opContext);
|
|
1154
|
+
for (const r of this._eclToIndexArray(part)) {
|
|
1155
|
+
union.add(r);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
const result = new SnomedFilterContext();
|
|
1159
|
+
result.descendants = [...union];
|
|
1160
|
+
return result;
|
|
1161
|
+
};
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* !!> / !!< : keep the concepts in `set` that have no ancestor (top) or no
|
|
1165
|
+
* descendant (bottom) that is also in `set`. Walks the stated parents of each
|
|
1166
|
+
* member within the set, so the cost is bounded by the set's own size.
|
|
1167
|
+
*/
|
|
1168
|
+
_eclTopOrBottom = function (set, top, opContext) {
|
|
1169
|
+
const members = new Set(set);
|
|
1170
|
+
// a member is "covered" (not top) when some ancestor is in the set; for
|
|
1171
|
+
// bottom, the ancestor is the one that is covered
|
|
1172
|
+
const covered = new Set();
|
|
1173
|
+
for (const idx of members) {
|
|
1174
|
+
if (opContext) opContext.deadCheck('ecl:topBottom');
|
|
1175
|
+
const seen = new Set();
|
|
1176
|
+
const stack = [...this.getConceptParents(idx)];
|
|
1177
|
+
while (stack.length > 0) {
|
|
1178
|
+
const p = stack.pop();
|
|
1179
|
+
if (seen.has(p)) continue;
|
|
1180
|
+
seen.add(p);
|
|
1181
|
+
if (members.has(p)) {
|
|
1182
|
+
covered.add(top ? idx : p);
|
|
1183
|
+
if (top) break;
|
|
1184
|
+
}
|
|
1185
|
+
stack.push(...this.getConceptParents(p));
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
const result = new SnomedFilterContext();
|
|
1189
|
+
result.descendants = [...members].filter(idx => !covered.has(idx));
|
|
1190
|
+
return result;
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Resolve an attribute name (the left side of a refinement or a dotted step)
|
|
1195
|
+
* to the set of relationship type indexes it denotes: a bare concept gives one
|
|
1196
|
+
* index, `*` any type, and an expression (<< 762705008, (A OR B), ^ refset)
|
|
1197
|
+
* whatever it evaluates to.
|
|
1198
|
+
* @returns {{any: boolean, types: Set<number>|null}}
|
|
1199
|
+
*/
|
|
1200
|
+
_eclAttributeTypes = function (nameNode, opContext) {
|
|
1201
|
+
if (nameNode.type === ECLNodeType.WILDCARD) {
|
|
1202
|
+
return { any: true, types: null };
|
|
1203
|
+
}
|
|
1204
|
+
if (nameNode.type === ECLNodeType.CONCEPT_REFERENCE) {
|
|
1205
|
+
const attrResult = this.concepts.findConcept(nameNode.conceptId);
|
|
1206
|
+
if (!attrResult.found) {
|
|
1207
|
+
throw new Error(`The SNOMED CT Concept ${nameNode.conceptId} is not known`);
|
|
1208
|
+
}
|
|
1209
|
+
return { any: false, types: new Set([attrResult.index]) };
|
|
1210
|
+
}
|
|
1211
|
+
const ctx = this._evalECLNode(nameNode, opContext);
|
|
1212
|
+
if (ctx.eclWildcard && (!ctx.descendants || ctx.descendants.length === 0)) {
|
|
1213
|
+
return { any: true, types: null };
|
|
1214
|
+
}
|
|
1215
|
+
return { any: false, types: new Set(this._eclToIndexArray(ctx)) };
|
|
896
1216
|
};
|
|
897
1217
|
|
|
898
1218
|
/**
|
|
@@ -988,12 +1308,21 @@ class SnomedServices {
|
|
|
988
1308
|
* @returns {SnomedFilterContext}
|
|
989
1309
|
*/
|
|
990
1310
|
_evalMemberOf = function (memberOfNode, opContext) {
|
|
1311
|
+
// ^ [field, ...] refset selects which field of the member rows to return
|
|
1312
|
+
// instead of the referenced component. Not supported: the referenced
|
|
1313
|
+
// components, and the fields, may be descriptions or other non-concept
|
|
1314
|
+
// components, which an expansion cannot carry.
|
|
1315
|
+
if (memberOfNode.fields) {
|
|
1316
|
+
throw new Error(`ECL reference set field selection (^ [${memberOfNode.fields.join(', ')}] ...) is not supported`);
|
|
1317
|
+
}
|
|
991
1318
|
const refsetConcepts = this._eclResolveSet(this._evalECLNode(memberOfNode.refSet, opContext), opContext);
|
|
1319
|
+
const memberFilters = this._eclPrepareMemberFilters(memberOfNode.memberFilters, opContext);
|
|
992
1320
|
|
|
993
1321
|
const members = new Set();
|
|
994
1322
|
for (const refsetIdx of refsetConcepts) {
|
|
995
1323
|
if (opContext) opContext.deadCheck('ecl:memberOf');
|
|
996
|
-
const
|
|
1324
|
+
const refSet = this._findRefSetRecord(refsetIdx);
|
|
1325
|
+
const membersRef = refSet ? refSet.membersByRef : null;
|
|
997
1326
|
if (membersRef === null) {
|
|
998
1327
|
// Operand concept is not a reference set: yield no members for it
|
|
999
1328
|
// (empty result) rather than erroring. Applies to both a bare concept
|
|
@@ -1003,15 +1332,21 @@ class SnomedServices {
|
|
|
1003
1332
|
if (membersRef === 0 || membersRef === 0xFFFFFFFF) {
|
|
1004
1333
|
continue; // a reference set with no members
|
|
1005
1334
|
}
|
|
1006
|
-
const memberList = this.refSetMembers.getMembers(membersRef);
|
|
1007
|
-
|
|
1335
|
+
const memberList = this.refSetMembers.getMembers(membersRef) || [];
|
|
1336
|
+
// A reference set whose members are descriptions (a language reference
|
|
1337
|
+
// set, say) has no concept members to return. Say so rather than
|
|
1338
|
+
// answering with an empty set, which would read as "no members".
|
|
1339
|
+
if (memberList.length > 0 && !memberList.some(m => m.kind === 0)) {
|
|
1340
|
+
throw new Error(`The members of reference set ${this.concepts.getConceptId(refsetIdx)} are descriptions, not concepts, so ^ cannot be evaluated for it`);
|
|
1341
|
+
}
|
|
1342
|
+
for (const m of memberList) {
|
|
1008
1343
|
// Concept referenced components only (kind 0). Description/other members
|
|
1009
1344
|
// are excluded, which also prevents the no-component sentinel (0xFFFFFFFF)
|
|
1010
1345
|
// from leaking in. The referenced concept may itself be INACTIVE and is
|
|
1011
1346
|
// still returned — "active" in the spec qualifies the membership row (and
|
|
1012
1347
|
// inactive rows are already dropped at import), not the referenced
|
|
1013
1348
|
// concept. The expansion marks/handles inactivity (e.g. activeOnly).
|
|
1014
|
-
if (m.kind === 0) {
|
|
1349
|
+
if (m.kind === 0 && this._eclMemberMatches(m, refSet, memberFilters)) {
|
|
1015
1350
|
members.add(m.ref);
|
|
1016
1351
|
}
|
|
1017
1352
|
}
|
|
@@ -1021,6 +1356,380 @@ class SnomedServices {
|
|
|
1021
1356
|
return result;
|
|
1022
1357
|
};
|
|
1023
1358
|
|
|
1359
|
+
/**
|
|
1360
|
+
* Apply {{ C ... }} concept filters to a result: each concept is tested on
|
|
1361
|
+
* its own active flag, module, effectiveTime and definition status. Several
|
|
1362
|
+
* filter constraints, and the comma-separated filters within one, all AND.
|
|
1363
|
+
*/
|
|
1364
|
+
_applyConceptFilters = function (ctx, filterConstraints, opContext) {
|
|
1365
|
+
const filters = [];
|
|
1366
|
+
for (const fc of filterConstraints) {
|
|
1367
|
+
for (const f of fc.filters) {
|
|
1368
|
+
if (f.valueType === 'expression') {
|
|
1369
|
+
const vctx = this._evalECLNode(f.value, opContext);
|
|
1370
|
+
if (vctx.eclWildcard && (!vctx.descendants || vctx.descendants.length === 0)) {
|
|
1371
|
+
filters.push({ ...f, wildcard: true });
|
|
1372
|
+
} else {
|
|
1373
|
+
filters.push({ ...f, set: new Set(this._eclToIndexArray(vctx)) });
|
|
1374
|
+
}
|
|
1375
|
+
} else {
|
|
1376
|
+
filters.push(f);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
// The definition status concepts, if this edition has them
|
|
1381
|
+
const statusIndex = (id) => {
|
|
1382
|
+
const r = this.concepts.findConcept(id);
|
|
1383
|
+
return r.found ? r.index : null;
|
|
1384
|
+
};
|
|
1385
|
+
const primitiveIdx = statusIndex('900000000000074008');
|
|
1386
|
+
const definedIdx = statusIndex('900000000000073002');
|
|
1387
|
+
|
|
1388
|
+
const matching = [];
|
|
1389
|
+
for (const idx of this._eclResolveSet(ctx, opContext)) {
|
|
1390
|
+
if (opContext) opContext.deadCheck('ecl:conceptFilter');
|
|
1391
|
+
let ok = true;
|
|
1392
|
+
for (const f of filters) {
|
|
1393
|
+
const field = f.field.toLowerCase();
|
|
1394
|
+
let r;
|
|
1395
|
+
if (field === 'active') {
|
|
1396
|
+
r = this.isActive(idx) === f.value;
|
|
1397
|
+
if (f.operator === '!=') r = !r;
|
|
1398
|
+
} else if (field === 'effectivetime') {
|
|
1399
|
+
const concept = this.concepts.getConcept(idx);
|
|
1400
|
+
r = this._eclCompareValues(this._snomedDateToString(concept.effectiveTime), f.operator, f.values, (a, b) => a.localeCompare(b));
|
|
1401
|
+
} else if (field === 'moduleid') {
|
|
1402
|
+
const inSet = f.wildcard || f.set.has(this.concepts.getModuleId(idx));
|
|
1403
|
+
r = f.operator === '=' ? inSet : !inSet;
|
|
1404
|
+
} else if (field === 'definitionstatusid') {
|
|
1405
|
+
const statusIdx = this.isPrimitive(idx) ? primitiveIdx : definedIdx;
|
|
1406
|
+
const inSet = f.wildcard || (statusIdx !== null && f.set.has(statusIdx));
|
|
1407
|
+
r = f.operator === '=' ? inSet : !inSet;
|
|
1408
|
+
} else if (field === 'definitionstatus') {
|
|
1409
|
+
const status = this.isPrimitive(idx) ? 'primitive' : 'defined';
|
|
1410
|
+
const inSet = f.values.includes(status);
|
|
1411
|
+
r = f.operator === '=' ? inSet : !inSet;
|
|
1412
|
+
} else {
|
|
1413
|
+
throw new Error(`Unsupported concept filter ${f.field}`);
|
|
1414
|
+
}
|
|
1415
|
+
if (!r) {
|
|
1416
|
+
ok = false;
|
|
1417
|
+
break;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
if (ok) {
|
|
1421
|
+
matching.push(idx);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
const result = new SnomedFilterContext();
|
|
1425
|
+
result.descendants = matching;
|
|
1426
|
+
return result;
|
|
1427
|
+
};
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Apply one {{ D ... }} block: a concept is kept when at least one of its
|
|
1431
|
+
* descriptions satisfies every filter in the block. Only active descriptions
|
|
1432
|
+
* are considered unless the block has an `active` filter. Several blocks
|
|
1433
|
+
* AND at the concept level (each may be satisfied by a different description).
|
|
1434
|
+
*/
|
|
1435
|
+
_applyDescriptionFilter = function (ctx, filters, opContext) {
|
|
1436
|
+
const K = this._displayConstants();
|
|
1437
|
+
const idx = (id) => { const r = this.concepts.findConcept(id); return r.found ? r.index : -1; };
|
|
1438
|
+
const typeTokens = { syn: K.synonym, fsn: K.fsn, def: idx('900000000000550004') };
|
|
1439
|
+
const acceptabilityIdx = { preferred: K.preferred, acceptable: idx('900000000000549004') };
|
|
1440
|
+
const resolveSet = (expr) => {
|
|
1441
|
+
const vctx = this._evalECLNode(expr, opContext);
|
|
1442
|
+
if (vctx.eclWildcard && (!vctx.descendants || vctx.descendants.length === 0)) return null; // any
|
|
1443
|
+
return new Set(this._eclToIndexArray(vctx));
|
|
1444
|
+
};
|
|
1445
|
+
let activeFilter = null;
|
|
1446
|
+
const prepared = filters.map(f => {
|
|
1447
|
+
const field = f.field.toLowerCase();
|
|
1448
|
+
switch (f.valueType) {
|
|
1449
|
+
case 'term':
|
|
1450
|
+
return { ...f, field, matchers: f.values.map(v => this._eclTermMatcher(v)) };
|
|
1451
|
+
case 'expression':
|
|
1452
|
+
return { ...f, field, set: resolveSet(f.value) };
|
|
1453
|
+
case 'type':
|
|
1454
|
+
return { ...f, field, set: new Set(f.values.map(t => typeTokens[t]).filter(i => i >= 0)) };
|
|
1455
|
+
case 'dialectId':
|
|
1456
|
+
case 'dialect':
|
|
1457
|
+
return { ...f, field, dialects: f.dialects.map(d => {
|
|
1458
|
+
let set;
|
|
1459
|
+
if (d.alias) {
|
|
1460
|
+
const id = ECL_DIALECT_ALIASES[d.alias];
|
|
1461
|
+
if (!id) throw new Error(`Unknown dialect alias ${d.alias} in description filter`);
|
|
1462
|
+
const i = idx(id);
|
|
1463
|
+
set = new Set(i >= 0 ? [i] : []);
|
|
1464
|
+
} else {
|
|
1465
|
+
set = resolveSet(d.expression);
|
|
1466
|
+
}
|
|
1467
|
+
const acceptability = d.acceptability
|
|
1468
|
+
? new Set(d.acceptability.map(a => acceptabilityIdx[a] !== undefined ? acceptabilityIdx[a] : idx(a)))
|
|
1469
|
+
: null;
|
|
1470
|
+
return { set, acceptability };
|
|
1471
|
+
}) };
|
|
1472
|
+
case 'boolean':
|
|
1473
|
+
activeFilter = f;
|
|
1474
|
+
return { ...f, field };
|
|
1475
|
+
case 'id':
|
|
1476
|
+
return { ...f, field, ids: new Set(f.values.map(v => BigInt(v))) };
|
|
1477
|
+
default:
|
|
1478
|
+
return { ...f, field };
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
const matching = [];
|
|
1483
|
+
for (const conceptIdx of this._eclResolveSet(ctx, opContext)) {
|
|
1484
|
+
if (opContext) opContext.deadCheck('ecl:descriptionFilter');
|
|
1485
|
+
const concept = this.concepts.getConcept(conceptIdx);
|
|
1486
|
+
const descIndices = concept.descriptions ? (this.refs.getReferences(concept.descriptions) || []) : [];
|
|
1487
|
+
let found = false;
|
|
1488
|
+
for (const descIndex of descIndices) {
|
|
1489
|
+
const d = this.descriptions.getDescription(descIndex);
|
|
1490
|
+
if (!activeFilter && !d.active) continue;
|
|
1491
|
+
if (prepared.every(f => this._eclDescriptionMatches(d, f))) {
|
|
1492
|
+
found = true;
|
|
1493
|
+
break;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
if (found) matching.push(conceptIdx);
|
|
1497
|
+
}
|
|
1498
|
+
const result = new SnomedFilterContext();
|
|
1499
|
+
result.descendants = matching;
|
|
1500
|
+
return result;
|
|
1501
|
+
};
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* Does one description satisfy one prepared description filter?
|
|
1505
|
+
*/
|
|
1506
|
+
_eclDescriptionMatches = function (d, f) {
|
|
1507
|
+
const neg = f.operator === '!=';
|
|
1508
|
+
const eq = (r) => neg ? !r : r;
|
|
1509
|
+
switch (f.field) {
|
|
1510
|
+
case 'term':
|
|
1511
|
+
return eq(f.matchers.some(m => m(this.strings.getEntry(d.iDesc))));
|
|
1512
|
+
case 'language':
|
|
1513
|
+
return eq(f.values.includes(SCT_LANGUAGE_CODES[d.lang] || 'en'));
|
|
1514
|
+
case 'typeid':
|
|
1515
|
+
return eq(f.set === null || f.set.has(d.kind));
|
|
1516
|
+
case 'type':
|
|
1517
|
+
return eq(f.set.has(d.kind));
|
|
1518
|
+
case 'moduleid':
|
|
1519
|
+
return eq(f.set === null || f.set.has(d.module));
|
|
1520
|
+
case 'dialectid':
|
|
1521
|
+
case 'dialect': {
|
|
1522
|
+
const refsets = d.refsets ? (this.refs.getReferences(d.refsets) || []) : [];
|
|
1523
|
+
const inDialect = f.dialects.some(dl => refsets.some(r => {
|
|
1524
|
+
if (dl.set !== null && !dl.set.has(r)) return false;
|
|
1525
|
+
if (!dl.acceptability) return true;
|
|
1526
|
+
return dl.acceptability.has(this._descAcceptability(d, r));
|
|
1527
|
+
}));
|
|
1528
|
+
return eq(inDialect);
|
|
1529
|
+
}
|
|
1530
|
+
case 'effectivetime':
|
|
1531
|
+
return this._eclCompareValues(this._snomedDateToString(d.date), f.operator, f.values, (a, b) => a.localeCompare(b));
|
|
1532
|
+
case 'active':
|
|
1533
|
+
return eq(d.active === f.value);
|
|
1534
|
+
case 'id':
|
|
1535
|
+
return eq(f.ids.has(d.id));
|
|
1536
|
+
default:
|
|
1537
|
+
throw new Error(`Unsupported description filter ${f.field}`);
|
|
1538
|
+
}
|
|
1539
|
+
};
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* A matcher for one typed search term. "match" semantics (the default): every
|
|
1543
|
+
* word of the search term is a prefix of some word of the description, case
|
|
1544
|
+
* insensitively. "wild" semantics: the whole description matches the pattern,
|
|
1545
|
+
* with * standing for any characters.
|
|
1546
|
+
*/
|
|
1547
|
+
_eclTermMatcher = function (v) {
|
|
1548
|
+
if (v.kind === 'wild') {
|
|
1549
|
+
const re = new RegExp('^' + v.value.split('*').map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$', 'i');
|
|
1550
|
+
return (term) => re.test(term);
|
|
1551
|
+
}
|
|
1552
|
+
const words = v.value.toLowerCase().split(/\s+/).filter(w => w.length > 0);
|
|
1553
|
+
return (term) => {
|
|
1554
|
+
const termWords = term.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(w => w.length > 0);
|
|
1555
|
+
return words.every(w => termWords.some(tw => tw.startsWith(w)));
|
|
1556
|
+
};
|
|
1557
|
+
};
|
|
1558
|
+
|
|
1559
|
+
/**
|
|
1560
|
+
* Resolve the expression-valued member filters (moduleId = ..., <conceptField> = ...)
|
|
1561
|
+
* once, before the members are walked. Returns the filters with a `set` of
|
|
1562
|
+
* concept indexes attached (or `wildcard: true`), or null when there are none.
|
|
1563
|
+
*/
|
|
1564
|
+
_eclPrepareMemberFilters = function (filters, opContext) {
|
|
1565
|
+
if (!filters || filters.length === 0) {
|
|
1566
|
+
return null;
|
|
1567
|
+
}
|
|
1568
|
+
return filters.map(f => {
|
|
1569
|
+
if (f.valueType !== 'expression') {
|
|
1570
|
+
return f;
|
|
1571
|
+
}
|
|
1572
|
+
const ctx = this._evalECLNode(f.value, opContext);
|
|
1573
|
+
if (ctx.eclWildcard && (!ctx.descendants || ctx.descendants.length === 0)) {
|
|
1574
|
+
return { ...f, wildcard: true };
|
|
1575
|
+
}
|
|
1576
|
+
return { ...f, set: new Set(this._eclToIndexArray(ctx)) };
|
|
1577
|
+
});
|
|
1578
|
+
};
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* Does a reference set member row satisfy every member filter?
|
|
1582
|
+
*
|
|
1583
|
+
* Member rows are only stored when active, so `active = false` matches
|
|
1584
|
+
* nothing. effectiveTime and moduleId come from the row itself (present when
|
|
1585
|
+
* the reference set stores ids); any other field name is looked up in the
|
|
1586
|
+
* reference set's additional fields (e.g. targetComponentId, mapTarget,
|
|
1587
|
+
* acceptabilityId), typed by the refset file name (c / i / s).
|
|
1588
|
+
*/
|
|
1589
|
+
_eclMemberMatches = function (m, refSet, filters) {
|
|
1590
|
+
if (!filters) {
|
|
1591
|
+
return true;
|
|
1592
|
+
}
|
|
1593
|
+
for (const f of filters) {
|
|
1594
|
+
const field = f.field.toLowerCase();
|
|
1595
|
+
let ok;
|
|
1596
|
+
if (field === 'active') {
|
|
1597
|
+
ok = (f.operator === '=') === f.value;
|
|
1598
|
+
} else if (field === 'effectivetime') {
|
|
1599
|
+
if (m.date == null) {
|
|
1600
|
+
return false;
|
|
1601
|
+
}
|
|
1602
|
+
ok = this._eclCompareValues(this._snomedDateToString(m.date), f.operator, f.values, (a, b) => a.localeCompare(b));
|
|
1603
|
+
} else if (field === 'moduleid') {
|
|
1604
|
+
if (m.module == null) {
|
|
1605
|
+
return false;
|
|
1606
|
+
}
|
|
1607
|
+
const inSet = f.wildcard || f.set.has(m.module);
|
|
1608
|
+
ok = f.operator === '=' ? inSet : !inSet;
|
|
1609
|
+
} else {
|
|
1610
|
+
const fv = this._eclMemberFieldValue(m, refSet, f.field);
|
|
1611
|
+
if (fv === undefined) {
|
|
1612
|
+
throw new Error(`Reference set ${this.concepts.getConceptId(refSet.definition)} has no field named ${f.field}`);
|
|
1613
|
+
}
|
|
1614
|
+
if (fv === null) {
|
|
1615
|
+
return false;
|
|
1616
|
+
}
|
|
1617
|
+
switch (f.valueType) {
|
|
1618
|
+
case 'expression': {
|
|
1619
|
+
if (fv.type !== 'concept') {
|
|
1620
|
+
throw new Error(`Reference set field ${f.field} is not a concept field`);
|
|
1621
|
+
}
|
|
1622
|
+
const inSet = f.wildcard || f.set.has(fv.value);
|
|
1623
|
+
ok = f.operator === '=' ? inSet : !inSet;
|
|
1624
|
+
break;
|
|
1625
|
+
}
|
|
1626
|
+
case 'numeric': {
|
|
1627
|
+
if (fv.type !== 'integer') {
|
|
1628
|
+
throw new Error(`Reference set field ${f.field} is not a numeric field`);
|
|
1629
|
+
}
|
|
1630
|
+
ok = this._eclCompareValues(Number(fv.value), f.operator, [Number(f.value)], (a, b) => a - b);
|
|
1631
|
+
break;
|
|
1632
|
+
}
|
|
1633
|
+
case 'string': {
|
|
1634
|
+
if (fv.type !== 'string') {
|
|
1635
|
+
throw new Error(`Reference set field ${f.field} is not a string field`);
|
|
1636
|
+
}
|
|
1637
|
+
ok = this._eclCompareValues(fv.value, f.operator, f.values, (a, b) => a.localeCompare(b));
|
|
1638
|
+
break;
|
|
1639
|
+
}
|
|
1640
|
+
case 'time': {
|
|
1641
|
+
if (fv.type !== 'string') {
|
|
1642
|
+
throw new Error(`Reference set field ${f.field} is not a time field`);
|
|
1643
|
+
}
|
|
1644
|
+
ok = this._eclCompareValues(fv.value, f.operator, f.values, (a, b) => a.localeCompare(b));
|
|
1645
|
+
break;
|
|
1646
|
+
}
|
|
1647
|
+
case 'boolean': {
|
|
1648
|
+
if (fv.type !== 'string' && fv.type !== 'integer') {
|
|
1649
|
+
throw new Error(`Reference set field ${f.field} is not a boolean field`);
|
|
1650
|
+
}
|
|
1651
|
+
const b = String(fv.value) === '1' || String(fv.value).toLowerCase() === 'true';
|
|
1652
|
+
ok = (f.operator === '=') === (b === f.value);
|
|
1653
|
+
break;
|
|
1654
|
+
}
|
|
1655
|
+
default:
|
|
1656
|
+
throw new Error(`Unsupported member filter on ${f.field}`);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
if (!ok) {
|
|
1660
|
+
return false;
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
return true;
|
|
1664
|
+
};
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* Compare one value against a value set with an ECL comparison operator.
|
|
1668
|
+
* "=" is true when any set value matches, "!=" when none does; the ordering
|
|
1669
|
+
* operators compare against the single value.
|
|
1670
|
+
*/
|
|
1671
|
+
_eclCompareValues = function (actual, operator, values, cmp) {
|
|
1672
|
+
switch (operator) {
|
|
1673
|
+
case '=': return values.some(v => cmp(actual, v) === 0);
|
|
1674
|
+
case '!=': return !values.some(v => cmp(actual, v) === 0);
|
|
1675
|
+
case '<': return cmp(actual, values[0]) < 0;
|
|
1676
|
+
case '<=': return cmp(actual, values[0]) <= 0;
|
|
1677
|
+
case '>': return cmp(actual, values[0]) > 0;
|
|
1678
|
+
case '>=': return cmp(actual, values[0]) >= 0;
|
|
1679
|
+
default: throw new Error(`Unsupported comparison operator ${operator}`);
|
|
1680
|
+
}
|
|
1681
|
+
};
|
|
1682
|
+
|
|
1683
|
+
/**
|
|
1684
|
+
* Read a named additional field from a member row. Returns undefined when the
|
|
1685
|
+
* reference set has no such field, null when the row has no value for it, and
|
|
1686
|
+
* otherwise { type: 'concept'|'description'|'integer'|'string', value }.
|
|
1687
|
+
*/
|
|
1688
|
+
_eclMemberFieldValue = function (m, refSet, fieldName) {
|
|
1689
|
+
if (!refSet.fieldNames) {
|
|
1690
|
+
return undefined;
|
|
1691
|
+
}
|
|
1692
|
+
const names = this.refs.getReferences(refSet.fieldNames) || [];
|
|
1693
|
+
const idx = names.findIndex(n => this.strings.getEntry(n).trim().toLowerCase() === fieldName.toLowerCase());
|
|
1694
|
+
if (idx < 0) {
|
|
1695
|
+
return undefined;
|
|
1696
|
+
}
|
|
1697
|
+
const values = m.values ? this.refs.getReferences(m.values) : null;
|
|
1698
|
+
if (!values || values.length < (idx + 1) * 2) {
|
|
1699
|
+
return null;
|
|
1700
|
+
}
|
|
1701
|
+
const value = values[idx * 2];
|
|
1702
|
+
const type = values[idx * 2 + 1];
|
|
1703
|
+
switch (type) {
|
|
1704
|
+
case 1: return { type: 'concept', value };
|
|
1705
|
+
case 2: return { type: 'description', value };
|
|
1706
|
+
case 4: return { type: 'integer', value };
|
|
1707
|
+
case 5: return { type: 'string', value: this.strings.getEntry(value) };
|
|
1708
|
+
default: return null;
|
|
1709
|
+
}
|
|
1710
|
+
};
|
|
1711
|
+
|
|
1712
|
+
/**
|
|
1713
|
+
* Stored SNOMED dates are days since the Pascal TDateTime epoch (1899-12-30);
|
|
1714
|
+
* render as YYYYMMDD for comparison with an ECL time value.
|
|
1715
|
+
*/
|
|
1716
|
+
_snomedDateToString = function (days) {
|
|
1717
|
+
const d = new Date(Date.UTC(1899, 11, 30) + days * 86400000);
|
|
1718
|
+
return d.toISOString().slice(0, 10).replace(/-/g, '');
|
|
1719
|
+
};
|
|
1720
|
+
|
|
1721
|
+
_findRefSetRecord = function (conceptIndex) {
|
|
1722
|
+
for (let i = 0; i < this.refSetIndex.count(); i++) {
|
|
1723
|
+
const refSet = this.refSetIndex.getReferenceSet(i);
|
|
1724
|
+
// definition 0 means the importer could not resolve the reference set's
|
|
1725
|
+
// concept (it is outside the subset) - not "the concept at offset 0"
|
|
1726
|
+
if (refSet.definition === conceptIndex && refSet.definition !== 0) {
|
|
1727
|
+
return refSet;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
return null;
|
|
1731
|
+
};
|
|
1732
|
+
|
|
1024
1733
|
/**
|
|
1025
1734
|
* Wildcard — every concept, active or not. The eclWildcard flag tells filterCheck /
|
|
1026
1735
|
* filterLocate to accept every active concept without enumeration.
|
|
@@ -1046,14 +1755,7 @@ class SnomedServices {
|
|
|
1046
1755
|
let current = this._eclResolveSet(this._evalECLNode(node.base, opContext), opContext);
|
|
1047
1756
|
|
|
1048
1757
|
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;
|
|
1758
|
+
const types = this._eclAttributeTypes(attr, opContext);
|
|
1057
1759
|
|
|
1058
1760
|
const next = new Set();
|
|
1059
1761
|
for (const conceptIdx of current) {
|
|
@@ -1061,7 +1763,7 @@ class SnomedServices {
|
|
|
1061
1763
|
const relIdxs = this.getConceptRelationships(conceptIdx);
|
|
1062
1764
|
for (const relIdx of relIdxs) {
|
|
1063
1765
|
const rel = this.relationships.getRelationship(relIdx);
|
|
1064
|
-
if (rel.active && rel.relType
|
|
1766
|
+
if (rel.active && (types.any || types.types.has(rel.relType))) {
|
|
1065
1767
|
next.add(rel.target);
|
|
1066
1768
|
}
|
|
1067
1769
|
}
|
|
@@ -1080,7 +1782,7 @@ class SnomedServices {
|
|
|
1080
1782
|
* Evaluate a refined expression: `<baseConstraint> : <refinement>`.
|
|
1081
1783
|
* Supported refinement shapes:
|
|
1082
1784
|
* - ATTRIBUTE attr = valueExpr
|
|
1083
|
-
* - ATTRIBUTE_SET attr1 = v1, attr2 = v2 (
|
|
1785
|
+
* - ATTRIBUTE_SET attr1 = v1, attr2 = v2 / attr1 = v1 AND|OR attr2 = v2 (operator 'AND'|'OR')
|
|
1084
1786
|
* - ATTRIBUTE_GROUP { attr1 = v1, attr2 = v2 } (same relationship group)
|
|
1085
1787
|
* Reverse attributes, cardinality, `!=`, and non-concept attribute names
|
|
1086
1788
|
* throw informative errors.
|
|
@@ -1113,10 +1815,11 @@ class SnomedServices {
|
|
|
1113
1815
|
case ECLNodeType.ATTRIBUTE:
|
|
1114
1816
|
return this._attributeMatches(rels, refinement, null, opContext);
|
|
1115
1817
|
case ECLNodeType.ATTRIBUTE_SET:
|
|
1116
|
-
|
|
1117
|
-
|
|
1818
|
+
// operator is 'AND' (also for ",") or 'OR'
|
|
1819
|
+
if (refinement.operator === 'OR') {
|
|
1820
|
+
return refinement.attributes.some(a => this._refinementMatches(rels, a, opContext));
|
|
1118
1821
|
}
|
|
1119
|
-
return
|
|
1822
|
+
return refinement.attributes.every(a => this._refinementMatches(rels, a, opContext));
|
|
1120
1823
|
case ECLNodeType.ATTRIBUTE_GROUP:
|
|
1121
1824
|
return this._attributeGroupMatches(rels, refinement, opContext);
|
|
1122
1825
|
default:
|
|
@@ -1124,6 +1827,23 @@ class SnomedServices {
|
|
|
1124
1827
|
}
|
|
1125
1828
|
};
|
|
1126
1829
|
|
|
1830
|
+
/**
|
|
1831
|
+
* The same, restricted to the relationships in one role group: an attribute
|
|
1832
|
+
* set inside { } may itself be an AND/OR tree of attributes.
|
|
1833
|
+
*/
|
|
1834
|
+
_refinementMatchesInGroup = function (rels, node, g, opContext) {
|
|
1835
|
+
if (node.type === ECLNodeType.ATTRIBUTE_SET) {
|
|
1836
|
+
if (node.operator === 'OR') {
|
|
1837
|
+
return node.attributes.some(a => this._refinementMatchesInGroup(rels, a, g, opContext));
|
|
1838
|
+
}
|
|
1839
|
+
return node.attributes.every(a => this._refinementMatchesInGroup(rels, a, g, opContext));
|
|
1840
|
+
}
|
|
1841
|
+
if (node.type !== ECLNodeType.ATTRIBUTE) {
|
|
1842
|
+
throw new Error(`Unsupported node type in attribute group: ${node.type}`);
|
|
1843
|
+
}
|
|
1844
|
+
return this._attributeMatches(rels, node, g, opContext);
|
|
1845
|
+
};
|
|
1846
|
+
|
|
1127
1847
|
/**
|
|
1128
1848
|
* Check whether a concept has at least one active relationship whose
|
|
1129
1849
|
* `relType` matches the attribute name and whose `target` is in the value
|
|
@@ -1142,14 +1862,11 @@ class SnomedServices {
|
|
|
1142
1862
|
throw new Error('ECL attribute without a comparison is not supported');
|
|
1143
1863
|
}
|
|
1144
1864
|
if (attr.comparison.type !== ECLNodeType.EXPRESSION_COMPARISON) {
|
|
1145
|
-
throw new Error(
|
|
1865
|
+
throw new Error('ECL concrete values (numeric, string and boolean attribute values) are not supported - concrete value relationships are not loaded');
|
|
1146
1866
|
}
|
|
1147
1867
|
if (attr.comparison.operator !== ECLTokenType.EQUALS) {
|
|
1148
1868
|
throw new Error('ECL != in refinements is not yet supported');
|
|
1149
1869
|
}
|
|
1150
|
-
if (attr.name.type !== ECLNodeType.CONCEPT_REFERENCE) {
|
|
1151
|
-
throw new Error('ECL refinements only support plain concept-reference attribute names');
|
|
1152
|
-
}
|
|
1153
1870
|
|
|
1154
1871
|
const count = this._countAttributeMatches(rels, attr, groupFilter, opContext);
|
|
1155
1872
|
|
|
@@ -1178,17 +1895,13 @@ class SnomedServices {
|
|
|
1178
1895
|
// fresh per request, so memoising on the node is safe within a request.
|
|
1179
1896
|
let resolved = attr._eclResolved;
|
|
1180
1897
|
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
1898
|
resolved = {
|
|
1186
|
-
|
|
1899
|
+
types: this._eclAttributeTypes(attr.name, opContext),
|
|
1187
1900
|
valueSet: new Set(this._eclResolveSet(this._evalECLNode(attr.comparison.value, opContext), opContext))
|
|
1188
1901
|
};
|
|
1189
1902
|
attr._eclResolved = resolved;
|
|
1190
1903
|
}
|
|
1191
|
-
const
|
|
1904
|
+
const types = resolved.types;
|
|
1192
1905
|
const valueSet = resolved.valueSet;
|
|
1193
1906
|
|
|
1194
1907
|
// ECL cardinality counts OCCURRENCES of a matching attribute, per role group
|
|
@@ -1203,7 +1916,7 @@ class SnomedServices {
|
|
|
1203
1916
|
for (const rel of rels) {
|
|
1204
1917
|
if (opContext) opContext.deadCheck('ecl:countAttributeMatches');
|
|
1205
1918
|
if (!rel.active) continue;
|
|
1206
|
-
if (rel.relType
|
|
1919
|
+
if (!types.any && !types.types.has(rel.relType)) continue;
|
|
1207
1920
|
if (groupFilter !== null && rel.group !== groupFilter) continue;
|
|
1208
1921
|
if (valueSet.has(rel.target)) matched.add(rel.group + ':' + rel.target);
|
|
1209
1922
|
}
|
|
@@ -1248,7 +1961,7 @@ class SnomedServices {
|
|
|
1248
1961
|
if (opContext) opContext.deadCheck('ecl:attributeGroup');
|
|
1249
1962
|
let allMatch = true;
|
|
1250
1963
|
for (const attr of group.attributes) {
|
|
1251
|
-
if (!this.
|
|
1964
|
+
if (!this._refinementMatchesInGroup(rels, attr, g, opContext)) {
|
|
1252
1965
|
allMatch = false;
|
|
1253
1966
|
break;
|
|
1254
1967
|
}
|
|
@@ -1690,6 +2403,15 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1690
2403
|
return this.sct.getDescription();
|
|
1691
2404
|
}
|
|
1692
2405
|
|
|
2406
|
+
/**
|
|
2407
|
+
* $lookup's display name: the edition description (e.g. "SNOMED CT International
|
|
2408
|
+
* Edition"), which says more than the bare code system title.
|
|
2409
|
+
*/
|
|
2410
|
+
// eslint-disable-next-line no-unused-vars
|
|
2411
|
+
lookupName(languages) {
|
|
2412
|
+
return this.sct.getDescription();
|
|
2413
|
+
}
|
|
2414
|
+
|
|
1693
2415
|
description() {
|
|
1694
2416
|
return this.sct.getDescription();
|
|
1695
2417
|
}
|
|
@@ -2038,34 +2760,46 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2038
2760
|
const ctxt = await this.#ensureContext(context);
|
|
2039
2761
|
if (ctxt) {
|
|
2040
2762
|
if (!(ctxt instanceof SnomedExpressionContext) || ctxt.expression?.concepts.length == 1) {
|
|
2041
|
-
|
|
2042
|
-
//
|
|
2043
|
-
//
|
|
2044
|
-
//
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2763
|
+
// Only what was asked for, if anything was (see _hasProp): effectiveTime, module,
|
|
2764
|
+
// parent, child and the defining relationships are returned by default but are not
|
|
2765
|
+
// important enough to return when the client named the properties it wants.
|
|
2766
|
+
// The mappings (alternate identifiers such as the LOINC code) are returned by
|
|
2767
|
+
// default too, and when asked for by name.
|
|
2768
|
+
if (this._hasProp(props, 'effectiveTime', true)) {
|
|
2769
|
+
const time = this.sct.concepts.getConcept(ctxt.getReference()).effectiveTime;
|
|
2770
|
+
// Pascal TDateTime epoch (1899-12-30) computed in UTC so the formatted
|
|
2771
|
+
// date is timezone-independent. new Date(1899,11,30) is LOCAL midnight,
|
|
2772
|
+
// which in zones ahead of UTC rolls the ISO date back by one day.
|
|
2773
|
+
const pascalEpochUTC = Date.UTC(1899, 11, 30);
|
|
2774
|
+
const date = new Date(pascalEpochUTC + time * 86400000);
|
|
2775
|
+
const dateStr = date.toISOString().slice(0, 10);
|
|
2776
|
+
this._addDateTimeProperty(params, 'property', 'effectiveTime', dateStr);
|
|
2777
|
+
}
|
|
2050
2778
|
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2779
|
+
if (this._hasProp(props, 'parent', true)) {
|
|
2780
|
+
const parents = this.sct.getConceptParents(ctxt.getReference());
|
|
2781
|
+
for (let parentRef of parents) {
|
|
2782
|
+
const code = this.sct.getConceptId(parentRef);
|
|
2783
|
+
const description = this.sct.getDisplayName(parentRef);
|
|
2784
|
+
this._addCodeProperty(params, 'property', 'parent', code, null, description);
|
|
2785
|
+
}
|
|
2056
2786
|
}
|
|
2057
2787
|
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2788
|
+
if (this._hasProp(props, 'child', true)) {
|
|
2789
|
+
const children = this.sct.getConceptChildren(ctxt.getReference());
|
|
2790
|
+
for (let childRef of children) {
|
|
2791
|
+
const code = this.sct.getConceptId(childRef);
|
|
2792
|
+
const description = this.sct.getDisplayName(childRef);
|
|
2793
|
+
this._addCodeProperty(params, 'property', 'child', code, null, description);
|
|
2794
|
+
}
|
|
2063
2795
|
}
|
|
2064
2796
|
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2797
|
+
if (this._hasProp(props, 'module', true)) {
|
|
2798
|
+
const moduleId = this.sct.concepts.getModuleId(ctxt.getReference());
|
|
2799
|
+
if (moduleId) {
|
|
2800
|
+
const code = this.sct.getConceptId(moduleId);
|
|
2801
|
+
this._addCodeProperty(params, 'property', 'module', code, null, this.sct.getDisplayName(moduleId));
|
|
2802
|
+
}
|
|
2069
2803
|
}
|
|
2070
2804
|
|
|
2071
2805
|
const relationships = this.sct.getConceptRelationships(ctxt.getReference());
|
|
@@ -2073,7 +2807,7 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2073
2807
|
for (let relationshipRef of relationships) {
|
|
2074
2808
|
const relationship = this.sct.relationships.getRelationship(relationshipRef);
|
|
2075
2809
|
const relType = this.sct.getConceptId(relationship.relType);
|
|
2076
|
-
if (relType != '116680003') {
|
|
2810
|
+
if (relType != '116680003' && this._hasProp(props, relType, true)) {
|
|
2077
2811
|
const relTypeD = this.sct.getDisplayName(relationship.relType);
|
|
2078
2812
|
const code = this.sct.getConceptId(relationship.target);
|
|
2079
2813
|
const description = this.sct.getDisplayName(relationship.target);
|
|
@@ -2084,6 +2818,8 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2084
2818
|
}
|
|
2085
2819
|
}
|
|
2086
2820
|
}
|
|
2821
|
+
|
|
2822
|
+
this.#addMappingProperties(ctxt, props, params);
|
|
2087
2823
|
}
|
|
2088
2824
|
if (ctxt instanceof SnomedExpressionContext) {
|
|
2089
2825
|
// ignore concepts for now, but list refinements and refinement groups
|
|
@@ -2099,6 +2835,27 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2099
2835
|
}
|
|
2100
2836
|
}
|
|
2101
2837
|
|
|
2838
|
+
/**
|
|
2839
|
+
* The concept's alternate identifiers in the schemes we know (IDENTIFIER_SCHEMES), as
|
|
2840
|
+
* Coding-valued properties named for the scheme - e.g. loinc = http://loinc.org#2160-0.
|
|
2841
|
+
* Returned by default, and when asked for by name.
|
|
2842
|
+
*/
|
|
2843
|
+
#addMappingProperties(ctxt, props, params) {
|
|
2844
|
+
const conceptId = this.sct.getConceptId(ctxt.getReference());
|
|
2845
|
+
for (const ident of this.sct.getIdentifiers(conceptId)) {
|
|
2846
|
+
const scheme = IDENTIFIER_SCHEMES[ident.scheme];
|
|
2847
|
+
if (scheme && this._hasProp(props, scheme.property, true)) {
|
|
2848
|
+
params.push({
|
|
2849
|
+
name: 'property',
|
|
2850
|
+
part: [
|
|
2851
|
+
{ name: 'code', valueCode: scheme.property },
|
|
2852
|
+
{ name: 'value', valueCoding: { system: scheme.system, code: ident.code } }
|
|
2853
|
+
]
|
|
2854
|
+
});
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2102
2859
|
/**
|
|
2103
2860
|
* Report one refinement of a post-coordinated expression as a lookup property.
|
|
2104
2861
|
*
|
|
@@ -2154,6 +2911,11 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2154
2911
|
return true;
|
|
2155
2912
|
}
|
|
2156
2913
|
|
|
2914
|
+
if (prop.startsWith(MAPPED_FILTER_PREFIX)) {
|
|
2915
|
+
return op === 'exists' && ['true', 'false'].includes(value) &&
|
|
2916
|
+
this.sct.stringToIdOrZero(prop.substring(MAPPED_FILTER_PREFIX.length)) !== 0n;
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2157
2919
|
const cid = this.sct.stringToIdOrZero(prop);
|
|
2158
2920
|
if (cid != 0) {
|
|
2159
2921
|
const id = this.sct.stringToIdOrZero(value);
|
|
@@ -2310,6 +3072,27 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2310
3072
|
return null;
|
|
2311
3073
|
}
|
|
2312
3074
|
|
|
3075
|
+
// mapped:<identifier scheme> exists true|false - concepts that do (or don't) have an
|
|
3076
|
+
// alternate identifier in that scheme (e.g. mapped:30051010000102: has a LOINC code).
|
|
3077
|
+
// A prefix, so as not to be confused with the attribute filters (<attribute> = <value>)
|
|
3078
|
+
if (prop.startsWith(MAPPED_FILTER_PREFIX)) {
|
|
3079
|
+
const scheme = prop.substring(MAPPED_FILTER_PREFIX.length);
|
|
3080
|
+
if (this.sct.stringToIdOrZero(scheme) === 0n) {
|
|
3081
|
+
throw new Error(`Invalid identifier scheme: ${scheme}`);
|
|
3082
|
+
}
|
|
3083
|
+
if (op !== 'exists') {
|
|
3084
|
+
throw new Error(`Unsupported filter operation: ${prop} ${op} ${value}`);
|
|
3085
|
+
}
|
|
3086
|
+
if (value !== 'true' && value !== 'false') {
|
|
3087
|
+
throw new Error(`Invalid filter value: ${value}`);
|
|
3088
|
+
}
|
|
3089
|
+
const filter = new SnomedFilterContext();
|
|
3090
|
+
filter.mappedScheme = scheme;
|
|
3091
|
+
filter.mappedExists = value === 'true';
|
|
3092
|
+
filterContext.filters.push(filter);
|
|
3093
|
+
return null;
|
|
3094
|
+
}
|
|
3095
|
+
|
|
2313
3096
|
const cid = this.sct.stringToIdOrZero(prop);
|
|
2314
3097
|
if (cid != 0) {
|
|
2315
3098
|
|
|
@@ -2413,7 +3196,9 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2413
3196
|
}
|
|
2414
3197
|
let found = false;
|
|
2415
3198
|
|
|
2416
|
-
if (set.
|
|
3199
|
+
if (set.mappedScheme !== undefined) {
|
|
3200
|
+
found = this.#isMapped(reference, set.mappedScheme) === set.mappedExists;
|
|
3201
|
+
} else if (set.inactive !== undefined) {
|
|
2417
3202
|
let concept = this.sct.concepts.getConcept(reference);
|
|
2418
3203
|
let active = (concept.flags & 0x0F) === 0;
|
|
2419
3204
|
found = active !== set.inactive
|
|
@@ -2459,6 +3244,9 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2459
3244
|
}
|
|
2460
3245
|
|
|
2461
3246
|
const reference = concept.getReference();
|
|
3247
|
+
if (set.mappedScheme !== undefined) {
|
|
3248
|
+
return this.#isMapped(reference, set.mappedScheme) === set.mappedExists;
|
|
3249
|
+
}
|
|
2462
3250
|
if (set.inactive !== undefined) {
|
|
2463
3251
|
return this.sct.isActive(reference) !== set.inactive;
|
|
2464
3252
|
}
|
|
@@ -2518,10 +3306,39 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2518
3306
|
return set._matchSet.has(reference);
|
|
2519
3307
|
}
|
|
2520
3308
|
|
|
3309
|
+
/** Whether the concept (by reference) has an alternate identifier in the scheme */
|
|
3310
|
+
#isMapped(reference, scheme) {
|
|
3311
|
+
return this.sct.getIdentifiers(this.sct.getConceptId(reference), scheme).length > 0;
|
|
3312
|
+
}
|
|
3313
|
+
|
|
2521
3314
|
#ensurePopulated(set) {
|
|
2522
3315
|
if (set.populationDone) {
|
|
2523
3316
|
return;
|
|
2524
3317
|
}
|
|
3318
|
+
if (set.mappedScheme !== undefined) {
|
|
3319
|
+
if (set.mappedExists) {
|
|
3320
|
+
// straight from the identifier index, in concept order
|
|
3321
|
+
const refs = new Set();
|
|
3322
|
+
for (const [conceptId, idents] of this.sct.identifiersByConcept) {
|
|
3323
|
+
if (idents.some(i => i.scheme === set.mappedScheme)) {
|
|
3324
|
+
const r = this.sct.concepts.findConcept(BigInt(conceptId));
|
|
3325
|
+
if (r.found) {
|
|
3326
|
+
refs.add(r.index);
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
set.descendants = [...refs].sort((a, b) => a - b);
|
|
3331
|
+
} else {
|
|
3332
|
+
for (let i = 0; i < this.sct.concepts.count(); i++) {
|
|
3333
|
+
const concept = this.sct.concepts.getConceptByCount(i);
|
|
3334
|
+
if (!this.#isMapped(concept.index, set.mappedScheme)) {
|
|
3335
|
+
set.descendants.push(concept.index);
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
set.populationDone = true;
|
|
3340
|
+
return;
|
|
3341
|
+
}
|
|
2525
3342
|
if (set.inactive !== undefined && set.descendants.length === 0) {
|
|
2526
3343
|
for (let i = 0; i < this.sct.concepts.count(); i++) {
|
|
2527
3344
|
let concept = this.sct.concepts.getConceptByCount(i);
|
|
@@ -2673,7 +3490,16 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2673
3490
|
}
|
|
2674
3491
|
|
|
2675
3492
|
async getTranslations(map, coding, target, reverse) {
|
|
2676
|
-
if (
|
|
3493
|
+
if (map && map.jsonObj.identifierMap) {
|
|
3494
|
+
return this.translateByIdentifier(map, coding, target);
|
|
3495
|
+
}
|
|
3496
|
+
if (!map || reverse) {
|
|
3497
|
+
return [];
|
|
3498
|
+
}
|
|
3499
|
+
if (map.jsonObj.editionTranslation) {
|
|
3500
|
+
return this.translateToEdition(map, coding);
|
|
3501
|
+
}
|
|
3502
|
+
if (target && target !== this.system()) {
|
|
2677
3503
|
return [];
|
|
2678
3504
|
}
|
|
2679
3505
|
let ref = this.sct.concepts.findConcept(map.id);
|
|
@@ -2730,6 +3556,181 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2730
3556
|
return result;
|
|
2731
3557
|
}
|
|
2732
3558
|
|
|
3559
|
+
/**
|
|
3560
|
+
* Translate using a module's alternate identifiers (the implicit map ?fhir_cm=<module>,
|
|
3561
|
+
* e.g. the LOINC Extension's LOINC codes). The identifiers say the concept and the code
|
|
3562
|
+
* are the same thing, so the map works the same in both directions: a SNOMED CT concept
|
|
3563
|
+
* translates to its codes, and a code in the identifier's system to its concepts. Either
|
|
3564
|
+
* way the relationship is equivalent.
|
|
3565
|
+
*/
|
|
3566
|
+
async translateByIdentifier(map, coding, target) {
|
|
3567
|
+
const { schemes } = map.jsonObj.identifierMap;
|
|
3568
|
+
const targetSystem = target ? target.split('|')[0] : null;
|
|
3569
|
+
const result = [];
|
|
3570
|
+
if (coding.system === this.system()) {
|
|
3571
|
+
const ctxt = await this.locate(coding.code);
|
|
3572
|
+
if (!ctxt.context) {
|
|
3573
|
+
return [];
|
|
3574
|
+
}
|
|
3575
|
+
const conceptId = this.sct.getConceptId(ctxt.context.getReference());
|
|
3576
|
+
for (const ident of this.sct.getIdentifiers(conceptId)) {
|
|
3577
|
+
const scheme = IDENTIFIER_SCHEMES[ident.scheme];
|
|
3578
|
+
if (scheme && schemes.includes(ident.scheme) && (!targetSystem || targetSystem === scheme.system)) {
|
|
3579
|
+
result.push({
|
|
3580
|
+
map: map.vurl,
|
|
3581
|
+
system: scheme.system,
|
|
3582
|
+
code: ident.code,
|
|
3583
|
+
relationship: 'equivalent'
|
|
3584
|
+
});
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
} else if (!targetSystem || targetSystem === this.system()) {
|
|
3588
|
+
for (const schemeId of schemes) {
|
|
3589
|
+
const scheme = IDENTIFIER_SCHEMES[schemeId];
|
|
3590
|
+
if (scheme && scheme.system === coding.system) {
|
|
3591
|
+
for (const conceptId of this.sct.getConceptsForIdentifier(schemeId, coding.code)) {
|
|
3592
|
+
result.push({
|
|
3593
|
+
map: map.vurl,
|
|
3594
|
+
system: this.system(),
|
|
3595
|
+
version: this.version(),
|
|
3596
|
+
code: conceptId,
|
|
3597
|
+
display: await this.display(conceptId),
|
|
3598
|
+
relationship: 'equivalent'
|
|
3599
|
+
});
|
|
3600
|
+
}
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
3603
|
+
}
|
|
3604
|
+
return result;
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
/**
|
|
3608
|
+
* Translate a concept of this edition to the edition it depends on (typically a national
|
|
3609
|
+
* extension to the International Edition). There is no map: the answer comes from the
|
|
3610
|
+
* definitions.
|
|
3611
|
+
* - a concept in one of the target edition's modules is its own translation
|
|
3612
|
+
* - an inactive concept translates via its historical associations (REPLACED BY, SAME AS,
|
|
3613
|
+
* POSSIBLY EQUIVALENT TO, ALTERNATIVE) to concepts in the target edition
|
|
3614
|
+
* - a fully defined extension concept translates to its normal form, provided every concept
|
|
3615
|
+
* in it belongs to the target edition; if the target edition has a concept with that same
|
|
3616
|
+
* normal form, to that concept
|
|
3617
|
+
* - otherwise (a primitive extension concept, or a definition that uses other extension
|
|
3618
|
+
* content) to its proximal ancestors in the target edition, which it is narrower than -
|
|
3619
|
+
* unless the only such ancestors are top-level hierarchies (or the root), which carry no
|
|
3620
|
+
* information: that is reported as a reason rather than a translation
|
|
3621
|
+
*/
|
|
3622
|
+
async translateToEdition(map, coding) {
|
|
3623
|
+
const ed = map.jsonObj.editionTranslation;
|
|
3624
|
+
const sct = this.sct;
|
|
3625
|
+
const src = sct.concepts.findConcept(coding.code);
|
|
3626
|
+
if (!src.found) {
|
|
3627
|
+
return [];
|
|
3628
|
+
}
|
|
3629
|
+
const inTarget = (ref) => ed.modules.has(sct.concepts.getModuleId(ref));
|
|
3630
|
+
const mk = (code, display, relationship) => ({
|
|
3631
|
+
map: map.vurl, system: this.system(), version: ed.version, code, display, relationship
|
|
3632
|
+
});
|
|
3633
|
+
const named = (ref) => `${sct.getConceptId(ref)} |${sct.getDisplayName(ref, sct.defaultLanguage)}|`;
|
|
3634
|
+
|
|
3635
|
+
if (inTarget(src.index)) {
|
|
3636
|
+
return [mk(sct.getConceptId(src.index), sct.getDisplayName(src.index, sct.defaultLanguage), 'equivalent')];
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
if (!sct.isActive(src.index)) {
|
|
3640
|
+
const result = [];
|
|
3641
|
+
for (const [refset, relationship] of [['900000000000527005', 'equivalent'], ['900000000000526001', 'equivalent'],
|
|
3642
|
+
['900000000000523009', 'related-to'], ['900000000000530003', 'related-to']]) {
|
|
3643
|
+
for (const tgt of this.#historicalAssociations(refset, src.index)) {
|
|
3644
|
+
if (inTarget(tgt)) {
|
|
3645
|
+
result.push(mk(sct.getConceptId(tgt), sct.getDisplayName(tgt, sct.defaultLanguage), relationship));
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
return result;
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
if (!sct.isPrimitive(src.index)) {
|
|
3653
|
+
const nf = sct.expressionServices.createNormalForm(src.index);
|
|
3654
|
+
if (this.#expressionConcepts(nf).every(inTarget)) {
|
|
3655
|
+
const same = sct.conceptWithNormalForm(sct.expressionServices.renderExpression(nf, SnomedServicesRenderOption.Minimal));
|
|
3656
|
+
if (same >= 0 && same !== src.index && inTarget(same)) {
|
|
3657
|
+
return [mk(sct.getConceptId(same), sct.getDisplayName(same, sct.defaultLanguage), 'equivalent')];
|
|
3658
|
+
}
|
|
3659
|
+
// the canonical form self-groups every attribute; for output, never-grouped ones go back ungrouped
|
|
3660
|
+
const out = sct.expressionServices.ungroupNeverGroupedRefinements(nf);
|
|
3661
|
+
return [mk(sct.expressionServices.renderExpression(out, SnomedServicesRenderOption.Minimal),
|
|
3662
|
+
sct.expressionServices.renderExpression(out, SnomedServicesRenderOption.FillMissing), 'equivalent')];
|
|
3663
|
+
}
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
const ancestors = this.#proximalAncestorsIn(src.index, inTarget);
|
|
3667
|
+
if (ancestors.length === 0) {
|
|
3668
|
+
return [];
|
|
3669
|
+
}
|
|
3670
|
+
const useful = ancestors.filter(a => sct.concepts.getDepth(a) >= 2);
|
|
3671
|
+
if (useful.length === 0) {
|
|
3672
|
+
const list = ancestors.map(named);
|
|
3673
|
+
const msgId = list.length === 1 ? 'TRANSLATE_SCT_EDITION_TOP_LEVEL_ONE' : 'TRANSLATE_SCT_EDITION_TOP_LEVEL_MANY';
|
|
3674
|
+
return [{ message: this.opContext.i18n.translate(msgId, this.opContext.langs,
|
|
3675
|
+
[getEditionName(ed.moduleId), named(src.index), list.join(', ')]) }];
|
|
3676
|
+
}
|
|
3677
|
+
return useful.map(a => mk(sct.getConceptId(a), sct.getDisplayName(a, sct.defaultLanguage), 'source-is-narrower-than-target'));
|
|
3678
|
+
}
|
|
3679
|
+
|
|
3680
|
+
/** The target components of the given association reference set for a concept */
|
|
3681
|
+
#historicalAssociations(refsetId, conceptIndex) {
|
|
3682
|
+
const sct = this.sct;
|
|
3683
|
+
const rs = sct.concepts.findConcept(refsetId);
|
|
3684
|
+
if (!rs.found) return [];
|
|
3685
|
+
const rref = sct.refSetIndex.getRefSetByConcept(rs.index);
|
|
3686
|
+
if (rref === -1) return [];
|
|
3687
|
+
const members = sct.refSetMembers.getMembers(sct.refSetIndex.getReferenceSet(rref).membersByRef);
|
|
3688
|
+
const result = [];
|
|
3689
|
+
for (const m of members) {
|
|
3690
|
+
if (m.ref !== conceptIndex) continue;
|
|
3691
|
+
const values = m.values ? sct.refs.getReferences(m.values) : null;
|
|
3692
|
+
if (values && values.length >= 1 && values[0]) {
|
|
3693
|
+
result.push(values[0]);
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
return result;
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
/** Every concept reference used anywhere in an expression */
|
|
3700
|
+
#expressionConcepts(exp, into = []) {
|
|
3701
|
+
for (const c of exp.concepts) {
|
|
3702
|
+
if (c.reference !== NO_REFERENCE) into.push(c.reference);
|
|
3703
|
+
}
|
|
3704
|
+
const refinements = [...exp.refinements, ...exp.refinementGroups.flatMap(g => g.refinements)];
|
|
3705
|
+
for (const r of refinements) {
|
|
3706
|
+
if (r.name && r.name.reference !== NO_REFERENCE) into.push(r.name.reference);
|
|
3707
|
+
if (r.value) this.#expressionConcepts(r.value, into);
|
|
3708
|
+
}
|
|
3709
|
+
return into;
|
|
3710
|
+
}
|
|
3711
|
+
|
|
3712
|
+
/**
|
|
3713
|
+
* The nearest ancestors of a concept that satisfy the test: a parent that does is taken
|
|
3714
|
+
* and not looked beyond; one that does not is replaced by its own parents.
|
|
3715
|
+
*/
|
|
3716
|
+
#proximalAncestorsIn(conceptIndex, test) {
|
|
3717
|
+
const result = [];
|
|
3718
|
+
const seen = new Set();
|
|
3719
|
+
const todo = [...this.sct.expressionServices.getConceptParents(conceptIndex)];
|
|
3720
|
+
while (todo.length) {
|
|
3721
|
+
const p = todo.shift();
|
|
3722
|
+
if (seen.has(p)) continue;
|
|
3723
|
+
seen.add(p);
|
|
3724
|
+
if (test(p)) {
|
|
3725
|
+
result.push(p);
|
|
3726
|
+
} else {
|
|
3727
|
+
todo.push(...this.sct.expressionServices.getConceptParents(p));
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
// an ancestor that is itself subsumed by another in the list is not proximal
|
|
3731
|
+
return result.filter(a => !result.some(b => b !== a && this.sct.subsumes(a, b)));
|
|
3732
|
+
}
|
|
3733
|
+
|
|
2733
3734
|
hasMultiHierarchy() {
|
|
2734
3735
|
return true;
|
|
2735
3736
|
}
|
|
@@ -2979,11 +3980,140 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
2979
3980
|
return edition + ' ' + formatDateMMDDYYYY(match[2].substring(4, 6) + match[2].substring(6, 8) + match[2].substring(0, 4));
|
|
2980
3981
|
}
|
|
2981
3982
|
|
|
3983
|
+
/**
|
|
3984
|
+
* A translation from this edition to an edition it depends on needs no map: the answer
|
|
3985
|
+
* comes from the definitions (see SnomedProvider.translateToEdition). The request names
|
|
3986
|
+
* the source by its edition URI (with or without a version) and the target as
|
|
3987
|
+
* system|edition URI (with or without a version); the map's url is the versioned source
|
|
3988
|
+
* edition URI plus ?fhir_cm=edition/<target edition module>
|
|
3989
|
+
*/
|
|
3990
|
+
async findImplicitConceptMaps(conceptMaps, source, sourceVersion, dest) {
|
|
3991
|
+
if (dest) {
|
|
3992
|
+
this.#findIdentifierMaps(conceptMaps, source, sourceVersion, dest);
|
|
3993
|
+
}
|
|
3994
|
+
if (source !== this.system() || !sourceVersion || !dest || !this.isMyEdition(sourceVersion)) {
|
|
3995
|
+
return;
|
|
3996
|
+
}
|
|
3997
|
+
const bar = dest.indexOf('|');
|
|
3998
|
+
if (bar === -1 || dest.substring(0, bar) !== this.system()) {
|
|
3999
|
+
return;
|
|
4000
|
+
}
|
|
4001
|
+
const dep = this.snomedServices.editionDependency(dest.substring(bar + 1));
|
|
4002
|
+
if (dep) {
|
|
4003
|
+
conceptMaps.push(this.editionTranslationMap(dep));
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
|
|
4007
|
+
/**
|
|
4008
|
+
* The implicit map from this edition to an edition it depends on. Its url is the one
|
|
4009
|
+
* agreed with SNOMED International: <source edition>/version/<date>?fhir_cm=edition/<target module>
|
|
4010
|
+
* (the source always versioned, whatever the request said)
|
|
4011
|
+
*/
|
|
4012
|
+
editionTranslationMap(dep) {
|
|
4013
|
+
return new ConceptMap({
|
|
4014
|
+
resourceType: 'ConceptMap',
|
|
4015
|
+
internalSource: this,
|
|
4016
|
+
editionTranslation: dep,
|
|
4017
|
+
url: `${this.version()}?fhir_cm=edition/${dep.moduleId}`,
|
|
4018
|
+
name: `SNOMED CT ${this.describeVersion(this.version())} to ${getEditionName(dep.moduleId)} Concept Map`,
|
|
4019
|
+
description: `The translation implied by the concept definitions of the edition ${this.version()} to the ${getEditionName(dep.moduleId)} that it depends on`,
|
|
4020
|
+
status: 'active',
|
|
4021
|
+
group: [{
|
|
4022
|
+
source: this.system(),
|
|
4023
|
+
target: this.system()
|
|
4024
|
+
}]
|
|
4025
|
+
});
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
/**
|
|
4029
|
+
* Maps implied by alternate identifiers, found from the systems rather than nominated:
|
|
4030
|
+
* from this edition (source system|version naming it) to an identifier system (LOINC,
|
|
4031
|
+
* say), or from an identifier system to this edition (the target given as
|
|
4032
|
+
* system|edition URI, with or without the version)
|
|
4033
|
+
*/
|
|
4034
|
+
#findIdentifierMaps(conceptMaps, source, sourceVersion, dest) {
|
|
4035
|
+
const bar = dest.indexOf('|');
|
|
4036
|
+
const destSystem = bar === -1 ? dest : dest.substring(0, bar);
|
|
4037
|
+
const destVersion = bar === -1 ? null : dest.substring(bar + 1);
|
|
4038
|
+
let identSystem;
|
|
4039
|
+
if (source === this.system() && sourceVersion && this.isMyEdition(sourceVersion)) {
|
|
4040
|
+
identSystem = destSystem;
|
|
4041
|
+
} else if (destSystem === this.system() && destVersion && this.isMyEdition(destVersion)) {
|
|
4042
|
+
identSystem = source;
|
|
4043
|
+
} else {
|
|
4044
|
+
return;
|
|
4045
|
+
}
|
|
4046
|
+
for (const [moduleId, schemes] of this.snomedServices.identifierModules) {
|
|
4047
|
+
if ([...schemes].some(s => IDENTIFIER_SCHEMES[s] && IDENTIFIER_SCHEMES[s].system === identSystem)) {
|
|
4048
|
+
const cm = this.identifierMap(moduleId);
|
|
4049
|
+
if (cm && !conceptMaps.some(m => m.url === cm.url)) {
|
|
4050
|
+
conceptMaps.push(cm);
|
|
4051
|
+
}
|
|
4052
|
+
}
|
|
4053
|
+
}
|
|
4054
|
+
}
|
|
4055
|
+
|
|
4056
|
+
/**
|
|
4057
|
+
* The implicit map defined by a module's alternate identifiers, if the module has any
|
|
4058
|
+
* in a scheme we know. Its url follows the edition maps: <source edition>/version/<date>?fhir_cm=<module>
|
|
4059
|
+
*/
|
|
4060
|
+
identifierMap(moduleId) {
|
|
4061
|
+
const schemes = [...(this.snomedServices.identifierModules.get(moduleId) || [])].filter(s => IDENTIFIER_SCHEMES[s]);
|
|
4062
|
+
if (schemes.length === 0) {
|
|
4063
|
+
return null;
|
|
4064
|
+
}
|
|
4065
|
+
const systems = schemes.map(s => IDENTIFIER_SCHEMES[s].system);
|
|
4066
|
+
const moduleName = this.snomedServices.conceptExists(moduleId)
|
|
4067
|
+
? this.snomedServices.getDisplayName(this.snomedServices.concepts.findConcept(BigInt(moduleId)).index) : moduleId;
|
|
4068
|
+
return new ConceptMap({
|
|
4069
|
+
resourceType: 'ConceptMap',
|
|
4070
|
+
internalSource: this,
|
|
4071
|
+
identifierMap: { module: moduleId, schemes: schemes },
|
|
4072
|
+
url: `${this.version()}?fhir_cm=${moduleId}`,
|
|
4073
|
+
name: `SNOMED CT ${moduleName} Concept Map`,
|
|
4074
|
+
description: `The concept map implied by the alternate identifiers (${systems.join(', ')}) of the module ${moduleId} |${moduleName}| in ${this.version()}`,
|
|
4075
|
+
status: 'active',
|
|
4076
|
+
group: [
|
|
4077
|
+
...systems.map(s => ({ source: this.system(), target: s })),
|
|
4078
|
+
...systems.map(s => ({ source: s, target: this.system() }))
|
|
4079
|
+
]
|
|
4080
|
+
});
|
|
4081
|
+
}
|
|
4082
|
+
|
|
4083
|
+
/** Whether a source version names this edition: exactly, or the edition without a version */
|
|
4084
|
+
isMyEdition(version) {
|
|
4085
|
+
const mine = this.version();
|
|
4086
|
+
return version === mine || mine.startsWith(version + '/version/');
|
|
4087
|
+
}
|
|
4088
|
+
|
|
2982
4089
|
async findImplicitConceptMap(url, version) {
|
|
2983
4090
|
if (version && (version !== this.version())) {
|
|
2984
4091
|
return null;
|
|
2985
4092
|
}
|
|
2986
|
-
if (!url
|
|
4093
|
+
if (!url) {
|
|
4094
|
+
return null;
|
|
4095
|
+
}
|
|
4096
|
+
// <edition[/version/date]>?fhir_cm=edition/<module>: this edition's definition-based
|
|
4097
|
+
// translation to an edition it depends on, nominated by url
|
|
4098
|
+
const q = url.indexOf('?fhir_cm=edition/');
|
|
4099
|
+
if (q !== -1) {
|
|
4100
|
+
if (!this.isMyEdition(url.substring(0, q))) {
|
|
4101
|
+
return null;
|
|
4102
|
+
}
|
|
4103
|
+
const dep = this.snomedServices.editionDependency(`http://snomed.info/sct/${url.substring(q + '?fhir_cm=edition/'.length)}`);
|
|
4104
|
+
return dep ? this.editionTranslationMap(dep) : null;
|
|
4105
|
+
}
|
|
4106
|
+
// <edition[/version/date]>?fhir_cm=<module>: the map implied by a module's alternate
|
|
4107
|
+
// identifiers (e.g. 11010000107, the LOINC Extension's LOINC codes)
|
|
4108
|
+
const qm = url.indexOf('?fhir_cm=');
|
|
4109
|
+
if (qm !== -1 && url.substring(0, qm) !== this.system()) {
|
|
4110
|
+
if (!this.isMyEdition(url.substring(0, qm))) {
|
|
4111
|
+
return null;
|
|
4112
|
+
}
|
|
4113
|
+
await this.#ensureLoaded();
|
|
4114
|
+
return this.identifierMap(url.substring(qm + '?fhir_cm='.length));
|
|
4115
|
+
}
|
|
4116
|
+
if (!url.startsWith(this.system()+"?fhir_cm=")) {
|
|
2987
4117
|
return null;
|
|
2988
4118
|
}
|
|
2989
4119
|
let id = url.substring(url.indexOf("=")+1);
|