From 280e79ec4acacc4b0fde2c650f5c70e80ea65b04 Mon Sep 17 00:00:00 2001 From: Dylan Hall Date: Mon, 9 Nov 2026 11:28:45 -0500 Subject: [PATCH 1/3] Add support for CQL 2.0 new Retrieve.codeComparators --- src/elm/external.ts | 127 +++++++++-- src/types/cql-patient.interfaces.ts | 7 +- test/elm/external/external-test.ts | 340 +++++++++++++++++++++++++++- 3 files changed, 447 insertions(+), 27 deletions(-) diff --git a/src/elm/external.ts b/src/elm/external.ts index 2644d2225..95635a298 100644 --- a/src/elm/external.ts +++ b/src/elm/external.ts @@ -1,19 +1,20 @@ import { Expression } from './expression'; import { resolveValueSet, typeIsArray } from '../util/util'; -import { equals } from '../util/comparison'; +import { equals, equivalent } from '../util/comparison'; import { Context } from '../runtime/context'; import { build } from './builder'; import { RetrieveDetails } from '../types/cql-patient.interfaces'; -import { Code, CodeSystem, CQLValueSet, ValueSet } from '../datatypes/clinical'; +import { Code, CodeSystem, Concept, CQLValueSet, ValueSet } from '../datatypes/clinical'; export class Retrieve extends Expression { datatype: string; templateId?: string; codeProperty?: string; - codeComparator?: 'in' | '=' | '~'; + codeComparator?: 'in' | '~in' | 'contains' | '~contains' | '=' | '~'; codes?: Expression | null; dateProperty?: string; dateRange?: Expression | null; + valueSetProperty?: string; constructor(json: any) { super(json); @@ -24,6 +25,7 @@ export class Retrieve extends Expression { this.codes = build(json.codes) as Expression; this.dateProperty = json.dateProperty; this.dateRange = build(json.dateRange) as Expression; + this.valueSetProperty = json.valueSetProperty; } async exec(ctx: Context) { @@ -33,11 +35,12 @@ export class Retrieve extends Expression { datatype: this.datatype, ...(this.codeProperty ? { codeProperty: this.codeProperty } : {}), ...(this.codeComparator ? { codeComparator: this.codeComparator } : {}), - ...(this.dateProperty ? { dateProperty: this.dateProperty } : {}) + ...(this.dateProperty ? { dateProperty: this.dateProperty } : {}), + ...(this.valueSetProperty ? { valueSetProperty: this.valueSetProperty } : {}) }; if (this.codes) { - const executedCodes: Code[] | CQLValueSet | undefined = await this.codes.execute(ctx); + const executedCodes = await this.codes.execute(ctx); if (executedCodes == null) { return []; @@ -45,9 +48,14 @@ export class Retrieve extends Expression { if (typeIsArray(executedCodes)) { retrieveDetails.codes = executedCodes as Code[]; - } else { + } else if (executedCodes instanceof CQLValueSet) { // retrieveDetails codes are expected to be expanded for external usage - retrieveDetails.codes = await resolveValueSet(executedCodes as CQLValueSet, ctx); + retrieveDetails.codes = await resolveValueSet(executedCodes, ctx); + } else if (executedCodes instanceof Code || executedCodes instanceof Concept) { + retrieveDetails.codes = + executedCodes instanceof Concept ? executedCodes.codes : [executedCodes]; + } else { + retrieveDetails.codes = executedCodes; } } @@ -65,7 +73,10 @@ export class Retrieve extends Expression { ); if (retrieveDetails.codes) { - records = records.filter((r: any) => this.recordMatchesCodesOrVS(r, retrieveDetails.codes)); + const matches = await Promise.all( + records.map((r: any) => this.recordMatchesCodesOrVS(r, retrieveDetails.codes, ctx)) + ); + records = records.filter((_r: any, i: number) => matches[i]); } if (retrieveDetails.dateRange && this.dateProperty) { @@ -82,42 +93,112 @@ export class Retrieve extends Expression { return records; } - recordMatchesCodesOrVS(record: any, codes: any) { + async recordMatchesCodesOrVS(record: any, codesToMatch: any, ctx: Context) { let recordCodeValue = record.getCode(this.codeProperty); - if (Array.isArray(recordCodeValue)) { - if (recordCodeValue.length == 0) { - return false; - } - } else if (!recordCodeValue) { - return false; - } else { + if (recordCodeValue == null) { + recordCodeValue = []; + } else if (!Array.isArray(recordCodeValue)) { recordCodeValue = [recordCodeValue]; } + if (recordCodeValue.length > 0) { + return this.codeMatchesCodesOrVS(recordCodeValue, codesToMatch); + } + + if (this.valueSetProperty) { + return await this.valueSetMatchesCodesOrVS(record, codesToMatch, ctx); + } + + return false; + } + + codeMatchesCodesOrVS(recordCodeValue: Array, codesToMatch: any): boolean { + const equivalence = this.codeComparator?.startsWith('~') ?? false; switch (this.codeComparator) { case 'in': - return this.in(recordCodeValue, codes); + case '~in': + return this.in(recordCodeValue, codesToMatch, equivalence); + + case 'contains': + case '~contains': + // "The equivalent contains operator (~contains) was introduced as the formal converse of the equivalent in operator (~in) + // (i.e. A ~in B will always result in the same value as B ~contains A)." + // Note this refers to the standalone operators, not specifically the context of a Retrieve, but we'll maintain that here as well + return this.in(codesToMatch, recordCodeValue, equivalence); case '~': - return this.equivalent(recordCodeValue, codes); + return this.equivalent(recordCodeValue, codesToMatch); case '=': - return this.equal(recordCodeValue, codes); + return this.equal(recordCodeValue, codesToMatch); default: // if there's a terminology filter, there should always be a comparator, // but just in case, default to "in" - return this.in(recordCodeValue, codes); + return this.in(recordCodeValue, codesToMatch, true); + } + } + + async valueSetMatchesCodesOrVS(record: any, codesToMatch: any, ctx: Context): Promise { + // "The valueSetProperty attribute optionally specifies which property of the model contains a value set identifier + // that can be used as an alternative mechanism for matching the value set of the retrieve, in the case when no code is specified in the source data." + // Consider for example a QICore negation, https://hl7.org/fhir/us/qicore/STU7.0.2/negation.html#extent-of-negation + // Negation of a single code in a MedicationAdministrationNotDone is medicationCodeableConcept.coding + // Negation of a whole class in a MedicationAdministrationNotDone is medicationCodeableConcept.extension:codeOptions, whose value is a canonical(ValueSet) + const recordVSIdentifier = record.get(this.valueSetProperty); + if (!recordVSIdentifier) { + return false; + } + + const recordVSInstance = await this.getValueSet(recordVSIdentifier, ctx); + switch (this.codeComparator) { + case 'contains': + case '~contains': + // "The equivalent contains operator (~contains) was introduced as the formal converse of the equivalent in operator (~in) + // (i.e. A ~in B will always result in the same value as B ~contains A)." + // Note that "contains" here isn't well-defined since there's no `ValueSet contains` standalone operator to base it off. + return this.in(codesToMatch, recordVSInstance, this.codeComparator.startsWith('~')); + + default: + // TODO. Other operators don't necessarily make sense here. Throw an error for illegal combinations? + + // It's not obvious which operator/operand combinations should be allowed, + // but if the RHS is a ValueSet, we can at least try to match it by identifier (not by content) + return codesToMatch instanceof ValueSet && recordVSIdentifier === codesToMatch.oid; + } + } + + async getValueSet(identifier: string, ctx: Context) { + let recordVSInstance = ctx.getValueSet(identifier, null); + if (!recordVSInstance) { + if (!ctx.codeService) { + // TODO: throw error? + return null; + } + + recordVSInstance = await ctx.codeService.findValueSet(identifier); + if (!recordVSInstance) { + // TODO: throw error? + return null; + } } + return recordVSInstance; } - private in(lhs: Code[], rhs: any) { + private in(lhs: Code[], rhs: any, equivalence: boolean = true) { if (rhs instanceof ValueSet || rhs instanceof CodeSystem) { + // Note that hasMatch always uses equivalence semantics: + // CQL 1.5 `in ValueSet` was defined with equivalence semantics, and preserved for backwards compatibility. + // CQL 2.0 introduced `~in` and `~contains` which makes it more clear it uses equivalence semantics. + // There is no equality-based terminology membership defined anywhere. return rhs.hasMatch(lhs); - } else { - // For code lists, fallback to the implementation in ~ . See comments below. + } + // For code lists, fallback to the implementation in ~/= . See comments below. + if (equivalence) { return this.equivalent(lhs, rhs); + } else { + return this.equal(lhs, rhs); } } diff --git a/src/types/cql-patient.interfaces.ts b/src/types/cql-patient.interfaces.ts index 75cb6281e..d6e3bb8b5 100644 --- a/src/types/cql-patient.interfaces.ts +++ b/src/types/cql-patient.interfaces.ts @@ -1,4 +1,4 @@ -import { Code, ValueSet } from '../datatypes/clinical'; +import { Code, CodeSystem, ValueSet } from '../datatypes/clinical'; import { Interval } from '../datatypes/interval'; import { AnyTypeSpecifier } from './type-specifiers.interfaces'; @@ -31,8 +31,9 @@ export interface RetrieveDetails { datatype: string; templateId?: string; codeProperty?: string; - codeComparator?: 'in' | '=' | '~'; - codes?: Code[] | ValueSet; + codeComparator?: 'in' | '~in' | 'contains' | '~contains' | '=' | '~'; + codes?: Code[] | ValueSet | CodeSystem; + valueSetProperty?: string; dateProperty?: string; dateRange?: Interval; } diff --git a/test/elm/external/external-test.ts b/test/elm/external/external-test.ts index c71da6d21..b040c0833 100644 --- a/test/elm/external/external-test.ts +++ b/test/elm/external/external-test.ts @@ -3,11 +3,349 @@ import 'should-sinon'; import sinon from 'sinon'; import { Code, Interval, Repository, RetrieveDetails } from '../../../src/cql'; import setup from '../../setup'; -import { ValueSet } from '../../../src/datatypes/clinical'; +import { CodeSystem, Concept, CQLValueSet, ValueSet } from '../../../src/datatypes/clinical'; +import { Retrieve } from '../../../src/elm/external'; +import { Expression } from '../../../src/elm/expression'; +import { Context } from '../../../src/runtime/context'; const data = require('./data'); const vsets = require('./valuesets'); const { p1 } = require('./patients'); +describe('CQL 2.0 Retrieve comparators', () => { + const code = new Code('123', 'system', '1', 'Original'); + const changedDisplay = new Code('123', 'system', '2', 'Changed'); + const otherCode = new Code('other', 'system'); + + // TODO: Replace these hand-built Retrieves with translator-generated ELM when the + // translator emits ~in, contains, ~contains, and valueSetProperty for retrieves. + function fixture(comparator: string, sourceCode: any, target: any, valueSetId?: string) { + const retrieve = new Retrieve({ + dataType: 'Condition', + codeProperty: 'code', + codeComparator: comparator, + ...(valueSetId !== undefined ? { valueSetProperty: 'valueSet' } : {}) + }); + retrieve.codes = new Expression({}); + sinon.stub(retrieve.codes, 'execute').resolves(target); + const record = { + get: (property: string) => (property === 'valueSet' ? valueSetId : undefined), + getCode: () => sourceCode + }; + const findValueSet = sinon + .stub() + .callsFake(async (id: string) => + id === 'vs' ? new ValueSet('vs', undefined, [code]) : null + ); + const findRecords = sinon.stub().resolves([record]); + const getValueSet = sinon.stub().returns(undefined); + const ctx = { + findRecords, + getValueSet, + codeService: { findValueSet }, + evaluatedRecords: [] + } as unknown as Context; + return { retrieve, record, ctx, findValueSet, findRecords, getValueSet }; + } + + describe('codeProperty comparisons', () => { + describe('=', () => { + it('does not find a record when code version and display differ', async () => { + const { retrieve, ctx } = fixture('=', [changedDisplay], [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('finds a record when the code is identical', async () => { + const { retrieve, ctx } = fixture('=', code, [code]); + should(await retrieve.exec(ctx)).have.length(1); + }); + + it('does not find a record when the code differs', async () => { + const { retrieve, ctx } = fixture('=', [otherCode], [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('does not find a record when codeProperty is null', async () => { + const { retrieve, ctx } = fixture('=', null, [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('does not find a record when codeProperty is empty', async () => { + const { retrieve, ctx } = fixture('=', [], [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + }); + + describe('~', () => { + it('finds a record when code and system agree despite different version and display', async () => { + const { retrieve, ctx } = fixture('~', [changedDisplay], [code]); + should(await retrieve.exec(ctx)).have.length(1); + }); + + it('finds a record when the code is identical', async () => { + const { retrieve, ctx } = fixture('~', code, [code]); + should(await retrieve.exec(ctx)).have.length(1); + }); + + it('does not find a record when the code differs', async () => { + const { retrieve, ctx } = fixture('~', [otherCode], [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('does not find a record when codeProperty is null', async () => { + const { retrieve, ctx } = fixture('~', null, [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('does not find a record when codeProperty is empty', async () => { + const { retrieve, ctx } = fixture('~', [], [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + }); + + describe('in', () => { + it('does not find a record when code version and display differ', async () => { + const { retrieve, ctx } = fixture('in', [changedDisplay], [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('finds a record when the code is identical', async () => { + const { retrieve, ctx } = fixture('in', code, [code]); + should(await retrieve.exec(ctx)).have.length(1); + }); + + it('does not find a record when the code differs', async () => { + const { retrieve, ctx } = fixture('in', [otherCode], [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('does not find a record when codeProperty is null', async () => { + const { retrieve, ctx } = fixture('in', null, [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('does not find a record when codeProperty is empty', async () => { + const { retrieve, ctx } = fixture('in', [], [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + }); + + describe('~in', () => { + it('finds a record when code and system agree despite different version and display', async () => { + const { retrieve, ctx } = fixture('~in', [changedDisplay], [code]); + should(await retrieve.exec(ctx)).have.length(1); + }); + + it('finds a record when the code is identical', async () => { + const { retrieve, ctx } = fixture('~in', code, [code]); + should(await retrieve.exec(ctx)).have.length(1); + }); + + it('does not find a record when the code differs', async () => { + const { retrieve, ctx } = fixture('~in', [otherCode], [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('does not find a record when codeProperty is null', async () => { + const { retrieve, ctx } = fixture('~in', null, [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('does not find a record when codeProperty is empty', async () => { + const { retrieve, ctx } = fixture('~in', [], [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + }); + + describe('contains', () => { + it('does not find a record when code version and display differ', async () => { + const { retrieve, ctx } = fixture('contains', [changedDisplay], [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('finds a record when the code is identical', async () => { + const { retrieve, ctx } = fixture('contains', code, [code]); + should(await retrieve.exec(ctx)).have.length(1); + }); + + it('does not find a record when the code differs', async () => { + const { retrieve, ctx } = fixture('contains', [otherCode], [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('does not find a record when codeProperty is null', async () => { + const { retrieve, ctx } = fixture('contains', null, [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('does not find a record when codeProperty is empty', async () => { + const { retrieve, ctx } = fixture('contains', [], [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + }); + + describe('~contains', () => { + it('finds a record when code and system agree despite different version and display', async () => { + const { retrieve, ctx } = fixture('~contains', [changedDisplay], [code]); + should(await retrieve.exec(ctx)).have.length(1); + }); + + it('finds a record when the code is identical', async () => { + const { retrieve, ctx } = fixture('~contains', code, [code]); + should(await retrieve.exec(ctx)).have.length(1); + }); + + it('does not find a record when the code differs', async () => { + const { retrieve, ctx } = fixture('~contains', [otherCode], [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('does not find a record when codeProperty is null', async () => { + const { retrieve, ctx } = fixture('~contains', null, [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('does not find a record when codeProperty is empty', async () => { + const { retrieve, ctx } = fixture('~contains', [], [code]); + should(await retrieve.exec(ctx)).be.empty(); + }); + }); + + it('in finds a record when its code is equivalent to a code in the requested ValueSet', async () => { + const { retrieve, ctx, findValueSet } = fixture( + 'in', + [changedDisplay], + new CQLValueSet('vs') + ); + should(await retrieve.exec(ctx)).have.length(1); + sinon.assert.calledOnceWithExactly(findValueSet, 'vs', undefined); + }); + + it('in does not find a record when its code is outside the requested ValueSet', async () => { + const { retrieve, ctx } = fixture('in', [otherCode], new CQLValueSet('vs')); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('in reports the existing CodeSystem membership limitation', async () => { + const { retrieve, ctx } = fixture('in', [code], new CodeSystem('system')); + await retrieve.exec(ctx).should.be.rejectedWith('In CodeSystem operation not yet supported.'); + }); + + it('~in finds a record when its code is equivalent to a code in the requested ValueSet', async () => { + const { retrieve, ctx, findValueSet } = fixture( + '~in', + [changedDisplay], + new CQLValueSet('vs') + ); + should(await retrieve.exec(ctx)).have.length(1); + sinon.assert.calledOnceWithExactly(findValueSet, 'vs', undefined); + }); + + it('~in does not find a record when its code is outside the requested ValueSet', async () => { + const { retrieve, ctx } = fixture('~in', [otherCode], new CQLValueSet('vs')); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('~in reports the existing CodeSystem membership limitation', async () => { + const { retrieve, ctx } = fixture('~in', [code], new CodeSystem('system')); + await retrieve.exec(ctx).should.be.rejectedWith('In CodeSystem operation not yet supported.'); + }); + + it('does not query the provider or find records when Retrieve.codes is null', async () => { + const { retrieve, ctx, findRecords } = fixture('~in', [code], null); + should(await retrieve.exec(ctx)).be.empty(); + sinon.assert.notCalled(findRecords); + }); + }); + + describe('valueSetProperty fallback', () => { + // TODO: Use a translator-generated Retrieve once primaryValueSetPath produces + // valueSetProperty and ~contains can target a direct-reference code. + it('~contains finds a record when the source ValueSet contains the requested Code', async () => { + const { retrieve, ctx, findValueSet, findRecords } = fixture('~contains', null, code, 'vs'); + const result = await retrieve.exec(ctx); + should(result).have.length(1); + should(ctx.evaluatedRecords).deepEqual(result); + should(findRecords.firstCall.lastArg.valueSetProperty).equal('valueSet'); + sinon.assert.calledOnceWithExactly(findValueSet, 'vs'); + }); + + it('~contains finds a record when the source ValueSet contains a code from the requested Concept', async () => { + const { retrieve, ctx, findValueSet, findRecords } = fixture( + '~contains', + null, + new Concept([code]), + 'vs' + ); + const result = await retrieve.exec(ctx); + should(result).have.length(1); + should(ctx.evaluatedRecords).deepEqual(result); + should(findRecords.firstCall.lastArg.valueSetProperty).equal('valueSet'); + sinon.assert.calledOnceWithExactly(findValueSet, 'vs'); + }); + + it('~contains finds a record when the source ValueSet contains a code from the requested list', async () => { + const { retrieve, ctx, findValueSet, findRecords } = fixture('~contains', null, [code], 'vs'); + const result = await retrieve.exec(ctx); + should(result).have.length(1); + should(ctx.evaluatedRecords).deepEqual(result); + should(findRecords.firstCall.lastArg.valueSetProperty).equal('valueSet'); + sinon.assert.calledOnceWithExactly(findValueSet, 'vs'); + }); + + it('~contains finds a record when the source ValueSet contains an equivalent Code', async () => { + const { retrieve, ctx } = fixture('~contains', null, changedDisplay, 'vs'); + should(await retrieve.exec(ctx)).have.length(1); + }); + + it('~contains does not find a record when the requested Code is outside the source ValueSet', async () => { + const { retrieve, ctx } = fixture('~contains', null, otherCode, 'vs'); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('does not find a record by source ValueSet when a nonmatching source code is present', async () => { + const { retrieve, ctx, findValueSet } = fixture('~contains', [otherCode], code, 'vs'); + should(await retrieve.exec(ctx)).be.empty(); + sinon.assert.notCalled(findValueSet); + }); + + it('does not find a record when the source ValueSet identifier is missing', async () => { + const { retrieve, ctx, findValueSet } = fixture('~contains', null, code, ''); + should(await retrieve.exec(ctx)).be.empty(); + sinon.assert.notCalled(findValueSet); + }); + + it('finds a record when source and requested ValueSet identifiers are the same for an in retrieve', async () => { + const { retrieve, ctx, findRecords } = fixture('in', null, new CQLValueSet('vs'), 'vs'); + should(await retrieve.exec(ctx)).have.length(1); + should(findRecords.firstCall.lastArg.valueSetProperty).equal('valueSet'); + }); + + it('does not find a record when source and requested ValueSet identifiers differ for an in retrieve', async () => { + const { retrieve, ctx } = fixture('in', null, new CQLValueSet('vs'), 'other-vs'); + should(await retrieve.exec(ctx)).be.empty(); + }); + + it('preserves record order after asynchronous source ValueSet resolution', async () => { + const { retrieve, record, ctx, findRecords, findValueSet } = fixture( + '~contains', + null, + code, + 'vs' + ); + const nonmatch = { get: () => 'other-vs', getCode: (): null => null }; + const last = { get: () => 'vs', getCode: (): null => null }; + findRecords.resolves([record, nonmatch, last]); + findValueSet.callsFake(async (id: string) => { + await new Promise(resolve => setTimeout(resolve, id === 'vs' ? 5 : 1)); + return new ValueSet(id, undefined, id === 'vs' ? [code] : [otherCode]); + }); + const result = await retrieve.exec(ctx); + should(result).deepEqual([record, last]); + should(ctx.evaluatedRecords).deepEqual(result); + }); + }); +}); + describe('Retrieve', () => { let findRecordsSpy: sinon.SinonSpy; beforeEach(function () { From 3c7f1636f57d5f2c87cb9ea801af5add58c76549 Mon Sep 17 00:00:00 2001 From: Dylan Hall Date: Mon, 9 Nov 2026 11:42:23 -0500 Subject: [PATCH 2/3] cleanup --- src/elm/external.ts | 58 ++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/elm/external.ts b/src/elm/external.ts index 95635a298..6f221a614 100644 --- a/src/elm/external.ts +++ b/src/elm/external.ts @@ -1,6 +1,6 @@ import { Expression } from './expression'; import { resolveValueSet, typeIsArray } from '../util/util'; -import { equals, equivalent } from '../util/comparison'; +import { equals } from '../util/comparison'; import { Context } from '../runtime/context'; import { build } from './builder'; import { RetrieveDetails } from '../types/cql-patient.interfaces'; @@ -109,7 +109,7 @@ export class Retrieve extends Expression { if (this.valueSetProperty) { return await this.valueSetMatchesCodesOrVS(record, codesToMatch, ctx); } - + return false; } @@ -122,7 +122,7 @@ export class Retrieve extends Expression { case 'contains': case '~contains': - // "The equivalent contains operator (~contains) was introduced as the formal converse of the equivalent in operator (~in) + // "The equivalent contains operator (~contains) was introduced as the formal converse of the equivalent in operator (~in) // (i.e. A ~in B will always result in the same value as B ~contains A)." // Note this refers to the standalone operators, not specifically the context of a Retrieve, but we'll maintain that here as well return this.in(codesToMatch, recordCodeValue, equivalence); @@ -141,32 +141,32 @@ export class Retrieve extends Expression { } async valueSetMatchesCodesOrVS(record: any, codesToMatch: any, ctx: Context): Promise { - // "The valueSetProperty attribute optionally specifies which property of the model contains a value set identifier - // that can be used as an alternative mechanism for matching the value set of the retrieve, in the case when no code is specified in the source data." - // Consider for example a QICore negation, https://hl7.org/fhir/us/qicore/STU7.0.2/negation.html#extent-of-negation - // Negation of a single code in a MedicationAdministrationNotDone is medicationCodeableConcept.coding - // Negation of a whole class in a MedicationAdministrationNotDone is medicationCodeableConcept.extension:codeOptions, whose value is a canonical(ValueSet) - const recordVSIdentifier = record.get(this.valueSetProperty); - if (!recordVSIdentifier) { - return false; - } + // "The valueSetProperty attribute optionally specifies which property of the model contains a value set identifier + // that can be used as an alternative mechanism for matching the value set of the retrieve, in the case when no code is specified in the source data." + // Consider for example a QICore negation, https://hl7.org/fhir/us/qicore/STU7.0.2/negation.html#extent-of-negation + // Negation of a single code in a MedicationAdministrationNotDone is medicationCodeableConcept.coding + // Negation of a whole class in a MedicationAdministrationNotDone is medicationCodeableConcept.extension:codeOptions, whose value is a canonical(ValueSet) + const recordVSIdentifier = record.get(this.valueSetProperty); + if (!recordVSIdentifier) { + return false; + } - const recordVSInstance = await this.getValueSet(recordVSIdentifier, ctx); - switch (this.codeComparator) { - case 'contains': - case '~contains': - // "The equivalent contains operator (~contains) was introduced as the formal converse of the equivalent in operator (~in) - // (i.e. A ~in B will always result in the same value as B ~contains A)." - // Note that "contains" here isn't well-defined since there's no `ValueSet contains` standalone operator to base it off. - return this.in(codesToMatch, recordVSInstance, this.codeComparator.startsWith('~')); - - default: - // TODO. Other operators don't necessarily make sense here. Throw an error for illegal combinations? - - // It's not obvious which operator/operand combinations should be allowed, - // but if the RHS is a ValueSet, we can at least try to match it by identifier (not by content) - return codesToMatch instanceof ValueSet && recordVSIdentifier === codesToMatch.oid; - } + const recordVSInstance = await this.getValueSet(recordVSIdentifier, ctx); + switch (this.codeComparator) { + case 'contains': + case '~contains': + // "The equivalent contains operator (~contains) was introduced as the formal converse of the equivalent in operator (~in) + // (i.e. A ~in B will always result in the same value as B ~contains A)." + // Note that "contains" here isn't well-defined since there's no `ValueSet contains` standalone operator to base it off. + return this.in(codesToMatch, recordVSInstance, this.codeComparator.startsWith('~')); + + default: + // TODO. Other operators don't necessarily make sense here. Throw an error for illegal combinations? + + // It's not obvious which operator/operand combinations should be allowed, + // but if the RHS is a ValueSet, we can at least try to match it by identifier (not by content) + return codesToMatch instanceof ValueSet && recordVSIdentifier === codesToMatch.oid; + } } async getValueSet(identifier: string, ctx: Context) { @@ -193,7 +193,7 @@ export class Retrieve extends Expression { // CQL 2.0 introduced `~in` and `~contains` which makes it more clear it uses equivalence semantics. // There is no equality-based terminology membership defined anywhere. return rhs.hasMatch(lhs); - } + } // For code lists, fallback to the implementation in ~/= . See comments below. if (equivalence) { return this.equivalent(lhs, rhs); From c75b7cedd154405d9420cdbac516971b430a91ab Mon Sep 17 00:00:00 2001 From: Dylan Hall Date: Mon, 9 Nov 2026 12:12:07 -0500 Subject: [PATCH 3/3] more cleanup --- src/elm/external.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/elm/external.ts b/src/elm/external.ts index 6f221a614..38478d249 100644 --- a/src/elm/external.ts +++ b/src/elm/external.ts @@ -4,7 +4,7 @@ import { equals } from '../util/comparison'; import { Context } from '../runtime/context'; import { build } from './builder'; import { RetrieveDetails } from '../types/cql-patient.interfaces'; -import { Code, CodeSystem, Concept, CQLValueSet, ValueSet } from '../datatypes/clinical'; +import { Code, CodeSystem, CQLValueSet, ValueSet } from '../datatypes/clinical'; export class Retrieve extends Expression { datatype: string; @@ -51,9 +51,6 @@ export class Retrieve extends Expression { } else if (executedCodes instanceof CQLValueSet) { // retrieveDetails codes are expected to be expanded for external usage retrieveDetails.codes = await resolveValueSet(executedCodes, ctx); - } else if (executedCodes instanceof Code || executedCodes instanceof Concept) { - retrieveDetails.codes = - executedCodes instanceof Concept ? executedCodes.codes : [executedCodes]; } else { retrieveDetails.codes = executedCodes; }