diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index 5530b890f..c11eaa9bf 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -13,6 +13,7 @@ "esdoc": "^1.1.0", "esdoc-importpath-plugin": "^1.0.2", "esdoc-standard-plugin": "^1.0.0", + "fast-check": "^3.1.3", "jest": "^27.5.1", "ts-jest": "^27.1.4", "ts-node": "^10.3.0", @@ -2941,6 +2942,22 @@ ], "optional": true }, + "node_modules/fast-check": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.1.3.tgz", + "integrity": "sha512-IFY7xJrOUktiC1ZnaJdrinaRpFgDZtURRPwzAiOhL8eyt2NbBTHNF1CO7vZUla1BoUeJVI7gLnTQA+Lko0T2dQ==", + "dev": true, + "dependencies": { + "pure-rand": "^5.0.1" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6108,6 +6125,16 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-5.0.3.tgz", + "integrity": "sha512-9N8x1h8dptBQpHyC7aZMS+iNOAm97WMGY0AFrguU1cpfW3I5jINkWe5BIY5md0ofy+1TCIELsVcm/GJXZSaPbw==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + }, "node_modules/qs": { "version": "6.5.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", @@ -9622,6 +9649,15 @@ "dev": true, "optional": true }, + "fast-check": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.1.3.tgz", + "integrity": "sha512-IFY7xJrOUktiC1ZnaJdrinaRpFgDZtURRPwzAiOhL8eyt2NbBTHNF1CO7vZUla1BoUeJVI7gLnTQA+Lko0T2dQ==", + "dev": true, + "requires": { + "pure-rand": "^5.0.1" + } + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -12090,6 +12126,12 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", "dev": true }, + "pure-rand": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-5.0.3.tgz", + "integrity": "sha512-9N8x1h8dptBQpHyC7aZMS+iNOAm97WMGY0AFrguU1cpfW3I5jINkWe5BIY5md0ofy+1TCIELsVcm/GJXZSaPbw==", + "dev": true + }, "qs": { "version": "6.5.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", diff --git a/packages/core/package.json b/packages/core/package.json index bd6b8fcce..55f34166b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,6 +34,7 @@ "esdoc": "^1.1.0", "esdoc-importpath-plugin": "^1.0.2", "esdoc-standard-plugin": "^1.0.0", + "fast-check": "^3.1.3", "jest": "^27.5.1", "ts-jest": "^27.1.4", "ts-node": "^10.3.0", diff --git a/packages/core/src/internal/temporal-util.ts b/packages/core/src/internal/temporal-util.ts index f23e43884..474fdefff 100644 --- a/packages/core/src/internal/temporal-util.ts +++ b/packages/core/src/internal/temporal-util.ts @@ -288,16 +288,7 @@ export function dateToIsoString ( month: NumberOrInteger | string, day: NumberOrInteger | string ): string { - year = int(year) - const isNegative = year.isNegative() - if (isNegative) { - year = year.multiply(-1) - } - let yearString = formatNumber(year, 4) - if (isNegative) { - yearString = '-' + yearString - } - + const yearString = formatYear(year) const monthString = formatNumber(month, 2) const dayString = formatNumber(day, 2) return `${yearString}-${monthString}-${dayString}` @@ -313,6 +304,25 @@ export function isoStringToStandardDate (isoString: string): Date { return new Date(isoString) } +/** + * Convert the given utc timestamp to a JavaScript Date object + * + * @param {number} utc Timestamp in UTC + * @returns {Date} the date + */ +export function toStandardDate (utc: number): Date { + return new Date(utc) +} + +/** + * Shortcut for creating a new StandardDate + * @param date + * @returns {Date} the standard date + */ +export function newDate (date: string | number | Date): Date { + return new Date(date) +} + /** * Get the total number of nanoseconds from the milliseconds of the given standard JavaScript date and optional nanosecond part. * @param {global.Date} standardDate the standard JavaScript date. @@ -341,11 +351,14 @@ export function totalNanoseconds ( * @return {number} the time zone offset in seconds. */ export function timeZoneOffsetInSeconds (standardDate: Date): number { + const secondsPortion = standardDate.getSeconds() >= standardDate.getUTCSeconds() + ? standardDate.getSeconds() - standardDate.getUTCSeconds() + : standardDate.getSeconds() - standardDate.getUTCSeconds() + 60 const offsetInMinutes = standardDate.getTimezoneOffset() if (offsetInMinutes === 0) { - return 0 + return 0 + secondsPortion } - return -1 * offsetInMinutes * SECONDS_PER_MINUTE + return -1 * offsetInMinutes * SECONDS_PER_MINUTE + secondsPortion } /** @@ -576,6 +589,19 @@ function formatNanosecond (value: NumberOrInteger | string): string { return value.equals(0) ? '' : '.' + formatNumber(value, 9) } +/** + * + * @param {Integer|number|string} year The year to be formatted + * @return {string} formatted year + */ +function formatYear (year: NumberOrInteger | string): string { + const yearInteger = int(year) + if (yearInteger.isNegative() || yearInteger.greaterThan(9999)) { + return formatNumber(yearInteger, 6, { usePositiveSign: true }) + } + return formatNumber(yearInteger, 4) +} + /** * @param {Integer|number|string} num the number to format. * @param {number} [stringLength=undefined] the string length to left-pad to. @@ -583,7 +609,10 @@ function formatNanosecond (value: NumberOrInteger | string): string { */ function formatNumber ( num: NumberOrInteger | string, - stringLength?: number + stringLength?: number, + params?: { + usePositiveSign?: boolean + } ): string { num = int(num) const isNegative = num.isNegative() @@ -598,7 +627,12 @@ function formatNumber ( numString = '0' + numString } } - return isNegative ? '-' + numString : numString + if (isNegative) { + return '-' + numString + } else if (params?.usePositiveSign === true) { + return '+' + numString + } + return numString } function add (x: NumberOrInteger, y: number): NumberOrInteger { diff --git a/packages/core/src/temporal-types.ts b/packages/core/src/temporal-types.ts index 36281048e..974f97c16 100644 --- a/packages/core/src/temporal-types.ts +++ b/packages/core/src/temporal-types.ts @@ -667,16 +667,7 @@ export class DateTime { * @throws {Error} If the time zone offset is not defined in the object. */ toStandardDate (): StandardDate { - if (this.timeZoneOffsetSeconds === undefined) { - throw new Error('Requires DateTime created with time zone offset') - } - return util.isoStringToStandardDate( - // the timezone name should be removed from the - // string, otherwise the javascript parse doesn't - // read the datetime correctly - this.toString().replace( - this.timeZoneId != null ? `[${this.timeZoneId}]` : '', '') - ) + return util.toStandardDate(this._toUTC()) } /** @@ -703,6 +694,32 @@ export class DateTime { return localDateTimeStr + timeOffset + timeZoneStr } + + /** + * @private + * @returns {number} + */ + private _toUTC (): number { + if (this.timeZoneOffsetSeconds === undefined) { + throw new Error('Requires DateTime created with time zone offset') + } + const epochSecond = util.localDateTimeToEpochSecond( + this.year, + this.month, + this.day, + this.hour, + this.minute, + this.second, + this.nanosecond + ) + + const utcSecond = epochSecond.subtract(this.timeZoneOffsetSeconds ?? 0) + + return int(utcSecond) + .multiply(1000) + .add(int(this.nanosecond).div(1_000_000)) + .toNumber() + } } Object.defineProperty( diff --git a/packages/core/test/temporal-types.test.ts b/packages/core/test/temporal-types.test.ts index 10de7564c..6c41fe02b 100644 --- a/packages/core/test/temporal-types.test.ts +++ b/packages/core/test/temporal-types.test.ts @@ -19,6 +19,12 @@ import { StandardDate } from '../src/graph-types' import { LocalDateTime, Date, DateTime } from '../src/temporal-types' +import { temporalUtil } from '../src/internal' +import fc from 'fast-check' + +const MIN_UTC_IN_MS = -8_640_000_000_000_000 +const MAX_UTC_IN_MS = 8_640_000_000_000_000 +const ONE_DAY_IN_MS = 86_400_000 describe('Date', () => { describe('.toStandardDate()', () => { @@ -33,15 +39,26 @@ describe('Date', () => { }) it('should be the reverse operation of fromStandardDate but losing time information', () => { - const standardDate = new global.Date() - - const date = Date.fromStandardDate(standardDate) - const receivedDate = date.toStandardDate() - - // Setting 00:00:00:000 UTC - standardDate.setHours(0, -1 * standardDate.getTimezoneOffset(), 0, 0) - - expect(receivedDate).toEqual(standardDate) + fc.assert( + fc.property( + fc.date({ + max: temporalUtil.newDate(MAX_UTC_IN_MS - ONE_DAY_IN_MS), + min: temporalUtil.newDate(MIN_UTC_IN_MS + ONE_DAY_IN_MS) + }), + standardDate => { + const date = Date.fromStandardDate(standardDate) + const receivedDate = date.toStandardDate() + + const adjustedDateTime = temporalUtil.newDate(standardDate) + adjustedDateTime.setHours(0, offset(receivedDate)) + + expect(receivedDate.getFullYear()).toEqual(adjustedDateTime.getFullYear()) + expect(receivedDate.getMonth()).toEqual(adjustedDateTime.getMonth()) + expect(receivedDate.getDate()).toEqual(adjustedDateTime.getDate()) + expect(receivedDate.getHours()).toEqual(adjustedDateTime.getHours()) + expect(receivedDate.getMinutes()).toEqual(adjustedDateTime.getMinutes()) + }) + ) }) }) }) @@ -63,12 +80,14 @@ describe('LocalDateTime', () => { }) it('should be the reverse operation of fromStandardDate', () => { - const date = new global.Date() - - const localDatetime = LocalDateTime.fromStandardDate(date) - const receivedDate = localDatetime.toStandardDate() - - expect(receivedDate).toEqual(date) + fc.assert( + fc.property(fc.date(), (date) => { + const localDatetime = LocalDateTime.fromStandardDate(date) + const receivedDate = localDatetime.toStandardDate() + + expect(receivedDate).toEqual(date) + }) + ) }) }) }) @@ -135,12 +154,14 @@ describe('DateTime', () => { }) it('should be the reverse operation of fromStandardDate', () => { - const date = new global.Date() - - const datetime = DateTime.fromStandardDate(date) - const receivedDate = datetime.toStandardDate() - - expect(receivedDate).toEqual(date) + fc.assert( + fc.property(fc.date(), (date) => { + const datetime = DateTime.fromStandardDate(date) + const receivedDate = datetime.toStandardDate() + + expect(receivedDate).toEqual(date) + }) + ) }) }) }) diff --git a/packages/neo4j-driver/test/internal/temporal-util.test.js b/packages/neo4j-driver/test/internal/temporal-util.test.js index 0d696c2f2..95b4430e6 100644 --- a/packages/neo4j-driver/test/internal/temporal-util.test.js +++ b/packages/neo4j-driver/test/internal/temporal-util.test.js @@ -90,17 +90,17 @@ describe('#unit temporal-util', () => { it('should convert date to ISO string', () => { expect(util.dateToIsoString(90, 2, 5)).toEqual('0090-02-05') expect(util.dateToIsoString(int(1), 1, int(1))).toEqual('0001-01-01') - expect(util.dateToIsoString(-123, int(12), int(23))).toEqual('-0123-12-23') + expect(util.dateToIsoString(-123, int(12), int(23))).toEqual('-000123-12-23') expect(util.dateToIsoString(int(-999), int(9), int(10))).toEqual( - '-0999-09-10' + '-000999-09-10' ) expect(util.dateToIsoString(1999, 12, 19)).toEqual('1999-12-19') expect(util.dateToIsoString(int(2023), int(8), int(16))).toEqual( '2023-08-16' ) - expect(util.dateToIsoString(12345, 12, 31)).toEqual('12345-12-31') + expect(util.dateToIsoString(12345, 12, 31)).toEqual('+012345-12-31') expect(util.dateToIsoString(int(19191919), int(11), int(30))).toEqual( - '19191919-11-30' + '+19191919-11-30' ) expect(util.dateToIsoString(-909090, 9, 9)).toEqual('-909090-09-09') expect(util.dateToIsoString(int(-888999777), int(7), int(26))).toEqual( diff --git a/packages/neo4j-driver/test/temporal-types.test.js b/packages/neo4j-driver/test/temporal-types.test.js index 39733e8fe..1c5309182 100644 --- a/packages/neo4j-driver/test/temporal-types.test.js +++ b/packages/neo4j-driver/test/temporal-types.test.js @@ -590,8 +590,8 @@ describe('#integration temporal-types', () => { it('should convert Date to ISO string', () => { expect(date(2015, 10, 12).toString()).toEqual('2015-10-12') expect(date(881, 1, 1).toString()).toEqual('0881-01-01') - expect(date(-999, 12, 24).toString()).toEqual('-0999-12-24') - expect(date(-9, 1, 1).toString()).toEqual('-0009-01-01') + expect(date(-999, 12, 24).toString()).toEqual('-000999-12-24') + expect(date(-9, 1, 1).toString()).toEqual('-000009-01-01') }, 60000) it('should convert LocalDateTime to ISO string', () => { @@ -599,7 +599,7 @@ describe('#integration temporal-types', () => { '1992-11-08T09:42:17.000000022' ) expect(localDateTime(-10, 7, 15, 8, 15, 33, 500).toString()).toEqual( - '-0010-07-15T08:15:33.000000500' + '-000010-07-15T08:15:33.000000500' ) expect(localDateTime(0, 1, 1, 0, 0, 0, 1).toString()).toEqual( '0000-01-01T00:00:00.000000001' @@ -615,7 +615,7 @@ describe('#integration temporal-types', () => { ).toEqual('0001-02-03T04:05:06.000000007-13:42:56') expect( dateTimeWithZoneOffset(-3, 3, 9, 9, 33, 27, 999000, 15300).toString() - ).toEqual('-0003-03-09T09:33:27.000999000+04:15') + ).toEqual('-000003-03-09T09:33:27.000999000+04:15') }, 60000) it('should convert DateTime with time zone id to ISO-like string', () => { @@ -642,7 +642,7 @@ describe('#integration temporal-types', () => { 123, 'Asia/Yangon' ).toString() - ).toEqual('-30455-05-05T12:24:10.000000123[Asia/Yangon]') + ).toEqual('-030455-05-05T12:24:10.000000123[Asia/Yangon]') }, 60000) it('should expose local time components in time', () => {