From 992533e309378f1cea6f42a00dc6f96a267edae8 Mon Sep 17 00:00:00 2001 From: Bryant Austin Date: Fri, 28 Aug 2026 12:29:12 -0600 Subject: [PATCH] Correct interval extraction from FHIR Range and Period MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extractors that map FHIR.Range and FHIR.Period back to CQL intervals — the representation the "Using CQL with FHIR" IG specifies for Interval in Conformance Requirement 4.3 — had four defects: - DateTimeIntervalExtractor hardcoded `highClosed: true`, so a Period with no `end` extracted as `{highClosed: true, high: null}`. An absent boundary is unbounded and therefore open; NumericIntervalExtractor already treated it that way, so the two disagreed on the same shape. - A null `valuePeriod`, a null `valueRange`, or a null Range boundary each threw a TypeError out of the extractor, which turns a test into an error rather than a comparison. - A Range boundary carrying `unit` but no `code` lost its unit, silently reducing a dimensioned boundary to a unitless one. Adds tests covering all seven interval point types the IG maps (Integer, Long, Decimal and Quantity onto Range; Date, DateTime and Time onto Period) plus the boundary and null cases above. One behaviour is pinned deliberately rather than changed: a Period carrying no cqf-cqlType is read as Interval. The point type cannot be recovered in that case — a boundary of 2012-01-01T00:00:00-07:00 is indistinguishable from a DateTime interval starting at midnight — and the IG states that a value without cqf-cqlType is the FHIR type. Inferring a Date would be a guess, so the test asserts the mismatch instead, keeping the missing extension visible as a server defect. Co-Authored-By: Claude Opus 5 (1M context) --- .../datetime-interval-extractor.ts | 38 ++-- .../quantity-interval-extractor.ts | 65 ++++--- test/interval-fhir-types.test.ts | 182 ++++++++++++++++++ 3 files changed, 245 insertions(+), 40 deletions(-) create mode 100644 test/interval-fhir-types.test.ts diff --git a/src/extractors/value-type-extractors/datetime-interval-extractor.ts b/src/extractors/value-type-extractors/datetime-interval-extractor.ts index 1bdc52f..9cb3ede 100644 --- a/src/extractors/value-type-extractors/datetime-interval-extractor.ts +++ b/src/extractors/value-type-extractors/datetime-interval-extractor.ts @@ -28,22 +28,30 @@ const BOUNDARY_FORMATS = { export class DateTimeIntervalExtractor extends BaseExtractor { protected _process(parameter: any): any { - if (parameter.hasOwnProperty('valuePeriod')) { - const format = BOUNDARY_FORMATS[declaredPointType(parameter) ?? 'DateTime']; - const low = parameter.valuePeriod.hasOwnProperty('start') - ? format(parameter.valuePeriod.start) - : null; - const high = parameter.valuePeriod.hasOwnProperty('end') - ? format(parameter.valuePeriod.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']; + const low = period.hasOwnProperty('start') ? format(period.start) : null; + const high = period.hasOwnProperty('end') ? format(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