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
659 changes: 360 additions & 299 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
},
"dependencies": {
"@lhncbc/ucum-lhc": "^7.1.9",
"decimal.js": "^10.6.0",
"immutable": "^5.1.6",
"luxon": "^3.7.2"
},
Expand Down
3 changes: 3 additions & 0 deletions src/cql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
Concept,
Date,
DateTime,
Decimal,
Interval,
Quantity,
Ratio,
Expand Down Expand Up @@ -54,6 +55,7 @@ export {
Concept,
Date,
DateTime,
Decimal,
Interval,
Quantity,
Ratio,
Expand Down Expand Up @@ -81,6 +83,7 @@ export default {
Concept,
Date,
DateTime,
Decimal,
Interval,
Quantity,
Ratio,
Expand Down
1 change: 1 addition & 0 deletions src/datatypes/datatypes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './bigint';
export * from './decimal';
export * from './logic';
export * from './clinical';
export * from './uncertainty';
Expand Down
36 changes: 23 additions & 13 deletions src/datatypes/datetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
MIN_DATETIME_VALUE_STRING,
MIN_TIME_VALUE_STRING
} from '../util/limits';
import { Decimal } from './decimal';

// It's easiest and most performant to organize formats by length of the supported strings.
// This way we can test strings only against the formats that have a chance of working.
Expand Down Expand Up @@ -529,7 +530,7 @@ export class DateTime extends AbstractDate {
minute: number | null;
second: number | null;
millisecond: number | null;
timezoneOffset: number | null;
timezoneOffset: Decimal | null;

static readonly Unit = {
YEAR: 'year',
Expand Down Expand Up @@ -599,13 +600,19 @@ export class DateTime extends AbstractDate {
}

// TODO: Note: using the jsDate type causes issues, fix later
static fromJSDate(date: any, timezoneOffset?: any) {
static fromJSDate(date: any, timezoneOffset?: number | string | Decimal) {
//This is from a JS Date, not a CQL Date
if (date instanceof DateTime) {
return date;
}
if (timezoneOffset != null) {
date = new jsDate(date.getTime() + timezoneOffset * 60 * 60 * 1000);
let tzOffset: number;
if (timezoneOffset instanceof Decimal) {
tzOffset = timezoneOffset.toNumber();
} else {
tzOffset = +timezoneOffset;
}
date = new jsDate(date.getTime() + tzOffset * 60 * 60 * 1000);
return new DateTime(
date.getUTCFullYear(),
date.getUTCMonth() + 1,
Expand All @@ -614,7 +621,7 @@ export class DateTime extends AbstractDate {
date.getUTCMinutes(),
date.getUTCSeconds(),
date.getUTCMilliseconds(),
timezoneOffset
tzOffset
);
} else {
return new DateTime(
Expand All @@ -641,7 +648,7 @@ export class DateTime extends AbstractDate {
luxonDT.minute,
luxonDT.second,
luxonDT.millisecond,
luxonDT.offset / 60
Decimal.from(luxonDT.offset / 60)
);
}

Expand All @@ -653,7 +660,7 @@ export class DateTime extends AbstractDate {
minute: number | null = null,
second: number | null = null,
millisecond: number | null = null,
timezoneOffset?: number | null
timezoneOffset?: Decimal | number | null
) {
// from the spec: If no timezone is specified, the timezone of the evaluation request timestamp is used.
// NOTE: timezoneOffset will be explicitly null for the Time overload, whereas
Expand All @@ -664,9 +671,11 @@ export class DateTime extends AbstractDate {
this.second = second;
this.millisecond = millisecond;
if (timezoneOffset === undefined) {
this.timezoneOffset = (new jsDate().getTimezoneOffset() / 60) * -1;
this.timezoneOffset = Decimal.from((new jsDate().getTimezoneOffset() / 60) * -1);
} else if (timezoneOffset === null) {
this.timezoneOffset = null;
} else {
this.timezoneOffset = timezoneOffset;
this.timezoneOffset = Decimal.from(timezoneOffset);
}
}

Expand Down Expand Up @@ -869,7 +878,7 @@ export class DateTime extends AbstractDate {
toLuxonDateTime() {
const offsetMins =
this.timezoneOffset != null
? this.timezoneOffset * 60
? this.timezoneOffset.toNumber() * 60
: new jsDate().getTimezoneOffset() * -1;
return LuxonDateTime.fromObject(
{
Expand Down Expand Up @@ -958,10 +967,11 @@ export class DateTime extends AbstractDate {
}

if (str.indexOf('T') !== -1 && this.timezoneOffset != null) {
str += this.timezoneOffset < 0 ? '-' : '+';
const offsetHours = Math.floor(Math.abs(this.timezoneOffset));
const tzOffset = this.timezoneOffset.toNumber();
str += tzOffset < 0 ? '-' : '+';
const offsetHours = Math.floor(Math.abs(tzOffset));
str += String(offsetHours).padStart(2, '0');
const offsetMin = (Math.abs(this.timezoneOffset) - offsetHours) * 60;
const offsetMin = (Math.abs(tzOffset) - offsetHours) * 60;
str += ':' + String(offsetMin).padStart(2, '0');
}

Expand Down Expand Up @@ -1202,7 +1212,7 @@ export class Date extends AbstractDate {
return str;
}

getDateTime(timeZoneOffset?: number | null) {
getDateTime(timeZoneOffset?: Decimal | null) {
// from the spec: the result will be a DateTime with the time components unspecified,
// except for the timezone offset, which will be set to the timezone offset of the evaluation
// request timestamp. (this last part is achieved by passing in the timeZoneOffset from the context)
Expand Down
223 changes: 223 additions & 0 deletions src/datatypes/decimal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { Decimal as DecimalJS } from 'decimal.js';

// Use a clone rather than DecimalJS.set because decimal.js configuration is otherwise global.
// This keeps our settings from changing the behavior of other decimal.js instances in
// the same process.
// Precision is significant figures (not decimal places);
// CQL's maximum Decimal value has 28 significant figures, 30 is just a cleaner number.
const CQLDecimalJS = DecimalJS.clone({ precision: 30 });

export type DecimalInput = Decimal | string | number | bigint;

export type DecimalRoundingMode = DecimalJS.Rounding;

const MIN_PRECISION_VALUE = CQLDecimalJS.pow(10, -8);

const CQL_IMPLICIT_SCALE = 8;
const CQL_IMPLICIT_ROUNDING = CQLDecimalJS.ROUND_HALF_UP;

export class Decimal {
private value: DecimalJS;

private constructor(value: string | number | bigint | DecimalJS) {
this.value = new CQLDecimalJS(value);
if (!this.value.isFinite()) {
throw new Error('Cannot create a decimal with a non-finite value');
}
}

static from(value: DecimalInput) {
if (value instanceof Decimal) {
return value;
}

return new Decimal(value);
}

get isDecimal() {
return true;
}

normalized() {
if (this.value.decimalPlaces() <= CQL_IMPLICIT_SCALE) {
return this;
}
return this.setScale(CQL_IMPLICIT_SCALE, CQL_IMPLICIT_ROUNDING);
}

// Helper function to reduce repeated boilerplate.
// Apply the given function with the given operand, and wrap the result in a Decimal.
private applyWrapper(operation: (value: any) => DecimalJS, other: DecimalInput): Decimal {
const operand = other instanceof Decimal ? other.value : other;

return new Decimal(operation.call(this.value, operand));
}

add(other: DecimalInput): Decimal {
return this.applyWrapper(this.value.add, other);
}

subtract(other: DecimalInput): Decimal {
return this.applyWrapper(this.value.minus, other);
}

multiplyBy(other: DecimalInput): Decimal {
return this.applyWrapper(this.value.times, other);
}

divideBy(other: DecimalInput): Decimal {
if (Decimal.from(other).equals(0)) {
throw new RangeError('Cannot divide a decimal by zero');
}
return this.applyWrapper(this.value.dividedBy, other);
}

modulo(other: DecimalInput) {
if (Decimal.from(other).equals(0)) {
throw new RangeError('Cannot calculate decimal modulo by zero');
}
return this.applyWrapper(this.value.mod, other);
}

compareTo(other: DecimalInput) {
if (other instanceof Decimal) {
return this.value.comparedTo(other.value);
}
return this.value.comparedTo(other);
}

greaterThan(other: DecimalInput) {
return this.compareTo(other) > 0;
}

greaterThanOrEquals(other: DecimalInput) {
return this.compareTo(other) >= 0;
}

lessThan(other: DecimalInput) {
return this.compareTo(other) < 0;
}

lessThanOrEquals(other: DecimalInput) {
return this.compareTo(other) <= 0;
}

equals(other: DecimalInput) {
return this.compareTo(other) === 0;
}

successor() {
// TODO: successor should be based on current precision
// For Decimal, successor is equivalent to adding 1 * the precision of the argument.
return new Decimal(this.value.add(MIN_PRECISION_VALUE));
}

predecessor() {
// TODO: predecessor should be based on current precision
// For Decimal, predecessor is equivalent to subtracting 1 * the precision of the argument.
return new Decimal(this.value.minus(MIN_PRECISION_VALUE));
}

negate() {
return new Decimal(this.value.neg());
}

abs() {
return new Decimal(this.value.abs());
}

truncate(): number {
return this.value.truncated().toNumber();
}

truncated(): Decimal {
return new Decimal(this.value.truncated());
}

ceil(): number {
return this.value.ceil().toNumber();
}

floor(): number {
return this.value.floor().toNumber();
}

isInteger() {
return this.value.isInteger();
}

power(exponent: DecimalInput) {
return this.applyWrapper(this.value.toPower, exponent);
}

sqrt() {
return new Decimal(this.value.sqrt());
}

ln() {
return new Decimal(this.value.ln());
}

exp() {
return new Decimal(this.value.exp());
}

log(base: DecimalInput) {
return this.applyWrapper(this.value.log, base);
}

round(scale: number) {
// notes on rounding modes
// ROUND_HALF_UP "Rounds towards nearest neighbour. If equidistant, rounds away from zero"
// rounds 0.5 -> 1.0, -0.5 -> -1.0
// ROUND_HALF_CEIL "Rounds towards nearest neighbour. If equidistant, rounds towards Infinity"
// rounds 0.5 -> 1.0, -0.5 -> 0.0
// https://mikemcl.github.io/decimal.js/#modes
return this.setScale(scale, CQLDecimalJS.ROUND_HALF_CEIL);
}

setScale(scale: number, roundingMode: DecimalRoundingMode = CQLDecimalJS.ROUND_DOWN) {
if (!Number.isInteger(scale) || scale < 0) {
throw new RangeError('Decimal scale must be a non-negative integer');
}

return new Decimal(this.value.toDecimalPlaces(scale, roundingMode));
}

toInteger() {
// note that this is permissive and converts non-integral values
return this.truncate();
}

toNumber() {
return this.value.toNumber();
}

toLong() {
// note that this is permissive and converts non-integral values
return BigInt(this.value.truncated().toString());
}

toString() {
// decimal.js toString can return exponential notation,
// toFixed always returns normal notation
// CQL spec expects format (-)?#0.0#
// https://cql.hl7.org/R2/09-b-cqlreference.html#tostring
// meaning, optional minus sign, at least one digit, decimal point, at least one digit
// (# means any number of digits, including none; 0 means a digit must appear)
// a regex for this is -?\d+\.\d+
// so Decimal.from(1).toString() --> "1.0"
const places = Math.max(1, this.value.decimalPlaces());
return this.value.toFixed(places);
}

toJSON() {
return this.toString();
}
}

export const MAX_DECIMAL_STRING = '99999999999999999999.99999999';
export const MIN_DECIMAL_STRING = '-99999999999999999999.99999999';

export const MAX_DECIMAL_VALUE = Decimal.from(MAX_DECIMAL_STRING);
export const MIN_DECIMAL_VALUE = Decimal.from(MIN_DECIMAL_STRING);
Loading
Loading