diff --git a/README.md b/README.md index 1b10620..50c2c33 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,151 @@ Google APIs Client Libraries, in [Client Libraries Explained][explained]. It's unlikely you will need to install this package directly, as it will be installed as a dependency when you install other `@google-cloud` packages. +```sh +$ npm install --save @google-cloud/precise-date +``` + +### Using the package + +`PreciseDate` extends the native `Date` object, so you can use it in place of +that or when you need nanosecond precision. + +```js +const {PreciseDate} = require('@google-cloud/precise-date'); +const date = new PreciseDate('1547253035381101032'); + +date.toISOString(); +// => 2019-01-12T00:30:35.381101032Z + +date.toFullTimeString(); +// => '1547253035381101032' +``` + +## API + +### PreciseDate([time]) + +Returns a new `date` instance. + +#### time + +Type: `string` [`BigInt`][big_int] `Object` `[number, number]` + +```js +// from a full ISO string +date = new PreciseDate('2019-02-08T10:34:29.481145231Z'); + +// from a string representing nanoseconds +date = new PreciseDate('1549622069481320032'); + +// from a BigInt representing nanoseconds (requires Node >= 10.7) +date = new PreciseDate(1549622069481320032n); + +// from an object containing `seconds` and `nanos` values +date = new PreciseDate({seconds: 1549622069, nanos: 481320032}); + +// from a tuple representing [seconds, nanos] +date = new PreciseDate([1549622069, 481320032]); +``` + +#### PreciseDate.parseFull(time) + +Similar to [`Date.parse()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse), but this accepts the same nanosecond time options as the `PreciseDate` constructor and returns a string representing the nanoseconds in the specified date according to universal time. + +```js +PreciseDate.parseFull('2019-02-08T10:34:29.481145231Z'); +// => '1549622069481145231' +``` + +#### PreciseDate.fullUTCString(...dateFields) + +Similar to [`Date.UTC()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/UTC), but also accepts microsecond and nanosecond parameters. Returns a string that represents the number of nanoseconds since January 1, 1970, 00:00:00 UTC. + +##### dateFields + +Type: `...number` + +```js +PreciseDate.fullUTCString(2019, 1, 8, 10, 34, 29, 481, 145, 231); +// => '1549622069481145231' +``` + +#### PreciseDate.fullUTC(...dateFields) + +Like `PreciseDate.fullUTCString()` but returns a native [`BigInt`][big_int] instead of a string. **Requires Node >= 10.7.** + +##### dateFields + +Type: `...number` + +```js +PreciseDate.fullUTC(2019, 1, 8, 10, 34, 29, 481, 145, 231); +// => 1549622069481145231n +``` + +### date + +`PreciseDate` instance. + +#### date.getFullTimeString() + +Returns a string of the specified date represented in nanoseconds according to universal time. + +#### date.getFullTime() + +Like `date.getFullTimeString()` but returns a native [`BigInt`][big_int] instead of a string. **Requires Node >= 10.7.** + +#### date.getMicroseconds() + +Returns the microseconds in the specified date according to universal time. + +#### date.getNanoseconds() + +Returns the nanoseconds in the specified date according to universal time. + +#### date.setMicroseconds(microseconds) + +Sets the microseconds for a specified date according to universal time. Returns a string representing the nanoseconds in the specified date according to universal time. + +##### microseconds + +Type: `number` + +#### date.setNanoseconds(nanoseconds) + +Sets the nanoseconds for a specified date according to universal time. Returns a string representing the nanoseconds in the specified date according to universal time. + +##### nanoseconds + +Type: `number` + +#### date.setFullTime(time) + +Sets the time to the number of supplied nanoseconds since January 1, 1970, 00:00:00 UTC. Returns a string representing the nanoseconds in the specified date according to universal time (effectively, the value of the argument). + +##### time + +Type: `number` `string` [`BigInt`][big_int] + +#### date.toStruct() + +Returns an object representing the specified date according to universal time. +Refer to [`google.protobuf.Timestamp`](https://developers.google.com/protocol-buffers/docs/reference/google.protobuf#timestamp|google.protobuf.Timestamp) for more information about this format. + +```js +const {seconds, nanos} = date.toStruct(); +``` + +#### date.toTuple() + +Like `date.toStruct()` but returns the `seconds` and `nanos` as a tuple. + +```js +const [seconds, nanos] = date.toTuple(); +``` + +[big_int]: https://github.com/tc39/proposal-bigint + ## Versioning This library follows [Semantic Versioning](http://semver.org/). diff --git a/package.json b/package.json index 3754bd2..5444a4e 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,13 @@ "devDependencies": { "@types/mocha": "^5.2.4", "@types/node": "^10.5.2", + "@types/sinon": "^7.0.5", "codecov": "^3.0.4", "gts": "^0.9.0", "intelli-espower-loader": "^1.0.1", "mocha": "^5.2.0", "nyc": "^13.0.0", + "sinon": "^7.2.3", "source-map-support": "^0.5.6", "typescript": "^3.0.0" } diff --git a/src/index.ts b/src/index.ts index 8f5fac8..ddf03c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ -/** - * Copyright 2014 Google Inc. All Rights Reserved. +/*! + * Copyright 2019 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,630 @@ * limitations under the License. */ -export async function date() { - console.warn('👻'); +const FULL_ISO_REG = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d{4,9}Z/; +const NO_BIG_INT = + 'BigInt only available in Node >= v10.7. Consider using getFullTimeString instead.'; + +export type DateTuple = [number, number]; + +export interface DateStruct { + seconds: number; + nanos: number; +} + +// https://github.com/Microsoft/TypeScript/issues/27920 +type DateFields = [number, number, number, number, number, number, number]; + +interface Long { + toNumber(): number; +} + +interface ProtobufDate { + seconds?: number|string|Long; + nanos?: number|string; +} + +enum Sign { + NEGATIVE = -1, + POSITIVE = 1, + ZERO = 0 +} + +/** + * The native Date object. + * @external Date + * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date} + */ +/** + * @typedef {array} DateTuple + * @property {number} 0 Represents seconds of UTC time since Unix epoch + * 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to + * 9999-12-31T23:59:59Z inclusive. + * @property {number} 1 Non-negative fractions of a second at nanosecond + * resolution. Negative second values with fractions must still have + * non-negative nanos values that count forward in time. Must be from 0 to + * 999,999,999 inclusive. + */ +/** + * @typedef {object} DateStruct + * @property {number} seconds Represents seconds of UTC time since Unix epoch + * 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to + * 9999-12-31T23:59:59Z inclusive. + * @property {number} nanos Non-negative fractions of a second at nanosecond + * resolution. Negative second values with fractions must still have + * non-negative nanos values that count forward in time. Must be from 0 to + * 999,999,999 inclusive. + */ +/** + * Date object with nanosecond precision. Supports all standard Date arguments + * in addition to several custom types as noted below. + * + * @class + * @extends external:Date + * + * @param {number|string|bigint|Date|DateTuple|DateStruct} [time] The time + * value. + * @param {...number} [dateFields] Additional date fields (month, date, hours, + * minutes, seconds, milliseconds, microseconds, nanoseconds). + * + * @example With a RFC 3339 formatted string. + * const date = new PreciseDate('2019-02-08T10:34:29.481145231Z'); + * + * @example With a nanosecond timestamp string. + * const date = new PreciseDate('1549622069481320032'); + * + * @example With a BigInt (requires Node >= v10.7) + * const date = new PreciseDate(1549622069481320032n); + * + * @example With a tuple containing seconds and nanoseconds. + * const date = new PreciseDate([1549622069, 481320032]); + * + * @example With an object containing `seconds` and `nanos` + * const date = new PreciseDate({seconds: 1549622069, nanos: 481320032}); + * + * @example Specifiying date fields + * const date = new PreciseDate(2018, 5, 14, 41, 11, 34, 123, 874, 321); + */ +export class PreciseDate extends Date { + private _micros = 0; + private _nanos = 0; + constructor(time?: number|Date); + constructor(preciseTime: string|bigint|DateTuple|ProtobufDate); + constructor( + year: number, month?: number, date?: number, hours?: number, + minutes?: number, seconds?: number, milliseconds?: number, + microseconds?: number, nanoseconds?: number); + constructor(time?: number|string|bigint|Date|DateTuple|ProtobufDate) { + super(); + + if (time && typeof time !== 'number' && !(time instanceof Date)) { + this.setFullTime(PreciseDate.parseFull(time)); + return; + } + + const args: number[] = Array.from(arguments); + const dateFields = args.slice(0, 7) as DateFields; + const date = new Date(...dateFields); + const nanos = args.length === 9 ? args.pop()! : 0; + const micros = args.length === 8 ? args.pop()! : 0; + + this.setTime(date.getTime()); + this.setMicroseconds(micros); + this.setNanoseconds(nanos); + } + /** + * Returns the specified date represented in nanoseconds according to + * universal time. + * + * **NOTE:** Because this method returns a `BigInt` it requires Node >= v10.7. + * Use {@link PreciseDate#getFullTimeString} to get the time as a string. + * + * @see {@link https://github.com/tc39/proposal-bigint|BigInt} + * + * @throws {error} If `BigInt` is unavailable. + * @returns {bigint} + * + * @example + * const date = new PreciseDate('2019-02-08T10:34:29.481145231Z'); + * + * console.log(date.getFullTime()); + * // expected output: 1549622069481145231n + */ + getFullTime(): bigint { + if (typeof BigInt !== 'function') { + throw new Error(NO_BIG_INT); + } + + return BigInt(this.getFullTimeString()); + } + /** + * Returns a string of the specified date represented in nanoseconds according + * to universal time. + * + * @returns {string} + * + * @example + * const date = new PreciseDate('2019-02-08T10:34:29.481145231Z'); + * + * console.log(date.getFullTimeString()); + * // expected output: "1549622069481145231" + */ + getFullTimeString(): string { + const seconds = this._getSeconds(); + let nanos = this._getNanos(); + + if (nanos && Math.sign(seconds) === Sign.NEGATIVE) { + nanos = 1e9 - nanos; + } + + return `${seconds}${padLeft(nanos, 9)}`; + } + /** + * Returns the microseconds in the specified date according to universal time. + * + * @returns {number} + * + * @example + * const date = new PreciseDate('2019-02-08T10:34:29.481145Z'); + * + * console.log(date.getMicroseconds()); + * // expected output: 145 + */ + getMicroseconds(): number { + return this._micros; + } + /** + * Returns the nanoseconds in the specified date according to universal time. + * + * @returns {number} + * + * @example + * const date = new PreciseDate('2019-02-08T10:34:29.481145231Z'); + * + * console.log(date.getNanoseconds()); + * // expected output: 231 + */ + getNanoseconds(): number { + return this._nanos; + } + /** + * Sets the microseconds for a specified date according to universal time. + * + * @param {number} microseconds A number representing the microseconds. + * @returns {string} Returns a string representing the nanoseconds in the + * specified date according to universal time. + * + * @example + * const date = new PreciseDate(); + * + * date.setMicroseconds(149); + * + * console.log(date.getMicroseconds()); + * // expected output: 149 + */ + setMicroseconds(micros: number): string { + const abs = Math.abs(micros); + let millis = this.getUTCMilliseconds(); + + if (abs >= 1000) { + millis += Math.floor(abs / 1000) * Math.sign(micros); + micros %= 1000; + } + + if (Math.sign(micros) === Sign.NEGATIVE) { + millis -= 1; + micros += 1000; + } + + this._micros = micros; + this.setUTCMilliseconds(millis); + + return this.getFullTimeString(); + } + /** + * Sets the nanoseconds for a specified date according to universal time. + * + * @param {number} nanoseconds A number representing the nanoseconds. + * @returns {string} Returns a string representing the nanoseconds in the + * specified date according to universal time. + * + * @example + * const date = new PreciseDate(); + * + * date.setNanoseconds(231); + * + * console.log(date.getNanoseconds()); + * // expected output: 231 + */ + setNanoseconds(nanos: number): string { + const abs = Math.abs(nanos); + let micros = this._micros; + + if (abs >= 1000) { + micros += Math.floor(abs / 1000) * Math.sign(nanos); + nanos %= 1000; + } + + if (Math.sign(nanos) === Sign.NEGATIVE) { + micros -= 1; + nanos += 1000; + } + + this._nanos = nanos; + + return this.setMicroseconds(micros); + } + /** + * Sets the PreciseDate object to the time represented by a number of + * nanoseconds since January 1, 1970, 00:00:00 UTC. + * + * @param {bigint|number|string} time Value representing the number of + * nanoseconds since January 1, 1970, 00:00:00 UTC. + * @returns {string} Returns a string representing the nanoseconds in the + * specified date according to universal time (effectively, the value of + * the argument). + * + * @see {@link https://github.com/tc39/proposal-bigint|BigInt} + * + * @example With a nanosecond string. + * const date = new PreciseDate(); + * date.setFullTime('1549622069481145231'); + * + * @example With a BigInt + * date.setFullTime(1549622069481145231n); + */ + setFullTime(time: string|number|bigint): string { + if (typeof time !== 'string') { + time = time.toString(); + } + + const sign = Math.sign(Number(time)); + + time = time.replace(/^\-/, ''); + + const seconds = Number(time.substr(0, time.length - 9)) * sign; + const nanos = Number(time.substr(-9)) * sign; + + this.setTime(seconds * 1000); + return this.setNanoseconds(nanos); + } + /** + * Sets the PreciseDate object to the time represented by a number of + * milliseconds since January 1, 1970, 00:00:00 UTC. Calling this method will + * reset both the microseconds and nanoseconds to 0. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/setTime|Date#setTime} + * + * @param {number} time Value representing the number of milliseconds since + * January 1, 1970, 00:00:00 UTC. + * @returns {string} The number of milliseconds between January 1, 1970, + * 00:00:00 UTC and the updated date (effectively, the value of the + * argument). + */ + setTime(time: number): number { + this._micros = 0; + this._nanos = 0; + return super.setTime(time); + } + /** + * Returns a string in RFC 3339 format. Unlike the native `Date#toISOString`, + * this will return 9 digits to represent sub-second precision. + * + * @see {@link https://tools.ietf.org/html/rfc3339|RFC 3339} + * + * @returns {string} + * + * @example + * const date = new PreciseDate(1549622069481145231n); + * + * console.log(date.toISOString()); + * // expected output: "2019-02-08T10:34:29.481145231Z" + */ + toISOString(): string { + const micros = padLeft(this._micros, 3); + const nanos = padLeft(this._nanos, 3); + return super.toISOString().replace(/z$/i, `${micros}${nanos}Z`); + } + /** + * Returns an object representing the specified date according to universal + * time. + * + * @see {@link https://developers.google.com/protocol-buffers/docs/reference/google.protobuf#timestamp|google.protobuf.Timestamp} + * + * @returns {DateStruct} + * + * @example + * const date = new PreciseDate('2019-02-08T10:34:29.481145231Z'); + * + * console.log(date.toStruct()); + * // expected output: {seconds: 1549622069, nanos: 481145231} + */ + toStruct(): DateStruct { + let seconds = this._getSeconds(); + const nanos = this._getNanos(); + const sign = Math.sign(seconds); + + // These objects are essentially a mirror of protobuf timestamps. + // `nanos` must always count forward in time, even if the date is <= Unix + // epoch. To do this we just need to count backwards 1 second and return the + // nanoseconds as is. + if (sign === Sign.NEGATIVE && nanos) { + seconds -= 1; + } + + return {seconds, nanos}; + } + /** + * Returns a tuple representing the specified date according to universal + * time. + * + * @returns {DateTuple} + * + * @example + * const date = new PreciseDate('2019-02-08T10:34:29.481145231Z'); + * + * console.log(date.toTuple()); + * // expected output: [1549622069, 481145231] + */ + toTuple(): DateTuple { + const {seconds, nanos} = this.toStruct(); + return [seconds, nanos]; + } + /** + * Returns the total number of seconds in the specified date since Unix epoch. + * Numbers representing < epoch will be negative. + * + * @private + * + * @returns {number} + */ + private _getSeconds(): number { + const time = this.getTime(); + const sign = Math.sign(time); + return Math.floor(Math.abs(time) / 1000) * sign; + } + /** + * Returns the sub-second precision of the specified date. This will always be + * a positive number. + * + * @private + * + * @returns {number} + */ + private _getNanos(): number { + const msInNanos = this.getUTCMilliseconds() * 1e6; + const microsInNanos = this._micros * 1000; + return this._nanos + msInNanos + microsInNanos; + } + /** + * Parses a precise time. + * + * @static + * + * @param {string|bigint|DateTuple|DateStruct} time The precise time value. + * @returns {string} Returns a string representing the nanoseconds in the + * specified date according to universal time. + * + * @example From a RFC 3339 formatted string. + * const time = PreciseDate.parseFull('2019-02-08T10:34:29.481145231Z'); + * console.log(time); // expected output: "1549622069481145231" + * + * @example From a nanosecond timestamp string. + * const time = PreciseDate.parseFull('1549622069481145231'); + * console.log(time); // expected output: "1549622069481145231" + * + * @example From a BigInt (requires Node >= v10.7) + * const time = PreciseDate.parseFull(1549622069481145231n); + * console.log(time); // expected output: "1549622069481145231" + * + * @example From a tuple. + * const time = PreciseDate.parseFull([1549622069, 481145231]); + * console.log(time); // expected output: "1549622069481145231" + * + * @example From an object. + * const struct = {seconds: 1549622069, nanos: 481145231}; + * const time = PreciseDate.parseFull(struct); + * console.log(time); // expected output: "1549622069481145231" + */ + static parseFull(time: string|bigint|DateTuple|ProtobufDate): string { + const date = new PreciseDate(); + + if (Array.isArray(time)) { + const [seconds, nanos] = time as DateTuple; + time = {seconds, nanos} as DateStruct; + } + + if (isFullTime(time)) { + date.setFullTime(time as string | bigint); + } else if (isStruct(time)) { + const {seconds, nanos} = parseProto(time as ProtobufDate); + date.setTime(seconds * 1000); + date.setNanoseconds(nanos); + } else if (isFullISOString(time)) { + date.setFullTime(parseFullISO(time as string)); + } else { + date.setTime(new Date(time as string).getTime()); + } + + return date.getFullTimeString(); + } + /** + * Accepts the same number parameters as the PreciseDate constructor, but + * treats them as UTC. It returns a string that represents the number of + * nanoseconds since January 1, 1970, 00:00:00 UTC. + * + * **NOTE:** Because this method returns a `BigInt` it requires Node >= v10.7. + * + * @see {@link https://github.com/tc39/proposal-bigint|BigInt} + * + * @static + * + * @throws {error} If `BigInt` is unavailable. + * + * @param {...number} [dateFields] The date fields. + * @returns {bigint} + * + * @example + * const time = PreciseDate.fullUTC(2019, 1, 8, 10, 34, 29, 481, 145, 231); + * console.log(time); // expected output: 1549622069481145231n + */ + static fullUTC(...args: number[]): bigint { + if (typeof BigInt !== 'function') { + throw new Error(NO_BIG_INT); + } + + return BigInt(PreciseDate.fullUTCString(...args)); + } + /** + * Accepts the same number parameters as the PreciseDate constructor, but + * treats them as UTC. It returns a string that represents the number of + * nanoseconds since January 1, 1970, 00:00:00 UTC. + * + * @static + * + * @param {...number} [dateFields] The date fields. + * @returns {string} + * + * @example + * const time = PreciseDate.fullUTCString(2019, 1, 8, 10, 34, 29, 481, 145, + * 231); console.log(time); // expected output: '1549622069481145231' + */ + static fullUTCString(...args: number[]): string { + const milliseconds = Date.UTC(...args.slice(0, 7) as DateFields); + const date = new PreciseDate(milliseconds); + + if (args.length === 9) { + date.setNanoseconds(args.pop()!); + } + + if (args.length === 8) { + date.setMicroseconds(args.pop()!); + } + + return date.getFullTimeString(); + } +} + +/** + * Parses a RFC 3339 formatted string representation of the date, and returns + * a string representing the nanoseconds since January 1, 1970, 00:00:00. + * + * @private + * + * @param {string} time The RFC 3339 formatted string. + * @returns {string} + */ +function parseFullISO(time: string): string { + let digits = '0'; + + time = time.replace(/\.(\d+)/, ($0, $1) => { + digits = $1; + return '.000'; + }); + + const nanos = Number(padRight(digits, 9)); + const date = new PreciseDate(time); + + return date.setNanoseconds(nanos); +} + +/** + * Normalizes a {@link google.protobuf.Timestamp} object. + * + * @private + * + * @param {google.protobuf.Timestamp} timestamp The timestamp object. + * @returns {DateStruct} + */ +function parseProto({seconds = 0, nanos = 0}: ProtobufDate): DateStruct { + if (typeof (seconds as Long).toNumber === 'function') { + seconds = (seconds as Long).toNumber(); + } + + seconds = Number(seconds); + nanos = Number(nanos); + + return {seconds, nanos}; +} + +/** + * Checks to see if time value is specified in nanoseconds. We assume that all + * BigInt and string timestamps represent nanoseconds. + * + * @private + * + * @param {*} time The time to check. + * @returns {boolean} + */ +function isFullTime(time: unknown): boolean { + return typeof time === 'bigint' || + typeof time === 'string' && /^\d+$/.test(time); +} + +/** + * Checks to see if time value is a {@link DateStruct}. + * + * @private + * + * @param {*} time The time to check. + * @returns {boolean} + */ +function isStruct(time: unknown): time is DateStruct { + return typeof time === 'object' && + typeof (time as DateStruct).seconds !== 'undefined' || + typeof (time as DateStruct).nanos === 'number'; +} + +/** + * Checks to see if the time value is a RFC 3339 formatted string. + * + * @private + * + * @param {*} time The time to check. + * @returns {boolean} + */ +function isFullISOString(time: unknown): boolean { + return typeof time === 'string' && FULL_ISO_REG.test(time); +} + +/** + * Pads a number/string with "0" to the left. + * + * @private + * + * @param {string|number} n The number/string to pad. + * @param {number} min The min size of the padded string. + * @returns {string} + */ +function padLeft(n: number|string, min: number): string { + const padding = getPadding(n, min); + return `${padding}${n}`; +} + +/** + * Pads a number/string with "0" to the right. + * + * @private + * + * @param {string|number} n The number/string to pad. + * @param {number} min The min size of the padded string. + * @returns {string} + */ +function padRight(n: number|string, min: number): string { + const padding = getPadding(n, min); + return `${n}${padding}`; +} + +/** + * Creates padding based on current size and min size needed. + * + * @private + * + * @param {string|number} n The number/string to pad. + * @param {number} [min=3] The min size of the padded string. + * @returns {string} + */ +function getPadding(n: number|string, min: number): string { + const size = Math.max(min - n.toString().length, 0); + return '0'.repeat(size); } diff --git a/test/index.ts b/test/index.ts index deded35..c5d682b 100644 --- a/test/index.ts +++ b/test/index.ts @@ -1,5 +1,5 @@ -/** - * Copyright 2014 Google Inc. All Rights Reserved. +/*! + * Copyright 2019 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,4 +16,461 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; -import * as util from '../src'; + +import {DateTuple, PreciseDate} from '../src'; + +class FakeBigInt { + value: number|string; + constructor(value: number|string) { + this.value = value; + } +} + +class FakeLong { + value: number; + constructor(value: number) { + this.value = value; + } + toNumber(): number { + return this.value; + } +} + +describe('PreciseDate', () => { + const hasNativeBigInt = typeof BigInt === 'function'; + const sandbox = sinon.createSandbox(); + + // tslint:disable-next-line variable-name + let RealBigInt: typeof BigInt; + let date: PreciseDate; + + const NO_BIG_INT_ERR = + /BigInt only available in Node \>\= v10\.7. Consider using getFullTimeString instead\./; + + const SECS = 1547253035; + const NANOS = 381101032; + + const FULL_ISO_STRING = `2019-01-12T00:30:35.381101032Z`; + const TIME_STRING = `${SECS}${NANOS}`; + + const LOCAL_DATE = new Date(FULL_ISO_STRING); + + const YEAR = LOCAL_DATE.getFullYear(); + const MONTH = LOCAL_DATE.getMonth(); + const DAY = LOCAL_DATE.getDate(); + const HOURS = LOCAL_DATE.getHours(); + const MINUTES = LOCAL_DATE.getMinutes(); + const SECONDS = LOCAL_DATE.getSeconds(); + const MILLISECONDS = 381; + const MICROSECONDS = 101; + const NANOSECONDS = 32; + + before(() => { + // tslint:disable-next-line no-any + RealBigInt = (global as any).BigInt; + }); + + beforeEach(() => { + // tslint:disable-next-line no-any + (global as any).BigInt = (value: number|string) => new FakeBigInt(value); + date = new PreciseDate(TIME_STRING); + }); + + after(() => { + // tslint:disable-next-line no-any + (global as any).BigInt = RealBigInt; + }); + + afterEach(() => sandbox.restore()); + + describe('#constructor()', () => { + it('should get the precise timestamp for non-(date|number) values', () => { + const fakeTimestamp = '123456789'; + const setStub = sandbox.stub(PreciseDate.prototype, 'setFullTime'); + + sandbox.stub(PreciseDate, 'parseFull') + .withArgs(TIME_STRING) + .returns(fakeTimestamp); + + const date = new PreciseDate(TIME_STRING); + const [timestamp] = setStub.lastCall.args; + assert.strictEqual(timestamp, fakeTimestamp); + }); + + it('should accept date fields', () => { + const timeStub = sandbox.stub(PreciseDate.prototype, 'setTime'); + const microsStub = sandbox.stub(PreciseDate.prototype, 'setMicroseconds'); + const nanosStub = sandbox.stub(PreciseDate.prototype, 'setNanoseconds'); + + const date = new PreciseDate( + YEAR, MONTH, DAY, HOURS, MINUTES, SECONDS, MILLISECONDS, MICROSECONDS, + NANOSECONDS); + + const [time] = timeStub.lastCall.args; + const [micros] = microsStub.lastCall.args; + const [nanos] = nanosStub.lastCall.args; + assert.strictEqual(time, LOCAL_DATE.getTime()); + assert.strictEqual(micros, MICROSECONDS); + assert.strictEqual(nanos, NANOSECONDS); + }); + }); + + describe('#getFullTime()', () => { + beforeEach(() => { + sandbox.stub(date, 'getFullTimeString').returns(TIME_STRING); + }); + + it('should throw an error if BigInt is unavailable', () => { + // tslint:disable-next-line no-any + delete (global as any).BigInt; + + assert.throws(() => date.getFullTime(), NO_BIG_INT_ERR); + }); + + it('should return the precise time as a BigInt', () => { + const expectedTimestamp = new FakeBigInt(TIME_STRING); + const timestamp = date.getFullTime(); + assert.deepStrictEqual(timestamp, expectedTimestamp); + }); + }); + + describe('#getFullTimeString()', () => { + it('should return the time as a string', () => { + const timestamp = date.getFullTimeString(); + assert.strictEqual(timestamp, TIME_STRING); + }); + + it('should pad the nanoseconds if needed', () => { + const nanos = 123; + const expectedTime = `${SECS}000000${nanos}`; + + const date = new PreciseDate({seconds: SECS, nanos}); + const timestamp = date.getFullTimeString(); + assert.strictEqual(timestamp, expectedTime); + }); + + it('should correctly return dates < Unix epoch', () => { + const expectedTime = `${- SECS + 1}${1e9 - NANOS}`; + + const date = new PreciseDate({seconds: -SECS, nanos: NANOS}); + const timestamp = date.getFullTimeString(); + assert.strictEqual(timestamp, expectedTime); + }); + + it('should correctly return (not so precise) dates < Unix epoch', () => { + const expectedTime = `-${SECS}000000000`; + + const date = new PreciseDate({seconds: -SECS, nanos: 0}); + const timestamp = date.getFullTimeString(); + assert.strictEqual(timestamp, expectedTime); + }); + }); + + describe('#getMicroseconds()', () => { + it('should return the microseconds', () => { + const microseconds = date.getMicroseconds(); + assert.strictEqual(microseconds, MICROSECONDS); + }); + }); + + describe('#getNanoseconds()', () => { + it('should return the nanoseconds', () => { + const nanoseconds = date.getNanoseconds(); + assert.strictEqual(nanoseconds, NANOSECONDS); + }); + }); + + describe('#setMicroseconds()', () => { + it('should set the microseconds', () => { + const micros = 912; + date.setMicroseconds(micros); + assert.strictEqual(date.getMicroseconds(), micros); + }); + + it('should return the precise time string', () => { + const fakeTimestamp = '123456789'; + sandbox.stub(date, 'getFullTimeString').returns(fakeTimestamp); + + const timestamp = date.setMicroseconds(0); + assert.strictEqual(timestamp, fakeTimestamp); + }); + + it('should update the milliseconds if abs(micros) >= 1000', () => { + const micros = 8123; + const expectedMicros = 123; + const expectedMilliseconds = MILLISECONDS + 8; + + date.setMicroseconds(micros); + assert.strictEqual(date.getMicroseconds(), expectedMicros); + assert.strictEqual(date.getUTCMilliseconds(), expectedMilliseconds); + }); + + it('should handle negative values', () => { + const micros = -541; + const expectedMicros = micros + 1000; + const expectedMilliseconds = MILLISECONDS - 1; + + date.setMicroseconds(micros); + assert.strictEqual(date.getMicroseconds(), expectedMicros); + assert.strictEqual(date.getUTCMilliseconds(), expectedMilliseconds); + }); + }); + + describe('#setNanoseconds()', () => { + it('should set the nanoseconds', () => { + const nanos = 728; + date.setNanoseconds(nanos); + assert.strictEqual(date.getNanoseconds(), nanos); + }); + + it('should return the precise time string', () => { + const fakeTimestamp = '123456789'; + sandbox.stub(date, 'setMicroseconds').returns(fakeTimestamp); + + const timestamp = date.setNanoseconds(0); + assert.strictEqual(timestamp, fakeTimestamp); + }); + + it('should update the microseconds if nanos >= 1000', () => { + const nanos = 7831; + const expectedNanos = 831; + const expectedMicros = MICROSECONDS + 7; + + date.setNanoseconds(nanos); + assert.strictEqual(date.getNanoseconds(), expectedNanos); + assert.strictEqual(date.getMicroseconds(), expectedMicros); + }); + + it('should handle negative values', () => { + const nanos = -913; + const expectedNanos = nanos + 1000; + const expectedMicros = MICROSECONDS - 1; + + date.setNanoseconds(nanos); + assert.strictEqual(date.getNanoseconds(), expectedNanos); + assert.strictEqual(date.getMicroseconds(), expectedMicros); + }); + }); + + describe('#setFullTime()', () => { + let timeStub: sinon.SinonStub; + let nanosStub: sinon.SinonStub; + + beforeEach(() => { + timeStub = sandbox.stub(date, 'setTime'); + nanosStub = sandbox.stub(date, 'setNanoseconds'); + }); + + it('should set the correct values', () => { + date.setFullTime(TIME_STRING); + + const [time] = timeStub.lastCall.args; + const [nanos] = nanosStub.lastCall.args; + assert.strictEqual(time, SECS * 1000); + assert.strictEqual(nanos, NANOS); + }); + + it('should return the precise time string', () => { + nanosStub.returns(TIME_STRING); + + const timestamp = date.setFullTime('0'); + assert.strictEqual(timestamp, TIME_STRING); + }); + + it('should handle small time values', () => { + const fakeTime = 1238123; + date.setFullTime(fakeTime); + + const [time] = timeStub.lastCall.args; + const [nanos] = nanosStub.lastCall.args; + assert.strictEqual(time, 0); + assert.strictEqual(nanos, fakeTime); + }); + + it('should handle negative time values', () => { + const expectedTime = -SECS * 1000; + const expectedNanos = -NANOS; + date.setFullTime(`-${TIME_STRING}`); + + const [time] = timeStub.lastCall.args; + const [nanos] = nanosStub.lastCall.args; + assert.strictEqual(time, expectedTime); + assert.strictEqual(nanos, expectedNanos); + }); + + it('should handle small negative time values', () => { + const fakeTime = -123456789; + date.setFullTime(fakeTime); + + const [time] = timeStub.lastCall.args; + const [nanos] = nanosStub.lastCall.args; + assert.strictEqual(Math.abs(time), 0); + assert.strictEqual(nanos, fakeTime); + }); + + (hasNativeBigInt ? it : it.skip)('should accept a BigInt', () => { + date.setFullTime(RealBigInt(TIME_STRING)); + + const [time] = timeStub.lastCall.args; + const [nanos] = nanosStub.lastCall.args; + assert.strictEqual(time, SECS * 1000); + assert.strictEqual(nanos, NANOS); + }); + }); + + describe('#setTime()', () => { + it('should clear the microseconds and nanoseconds', () => { + date.setTime(date.getTime()); + + assert.strictEqual(date.getMicroseconds(), 0); + assert.strictEqual(date.getNanoseconds(), 0); + }); + + it('should return the millisecond time', () => { + const expectedTime = date.getTime(); + const time = date.setTime(expectedTime); + + assert.strictEqual(time, expectedTime); + }); + }); + + describe('#toISOString()', () => { + it('should include the microseconds and nanoseconds', () => { + const isoString = date.toISOString(); + assert.strictEqual(isoString, FULL_ISO_STRING); + }); + }); + + describe('#toStruct()', () => { + it('should return the date as a struct', () => { + const {seconds, nanos} = date.toStruct(); + assert.strictEqual(seconds, SECS); + assert.strictEqual(nanos, NANOS); + }); + + it('should correctly return dates < Unix epoch', () => { + const expectedSeconds = -SECS - 1; + sandbox.stub(date, 'getTime').returns(-SECS * 1000); + + const {seconds, nanos} = date.toStruct(); + assert.strictEqual(seconds, expectedSeconds); + assert.strictEqual(nanos, NANOS); + }); + + it('should correctly return (not so precise) dates < Unix epoch', () => { + sandbox.stub(date, 'getTime').returns(-SECS * 1000); + + date.setUTCMilliseconds(0); + date.setMicroseconds(0); + date.setNanoseconds(0); + + const {seconds, nanos} = date.toStruct(); + assert.strictEqual(seconds, -SECS); + assert.strictEqual(nanos, 0); + }); + }); + + describe('#toTuple()', () => { + it('should return the date as a tuple', () => { + sandbox.stub(date, 'toStruct').returns({seconds: SECS, nanos: NANOS}); + + const [seconds, nanos] = date.toTuple(); + assert.strictEqual(seconds, SECS); + assert.strictEqual(nanos, NANOS); + }); + }); + + describe('.parseFull()', () => { + it('should parse a string timestamp', () => { + const timestamp = PreciseDate.parseFull(TIME_STRING); + assert.strictEqual(timestamp, TIME_STRING); + }); + + (hasNativeBigInt ? it : it.skip)('should parse a BigInt', () => { + const bigIntTimestamp = RealBigInt(TIME_STRING); + const timestamp = PreciseDate.parseFull(bigIntTimestamp); + assert.strictEqual(timestamp, TIME_STRING); + }); + + it('should parse a date struct', () => { + const struct = {seconds: SECS, nanos: NANOS}; + const timestamp = PreciseDate.parseFull(struct); + assert.strictEqual(timestamp, TIME_STRING); + }); + + it('should parse a protobuf Timestamp', () => { + const expectedTimestamp = `${SECS}000000000`; + const seconds = new FakeLong(SECS); + const timestamp = PreciseDate.parseFull({seconds}); + assert.strictEqual(timestamp, expectedTimestamp); + }); + + it('should parse a date tuples', () => { + const tuple: DateTuple = [SECS, NANOS]; + const timestamp = PreciseDate.parseFull(tuple); + assert.strictEqual(timestamp, TIME_STRING); + }); + + it('should parse a complete ISO string', () => { + const timestamp = PreciseDate.parseFull(FULL_ISO_STRING); + assert.strictEqual(timestamp, TIME_STRING); + }); + + it('should parse a micro precise ISO string', () => { + const microIsoString = `2019-01-12T00:30:35.381101Z`; + const expectedTimestamp = `${SECS}${MILLISECONDS}${MICROSECONDS}000`; + const timestamp = PreciseDate.parseFull(microIsoString); + assert.strictEqual(timestamp, expectedTimestamp); + }); + + it('should get millisecond precision for anything else', () => { + const date = new Date(FULL_ISO_STRING); + const expectedTimestamp = `${date.getTime()}000000`; + const timestamp = PreciseDate.parseFull(date.toISOString()); + assert.strictEqual(timestamp, expectedTimestamp); + }); + }); + + describe('.fullUTC()', () => { + it('should throw an error if BigInt is unavailable', () => { + // tslint:disable-next-line no-any + delete (global as any).BigInt; + + assert.throws(() => PreciseDate.fullUTC(), NO_BIG_INT_ERR); + }); + + it('should accept microseconds and nanoseconds', () => { + const expectedTimestamp = new FakeBigInt(TIME_STRING); + const dateFields = [ + YEAR, + MONTH, + DAY, + HOURS, + MINUTES, + SECONDS, + MILLISECONDS, + MICROSECONDS, + NANOSECONDS, + ]; + + sandbox.stub(PreciseDate, 'fullUTCString') + .withArgs(...dateFields) + .returns(TIME_STRING); + + const timestamp = PreciseDate.fullUTC(...dateFields); + assert.deepStrictEqual(timestamp, expectedTimestamp); + }); + }); + + describe('.fullUTCString()', () => { + it('should accept microseconds and nanoseconds', () => { + const utcDate = PreciseDate.fullUTCString( + date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), + date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), + date.getUTCMilliseconds(), date.getMicroseconds(), + date.getNanoseconds()); + + assert.deepStrictEqual(utcDate, date.getFullTimeString()); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 371d4fa..5ff8f94 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,10 @@ "compilerOptions": { "rootDir": ".", "outDir": "build", + "lib": [ + "es2016", + "esnext.bigint" + ] }, "include": [ "src/*.ts",