From 8a4b61a4981d0621cb8fbde6882056194c97a24b Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Thu, 16 Apr 2026 22:32:55 +0800 Subject: [PATCH 1/6] prototype ECL support --- tx/cs/cs-snomed.js | 286 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 285 insertions(+), 1 deletion(-) diff --git a/tx/cs/cs-snomed.js b/tx/cs/cs-snomed.js index be867424..cec5cce3 100644 --- a/tx/cs/cs-snomed.js +++ b/tx/cs/cs-snomed.js @@ -445,6 +445,276 @@ class SnomedServices { } + /** + * Supported ECL subset: + * Plain concept ref 404684003 + * << (descendant-or-self-of) + * > (ancestor-or-self-of) + * >! (strict ancestor-of) + * > (parent-of) + * ^ (member-of refset) — refset must be a plain concept ID + * * (wildcard) + * AND / OR / MINUS compound expressions + * + * Everything else (refinements, dotted expressions, cardinality, + * reverse attributes, numeric/string comparisons) throws an informative error. + */ + + /** + * Parse an ECL expression string and return a SnomedFilterContext whose + * `descendants` array contains the resolved concept indexes. + * + * Throws an Error for syntax errors, unknown concepts, or unsupported features. + * + * @param {string} eclExpression + * @returns {SnomedFilterContext} + */ + filterECL = function (eclExpression) { + let ast; + try { + const tokens = new ECLLexer(eclExpression).tokenize(); + ast = new ECLParser(tokens).parse(); + } catch (err) { + throw new Error(`Invalid ECL expression: ${err.message}`); + } + return this._evalECLNode(ast); + }; + + /** + * Recursive ECL AST evaluator. + * @param {object} node + * @returns {SnomedFilterContext} + */ + _evalECLNode = function (node) { + if (!node) { + throw new Error('ECL evaluation error: null AST node'); + } + + switch (node.type) { + + case ECLNodeType.SUB_EXPRESSION_CONSTRAINT: + return this._evalSubExpression(node); + + case ECLNodeType.COMPOUND_EXPRESSION_CONSTRAINT: { + const left = this._evalECLNode(node.left); + const right = this._evalECLNode(node.right); + switch (node.operator) { + case ECLNodeType.CONJUNCTION: + return this._eclIntersect(left, right); + case ECLNodeType.DISJUNCTION: + return this._eclUnion(left, right); + case ECLNodeType.EXCLUSION: + return this._eclMinus(left, right); + default: + throw new Error(`Unsupported ECL compound operator: ${node.operator}`); + } + } + + case ECLNodeType.REFINED_EXPRESSION_CONSTRAINT: + throw new Error('ECL refinements (the : syntax) are not yet supported by this server'); + + case ECLNodeType.DOTTED_EXPRESSION_CONSTRAINT: + throw new Error('ECL dotted expressions are not yet supported by this server'); + + default: + // Could be a bare concept reference or wildcard passed in directly + // (e.g. when a parenthesised expression resolves to one of these). + if (node.type === ECLNodeType.CONCEPT_REFERENCE || + node.type === ECLNodeType.WILDCARD || + node.type === ECLNodeType.MEMBER_OF) { + // Wrap it as if it came from a no-operator SubExpressionConstraint + return this._evalSubExpression({type: ECLNodeType.SUB_EXPRESSION_CONSTRAINT, operator: null, focus: node}); + } + throw new Error(`Unsupported ECL node type: ${node.type}`); + } + }; + + /** + * Evaluate a SUB_EXPRESSION_CONSTRAINT node, which combines an optional + * hierarchy operator with a focus (concept ref, wildcard, or member-of). + * @param {object} node + * @returns {SnomedFilterContext} + */ + _evalSubExpression = function (node) { + const operator = node.operator; // an ECLTokenType string, or null + const focus = node.focus; + + // Wildcard + if (focus.type === ECLNodeType.WILDCARD) { + if (operator) { + throw new Error('ECL hierarchy operators combined with wildcard (*) are not supported'); + } + return this._eclWildcard(); + } + + // Member-of (^) + if (focus.type === ECLNodeType.MEMBER_OF) { + if (operator) { + throw new Error('ECL hierarchy operators combined with ^ (member-of) are not yet supported'); + } + return this._evalMemberOf(focus); + } + + // Plain concept reference + if (focus.type === ECLNodeType.CONCEPT_REFERENCE) { + return this._evalConceptWithOperator(focus.conceptId, operator); + } + + // Parenthesised sub-expression: focus is itself a full constraint node + return this._evalECLNode(focus); + }; + + /** + * Resolve a concept ID + hierarchy operator. + * @param {string} conceptId + * @param {string|null} operator ECLTokenType constant + * @returns {SnomedFilterContext} + */ + _evalConceptWithOperator = function (conceptId, operator) { + switch (operator) { + case null: + case undefined: + return this.filterEquals(conceptId); + + case ECLTokenType.CHILD_OR_SELF_OF: // << + case ECLTokenType.DESCENDANT_OR_SELF_OF: // <> + case ECLTokenType.ANCESTOR_OR_SELF_OF: { // >>! — same for a single concept + const result = this.filterGeneralizes(conceptId); + // filterGeneralizes returns ancestors only; add self + const self = this.concepts.findConcept(conceptId); + if (self.found && !result.descendants.includes(self.index)) { + result.descendants.push(self.index); + } + return result; + } + + case ECLTokenType.ANCESTOR_OF: // >! + return this.filterGeneralizes(conceptId); + + case ECLTokenType.PARENT_OF: { // > — direct parents only + const conceptResult = this.concepts.findConcept(conceptId); + if (!conceptResult.found) { + throw new Error(`The SNOMED CT Concept ${conceptId} is not known`); + } + const result = new SnomedFilterContext(); + result.descendants = this.getConceptParents(conceptResult.index); + return result; + } + + default: + throw new Error(`Unsupported ECL hierarchy operator: ${operator}`); + } + }; + + /** + * Evaluate a MEMBER_OF node. Only plain concept-reference refsets are + * supported; complex expressions inside ^ are not yet supported. + * @param {object} memberOfNode + * @returns {SnomedFilterContext} + */ + _evalMemberOf = function (memberOfNode) { + const refSet = memberOfNode.refSet; + if (refSet.type !== ECLNodeType.CONCEPT_REFERENCE) { + throw new Error('ECL ^ (member-of) with a non-concept-reference refset is not yet supported'); + } + // filterIn accepts a comma-separated string; a single ID works fine + return this.filterIn(refSet.conceptId); + }; + + /** + * Wildcard — all active concepts. The eclWildcard flag tells filterCheck / + * filterLocate to accept every active concept without enumeration. + * @returns {SnomedFilterContext} + */ + _eclWildcard = function () { + const result = new SnomedFilterContext(); + result.eclWildcard = true; + return result; + }; + +// ── Set operation helpers ──────────────────────────────────────────────────── + + /** + * Flatten a SnomedFilterContext to a plain array of concept indexes, + * handling the three different storage slots used by the existing filters. + * @param {SnomedFilterContext} ctx + * @returns {number[]} + */ + _eclToIndexArray = function (ctx) { + if (ctx.descendants && ctx.descendants.length > 0) return ctx.descendants; + if (ctx.members && ctx.members.length > 0) return ctx.members.map(m => m.ref); + if (ctx.matches && ctx.matches.length > 0) return ctx.matches.map(m => m.index); + return []; + }; + + /** + * AND: concepts present in both sets. + */ + _eclIntersect = function (left, right) { + if (left.eclWildcard) return right; + if (right.eclWildcard) return left; + const leftSet = new Set(this._eclToIndexArray(left)); + const result = new SnomedFilterContext(); + result.descendants = this._eclToIndexArray(right).filter(idx => leftSet.has(idx)); + return result; + }; + + /** + * OR: concepts present in either set. + */ + _eclUnion = function (left, right) { + if (left.eclWildcard || right.eclWildcard) return this._eclWildcard(); + const combined = new Set([ + ...this._eclToIndexArray(left), + ...this._eclToIndexArray(right) + ]); + const result = new SnomedFilterContext(); + result.descendants = [...combined]; + return result; + }; + + /** + * MINUS: concepts in left that are not in right. + */ + _eclMinus = function (left, right) { + const result = new SnomedFilterContext(); + + if (right.eclWildcard) { + result.descendants = []; + return result; + } + + const rightSet = new Set(this._eclToIndexArray(right)); + + if (left.eclWildcard) { + // Enumerate all active concepts minus the right set + const all = []; + for (let i = 0; i < this.concepts.count(); i++) { + const concept = this.concepts.getConceptByCount(i); + if (this.isActive(concept.index) && !rightSet.has(concept.index)) { + all.push(concept.index); + } + } + result.descendants = all; + return result; + } + + result.descendants = this._eclToIndexArray(left).filter(idx => !rightSet.has(idx)); + return result; + }; + + searchFilter(searchText, includeInactive = false, exactMatch = false) { const result = new SnomedFilterContext(); @@ -918,6 +1188,9 @@ class SnomedProvider extends BaseCSServices { const id = this.sct.stringToIdOrZero(value); return id !== 0n && op === '='; } + if (prop === 'constraint') { + return op === '='; + } if (prop == 'expressions' && op == '=' && ['true', 'false'].includes(value)) { return true; @@ -991,6 +1264,11 @@ class SnomedProvider extends BaseCSServices { } } + if (prop === 'constraint' && op === '=') { + filterContext.filters.push(await this.sct.filterECL(value)); + return null; + } + if (prop === 'moduleId') { const id = this.sct.stringToIdOrZero(value); if (id === 0n) { @@ -1035,6 +1313,7 @@ class SnomedProvider extends BaseCSServices { throw new Error(`Unsupported filter property: ${prop}`); } + async executeFilters(filterContext) { return filterContext.filters; } @@ -1096,6 +1375,9 @@ class SnomedProvider extends BaseCSServices { return conceptResult.message; } + if (set.eclWildcard) { + return this.sct.isActive(reference) ? ctxt : null; + } const ctxt = conceptResult.context; const reference = ctxt.getReference(); let found = false; @@ -1168,7 +1450,9 @@ class SnomedProvider extends BaseCSServices { } else if (set.descendants && set.descendants.length > 0) { return set.descendants.includes(reference); } - + if (set.eclWildcard) { + return this.sct.isActive(reference); + } return false; } From 81f7390a2c79bb91de23618fbcb9459a65f9b4a1 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Mon, 20 Apr 2026 16:40:50 +1000 Subject: [PATCH 2/6] Add support for handling contained value sets & fix count on empty value set --- tx/workers/expand.js | 66 +++++++++++++++++++++++++++--------------- tx/workers/related.js | 2 +- tx/workers/validate.js | 45 ++++++++++++++-------------- tx/workers/worker.js | 17 ++++++++++- 4 files changed, 80 insertions(+), 50 deletions(-) diff --git a/tx/workers/expand.js b/tx/workers/expand.js index a45fd5da..1992b0e7 100644 --- a/tx/workers/expand.js +++ b/tx/workers/expand.js @@ -22,6 +22,7 @@ const {debugLog} = require("../operation-context"); // Expansion limits (from Pascal constants) const EXTERNAL_DEFAULT_LIMIT = 1000; +const EXTERNAL_TEST_DEFAULT_LIMIT = 3000; const INTERNAL_DEFAULT_LIMIT = 10000; const EXPANSION_DEAD_TIME_SECS = 30; const CACHE_WHEN_DEBUGGING = false; @@ -508,8 +509,8 @@ class ValueSetExpander { this.excluded.add(key); } - async checkCanExpandValueSet(uri, version) { - const vs = await this.worker.findValueSet(uri, version); + async checkCanExpandValueSet(uri, version, source) { + const vs = await this.worker.findValueSet(uri, version, source); if (vs == null) { if (!version && uri.includes('|')) { version = uri.substring(uri.indexOf('|') + 1); @@ -525,9 +526,8 @@ class ValueSetExpander { } } - async expandValueSet(uri, version, filter, notClosed) { + async expandValueSet(uri, version, vs, filter, notClosed) { - let vs = await this.worker.findValueSet(uri, version); if (!vs) { if (version) { throw new Issue('error', 'not-found', null, 'VS_EXP_IMPORT_UNK_PINNED', this.worker.i18n.translate('VS_EXP_IMPORT_UNK_PINNED', this.params.httpLanguages, [uri, version]), "not-found", 422); @@ -609,14 +609,14 @@ class ValueSetExpander { } } - async checkSource(cset, exp, filter, srcURL, ts, vsInfo) { + async checkSource(cset, exp, filter, srcURL, ts, vsInfo , source) { this.worker.deadCheck('checkSource'); Extensions.checkNoModifiers(cset, 'ValueSetExpander.checkSource', 'set', srcURL); let imp = false; for (const u of cset.valueSet || []) { this.worker.deadCheck('checkSource'); const s = this.worker.pinValueSet(u); - await this.checkCanExpandValueSet(s, ''); + await this.checkCanExpandValueSet(s, '', source); imp = true; } @@ -659,7 +659,7 @@ class ValueSetExpander { if (!cset.concept && !cset.filter) { if (cs.specialEnumeration()) { - await this.checkCanExpandValueSet(cs.specialEnumeration(), ''); + await this.checkCanExpandValueSet(cs.specialEnumeration(), '', null); } else if (filter.isNull) { if (cs.isNotClosed()) { if (cs.specialEnumeration()) { @@ -704,9 +704,12 @@ class ValueSetExpander { this.worker.deadCheck('processCodes#2'); const s = this.worker.pinValueSet(u); this.worker.opContext.log('import value set ' + s); - const ivs = new ImportedValueSet(await this.expandValueSet(s, '', filter, notClosed)); - this.checkResourceCanonicalStatus(expansion, ivs.valueSet, this.valueSet); - this.addParamUri(expansion, 'used-valueset', this.worker.makeVurl(ivs.valueSet)); + let vs = await this.worker.findValueSet(s, '', vsSrc); + const ivs = new ImportedValueSet(await this.expandValueSet(s, '', vs, filter, notClosed)); + this. checkResourceCanonicalStatus(expansion, ivs.valueSet, this.valueSet); + if (!vs.isContained) { + this.addParamUri(expansion, 'used-valueset', this.worker.makeVurl(ivs.valueSet)); + } valueSets.push(ivs); } this.addToTotal(await this.importValueSet(valueSets[0].valueSet, expansion, valueSets, 1)); @@ -728,16 +731,20 @@ class ValueSetExpander { this.worker.deadCheck('processCodes#2'); const s = this.worker.pinValueSet(u); this.worker.opContext.log('import value set ' + s); - const ivs = new ImportedValueSet(await this.expandValueSet(s, '', filter, notClosed)); + let vs = await this.worker.findValueSet(s, '', vsSrc); + const ivs = new ImportedValueSet(await this.expandValueSet(s, '', vs, filter, notClosed)); this.checkResourceCanonicalStatus(expansion, ivs.valueSet, this.valueSet); - this.addParamUri(expansion, 'used-valueset', this.worker.makeVurl(ivs.valueSet)); + if (!vs.isContained) { + this.addParamUri(expansion, 'used-valueset', this.worker.makeVurl(ivs.valueSet)); + } valueSets.push(ivs); } if (!cset.concept && !cset.filter) { if (cs.specialEnumeration() && filters.length === 0) { this.worker.opContext.log('import special value set ' + cs.specialEnumeration()); - const base = await this.expandValueSet(cs.specialEnumeration(), '', filter, notClosed); + let vs = await this.worker.findValueSet(cs.specialEnumeration(), '', null); + const base = await this.expandValueSet(cs.specialEnumeration(), '', vs, filter, notClosed); Extensions.addBoolean(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed", true); Extensions.addString(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed-reason", 'The code System "' + cs.system() + " has a grammar and so has infinite members. This extension is based on " + cs.specialEnumeration()); await this.importValueSet(base, expansion, valueSets, 0); @@ -860,7 +867,7 @@ class ValueSetExpander { throw new Issue('error', 'invalid', path + ".filter[" + i + "]", 'UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.worker.i18n.translate('UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.params.httpLanguages, [cs.system(), fc.property, fc.op]), 'vs-invalid', 400); } Extensions.checkNoModifiers(fc, 'ValueSetExpander.processCodes', 'filter', vsSrc.vurl); - await cs.filter(prep, fc.property, fc.op, fc.value); + await cs.filter(prep, i == 0, fc.property, fc.op, fc.value); } const fset = await cs.executeFilters(prep); @@ -871,6 +878,7 @@ class ValueSetExpander { } this.worker.opContext.log('iterate filters'); + this.addToTotal(0); const cds = new Designations(this.worker.i18n.languageDefinitions); while (await cs.filterMore(prep, fset[0])) { this.worker.deadCheck('processCodes#5'); @@ -937,9 +945,12 @@ class ValueSetExpander { for (const u of cset.valueSet) { const s = this.worker.pinValueSet(u); this.worker.deadCheck('processCodes#2'); - const ivs = new ImportedValueSet(await this.expandValueSet(s, '', filter, notClosed)); + let vs = await this.worker.findValueSet(s, '', vsSrc); + const ivs = new ImportedValueSet(await this.expandValueSet(s, '', vs, filter, notClosed)); this.checkResourceCanonicalStatus(expansion, ivs.valueSet, this.valueSet); - this.addParamUri(expansion, 'used-valueset', ivs.valueSet.vurl); + if (!vs.isContained) { + this.addParamUri(expansion, 'used-valueset', ivs.valueSet.vurl); + } valueSets.push(ivs); } this.excludeValueSet(valueSets[0].valueSet, expansion, valueSets, 1); @@ -959,9 +970,12 @@ class ValueSetExpander { this.worker.deadCheck('processCodes#3'); const s = this.worker.pinValueSet(u); this.worker.opContext.log('import value set ' + s); - const ivs = new ImportedValueSet(await this.expandValueSet(s, '', filter, notClosed)); + let vs = await this.worker.findValueSet(s, '', vsSrc); + const ivs = new ImportedValueSet(await this.expandValueSet(s, '', vs, filter, notClosed)); this.checkResourceCanonicalStatus(expansion, ivs.valueSet, this.valueSet); - this.addParamUri(expansion, 'used-valueset', this.worker.makeVurl(ivs.valueSet)); + if (!vs.isContained) { + this.addParamUri(expansion, 'used-valueset', this.worker.makeVurl(ivs.valueSet)); + } valueSets.push(ivs); } @@ -1039,10 +1053,12 @@ class ValueSetExpander { Extensions.addString(expansion, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed-reason", 'The code System "' + cs.system() + " has a grammar and so has infinite members. This extension is based on " + cs.specialEnumeration()); } + let first = true; for (let fc of cset.filter) { this.worker.deadCheck('processCodes#4a'); Extensions.checkNoModifiers(fc, 'ValueSetExpander.processCodes', 'filter', vsSrc.vurl); - await cs.filter(prep, fc.property, fc.op, fc.value); + await cs.filter(prep, first, fc.property, fc.op, fc.value); + first = false; } this.worker.opContext.log('iterate filters'); @@ -1150,12 +1166,12 @@ class ValueSetExpander { const ts = new Map(); for (const c of source.jsonObj.compose.include || []) { this.worker.deadCheck('handleCompose#2'); - await this.checkSource(c, expansion, filter, source.url, ts, vsInfo); + await this.checkSource(c, expansion, filter, source.url, ts, vsInfo, source); } for (const c of source.jsonObj.compose.exclude || []) { this.worker.deadCheck('handleCompose#3'); this.hasExclusions = true; - await this.checkSource(c, expansion, filter, source.url, ts, null); + await this.checkSource(c, expansion, filter, source.url, ts, null, source); } this.worker.opContext.log('compose #2'); @@ -1215,6 +1231,7 @@ class ValueSetExpander { result.publisher = undefined; result.extension = undefined; result.text = undefined; + result.contained = undefined; } for (let s of this.params.supplements) this.requiredSupplements.add(s); @@ -1909,7 +1926,7 @@ class ExpandWorker extends TerminologyWorker { const url = this.getParameterValue(urlParam); const version = versionParam ? this.getParameterValue(versionParam) : null; - valueSet = await this.findValueSet(url, version); + valueSet = await this.findValueSet(url, version, null); this.seeSourceVS(valueSet, url); if (!valueSet) { return res.status(422).json(this.operationOutcome('error', 'not-found', @@ -2072,8 +2089,8 @@ class ExpandWorker extends TerminologyWorker { if (params.limit < -1) { params.limit = -1; - } else if (params.limit > EXTERNAL_DEFAULT_LIMIT) { - params.limit = EXTERNAL_DEFAULT_LIMIT; // can't ask for more than this externally, though you can internally + } else if (params.limit > this.externalLimit) { + params.limit = this.externalLimit; // can't ask for more than this externally, though you can internally } const filter = new SearchFilterText(params.filter); @@ -2123,6 +2140,7 @@ module.exports = { EmptyFilterContext, EXTERNAL_DEFAULT_LIMIT, INTERNAL_DEFAULT_LIMIT, + EXTERNAL_TEST_DEFAULT_LIMIT, TotalStatus, EXPANSION_DEAD_TIME_SECS }; \ No newline at end of file diff --git a/tx/workers/related.js b/tx/workers/related.js index acb698b3..08e96c81 100644 --- a/tx/workers/related.js +++ b/tx/workers/related.js @@ -188,7 +188,7 @@ class RelatedWorker extends TerminologyWorker { const url = this.getParameterValue(urlParam); const version = versionParam ? this.getParameterValue(versionParam) : null; - let valueSet = await this.findValueSet(url, version); + let valueSet = await this.findValueSet(url, version, null); this.seeSourceVS(valueSet, url); if (!valueSet) { return res.status(404).json(this.operationOutcome('error', 'not-found', diff --git a/tx/workers/validate.js b/tx/workers/validate.js index aded791e..40c5210e 100644 --- a/tx/workers/validate.js +++ b/tx/workers/validate.js @@ -351,7 +351,7 @@ class ValueSetChecker { let s = this.worker.pinValueSet(u); this.worker.deadCheck('prepareConceptSet'); if (!this.others.has(s)) { - let other = await this.worker.findValueSet(s, ''); + let other = await this.worker.findValueSet(s, '', vs); if (other === null) { throw new Issue('error', 'not-found', null, 'Unable_to_resolve_value_Set_', this.worker.i18n.translate('Unable_to_resolve_value_Set_', this.params.HTTPLanguages, [s]), 'not-found', 422); } @@ -472,7 +472,7 @@ class ValueSetChecker { this.worker.opContext.addNote(this.valueSet, 'Didn\'t find CodeSystem "' + this.worker.renderer.displayCoded(system, version) + '"', this.indentCount); result = null; cause.value = 'not-found'; - let vss = await this.worker.findValueSet(system, ''); + let vss = await this.worker.findValueSet(system, '', null); if (vss !== null) { vss = null; let msg = this.worker.i18n.translate('Terminology_TX_System_ValueSet2', this.params.HTTPLanguages, [system]); @@ -1154,7 +1154,7 @@ class ValueSetChecker { } let prov = await this.worker.findCodeSystem(ws, c.version, this.params, ['complete', 'fragment'], op,true, true, false, this.worker.requiredSupplements); if (prov === null) { - let vss = await this.worker.findValueSet(ws, ''); + let vss = await this.worker.findValueSet(ws, '', null); if (vss !== null) { vss = null; let m = this.worker.i18n.translate('Terminology_TX_System_ValueSet2', this.params.HTTPLanguages, [ws]); @@ -1538,9 +1538,6 @@ class ValueSetChecker { async checkConceptSet(path, role, cs, cset, code, displays, vs, message, inactive, normalForm, vstatus, op, vcc, messages) { this.worker.opContext.addNote(vs, 'check code ' + role + ' ' + this.worker.renderer.displayValueSetInclude(cset) + ' at ' + path, this.indentCount); - if (role !== 'not in') { - inactive.value = false; - } let result = false; if (!cset.concept && !cset.filter) { let loc = await cs.locate(code); @@ -1683,7 +1680,7 @@ class ValueSetChecker { if (!fc.value) { throw new Issue('error', 'invalid', null, 'UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.worker.i18n.translate('UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.params.HTTPLanguages, [cs.system(), fc.property, fc.op])); } - await cs.filter(prep, fc.property, fc.op, fc.value); + await cs.filter(prep, false, fc.property, fc.op, fc.value); // if (f === null) { // throw new Issue('error', 'not-supported', null, 'FILTER_NOT_UNDERSTOOD', this.worker.i18n.translate('FILTER_NOT_UNDERSTOOD', this.params.HTTPLanguages, [fc.property, fc.op, fc.value, vs.vurl, cs.system()]) + ' (2)', 'vs-invalid'); // } @@ -2232,7 +2229,7 @@ class ValidateWorker extends TerminologyWorker { if (csp) { return csp; } else { - let vs = await this.findValueSet(url, version); + let vs = await this.findValueSet(url, version, null); if (vs) { let msg = this.i18n.translate('Terminology_TX_System_ValueSet2', txParams.HTTPLanguages, [url]); throw new Issue('error', 'invalid', path, 'Terminology_TX_System_ValueSet2', msg, 'invalid-data'); @@ -2448,22 +2445,22 @@ class ValidateWorker extends TerminologyWorker { return defaultValue; } - /** - * Find a ValueSet by URL - * @param {string} url - ValueSet URL - * @param {string} [version] - ValueSet version - * @returns {Object|null} ValueSet resource or null - */ - async findValueSet(url, version = null) { - // First check additional resources - const found = this.findInAdditionalResources(url, version || '', 'ValueSet', false); - if (found) { - return found; - } - - // Then check provider - return await this.provider.findValueSet(this.opContext, url, version); - } + // /** + // * Find a ValueSet by URL + // * @param {string} url - ValueSet URL + // * @param {string} [version] - ValueSet version + // * @returns {Object|null} ValueSet resource or null + // */ + // async findValueSet(url, version = null) { + // // First check additional resources + // const found = this.findInAdditionalResources(url, version || '', 'ValueSet', false); + // if (found) { + // return found; + // } + // + // // Then check provider + // return await this.provider.findValueSet(this.opContext, url, version); + // } /** * Get display text for a code (stub implementation for doValidationCS) diff --git a/tx/workers/worker.js b/tx/workers/worker.js index c3443185..a9676c34 100644 --- a/tx/workers/worker.js +++ b/tx/workers/worker.js @@ -340,10 +340,25 @@ class TerminologyWorker { * @param {string} version - ValueSet version (optional, overrides URL version) * @returns {ValueSet|null} Found ValueSet or null */ - async findValueSet(url, version = '') { + async findValueSet(url, version, source = '') { if (!url) { return null; } + if (url.startsWith("#")) { + if (source) { + if (source.jsonObj) { + source = source.jsonObj; + } + for (const contained of source.contained || []) { + if (contained.id === url.substring(1)) { + const ret = this.wrapRawResource(contained); + ret.isContained = true; + return ret; + } + } + } + return null; + } // Parse URL|version format let effectiveUrl = url; From 220e12e64f9ecc5c5ea091e61034d04a137c23aa Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Mon, 20 Apr 2026 16:42:01 +1000 Subject: [PATCH 3/6] bump vsac fetch to 1000 and improve history presentation --- tx/vs/vs-vsac.js | 47 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/tx/vs/vs-vsac.js b/tx/vs/vs-vsac.js index 9f1ee8db..7d24ae2e 100644 --- a/tx/vs/vs-vsac.js +++ b/tx/vs/vs-vsac.js @@ -136,7 +136,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider { console.log('Starting VSAC ValueSet refresh...'); // This lists all the currently valid value sets by URL, but not the older versions - let url = '/ValueSet?_offset=0&_count=100&_elements=id,url,version,status'; + let url = '/ValueSet?_offset=0&_count=1000&_elements=id,url,version,status'; let total = undefined; let count = 0; @@ -669,7 +669,23 @@ class VSACValueSetProvider extends AbstractValueSetProvider { ); }); - const fmt = ts => ts + // ISO date (YYYY-MM-DD UTC) for grouping + const dayKey = ts => ts + ? new Date(ts * 1000).toISOString().substring(0, 10) + : ''; + // Human-friendly day heading e.g. "Tuesday, 14 April 2026" + const dayLabel = ts => ts + ? new Date(ts * 1000).toLocaleDateString('en-GB', { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', + timeZone: 'UTC' + }) + : '—'; + // HH:MM:SS UTC within a day + const timeOnly = ts => ts + ? new Date(ts * 1000).toISOString().substring(11, 19) + ' UTC' + : '—'; + // Full timestamp (used in "Running..." detail where context is needed) + const fmtFull = ts => ts ? new Date(ts * 1000).toISOString().replace('T', ' ').substring(0, 19) + ' UTC' : '—'; @@ -678,7 +694,16 @@ class VSACValueSetProvider extends AbstractValueSetProvider { html += 'TimeEventDetail'; html += ''; + let currentDay = null; for (const row of rows) { + const rowDay = dayKey(row.ts); + if (rowDay !== currentDay) { + currentDay = rowDay; + html += ``; + html += `${escape(dayLabel(row.ts))}`; + html += ``; + } + if (row.kind === 'run') { const duration = row.finished_at ? `${row.finished_at - row.ts}s` : 'in progress'; let detail, colour; @@ -690,11 +715,11 @@ class VSACValueSetProvider extends AbstractValueSetProvider { detail = `Failed: ${escape(row.error_message || '')} (${duration})`; colour = 'red'; } else { - detail = `Running... (started ${fmt(row.ts)})`; + detail = `Running... (started ${fmtFull(row.ts)})`; colour = 'orange'; } html += ``; - html += `${escape(fmt(row.ts))}`; + html += `${escape(timeOnly(row.ts))}`; html += `Sync run`; html += `${detail}`; html += ``; @@ -703,15 +728,15 @@ class VSACValueSetProvider extends AbstractValueSetProvider { let label, colour; switch (row.event_type) { case 'new': - label = 'New value set'; + label = 'New'; colour = 'green'; break; case 'updated': - label = 'Updated value set'; + label = 'Updated'; colour = 'blue'; break; case 'deleted': - label = 'Deleted value set'; + label = 'Deleted'; colour = 'red'; break; default: @@ -719,9 +744,9 @@ class VSACValueSetProvider extends AbstractValueSetProvider { colour = 'black'; } html += ``; - html += `${escape(fmt(row.ts))}`; + html += `${escape(timeOnly(row.ts))}`; html += `${label}`; - html += `${escape(row.url || '')}#${escape(row.version || '')}`; + html += `${escape(this.urlTail(row.url) || '')} v ${escape(row.version || '')}`; html += ``; } } @@ -733,6 +758,10 @@ class VSACValueSetProvider extends AbstractValueSetProvider { id() { return "vsac"; } + + urlTail(url) { + return url ? url.substring(url.lastIndexOf('/') + 1) : ''; + } } // Usage examples: From 2ee6d2ae94183ad6bd8e4d3812d7375852859bf8 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Mon, 20 Apr 2026 16:42:40 +1000 Subject: [PATCH 4/6] fix up ecl parsing --- tx/sct/ecl.js | 147 +++++++++++++++++++++++++++++++++----------------- tx/tx.js | 8 ++- 2 files changed, 104 insertions(+), 51 deletions(-) diff --git a/tx/sct/ecl.js b/tx/sct/ecl.js index ac605b5e..8890e30a 100644 --- a/tx/sct/ecl.js +++ b/tx/sct/ecl.js @@ -7,8 +7,6 @@ * Supports ECL v2.1 specification from SNOMED International */ -const { SnomedFilterContext } = require('../cs/cs-snomed'); - // ECL Token Types const ECLTokenType = { // Literals @@ -18,15 +16,15 @@ const ECLTokenType = { INTEGER: 'INTEGER', DECIMAL: 'DECIMAL', - // Operators - CHILD_OF: 'CHILD_OF', // < - CHILD_OR_SELF_OF: 'CHILD_OR_SELF_OF', // << - DESCENDANT_OF: 'DESCENDANT_OF', // - PARENT_OR_SELF_OF: 'PARENT_OR_SELF_OF', // >> - ANCESTOR_OF: 'ANCESTOR_OF', // >! - ANCESTOR_OR_SELF_OF: 'ANCESTOR_OR_SELF_OF', // >>! + // Operators (ECL 2.x spec) + CHILD_OF: 'CHILD_OF', // ! direct parents only + PARENT_OR_SELF_OF: 'PARENT_OR_SELF_OF', // >>! self + direct parents + ANCESTOR_OF: 'ANCESTOR_OF', // > all transitive ancestors, no self + ANCESTOR_OR_SELF_OF: 'ANCESTOR_OR_SELF_OF', // >> self + all transitive ancestors // Set operators AND: 'AND', @@ -268,29 +266,38 @@ class ECLLexer { } // Multi-character operators + // ECL 2.x hierarchy operators: + // < descendantOf (transitive, no self) + // << descendantOrSelfOf (transitive, with self) + // ancestorOf (transitive, no self) + // >> ancestorOrSelfOf (transitive, with self) + // >! parentOf (one step) + // >>! parentOrSelfOf (one step + self) if (this.current === '<') { if (this.peek() === '<') { if (this.peek(2) === '!') { this.advance(); this.advance(); this.advance(); - return { type: ECLTokenType.DESCENDANT_OR_SELF_OF, value: '<>!' }; + return { type: ECLTokenType.PARENT_OR_SELF_OF, value: '>>!' }; } else { this.advance(); this.advance(); - return { type: ECLTokenType.PARENT_OR_SELF_OF, value: '>>' }; + return { type: ECLTokenType.ANCESTOR_OR_SELF_OF, value: '>>' }; } } else if (this.peek() === '!') { this.advance(); this.advance(); - return { type: ECLTokenType.ANCESTOR_OF, value: '>!' }; + return { type: ECLTokenType.PARENT_OF, value: '>!' }; } else if (this.peek() === '=') { this.advance(); this.advance(); return { type: ECLTokenType.GTE, value: '>=' }; } else { this.advance(); - return { type: ECLTokenType.PARENT_OF, value: '>' }; + return { type: ECLTokenType.ANCESTOR_OF, value: '>' }; } } @@ -350,9 +357,9 @@ class ECLLexer { // Check if immediately followed by .digit (decimal number) if (pos < this.input.length && - this.input[pos] === '.' && - pos + 1 < this.input.length && - /\d/.test(this.input[pos + 1])) { + this.input[pos] === '.' && + pos + 1 < this.input.length && + /\d/.test(this.input[pos + 1])) { // This is a decimal number - parse it completely const num = this.readNumber(); return { type: num.type, value: num.value }; @@ -471,8 +478,8 @@ class ECLParser { const right = this.parseRefinedExpressionConstraint(); const nodeType = operator.type === ECLTokenType.AND ? ECLNodeType.CONJUNCTION : - operator.type === ECLTokenType.OR ? ECLNodeType.DISJUNCTION : - ECLNodeType.EXCLUSION; + operator.type === ECLTokenType.OR ? ECLNodeType.DISJUNCTION : + ECLNodeType.EXCLUSION; left = { type: ECLNodeType.COMPOUND_EXPRESSION_CONSTRAINT, @@ -527,10 +534,10 @@ class ECLParser { // Handle constraint operators let operator = null; if (this.match( - ECLTokenType.CHILD_OF, ECLTokenType.CHILD_OR_SELF_OF, - ECLTokenType.DESCENDANT_OF, ECLTokenType.DESCENDANT_OR_SELF_OF, - ECLTokenType.PARENT_OF, ECLTokenType.PARENT_OR_SELF_OF, - ECLTokenType.ANCESTOR_OF, ECLTokenType.ANCESTOR_OR_SELF_OF + ECLTokenType.CHILD_OF, ECLTokenType.CHILD_OR_SELF_OF, + ECLTokenType.DESCENDANT_OF, ECLTokenType.DESCENDANT_OR_SELF_OF, + ECLTokenType.PARENT_OF, ECLTokenType.PARENT_OR_SELF_OF, + ECLTokenType.ANCESTOR_OF, ECLTokenType.ANCESTOR_OR_SELF_OF )) { operator = this.current; this.advance(); @@ -681,8 +688,11 @@ class ECLParser { operator: operator.type, value }; - } else if (this.match(ECLTokenType.LT, ECLTokenType.LTE, ECLTokenType.CHILD_OF, ECLTokenType.PARENT_OF, ECLTokenType.GTE)) { - // Note: CHILD_OF (<) is treated as LT and PARENT_OF (>) is treated as GT in numeric comparison context + } else if (this.match(ECLTokenType.LT, ECLTokenType.LTE, ECLTokenType.DESCENDANT_OF, ECLTokenType.ANCESTOR_OF, ECLTokenType.GTE)) { + // In ECL, bare `<` and `>` are overloaded: they lex as hierarchy + // operators (DESCENDANT_OF / ANCESTOR_OF) but in an attribute comparison + // context they mean less-than / greater-than. Accept them here and map + // them to LT / GT so downstream consumers see a uniform shape. const operator = this.current; this.advance(); @@ -697,11 +707,11 @@ class ECLParser { this.error('Expected numeric value after #'); } - // Map CHILD_OF to LT and PARENT_OF to GT for numeric comparisons + // Map DESCENDANT_OF to LT and ANCESTOR_OF to GT for numeric comparisons let operatorType = operator.type; - if (operator.type === ECLTokenType.CHILD_OF) { + if (operator.type === ECLTokenType.DESCENDANT_OF) { operatorType = ECLTokenType.LT; - } else if (operator.type === ECLTokenType.PARENT_OF) { + } else if (operator.type === ECLTokenType.ANCESTOR_OF) { operatorType = ECLTokenType.GT; } @@ -1030,6 +1040,8 @@ class ECLValidator { } async evaluateWildcard() { + const { SnomedFilterContext } = require('../cs/cs-snomed'); + // Return all concepts - this would need optimization in practice const filter = new SnomedFilterContext(); const allConcepts = []; @@ -1046,49 +1058,86 @@ class ECLValidator { } async evaluateSubExpressionConstraint(node) { + const { SnomedFilterContext } = require('../cs/cs-snomed'); + const baseFilter = await this.evaluateAST(node.focus); if (!node.operator) { return baseFilter; } - // Apply constraint operator - const results = new SnomedFilterContext(); + // Apply constraint operator — collect into a Set to deduplicate across + // multi-concept base filters. + const accumulated = new Set(); for (const conceptIndex of baseFilter.descendants || []) { const conceptId = this.sct.concepts.getConceptId(conceptIndex); let operatorFilter; switch (node.operator) { - case ECLTokenType.CHILD_OF: + // ── Descendants ───────────────────────────────────────────────────── + case ECLTokenType.DESCENDANT_OF: // < transitive, no self operatorFilter = this.sct.filterIsA(conceptId, false); break; - case ECLTokenType.CHILD_OR_SELF_OF: + case ECLTokenType.DESCENDANT_OR_SELF_OF: // << transitive + self operatorFilter = this.sct.filterIsA(conceptId, true); break; - case ECLTokenType.DESCENDANT_OF: - operatorFilter = this.sct.filterIsA(conceptId, false); + case ECLTokenType.CHILD_OF: // transitive, no self + operatorFilter = this.sct.filterGeneralizes(conceptId); + break; + case ECLTokenType.ANCESTOR_OR_SELF_OF: { // >> transitive + self + operatorFilter = this.sct.filterGeneralizes(conceptId); + const selfResult = this.sct.concepts.findConcept(conceptId); + if (selfResult.found && !operatorFilter.descendants.includes(selfResult.index)) { + operatorFilter.descendants.push(selfResult.index); + } + break; + } + case ECLTokenType.PARENT_OF: { // >! direct parents only + operatorFilter = new SnomedFilterContext(); + const selfResult = this.sct.concepts.findConcept(conceptId); + operatorFilter.descendants = selfResult.found + ? this.sct.getConceptParents(selfResult.index) + : []; + break; + } + case ECLTokenType.PARENT_OR_SELF_OF: { // >>! self + direct parents + operatorFilter = new SnomedFilterContext(); + const selfResult = this.sct.concepts.findConcept(conceptId); + const parents = selfResult.found ? this.sct.getConceptParents(selfResult.index) : []; + operatorFilter.descendants = selfResult.found ? [selfResult.index, ...parents] : parents; break; - case ECLTokenType.PARENT_OF: - case ECLTokenType.PARENT_OR_SELF_OF: - case ECLTokenType.ANCESTOR_OF: - case ECLTokenType.ANCESTOR_OR_SELF_OF: - // These would require reverse hierarchy traversal - throw new Error(`Operator ${node.operator} not yet implemented`); + } + default: throw new Error(`Unknown constraint operator: ${node.operator}`); } - results.descendants = [...(results.descendants || []), ...(operatorFilter.descendants || [])]; + for (const idx of operatorFilter.descendants || []) { + accumulated.add(idx); + } } + const results = new SnomedFilterContext(); + results.descendants = [...accumulated]; return results; } async evaluateCompoundExpression(node) { + const { SnomedFilterContext } = require('../cs/cs-snomed'); + const leftFilter = await this.evaluateAST(node.left); const rightFilter = await this.evaluateAST(node.right); @@ -1327,7 +1376,7 @@ class ECLValidator { this.validateSemanticAST(node.value, errors); break; - // Basic nodes don't need semantic validation + // Basic nodes don't need semantic validation case ECLNodeType.CONCEPT_REFERENCE: case ECLNodeType.WILDCARD: break; diff --git a/tx/tx.js b/tx/tx.js index 5e605916..2f1964e9 100644 --- a/tx/tx.js +++ b/tx/tx.js @@ -20,7 +20,7 @@ const packageJson = require("../package.json"); // Import workers const ReadWorker = require('./workers/read'); const SearchWorker = require('./workers/search'); -const { ExpandWorker, INTERNAL_DEFAULT_LIMIT, EXTERNAL_DEFAULT_LIMIT} = require('./workers/expand'); +const { ExpandWorker, INTERNAL_DEFAULT_LIMIT, EXTERNAL_TEST_DEFAULT_LIMIT} = require('./workers/expand'); const { ValidateWorker } = require('./workers/validate'); const TranslateWorker = require('./workers/translate'); const LookupWorker = require('./workers/lookup'); @@ -1212,8 +1212,12 @@ class TXModule { } externalLimit(req) { + let hdr = req.headers["x-too-costly-threshold"]; + if (hdr) { + return parseInt(hdr); + } let isTest = req.header("User-Agent") == 'Tools/Java'; - if (this.config.internalLimit && !isTest) return this.config.externalLimit; else return EXTERNAL_DEFAULT_LIMIT; + if (this.config.internalLimit && !isTest) return this.config.externalLimit; else return EXTERNAL_TEST_DEFAULT_LIMIT; } } From e81908b79a68d95a4418009a17292632c984f3a8 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Mon, 20 Apr 2026 16:48:50 +1000 Subject: [PATCH 5/6] Add beta support for Snomed ECL --- CHANGELOG.md | 19 ++ tests/cs/cs-areacode.test.js | 10 +- tests/cs/cs-country.test.js | 30 +-- tests/cs/cs-cpt.test.js | 24 +- tests/cs/cs-cs.test.js | 48 ++-- tests/cs/cs-currency.test.js | 12 +- tests/cs/cs-lang.test.js | 18 +- tests/cs/cs-loinc.test.js | 12 +- tests/cs/cs-ndc.test.js | 12 +- tests/cs/cs-omop.test.js | 12 +- tests/cs/cs-snomed-ecl.test.js | 35 +-- tests/cs/cs-ucum.test.js | 2 +- tests/tx/test-cases.test.js | 326 +++++++++++++++++++++++++-- translations/Messages.properties | 4 +- tx/cs/cs-api.js | 3 +- tx/cs/cs-areacode.js | 2 +- tx/cs/cs-country.js | 2 +- tx/cs/cs-cpt.js | 2 +- tx/cs/cs-cs.js | 2 +- tx/cs/cs-currency.js | 2 +- tx/cs/cs-hgvs.js | 2 +- tx/cs/cs-lang.js | 2 +- tx/cs/cs-loinc.js | 2 +- tx/cs/cs-ndc.js | 2 +- tx/cs/cs-omop.js | 2 +- tx/cs/cs-rxnorm.js | 2 +- tx/cs/cs-snomed.js | 369 ++++++++++++++++++++++++++++--- tx/cs/cs-ucum.js | 2 +- 28 files changed, 792 insertions(+), 168 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c46489d..4275a7af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to the Health Intersections Node Server will be documented i The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v0.9.3] - 2026-04-10 + +### Added + +- Add support for handling contained value sets +- Add beta support for ECL + +### Changed + +- Bump vsac fetch to 1000 and improve history presentation + +### Fixed + +- Fix count on empty value set + +### Tx Conformance Statement + +FHIRsmith passed all 1651 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1, runner v6.9.6) + ## [v0.9.2] - 2026-04-14 ### Fixed diff --git a/tests/cs/cs-areacode.test.js b/tests/cs/cs-areacode.test.js index 43d17002..11d742a5 100644 --- a/tests/cs/cs-areacode.test.js +++ b/tests/cs/cs-areacode.test.js @@ -130,7 +130,7 @@ describe('AreaCodeServices', () => { beforeEach(async () => { ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'class', '=', 'country'); + await provider.filter(ctxt, true, 'class', '=', 'country'); const filters = await provider.executeFilters(ctxt); countryFilter = filters[0]; }); @@ -195,7 +195,7 @@ describe('AreaCodeServices', () => { beforeEach(async () => { ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'type', '=', 'region'); + await provider.filter(ctxt, true, 'type', '=', 'region'); const filters = await provider.executeFilters(ctxt); regionFilter = filters[0]; }); @@ -253,13 +253,13 @@ describe('AreaCodeServices', () => { describe('Filter Error Cases', () => { test('should throw error for unsupported property', async () => { await expect( - provider.filter(await provider.getPrepContext(false), 'display', '=', 'test') + provider.filter(await provider.getPrepContext(false), true, 'display', '=', 'test') ).rejects.toThrow('not supported'); }); test('should throw error for unsupported operator', async () => { await expect( - provider.filter(await provider.getPrepContext(false), 'class', 'contains', 'country') + provider.filter(await provider.getPrepContext(false), true, 'class', 'contains', 'country') ).rejects.toThrow('not supported'); }); @@ -274,7 +274,7 @@ describe('AreaCodeServices', () => { describe('Execute Filters', () => { test('should execute single filter', async () => { const ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'class', '=', 'country'); + await provider.filter(ctxt, true, 'class', '=', 'country'); const results = await provider.executeFilters(ctxt); const countryFilter = results[0]; diff --git a/tests/cs/cs-country.test.js b/tests/cs/cs-country.test.js index 94cd3260..f16b0866 100644 --- a/tests/cs/cs-country.test.js +++ b/tests/cs/cs-country.test.js @@ -172,7 +172,7 @@ describe('CountryCodeServices', () => { describe('Regex Filtering', () => { test('should filter by 2-letter code pattern', async () => { const ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'code', 'regex', 'U[S|A]'); + await provider.filter(ctxt, true, 'code', 'regex', 'U[S|A]'); const filters = await provider.executeFilters(ctxt); expect(filters[0]).toBeTruthy(); expect(filters[0].list).toBeTruthy(); @@ -197,7 +197,7 @@ describe('CountryCodeServices', () => { test('should filter by 3-letter code pattern', async () => { const ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'code', 'regex', 'US.*'); + await provider.filter(ctxt, true, 'code', 'regex', 'US.*'); const filters = await provider.executeFilters(ctxt); const filter = filters[0]; @@ -220,7 +220,7 @@ describe('CountryCodeServices', () => { test('should filter by numeric code pattern', async () => { const ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'code', 'regex', '8[0-9]{2}'); + await provider.filter(ctxt, true, 'code', 'regex', '8[0-9]{2}'); const filters = await provider.executeFilters(ctxt); const filter = filters[0]; @@ -245,7 +245,7 @@ describe('CountryCodeServices', () => { test('should filter by exact match pattern', async () => { const ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'code', 'regex', 'US'); + await provider.filter(ctxt, true, 'code', 'regex', 'US'); const filters = await provider.executeFilters(ctxt); const filter = filters[0]; @@ -263,7 +263,7 @@ describe('CountryCodeServices', () => { test('should filter all 2-letter codes', async () => { const ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'code', 'regex', '[A-Z]{2}'); + await provider.filter(ctxt, true, 'code', 'regex', '[A-Z]{2}'); const filters = await provider.executeFilters(ctxt); const filter = filters[0]; @@ -286,7 +286,7 @@ describe('CountryCodeServices', () => { test('should filter all 3-letter codes', async () => { const ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'code', 'regex', '[A-Z]{3}'); + await provider.filter(ctxt, true, 'code', 'regex', '[A-Z]{3}'); const filters = await provider.executeFilters(ctxt); const filter = filters[0]; @@ -309,7 +309,7 @@ describe('CountryCodeServices', () => { test('should filter all numeric codes', async () => { const ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'code', 'regex', '\\d{3}'); + await provider.filter(ctxt, true, 'code', 'regex', '\\d{3}'); const filters = await provider.executeFilters(ctxt); const filter = filters[0]; @@ -332,7 +332,7 @@ describe('CountryCodeServices', () => { test('should handle empty filter results', async () => { const ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'code', 'regex', 'ZZZZZ'); + await provider.filter(ctxt, true, 'code', 'regex', 'ZZZZZ'); const filters = await provider.executeFilters(ctxt); const filter = filters[0]; @@ -344,7 +344,7 @@ describe('CountryCodeServices', () => { test('should locate specific code in filter', async () => { const ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'code', 'regex', 'US.*'); + await provider.filter(ctxt, true, 'code', 'regex', 'US.*'); const filters = await provider.executeFilters(ctxt); const filter = filters[0]; @@ -356,7 +356,7 @@ describe('CountryCodeServices', () => { test('should not locate code not in filter', async () => { const ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'code', 'regex', 'US.*'); + await provider.filter(ctxt, true, 'code', 'regex', 'US.*'); const filters = await provider.executeFilters(ctxt); const filter = filters[0]; @@ -367,7 +367,7 @@ describe('CountryCodeServices', () => { test('should check if concept is in filter', async () => { const ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'code', 'regex', 'US.*'); + await provider.filter(ctxt, true, 'code', 'regex', 'US.*'); const filters = await provider.executeFilters(ctxt); const filter = filters[0]; @@ -384,19 +384,19 @@ describe('CountryCodeServices', () => { describe('Filter Error Cases', () => { test('should throw error for unsupported property', async () => { await expect( - provider.filter(await provider.getPrepContext(false), 'display', 'regex', 'test') + provider.filter(await provider.getPrepContext(false), true, 'display', 'regex', 'test') ).rejects.toThrow('not supported'); }); test('should throw error for unsupported operator', async () => { await expect( - provider.filter(await provider.getPrepContext(false), 'code', 'equals', 'US') + provider.filter(await provider.getPrepContext(false), true, 'code', 'equals', 'US') ).rejects.toThrow('not supported'); }); test('should throw error for invalid regex', async () => { await expect( - provider.filter(await provider.getPrepContext(false), 'code', 'regex', '[invalid') + provider.filter(await provider.getPrepContext(false), true, 'code', 'regex', '[invalid') ).rejects.toThrow('Invalid regex pattern'); }); @@ -411,7 +411,7 @@ describe('CountryCodeServices', () => { describe('Execute Filters', () => { test('should execute single filter', async () => { const ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'code', 'regex', 'US.*'); + await provider.filter(ctxt, true, 'code', 'regex', 'US.*'); const results = await provider.executeFilters(ctxt); expect(results).toBeTruthy(); diff --git a/tests/cs/cs-cpt.test.js b/tests/cs/cs-cpt.test.js index 9a5adea3..88cc1192 100644 --- a/tests/cs/cs-cpt.test.js +++ b/tests/cs/cs-cpt.test.js @@ -327,7 +327,7 @@ describe('CPT Provider', () => { describe('Modifier Filters', () => { test('should filter modifier=true', async () => { const filterContext = await provider.getPrepContext(true); - await provider.filter(filterContext, 'modifier', '=', 'true'); + await provider.filter(filterContext, true, 'modifier', '=', 'true'); const filters = await provider.executeFilters(filterContext); const filter = filters[0]; @@ -346,7 +346,7 @@ describe('CPT Provider', () => { test('should filter modifier=false', async () => { const filterContext = await provider.getPrepContext(true); - await provider.filter(filterContext, 'modifier', '=', 'false'); + await provider.filter(filterContext, true, 'modifier', '=', 'false'); const filters = await provider.executeFilters(filterContext); const filter = filters[0]; @@ -367,7 +367,7 @@ describe('CPT Provider', () => { describe('Kind Filters', () => { test('should filter by kind=code', async () => { const filterContext = await provider.getPrepContext(true); - await provider.filter(filterContext, 'kind', '=', 'code'); + await provider.filter(filterContext, true, 'kind', '=', 'code'); const filters = await provider.executeFilters(filterContext); const filter = filters[0]; @@ -386,7 +386,7 @@ describe('CPT Provider', () => { test('should filter by kind=cat-2', async () => { const filterContext = await provider.getPrepContext(true); - await provider.filter(filterContext, 'kind', '=', 'cat-2'); + await provider.filter(filterContext, true, 'kind', '=', 'cat-2'); const filters = await provider.executeFilters(filterContext); const filter = filters[0]; @@ -403,7 +403,7 @@ describe('CPT Provider', () => { test('should filter by kind=general', async () => { const filterContext = await provider.getPrepContext(true); - await provider.filter(filterContext, 'kind', '=', 'general'); + await provider.filter(filterContext, true, 'kind', '=', 'general'); const filters = await provider.executeFilters(filterContext); const filter = filters[0]; @@ -422,7 +422,7 @@ describe('CPT Provider', () => { describe('Modified Filter', () => { test('should filter modified=false (all codes)', async () => { const filterContext = await provider.getPrepContext(true); - await provider.filter(filterContext, 'modified', '=', 'false'); + await provider.filter(filterContext, true, 'modified', '=', 'false'); const filters = await provider.executeFilters(filterContext); const filter = filters[0]; @@ -434,7 +434,7 @@ describe('CPT Provider', () => { test('should filter modified=true (empty)', async () => { const filterContext = await provider.getPrepContext(true); - await provider.filter(filterContext, 'modified', '=', 'true'); + await provider.filter(filterContext, true, 'modified', '=', 'true'); const filters = await provider.executeFilters(filterContext); const filter = filters[0]; @@ -452,7 +452,7 @@ describe('CPT Provider', () => { describe('Filter Operations', () => { test('should locate codes within filters', async () => { const filterContext = await provider.getPrepContext(false); - await provider.filter(filterContext, 'modifier', '=', 'true'); + await provider.filter(filterContext, true, 'modifier', '=', 'true'); const filters = await provider.executeFilters(filterContext); const filter = filters[0]; @@ -466,7 +466,7 @@ describe('CPT Provider', () => { test('should check if concepts are in filters', async () => { const filterContext = await provider.getPrepContext(true); - await provider.filter(filterContext, 'modifier', '=', 'false'); + await provider.filter(filterContext, true, 'modifier', '=', 'false'); const filters = await provider.executeFilters(filterContext); const filter = filters[0]; @@ -483,7 +483,7 @@ describe('CPT Provider', () => { test('should handle expressions in filters', async () => { const filterContext = await provider.getPrepContext(true); - await provider.filter(filterContext, 'modified', '=', 'true'); + await provider.filter(filterContext, true, 'modified', '=', 'true'); const filters = await provider.executeFilters(filterContext); const filter = filters[0]; @@ -533,7 +533,7 @@ describe('CPT Provider', () => { const filterContext = await provider.getPrepContext(true); await expect( - provider.filter(filterContext, 'unsupported', '=', 'value') + provider.filter(filterContext, true, 'unsupported', '=', 'value') ).rejects.toThrow('not supported'); }); @@ -579,7 +579,7 @@ describe('CPT Provider', () => { describe('Performance and Cleanup', () => { test('should handle filter cleanup', async () => { const filterContext = await provider.getPrepContext(true); - await provider.filter(filterContext, 'modifier', '=', 'true'); + await provider.filter(filterContext, true, 'modifier', '=', 'true'); await provider.executeFilters(filterContext); // Should not throw diff --git a/tests/cs/cs-cs.test.js b/tests/cs/cs-cs.test.js index 3027ebd6..a7140321 100644 --- a/tests/cs/cs-cs.test.js +++ b/tests/cs/cs-cs.test.js @@ -1462,7 +1462,7 @@ describe('FHIR CodeSystem Provider', () => { describe('Concept/Code Filters', () => { test('should filter by is-a relationship', async () => { - const results = await simpleProvider.filter(filterContext, 'concept', 'is-a', 'code2'); + const results = await simpleProvider.filter(filterContext, true, 'concept', 'is-a', 'code2'); expect(results.size()).toBe(5); // code2a + children, code2b expect(results.findConceptByCode('code2a')).toBeDefined(); @@ -1471,7 +1471,7 @@ describe('FHIR CodeSystem Provider', () => { }); test('should filter by descendent-of relationship', async () => { - const results = await simpleProvider.filter(filterContext, 'code', 'descendent-of', 'code2'); + const results = await simpleProvider.filter(filterContext, true, 'code', 'descendent-of', 'code2'); expect(results.size()).toBe(4); // code2a, code2aI, code2aII, code2b (not code2 itself) expect(results.findConceptByCode('code2')).toBeNull(); // Root not included @@ -1482,7 +1482,7 @@ describe('FHIR CodeSystem Provider', () => { }); test('should filter by is-not-a relationship', async () => { - const results = await simpleProvider.filter(filterContext, 'concept', 'is-not-a', 'code2'); + const results = await simpleProvider.filter(filterContext, true, 'concept', 'is-not-a', 'code2'); expect(results.size()).toBe(2); // code1 and code3 (not descendants of code2) expect(results.findConceptByCode('code1')).toBeDefined(); @@ -1492,7 +1492,7 @@ describe('FHIR CodeSystem Provider', () => { }); test('should filter by in relationship', async () => { - const results = await simpleProvider.filter(filterContext, 'code', 'in', 'code1,code3'); + const results = await simpleProvider.filter(filterContext, true, 'code', 'in', 'code1,code3'); expect(results.size()).toBe(2); expect(results.findConceptByCode('code1')).toBeDefined(); @@ -1501,7 +1501,7 @@ describe('FHIR CodeSystem Provider', () => { }); test('should filter by exact match', async () => { - const results = await simpleProvider.filter(filterContext, 'code', '=', 'code1'); + const results = await simpleProvider.filter(filterContext, true, 'code', '=', 'code1'); expect(results.size()).toBe(1); expect(results.findConceptByCode('code1')).toBeDefined(); @@ -1509,7 +1509,7 @@ describe('FHIR CodeSystem Provider', () => { }); test('should filter by regex pattern', async () => { - const results = await simpleProvider.filter(filterContext, 'code', 'regex', 'code2.*'); + const results = await simpleProvider.filter(filterContext, true, 'code', 'regex', 'code2.*'); expect(results.size()).toBeGreaterThan(1); // Should match code2, code2a, code2b, etc. expect(results.findConceptByCode('code2')).toBeDefined(); @@ -1519,14 +1519,14 @@ describe('FHIR CodeSystem Provider', () => { test('should handle invalid regex gracefully', async () => { await expect( - simpleProvider.filter(filterContext, 'code', 'regex', '[invalid') + simpleProvider.filter(filterContext, true, 'code', 'regex', '[invalid') ).rejects.toThrow('The regex \'[invalid\' is not valid: Invalid regular expression: /^[invalid$/u: missing ]: [invalid$'); }); }); describe('Child Existence Filter', () => { test('should find concepts with children', async () => { - const results = await simpleProvider.filter(filterContext, 'child', 'exists', 'true'); + const results = await simpleProvider.filter(filterContext, true, 'child', 'exists', 'true'); expect(results.size()).toBe(2); // code2 and code2a have children expect(results.findConceptByCode('code2')).toBeDefined(); @@ -1535,7 +1535,7 @@ describe('FHIR CodeSystem Provider', () => { }); test('should find concepts without children (leaf nodes)', async () => { - const results = await simpleProvider.filter(filterContext, 'child', 'exists', 'false'); + const results = await simpleProvider.filter(filterContext, true, 'child', 'exists', 'false'); expect(results.size()).toBe(5); // code1, code2aI, code2aII, code2b, code3 expect(results.findConceptByCode('code1')).toBeDefined(); @@ -1549,7 +1549,7 @@ describe('FHIR CodeSystem Provider', () => { describe('Property-Based Filters', () => { test('should filter by property equality', async () => { - const results = await simpleProvider.filter(filterContext, 'prop', '=', 'old'); + const results = await simpleProvider.filter(filterContext, true, 'prop', '=', 'old'); expect(results.size()).toBeGreaterThan(0); // code1 has prop=old @@ -1557,7 +1557,7 @@ describe('FHIR CodeSystem Provider', () => { }); test('should filter by property in values', async () => { - const results = await simpleProvider.filter(filterContext, 'prop', 'in', 'old,new'); + const results = await simpleProvider.filter(filterContext, true, 'prop', 'in', 'old,new'); expect(results.size()).toBeGreaterThan(0); // Should find concepts with either old or new values @@ -1565,7 +1565,7 @@ describe('FHIR CodeSystem Provider', () => { }); test('should filter by property not in values', async () => { - const results = await simpleProvider.filter(filterContext, 'prop', 'not-in', 'retired'); + const results = await simpleProvider.filter(filterContext, true, 'prop', 'not-in', 'retired'); expect(results.size()).toBeGreaterThan(0); // Should exclude concepts with retired status @@ -1573,7 +1573,7 @@ describe('FHIR CodeSystem Provider', () => { }); test('should filter by property regex', async () => { - const results = await simpleProvider.filter(filterContext, 'prop', 'regex', 'ol.*'); + const results = await simpleProvider.filter(filterContext, true, 'prop', 'regex', 'ol.*'); expect(results.size()).toBeGreaterThan(0); // Should match "old" values @@ -1583,7 +1583,7 @@ describe('FHIR CodeSystem Provider', () => { describe('Known Property Filters', () => { test('should filter by notSelectable property', async () => { - const results = await simpleProvider.filter(filterContext, 'notSelectable', '=', 'true'); + const results = await simpleProvider.filter(filterContext, true, 'notSelectable', '=', 'true'); expect(results.size()).toBe(1); // code2 has notSelectable=true @@ -1591,7 +1591,7 @@ describe('FHIR CodeSystem Provider', () => { }); test('should filter by status property', async () => { - const results = await simpleProvider.filter(filterContext, 'status', '=', 'retired'); + const results = await simpleProvider.filter(filterContext, true, 'status', '=', 'retired'); expect(results.size()).toBe(1); // code2 has status=retired @@ -1599,14 +1599,14 @@ describe('FHIR CodeSystem Provider', () => { }); test('should filter by status in values', async () => { - const results = await simpleProvider.filter(filterContext, 'status', 'in', 'active,retired'); + const results = await simpleProvider.filter(filterContext, true, 'status', 'in', 'active,retired'); expect(results.size()).toBeGreaterThan(0); }); }); describe('Filter Iteration', () => { test('should iterate through filter results', async () => { - const results = await simpleProvider.filter(filterContext, 'code', 'in', 'code1,code2,code3'); + const results = await simpleProvider.filter(filterContext, true, 'code', 'in', 'code1,code2,code3'); expect(results.size()).toBe(3); results.reset(); @@ -1621,7 +1621,7 @@ describe('FHIR CodeSystem Provider', () => { }); test('should locate specific code in filter results', async () => { - const results = await simpleProvider.filter(filterContext, 'code', 'in', 'code1,code2'); + const results = await simpleProvider.filter(filterContext, true, 'code', 'in', 'code1,code2'); const located = await simpleProvider.filterLocate(filterContext, results, 'code1'); expect(located).toBeInstanceOf(FhirCodeSystemProviderContext); @@ -1632,7 +1632,7 @@ describe('FHIR CodeSystem Provider', () => { }); test('should check if concept is in filter results', async () => { - const results = await simpleProvider.filter(filterContext, 'code', 'in', 'code1,code2'); + const results = await simpleProvider.filter(filterContext, true, 'code', 'in', 'code1,code2'); const concept1 = new FhirCodeSystemProviderContext('code1', simpleCS.getConceptByCode('code1')); const concept3 = new FhirCodeSystemProviderContext('code3', simpleCS.getConceptByCode('code3')); @@ -1645,7 +1645,7 @@ describe('FHIR CodeSystem Provider', () => { }); test('should get filter size correctly', async () => { - const results = await simpleProvider.filter(filterContext, 'code', 'in', 'code1,code2,code3'); + const results = await simpleProvider.filter(filterContext, true, 'code', 'in', 'code1,code2,code3'); const size = await simpleProvider.filterSize(filterContext, results); expect(size).toBe(3); @@ -1656,7 +1656,7 @@ describe('FHIR CodeSystem Provider', () => { test('should execute and finish filters properly', async () => { filterContext.filters = []; - await simpleProvider.filter(filterContext, 'code', '=', 'code1'); + await simpleProvider.filter(filterContext, true, 'code', '=', 'code1'); const executed = await simpleProvider.executeFilters(filterContext); expect(Array.isArray(executed)).toBe(true); @@ -1688,17 +1688,17 @@ describe('FHIR CodeSystem Provider', () => { }); test('should work with Extensions CodeSystem', async () => { - const results = await extensionsProvider.filter(filterContext, 'code', 'regex', 'code[1-3]'); + const results = await extensionsProvider.filter(filterContext, true, 'code', 'regex', 'code[1-3]'); expect(results.size()).toBe(3); }); test('should handle multiple filters in sequence', async () => { // First filter: get all concepts with children - const withChildren = await simpleProvider.filter(filterContext, 'child', 'exists', 'true'); + const withChildren = await simpleProvider.filter(filterContext, true, 'child', 'exists', 'true'); expect(withChildren.size()).toBe(2); // Second filter: get concepts with specific property - const withProperty = await simpleProvider.filter(filterContext, 'prop', '=', 'new'); + const withProperty = await simpleProvider.filter(filterContext, true, 'prop', '=', 'new'); expect(withProperty.size()).toBeGreaterThan(0); // Both filters should be in context diff --git a/tests/cs/cs-currency.test.js b/tests/cs/cs-currency.test.js index 86ef4ecd..123b1026 100644 --- a/tests/cs/cs-currency.test.js +++ b/tests/cs/cs-currency.test.js @@ -207,7 +207,7 @@ describe('Iso4217Services', () => { test('should throw error for unsupported filter', async () => { const ctxt = await provider.getPrepContext(false); await expect( - provider.filter(ctxt, 'symbol', 'equals', '$') + provider.filter(ctxt, true, 'symbol', 'equals', '$') ).rejects.toThrow('not supported'); }); }); @@ -218,7 +218,7 @@ describe('Iso4217Services', () => { beforeEach(async () => { ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'decimals', 'equals', '2'); + await provider.filter(ctxt, true, 'decimals', 'equals', '2'); const filters = await provider.executeFilters(ctxt); decimalsFilter = filters[0]; }); @@ -286,7 +286,7 @@ describe('Iso4217Services', () => { beforeEach(async () => { ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'decimals', 'equals', '0'); + await provider.filter(ctxt, true, 'decimals', 'equals', '0'); const filters = await provider.executeFilters(ctxt); decimalsFilter = filters[0]; }); @@ -345,7 +345,7 @@ describe('Iso4217Services', () => { beforeEach(async () => { ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'decimals', 'equals', '3'); + await provider.filter(ctxt, true, 'decimals', 'equals', '3'); const filters = await provider.executeFilters(ctxt); decimalsFilter = filters[0]; }); @@ -390,7 +390,7 @@ describe('Iso4217Services', () => { beforeEach(async () => { ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'decimals', 'equals', '-1'); + await provider.filter(ctxt, true, 'decimals', 'equals', '-1'); const filters = await provider.executeFilters(ctxt); decimalsFilter = filters[0]; }); @@ -432,7 +432,7 @@ describe('Iso4217Services', () => { describe('Execute Filters', () => { test('should execute single filter', async () => { const ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'decimals', 'equals', '2'); + await provider.filter(ctxt, true, 'decimals', 'equals', '2'); const results = await provider.executeFilters(ctxt); expect(results).toBeTruthy(); diff --git a/tests/cs/cs-lang.test.js b/tests/cs/cs-lang.test.js index 45872d7a..b0faebfb 100644 --- a/tests/cs/cs-lang.test.js +++ b/tests/cs/cs-lang.test.js @@ -170,7 +170,7 @@ describe('IETF Language CodeSystem Provider', () => { test('should create language component filters', async () => { const prep = await provider.getPrepContext(false); - await provider.filter(prep, 'language', 'exists', 'true'); + await provider.filter(prep, true, 'language', 'exists', 'true'); const filters = await provider.executeFilters(prep); expect(filters[0]).toBeInstanceOf(IETFLanguageCodeFilter); expect(filters[0].component).toBe(LanguageComponent.LANG); @@ -181,7 +181,7 @@ describe('IETF Language CodeSystem Provider', () => { const prep = await provider.getPrepContext(false); await expect( - provider.filter(prep, 'language', 'equals', 'en') + provider.filter(prep, true, 'language', 'equals', 'en') ).rejects.toThrow('Unsupported filter operator'); }); @@ -189,7 +189,7 @@ describe('IETF Language CodeSystem Provider', () => { const prep = await provider.getPrepContext(false); await expect( - provider.filter(prep, 'language', 'exists', 'maybe') + provider.filter(prep, true, 'language', 'exists', 'maybe') ).rejects.toThrow('Invalid exists value'); }); @@ -197,7 +197,7 @@ describe('IETF Language CodeSystem Provider', () => { const prep = await provider.getPrepContext(false); await expect( - provider.filter(prep, 'invalid-prop', 'exists', 'true') + provider.filter(prep, true, 'invalid-prop', 'exists', 'true') ).rejects.toThrow('Unsupported filter property'); }); }); @@ -205,7 +205,7 @@ describe('IETF Language CodeSystem Provider', () => { describe('Filter location', () => { test('should locate code with required language component', async () => { const prep = await provider.getPrepContext(false); - await provider.filter(prep, 'language', 'exists', 'true'); + await provider.filter(prep, true, 'language', 'exists', 'true'); const filters = await provider.executeFilters(prep); const result = await provider.filterLocate(prep, filters[0], 'en-US'); expect(result).toBeInstanceOf(Language); @@ -214,7 +214,7 @@ describe('IETF Language CodeSystem Provider', () => { test('should locate code with required region component', async () => { const prep = await provider.getPrepContext(false); - await provider.filter(prep, 'region', 'exists', 'true'); + await provider.filter(prep, true, 'region', 'exists', 'true'); const filters = await provider.executeFilters(prep); const filter = filters[0]; @@ -225,7 +225,7 @@ describe('IETF Language CodeSystem Provider', () => { test('should reject code missing required component', async () => { const prep = await provider.getPrepContext(false); - await provider.filter(prep, 'region', 'exists', 'true'); + await provider.filter(prep, true, 'region', 'exists', 'true'); const filters = await provider.executeFilters(prep); const filter = filters[0]; @@ -236,7 +236,7 @@ describe('IETF Language CodeSystem Provider', () => { test('should reject code with forbidden component', async () => { const prep = await provider.getPrepContext(false); - await provider.filter(prep, 'region', 'exists', 'false'); + await provider.filter(prep, true, 'region', 'exists', 'false'); const filters = await provider.executeFilters(prep); const filter = filters[0]; @@ -248,7 +248,7 @@ describe('IETF Language CodeSystem Provider', () => { test('should reject invalid language codes in filter', async () => { const prep = await provider.getPrepContext(false); - await provider.filter(prep, 'language', 'exists', 'true'); + await provider.filter(prep, true, 'language', 'exists', 'true'); const filters = await provider.executeFilters(prep); const filter = filters[0]; diff --git a/tests/cs/cs-loinc.test.js b/tests/cs/cs-loinc.test.js index d19761ef..5c0e826d 100644 --- a/tests/cs/cs-loinc.test.js +++ b/tests/cs/cs-loinc.test.js @@ -618,6 +618,7 @@ describe('LOINC Provider', () => { const filterContext = await provider.getPrepContext(true); await provider.filter( filterContext, + true, testCase.property, testCase.operator, testCase.value @@ -657,6 +658,7 @@ describe('LOINC Provider', () => { const filterContext = await provider.getPrepContext(true); await provider.filter( filterContext, + true, testCase.property, testCase.operator, testCase.value @@ -694,6 +696,7 @@ describe('LOINC Provider', () => { const filterContext = await provider.getPrepContext(true); await provider.filter( filterContext, + true, testCase.property, testCase.operator, testCase.value @@ -729,6 +732,7 @@ describe('LOINC Provider', () => { const filterContext = await provider.getPrepContext(true); await provider.filter( filterContext, + true, 'CLASSTYPE', '=', testCase.value @@ -753,6 +757,7 @@ describe('LOINC Provider', () => { const filterContext = await provider.getPrepContext(true); await provider.filter( filterContext, + true, 'concept', testCase.operator, testCase.value @@ -779,6 +784,7 @@ describe('LOINC Provider', () => { const filterContext = await provider.getPrepContext(true); await provider.filter( filterContext, + true, 'STATUS', '=', testCase.value @@ -800,6 +806,7 @@ describe('LOINC Provider', () => { const filterContext = await provider.getPrepContext(true); await provider.filter( filterContext, + true, 'copyright', '=', testCase.value @@ -830,6 +837,7 @@ describe('LOINC Provider', () => { const filterContext = await provider.getPrepContext(false); await provider.filter( filterContext, + true, testCase.property, testCase.operator, testCase.value @@ -853,6 +861,7 @@ describe('LOINC Provider', () => { const filterContext = await provider.getPrepContext(true); await provider.filter( filterContext, + true, testCase.property, testCase.operator, testCase.value @@ -876,6 +885,7 @@ describe('LOINC Provider', () => { const filterContext = await provider.getPrepContext(true); await provider.filter( filterContext, + true, testCase.property, testCase.operator, testCase.value @@ -954,7 +964,7 @@ describe('LOINC Provider', () => { const filterContext = await provider.getPrepContext(true); await expect( - provider.filter(filterContext, 'unsupported', '=', 'value') + provider.filter(filterContext, true, 'unsupported', '=', 'value') ).rejects.toThrow('not supported'); }); diff --git a/tests/cs/cs-ndc.test.js b/tests/cs/cs-ndc.test.js index 1b1a7dcf..00a366cd 100644 --- a/tests/cs/cs-ndc.test.js +++ b/tests/cs/cs-ndc.test.js @@ -604,7 +604,7 @@ describe('NDC Provider', () => { test('should filter by product code-type', async () => { const filterContext = await provider.getPrepContext(true); - const filter = await provider.filter(filterContext, 'code-type', '=', 'product'); + const filter = await provider.filter(filterContext, true, 'code-type', '=', 'product'); expect(filter).toBeDefined(); expect(filter.type).toBe('code-type'); @@ -621,7 +621,7 @@ describe('NDC Provider', () => { test('should filter by 10-digit code-type', async () => { const filterContext = await provider.getPrepContext(true); - const filter = await provider.filter(filterContext, 'code-type', '=', '10-digit'); + const filter = await provider.filter(filterContext, true, 'code-type', '=', '10-digit'); const size = await provider.filterSize(filterContext, filter); expect(size).toBeGreaterThan(0); @@ -631,7 +631,7 @@ describe('NDC Provider', () => { test('should locate code within filter', async () => { const filterContext = await provider.getPrepContext(true); - const filter = await provider.filter(filterContext, 'code-type', '=', 'product'); + const filter = await provider.filter(filterContext, true, 'code-type', '=', 'product'); const located = await provider.filterLocate(filterContext, filter, '0002-0152'); expect(located).toBeInstanceOf(NdcConcept); @@ -642,7 +642,7 @@ describe('NDC Provider', () => { test('should check if concept is in filter', async () => { const filterContext = await provider.getPrepContext(true); - const productFilter = await provider.filter(filterContext, 'code-type', '=', 'product'); + const productFilter = await provider.filter(filterContext, true, 'code-type', '=', 'product'); const productResult = await provider.locate('0002-0152'); const packageResult = await provider.locate('0002-0152-01'); @@ -658,7 +658,7 @@ describe('NDC Provider', () => { test('should iterate filter results', async () => { const filterContext = await provider.getPrepContext(true); - const filter = await provider.filter(filterContext, 'code-type', '=', 'product'); + const filter = await provider.filter(filterContext, true, 'code-type', '=', 'product'); let hasMore = await provider.filterMore(filterContext, filter); expect(hasMore).toBe(true); @@ -684,7 +684,7 @@ describe('NDC Provider', () => { const filterContext = await provider.getPrepContext(true); await expect( - provider.filter(filterContext, 'unsupported', '=', 'value') + provider.filter(filterContext, true, 'unsupported', '=', 'value') ).rejects.toThrow('not supported'); }); diff --git a/tests/cs/cs-omop.test.js b/tests/cs/cs-omop.test.js index e8680097..9bf71efc 100644 --- a/tests/cs/cs-omop.test.js +++ b/tests/cs/cs-omop.test.js @@ -312,7 +312,7 @@ describe('OMOP Provider', () => { const testDomain = testData.domains[0]; const filterContext = await provider.getPrepContext(true); - await provider.filter(filterContext, 'domain', '=', testDomain); + await provider.filter(filterContext, true, 'domain', '=', testDomain); const filters = await provider.executeFilters(filterContext); const filter = filters[0]; @@ -348,7 +348,7 @@ describe('OMOP Provider', () => { const filterContext = await provider.getPrepContext(true); try { - await provider.filter(filterContext, 'domain', '=', domain); + await provider.filter(filterContext, true, 'domain', '=', domain); const filters = await provider.executeFilters(filterContext); const filter = filters[0]; @@ -372,7 +372,7 @@ describe('OMOP Provider', () => { if (concept && concept.domain) { const filterContext = await provider.getPrepContext(true); - await provider.filter(filterContext, 'domain', '=', concept.domain); + await provider.filter(filterContext, true, 'domain', '=', concept.domain); const filters = await provider.executeFilters(filterContext); const filter = filters[0]; @@ -389,7 +389,7 @@ describe('OMOP Provider', () => { test('should have closed filters', async () => { if (testData.domains.length > 0) { const filterContext = await provider.getPrepContext(true); - await provider.filter(filterContext, 'domain', '=', testData.domains[0]); + await provider.filter(filterContext, true, 'domain', '=', testData.domains[0]); const notClosed = await provider.filtersNotClosed(filterContext); expect(notClosed).toBe(false); @@ -483,7 +483,7 @@ describe('OMOP Provider', () => { const filterContext = await provider.getPrepContext(true); await expect( - provider.filter(filterContext, 'unsupported', '=', 'value') + provider.filter(filterContext, true, 'unsupported', '=', 'value') ).rejects.toThrow('not understood'); }); @@ -538,7 +538,7 @@ describe('OMOP Provider', () => { for (const domain of testData.domains.slice(0, 3)) { const filterContext = await provider.getPrepContext(true); - await provider.filter(filterContext, 'domain', '=', domain); + await provider.filter(filterContext, true, 'domain', '=', domain); const filters = await provider.executeFilters(filterContext); const filter = filters[0]; diff --git a/tests/cs/cs-snomed-ecl.test.js b/tests/cs/cs-snomed-ecl.test.js index fd5ceb7d..1ac0a8c9 100644 --- a/tests/cs/cs-snomed-ecl.test.js +++ b/tests/cs/cs-snomed-ecl.test.js @@ -51,18 +51,19 @@ describe('ECL Validator Test Suite', () => { test('should tokenize constraint operators', () => { const operators = [ - { input: '<', expected: ECLTokenType.CHILD_OF }, - { input: '<<', expected: ECLTokenType.CHILD_OR_SELF_OF }, - { input: '<', expected: ECLTokenType.PARENT_OF }, - { input: '>>', expected: ECLTokenType.PARENT_OR_SELF_OF }, - { input: '>>!', expected: ECLTokenType.ANCESTOR_OR_SELF_OF }, - { input: '>!', expected: ECLTokenType.ANCESTOR_OF } + { input: '<', expected: ECLTokenType.DESCENDANT_OF }, + { input: '', expected: ECLTokenType.ANCESTOR_OF }, + { input: '>>', expected: ECLTokenType.ANCESTOR_OR_SELF_OF }, + { input: '>!', expected: ECLTokenType.PARENT_OF }, + { input: '>>!', expected: ECLTokenType.PARENT_OR_SELF_OF } ]; operators.forEach(({ input, expected }) => { const lexer = new ECLLexer(input); + console.log(`Tokenizing "${input}": ${expected}`); const tokens = lexer.tokenize(); expect(tokens[0].type).toBe(expected); expect(tokens[0].value).toBe(input); @@ -94,8 +95,8 @@ describe('ECL Validator Test Suite', () => { ECLTokenType.COLON, ECLTokenType.EQUALS, ECLTokenType.NOT_EQUALS, - ECLTokenType.CHILD_OF, - ECLTokenType.PARENT_OF, + ECLTokenType.DESCENDANT_OF, + ECLTokenType.ANCESTOR_OF, ECLTokenType.LTE, ECLTokenType.GTE, ECLTokenType.MEMBER_OF, @@ -105,6 +106,7 @@ describe('ECL Validator Test Suite', () => { ]; tokens.forEach((token, index) => { + console.log(`Token ${expectedTypes[index]}: ${token.type}`); if (index < expectedTypes.length - 1) { // Exclude EOF from detailed check expect(token.type).toBe(expectedTypes[index]); } @@ -162,7 +164,7 @@ describe('ECL Validator Test Suite', () => { const tokens = lexer.tokenize(); expect(tokens).toHaveLength(3); // <<, SCTID, EOF (whitespace ignored) - expect(tokens[0].type).toBe(ECLTokenType.CHILD_OR_SELF_OF); + expect(tokens[0].type).toBe(ECLTokenType.DESCENDANT_OR_SELF_OF); expect(tokens[1].type).toBe(ECLTokenType.SCTID); }); @@ -205,23 +207,24 @@ describe('ECL Validator Test Suite', () => { const testCases = [ { input: '< 404684003 |Clinical finding|', - operator: ECLTokenType.CHILD_OF + operator: ECLTokenType.DESCENDANT_OF }, { input: '<< 404684003 |Clinical finding|', - operator: ECLTokenType.CHILD_OR_SELF_OF + operator: ECLTokenType.DESCENDANT_OR_SELF_OF }, { input: ' { + console.log(`Parsing: ${input}`); const result = eclValidator.parse(input); expect(result.success).toBe(true); expect(result.ast.operator).toBe(operator); @@ -260,7 +263,7 @@ describe('ECL Validator Test Suite', () => { expect(result.success).toBe(true); expect(result.ast.type).toBe(ECLNodeType.REFINED_EXPRESSION_CONSTRAINT); - expect(result.ast.base.operator).toBe(ECLTokenType.CHILD_OR_SELF_OF); + expect(result.ast.base.operator).toBe(ECLTokenType.DESCENDANT_OR_SELF_OF); expect(result.ast.refinement.type).toBe(ECLNodeType.ATTRIBUTE); }); diff --git a/tests/cs/cs-ucum.test.js b/tests/cs/cs-ucum.test.js index dbdbbabe..6532f78f 100644 --- a/tests/cs/cs-ucum.test.js +++ b/tests/cs/cs-ucum.test.js @@ -240,7 +240,7 @@ describe('UCUM Provider Integration Tests', () => { const filterContext = new FilterExecutionContext(); // Create filter for mass units (canonical: 'g') - await provider.filter(filterContext, 'canonical', '=', 'g'); + await provider.filter(filterContext, true, 'canonical', '=', 'g'); const filters = await provider.executeFilters(filterContext); // Test units that should match (all mass units) diff --git a/tests/tx/test-cases.test.js b/tests/tx/test-cases.test.js index 4392eb87..d6f5e83b 100644 --- a/tests/tx/test-cases.test.js +++ b/tests/tx/test-cases.test.js @@ -84,6 +84,14 @@ describe('simple-cases', () => { await runTest({"suite":"simple-cases","test":"simple-expand-isa"}, "4.0"); }); + it('simple-expand-child-ofR5', async () => { + await runTest({"suite":"simple-cases","test":"simple-expand-child-of"}, "5.0"); + }); + + it('simple-expand-child-ofR4', async () => { + await runTest({"suite":"simple-cases","test":"simple-expand-child-of"}, "4.0"); + }); + it('simple-expand-isa-o2R5', async () => { await runTest({"suite":"simple-cases","test":"simple-expand-isa-o2"}, "5.0"); }); @@ -164,6 +172,10 @@ describe('simple-cases', () => { await runTest({"suite":"simple-cases","test":"simple-expand-all-count"}, "4.0"); }); + it('simple-expand-containedR5', async () => { + await runTest({"suite":"simple-cases","test":"simple-expand-contained"}, "5.0"); + }); + }); describe('parameters', () => { @@ -1333,6 +1345,14 @@ describe('validation', () => { await runTest({"suite":"validation","test":"validation-cs-code-bad-code"}, "4.0"); }); + it('validation-contained-badR5', async () => { + await runTest({"suite":"validation","test":"validation-contained-bad"}, "5.0"); + }); + + it('validation-contained-badR4', async () => { + await runTest({"suite":"validation","test":"validation-contained-bad"}, "4.0"); + }); + }); describe('version', () => { @@ -4906,20 +4926,20 @@ describe('snomed', () => { await runTest({"suite":"snomed","test":"snomed-inactive-display"}, "4.0"); }); - it('snomed-procedure-in-displayR5', async () => { - await runTest({"suite":"snomed","test":"snomed-procedure-in-display"}, "5.0"); + it('snomed-isa-in-displayR5', async () => { + await runTest({"suite":"snomed","test":"snomed-isa-in-display"}, "5.0"); }); - it('snomed-procedure-in-displayR4', async () => { - await runTest({"suite":"snomed","test":"snomed-procedure-in-display"}, "4.0"); + it('snomed-isa-in-displayR4', async () => { + await runTest({"suite":"snomed","test":"snomed-isa-in-display"}, "4.0"); }); - it('snomed-procedure-out-displayR5', async () => { - await runTest({"suite":"snomed","test":"snomed-procedure-out-display"}, "5.0"); + it('snomed-isa-out-displayR5', async () => { + await runTest({"suite":"snomed","test":"snomed-isa-out-display"}, "5.0"); }); - it('snomed-procedure-out-displayR4', async () => { - await runTest({"suite":"snomed","test":"snomed-procedure-out-display"}, "4.0"); + it('snomed-isa-out-displayR4', async () => { + await runTest({"suite":"snomed","test":"snomed-isa-out-display"}, "4.0"); }); it('snomed-expand-inactiveR5', async () => { @@ -4930,20 +4950,276 @@ describe('snomed', () => { await runTest({"suite":"snomed","test":"snomed-expand-inactive"}, "4.0"); }); - it('snomed-expand-diabetesR5', async () => { - await runTest({"suite":"snomed","test":"snomed-expand-diabetes"}, "5.0"); + it('snomed-expand-isaR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-isa"}, "5.0"); + }); + + it('snomed-expand-isaR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-isa"}, "4.0"); + }); + + it('snomed-expand-ecl-descOrSelfR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-descOrSelf"}, "5.0"); + }); + + it('snomed-expand-ecl-descOrSelfR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-descOrSelf"}, "4.0"); + }); + + it('snomed-expand-ecl-descendentsR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-descendents"}, "5.0"); + }); + + it('snomed-expand-ecl-descendentsR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-descendents"}, "4.0"); + }); + + it('snomed-expand-ecl-ancestorsR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-ancestors"}, "5.0"); + }); + + it('snomed-expand-ecl-ancestorsR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-ancestors"}, "4.0"); + }); + + it('snomed-expand-ecl-ancOrSelfR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-ancOrSelf"}, "5.0"); + }); + + it('snomed-expand-ecl-ancOrSelfR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-ancOrSelf"}, "4.0"); + }); + + it('snomed-expand-ecl-childrenOrSelfR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-childrenOrSelf"}, "5.0"); + }); + + it('snomed-expand-ecl-childrenOrSelfR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-childrenOrSelf"}, "4.0"); + }); + + it('snomed-expand-ecl-childrenR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-children"}, "5.0"); + }); + + it('snomed-expand-ecl-childrenR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-children"}, "4.0"); + }); + + it('snomed-expand-ecl-parentsR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-parents"}, "5.0"); + }); + + it('snomed-expand-ecl-parentsR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-parents"}, "4.0"); + }); + + it('snomed-expand-ecl-parentsOrSelfR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-parentsOrSelf"}, "5.0"); + }); + + it('snomed-expand-ecl-parentsOrSelfR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-parentsOrSelf"}, "4.0"); + }); + + it('snomed-expand-ecl-wildcardR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-wildcard"}, "5.0"); + }); + + it('snomed-expand-ecl-wildcardR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-wildcard"}, "4.0"); + }); + + it('snomed-expand-ecl-memberOf-refsetR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-memberOf-refset"}, "5.0"); + }); + + it('snomed-expand-ecl-memberOf-refsetR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-memberOf-refset"}, "4.0"); + }); + + it('snomed-expand-ecl-memberOf-nonRefsetR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-memberOf-nonRefset"}, "5.0"); + }); + + it('snomed-expand-ecl-memberOf-nonRefsetR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-memberOf-nonRefset"}, "4.0"); + }); + + it('snomed-expand-ecl-orR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-or"}, "5.0"); + }); + + it('snomed-expand-ecl-orR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-or"}, "4.0"); + }); + + it('snomed-expand-ecl-andR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-and"}, "5.0"); + }); + + it('snomed-expand-ecl-andR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-and"}, "4.0"); + }); + + it('snomed-expand-ecl-minusR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-minus"}, "5.0"); + }); + + it('snomed-expand-ecl-minusR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-minus"}, "4.0"); + }); + + it('snomed-expand-ecl-minus-emptyR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-minus-empty"}, "5.0"); + }); + + it('snomed-expand-ecl-minus-emptyR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-minus-empty"}, "4.0"); + }); + + it('snomed-expand-ecl-wildcard-minusR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-wildcard-minus"}, "5.0"); + }); + + it('snomed-expand-ecl-wildcard-minusR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-wildcard-minus"}, "4.0"); + }); + + it('snomed-expand-ecl-parens-precedenceR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-parens-precedence"}, "5.0"); + }); + + it('snomed-expand-ecl-parens-precedenceR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-parens-precedence"}, "4.0"); + }); + + it('snomed-expand-ecl-left-associativeR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-left-associative"}, "5.0"); + }); + + it('snomed-expand-ecl-left-associativeR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-left-associative"}, "4.0"); + }); + + it('snomed-expand-ecl-term-matchR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-term-match"}, "5.0"); + }); + + it('snomed-expand-ecl-term-matchR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-term-match"}, "4.0"); + }); + + it('snomed-expand-ecl-term-mismatchR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-term-mismatch"}, "5.0"); + }); + + it('snomed-expand-ecl-term-mismatchR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-term-mismatch"}, "4.0"); + }); + + it('snomed-expand-ecl-term-with-operatorR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-term-with-operator"}, "5.0"); + }); + + it('snomed-expand-ecl-term-with-operatorR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-term-with-operator"}, "4.0"); + }); + + it('snomed-expand-ecl-unknown-conceptR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-unknown-concept"}, "5.0"); + }); + + it('snomed-expand-ecl-unknown-conceptR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-unknown-concept"}, "4.0"); + }); + + it('snomed-expand-ecl-invalid-sctidR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-invalid-sctid"}, "5.0"); + }); + + it('snomed-expand-ecl-invalid-sctidR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-invalid-sctid"}, "4.0"); + }); + + it('snomed-expand-ecl-missing-focusR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-missing-focus"}, "5.0"); + }); + + it('snomed-expand-ecl-missing-focusR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-missing-focus"}, "4.0"); + }); + + it('snomed-expand-ecl-trailing-tokensR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-trailing-tokens"}, "5.0"); }); - it('snomed-expand-diabetesR4', async () => { - await runTest({"suite":"snomed","test":"snomed-expand-diabetes"}, "4.0"); + it('snomed-expand-ecl-trailing-tokensR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-trailing-tokens"}, "4.0"); }); - it('snomed-expand-proceduresR5', async () => { - await runTest({"suite":"snomed","test":"snomed-expand-procedures"}, "5.0"); + it('snomed-expand-ecl-nested-parensR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-nested-parens"}, "5.0"); }); - it('snomed-expand-proceduresR4', async () => { - await runTest({"suite":"snomed","test":"snomed-expand-procedures"}, "4.0"); + it('snomed-expand-ecl-nested-parensR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-nested-parens"}, "4.0"); + }); + + it('snomed-expand-ecl-refinement-simpleR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-refinement-simple"}, "5.0"); + }); + + it('snomed-expand-ecl-refinement-simpleR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-refinement-simple"}, "4.0"); + }); + + it('snomed-expand-ecl-refinement-morphologyR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-refinement-morphology"}, "5.0"); + }); + + it('snomed-expand-ecl-refinement-morphologyR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-refinement-morphology"}, "4.0"); + }); + + it('snomed-expand-ecl-refinement-wildcardR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-refinement-wildcard"}, "5.0"); + }); + + it('snomed-expand-ecl-refinement-wildcardR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-refinement-wildcard"}, "4.0"); + }); + + it('snomed-expand-ecl-refinement-groupR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-refinement-group"}, "5.0"); + }); + + it('snomed-expand-ecl-refinement-groupR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-refinement-group"}, "4.0"); + }); + + it('snomed-expand-ecl-refinement-cardinalityR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-refinement-cardinality"}, "5.0"); + }); + + it('snomed-expand-ecl-refinement-cardinalityR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-refinement-cardinality"}, "4.0"); + }); + + it('snomed-expand-ecl-dottedR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-dotted"}, "5.0"); + }); + + it('snomed-expand-ecl-dottedR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-ecl-dotted"}, "4.0"); + }); + + it('snomed-expand-too-bigR5', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-too-big"}, "5.0"); + }); + + it('snomed-expand-too-bigR4', async () => { + await runTest({"suite":"snomed","test":"snomed-expand-too-big"}, "4.0"); }); it('lookupR5', async () => { @@ -6195,7 +6471,7 @@ describe('permutations', () => { }); describe('regex-bad', () => { - // Bad Regex - denial of service attack + // Bad Regex - checking defences against denial of service attack. These are unusual because servers have the option to succeed, or to refuse the request it('expand-regex-badR5', async () => { await runTest({"suite":"regex-bad","test":"expand-regex-bad"}, "5.0"); @@ -6213,6 +6489,22 @@ describe('regex-bad', () => { await runTest({"suite":"regex-bad","test":"validate-regex-bad"}, "4.0"); }); + it('expand-regex-bad-2R5', async () => { + await runTest({"suite":"regex-bad","test":"expand-regex-bad-2"}, "5.0"); + }); + + it('expand-regex-bad-2R4', async () => { + await runTest({"suite":"regex-bad","test":"expand-regex-bad-2"}, "4.0"); + }); + + it('validate-regex-bad-2R5', async () => { + await runTest({"suite":"regex-bad","test":"validate-regex-bad-2"}, "5.0"); + }); + + it('validate-regex-bad-2R4', async () => { + await runTest({"suite":"regex-bad","test":"validate-regex-bad-2"}, "4.0"); + }); + }); describe('related2', () => { diff --git a/translations/Messages.properties b/translations/Messages.properties index 9f8314fb..daea538e 100644 --- a/translations/Messages.properties +++ b/translations/Messages.properties @@ -1511,4 +1511,6 @@ CONFORMANCE_STATEMENT_WORD = The html source contains the word ''{0}'' but it is VALUESET_CODE_CONCEPT_HINT = {3}. Note that the display in the ValueSet does not have to match; this check exists to help check that it''s not accidentally the wrong code VALUESET_CODE_CONCEPT_HINT_VER ={3}. Note that the display in the ValueSet does not have to match; this check exists to help check that it''s not accidentally the wrong code TERMINOLOGY_TX_SYSTEM_UNSUPPORTED = The code cannot be checked because codeSystem ''{0}'' version ''{1}'' is not supported ({2}) -INVALID_REGEX = The regex ''{0}'' is not valid: {1} \ No newline at end of file +INVALID_REGEX = The regex ''{0}'' is not valid: {1} +INVALID_ECL = Invalid ECL expression: ''{0}'': ({1}) +UNSUPPORTED_ECL = The ECL expression is not supported: ''{0}'': ({1}) diff --git a/tx/cs/cs-api.js b/tx/cs/cs-api.js index 5bfa9e20..a93711ea 100644 --- a/tx/cs/cs-api.js +++ b/tx/cs/cs-api.js @@ -579,11 +579,12 @@ class CodeSystemProvider { * throws an exception if the search filter can't be handled * * @param {FilterExecutionContext} filterContext filtering context + * @param {boolean} forIteration - whether this filter is going to be iterated * @param {String} prop * @param {ValueSetFilterOperator} op * @param {String} prop **/ - async filter(filterContext, prop, op, value) { throw new Error("Must override"); } // well, only if any filters are actually supported + async filter(filterContext, forIteration, prop, op, value) { throw new Error("Must override"); } // well, only if any filters are actually supported /** * called once all the filters have been handled, and iteration is about to happen. diff --git a/tx/cs/cs-areacode.js b/tx/cs/cs-areacode.js index ee5c9eae..a94be9d4 100644 --- a/tx/cs/cs-areacode.js +++ b/tx/cs/cs-areacode.js @@ -169,7 +169,7 @@ class AreaCodeServices extends CodeSystemProvider { return (prop === 'type' || prop === 'class') && op === '='; } - async filter(filterContext, prop, op, value) { + async filter(filterContext, forIteration, prop, op, value) { assert(filterContext && filterContext instanceof FilterExecutionContext, 'filterContext must be a FilterExecutionContext'); assert(prop != null && typeof prop === 'string', 'prop must be a non-null string'); diff --git a/tx/cs/cs-country.js b/tx/cs/cs-country.js index 3c1b6057..a93f2fea 100644 --- a/tx/cs/cs-country.js +++ b/tx/cs/cs-country.js @@ -188,7 +188,7 @@ class CountryCodeServices extends CodeSystemProvider { } - async filter(filterContext, prop, op, value) { + async filter(filterContext, forIteration, prop, op, value) { assert(filterContext && filterContext instanceof FilterExecutionContext, 'filterContext must be a FilterExecutionContext'); assert(prop != null && typeof prop === 'string', 'prop must be a non-null string'); diff --git a/tx/cs/cs-cpt.js b/tx/cs/cs-cpt.js index d5c3c96b..13debf53 100644 --- a/tx/cs/cs-cpt.js +++ b/tx/cs/cs-cpt.js @@ -490,7 +490,7 @@ class CPTServices extends BaseCSServices { return new CPTPrep(iterate); } - async filter(filterContext, prop, op, value) { + async filter(filterContext, forIteration, prop, op, value) { let list; diff --git a/tx/cs/cs-cs.js b/tx/cs/cs-cs.js index 20d3e5ce..d531eeb9 100644 --- a/tx/cs/cs-cs.js +++ b/tx/cs/cs-cs.js @@ -1217,7 +1217,7 @@ class FhirCodeSystemProvider extends BaseCSServices { * @param {string} value - Filter value * @returns {Promise} Filter results */ - async filter(filterContext, prop, op, value) { + async filter(filterContext, forIteration, prop, op, value) { let results = null; diff --git a/tx/cs/cs-currency.js b/tx/cs/cs-currency.js index e07fc36a..dfd0784d 100644 --- a/tx/cs/cs-currency.js +++ b/tx/cs/cs-currency.js @@ -176,7 +176,7 @@ class Iso4217Services extends CodeSystemProvider { return prop === 'decimals' && op === 'equals'; } - async filter(filterContext, prop, op, value) { + async filter(filterContext, forIteration, prop, op, value) { assert(filterContext && filterContext instanceof FilterExecutionContext, 'filterContext must be a FilterExecutionContext'); assert(prop != null && typeof prop === 'string', 'prop must be a non-null string'); diff --git a/tx/cs/cs-hgvs.js b/tx/cs/cs-hgvs.js index d2a90ca8..140a9b8a 100644 --- a/tx/cs/cs-hgvs.js +++ b/tx/cs/cs-hgvs.js @@ -200,7 +200,7 @@ class HGVSServices extends CodeSystemProvider { throw new Error('Filters are not supported for HGVS'); } - async filter(filterContext, prop, op, value) { + async filter(filterContext, forIteration, prop, op, value) { throw new Error('Filters are not supported for HGVS'); } diff --git a/tx/cs/cs-lang.js b/tx/cs/cs-lang.js index 774a6208..fe85a6bf 100644 --- a/tx/cs/cs-lang.js +++ b/tx/cs/cs-lang.js @@ -259,7 +259,7 @@ class IETFLanguageCodeProvider extends CodeSystemProvider { return false; } - async filter(filterContext, prop, op, value) { + async filter(filterContext, forIteration, prop, op, value) { assert(filterContext && filterContext instanceof FilterExecutionContext, 'filterContext must be a FilterExecutionContext'); assert(prop != null && typeof prop === 'string', 'prop must be a non-null string'); diff --git a/tx/cs/cs-loinc.js b/tx/cs/cs-loinc.js index b5020aab..bf187c25 100644 --- a/tx/cs/cs-loinc.js +++ b/tx/cs/cs-loinc.js @@ -658,7 +658,7 @@ class LoincServices extends BaseCSServices { return new LoincPrep(iterate); } - async filter(filterContext, prop, op, value) { + async filter(filterContext, forIteration, prop, op, value) { const filter = new LoincFilterHolder(); await this.#executeFilterQuery(prop, op, value, filter); filterContext.filters.push(filter); diff --git a/tx/cs/cs-ndc.js b/tx/cs/cs-ndc.js index 49c76bde..3ee95ab6 100644 --- a/tx/cs/cs-ndc.js +++ b/tx/cs/cs-ndc.js @@ -405,7 +405,7 @@ class NdcServices extends CodeSystemProvider { ['10-digit', '11-digit', 'product'].includes(value); } - async filter(filterContext, prop, op, value) { + async filter(filterContext, forIteration, prop, op, value) { if (prop === 'code-type' && op === '=') { diff --git a/tx/cs/cs-omop.js b/tx/cs/cs-omop.js index 5933852d..866281c7 100644 --- a/tx/cs/cs-omop.js +++ b/tx/cs/cs-omop.js @@ -549,7 +549,7 @@ class OMOPServices extends BaseCSServices { return new OMOPPrep(iterate); } - async filter(filterContext, prop, op, value) { + async filter(filterContext, forIteration, prop, op, value) { if (prop === 'domain' && op === '=') { diff --git a/tx/cs/cs-rxnorm.js b/tx/cs/cs-rxnorm.js index 82badecd..14b89572 100644 --- a/tx/cs/cs-rxnorm.js +++ b/tx/cs/cs-rxnorm.js @@ -366,7 +366,7 @@ class RxNormServices extends CodeSystemProvider { return new RxNormPrep(); } - async filter(filterContext, prop, op, value) { + async filter(filterContext, forIteration, prop, op, value) { const filter = new RxNormFilterHolder(); diff --git a/tx/cs/cs-snomed.js b/tx/cs/cs-snomed.js index cec5cce3..f00107ec 100644 --- a/tx/cs/cs-snomed.js +++ b/tx/cs/cs-snomed.js @@ -13,6 +13,9 @@ const {DesignationUse} = require("../library/designations"); const {BaseCSServices} = require("./cs-base"); const {formatDateMMDDYYYY} = require("../../library/utilities"); const {ConceptMap} = require("../library/conceptmap"); +const {ECLLexer, ECLParser, ECLNodeType, ECLTokenType} = require("../sct/ecl"); +const {Issue} = require("../library/operation-outcome"); +const {debugLog} = require("../operation-context"); // Context kinds matching Pascal enum const SnomedProviderContextKind = { @@ -57,15 +60,15 @@ class SnomedExpressionContext { getReference() { return this.expression && this.expression.concepts.length > 0 - ? this.expression.concepts[0].reference - : NO_REFERENCE; + ? this.expression.concepts[0].reference + : NO_REFERENCE; } getCode() { if (this.source) return this.source; return this.expression && this.expression.concepts.length > 0 - ? this.expression.concepts[0].code - : ''; + ? this.expression.concepts[0].code + : ''; } } @@ -471,15 +474,48 @@ class SnomedServices { * @param {string} eclExpression * @returns {SnomedFilterContext} */ - filterECL = function (eclExpression) { + filterECL = function (eclExpression, forIteration, opContext) { let ast; try { const tokens = new ECLLexer(eclExpression).tokenize(); ast = new ECLParser(tokens).parse(); } catch (err) { - throw new Error(`Invalid ECL expression: ${err.message}`); + debugLog(err); + throw new Issue('error', 'invalid', null, 'INVALID_ECL', opContext.i18n.translate('INVALID_ECL', opContext.langs, [eclExpression, err.message]), 'vs-invalid').handleAsOO(400); } - return this._evalECLNode(ast); + let result; + try { + result = this._evalECLNode(ast); + } catch (err) { + debugLog(err); + throw new Issue('error', 'invalid', null, 'UNSUPPORTED_ECL', opContext.i18n.translate('UNSUPPORTED_ECL', opContext.langs, [eclExpression, err.message]), 'vs-invalid').handleAsOO(400); + } + // Wildcard + iteration: the `eclWildcard` flag is only consulted by the + // per-concept membership checks (filterCheck/filterLocate). For an $expand + // we actually need the full concept list, otherwise filterSize returns 0 + // and the iteration yields nothing. Materialise active concepts now. + if (forIteration && result.eclWildcard && (!result.descendants || result.descendants.length === 0)) { + result.descendants = this._eclEnumerateActiveConcepts(); + delete result.eclWildcard; + } + return result; + }; + + /** + * Return every active concept's index. Used to materialise wildcard results + * when the filter needs to be iterated over (e.g. $expand). + * @returns {number[]} + */ + _eclEnumerateActiveConcepts = function () { + const all = []; + const n = this.concepts.count(); + for (let i = 0; i < n; i++) { + const concept = this.concepts.getConceptByCount(i); + if ((concept.flags & 0x0F) === 0) { // active + all.push(concept.index); + } + } + return all; }; /** @@ -513,10 +549,10 @@ class SnomedServices { } case ECLNodeType.REFINED_EXPRESSION_CONSTRAINT: - throw new Error('ECL refinements (the : syntax) are not yet supported by this server'); + return this._evalRefined(node); case ECLNodeType.DOTTED_EXPRESSION_CONSTRAINT: - throw new Error('ECL dotted expressions are not yet supported by this server'); + return this._evalDotted(node); default: // Could be a bare concept reference or wildcard passed in directly @@ -578,20 +614,33 @@ class SnomedServices { case undefined: return this.filterEquals(conceptId); - case ECLTokenType.CHILD_OR_SELF_OF: // << - case ECLTokenType.DESCENDANT_OR_SELF_OF: // <> - case ECLTokenType.ANCESTOR_OR_SELF_OF: { // >>! — same for a single concept + // ── Ancestors ────────────────────────────────────────────────────────── + case ECLTokenType.ANCESTOR_OR_SELF_OF: { // >> self + all transitive ancestors const result = this.filterGeneralizes(conceptId); - // filterGeneralizes returns ancestors only; add self const self = this.concepts.findConcept(conceptId); if (self.found && !result.descendants.includes(self.index)) { result.descendants.push(self.index); @@ -599,10 +648,22 @@ class SnomedServices { return result; } - case ECLTokenType.ANCESTOR_OF: // >! + case ECLTokenType.ANCESTOR_OF: { // > all transitive ancestors, no self return this.filterGeneralizes(conceptId); + } + + case ECLTokenType.PARENT_OR_SELF_OF: { // >>! self + direct parents only + const conceptResult = this.concepts.findConcept(conceptId); + if (!conceptResult.found) { + throw new Error(`The SNOMED CT Concept ${conceptId} is not known`); + } + const result = new SnomedFilterContext(); + const parents = this.getConceptParents(conceptResult.index); + result.descendants = [conceptResult.index, ...parents]; + return result; + } - case ECLTokenType.PARENT_OF: { // > — direct parents only + case ECLTokenType.PARENT_OF: { // >! direct parents only const conceptResult = this.concepts.findConcept(conceptId); if (!conceptResult.found) { throw new Error(`The SNOMED CT Concept ${conceptId} is not known`); @@ -643,6 +704,218 @@ class SnomedServices { return result; }; +// ── Dotted expressions ─────────────────────────────────────────────────────── + + /** + * Evaluate a dotted expression: ` . attrA . attrB`. + * For each chained attribute, replaces the current set with the set of + * active relationship targets whose `relType` matches the attribute. + * Only plain concept-reference attribute names are supported. + * @param {object} node + * @returns {SnomedFilterContext} + */ + _evalDotted = function (node) { + let current = this._eclResolveSet(this._evalECLNode(node.base)); + + for (const attr of node.attributes || []) { + if (attr.type !== ECLNodeType.CONCEPT_REFERENCE) { + throw new Error('ECL dotted expressions only support plain concept-reference attribute names'); + } + const attrResult = this.concepts.findConcept(attr.conceptId); + if (!attrResult.found) { + throw new Error(`The SNOMED CT Concept ${attr.conceptId} is not known`); + } + const attrTypeIdx = attrResult.index; + + const next = new Set(); + for (const conceptIdx of current) { + const relIdxs = this.getConceptRelationships(conceptIdx); + for (const relIdx of relIdxs) { + const rel = this.relationships.getRelationship(relIdx); + if (rel.active && rel.relType === attrTypeIdx) { + next.add(rel.target); + } + } + } + current = [...next]; + } + + const result = new SnomedFilterContext(); + result.descendants = current; + return result; + }; + +// ── Refinements ────────────────────────────────────────────────────────────── + + /** + * Evaluate a refined expression: ` : `. + * Supported refinement shapes: + * - ATTRIBUTE attr = valueExpr + * - ATTRIBUTE_SET attr1 = v1, attr2 = v2 (conjunction) + * - ATTRIBUTE_GROUP { attr1 = v1, attr2 = v2 } (same relationship group) + * Reverse attributes, cardinality, `!=`, and non-concept attribute names + * throw informative errors. + * @param {object} node + * @returns {SnomedFilterContext} + */ + _evalRefined = function (node) { + const baseSet = this._eclResolveSet(this._evalECLNode(node.base)); + const matching = []; + for (const conceptIdx of baseSet) { + if (this._refinementMatches(conceptIdx, node.refinement)) { + matching.push(conceptIdx); + } + } + const result = new SnomedFilterContext(); + result.descendants = matching; + return result; + }; + + /** + * Check whether a single concept satisfies a refinement node (ATTRIBUTE, + * ATTRIBUTE_SET, or ATTRIBUTE_GROUP). + * @param {number} conceptIdx + * @param {object} refinement + * @returns {boolean} + */ + _refinementMatches = function (conceptIdx, refinement) { + switch (refinement.type) { + case ECLNodeType.ATTRIBUTE: + return this._attributeMatches(conceptIdx, refinement, null); + case ECLNodeType.ATTRIBUTE_SET: + for (const a of refinement.attributes) { + if (!this._refinementMatches(conceptIdx, a)) return false; + } + return true; + case ECLNodeType.ATTRIBUTE_GROUP: + return this._attributeGroupMatches(conceptIdx, refinement); + default: + throw new Error(`Unsupported refinement node type: ${refinement.type}`); + } + }; + + /** + * Check whether a concept has at least one active relationship whose + * `relType` matches the attribute name and whose `target` is in the value + * expression's result set. If `groupFilter` is not null, the relationship + * must also have that exact `group` number (used by group matching). + * @param {number} conceptIdx + * @param {object} attr + * @param {number|null} groupFilter + * @returns {boolean} + */ + _attributeMatches = function (conceptIdx, attr, groupFilter) { + if (attr.reverse) { + throw new Error('ECL reverse attributes (R) are not yet supported'); + } + if (!attr.comparison) { + throw new Error('ECL attribute without a comparison is not supported'); + } + if (attr.comparison.type !== ECLNodeType.EXPRESSION_COMPARISON) { + throw new Error(`ECL ${attr.comparison.type} in refinements is not yet supported`); + } + if (attr.comparison.operator !== ECLTokenType.EQUALS) { + throw new Error('ECL != in refinements is not yet supported'); + } + if (attr.name.type !== ECLNodeType.CONCEPT_REFERENCE) { + throw new Error('ECL refinements only support plain concept-reference attribute names'); + } + + const count = this._countAttributeMatches(conceptIdx, attr, groupFilter); + + if (attr.cardinality) { + return this._cardinalityAccepts(attr.cardinality, count); + } + return count >= 1; + }; + + /** + * Count the number of active relationships on the concept whose `relType` + * matches the attribute name and whose `target` is in the value expression's + * result set. Honours an optional group filter. + * @param {number} conceptIdx + * @param {object} attr + * @param {number|null} groupFilter + * @returns {number} + */ + _countAttributeMatches = function (conceptIdx, attr, groupFilter) { + const attrResult = this.concepts.findConcept(attr.name.conceptId); + if (!attrResult.found) { + throw new Error(`The SNOMED CT Concept ${attr.name.conceptId} is not known`); + } + const attrTypeIdx = attrResult.index; + + const valueSet = new Set(this._eclResolveSet(this._evalECLNode(attr.comparison.value))); + + const relIdxs = this.getConceptRelationships(conceptIdx); + let count = 0; + for (const relIdx of relIdxs) { + const rel = this.relationships.getRelationship(relIdx); + if (!rel.active) continue; + if (rel.relType !== attrTypeIdx) continue; + if (groupFilter !== null && rel.group !== groupFilter) continue; + if (valueSet.has(rel.target)) count++; + } + return count; + }; + + /** + * Test a count against a parsed cardinality `{min, max}` where `max` is + * either an integer or the string `'*'` (unbounded). + * @param {{min: number, max: number|'*'}} cardinality + * @param {number} count + * @returns {boolean} + */ + _cardinalityAccepts = function (cardinality, count) { + const { min, max } = cardinality; + if (min != null && count < min) return false; + if (max != null && max !== '*' && count > max) return false; + return true; + }; + + /** + * Check whether any single relationship group on the concept satisfies all + * attributes in an ATTRIBUTE_GROUP. Ungrouped relationships (group === 0) + * are not eligible — an attribute group must match within a real group. + * + * If the group itself carries cardinality (e.g. `[1..1] {…}`), the match + * requires the count of matching groups to fall within the specified range. + * @param {number} conceptIdx + * @param {object} group + * @returns {boolean} + */ + _attributeGroupMatches = function (conceptIdx, group) { + const relIdxs = this.getConceptRelationships(conceptIdx); + const groupNumbers = new Set(); + for (const relIdx of relIdxs) { + const rel = this.relationships.getRelationship(relIdx); + if (rel.active && rel.group > 0) { + groupNumbers.add(rel.group); + } + } + + let matchingGroupCount = 0; + for (const g of groupNumbers) { + let allMatch = true; + for (const attr of group.attributes) { + if (!this._attributeMatches(conceptIdx, attr, g)) { + allMatch = false; + break; + } + } + if (allMatch) { + matchingGroupCount++; + // With no cardinality, short-circuit on the first matching group. + if (!group.cardinality) return true; + } + } + + if (group.cardinality) { + return this._cardinalityAccepts(group.cardinality, matchingGroupCount); + } + return false; + }; + // ── Set operation helpers ──────────────────────────────────────────────────── /** @@ -658,6 +931,21 @@ class SnomedServices { return []; }; + /** + * Like _eclToIndexArray, but if the context is a bare wildcard (no + * descendants populated) it materialises the full active-concept list + * via _eclEnumerateActiveConcepts. Used by dotted/refined evaluation, + * which need an explicit concept set to iterate over. + * @param {SnomedFilterContext} ctx + * @returns {number[]} + */ + _eclResolveSet = function (ctx) { + if (ctx.eclWildcard && (!ctx.descendants || ctx.descendants.length === 0)) { + return this._eclEnumerateActiveConcepts(); + } + return this._eclToIndexArray(ctx); + }; + /** * AND: concepts present in both sets. */ @@ -845,7 +1133,7 @@ class SnomedProvider extends BaseCSServices { // Core concept methods async code(context) { - + const ctxt = await this.#ensureContext(context); if (!ctxt) return null; @@ -858,7 +1146,7 @@ class SnomedProvider extends BaseCSServices { } async display(context) { - + const ctxt = await this.#ensureContext(context); if (!ctxt) return null; @@ -885,7 +1173,7 @@ class SnomedProvider extends BaseCSServices { } async isInactive(context) { - + const ctxt = await this.#ensureContext(context); if (!ctxt || ctxt.isComplex()) return false; @@ -900,7 +1188,7 @@ class SnomedProvider extends BaseCSServices { } async getStatus(context) { - + const ctxt = await this.#ensureContext(context); if (!ctxt || ctxt.isComplex()) return null; @@ -909,7 +1197,7 @@ class SnomedProvider extends BaseCSServices { } async designations(context, displays) { - + const ctxt = await this.#ensureContext(context); if (ctxt) { @@ -1022,7 +1310,7 @@ class SnomedProvider extends BaseCSServices { } async locateIsA(code, parent, disallowParent = false) { - + const childId = this.sct.stringToIdOrZero(code); const parentId = this.sct.stringToIdOrZero(parent); @@ -1053,7 +1341,7 @@ class SnomedProvider extends BaseCSServices { // Iterator methods async iterator(context) { - + if (!context) { // Iterate all active root concepts @@ -1161,7 +1449,7 @@ class SnomedProvider extends BaseCSServices { } } - // Filter support + // Filter support async doesFilter(prop, op, value) { if (prop === 'concept') { const id = this.sct.stringToIdOrZero(value); @@ -1207,11 +1495,11 @@ class SnomedProvider extends BaseCSServices { // eslint-disable-next-line no-unused-vars async getPrepContext(iterate) { - + return new SnomedPrep(); // Simple filter context } - async filter(filterContext, prop, op, value) { + async filter(filterContext, forIteration, prop, op, value) { if (prop === 'concept') { const id = this.sct.stringToIdOrZero(value); @@ -1265,7 +1553,7 @@ class SnomedProvider extends BaseCSServices { } if (prop === 'constraint' && op === '=') { - filterContext.filters.push(await this.sct.filterECL(value)); + filterContext.filters.push(await this.sct.filterECL(value, forIteration, this.opContext)); return null; } @@ -1502,7 +1790,7 @@ class SnomedProvider extends BaseCSServices { // Subsumption testing async subsumesTest(codeA, codeB) { - + try { const exprA = new SnomedExpressionParser(this.sct.concepts).parse(codeA); @@ -1571,7 +1859,7 @@ class SnomedProvider extends BaseCSServices { isDisplay(cd) { return cd.use.system === this.system() && - (cd.use.code === '900000000000013009' || cd.use.code === '900000000000003001'); + (cd.use.code === '900000000000013009' || cd.use.code === '900000000000003001'); } async getTranslations(map, coding, target, reverse) { @@ -1702,8 +1990,8 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider { } if (url.startsWith('http://snomed.info/sct?fhir_vs') || - url.startsWith(`http://snomed.info/sct/${this.edition}?fhir_vs`) || - url.startsWith(`http://snomed.info/sct/${this.edition}/version/${this.version}?fhir_vs`)) { + url.startsWith(`http://snomed.info/sct/${this.edition}?fhir_vs`) || + url.startsWith(`http://snomed.info/sct/${this.edition}/version/${this.version}?fhir_vs`)) { id = url.substring(qIdx); } else { return null; @@ -1848,9 +2136,12 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider { this.uses++; } - name() { - return `SCT ${getEditionCode(this._sharedData.edition)}`; + if (this.version().includes("xsct")) { + return "SNOMED CT Test Set"; + } else { + return `SCT ${getEditionCode(this._sharedData.edition)}`; + } } nameBase() { @@ -1858,7 +2149,13 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider { } id() { - const match = this.version().match(/^http:\/\/snomed\.info\/sct\/(\d+)(?:\/version\/(\d{8}))?$/); + let match = this.version().match(/^http:\/\/snomed\.info\/sct\/(\d+)(?:\/version\/(\d{8}))?$/); + if (!match) { + match = this.version().match(/^http:\/\/snomed\.info\/xsct\/(\d+)(?:\/version\/(\d{8}))?$/); + if (match) { + match = "x"+match; + } + } return match && match[1] && match[2] ? "SCT-"+match[1]+"-"+match[2] : null; } diff --git a/tx/cs/cs-ucum.js b/tx/cs/cs-ucum.js index 973cdb67..2f026035 100644 --- a/tx/cs/cs-ucum.js +++ b/tx/cs/cs-ucum.js @@ -256,7 +256,7 @@ class UcumCodeSystemProvider extends BaseCSServices { // filterContext.filters.push(ucumFilter); } - async filter(filterContext, prop, op, value) { + async filter(filterContext, forIteration, prop, op, value) { assert(filterContext && filterContext instanceof FilterExecutionContext, 'filterContext must be a FilterExecutionContext'); assert(prop != null && typeof prop === 'string', 'prop must be a non-null string'); assert(op != null && typeof op === 'string', 'op must be a non-null string'); From c4875373a1f033793a0e33e43230b25acfb9148f Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Mon, 20 Apr 2026 16:57:42 +1000 Subject: [PATCH 6/6] remove tests needing upgraded validator --- tests/tx/test-cases.test.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/tx/test-cases.test.js b/tests/tx/test-cases.test.js index d6f5e83b..b1855681 100644 --- a/tests/tx/test-cases.test.js +++ b/tests/tx/test-cases.test.js @@ -3301,13 +3301,6 @@ describe('fragment', () => { describe('big', () => { // Testing handling a big code system - it('big-echo-no-limitR5', async () => { - await runTest({"suite":"big","test":"big-echo-no-limit"}, "5.0"); - }); - - it('big-echo-no-limitR4', async () => { - await runTest({"suite":"big","test":"big-echo-no-limit"}, "4.0"); - }); it('big-echo-zero-fifty-limitR5', async () => { await runTest({"suite":"big","test":"big-echo-zero-fifty-limit"}, "5.0");