diff --git a/src/extractors/value-type-extractors/datetime-interval-extractor.ts b/src/extractors/value-type-extractors/datetime-interval-extractor.ts index 83a1976..df7d643 100644 --- a/src/extractors/value-type-extractors/datetime-interval-extractor.ts +++ b/src/extractors/value-type-extractors/datetime-interval-extractor.ts @@ -37,25 +37,37 @@ const BOUNDARY_FORMATS = { export class DateTimeIntervalExtractor extends BaseExtractor { protected _process(parameter: any): any { - if (parameter.hasOwnProperty('valuePeriod')) { - const format = BOUNDARY_FORMATS[declaredPointType(parameter) ?? 'DateTime']; - // Period boundaries are FHIR dateTimes, so a boundary coarser than seconds arrives - // zero-padded with its real precision on the `_start`/`_end` companion element. - const period = parameter.valuePeriod; - const low = period.hasOwnProperty('start') - ? format(applyDeclaredTimePrecision(period.start, period._start)) - : null; - const high = period.hasOwnProperty('end') - ? format(applyDeclaredTimePrecision(period.end, period._end)) - : null; - return { - lowClosed: low !== null, - low: low, - highClosed: true, - high: high, - }; + if (!parameter.hasOwnProperty('valuePeriod')) { + return undefined; } - return undefined; + const period = parameter.valuePeriod; + if (period === null || typeof period !== 'object') { + return undefined; + } + + // With no cqf-cqlType the point type cannot be recovered: a Period boundary of + // `2012-01-01T00:00:00-07:00` is indistinguishable from a DateTime interval that starts at + // midnight. The IG requires the extension on every CQL-valued result and says a value + // without it is the FHIR type, so an undeclared Period is read as Interval. + const format = BOUNDARY_FORMATS[declaredPointType(parameter) ?? 'DateTime']; + + // Period boundaries are FHIR dateTimes, so a boundary coarser than seconds arrives + // zero-padded with its real precision on the `_start`/`_end` companion element. + const low = period.hasOwnProperty('start') + ? format(applyDeclaredTimePrecision(period.start, period._start)) + : null; + const high = period.hasOwnProperty('end') + ? format(applyDeclaredTimePrecision(period.end, period._end)) + : null; + + return { + lowClosed: low !== null, + low: low, + // An absent boundary is unbounded, so it is not closed. Matching + // NumericIntervalExtractor keeps `Interval[x, null)` comparable either way it arrives. + highClosed: high !== null, + high: high, + }; } } diff --git a/src/extractors/value-type-extractors/quantity-interval-extractor.ts b/src/extractors/value-type-extractors/quantity-interval-extractor.ts index 56367ab..6d45dcc 100644 --- a/src/extractors/value-type-extractors/quantity-interval-extractor.ts +++ b/src/extractors/value-type-extractors/quantity-interval-extractor.ts @@ -1,35 +1,50 @@ import { BaseExtractor } from '../base-extractor.js'; +/** + * A Range boundary as the CQL Quantity shape CVL produces: `{value, unit}`. + * + * The unit is taken from `Quantity.code` — the coded, comparable form — falling back to + * `Quantity.unit` when a boundary carries only the human-readable unit, so a dimensioned + * boundary is not silently reduced to a unitless one. A boundary that is absent, not an object, + * or carries no `value` is treated as no boundary at all rather than throwing. + */ +function boundaryQuantity(value: any): { value: any; unit: any } | null { + if (value === null || typeof value !== 'object' || !value.hasOwnProperty('value')) { + return null; + } + const unit = value.hasOwnProperty('code') + ? value.code + : value.hasOwnProperty('unit') + ? value.unit + : null; + return { value: value.value, unit: unit }; +} + +/** + * Extracts a `valueRange` as an `Interval`. This is the fallback for any Range that + * NumericIntervalExtractor did not claim: per the IG a result carrying no `cqf-cqlType` is read as + * the FHIR type, and the CQL type mapped to `FHIR.Range` with quantity boundaries is + * `Interval`. + */ export class QuantityIntervalExtractor extends BaseExtractor { protected _process(parameter: any): any { - function getQuantity(value: any): any { - if (value.hasOwnProperty('value')) { - return { - value: value.value, - unit: value.hasOwnProperty('code') ? value.code : null, - }; - } - - return null; + if (!parameter.hasOwnProperty('valueRange')) { + return undefined; } - if (parameter.hasOwnProperty('valueRange')) { - const low = parameter.valueRange.hasOwnProperty('low') - ? getQuantity(parameter.valueRange.low) - : null; - - const high = parameter.valueRange.hasOwnProperty('high') - ? getQuantity(parameter.valueRange.high) - : null; - - return { - lowClosed: low !== null, - low: low, - highClosed: high !== null, - high: high, - }; + const range = parameter.valueRange; + if (range === null || typeof range !== 'object') { + return undefined; } - return undefined; + const low = range.hasOwnProperty('low') ? boundaryQuantity(range.low) : null; + const high = range.hasOwnProperty('high') ? boundaryQuantity(range.high) : null; + + return { + lowClosed: low !== null, + low: low, + highClosed: high !== null, + high: high, + }; } } diff --git a/test/interval-fhir-types.test.ts b/test/interval-fhir-types.test.ts new file mode 100644 index 0000000..8da7fa5 --- /dev/null +++ b/test/interval-fhir-types.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { buildExtractor } from '../src/server/extractor-builder.js'; +import { resultsEqual } from '../src/shared/results-utils.js'; +import { ValueMap } from '../src/extractors/value-map.js'; + +/** + * Covers the FHIR types the "Using CQL with FHIR" IG maps intervals onto (Conformance + * Requirement 4.3): Interval as FHIR.Range and + * Interval as FHIR.Period. + */ +const CQL_TYPE_URL = 'http://hl7.org/fhir/StructureDefinition/cqf-cqlType'; + +let cvl: any; +let extractor: ReturnType; + +beforeAll(async () => { + // @ts-expect-error - cvl.mjs has no declaration file + cvl = (await import('../cvl/cvl.mjs')).default; + extractor = buildExtractor(); +}); + +function parameters(parameter: any) { + return { resourceType: 'Parameters', parameter: [parameter] }; +} + +/** Extracts a single-parameter response the way runTest does, given the expected CQL literal. */ +function extractFor(parameter: any, expectedLiteral: string) { + const expected = cvl.parse(expectedLiteral); + const actual = extractor.extract(parameters(parameter), { + singletonListKeys: ValueMap.singletonListKeysFromExpected(expected), + }); + return { expected, actual }; +} + +function cqlType(type: string) { + return { extension: [{ url: CQL_TYPE_URL, valueString: type }] }; +} + +describe('Interval as FHIR.Range', () => { + it('matches an Integer interval declared by cqf-cqlType', () => { + const { expected, actual } = extractFor( + { + name: 'return', + ...cqlType('Interval'), + valueRange: { low: { value: 1 }, high: { value: 10 } }, + }, + 'Interval[1, 10]' + ); + expect(resultsEqual(expected, actual)).toBe(true); + }); + + it('matches a Decimal interval declared by cqf-cqlType', () => { + const { expected, actual } = extractFor( + { + name: 'return', + ...cqlType('Interval'), + valueRange: { low: { value: 1.0 }, high: { value: 10.0 } }, + }, + 'Interval[1.0, 10.0]' + ); + expect(resultsEqual(expected, actual)).toBe(true); + }); + + it('yields plain numbers, not quantities, for a numeric interval', () => { + const { actual } = extractFor( + { + name: 'return', + ...cqlType('Interval'), + valueRange: { low: { value: 1 }, high: { value: 10 } }, + }, + 'Interval[1, 10]' + ); + expect(actual).toEqual({ lowClosed: true, low: 1, highClosed: true, high: 10 }); + }); +}); + +describe('Interval as FHIR.Range', () => { + it('matches a dimensioned quantity interval', () => { + const { expected, actual } = extractFor( + { + name: 'return', + valueRange: { + low: { value: 1, unit: 'mg', system: 'http://unitsofmeasure.org', code: 'mg' }, + high: { value: 10, unit: 'mg', system: 'http://unitsofmeasure.org', code: 'mg' }, + }, + }, + "Interval[1 'mg', 10 'mg']" + ); + expect(resultsEqual(expected, actual)).toBe(true); + }); + + it('falls back to Quantity.unit when a boundary carries no code', () => { + // Otherwise a dimensioned boundary is silently reduced to a unitless one. + const { expected, actual } = extractFor( + { + name: 'return', + valueRange: { low: { value: 1, unit: 'mg' }, high: { value: 10, unit: 'mg' } }, + }, + "Interval[1 'mg', 10 'mg']" + ); + expect(resultsEqual(expected, actual)).toBe(true); + }); +}); + +describe('Interval as FHIR.Period', () => { + it('matches a Date interval declared by cqf-cqlType', () => { + const { expected, actual } = extractFor( + { + name: 'return', + ...cqlType('Interval'), + valuePeriod: { start: '2012-01-01T00:00:00-07:00', end: '2012-12-31T00:00:00-07:00' }, + }, + 'Interval[@2012-01-01, @2012-12-31]' + ); + expect(resultsEqual(expected, actual)).toBe(true); + }); + + it('matches a Time interval anchored to the placeholder date', () => { + // The IG maps Interval