fhirsmith 0.12.0 → 0.13.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/README.md +1 -1
  3. package/config-template.json +13 -0
  4. package/library/languages.js +191 -34
  5. package/library/path-safety.js +51 -0
  6. package/library/request-token.js +56 -0
  7. package/package.json +2 -2
  8. package/packages/packages.js +17 -118
  9. package/publisher/publisher.js +60 -30
  10. package/publisher/validation.js +15 -3
  11. package/server.js +12 -3
  12. package/translations/Messages.properties +1 -0
  13. package/tx/README.md +21 -0
  14. package/tx/cs/cs-api.js +30 -1
  15. package/tx/cs/cs-cs.js +1 -1
  16. package/tx/cs/cs-icd11.js +805 -0
  17. package/tx/cs/cs-lang.js +331 -85
  18. package/tx/cs/cs-loinc.js +45 -4
  19. package/tx/cs/cs-mimetypes.js +492 -21
  20. package/tx/cs/cs-snomed.js +38 -4
  21. package/tx/cs/cs-ucum.js +33 -3
  22. package/tx/cs/icd11-expressions.js +243 -0
  23. package/tx/importers/export-icd11-codesystem.js +823 -0
  24. package/tx/importers/icd11-schema.md +285 -0
  25. package/tx/importers/import-icd11.js +861 -0
  26. package/tx/importers/import-loinc.module.js +14 -7
  27. package/tx/importers/import-sct.module.js +10 -1
  28. package/tx/library/codesystem.js +21 -27
  29. package/tx/library/nesting-limit.js +79 -0
  30. package/tx/library/operation-outcome.js +97 -3
  31. package/tx/library/valueset.js +16 -20
  32. package/tx/library.js +23 -1
  33. package/tx/ocl/cache/cache-utils.cjs +5 -3
  34. package/tx/ocl/cs-ocl.cjs +73 -17
  35. package/tx/ocl/jobs/background-queue.cjs +6 -3
  36. package/tx/ocl/mappers/concept-mapper.cjs +9 -1
  37. package/tx/ocl/shared/patches.cjs +48 -1
  38. package/tx/ocl/vs-ocl.cjs +15 -13
  39. package/tx/sct/expressions.js +194 -17
  40. package/tx/tx.fhir.org.yml +2 -1
  41. package/tx/tx.js +15 -42
  42. package/tx/workers/batch-validate.js +6 -16
  43. package/tx/workers/batch.js +16 -28
  44. package/tx/workers/cache-control.js +6 -12
  45. package/tx/workers/closure.js +3 -8
  46. package/tx/workers/compare.js +6 -34
  47. package/tx/workers/expand.js +36 -45
  48. package/tx/workers/lookup.js +21 -39
  49. package/tx/workers/read.js +6 -40
  50. package/tx/workers/search.js +2 -8
  51. package/tx/workers/subsumes.js +58 -41
  52. package/tx/workers/translate.js +11 -17
  53. package/tx/workers/validate.js +76 -77
  54. package/tx/workers/worker.js +70 -1
  55. package/tx/xml/xml-base.js +243 -76
  56. package/tx/xversion/xv-valueset.js +39 -1
package/tx/cs/cs-lang.js CHANGED
@@ -2,6 +2,7 @@ const { CodeSystemProvider, FilterExecutionContext, CodeSystemFactoryProvider} =
2
2
  const { Language } = require('../../library/languages');
3
3
  const { CodeSystem } = require("../library/codesystem");
4
4
  const assert = require('assert');
5
+ const { Issue } = require("../library/operation-outcome");
5
6
 
6
7
  /**
7
8
  * Language component types for filtering
@@ -18,14 +19,157 @@ const LanguageComponent = {
18
19
 
19
20
  const CODES_LanguageComponent = Object.values(LanguageComponent);
20
21
 
22
+ /**
23
+ * The components an `=` filter is offered on. These are the three with a bounded list
24
+ * of registered values behind them, which is what makes an expansion possible at all.
25
+ */
26
+ const EQUALITY_COMPONENTS = [LanguageComponent.LANG, LanguageComponent.SCRIPT, LanguageComponent.REGION];
27
+
21
28
  /**
22
29
  * Filter context for language component filters
23
30
  */
31
+ /**
32
+ * How this code system validates a tag. Deliberately stricter than the default parse -
33
+ * see locate().
34
+ */
35
+ const LANG_VALIDATION_OPTIONS = Object.freeze({ caseInsensitive: true, checkCombinations: true });
36
+
37
+ /**
38
+ * Which part of a tag a filter is about, and how a Language exposes it.
39
+ * @param {Language} lang
40
+ * @param {string} component
41
+ * @returns {string} the component's value, '' when the tag does not state it
42
+ */
43
+ function componentValue(lang, component) {
44
+ switch (component) {
45
+ case LanguageComponent.LANG: return lang.language || '';
46
+ case LanguageComponent.EXTLANG: return lang.extLang.join('-');
47
+ case LanguageComponent.SCRIPT: return lang.script || '';
48
+ case LanguageComponent.REGION: return lang.region || '';
49
+ case LanguageComponent.VARIANT: return lang.variant || '';
50
+ case LanguageComponent.EXTENSION: return lang.extension || '';
51
+ case LanguageComponent.PRIVATE_USE: return lang.privateUse.join('-');
52
+ default: return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * `<component> exists true|false` - does the tag state this part at all?
58
+ */
24
59
  class IETFLanguageCodeFilter {
25
60
  constructor(component, status) {
26
61
  this.component = component; // LanguageComponent
27
62
  this.status = status; // boolean - true if component must exist, false if must not exist
28
63
  }
64
+
65
+ /**
66
+ * @param {Language} lang
67
+ * @returns {boolean|string} true/false, or a message when the component is unknown
68
+ */
69
+ matches(lang) {
70
+ const value = componentValue(lang, this.component);
71
+ if (value === null) {
72
+ return `Unknown language component: ${this.component}`;
73
+ }
74
+ return (value !== '') === this.status;
75
+ }
76
+
77
+ /** Why a code did not match, for the caller. @returns {string} */
78
+ describe(code, _lang) {
79
+ const action = this.status ? 'does not contain' : 'contains';
80
+ const requirement = this.status ? 'required' : 'not allowed';
81
+ return `The language code ${code} ${action} a ${this.component}, and it is ${requirement}`;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * `<component> = <value>` - the tag states this part, with this value.
87
+ *
88
+ * Comparison is case-insensitive, like everything else about BCP 47 tags.
89
+ */
90
+ class IETFLanguageEqualityFilter {
91
+ constructor(component, value) {
92
+ this.component = component;
93
+ this.value = value;
94
+ }
95
+
96
+ matches(lang) {
97
+ const value = componentValue(lang, this.component);
98
+ if (value === null) {
99
+ return `Unknown language component: ${this.component}`;
100
+ }
101
+ return value.toLowerCase() === this.value.toLowerCase();
102
+ }
103
+
104
+ describe(code, _lang) {
105
+ return `The language code ${code} does not have a ${this.component} of '${this.value}'`;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * The enumerable combinations.
111
+ *
112
+ * The BCP 47 grammar is unbounded, so a filter can only be expanded where fixing part
113
+ * of a tag leaves a finite list of registry entries to vary. Three combinations do:
114
+ *
115
+ * - language fixed: every region, plus the bare language, since a tag need state no
116
+ * region at all
117
+ * - region fixed: every language with that region
118
+ * - language AND region fixed: every script, plus the tag with no script
119
+ *
120
+ * Nothing else is enumerable - a script on its own leaves the language open, and there
121
+ * are as many tags as there are languages times everything else. Every one of these is
122
+ * still an incomplete answer, because variants, extensions and private-use subtags can
123
+ * always be added, so the expansion is marked unclosed.
124
+ */
125
+ class IETFLanguageEnumerationFilter {
126
+ /**
127
+ * @param {string} mode - 'regions' | 'languages' | 'scripts'
128
+ * @param {object} fixed - { language, region } as far as they are fixed
129
+ * @param {LanguageDefinitions} definitions
130
+ * @param {IETFLanguageEqualityFilter[]} sources - the filters this replaces
131
+ */
132
+ constructor(mode, fixed, definitions, sources) {
133
+ this.mode = mode;
134
+ this.fixed = fixed;
135
+ this.definitions = definitions;
136
+ this.sources = sources;
137
+ this.cursor = 0;
138
+ this._codes = null;
139
+ }
140
+
141
+ /**
142
+ * The tags this filter covers, built once on demand.
143
+ * @returns {string[]}
144
+ */
145
+ codes() {
146
+ if (this._codes === null) {
147
+ const d = this.definitions;
148
+ if (this.mode === 'regions') {
149
+ this._codes = [this.fixed.language]
150
+ .concat([...d.regions.keys()].map(r => `${this.fixed.language}-${r}`));
151
+ } else if (this.mode === 'languages') {
152
+ this._codes = [...d.languages.keys()].map(l => `${l}-${this.fixed.region}`);
153
+ } else {
154
+ this._codes = [`${this.fixed.language}-${this.fixed.region}`]
155
+ .concat([...d.scripts.keys()].map(sc => `${this.fixed.language}-${sc}-${this.fixed.region}`));
156
+ }
157
+ }
158
+ return this._codes;
159
+ }
160
+
161
+ matches(lang) {
162
+ return this.sources.every(f => f.matches(lang) === true);
163
+ }
164
+
165
+ /**
166
+ * Only the rules that actually failed - saying `en-GB` has the wrong language, when
167
+ * it is the region that differs, sends the reader looking in the wrong place.
168
+ */
169
+ describe(code, lang) {
170
+ const failed = lang ? this.sources.filter(f => f.matches(lang) !== true) : this.sources;
171
+ return (failed.length ? failed : this.sources).map(f => f.describe(code)).join('; ');
172
+ }
29
173
  }
30
174
 
31
175
  /**
@@ -86,13 +230,108 @@ class IETFLanguageCodeProvider extends CodeSystemProvider {
86
230
  return super.hasAnyDisplays(langs);
87
231
  }
88
232
 
233
+ // ========== Subsumption ==========
234
+
235
+ /**
236
+ * Does one language tag subsume another?
237
+ *
238
+ * A tag is a set of named components - language, extended language, script, region,
239
+ * variant - not an ordered path, so one tag subsumes another when it says a subset of
240
+ * what the other says: `en` subsumes `en-US`, and `en-US` subsumes `en-Latn-US`,
241
+ * because everything true of the shorter tag is true of the longer one. The language
242
+ * itself must match; it is the one component that cannot be left unstated.
243
+ *
244
+ * This is RFC 4647's extended filtering rather than basic filtering. Basic filtering
245
+ * is strictly positional, so it would not have `en-US` subsume `en-Latn-US` - the
246
+ * script sits between the two subtags the range names. Extended filtering is the
247
+ * reading that matches what subsumption means, and it is the one that makes the
248
+ * relation transitive: `en` subsumes `en-US` subsumes `en-Latn-US`.
249
+ *
250
+ * Grandfathered tags are atomic. `i-klingon` has no components to compare - it does
251
+ * not decompose into valid subtags at all - so it relates only to itself.
252
+ *
253
+ * Note that Suppress-Script is deliberately not consulted: `en` carries
254
+ * `Suppress-Script: Latn`, which arguably makes `en-Latn` mean exactly `en`, but the
255
+ * registry calls that redundant rather than invalid and this server takes no position
256
+ * on it. So `en` subsumes `en-Latn` rather than being equivalent to it.
257
+ *
258
+ * @param {string} codeA
259
+ * @param {string} codeB
260
+ * @returns {Promise<string>} equivalent, subsumes, subsumed-by or not-subsumed
261
+ */
262
+ async subsumesTest(codeA, codeB) {
263
+ const a = await this.#ensureContext(codeA);
264
+ const b = await this.#ensureContext(codeB);
265
+ if (!(a instanceof Language) || !(b instanceof Language)) {
266
+ return 'not-subsumed';
267
+ }
268
+
269
+ // A grandfathered tag is matched whole, so the only tag it stands in any relation to
270
+ // is itself (spelling aside - tags are case-insensitive).
271
+ if (a.wholeTag || b.wholeTag) {
272
+ return a.toString().toLowerCase() === b.toString().toLowerCase() ? 'equivalent' : 'not-subsumed';
273
+ }
274
+
275
+ const aCoversB = this.#covers(a, b);
276
+ const bCoversA = this.#covers(b, a);
277
+ if (aCoversB && bCoversA) {
278
+ return 'equivalent';
279
+ }
280
+ if (aCoversB) {
281
+ return 'subsumes';
282
+ }
283
+ if (bCoversA) {
284
+ return 'subsumed-by';
285
+ }
286
+ return 'not-subsumed';
287
+ }
288
+
289
+ /**
290
+ * Is everything `general` states also stated, identically, by `specific`?
291
+ *
292
+ * Comparison is case-insensitive throughout: the parts have already been canonicalised
293
+ * by the parser, but a tag that arrived in the wrong case must still take part in
294
+ * subsumption on equal terms.
295
+ *
296
+ * @param {Language} general
297
+ * @param {Language} specific
298
+ * @returns {boolean}
299
+ * @private
300
+ */
301
+ #covers(general, specific) {
302
+ const same = (x, y) => (x || '').toLowerCase() === (y || '').toLowerCase();
303
+ const unstatedOrSame = (x, y) => !x || same(x, y);
304
+
305
+ if (!same(general.language, specific.language)) {
306
+ return false;
307
+ }
308
+ // Extended languages are ordered and there are at most three, so every one the
309
+ // general tag names has to appear in the specific tag, in the same order.
310
+ for (let i = 0; i < general.extLang.length; i++) {
311
+ if (!same(general.extLang[i], specific.extLang[i])) {
312
+ return false;
313
+ }
314
+ }
315
+ return unstatedOrSame(general.script, specific.script)
316
+ && unstatedOrSame(general.region, specific.region)
317
+ && unstatedOrSame(general.variant, specific.variant)
318
+ && unstatedOrSame(general.extension, specific.extension)
319
+ && general.privateUse.every((p, i) => same(p, specific.privateUse[i]));
320
+ }
321
+
89
322
  // ========== Code Information Methods ==========
90
323
 
91
324
  async code(code) {
92
325
 
93
326
  const ctxt = await this.#ensureContext(code);
94
327
  if (ctxt instanceof Language) {
95
- return ctxt.code;
328
+ // The CANONICAL casing, not what the caller wrote. BCP 47 s2.1.1 makes tags
329
+ // case-insensitive but recommends a casing - lower case language, Titlecase
330
+ // script, UPPER CASE region - and toString() reassembles the tag from parts that
331
+ // were canonicalised as they were parsed. Returning it here is what lets the
332
+ // validator notice a difference and hand back a normalized-code, the same way it
333
+ // does for any other case-insensitive code system.
334
+ return ctxt.toString();
96
335
  }
97
336
  throw new Error('Invalid context type');
98
337
  }
@@ -236,9 +475,19 @@ class IETFLanguageCodeProvider extends CodeSystemProvider {
236
475
  assert(!code || typeof code === 'string', 'code must be string');
237
476
  if (!code) return { context: null, message: 'Empty code' };
238
477
 
239
- const language = this.languageDefinitions.parse(code);
478
+ // Validating a code as a member of urn:ietf:bcp:47 is a stricter job than working
479
+ // out what a client asked for in an Accept-Language header, so this asks for the
480
+ // full check. The registry's own statements about subtag combinations - the Prefix
481
+ // on an extlang or variant - are applied, and a variant has to be registered at all.
482
+ // Case folding is on because BCP 47 s2.1.1 says tags are case-insensitive: 'en-us'
483
+ // is a valid way to write 'en-US', whatever the conventional casing recommends.
484
+ //
485
+ // These are OPTIONS rather than the default so that Accept-Language parsing keeps
486
+ // the behaviour it was written against - see Language's constructor.
487
+ const msg = {};
488
+ const language = this.languageDefinitions.parse(code, msg, LANG_VALIDATION_OPTIONS);
240
489
  if (!language) {
241
- return { context: null, message: undefined };
490
+ return { context: null, message: msg.message };
242
491
  }
243
492
 
244
493
  return { context: language, message: null };
@@ -256,6 +505,9 @@ class IETFLanguageCodeProvider extends CodeSystemProvider {
256
505
  if (op === 'exists' && (value === 'true' || value === 'false')) {
257
506
  return CODES_LanguageComponent.includes(prop);
258
507
  }
508
+ if (op === '=') {
509
+ return EQUALITY_COMPONENTS.includes(prop);
510
+ }
259
511
  return false;
260
512
  }
261
513
 
@@ -266,6 +518,22 @@ class IETFLanguageCodeProvider extends CodeSystemProvider {
266
518
  assert(op != null && typeof op === 'string', 'op must be a non-null string');
267
519
  assert(value != null && typeof value === 'string', 'value must be a non-null string');
268
520
 
521
+ if (op === '=') {
522
+ if (!EQUALITY_COMPONENTS.includes(prop)) {
523
+ throw new Error(`The '=' filter is only supported on ${EQUALITY_COMPONENTS.join(', ')}, not '${prop}'`);
524
+ }
525
+ // The value has to be a registered subtag of the right kind, or the filter selects
526
+ // nothing and says nothing about why
527
+ const known = { language: this.languageDefinitions.languages,
528
+ script: this.languageDefinitions.scripts, region: this.languageDefinitions.regions }[prop];
529
+ const canonical = [...known.keys()].find(k => k.toLowerCase() === value.toLowerCase());
530
+ if (!canonical) {
531
+ throw new Error(`'${value}' is not a valid ${prop} subtag, so it cannot be filtered on`);
532
+ }
533
+ filterContext.filters.push(new IETFLanguageEqualityFilter(prop, canonical));
534
+ return;
535
+ }
536
+
269
537
  if (op !== 'exists') {
270
538
  throw new Error(`Unsupported filter operator: ${op}`);
271
539
  }
@@ -288,15 +556,46 @@ class IETFLanguageCodeProvider extends CodeSystemProvider {
288
556
  async executeFilters(filterContext) {
289
557
 
290
558
  assert(filterContext && filterContext instanceof FilterExecutionContext, 'filterContext must be a FilterExecutionContext');
291
- return filterContext.filters;
559
+
560
+ // Whether anything can be enumerated depends on the filters TOGETHER, not one at a
561
+ // time: language alone gives regions, region alone gives languages, and the two
562
+ // together give scripts. So the equality filters are collapsed into a single set
563
+ // that knows which of those it is. Anything else is left alone and acts as a
564
+ // predicate - see filterCheck.
565
+ const equality = filterContext.filters.filter(f => f instanceof IETFLanguageEqualityFilter);
566
+ const language = equality.find(f => f.component === LanguageComponent.LANG);
567
+ const region = equality.find(f => f.component === LanguageComponent.REGION);
568
+ const script = equality.find(f => f.component === LanguageComponent.SCRIPT);
569
+
570
+ let mode = null;
571
+ if (language && region && !script) {
572
+ mode = 'scripts';
573
+ } else if (language && !region && !script) {
574
+ mode = 'regions';
575
+ } else if (region && !language && !script) {
576
+ mode = 'languages';
577
+ }
578
+ if (!mode) {
579
+ return filterContext.filters;
580
+ }
581
+
582
+ const combined = new IETFLanguageEnumerationFilter(mode,
583
+ { language: language ? language.value : null, region: region ? region.value : null },
584
+ this.languageDefinitions, [language, region].filter(f => f));
585
+ return [combined].concat(filterContext.filters.filter(f => f !== language && f !== region));
292
586
  }
293
587
 
294
588
  async filterSize(filterContext, set) {
295
589
 
296
590
  assert(filterContext && filterContext instanceof FilterExecutionContext, 'filterContext must be a FilterExecutionContext');
297
- assert(set && set instanceof IETFLanguageCodeFilter, 'set must be a IETFLanguageCodeFilter');
298
591
 
299
- throw new Error('Language valuesets cannot be expanded as they are based on a grammar');
592
+ if (set instanceof IETFLanguageEnumerationFilter) {
593
+ return set.codes().length;
594
+ }
595
+ throw new Issue('error', 'not-supported', null, 'CODESYSTEM_NOT_ENUMERABLE',
596
+ 'This filter on urn:ietf:bcp:47 cannot be expanded: the language grammar is unbounded, and only '
597
+ + 'a fixed language (giving its regions), a fixed region (giving its languages), or both '
598
+ + '(giving the scripts) leave a finite list to enumerate', 'not-supported', 422);
300
599
  }
301
600
 
302
601
  async filtersNotClosed(filterContext) {
@@ -308,103 +607,56 @@ class IETFLanguageCodeProvider extends CodeSystemProvider {
308
607
  async filterMore(filterContext, set) {
309
608
 
310
609
  assert(filterContext && filterContext instanceof FilterExecutionContext, 'filterContext must be a FilterExecutionContext');
311
- assert(set && set instanceof IETFLanguageCodeFilter, 'set must be a IETFLanguageCodeFilter');
312
- throw new Error('Language valuesets cannot be expanded as they are based on a grammar');
610
+
611
+ if (set instanceof IETFLanguageEnumerationFilter) {
612
+ return set.cursor < set.codes().length;
613
+ }
614
+ // Throwing rather than answering false: a mistake here would otherwise surface as an
615
+ // empty expansion, which reads as "there are none" instead of "this cannot be done".
616
+ throw new Issue('error', 'not-supported', null, 'CODESYSTEM_NOT_ENUMERABLE',
617
+ 'This filter on urn:ietf:bcp:47 cannot be expanded', 'not-supported', 422);
313
618
  }
314
619
 
315
620
  async filterConcept(filterContext, set) {
316
621
 
317
622
  assert(filterContext && filterContext instanceof FilterExecutionContext, 'filterContext must be a FilterExecutionContext');
318
- assert(set && set instanceof IETFLanguageCodeFilter, 'set must be a IETFLanguageCodeFilter');
319
- throw new Error('Language valuesets cannot be expanded as they are based on a grammar');
623
+
624
+ if (set instanceof IETFLanguageEnumerationFilter) {
625
+ const code = set.codes()[set.cursor];
626
+ set.cursor++;
627
+ return this.languageDefinitions.parse(code, {}, LANG_VALIDATION_OPTIONS);
628
+ }
629
+ throw new Issue('error', 'not-supported', null, 'CODESYSTEM_NOT_ENUMERABLE',
630
+ 'This filter on urn:ietf:bcp:47 cannot be expanded', 'not-supported', 422);
320
631
  }
321
632
 
322
633
  async filterLocate(filterContext, set, code) {
323
634
 
324
635
  assert(filterContext && filterContext instanceof FilterExecutionContext, 'filterContext must be a FilterExecutionContext');
325
- assert(set && set instanceof IETFLanguageCodeFilter, 'set must be a IETFLanguageCodeFilter');
636
+ assert(set && typeof set.matches === 'function', 'set must be a language filter');
326
637
  assert(typeof code === 'string', 'code must be non-null string');
327
638
 
328
- const language = this.languageDefinitions.parse(code);
639
+ // Same rules as locate(), or a code could pass one path and fail the other
640
+ const language = this.languageDefinitions.parse(code, {}, LANG_VALIDATION_OPTIONS);
329
641
  if (!language) {
330
642
  return `Invalid language code: ${code}`;
331
643
  }
332
644
 
333
- const filter = set;
334
- let hasComponent = false;
335
-
336
- switch (filter.component) {
337
- case LanguageComponent.LANG:
338
- hasComponent = !!language.language;
339
- break;
340
- case LanguageComponent.EXTLANG:
341
- hasComponent = !!language.extLang.length;
342
- break;
343
- case LanguageComponent.SCRIPT:
344
- hasComponent = !!language.script;
345
- break;
346
- case LanguageComponent.REGION:
347
- hasComponent = !!language.region;
348
- break;
349
- case LanguageComponent.VARIANT:
350
- hasComponent = !!language.variant;
351
- break;
352
- case LanguageComponent.EXTENSION:
353
- hasComponent = !!language.extension;
354
- break;
355
- case LanguageComponent.PRIVATE_USE:
356
- hasComponent = language.privateUse.length > 0;
357
- break;
358
- default:
359
- return `Unknown language component: ${filter.component}`;
360
- }
361
-
362
- if (hasComponent === filter.status) {
363
- return language;
364
- } else {
365
- const action = filter.status ? 'does not contain' : 'contains';
366
- const requirement = filter.status ? 'required' : 'not allowed';
367
- return `The language code ${code} ${action} a ${filter.component}, and it is ${requirement}`;
645
+ const result = set.matches(language);
646
+ if (typeof result === 'string') {
647
+ return result; // the filter could not be evaluated at all
368
648
  }
649
+ return result ? language : set.describe(code, language);
369
650
  }
370
651
 
652
+
371
653
  async filterCheck(filterContext, set, concept) {
372
654
 
373
655
  assert(filterContext && filterContext instanceof FilterExecutionContext, 'filterContext must be a FilterExecutionContext');
374
- assert(set && set instanceof IETFLanguageCodeFilter, 'set must be a IETFLanguageCodeFilter');
656
+ assert(set && typeof set.matches === 'function', 'set must be a language filter');
375
657
  const ctxt = await this.#ensureContext(concept);
376
658
 
377
-
378
- const filter = set;
379
- let hasComponent = false;
380
-
381
- switch (filter.component) {
382
- case LanguageComponent.LANG:
383
- hasComponent = !!ctxt.language;
384
- break;
385
- case LanguageComponent.EXTLANG:
386
- hasComponent = ctxt.extLang.length > 0;
387
- break;
388
- case LanguageComponent.SCRIPT:
389
- hasComponent = !!ctxt.script;
390
- break;
391
- case LanguageComponent.REGION:
392
- hasComponent = !!ctxt.region;
393
- break;
394
- case LanguageComponent.VARIANT:
395
- hasComponent = !!ctxt.variant;
396
- break;
397
- case LanguageComponent.EXTENSION:
398
- hasComponent = !!ctxt.extension;
399
- break;
400
- case LanguageComponent.PRIVATE_USE:
401
- hasComponent = ctxt.privateUse.length > 0;
402
- break;
403
- default:
404
- return `Unknown language component: ${filter.component}`;
405
- }
406
-
407
- return hasComponent === filter.status;
659
+ return set.matches(ctxt);
408
660
  }
409
661
 
410
662
 
@@ -420,12 +672,6 @@ class IETFLanguageCodeProvider extends CodeSystemProvider {
420
672
  return codeA === codeB;
421
673
  }
422
674
 
423
- async subsumesTest(codeA, codeB) {
424
- await this.#ensureContext(codeA);
425
- await this.#ensureContext(codeB);
426
- return 'not-subsumed'; // No subsumption in language codes
427
- }
428
-
429
675
  versionAlgorithm() {
430
676
  return null;
431
677
  }
package/tx/cs/cs-loinc.js CHANGED
@@ -1037,12 +1037,53 @@ class LoincServices extends BaseCSServices {
1037
1037
  return set.hasKey(concept.key);
1038
1038
  }
1039
1039
 
1040
- // Subsumption testing
1040
+ /**
1041
+ * Subsumption over the LOINC multiaxial hierarchy. The Closure table is a full transitive
1042
+ * ancestor/descendent closure - the importer walks PATH_TO_ROOT from
1043
+ * ComponentHierarchyBySystem and records every ancestor on the path, not just the immediate
1044
+ * parent - so one indexed lookup each way answers the question. This is the same table the
1045
+ * `is-a` / `descendent-of` filters run against, so $subsumes and an expansion agree.
1046
+ *
1047
+ * In practice the relationships found are Part-to-Part and Part-to-code: LOINC codes hang off
1048
+ * the hierarchy as leaves, so two codes never subsume one another.
1049
+ *
1050
+ * @param {string|LoincProviderContext} codeA
1051
+ * @param {string|LoincProviderContext} codeB
1052
+ * @returns {string} equivalent, subsumes, subsumed-by or not-subsumed
1053
+ */
1041
1054
  async subsumesTest(codeA, codeB) {
1042
- await this.#ensureContext(codeA);
1043
- await this.#ensureContext(codeB);
1055
+ const a = await this.#ensureContext(codeA);
1056
+ const b = await this.#ensureContext(codeB);
1057
+
1058
+ if (a.key === b.key) {
1059
+ return 'equivalent';
1060
+ }
1061
+ if (await this.#isAncestorOf(a.key, b.key)) {
1062
+ return 'subsumes';
1063
+ }
1064
+ if (await this.#isAncestorOf(b.key, a.key)) {
1065
+ return 'subsumed-by';
1066
+ }
1067
+ return 'not-subsumed';
1068
+ }
1044
1069
 
1045
- return 'not-subsumed'; // Not implemented yet
1070
+ /**
1071
+ * Is ancestorKey an ancestor of descendentKey in the multiaxial hierarchy?
1072
+ * @param {number} ancestorKey
1073
+ * @param {number} descendentKey
1074
+ * @returns {Promise<boolean>}
1075
+ */
1076
+ async #isAncestorOf(ancestorKey, descendentKey) {
1077
+ return new Promise((resolve, reject) => {
1078
+ const sql = 'SELECT 1 FROM Closure WHERE AncestorKey = ? AND DescendentKey = ? LIMIT 1';
1079
+ this.db.get(sql, [ancestorKey, descendentKey], (err, row) => {
1080
+ if (err) {
1081
+ reject(err);
1082
+ } else {
1083
+ resolve(!!row);
1084
+ }
1085
+ });
1086
+ });
1046
1087
  }
1047
1088
 
1048
1089
  versionAlgorithm() {