From 008aaf4e9261c74772e037253ca52bbb8f1ddb9b Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Thu, 8 Oct 2026 17:57:16 +0100 Subject: [PATCH 1/2] Use execution timezone offset for ToDateTime(String) Per the CQL spec, when a string has no timezone offset the offset of the evaluation request timestamp is assumed. DateTime.parse now accepts an optional default offset, which ToDateTime supplies from the execution context. Other DateTime.parse callers are unchanged. --- src/datatypes/datetime.ts | 4 +++- src/elm/type.ts | 2 +- test/datatypes/datetime-test.ts | 14 ++++++++++++++ test/elm/convert/convert-test.ts | 16 ++++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/datatypes/datetime.ts b/src/datatypes/datetime.ts index 335b0ef8..c066c517 100644 --- a/src/datatypes/datetime.ts +++ b/src/datatypes/datetime.ts @@ -289,7 +289,7 @@ export class DateTime extends AbstractDate { DateTime.Unit.MILLISECOND ]; - static parse(string: any) { + static parse(string: any, defaultTimezoneOffset?: Decimal | number | null) { if (string === null) { return null; } @@ -330,6 +330,8 @@ export class DateTime extends AbstractDate { args.push(matches[17] === '+' ? num : num * -1); } else if (matches[15] === 'Z') { args.push(0); + } else if (defaultTimezoneOffset != null) { + args.push(Decimal.from(defaultTimezoneOffset).toNumber()); } // @ts-ignore return new DateTime(...args); diff --git a/src/elm/type.ts b/src/elm/type.ts index ae95006b..cfbcc123 100644 --- a/src/elm/type.ts +++ b/src/elm/type.ts @@ -162,7 +162,7 @@ export class ToDateTime extends Expression { const timezoneOffset = ctx.getExecutionDateTime().timezoneOffset; return arg.getDateTime(timezoneOffset); } else { - return DateTime.parse(arg.toString()); + return DateTime.parse(arg.toString(), ctx.getExecutionDateTime().timezoneOffset); } } } diff --git a/test/datatypes/datetime-test.ts b/test/datatypes/datetime-test.ts index 85dea5ce..ebd62f11 100644 --- a/test/datatypes/datetime-test.ts +++ b/test/datatypes/datetime-test.ts @@ -146,6 +146,20 @@ describe('DateTime', () => { d.should.eql(new DateTime(2012, 10, 25, 12, 55, 14, 953, 0)); }); + it('should use the default timezone offset when the string has none', () => { + // use a quarter-hour offset that no real-world timezone has, so it can + // never coincide with the host machine's offset + const d = DateTime.parse('2012-10-25T12:55', -3.25); + d.timezoneOffset.should.equalDecimal(-3.25); + }); + + it('should ignore the default timezone offset when the string specifies one', () => { + let d = DateTime.parse('2012-10-25T12:55+05:30', -3.25); + d.timezoneOffset.should.equalDecimal(5.5); + d = DateTime.parse('2012-10-25T12:55Z', -3.25); + d.timezoneOffset.should.equalDecimal(0); + }); + it('should toString yyyy', () => { const d = new DateTime(2012); d.toString().should.eql('2012'); diff --git a/test/elm/convert/convert-test.ts b/test/elm/convert/convert-test.ts index ec396ae6..0c9134bc 100644 --- a/test/elm/convert/convert-test.ts +++ b/test/elm/convert/convert-test.ts @@ -6,6 +6,7 @@ import { DateTime } from '../../../src/datatypes/datetime'; import { Quantity } from '../../../src/datatypes/quantity'; import { Uncertainty } from '../../../src/datatypes/uncertainty'; import { Decimal } from '../../../src/datatypes/decimal'; +import { PatientContext } from '../../../src/runtime/context'; describe('FromString', () => { beforeEach(function () { @@ -92,6 +93,21 @@ describe('FromString', () => { date.isDateTime.should.equal(true); }); + it("should convert '2015-01-02' to DateTime using the execution timezone offset", async function () { + this.ctx = new PatientContext( + this.ctx.library, + this.ctx.patient, + this.ctx.codeService, + this.ctx.parameters, + // use a quarter-hour offset that no real-world timezone has, so it can + // never coincide with the host machine's offset + DateTime.fromJSDate(new Date(), -3.25) + ); + const date = await this.dateTimeStr.exec(this.ctx); + date.timezoneOffset.should.equalDecimal(-3.25); + date.isDateTime.should.equal(true); + }); + it("should convert '2015-01-02' to Date", async function () { const date = await this.dateStr.exec(this.ctx); date.year.should.equal(2015); From c368a16ddb9010e0686996f275878bd114f95c84 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Fri, 9 Oct 2026 15:00:55 +0100 Subject: [PATCH 2/2] Re-enable ToDateTime spec tests and regenerate ELM --- test/spec-tests/cql/CqlTypeOperatorsTest.cql | 16 +- test/spec-tests/cql/CqlTypeOperatorsTest.json | 372 +++++++++++++++--- test/spec-tests/skip-list.txt | 4 - 3 files changed, 328 insertions(+), 64 deletions(-) diff --git a/test/spec-tests/cql/CqlTypeOperatorsTest.cql b/test/spec-tests/cql/CqlTypeOperatorsTest.cql index 4ae19f7c..71f04973 100644 --- a/test/spec-tests/cql/CqlTypeOperatorsTest.cql +++ b/test/spec-tests/cql/CqlTypeOperatorsTest.cql @@ -31,11 +31,9 @@ define "Convert": Tuple{ output: null }, "StringToDateTime": Tuple{ - skipped: 'Wrong answer (different offsets)' - /* expression: convert '2014-01-01' to DateTime, output: @2014-01-01T - */ }, + }, "StringToTime": Tuple{ expression: convert 'T14:30:00.0' to Time, output: @T14:30:00.000 @@ -79,23 +77,17 @@ define "ToConcept": Tuple{ define "ToDateTime": Tuple{ "ToDateTime1": Tuple{ - skipped: 'Wrong answer (different offsets)' - /* expression: ToDateTime('2014-01-01'), output: @2014-01-01T - */ }, + }, "ToDateTime2": Tuple{ - skipped: 'Wrong answer (different offsets)' - /* expression: ToDateTime('2014-01-01T12:05'), output: @2014-01-01T12:05 - */ }, + }, "ToDateTime3": Tuple{ - skipped: 'Wrong answer (different offsets)' - /* expression: ToDateTime('2014-01-01T12:05:05.955'), output: @2014-01-01T12:05:05.955 - */ }, + }, "ToDateTime4": Tuple{ expression: ToDateTime('2014-01-01T12:05:05.955+01:30'), output: @2014-01-01T12:05:05.955+01:30 diff --git a/test/spec-tests/cql/CqlTypeOperatorsTest.json b/test/spec-tests/cql/CqlTypeOperatorsTest.json index 68578b7b..8bfc3445 100644 --- a/test/spec-tests/cql/CqlTypeOperatorsTest.json +++ b/test/spec-tests/cql/CqlTypeOperatorsTest.json @@ -589,11 +589,20 @@ "annotation": [], "element": [ { - "name": "skipped", + "name": "expression", "annotation": [], "elementType": { "type": "NamedTypeSpecifier", - "name": "{urn:hl7-org:elm-types:r1}String", + "name": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [] + } + }, + { + "name": "output", + "annotation": [], + "elementType": { + "type": "NamedTypeSpecifier", + "name": "{urn:hl7-org:elm-types:r1}DateTime", "annotation": [] } } @@ -757,11 +766,20 @@ "annotation": [], "element": [ { - "name": "skipped", + "name": "expression", "annotation": [], "elementType": { "type": "NamedTypeSpecifier", - "name": "{urn:hl7-org:elm-types:r1}String", + "name": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [] + } + }, + { + "name": "output", + "annotation": [], + "elementType": { + "type": "NamedTypeSpecifier", + "name": "{urn:hl7-org:elm-types:r1}DateTime", "annotation": [] } } @@ -1012,11 +1030,20 @@ "annotation": [], "element": [ { - "name": "skipped", + "name": "expression", "annotation": [], "elementType": { "type": "NamedTypeSpecifier", - "name": "{urn:hl7-org:elm-types:r1}String", + "name": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [] + } + }, + { + "name": "output", + "annotation": [], + "elementType": { + "type": "NamedTypeSpecifier", + "name": "{urn:hl7-org:elm-types:r1}DateTime", "annotation": [] } } @@ -1024,13 +1051,46 @@ }, "element": [ { - "name": "skipped", + "name": "expression", "value": { - "type": "Literal", - "resultTypeName": "{urn:hl7-org:elm-types:r1}String", - "valueType": "{urn:hl7-org:elm-types:r1}String", - "value": "Wrong answer (different offsets)", - "annotation": [] + "type": "ToDateTime", + "resultTypeName": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [], + "signature": [], + "operand": { + "type": "Literal", + "resultTypeName": "{urn:hl7-org:elm-types:r1}String", + "valueType": "{urn:hl7-org:elm-types:r1}String", + "value": "2014-01-01", + "annotation": [] + } + } + }, + { + "name": "output", + "value": { + "type": "DateTime", + "resultTypeName": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [], + "signature": [], + "year": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "2014", + "annotation": [] + }, + "month": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "1", + "annotation": [] + }, + "day": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "1", + "annotation": [] + } } } ] @@ -1909,11 +1969,20 @@ "annotation": [], "element": [ { - "name": "skipped", + "name": "expression", "annotation": [], "elementType": { "type": "NamedTypeSpecifier", - "name": "{urn:hl7-org:elm-types:r1}String", + "name": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [] + } + }, + { + "name": "output", + "annotation": [], + "elementType": { + "type": "NamedTypeSpecifier", + "name": "{urn:hl7-org:elm-types:r1}DateTime", "annotation": [] } } @@ -1928,11 +1997,20 @@ "annotation": [], "element": [ { - "name": "skipped", + "name": "expression", "annotation": [], "elementType": { "type": "NamedTypeSpecifier", - "name": "{urn:hl7-org:elm-types:r1}String", + "name": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [] + } + }, + { + "name": "output", + "annotation": [], + "elementType": { + "type": "NamedTypeSpecifier", + "name": "{urn:hl7-org:elm-types:r1}DateTime", "annotation": [] } } @@ -1947,11 +2025,20 @@ "annotation": [], "element": [ { - "name": "skipped", + "name": "expression", "annotation": [], "elementType": { "type": "NamedTypeSpecifier", - "name": "{urn:hl7-org:elm-types:r1}String", + "name": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [] + } + }, + { + "name": "output", + "annotation": [], + "elementType": { + "type": "NamedTypeSpecifier", + "name": "{urn:hl7-org:elm-types:r1}DateTime", "annotation": [] } } @@ -2143,11 +2230,20 @@ "annotation": [], "element": [ { - "name": "skipped", + "name": "expression", "annotation": [], "elementType": { "type": "NamedTypeSpecifier", - "name": "{urn:hl7-org:elm-types:r1}String", + "name": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [] + } + }, + { + "name": "output", + "annotation": [], + "elementType": { + "type": "NamedTypeSpecifier", + "name": "{urn:hl7-org:elm-types:r1}DateTime", "annotation": [] } } @@ -2162,11 +2258,20 @@ "annotation": [], "element": [ { - "name": "skipped", + "name": "expression", "annotation": [], "elementType": { "type": "NamedTypeSpecifier", - "name": "{urn:hl7-org:elm-types:r1}String", + "name": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [] + } + }, + { + "name": "output", + "annotation": [], + "elementType": { + "type": "NamedTypeSpecifier", + "name": "{urn:hl7-org:elm-types:r1}DateTime", "annotation": [] } } @@ -2181,11 +2286,20 @@ "annotation": [], "element": [ { - "name": "skipped", + "name": "expression", "annotation": [], "elementType": { "type": "NamedTypeSpecifier", - "name": "{urn:hl7-org:elm-types:r1}String", + "name": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [] + } + }, + { + "name": "output", + "annotation": [], + "elementType": { + "type": "NamedTypeSpecifier", + "name": "{urn:hl7-org:elm-types:r1}DateTime", "annotation": [] } } @@ -2373,11 +2487,20 @@ "annotation": [], "element": [ { - "name": "skipped", + "name": "expression", "annotation": [], "elementType": { "type": "NamedTypeSpecifier", - "name": "{urn:hl7-org:elm-types:r1}String", + "name": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [] + } + }, + { + "name": "output", + "annotation": [], + "elementType": { + "type": "NamedTypeSpecifier", + "name": "{urn:hl7-org:elm-types:r1}DateTime", "annotation": [] } } @@ -2385,13 +2508,46 @@ }, "element": [ { - "name": "skipped", + "name": "expression", "value": { - "type": "Literal", - "resultTypeName": "{urn:hl7-org:elm-types:r1}String", - "valueType": "{urn:hl7-org:elm-types:r1}String", - "value": "Wrong answer (different offsets)", - "annotation": [] + "type": "ToDateTime", + "resultTypeName": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [], + "signature": [], + "operand": { + "type": "Literal", + "resultTypeName": "{urn:hl7-org:elm-types:r1}String", + "valueType": "{urn:hl7-org:elm-types:r1}String", + "value": "2014-01-01", + "annotation": [] + } + } + }, + { + "name": "output", + "value": { + "type": "DateTime", + "resultTypeName": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [], + "signature": [], + "year": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "2014", + "annotation": [] + }, + "month": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "1", + "annotation": [] + }, + "day": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "1", + "annotation": [] + } } } ] @@ -2407,11 +2563,20 @@ "annotation": [], "element": [ { - "name": "skipped", + "name": "expression", "annotation": [], "elementType": { "type": "NamedTypeSpecifier", - "name": "{urn:hl7-org:elm-types:r1}String", + "name": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [] + } + }, + { + "name": "output", + "annotation": [], + "elementType": { + "type": "NamedTypeSpecifier", + "name": "{urn:hl7-org:elm-types:r1}DateTime", "annotation": [] } } @@ -2419,13 +2584,58 @@ }, "element": [ { - "name": "skipped", + "name": "expression", "value": { - "type": "Literal", - "resultTypeName": "{urn:hl7-org:elm-types:r1}String", - "valueType": "{urn:hl7-org:elm-types:r1}String", - "value": "Wrong answer (different offsets)", - "annotation": [] + "type": "ToDateTime", + "resultTypeName": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [], + "signature": [], + "operand": { + "type": "Literal", + "resultTypeName": "{urn:hl7-org:elm-types:r1}String", + "valueType": "{urn:hl7-org:elm-types:r1}String", + "value": "2014-01-01T12:05", + "annotation": [] + } + } + }, + { + "name": "output", + "value": { + "type": "DateTime", + "resultTypeName": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [], + "signature": [], + "year": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "2014", + "annotation": [] + }, + "month": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "1", + "annotation": [] + }, + "day": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "1", + "annotation": [] + }, + "hour": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "12", + "annotation": [] + }, + "minute": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "5", + "annotation": [] + } } } ] @@ -2441,11 +2651,20 @@ "annotation": [], "element": [ { - "name": "skipped", + "name": "expression", "annotation": [], "elementType": { "type": "NamedTypeSpecifier", - "name": "{urn:hl7-org:elm-types:r1}String", + "name": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [] + } + }, + { + "name": "output", + "annotation": [], + "elementType": { + "type": "NamedTypeSpecifier", + "name": "{urn:hl7-org:elm-types:r1}DateTime", "annotation": [] } } @@ -2453,13 +2672,70 @@ }, "element": [ { - "name": "skipped", + "name": "expression", + "value": { + "type": "ToDateTime", + "resultTypeName": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [], + "signature": [], + "operand": { + "type": "Literal", + "resultTypeName": "{urn:hl7-org:elm-types:r1}String", + "valueType": "{urn:hl7-org:elm-types:r1}String", + "value": "2014-01-01T12:05:05.955", + "annotation": [] + } + } + }, + { + "name": "output", "value": { - "type": "Literal", - "resultTypeName": "{urn:hl7-org:elm-types:r1}String", - "valueType": "{urn:hl7-org:elm-types:r1}String", - "value": "Wrong answer (different offsets)", - "annotation": [] + "type": "DateTime", + "resultTypeName": "{urn:hl7-org:elm-types:r1}DateTime", + "annotation": [], + "signature": [], + "year": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "2014", + "annotation": [] + }, + "month": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "1", + "annotation": [] + }, + "day": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "1", + "annotation": [] + }, + "hour": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "12", + "annotation": [] + }, + "minute": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "5", + "annotation": [] + }, + "second": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "5", + "annotation": [] + }, + "millisecond": { + "type": "Literal", + "valueType": "{urn:hl7-org:elm-types:r1}Integer", + "value": "955", + "annotation": [] + } } } ] diff --git a/test/spec-tests/skip-list.txt b/test/spec-tests/skip-list.txt index c6fcfa92..f8fb8bac 100644 --- a/test/spec-tests/skip-list.txt +++ b/test/spec-tests/skip-list.txt @@ -71,10 +71,6 @@ CqlComparisonOperatorsTest.Equal.DateTimeEqNull Wrong answer (tr CqlIntervalOperatorsTest.Collapse.TestCollapseNull Wrong answer (Interval(null, null) vs null) CqlIntervalOperatorsTest.Expand.ExpandListWithNull Wrong answer (should be empty list due to removing nulls) -CqlTypeOperatorsTest.Convert.StringToDateTime Wrong answer (different offsets) -CqlTypeOperatorsTest.ToDateTime.ToDateTime1 Wrong answer (different offsets) -CqlTypeOperatorsTest.ToDateTime.ToDateTime2 Wrong answer (different offsets) -CqlTypeOperatorsTest.ToDateTime.ToDateTime3 Wrong answer (different offsets) ValueLiteralsAndSelectors.Decimal.Decimal10Pow28ToZeroOneStepDecimalMaxValue Wrong answer (null vs big number); intermediate value exceeds max Decimal ValueLiteralsAndSelectors.Decimal.DecimalPos10Pow28ToZeroOneStepDecimalMaxValue Wrong answer (null vs big number); intermediate value exceeds max Decimal