Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 30 additions & 18 deletions src/extractors/value-type-extractors/datetime-interval-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DateTime>.
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,
};
}
}
65 changes: 40 additions & 25 deletions src/extractors/value-type-extractors/quantity-interval-extractor.ts
Original file line number Diff line number Diff line change
@@ -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<Quantity>`. 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<Quantity>`.
*/
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,
};
}
}
182 changes: 182 additions & 0 deletions test/interval-fhir-types.test.ts
Original file line number Diff line number Diff line change
@@ -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<Integer|Long|Decimal|Quantity> as FHIR.Range and
* Interval<Date|DateTime|Time> as FHIR.Period.
*/
const CQL_TYPE_URL = 'http://hl7.org/fhir/StructureDefinition/cqf-cqlType';

let cvl: any;
let extractor: ReturnType<typeof buildExtractor>;

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<Integer|Decimal> as FHIR.Range', () => {
it('matches an Integer interval declared by cqf-cqlType', () => {
const { expected, actual } = extractFor(
{
name: 'return',
...cqlType('Interval<System.Integer>'),
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<System.Decimal>'),
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<System.Integer>'),
valueRange: { low: { value: 1 }, high: { value: 10 } },
},
'Interval[1, 10]'
);
expect(actual).toEqual({ lowClosed: true, low: 1, highClosed: true, high: 10 });
});
});

describe('Interval<Quantity> 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<Date|DateTime|Time> as FHIR.Period', () => {
it('matches a Date interval declared by cqf-cqlType', () => {
const { expected, actual } = extractFor(
{
name: 'return',
...cqlType('Interval<System.Date>'),
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<Time> onto a Period whose boundaries are dateTimes anchored to
// @0001-01-01; the date and any offset are artifacts of that mapping.
const { expected, actual } = extractFor(
{
name: 'return',
...cqlType('Interval<System.Time>'),
valuePeriod: { start: '0001-01-01T01:00:00.000Z', end: '0001-01-01T02:00:00.000Z' },
},
'Interval[@T01:00:00.000, @T02:00:00.000]'
);
expect(resultsEqual(expected, actual)).toBe(true);
});

it('reads an undeclared Period as Interval<DateTime>, per the IG', () => {
// Without cqf-cqlType the point type cannot be recovered, and the IG says a value with no
// cqlType is the FHIR type. This documents that a Date interval returned without the
// extension does NOT match — the omission is the server's to fix, not something to guess at.
const { expected, actual } = extractFor(
{
name: 'return',
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(false);
});
});

describe('boundary and robustness handling', () => {
it('treats an absent Period end as an unbounded, open high boundary', () => {
const actual = extractor.extract(
parameters({ name: 'return', valuePeriod: { start: '2012-01-01' } })
);
expect(actual).toMatchObject({ highClosed: false, high: null });
});

it('treats an absent Range high the same way', () => {
const actual = extractor.extract(
parameters({ name: 'return', valueRange: { low: { value: 1, code: 'mg' } } })
);
expect(actual).toMatchObject({ highClosed: false, high: null });
});

it('does not throw on a null valuePeriod, valueRange or boundary', () => {
expect(() =>
extractor.extract(parameters({ name: 'return', valuePeriod: null }))
).not.toThrow();
expect(() =>
extractor.extract(parameters({ name: 'return', valueRange: null }))
).not.toThrow();
expect(() =>
extractor.extract(
parameters({ name: 'return', valueRange: { low: null, high: { value: 10, code: 'mg' } } })
)
).not.toThrow();
});

it('treats a boundary with no value as no boundary', () => {
const actual = extractor.extract(
parameters({ name: 'return', valueRange: { low: {}, high: { value: 10, code: 'mg' } } })
);
expect(actual).toMatchObject({ lowClosed: false, low: null });
});
});