fhirsmith 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -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 +422 -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 +7 -2
- 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 +9 -0
- package/tx/README.md +52 -0
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +56 -1
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +47 -25
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +98 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/provider.js +16 -4
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +15 -1
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +144 -2
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +33 -6
- 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 +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/xig/xig.js +32 -0
- package/registry/readme.md +0 -124
package/tx/sct/ecl.js
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* This implementation provides parsing, validation, and evaluation of ECL expressions
|
|
5
5
|
* against SNOMED CT data using the existing SnomedServices infrastructure.
|
|
6
6
|
*
|
|
7
|
-
* Supports ECL v2.
|
|
7
|
+
* Supports the ECL v2.2 grammar from SNOMED International (the parser); which
|
|
8
|
+
* constructs are evaluated is documented on SnomedServices.filterECL in
|
|
9
|
+
* cs-snomed.js. The ECLValidator evaluate* methods in this file are a legacy
|
|
10
|
+
* evaluator that the server does not use.
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
// ECL Token Types
|
|
@@ -25,6 +28,8 @@ const ECLTokenType = {
|
|
|
25
28
|
PARENT_OR_SELF_OF: 'PARENT_OR_SELF_OF', // >>! self + direct parents
|
|
26
29
|
ANCESTOR_OF: 'ANCESTOR_OF', // > all transitive ancestors, no self
|
|
27
30
|
ANCESTOR_OR_SELF_OF: 'ANCESTOR_OR_SELF_OF', // >> self + all transitive ancestors
|
|
31
|
+
TOP_OF_SET: 'TOP_OF_SET', // !!> members of the set with no ancestor in the set
|
|
32
|
+
BOTTOM_OF_SET: 'BOTTOM_OF_SET', // !!< members of the set with no descendant in the set
|
|
28
33
|
|
|
29
34
|
// Set operators
|
|
30
35
|
AND: 'AND',
|
|
@@ -54,6 +59,8 @@ const ECLTokenType = {
|
|
|
54
59
|
RPAREN: 'RPAREN', // )
|
|
55
60
|
LBRACE: 'LBRACE', // {
|
|
56
61
|
RBRACE: 'RBRACE', // }
|
|
62
|
+
LFILTER: 'LFILTER', // {{ (filter constraint open)
|
|
63
|
+
RFILTER: 'RFILTER', // }} (filter constraint close)
|
|
57
64
|
LBRACKET: 'LBRACKET', // [
|
|
58
65
|
RBRACKET: 'RBRACKET', // ]
|
|
59
66
|
|
|
@@ -61,6 +68,7 @@ const ECLTokenType = {
|
|
|
61
68
|
COMMA: 'COMMA', // ,
|
|
62
69
|
PIPE: 'PIPE', // |
|
|
63
70
|
HASH: 'HASH', // #
|
|
71
|
+
PLUS: 'PLUS', // + (history supplement)
|
|
64
72
|
|
|
65
73
|
// Special tokens
|
|
66
74
|
WS: 'WS',
|
|
@@ -78,6 +86,10 @@ const ECLNodeType = {
|
|
|
78
86
|
CONCEPT_REFERENCE: 'ConceptReference',
|
|
79
87
|
WILDCARD: 'Wildcard',
|
|
80
88
|
MEMBER_OF: 'MemberOf',
|
|
89
|
+
MEMBER_FILTER: 'MemberFilter', // one {{ M ... }} term
|
|
90
|
+
CONCEPT_FILTER: 'ConceptFilter', // one {{ C ... }} term
|
|
91
|
+
DESCRIPTION_FILTER: 'DescriptionFilter', // one {{ D ... }} term
|
|
92
|
+
FILTER_CONSTRAINT: 'FilterConstraint', // a {{ D ... }} / {{ C ... }} / {{ + ... }} block (parsed, not evaluated)
|
|
81
93
|
|
|
82
94
|
REFINEMENT: 'Refinement',
|
|
83
95
|
ATTRIBUTE_SET: 'AttributeSet',
|
|
@@ -88,6 +100,8 @@ const ECLNodeType = {
|
|
|
88
100
|
EXPRESSION_COMPARISON: 'ExpressionComparison',
|
|
89
101
|
NUMERIC_COMPARISON: 'NumericComparison',
|
|
90
102
|
STRING_COMPARISON: 'StringComparison',
|
|
103
|
+
BOOLEAN_COMPARISON: 'BooleanComparison',
|
|
104
|
+
ALT_IDENTIFIER: 'AltIdentifier', // scheme#code (ECL 2.1 alternate identifier)
|
|
91
105
|
|
|
92
106
|
CONJUNCTION: 'Conjunction',
|
|
93
107
|
DISJUNCTION: 'Disjunction',
|
|
@@ -213,6 +227,23 @@ class ECLLexer {
|
|
|
213
227
|
continue;
|
|
214
228
|
}
|
|
215
229
|
|
|
230
|
+
// ECL comments: /* ... */ are whitespace
|
|
231
|
+
if (this.current === '/' && this.peek() === '*') {
|
|
232
|
+
const start = this.position;
|
|
233
|
+
this.advance();
|
|
234
|
+
this.advance();
|
|
235
|
+
while (this.current && !(this.current === '*' && this.peek() === '/')) {
|
|
236
|
+
this.advance();
|
|
237
|
+
}
|
|
238
|
+
if (!this.current) {
|
|
239
|
+
this.position = start;
|
|
240
|
+
this.error('Unterminated comment - missing */');
|
|
241
|
+
}
|
|
242
|
+
this.advance();
|
|
243
|
+
this.advance();
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
216
247
|
// Single character tokens
|
|
217
248
|
switch (this.current) {
|
|
218
249
|
case '(':
|
|
@@ -222,9 +253,21 @@ class ECLLexer {
|
|
|
222
253
|
this.advance();
|
|
223
254
|
return { type: ECLTokenType.RPAREN, value: ')' };
|
|
224
255
|
case '{':
|
|
256
|
+
// "{{" opens an ECL 2.x filter constraint; attribute groups never nest,
|
|
257
|
+
// so a doubled brace is unambiguous.
|
|
258
|
+
if (this.peek() === '{') {
|
|
259
|
+
this.advance();
|
|
260
|
+
this.advance();
|
|
261
|
+
return { type: ECLTokenType.LFILTER, value: '{{' };
|
|
262
|
+
}
|
|
225
263
|
this.advance();
|
|
226
264
|
return { type: ECLTokenType.LBRACE, value: '{' };
|
|
227
265
|
case '}':
|
|
266
|
+
if (this.peek() === '}') {
|
|
267
|
+
this.advance();
|
|
268
|
+
this.advance();
|
|
269
|
+
return { type: ECLTokenType.RFILTER, value: '}}' };
|
|
270
|
+
}
|
|
228
271
|
this.advance();
|
|
229
272
|
return { type: ECLTokenType.RBRACE, value: '}' };
|
|
230
273
|
case '[':
|
|
@@ -332,7 +375,24 @@ class ECLLexer {
|
|
|
332
375
|
return { type: ECLTokenType.EQUALS, value: '=' };
|
|
333
376
|
}
|
|
334
377
|
|
|
378
|
+
if (this.current === '+') {
|
|
379
|
+
this.advance();
|
|
380
|
+
return { type: ECLTokenType.PLUS, value: '+' };
|
|
381
|
+
}
|
|
382
|
+
|
|
335
383
|
if (this.current === '!') {
|
|
384
|
+
if (this.peek() === '!' && this.peek(2) === '>') {
|
|
385
|
+
this.advance();
|
|
386
|
+
this.advance();
|
|
387
|
+
this.advance();
|
|
388
|
+
return { type: ECLTokenType.TOP_OF_SET, value: '!!>' };
|
|
389
|
+
}
|
|
390
|
+
if (this.peek() === '!' && this.peek(2) === '<') {
|
|
391
|
+
this.advance();
|
|
392
|
+
this.advance();
|
|
393
|
+
this.advance();
|
|
394
|
+
return { type: ECLTokenType.BOTTOM_OF_SET, value: '!!<' };
|
|
395
|
+
}
|
|
336
396
|
if (this.peek() === '=') {
|
|
337
397
|
this.advance();
|
|
338
398
|
this.advance();
|
|
@@ -379,7 +439,7 @@ class ECLLexer {
|
|
|
379
439
|
// Keywords and identifiers
|
|
380
440
|
if (/[a-zA-Z_]/.test(this.current)) {
|
|
381
441
|
let value = '';
|
|
382
|
-
while (this.current && /[a-zA-Z0-9_]/.test(this.current)) {
|
|
442
|
+
while (this.current && (/[a-zA-Z0-9_]/.test(this.current) || (this.current === '-' && /[a-zA-Z]/.test(this.peek() || '')))) {
|
|
383
443
|
value += this.current;
|
|
384
444
|
this.advance();
|
|
385
445
|
}
|
|
@@ -550,7 +610,8 @@ class ECLParser {
|
|
|
550
610
|
ECLTokenType.CHILD_OF, ECLTokenType.CHILD_OR_SELF_OF,
|
|
551
611
|
ECLTokenType.DESCENDANT_OF, ECLTokenType.DESCENDANT_OR_SELF_OF,
|
|
552
612
|
ECLTokenType.PARENT_OF, ECLTokenType.PARENT_OR_SELF_OF,
|
|
553
|
-
ECLTokenType.ANCESTOR_OF, ECLTokenType.ANCESTOR_OR_SELF_OF
|
|
613
|
+
ECLTokenType.ANCESTOR_OF, ECLTokenType.ANCESTOR_OR_SELF_OF,
|
|
614
|
+
ECLTokenType.TOP_OF_SET, ECLTokenType.BOTTOM_OF_SET
|
|
554
615
|
)) {
|
|
555
616
|
operator = this.current;
|
|
556
617
|
this.advance();
|
|
@@ -561,6 +622,22 @@ class ECLParser {
|
|
|
561
622
|
// Handle memberOf
|
|
562
623
|
if (this.match(ECLTokenType.MEMBER_OF)) {
|
|
563
624
|
this.advance(); // consume ^
|
|
625
|
+
// Optional reference set field selection: ^ [field, field] refset
|
|
626
|
+
// (ECL 2.x). Parsed into focus.fields; the evaluator decides what it
|
|
627
|
+
// can do with it.
|
|
628
|
+
let fields = null;
|
|
629
|
+
if (this.match(ECLTokenType.LBRACKET)) {
|
|
630
|
+
this.advance();
|
|
631
|
+
fields = [];
|
|
632
|
+
do {
|
|
633
|
+
if (!this.match(ECLTokenType.SCTID) || !/^[A-Za-z][A-Za-z0-9_]*$/.test(this.current.value)) {
|
|
634
|
+
this.error('Expected a reference set field name after ^ [');
|
|
635
|
+
}
|
|
636
|
+
fields.push(this.current.value);
|
|
637
|
+
this.advance();
|
|
638
|
+
} while (this.match(ECLTokenType.COMMA) && (this.advance(), true));
|
|
639
|
+
this.expect(ECLTokenType.RBRACKET);
|
|
640
|
+
}
|
|
564
641
|
// Parse the reference set - can be concept reference, wildcard, or parenthesized expression
|
|
565
642
|
// but NOT another constraint operator or member-of expression
|
|
566
643
|
let refSet;
|
|
@@ -576,6 +653,9 @@ class ECLParser {
|
|
|
576
653
|
type: ECLNodeType.MEMBER_OF,
|
|
577
654
|
refSet
|
|
578
655
|
};
|
|
656
|
+
if (fields) {
|
|
657
|
+
focus.fields = fields;
|
|
658
|
+
}
|
|
579
659
|
} else if (this.match(ECLTokenType.LPAREN)) {
|
|
580
660
|
this.advance(); // consume (
|
|
581
661
|
focus = this.parseExpressionConstraint();
|
|
@@ -584,15 +664,512 @@ class ECLParser {
|
|
|
584
664
|
focus = this.parseEclFocusConcept();
|
|
585
665
|
}
|
|
586
666
|
|
|
667
|
+
// ECL 2.x filter constraints: *(memberFilterConstraint) directly after a
|
|
668
|
+
// memberOf focus, then *(descriptionFilter / conceptFilter) and an optional
|
|
669
|
+
// history supplement after any sub expression. Member filters are parsed
|
|
670
|
+
// into focus.memberFilters; the others are parsed into focus-level
|
|
671
|
+
// `filters` and rejected (as unsupported) at evaluation time.
|
|
672
|
+
const filters = [];
|
|
673
|
+
while (this.match(ECLTokenType.LFILTER)) {
|
|
674
|
+
const fc = this.parseFilterConstraint();
|
|
675
|
+
if (fc.kind === 'M') {
|
|
676
|
+
if (focus.type !== ECLNodeType.MEMBER_OF) {
|
|
677
|
+
throw new Error('A member filter ({{ M ... }}) may only follow a memberOf (^) expression constraint');
|
|
678
|
+
}
|
|
679
|
+
if (filters.length > 0) {
|
|
680
|
+
throw new Error('A member filter ({{ M ... }}) must precede any description, concept or history filter');
|
|
681
|
+
}
|
|
682
|
+
focus.memberFilters = (focus.memberFilters || []).concat(fc.filters);
|
|
683
|
+
} else {
|
|
684
|
+
filters.push(fc);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
587
688
|
const result = {
|
|
588
689
|
type: ECLNodeType.SUB_EXPRESSION_CONSTRAINT,
|
|
589
690
|
operator: operator ? operator.type : null,
|
|
590
691
|
focus
|
|
591
692
|
};
|
|
693
|
+
if (filters.length > 0) {
|
|
694
|
+
result.filters = filters;
|
|
695
|
+
}
|
|
592
696
|
|
|
593
697
|
return result;
|
|
594
698
|
}
|
|
595
699
|
|
|
700
|
+
/**
|
|
701
|
+
* Parse one "{{ ... }}" block. Returns { kind: 'M'|'D'|'C'|'+', filters } where
|
|
702
|
+
* filters is only populated for member filters; the other kinds keep their
|
|
703
|
+
* raw token text so the evaluator can report them as unsupported.
|
|
704
|
+
*/
|
|
705
|
+
parseFilterConstraint() {
|
|
706
|
+
this.expect(ECLTokenType.LFILTER);
|
|
707
|
+
let kind;
|
|
708
|
+
if (this.match(ECLTokenType.PLUS)) {
|
|
709
|
+
kind = '+';
|
|
710
|
+
this.advance();
|
|
711
|
+
} else if (this.match(ECLTokenType.SCTID) && /^[MDCmdc]$/.test(this.current.value)) {
|
|
712
|
+
kind = this.current.value.toUpperCase();
|
|
713
|
+
this.advance();
|
|
714
|
+
} else {
|
|
715
|
+
this.error('Expected M, D, C or + after {{');
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (kind === 'C' || kind === 'D') {
|
|
719
|
+
const parseOne = kind === 'C' ? () => this.parseConceptFilter() : () => this.parseDescriptionFilter();
|
|
720
|
+
const filters = [parseOne()];
|
|
721
|
+
while (this.match(ECLTokenType.COMMA)) {
|
|
722
|
+
this.advance();
|
|
723
|
+
filters.push(parseOne());
|
|
724
|
+
}
|
|
725
|
+
this.expect(ECLTokenType.RFILTER);
|
|
726
|
+
return { type: ECLNodeType.FILTER_CONSTRAINT, kind, filters };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (kind !== 'M') {
|
|
730
|
+
// Skip to the closing }} keeping the raw text for diagnostics
|
|
731
|
+
const raw = [];
|
|
732
|
+
while (!this.match(ECLTokenType.RFILTER)) {
|
|
733
|
+
if (this.match(ECLTokenType.EOF)) {
|
|
734
|
+
this.error('Unterminated filter constraint - missing }}');
|
|
735
|
+
}
|
|
736
|
+
raw.push(this.current.value);
|
|
737
|
+
this.advance();
|
|
738
|
+
}
|
|
739
|
+
this.advance(); // }}
|
|
740
|
+
return { type: ECLNodeType.FILTER_CONSTRAINT, kind, raw: raw.join(' ') };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const filters = [this.parseMemberFilter()];
|
|
744
|
+
while (this.match(ECLTokenType.COMMA)) {
|
|
745
|
+
this.advance();
|
|
746
|
+
filters.push(this.parseMemberFilter());
|
|
747
|
+
}
|
|
748
|
+
this.expect(ECLTokenType.RFILTER);
|
|
749
|
+
return { type: ECLNodeType.FILTER_CONSTRAINT, kind, filters };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* descriptionFilter = termFilter / languageFilter / typeFilter / dialectFilter
|
|
754
|
+
* / moduleFilter / effectiveTimeFilter / activeFilter / descriptionIdFilter
|
|
755
|
+
*
|
|
756
|
+
* term = / != (typedSearchTerm / "(" typedSearchTerm* ")") typedSearchTerm = [match:]"..." / wild:"..."
|
|
757
|
+
* language = / != (languageCode / "(" languageCode* ")") e.g. en, fr
|
|
758
|
+
* typeId = / != (subExpressionConstraint / "(" conceptRef* ")")
|
|
759
|
+
* type = / != (syn / fsn / def, or a bracketed set of them)
|
|
760
|
+
* dialectId = / != (subExpressionConstraint / "(" dialectId [acceptabilitySet]* ")") [acceptabilitySet]
|
|
761
|
+
* dialect = / != (dialectAlias / "(" dialectAlias [acceptabilitySet]* ")") [acceptabilitySet]
|
|
762
|
+
* moduleId = / != (subExpressionConstraint / "(" conceptRef* ")")
|
|
763
|
+
* effectiveTime timeComparisonOperator (timeValue / "(" timeValue* ")")
|
|
764
|
+
* active = / != (true / false / 1 / 0)
|
|
765
|
+
* id = / != (descriptionId / "(" descriptionId* ")")
|
|
766
|
+
*
|
|
767
|
+
* acceptabilitySet = "(" (preferred / acceptable / prefer / accept / conceptRef)* ")"
|
|
768
|
+
*/
|
|
769
|
+
parseDescriptionFilter() {
|
|
770
|
+
if (!this.match(ECLTokenType.SCTID) || !/^[A-Za-z_]/.test(this.current.value)) {
|
|
771
|
+
this.error('Expected term, language, typeId, type, dialectId, dialect, moduleId, effectiveTime, active or id in description filter');
|
|
772
|
+
}
|
|
773
|
+
const field = this.current.value;
|
|
774
|
+
const lower = field.toLowerCase();
|
|
775
|
+
this.advance();
|
|
776
|
+
const operator = this.parseComparisonOperator(field);
|
|
777
|
+
const node = { type: ECLNodeType.DESCRIPTION_FILTER, field, operator };
|
|
778
|
+
const booleanOnly = () => {
|
|
779
|
+
if (operator !== '=' && operator !== '!=') {
|
|
780
|
+
throw new Error(`Operator ${operator} is not valid for ${field} in a description filter`);
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
switch (lower) {
|
|
784
|
+
case 'term':
|
|
785
|
+
booleanOnly();
|
|
786
|
+
node.valueType = 'term';
|
|
787
|
+
node.values = this.parseStringValueSet(true).map(v => typeof v === 'string' ? { kind: 'match', value: v } : v);
|
|
788
|
+
for (const v of node.values) {
|
|
789
|
+
if (v.value.trim() === '') {
|
|
790
|
+
throw new Error('A term filter needs a non-empty search term');
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
break;
|
|
794
|
+
case 'language':
|
|
795
|
+
booleanOnly();
|
|
796
|
+
node.valueType = 'language';
|
|
797
|
+
node.values = this.parseTokenSet(/^[a-z]{2}$/i, 'a two-letter language code').map(v => v.toLowerCase());
|
|
798
|
+
break;
|
|
799
|
+
case 'typeid':
|
|
800
|
+
case 'moduleid':
|
|
801
|
+
booleanOnly();
|
|
802
|
+
node.valueType = 'expression';
|
|
803
|
+
node.value = this.parseConceptReferenceSetOrExpression();
|
|
804
|
+
break;
|
|
805
|
+
case 'type':
|
|
806
|
+
booleanOnly();
|
|
807
|
+
node.valueType = 'type';
|
|
808
|
+
node.values = this.parseTokenSet(/^(syn|fsn|def)$/i, 'syn, fsn or def').map(v => v.toLowerCase());
|
|
809
|
+
break;
|
|
810
|
+
case 'dialectid':
|
|
811
|
+
case 'dialect': {
|
|
812
|
+
booleanOnly();
|
|
813
|
+
node.valueType = lower === 'dialectid' ? 'dialectId' : 'dialect';
|
|
814
|
+
node.dialects = this.parseDialectSet(lower === 'dialectid');
|
|
815
|
+
if (this.match(ECLTokenType.LPAREN)) {
|
|
816
|
+
const acc = this.parseAcceptabilitySet();
|
|
817
|
+
for (const d of node.dialects) {
|
|
818
|
+
if (!d.acceptability) d.acceptability = acc;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
case 'effectivetime':
|
|
824
|
+
node.valueType = 'time';
|
|
825
|
+
node.values = this.parseTimeValueSet();
|
|
826
|
+
break;
|
|
827
|
+
case 'active':
|
|
828
|
+
booleanOnly();
|
|
829
|
+
node.valueType = 'boolean';
|
|
830
|
+
node.value = this.parseBooleanValue();
|
|
831
|
+
break;
|
|
832
|
+
case 'id':
|
|
833
|
+
booleanOnly();
|
|
834
|
+
node.valueType = 'id';
|
|
835
|
+
node.values = this.parseTokenSet(/^\d{6,18}$/, 'a description id');
|
|
836
|
+
break;
|
|
837
|
+
default:
|
|
838
|
+
throw new Error(`Unknown description filter field ${field} - expected term, language, typeId, type, dialectId, dialect, moduleId, effectiveTime, active or id`);
|
|
839
|
+
}
|
|
840
|
+
return node;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// token / "(" token* ")" where each token is an identifier matching `pattern`
|
|
844
|
+
parseTokenSet(pattern, what) {
|
|
845
|
+
const values = [];
|
|
846
|
+
const readOne = () => {
|
|
847
|
+
if (this.match(ECLTokenType.SCTID) && pattern.test(this.current.value)) {
|
|
848
|
+
values.push(this.current.value);
|
|
849
|
+
this.advance();
|
|
850
|
+
} else {
|
|
851
|
+
this.error(`Expected ${what}`);
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
if (this.match(ECLTokenType.LPAREN)) {
|
|
855
|
+
this.advance();
|
|
856
|
+
do {
|
|
857
|
+
readOne();
|
|
858
|
+
} while (!this.match(ECLTokenType.RPAREN) && !this.match(ECLTokenType.EOF));
|
|
859
|
+
this.expect(ECLTokenType.RPAREN);
|
|
860
|
+
} else {
|
|
861
|
+
readOne();
|
|
862
|
+
}
|
|
863
|
+
return values;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* The dialects of a dialect filter: [{ id | alias, acceptability? }]. A
|
|
868
|
+
* dialectId value may be a sub expression (one entry with `expression`), or a
|
|
869
|
+
* bracketed list of ids each optionally followed by its own acceptability set;
|
|
870
|
+
* a dialect alias (en-us, en-gb, ...) is an identifier.
|
|
871
|
+
*/
|
|
872
|
+
parseDialectSet(byId) {
|
|
873
|
+
const readAlias = () => {
|
|
874
|
+
// "en-gb" lexes as SCTID(en-gb) because '-' followed by a letter is kept in identifiers
|
|
875
|
+
if (this.match(ECLTokenType.SCTID) && /^[a-z]{2}(-[a-z0-9]+)*$/i.test(this.current.value)) {
|
|
876
|
+
const alias = this.current.value.toLowerCase();
|
|
877
|
+
this.advance();
|
|
878
|
+
return { alias };
|
|
879
|
+
}
|
|
880
|
+
this.error('Expected a dialect alias such as en-us or en-gb');
|
|
881
|
+
};
|
|
882
|
+
if (!byId) {
|
|
883
|
+
if (!this.match(ECLTokenType.LPAREN)) {
|
|
884
|
+
return [readAlias()];
|
|
885
|
+
}
|
|
886
|
+
this.advance();
|
|
887
|
+
const dialects = [];
|
|
888
|
+
while (!this.match(ECLTokenType.RPAREN) && !this.match(ECLTokenType.EOF)) {
|
|
889
|
+
const d = readAlias();
|
|
890
|
+
if (this.match(ECLTokenType.LPAREN)) d.acceptability = this.parseAcceptabilitySet();
|
|
891
|
+
dialects.push(d);
|
|
892
|
+
}
|
|
893
|
+
this.expect(ECLTokenType.RPAREN);
|
|
894
|
+
return dialects;
|
|
895
|
+
}
|
|
896
|
+
// by id: "(" id [acceptabilitySet] ... ")" or a sub expression
|
|
897
|
+
if (this.match(ECLTokenType.LPAREN) && this.peek().type === ECLTokenType.SCTID && /^\d+$/.test(this.peek().value)) {
|
|
898
|
+
this.advance();
|
|
899
|
+
const dialects = [];
|
|
900
|
+
while (!this.match(ECLTokenType.RPAREN) && !this.match(ECLTokenType.EOF)) {
|
|
901
|
+
const d = { expression: { type: ECLNodeType.SUB_EXPRESSION_CONSTRAINT, operator: null, focus: this.parseEclFocusConcept() } };
|
|
902
|
+
if (this.match(ECLTokenType.LPAREN)) d.acceptability = this.parseAcceptabilitySet();
|
|
903
|
+
dialects.push(d);
|
|
904
|
+
}
|
|
905
|
+
this.expect(ECLTokenType.RPAREN);
|
|
906
|
+
return dialects;
|
|
907
|
+
}
|
|
908
|
+
return [{ expression: this.parseSubExpressionConstraint() }];
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// "(" (preferred / acceptable / prefer / accept / conceptRef)* ")" -> ['preferred'|'acceptable'|conceptId ...]
|
|
912
|
+
parseAcceptabilitySet() {
|
|
913
|
+
this.expect(ECLTokenType.LPAREN);
|
|
914
|
+
const values = [];
|
|
915
|
+
while (!this.match(ECLTokenType.RPAREN) && !this.match(ECLTokenType.EOF)) {
|
|
916
|
+
if (this.match(ECLTokenType.SCTID) && /^(prefer|preferred|accept|acceptable)$/i.test(this.current.value)) {
|
|
917
|
+
values.push(/^pref/i.test(this.current.value) ? 'preferred' : 'acceptable');
|
|
918
|
+
this.advance();
|
|
919
|
+
} else if (this.match(ECLTokenType.SCTID) && /^\d+$/.test(this.current.value)) {
|
|
920
|
+
values.push(this.parseEclFocusConcept().conceptId);
|
|
921
|
+
} else {
|
|
922
|
+
this.error('Expected preferred, acceptable or an acceptability concept id');
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
this.expect(ECLTokenType.RPAREN);
|
|
926
|
+
if (values.length === 0) {
|
|
927
|
+
this.error('An acceptability set must not be empty');
|
|
928
|
+
}
|
|
929
|
+
return values;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* conceptFilter = definitionStatusIdFilter / definitionStatusTokenFilter / moduleFilter
|
|
934
|
+
* / effectiveTimeFilter / activeFilter
|
|
935
|
+
*
|
|
936
|
+
* definitionStatusId = / != (subExpressionConstraint / "(" conceptRef* ")")
|
|
937
|
+
* definitionStatus = / != (primitive / defined / "(" (primitive / defined)* ")")
|
|
938
|
+
* moduleId = / != (subExpressionConstraint / "(" conceptRef* ")")
|
|
939
|
+
* effectiveTime timeComparisonOperator (timeValue / "(" timeValue* ")")
|
|
940
|
+
* active = / != (true / false / 1 / 0)
|
|
941
|
+
*/
|
|
942
|
+
parseConceptFilter() {
|
|
943
|
+
if (!this.match(ECLTokenType.SCTID) || !/^[A-Za-z_]/.test(this.current.value)) {
|
|
944
|
+
this.error('Expected definitionStatusId, definitionStatus, moduleId, effectiveTime or active in concept filter');
|
|
945
|
+
}
|
|
946
|
+
const field = this.current.value;
|
|
947
|
+
const lower = field.toLowerCase();
|
|
948
|
+
this.advance();
|
|
949
|
+
const operator = this.parseComparisonOperator(field);
|
|
950
|
+
const node = { type: ECLNodeType.CONCEPT_FILTER, field, operator };
|
|
951
|
+
const booleanOnly = () => {
|
|
952
|
+
if (operator !== '=' && operator !== '!=') {
|
|
953
|
+
throw new Error(`Operator ${operator} is not valid for ${field} in a concept filter`);
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
switch (lower) {
|
|
957
|
+
case 'effectivetime':
|
|
958
|
+
node.valueType = 'time';
|
|
959
|
+
node.values = this.parseTimeValueSet();
|
|
960
|
+
break;
|
|
961
|
+
case 'active':
|
|
962
|
+
booleanOnly();
|
|
963
|
+
node.valueType = 'boolean';
|
|
964
|
+
node.value = this.parseBooleanValue();
|
|
965
|
+
break;
|
|
966
|
+
case 'moduleid':
|
|
967
|
+
case 'definitionstatusid':
|
|
968
|
+
booleanOnly();
|
|
969
|
+
node.valueType = 'expression';
|
|
970
|
+
node.value = this.parseConceptReferenceSetOrExpression();
|
|
971
|
+
break;
|
|
972
|
+
case 'definitionstatus': {
|
|
973
|
+
booleanOnly();
|
|
974
|
+
node.valueType = 'definitionStatus';
|
|
975
|
+
node.values = [];
|
|
976
|
+
const readToken = () => {
|
|
977
|
+
if (this.match(ECLTokenType.SCTID) && /^(primitive|defined)$/i.test(this.current.value)) {
|
|
978
|
+
node.values.push(this.current.value.toLowerCase());
|
|
979
|
+
this.advance();
|
|
980
|
+
} else {
|
|
981
|
+
this.error('Expected primitive or defined');
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
if (this.match(ECLTokenType.LPAREN)) {
|
|
985
|
+
this.advance();
|
|
986
|
+
do {
|
|
987
|
+
readToken();
|
|
988
|
+
} while (!this.match(ECLTokenType.RPAREN) && !this.match(ECLTokenType.EOF));
|
|
989
|
+
this.expect(ECLTokenType.RPAREN);
|
|
990
|
+
} else {
|
|
991
|
+
readToken();
|
|
992
|
+
}
|
|
993
|
+
break;
|
|
994
|
+
}
|
|
995
|
+
default:
|
|
996
|
+
throw new Error(`Unknown concept filter field ${field} - expected definitionStatusId, definitionStatus, moduleId, effectiveTime or active`);
|
|
997
|
+
}
|
|
998
|
+
return node;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
parseComparisonOperator(field) {
|
|
1002
|
+
if (this.match(ECLTokenType.EQUALS, ECLTokenType.NOT_EQUALS, ECLTokenType.LT, ECLTokenType.LTE,
|
|
1003
|
+
ECLTokenType.GT, ECLTokenType.GTE, ECLTokenType.DESCENDANT_OF, ECLTokenType.ANCESTOR_OF)) {
|
|
1004
|
+
// "<" and ">" lex as hierarchy operators; in a comparison they are less/greater than
|
|
1005
|
+
const t = this.current.type;
|
|
1006
|
+
const operator = t === ECLTokenType.DESCENDANT_OF ? '<' : t === ECLTokenType.ANCESTOR_OF ? '>' : this.current.value;
|
|
1007
|
+
this.advance();
|
|
1008
|
+
return operator;
|
|
1009
|
+
}
|
|
1010
|
+
this.error(`Expected comparison operator after ${field}`);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
parseTimeValueSet() {
|
|
1014
|
+
const values = this.parseStringValueSet();
|
|
1015
|
+
for (const v of values) {
|
|
1016
|
+
if (!/^\d{8}$/.test(v)) {
|
|
1017
|
+
throw new Error(`Invalid effectiveTime value "${v}" - expected "YYYYMMDD"`);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return values;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* subExpressionConstraint / eclConceptReferenceSet. The reference set form
|
|
1025
|
+
* "( 123 |a| 456 |b| )" is a bare list of concept references, which is not a
|
|
1026
|
+
* valid expression; it is parsed into an OR of the references.
|
|
1027
|
+
*/
|
|
1028
|
+
parseConceptReferenceSetOrExpression() {
|
|
1029
|
+
if (this.match(ECLTokenType.LPAREN)) {
|
|
1030
|
+
// look ahead: "(" SCTID [TERM] (SCTID / ")") marks a reference set
|
|
1031
|
+
let i = 1;
|
|
1032
|
+
if (this.peek(i).type === ECLTokenType.SCTID && /^\d+$/.test(this.peek(i).value)) {
|
|
1033
|
+
i++;
|
|
1034
|
+
if (this.peek(i).type === ECLTokenType.TERM) i++;
|
|
1035
|
+
if (this.peek(i).type === ECLTokenType.SCTID || this.peek(i).type === ECLTokenType.RPAREN) {
|
|
1036
|
+
this.advance(); // (
|
|
1037
|
+
let node = null;
|
|
1038
|
+
while (!this.match(ECLTokenType.RPAREN)) {
|
|
1039
|
+
if (!this.match(ECLTokenType.SCTID)) {
|
|
1040
|
+
this.error('Expected a concept reference in the reference set');
|
|
1041
|
+
}
|
|
1042
|
+
const ref = { type: ECLNodeType.SUB_EXPRESSION_CONSTRAINT, operator: null, focus: this.parseEclFocusConcept() };
|
|
1043
|
+
node = node === null ? ref : {
|
|
1044
|
+
type: ECLNodeType.COMPOUND_EXPRESSION_CONSTRAINT, operator: ECLNodeType.DISJUNCTION, left: node, right: ref
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
this.advance(); // )
|
|
1048
|
+
return node;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
return this.parseSubExpressionConstraint();
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* memberFilter = (refsetFieldName / "moduleId" / "effectiveTime" / "active") ws comparison
|
|
1057
|
+
*
|
|
1058
|
+
* The comparison form is decided by the field name for the three standard
|
|
1059
|
+
* fields, and otherwise by the token that follows the operator:
|
|
1060
|
+
* "#" number -> numeric
|
|
1061
|
+
* string / ( string ... ) -> string (or time, for an 8-digit value)
|
|
1062
|
+
* true/false/1/0 -> boolean
|
|
1063
|
+
* anything else -> subExpressionConstraint (a concept-valued field)
|
|
1064
|
+
*/
|
|
1065
|
+
parseMemberFilter() {
|
|
1066
|
+
if (!this.match(ECLTokenType.SCTID) || !/^[A-Za-z_]/.test(this.current.value)) {
|
|
1067
|
+
this.error('Expected a reference set field name in member filter');
|
|
1068
|
+
}
|
|
1069
|
+
const field = this.current.value;
|
|
1070
|
+
this.advance();
|
|
1071
|
+
|
|
1072
|
+
const operator = this.parseComparisonOperator(field);
|
|
1073
|
+
const node = { type: ECLNodeType.MEMBER_FILTER, field, operator };
|
|
1074
|
+
const lower = field.toLowerCase();
|
|
1075
|
+
|
|
1076
|
+
if (lower === 'effectivetime') {
|
|
1077
|
+
node.valueType = 'time';
|
|
1078
|
+
node.values = this.parseTimeValueSet();
|
|
1079
|
+
} else if (lower === 'active') {
|
|
1080
|
+
node.valueType = 'boolean';
|
|
1081
|
+
node.value = this.parseBooleanValue();
|
|
1082
|
+
if (operator !== '=' && operator !== '!=') {
|
|
1083
|
+
throw new Error(`Operator ${operator} is not valid for a boolean member filter`);
|
|
1084
|
+
}
|
|
1085
|
+
} else if (lower === 'moduleid') {
|
|
1086
|
+
node.valueType = 'expression';
|
|
1087
|
+
node.value = this.parseConceptReferenceSetOrExpression();
|
|
1088
|
+
if (operator !== '=' && operator !== '!=') {
|
|
1089
|
+
throw new Error(`Operator ${operator} is not valid for moduleId in a member filter`);
|
|
1090
|
+
}
|
|
1091
|
+
} else if (this.match(ECLTokenType.HASH)) {
|
|
1092
|
+
this.advance();
|
|
1093
|
+
if (!this.match(ECLTokenType.INTEGER, ECLTokenType.DECIMAL, ECLTokenType.SCTID)) {
|
|
1094
|
+
this.error('Expected a number after #');
|
|
1095
|
+
}
|
|
1096
|
+
node.valueType = 'numeric';
|
|
1097
|
+
node.value = this.current.value;
|
|
1098
|
+
this.advance();
|
|
1099
|
+
} else if (this.match(ECLTokenType.STRING, ECLTokenType.LPAREN) && this.#looksLikeStringSet()) {
|
|
1100
|
+
node.valueType = 'string';
|
|
1101
|
+
node.values = this.parseStringValueSet();
|
|
1102
|
+
} else if (this.match(ECLTokenType.SCTID) && /^(true|false|1|0)$/i.test(this.current.value) &&
|
|
1103
|
+
!this.#followedByTerm()) {
|
|
1104
|
+
node.valueType = 'boolean';
|
|
1105
|
+
node.value = this.parseBooleanValue();
|
|
1106
|
+
} else {
|
|
1107
|
+
node.valueType = 'expression';
|
|
1108
|
+
node.value = this.parseSubExpressionConstraint();
|
|
1109
|
+
if (operator !== '=' && operator !== '!=') {
|
|
1110
|
+
throw new Error(`Operator ${operator} is not valid for a concept-valued member filter field`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
return node;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Is the current token a string, or a bracketed list of strings (as opposed to
|
|
1117
|
+
// a bracketed sub expression constraint)?
|
|
1118
|
+
#looksLikeStringSet() {
|
|
1119
|
+
if (this.match(ECLTokenType.STRING)) return true;
|
|
1120
|
+
return this.peek().type === ECLTokenType.STRING;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
#followedByTerm() {
|
|
1124
|
+
return this.peek().type === ECLTokenType.TERM;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// string / "(" string *(ws string) ")" (also accepts the typed forms match:"x" / wild:"x")
|
|
1128
|
+
parseStringValueSet(allowWild = false) {
|
|
1129
|
+
const values = [];
|
|
1130
|
+
const readOne = () => {
|
|
1131
|
+
if (this.match(ECLTokenType.SCTID) && /^(match|wild)$/i.test(this.current.value) &&
|
|
1132
|
+
this.peek().type === ECLTokenType.COLON) {
|
|
1133
|
+
const kind = this.current.value.toLowerCase();
|
|
1134
|
+
this.advance();
|
|
1135
|
+
this.advance();
|
|
1136
|
+
if (kind === 'wild' && !allowWild) {
|
|
1137
|
+
throw new Error('wild: search terms are not supported in member filters');
|
|
1138
|
+
}
|
|
1139
|
+
if (!this.match(ECLTokenType.STRING)) {
|
|
1140
|
+
this.error('Expected a quoted string value');
|
|
1141
|
+
}
|
|
1142
|
+
values.push({ kind, value: this.current.value });
|
|
1143
|
+
this.advance();
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
if (!this.match(ECLTokenType.STRING)) {
|
|
1147
|
+
this.error('Expected a quoted string value');
|
|
1148
|
+
}
|
|
1149
|
+
values.push(this.current.value);
|
|
1150
|
+
this.advance();
|
|
1151
|
+
};
|
|
1152
|
+
if (this.match(ECLTokenType.LPAREN)) {
|
|
1153
|
+
this.advance();
|
|
1154
|
+
do {
|
|
1155
|
+
readOne();
|
|
1156
|
+
} while (!this.match(ECLTokenType.RPAREN) && !this.match(ECLTokenType.EOF));
|
|
1157
|
+
this.expect(ECLTokenType.RPAREN);
|
|
1158
|
+
} else {
|
|
1159
|
+
readOne();
|
|
1160
|
+
}
|
|
1161
|
+
return values;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
parseBooleanValue() {
|
|
1165
|
+
if (this.match(ECLTokenType.SCTID) && /^(true|false|1|0)$/i.test(this.current.value)) {
|
|
1166
|
+
const v = this.current.value.toLowerCase();
|
|
1167
|
+
this.advance();
|
|
1168
|
+
return v === 'true' || v === '1';
|
|
1169
|
+
}
|
|
1170
|
+
this.error('Expected true, false, 1 or 0');
|
|
1171
|
+
}
|
|
1172
|
+
|
|
596
1173
|
parseEclFocusConcept() {
|
|
597
1174
|
if (this.match(ECLTokenType.WILDCARD)) {
|
|
598
1175
|
this.advance();
|
|
@@ -601,8 +1178,33 @@ class ECLParser {
|
|
|
601
1178
|
};
|
|
602
1179
|
}
|
|
603
1180
|
|
|
1181
|
+
// ECL 2.1 alternate identifiers: "scheme#code" (quoted) or scheme#code
|
|
1182
|
+
if (this.match(ECLTokenType.STRING) && /^[A-Za-z0-9_-]+#.+$/.test(this.current.value)) {
|
|
1183
|
+
const [scheme, ...rest] = this.current.value.split('#');
|
|
1184
|
+
this.advance();
|
|
1185
|
+
return this.#altIdentifier(scheme, rest.join('#'));
|
|
1186
|
+
}
|
|
1187
|
+
if (this.match(ECLTokenType.SCTID) && /^[A-Za-z]/.test(this.current.value) && this.peek().type === ECLTokenType.HASH) {
|
|
1188
|
+
const scheme = this.current.value;
|
|
1189
|
+
this.advance();
|
|
1190
|
+
this.advance(); // #
|
|
1191
|
+
let code = '';
|
|
1192
|
+
// the code runs until whitespace: alphanumerics, "-", "." and "_"
|
|
1193
|
+
while (this.match(ECLTokenType.SCTID, ECLTokenType.INTEGER, ECLTokenType.DECIMAL, ECLTokenType.DOT, ECLTokenType.CARDINALITY_RANGE)) {
|
|
1194
|
+
code += this.current.value;
|
|
1195
|
+
this.advance();
|
|
1196
|
+
}
|
|
1197
|
+
if (code === '') {
|
|
1198
|
+
this.error(`Expected a code after ${scheme}#`);
|
|
1199
|
+
}
|
|
1200
|
+
return this.#altIdentifier(scheme, code);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
604
1203
|
if (this.match(ECLTokenType.SCTID)) {
|
|
605
1204
|
const conceptId = this.current.value;
|
|
1205
|
+
if (!/^\d{6,18}$/.test(conceptId)) {
|
|
1206
|
+
this.error(`"${conceptId}" is not a valid SNOMED CT concept id`);
|
|
1207
|
+
}
|
|
606
1208
|
this.advance();
|
|
607
1209
|
|
|
608
1210
|
let term = null;
|
|
@@ -621,60 +1223,130 @@ class ECLParser {
|
|
|
621
1223
|
this.error('Expected concept reference or wildcard');
|
|
622
1224
|
}
|
|
623
1225
|
|
|
624
|
-
|
|
625
|
-
|
|
1226
|
+
#altIdentifier(scheme, code) {
|
|
1227
|
+
let term = null;
|
|
1228
|
+
if (this.match(ECLTokenType.TERM)) {
|
|
1229
|
+
term = this.current.value;
|
|
1230
|
+
this.advance();
|
|
1231
|
+
}
|
|
1232
|
+
return { type: ECLNodeType.ALT_IDENTIFIER, scheme, code, term };
|
|
626
1233
|
}
|
|
627
1234
|
|
|
628
|
-
|
|
629
|
-
|
|
1235
|
+
/**
|
|
1236
|
+
* eclRefinement = subRefinement *(ws (AND / OR / ",") ws subRefinement)
|
|
1237
|
+
* subRefinement = eclAttributeSet / eclAttributeGroup / "(" eclRefinement ")"
|
|
1238
|
+
*
|
|
1239
|
+
* "," is a conjunction. As with compound expressions, AND and OR may not be
|
|
1240
|
+
* mixed in one un-bracketed sequence (ECL 6.4). A single sub refinement is
|
|
1241
|
+
* returned as-is; otherwise an ATTRIBUTE_SET with operator 'AND' or 'OR'.
|
|
1242
|
+
*/
|
|
1243
|
+
parseRefinement() {
|
|
1244
|
+
return this.parseRefinementSet(() => this.parseSubRefinement(), 'refinement');
|
|
1245
|
+
}
|
|
630
1246
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
let
|
|
636
|
-
if (this.match(ECLTokenType.
|
|
637
|
-
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
// Attribute group
|
|
641
|
-
attributes.push(this.parseAttributeGroup(cardinality));
|
|
1247
|
+
parseRefinementSet(parseItem, what) {
|
|
1248
|
+
const items = [parseItem()];
|
|
1249
|
+
let operator = null;
|
|
1250
|
+
for (;;) {
|
|
1251
|
+
let op;
|
|
1252
|
+
if (this.match(ECLTokenType.COMMA, ECLTokenType.AND)) {
|
|
1253
|
+
op = 'AND';
|
|
1254
|
+
} else if (this.match(ECLTokenType.OR)) {
|
|
1255
|
+
op = 'OR';
|
|
642
1256
|
} else {
|
|
643
|
-
|
|
644
|
-
attributes.push(this.parseAttribute(cardinality));
|
|
1257
|
+
break;
|
|
645
1258
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
1259
|
+
if (operator !== null && op !== operator) {
|
|
1260
|
+
throw new Error(
|
|
1261
|
+
`Ambiguous ECL ${what}: AND (or ",") and OR cannot be mixed without parentheses (ECL 6.4). ` +
|
|
1262
|
+
'Use round brackets to disambiguate.');
|
|
1263
|
+
}
|
|
1264
|
+
operator = op;
|
|
1265
|
+
this.advance();
|
|
1266
|
+
items.push(parseItem());
|
|
1267
|
+
}
|
|
1268
|
+
if (items.length === 1) {
|
|
1269
|
+
return items[0];
|
|
650
1270
|
}
|
|
651
|
-
|
|
652
1271
|
return {
|
|
653
1272
|
type: ECLNodeType.ATTRIBUTE_SET,
|
|
654
|
-
|
|
1273
|
+
operator,
|
|
1274
|
+
attributes: items
|
|
655
1275
|
};
|
|
656
1276
|
}
|
|
657
1277
|
|
|
1278
|
+
parseSubRefinement() {
|
|
1279
|
+
// A leading cardinality may prefix either a single attribute
|
|
1280
|
+
// ([m..n] attr = v) or an attribute group ([m..n] { ... }). Parse it once,
|
|
1281
|
+
// then dispatch on whether a group ({) or an attribute follows.
|
|
1282
|
+
let cardinality = null;
|
|
1283
|
+
if (this.match(ECLTokenType.LBRACKET)) {
|
|
1284
|
+
cardinality = this.parseCardinality();
|
|
1285
|
+
}
|
|
1286
|
+
if (this.match(ECLTokenType.LBRACE)) {
|
|
1287
|
+
return this.parseAttributeGroup(cardinality);
|
|
1288
|
+
}
|
|
1289
|
+
if (cardinality === null && this.match(ECLTokenType.LPAREN) && !this.#parenIsAttributeName()) {
|
|
1290
|
+
this.advance();
|
|
1291
|
+
const inner = this.parseRefinement();
|
|
1292
|
+
this.expect(ECLTokenType.RPAREN);
|
|
1293
|
+
return inner;
|
|
1294
|
+
}
|
|
1295
|
+
return this.parseAttribute(cardinality);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// At a "(" inside a refinement: is this a bracketed attribute name
|
|
1299
|
+
// ("(A OR B) = x") rather than a bracketed sub refinement ("(A = x OR B = y)")?
|
|
1300
|
+
// It is when the token after the matching ")" is a comparison operator.
|
|
1301
|
+
#parenIsAttributeName() {
|
|
1302
|
+
let depth = 0;
|
|
1303
|
+
for (let i = 0; ; i++) {
|
|
1304
|
+
const t = this.peek(i).type;
|
|
1305
|
+
if (t === ECLTokenType.EOF) return false;
|
|
1306
|
+
if (t === ECLTokenType.LPAREN) depth++;
|
|
1307
|
+
if (t === ECLTokenType.RPAREN && --depth === 0) {
|
|
1308
|
+
return [ECLTokenType.EQUALS, ECLTokenType.NOT_EQUALS, ECLTokenType.LT, ECLTokenType.LTE, ECLTokenType.GT,
|
|
1309
|
+
ECLTokenType.GTE, ECLTokenType.DESCENDANT_OF, ECLTokenType.ANCESTOR_OF].includes(this.peek(i + 1).type);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* eclAttributeSet = subAttributeSet *(ws (AND / OR / ",") ws subAttributeSet)
|
|
1316
|
+
* subAttributeSet = eclAttribute / "(" eclAttributeSet ")"
|
|
1317
|
+
* (the contents of an attribute group - no nested groups)
|
|
1318
|
+
*/
|
|
1319
|
+
parseAttributeSet() {
|
|
1320
|
+
return this.parseRefinementSet(() => {
|
|
1321
|
+
if (this.match(ECLTokenType.LPAREN) && !this.#parenIsAttributeName()) {
|
|
1322
|
+
this.advance();
|
|
1323
|
+
const inner = this.parseAttributeSet();
|
|
1324
|
+
this.expect(ECLTokenType.RPAREN);
|
|
1325
|
+
return inner;
|
|
1326
|
+
}
|
|
1327
|
+
return this.parseAttribute();
|
|
1328
|
+
}, 'attribute set');
|
|
1329
|
+
}
|
|
1330
|
+
|
|
658
1331
|
parseAttributeGroup(cardinality = null) {
|
|
659
|
-
// Cardinality may have been parsed by the caller (
|
|
1332
|
+
// Cardinality may have been parsed by the caller (parseSubRefinement); if
|
|
660
1333
|
// not, check for it here (before the opening brace).
|
|
661
1334
|
if (cardinality === null && this.match(ECLTokenType.LBRACKET)) {
|
|
662
1335
|
cardinality = this.parseCardinality();
|
|
663
1336
|
}
|
|
664
1337
|
|
|
665
1338
|
this.expect(ECLTokenType.LBRACE);
|
|
666
|
-
|
|
667
|
-
const attributes = [];
|
|
668
|
-
do {
|
|
669
|
-
attributes.push(this.parseAttribute());
|
|
670
|
-
} while (this.match(ECLTokenType.COMMA) && (this.advance(), true));
|
|
671
|
-
|
|
1339
|
+
const content = this.parseAttributeSet();
|
|
672
1340
|
this.expect(ECLTokenType.RBRACE);
|
|
673
1341
|
|
|
674
1342
|
return {
|
|
675
1343
|
type: ECLNodeType.ATTRIBUTE_GROUP,
|
|
676
1344
|
cardinality,
|
|
677
|
-
attributes
|
|
1345
|
+
// A plain conjunction of attributes ("{ a = x, b = y }") stays a flat
|
|
1346
|
+
// list, as it always was; anything with OR or nesting is a single
|
|
1347
|
+
// ATTRIBUTE_SET entry carrying that structure.
|
|
1348
|
+
attributes: content.type === ECLNodeType.ATTRIBUTE_SET && content.operator === 'AND' &&
|
|
1349
|
+
content.attributes.every(a => a.type === ECLNodeType.ATTRIBUTE) ? content.attributes : [content]
|
|
678
1350
|
};
|
|
679
1351
|
}
|
|
680
1352
|
|
|
@@ -700,13 +1372,30 @@ class ECLParser {
|
|
|
700
1372
|
if (this.match(ECLTokenType.EQUALS, ECLTokenType.NOT_EQUALS)) {
|
|
701
1373
|
const operator = this.current;
|
|
702
1374
|
this.advance();
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
1375
|
+
if (this.match(ECLTokenType.HASH)) {
|
|
1376
|
+
// numeric concrete value: = #5, != #1.5
|
|
1377
|
+
this.advance();
|
|
1378
|
+
if (!this.match(ECLTokenType.SCTID, ECLTokenType.DECIMAL, ECLTokenType.INTEGER)) {
|
|
1379
|
+
this.error('Expected numeric value after #');
|
|
1380
|
+
}
|
|
1381
|
+
comparison = { type: ECLNodeType.NUMERIC_COMPARISON, operator: operator.type, value: this.current.value };
|
|
1382
|
+
this.advance();
|
|
1383
|
+
} else if (this.match(ECLTokenType.STRING) ||
|
|
1384
|
+
(this.match(ECLTokenType.LPAREN) && this.peek().type === ECLTokenType.STRING) ||
|
|
1385
|
+
(this.match(ECLTokenType.SCTID) && /^(match|wild)$/i.test(this.current.value) && this.peek().type === ECLTokenType.COLON)) {
|
|
1386
|
+
// string concrete value: = "x", != ("x" "y"), = match:"x"
|
|
1387
|
+
comparison = { type: ECLNodeType.STRING_COMPARISON, operator: operator.type, values: this.parseStringValueSet(true) };
|
|
1388
|
+
} else if (this.match(ECLTokenType.SCTID) && /^(true|false)$/i.test(this.current.value) && this.peek().type !== ECLTokenType.TERM) {
|
|
1389
|
+
// boolean concrete value: = true, != false
|
|
1390
|
+
comparison = { type: ECLNodeType.BOOLEAN_COMPARISON, operator: operator.type, value: this.parseBooleanValue() };
|
|
1391
|
+
} else {
|
|
1392
|
+
const value = this.parseSubExpressionConstraint();
|
|
1393
|
+
comparison = {
|
|
1394
|
+
type: ECLNodeType.EXPRESSION_COMPARISON,
|
|
1395
|
+
operator: operator.type,
|
|
1396
|
+
value
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
710
1399
|
} else if (this.match(ECLTokenType.LT, ECLTokenType.LTE, ECLTokenType.DESCENDANT_OF, ECLTokenType.ANCESTOR_OF, ECLTokenType.GTE)) {
|
|
711
1400
|
// In ECL, bare `<` and `>` are overloaded: they lex as hierarchy
|
|
712
1401
|
// operators (DESCENDANT_OF / ANCESTOR_OF) but in an attribute comparison
|
|
@@ -750,8 +1439,18 @@ class ECLParser {
|
|
|
750
1439
|
};
|
|
751
1440
|
}
|
|
752
1441
|
|
|
1442
|
+
/**
|
|
1443
|
+
* eclAttributeName = subExpressionConstraint. A plain concept reference or
|
|
1444
|
+
* wildcard is returned unwrapped (the shape every consumer expects); anything
|
|
1445
|
+
* else (<< 363698007, (A OR B), ^ refset) is returned as the sub expression.
|
|
1446
|
+
*/
|
|
753
1447
|
parseAttributeName() {
|
|
754
|
-
|
|
1448
|
+
const node = this.parseSubExpressionConstraint();
|
|
1449
|
+
if (node.type === ECLNodeType.SUB_EXPRESSION_CONSTRAINT && !node.operator && !node.filters &&
|
|
1450
|
+
(node.focus.type === ECLNodeType.CONCEPT_REFERENCE || node.focus.type === ECLNodeType.WILDCARD)) {
|
|
1451
|
+
return node.focus;
|
|
1452
|
+
}
|
|
1453
|
+
return node;
|
|
755
1454
|
}
|
|
756
1455
|
|
|
757
1456
|
parseCardinality() {
|
|
@@ -865,11 +1564,23 @@ class ECLValidator {
|
|
|
865
1564
|
|
|
866
1565
|
case ECLNodeType.SUB_EXPRESSION_CONSTRAINT:
|
|
867
1566
|
this.validateAST(node.focus);
|
|
1567
|
+
for (const fc of node.filters || []) {
|
|
1568
|
+
for (const f of fc.filters || []) {
|
|
1569
|
+
if (f.valueType === 'expression') {
|
|
1570
|
+
this.validateAST(f.value);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
868
1574
|
break;
|
|
869
1575
|
|
|
870
1576
|
case ECLNodeType.MEMBER_OF:
|
|
871
1577
|
this.validateAST(node.refSet);
|
|
872
1578
|
this.validateReferenceSet(node.refSet);
|
|
1579
|
+
for (const f of node.memberFilters || []) {
|
|
1580
|
+
if (f.valueType === 'expression') {
|
|
1581
|
+
this.validateAST(f.value);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
873
1584
|
break;
|
|
874
1585
|
|
|
875
1586
|
case ECLNodeType.ATTRIBUTE_SET:
|
|
@@ -1002,7 +1713,8 @@ class ECLValidator {
|
|
|
1002
1713
|
break;
|
|
1003
1714
|
|
|
1004
1715
|
case ECLNodeType.STRING_COMPARISON:
|
|
1005
|
-
|
|
1716
|
+
case ECLNodeType.BOOLEAN_COMPARISON:
|
|
1717
|
+
// String and boolean values are always valid
|
|
1006
1718
|
break;
|
|
1007
1719
|
}
|
|
1008
1720
|
}
|
|
@@ -1606,10 +2318,10 @@ class ECLValidator {
|
|
|
1606
2318
|
attributes.push(refinementNode);
|
|
1607
2319
|
break;
|
|
1608
2320
|
case ECLNodeType.ATTRIBUTE_SET:
|
|
1609
|
-
attributes.push(...refinementNode.attributes);
|
|
1610
|
-
break;
|
|
1611
2321
|
case ECLNodeType.ATTRIBUTE_GROUP:
|
|
1612
|
-
|
|
2322
|
+
for (const a of refinementNode.attributes) {
|
|
2323
|
+
attributes.push(...this.extractAttributesFromRefinement(a));
|
|
2324
|
+
}
|
|
1613
2325
|
break;
|
|
1614
2326
|
}
|
|
1615
2327
|
|