diff --git a/packages/bolt-connection/src/bolt/bolt-protocol-v4x3.js b/packages/bolt-connection/src/bolt/bolt-protocol-v4x3.js index 8d99f572e..0a935274c 100644 --- a/packages/bolt-connection/src/bolt/bolt-protocol-v4x3.js +++ b/packages/bolt-connection/src/bolt/bolt-protocol-v4x3.js @@ -19,6 +19,8 @@ import BoltProtocolV42 from './bolt-protocol-v4x2' import RequestMessage from './request-message' import { RouteObserver } from './stream-observers' +import RequestMessage from './request-message' +import { LoginObserver } from './stream-observers' import { internal } from 'neo4j-driver-core' @@ -65,4 +67,49 @@ export default class BoltProtocol extends BoltProtocolV42 { return observer } + + /** + * Initialize a connection with the server + * + * @param {Object} param0 The params + * @param {string} param0.userAgent The user agent + * @param {any} param0.authToken The auth token + * @param {function(error)} param0.onError On error callback + * @param {function(onComplte)} param0.onComplete On complete callback + * @returns {LoginObserver} The Login observer + */ + initialize ({ userAgent, authToken, onError, onComplete } = {}) { + const observer = new LoginObserver({ + onError: error => this._onLoginError(error, onError), + onCompleted: metadata => { + if (metadata.patch_bolt !== undefined) { + this._applyPatches(metadata.patch_bolt) + } + return this._onLoginCompleted(metadata, onComplete) + } + }) + + this.write( + RequestMessage.hello(userAgent, authToken, this._serversideRouting, ['utc']), + observer, + true + ) + + return observer + } + + /** + * + * @param {string[]} patches Patches to be applied to the protocol + */ + _applyPatches (patches) { + if (patches.includes('utc')) { + this._applyUtcPatch() + } + } + + _applyUtcPatch () { + this._packer.useUtc = true + this._unpacker.useUtc = true + } } diff --git a/packages/bolt-connection/src/bolt/request-message.js b/packages/bolt-connection/src/bolt/request-message.js index 11b29852f..1538f4a1c 100644 --- a/packages/bolt-connection/src/bolt/request-message.js +++ b/packages/bolt-connection/src/bolt/request-message.js @@ -106,11 +106,14 @@ export default class RequestMessage { * @param {Object} optional server side routing, set to routing context to turn on server side routing (> 4.1) * @return {RequestMessage} new HELLO message. */ - static hello (userAgent, authToken, routing = null) { + static hello (userAgent, authToken, routing = null, patchs = null) { const metadata = Object.assign({ user_agent: userAgent }, authToken) if (routing) { metadata.routing = routing } + if (patchs) { + metadata.patch_bolt = patchs + } return new RequestMessage( HELLO, [metadata], diff --git a/packages/bolt-connection/src/packstream/packstream-utc.js b/packages/bolt-connection/src/packstream/packstream-utc.js new file mode 100644 index 000000000..377ff95a2 --- /dev/null +++ b/packages/bolt-connection/src/packstream/packstream-utc.js @@ -0,0 +1,323 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + import { + DateTime, + isInt, + int, + internal + } from 'neo4j-driver-core' + + + import { + epochSecondAndNanoToLocalDateTime + } from './temporal-factory' + + const { + temporalUtil: { + localDateTimeToEpochSecond + } + } = internal + + export const DATE_TIME_WITH_ZONE_OFFSET = 0x49 + const DATE_TIME_WITH_ZONE_OFFSET_STRUCT_SIZE = 3 + + export const DATE_TIME_WITH_ZONE_ID = 0x69 + const DATE_TIME_WITH_ZONE_ID_STRUCT_SIZE = 3 + + /** + * Unpack date time with zone offset value using the given unpacker. + * @param {Unpacker} unpacker the unpacker to use. + * @param {number} structSize the retrieved struct size. + * @param {BaseBuffer} buffer the buffer to unpack from. + * @param {boolean} disableLosslessIntegers if integer properties in the result date-time should be native JS numbers. + * @return {DateTime} the unpacked date time with zone offset value. + */ +export function unpackDateTimeWithZoneOffset ( + unpacker, + structSize, + buffer, + disableLosslessIntegers, + useBigInt +) { + unpacker._verifyStructSize( + 'DateTimeWithZoneOffset', + DATE_TIME_WITH_ZONE_OFFSET_STRUCT_SIZE, + structSize + ) + + const utcSecond = unpacker.unpackInteger(buffer) + const nano = unpacker.unpackInteger(buffer) + const timeZoneOffsetSeconds = unpacker.unpackInteger(buffer) + + const epochSecond = int(utcSecond).add(timeZoneOffsetSeconds) + const localDateTime = epochSecondAndNanoToLocalDateTime(epochSecond, nano) + const result = new DateTime( + localDateTime.year, + localDateTime.month, + localDateTime.day, + localDateTime.hour, + localDateTime.minute, + localDateTime.second, + localDateTime.nanosecond, + timeZoneOffsetSeconds, + null + ) + return convertIntegerPropsIfNeeded(result, disableLosslessIntegers, useBigInt) +} + +/** + * Unpack date time with zone id value using the given unpacker. + * @param {Unpacker} unpacker the unpacker to use. + * @param {number} structSize the retrieved struct size. + * @param {BaseBuffer} buffer the buffer to unpack from. + * @param {boolean} disableLosslessIntegers if integer properties in the result date-time should be native JS numbers. + * @return {DateTime} the unpacked date time with zone id value. + */ + export function unpackDateTimeWithZoneId ( + unpacker, + structSize, + buffer, + disableLosslessIntegers, + useBigInt +) { + unpacker._verifyStructSize( + 'DateTimeWithZoneId', + DATE_TIME_WITH_ZONE_ID_STRUCT_SIZE, + structSize + ) + + const epochSecond = unpacker.unpackInteger(buffer) + const nano = unpacker.unpackInteger(buffer) + const timeZoneId = unpacker.unpack(buffer) + + const localDateTime = getTimeInZoneId(timeZoneId, epochSecond, nano) + + const result = new DateTime( + localDateTime.year, + localDateTime.month, + localDateTime.day, + localDateTime.hour, + localDateTime.minute, + localDateTime.second, + int(nano), + localDateTime.timeZoneOffsetSeconds, + timeZoneId + ) + return convertIntegerPropsIfNeeded(result, disableLosslessIntegers, useBigInt) +} + +/* +* Pack given date time. +* @param {DateTime} value the date time value to pack. +* @param {Packer} packer the packer to use. +*/ +export function packDateTime (value, packer) { + if (value.timeZoneId) { + packDateTimeWithZoneId(value, packer) + } else { + packDateTimeWithZoneOffset(value, packer) + } +} + +/** + * Pack given date time with zone id. + * @param {DateTime} value the date time value to pack. + * @param {Packer} packer the packer to use. + */ + function packDateTimeWithZoneId (value, packer) { + + const epochSecond = localDateTimeToEpochSecond( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.nanosecond + ) + + const offset = value.timeZoneOffsetSeconds != null + ? value.timeZoneOffsetSeconds + : getOffsetFromZoneId(value.timeZoneId, epochSecond, value.nanosecond) + + const utc = epochSecond.subtract(offset) + const nano = int(value.nanosecond) + const timeZoneId = value.timeZoneId + + const packableStructFields = [ + packer.packable(utc), + packer.packable(nano), + packer.packable(timeZoneId) + ] + packer.packStruct(DATE_TIME_WITH_ZONE_ID, packableStructFields) +} + +/** + * Pack given date time with zone offset. + * @param {DateTime} value the date time value to pack. + * @param {Packer} packer the packer to use. + */ + function packDateTimeWithZoneOffset (value, packer) { + const epochSecond = localDateTimeToEpochSecond( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.nanosecond + ) + const nano = int(value.nanosecond) + const timeZoneOffsetSeconds = int(value.timeZoneOffsetSeconds) + const utcSecond = epochSecond.subtract(timeZoneOffsetSeconds) + + const packableStructFields = [ + packer.packable(utcSecond), + packer.packable(nano), + packer.packable(timeZoneOffsetSeconds) + ] + packer.packStruct(DATE_TIME_WITH_ZONE_OFFSET, packableStructFields) +} + + + /** + * Returns the offset for a given timezone id + * + * Javascript doesn't have support for direct getting the timezone offset from a given + * TimeZoneId and DateTime in the given TimeZoneId. For solving this issue, + * + * 1. The ZoneId is applied to the timestamp, so we could make the difference between the + * given timestamp and the new calculated one. This is the offset for the timezone + * in the utc is equal to epoch (some time in the future or past) + * 2. The offset is subtracted from the timestamp, so we have an estimated utc timestamp. + * 3. The ZoneId is applied to the new timestamp, se we could could make the difference + * between the new timestamp and the calculated one. This is the offset for the given timezone. + * + * Example: + * Input: 2022-3-27 1:59:59 'Europe/Berlin' + * Apply 1, 2022-3-27 1:59:59 => 2022-3-27 3:59:59 'Europe/Berlin' +2:00 + * Apply 2, 2022-3-27 1:59:59 - 2:00 => 2022-3-26 23:59:59 + * Apply 3, 2022-3-26 23:59:59 => 2022-3-27 00:59:59 'Europe/Berlin' +1:00 + * The offset is +1 hour. + * + * @param {string} timeZoneId The timezone id + * @param {Integer} epochSecond The epoch second in the timezone id + * @param {Integerable} nanosecond The nanoseconds in the timezone id + * @returns The timezone offset + */ + function getOffsetFromZoneId (timeZoneId, epochSecond, nanosecond) { + const dateTimeWithZoneAppliedTwice = getTimeInZoneId(timeZoneId, epochSecond, nanosecond) + + // The wallclock form the current date time + const epochWithZoneAppliedTwice = localDateTimeToEpochSecond( + dateTimeWithZoneAppliedTwice.year, + dateTimeWithZoneAppliedTwice.month, + dateTimeWithZoneAppliedTwice.day, + dateTimeWithZoneAppliedTwice.hour, + dateTimeWithZoneAppliedTwice.minute, + dateTimeWithZoneAppliedTwice.second, + nanosecond) + + const offsetOfZoneInTheFutureUtc = epochWithZoneAppliedTwice.subtract(epochSecond) + const guessedUtc = epochSecond.subtract(offsetOfZoneInTheFutureUtc) + + const zonedDateTimeFromGuessedUtc = getTimeInZoneId(timeZoneId, guessedUtc, nanosecond) + + const zonedEpochFromGuessedUtc = localDateTimeToEpochSecond( + zonedDateTimeFromGuessedUtc.year, + zonedDateTimeFromGuessedUtc.month, + zonedDateTimeFromGuessedUtc.day, + zonedDateTimeFromGuessedUtc.hour, + zonedDateTimeFromGuessedUtc.minute, + zonedDateTimeFromGuessedUtc.second, + nanosecond) + + const offset = zonedEpochFromGuessedUtc.subtract(guessedUtc) + return offset + } + + function getTimeInZoneId (timeZoneId, epochSecond, nano) { + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timeZoneId, + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hour12: false + }) + + const l = epochSecondAndNanoToLocalDateTime(epochSecond, nano) + const utc = Date.UTC( + int(l.year).toNumber(), + int(l.month).toNumber() - 1, + int(l.day).toNumber(), + int(l.hour).toNumber(), + int(l.minute).toNumber(), + int(l.second).toNumber() + ) + + const formattedUtcParts = formatter.formatToParts(utc) + + const localDateTime = formattedUtcParts.reduce((obj, currentValue) => { + if (currentValue.type !== 'literal') { + obj[currentValue.type] = int(currentValue.value) + } + return obj + }, {}) + + const epochInTimeZone = localDateTimeToEpochSecond( + localDateTime.year, + localDateTime.month, + localDateTime.day, + localDateTime.hour, + localDateTime.minute, + localDateTime.second, + localDateTime.nanosecond + ) + + localDateTime.timeZoneOffsetSeconds = epochInTimeZone.subtract(epochSecond) + localDateTime.hour = localDateTime.hour.modulo(24) + + return localDateTime + } + + + function convertIntegerPropsIfNeeded (obj, disableLosslessIntegers, useBigInt) { + if (!disableLosslessIntegers && !useBigInt) { + return obj + } + + const convert = value => + useBigInt ? value.toBigInt() : value.toNumberOrInfinity() + + const clone = Object.create(Object.getPrototypeOf(obj)) + for (const prop in obj) { + if (Object.prototype.hasOwnProperty.call(obj, prop) === true) { + const value = obj[prop] + clone[prop] = isInt(value) ? convert(value) : value + } + } + Object.freeze(clone) + return clone + } + + diff --git a/packages/bolt-connection/src/packstream/packstream-v2.js b/packages/bolt-connection/src/packstream/packstream-v2.js index d1c1b8ed2..0126f3677 100644 --- a/packages/bolt-connection/src/packstream/packstream-v2.js +++ b/packages/bolt-connection/src/packstream/packstream-v2.js @@ -18,6 +18,7 @@ */ import * as v1 from './packstream-v1' +import * as utc from './packstream-utc' import { int, isInt, @@ -94,7 +95,9 @@ export class Packer extends v1.Packer { return () => packDate(obj, this) } else if (isLocalDateTime(obj)) { return () => packLocalDateTime(obj, this) - } else if (isDateTime(obj)) { + } else if (isDateTime(obj) && this.useUtc) { + return () => utc.packDateTime(obj, this) + } else if (isDateTime(obj) && !this.useUtc) { return () => packDateTime(obj, this) } else { return super.packable(obj) @@ -151,7 +154,15 @@ export class Unpacker extends v1.Unpacker { this._disableLosslessIntegers, this._useBigInt ) - } else if (signature === DATE_TIME_WITH_ZONE_OFFSET) { + } else if (signature === utc.DATE_TIME_WITH_ZONE_OFFSET && this.useUtc) { + return utc.unpackDateTimeWithZoneOffset( + this, + structSize, + buffer, + this._disableLosslessIntegers, + this._useBigInt + ) + } else if (signature === DATE_TIME_WITH_ZONE_OFFSET && !this.useUtc) { return unpackDateTimeWithZoneOffset( this, structSize, @@ -159,7 +170,15 @@ export class Unpacker extends v1.Unpacker { this._disableLosslessIntegers, this._useBigInt ) - } else if (signature === DATE_TIME_WITH_ZONE_ID) { + } else if (signature === utc.DATE_TIME_WITH_ZONE_ID && this.useUtc) { + return utc.unpackDateTimeWithZoneId( + this, + structSize, + buffer, + this._disableLosslessIntegers, + this._useBigInt + ) + } else if (signature === DATE_TIME_WITH_ZONE_ID && !this.useUtc) { return unpackDateTimeWithZoneId( this, structSize, diff --git a/packages/bolt-connection/test/bolt/bolt-protocol-v4x3.test.js b/packages/bolt-connection/test/bolt/bolt-protocol-v4x3.test.js index 41c5f4de1..b2d34e86d 100644 --- a/packages/bolt-connection/test/bolt/bolt-protocol-v4x3.test.js +++ b/packages/bolt-connection/test/bolt/bolt-protocol-v4x3.test.js @@ -22,12 +22,14 @@ import RequestMessage from '../../src/bolt/request-message' import utils from '../test-utils' import { RouteObserver } from '../../src/bolt/stream-observers' import { internal } from 'neo4j-driver-core' +import { alloc } from '../../src/channel' const WRITE = 'WRITE' const { txConfig: { TxConfig }, - bookmark: { Bookmark } + bookmark: { Bookmark }, + logger: { Logger } } = internal describe('#unit BoltProtocolV4x3', () => { @@ -179,7 +181,7 @@ describe('#unit BoltProtocolV4x3', () => { protocol.verifyMessageCount(1) expect(protocol.messages[0]).toBeMessage( - RequestMessage.hello(clientName, authToken) + RequestMessage.hello(clientName, authToken, null, ['utc']) ) expect(protocol.observers).toEqual([observer]) expect(protocol.flushes).toEqual([true]) @@ -299,4 +301,71 @@ describe('#unit BoltProtocolV4x3', () => { } ) }) + + describe('utc patch', () => { + describe('the server accepted the patch', () => { + let protocol + let buffer + let loggerFunction + + beforeEach(() => { + buffer = alloc(256) + loggerFunction = jest.fn() + protocol = new BoltProtocolV4x3( + new utils.MessageRecordingConnection(), + buffer, + { disableLosslessIntegers: true }, + undefined, + new Logger('debug', loggerFunction) + ) + utils.spyProtocolWrite(protocol) + + const clientName = 'js-driver/1.2.3' + const authToken = { username: 'neo4j', password: 'secret' } + + const observer = protocol.initialize({ userAgent: clientName, authToken }) + + observer.onCompleted({ patch_bolt: ['utc'] }) + + buffer.reset() + }) + + it('should enable utc in the packer and unpacker', () => { + expect(protocol._packer.useUtc).toBe(true) + expect(protocol._unpacker.useUtc).toBe(true) + }) + }) + + describe('the server does not the patch', () => { + let protocol + let buffer + let loggerFunction + + beforeEach(() => { + buffer = alloc(256) + loggerFunction = jest.fn() + protocol = new BoltProtocolV4x3( + new utils.MessageRecordingConnection(), + buffer, + { disableLosslessIntegers: true }, + undefined, + new Logger('debug', loggerFunction) + ) + utils.spyProtocolWrite(protocol) + + const clientName = 'js-driver/1.2.3' + const authToken = { username: 'neo4j', password: 'secret' } + + const observer = protocol.initialize({ userAgent: clientName, authToken }) + + observer.onCompleted({}) + + buffer.reset() + }) + it('should not enable utc in the packer and unpacker', () => { + expect(protocol._packer.useUtc).not.toBe(true) + expect(protocol._unpacker.useUtc).not.toBe(true) + }) + }) + }) }) diff --git a/packages/bolt-connection/test/bolt/bolt-protocol-v4x4.test.js b/packages/bolt-connection/test/bolt/bolt-protocol-v4x4.test.js index 2314922ed..12e8f887f 100644 --- a/packages/bolt-connection/test/bolt/bolt-protocol-v4x4.test.js +++ b/packages/bolt-connection/test/bolt/bolt-protocol-v4x4.test.js @@ -22,12 +22,14 @@ import RequestMessage from '../../src/bolt/request-message' import utils from '../test-utils' import { RouteObserver } from '../../src/bolt/stream-observers' import { internal } from 'neo4j-driver-core' +import { alloc } from '../../src/channel' const WRITE = 'WRITE' const { txConfig: { TxConfig }, - bookmark: { Bookmark } + bookmark: { Bookmark }, + logger: { Logger } } = internal describe('#unit BoltProtocolV4x4', () => { @@ -253,7 +255,7 @@ describe('#unit BoltProtocolV4x4', () => { protocol.verifyMessageCount(1) expect(protocol.messages[0]).toBeMessage( - RequestMessage.hello(clientName, authToken) + RequestMessage.hello(clientName, authToken, null, ['utc']) ) expect(protocol.observers).toEqual([observer]) expect(protocol.flushes).toEqual([true]) @@ -332,4 +334,72 @@ describe('#unit BoltProtocolV4x4', () => { } ) }) + + describe('utc patch', () => { + describe('the server accepted the patch', () => { + let protocol + let buffer + let loggerFunction + + beforeEach(() => { + buffer = alloc(256) + loggerFunction = jest.fn() + protocol = new BoltProtocolV4x4( + new utils.MessageRecordingConnection(), + buffer, + { disableLosslessIntegers: true }, + undefined, + new Logger('debug', loggerFunction) + ) + utils.spyProtocolWrite(protocol) + + const clientName = 'js-driver/1.2.3' + const authToken = { username: 'neo4j', password: 'secret' } + + const observer = protocol.initialize({ userAgent: clientName, authToken }) + + observer.onCompleted({ patch_bolt: ['utc'] }) + + buffer.reset() + }) + + it('should enable utc in the packer and unpacker', () => { + expect(protocol._packer.useUtc).toBe(true) + expect(protocol._unpacker.useUtc).toBe(true) + }) + }) + + describe('the server does not the patch', () => { + let protocol + let buffer + let loggerFunction + + beforeEach(() => { + buffer = alloc(256) + loggerFunction = jest.fn() + protocol = new BoltProtocolV4x4( + new utils.MessageRecordingConnection(), + buffer, + { disableLosslessIntegers: true }, + undefined, + new Logger('debug', loggerFunction) + ) + utils.spyProtocolWrite(protocol) + + const clientName = 'js-driver/1.2.3' + const authToken = { username: 'neo4j', password: 'secret' } + + const observer = protocol.initialize({ userAgent: clientName, authToken }) + + observer.onCompleted({}) + + buffer.reset() + }) + it('should not enable utc in the packer and unpacker', () => { + expect(protocol._packer.useUtc).not.toBe(true) + expect(protocol._unpacker.useUtc).not.toBe(true) + }) + }) + + }) }) diff --git a/packages/bolt-connection/test/packstream/__snapshots__/packstream-v2.test.js.snap b/packages/bolt-connection/test/packstream/__snapshots__/packstream-v2.test.js.snap new file mode 100644 index 000000000..6fe417f17 --- /dev/null +++ b/packages/bolt-connection/test/packstream/__snapshots__/packstream-v2.test.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#unit PackStreamV2 utc should not unpack with wrong size (DateTimeWithZoneId with less fields) 1`] = `"Wrong struct size for DateTimeWithZoneId, expected 3 but was 2"`; + +exports[`#unit PackStreamV2 utc should not unpack with wrong size (DateTimeWithZoneId with more fields) 1`] = `"Wrong struct size for DateTimeWithZoneId, expected 3 but was 4"`; + +exports[`#unit PackStreamV2 utc should not unpack with wrong size (DateTimeWithZoneOffset with less fields) 1`] = `"Wrong struct size for DateTimeWithZoneOffset, expected 3 but was 2"`; + +exports[`#unit PackStreamV2 utc should not unpack with wrong size (DateTimeWithZoneOffset with more fields) 1`] = `"Wrong struct size for DateTimeWithZoneOffset, expected 3 but was 4"`; diff --git a/packages/bolt-connection/test/packstream/packstream-v2.test.js b/packages/bolt-connection/test/packstream/packstream-v2.test.js new file mode 100644 index 000000000..0be8d8c78 --- /dev/null +++ b/packages/bolt-connection/test/packstream/packstream-v2.test.js @@ -0,0 +1,416 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { int, Integer, DateTime } from 'neo4j-driver-core' +import { alloc } from '../../src/channel' +import { Packer, Unpacker } from '../../src/packstream/packstream-v2' +import { Structure } from '../../src/packstream/packstream-v1' + +describe('#unit PackStreamV2', () => { + it('should pack integers with small numbers', () => { + let n, i + // test small numbers + for (n = -999; n <= 999; n += 1) { + i = int(n) + expect(packAndUnpack(i).toString()).toBe(i.toString()) + expect( + packAndUnpack(i, { disableLosslessIntegers: true }).toString() + ).toBe(i.toString()) + expect(packAndUnpack(i, { useBigInt: true }).toString()).toBe( + i.toString() + ) + } + }) + + it('should pack integers with small numbers created with Integer', () => { + let n, i + // test small numbers + for (n = -10; n <= 10; n += 1) { + i = new Integer(n, 0) + expect(packAndUnpack(i).toString()).toBe(i.toString()) + expect( + packAndUnpack(i, { disableLosslessIntegers: true }).toString() + ).toBe(i.toString()) + expect(packAndUnpack(i, { useBigInt: true }).toString()).toBe( + i.toString() + ) + } + }) + + it('should pack integers with positive numbers', () => { + let n, i + // positive numbers + for (n = 16; n <= 16; n += 1) { + i = int(Math.pow(2, n)) + expect(packAndUnpack(i).toString()).toBe(i.toString()) + + const unpackedLossyInteger = packAndUnpack(i, { + disableLosslessIntegers: true + }) + expect(typeof unpackedLossyInteger).toBe('number') + expect(unpackedLossyInteger.toString()).toBe( + i.inSafeRange() ? i.toString() : 'Infinity' + ) + + const bigint = packAndUnpack(i, { useBigInt: true }) + expect(typeof bigint).toBe('bigint') + expect(bigint.toString()).toBe(i.toString()) + } + }) + + it('should pack integer with negative numbers', () => { + let n, i + // negative numbers + for (n = 0; n <= 63; n += 1) { + i = int(-Math.pow(2, n)) + expect(packAndUnpack(i).toString()).toBe(i.toString()) + + const unpackedLossyInteger = packAndUnpack(i, { + disableLosslessIntegers: true + }) + expect(typeof unpackedLossyInteger).toBe('number') + expect(unpackedLossyInteger.toString()).toBe( + i.inSafeRange() ? i.toString() : '-Infinity' + ) + + const bigint = packAndUnpack(i, { useBigInt: true }) + expect(typeof bigint).toBe('bigint') + expect(bigint.toString()).toBe(i.toString()) + } + }) + + it('should pack BigInt with small numbers', () => { + let n, i + // test small numbers + for (n = -999; n <= 999; n += 1) { + i = BigInt(n) + expect(packAndUnpack(i).toString()).toBe(i.toString()) + expect( + packAndUnpack(i, { disableLosslessIntegers: true }).toString() + ).toBe(i.toString()) + expect(packAndUnpack(i, { useBigInt: true }).toString()).toBe( + i.toString() + ) + } + }) + + it('should pack BigInt with positive numbers', () => { + let n, i + // positive numbers + for (n = 16; n <= 16; n += 1) { + i = BigInt(Math.pow(2, n)) + expect(packAndUnpack(i).toString()).toBe(i.toString()) + + const unpackedLossyInteger = packAndUnpack(i, { + disableLosslessIntegers: true + }) + expect(typeof unpackedLossyInteger).toBe('number') + expect(unpackedLossyInteger.toString()).toBe( + int(i).inSafeRange() ? i.toString() : 'Infinity' + ) + + const bigint = packAndUnpack(i, { useBigInt: true }) + expect(typeof bigint).toBe('bigint') + expect(bigint.toString()).toBe(i.toString()) + } + }) + + it('should pack BigInt with negative numbers', () => { + let n, i + // negative numbers + for (n = 0; n <= 63; n += 1) { + i = BigInt(-Math.pow(2, n)) + expect(packAndUnpack(i).toString()).toBe(i.toString()) + + const unpackedLossyInteger = packAndUnpack(i, { + disableLosslessIntegers: true + }) + expect(typeof unpackedLossyInteger).toBe('number') + expect(unpackedLossyInteger.toString()).toBe( + int(i).inSafeRange() ? i.toString() : '-Infinity' + ) + + const bigint = packAndUnpack(i, { useBigInt: true }) + expect(typeof bigint).toBe('bigint') + expect(bigint.toString()).toBe(i.toString()) + } + }) + + it('should pack strings', () => { + expect(packAndUnpack('')).toBe('') + expect(packAndUnpack('abcdefg123567')).toBe('abcdefg123567') + const str = Array(65536 + 1).join('a') // 2 ^ 16 + 1 + expect(packAndUnpack(str, { bufferSize: str.length + 8 })).toBe(str) + }) + + it('should pack structures', () => { + expect(packAndUnpack(new Structure(1, ['Hello, world!!!'])).fields[0]).toBe( + 'Hello, world!!!' + ) + }) + + it('should pack lists', () => { + const list = ['a', 'b'] + const unpacked = packAndUnpack(list) + expect(unpacked[0]).toBe(list[0]) + expect(unpacked[1]).toBe(list[1]) + }) + + it('should pack long lists', () => { + const listLength = 256 + const list = [] + for (let i = 0; i < listLength; i++) { + list.push(null) + } + const unpacked = packAndUnpack(list, { bufferSize: 1400 }) + expect(unpacked[0]).toBe(list[0]) + expect(unpacked[1]).toBe(list[1]) + }) + + describe('utc', () => { + it.each([ + [ + 'DateTimeWithZoneOffset', + new DateTime(2022, 6, 14, 15, 21, 18, 183_000_000, 120 * 60) + ], + [ + 'DateTimeWithZoneId / Berlin 2:30 CET', + new DateTime(2022, 10, 30, 2, 30, 0, 183_000_000, 2 * 60 * 60, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Berlin 2:30 CEST', + new DateTime(2022, 10, 30, 2, 30, 0, 183_000_000, 1 * 60 * 60, 'Europe/Berlin') + ] + ])('should pack temporal types (%s)', (_, object) => { + + const unpacked = packAndUnpack(object, { disableLosslessIntegers: true, useUtc: true }) + + expect(unpacked).toEqual(object) + }) + + it.each([ + [ + 'DateTimeWithZoneId / Australia', + new DateTime(2022, 6, 15, 15, 21, 18, 183_000_000, undefined, 'Australia/Eucla') + ], + [ + 'DateTimeWithZoneId', + new DateTime(2022, 6, 22, 15, 21, 18, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Europe just before turn CEST', + new DateTime(2022, 3, 27, 1, 59, 59, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Europe just 1 before turn CEST', + new DateTime(2022, 3, 27, 0, 59, 59, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Europe just after turn CEST', + new DateTime(2022, 3, 27, 3, 0, 0, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Europe just 1 after turn CEST', + new DateTime(2022, 3, 27, 4, 0, 0, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Europe just before turn CET', + new DateTime(2022, 10, 30, 2, 59, 59, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Europe just 1 before turn CET', + new DateTime(2022, 10, 30, 1, 59, 59, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Europe just after turn CET', + new DateTime(2022, 10, 30, 3, 0, 0, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Europe just 1 after turn CET', + new DateTime(2022, 10, 30, 4, 0, 0, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Sao Paulo just before turn summer time', + new DateTime(2018, 11, 4, 11, 59, 59, 183_000_000, undefined, 'America/Sao_Paulo') + ], + [ + 'DateTimeWithZoneId / Sao Paulo just 1 before turn summer time', + new DateTime(2018, 11, 4, 10, 59, 59, 183_000_000, undefined, 'America/Sao_Paulo') + ], + [ + 'DateTimeWithZoneId / Sao Paulo just after turn summer time', + new DateTime(2018, 11, 5, 1, 0, 0, 183_000_000, undefined, 'America/Sao_Paulo') + ], + [ + 'DateTimeWithZoneId / Sao Paulo just 1 after turn summer time', + new DateTime(2018, 11, 5, 2, 0, 0, 183_000_000, undefined, 'America/Sao_Paulo') + ], + [ + 'DateTimeWithZoneId / Sao Paulo just before turn winter time', + new DateTime(2019, 2, 17, 11, 59, 59, 183_000_000, undefined, 'America/Sao_Paulo') + ], + [ + 'DateTimeWithZoneId / Sao Paulo just 1 before turn winter time', + new DateTime(2019, 2, 17, 10, 59, 59, 183_000_000, undefined, 'America/Sao_Paulo') + ], + [ + 'DateTimeWithZoneId / Sao Paulo just after turn winter time', + new DateTime(2019, 2, 18, 0, 0, 0, 183_000_000, undefined, 'America/Sao_Paulo') + ], + [ + 'DateTimeWithZoneId / Sao Paulo just 1 after turn winter time', + new DateTime(2019, 2, 18, 1, 0, 0, 183_000_000, undefined, 'America/Sao_Paulo') + ] + ])('should pack and unpack DateTimeWithZoneId and without offset (%s)', (_, object) => { + const unpacked = packAndUnpack(object, { disableLosslessIntegers: true, useUtc: true}) + + expect(unpacked.timeZoneOffsetSeconds).toBeDefined() + + const unpackedDateTimeWithoutOffset = new DateTime( + unpacked.year, + unpacked.month, + unpacked.day, + unpacked.hour, + unpacked.minute, + unpacked.second, + unpacked.nanosecond, + undefined, + unpacked.timeZoneId + ) + + expect(unpackedDateTimeWithoutOffset).toEqual(object) + }) + + it.each([ + [ + 'DateTimeWithZoneOffset with less fields', + new Structure(0x49, [1, 2]) + ], + [ + 'DateTimeWithZoneOffset with more fields', + new Structure(0x49, [1, 2, 3, 4]) + ], + [ + 'DateTimeWithZoneId with less fields', + new Structure(0x69, [1, 2]) + ], + [ + 'DateTimeWithZoneId with more fields', + new Structure(0x69, [1, 2, 'America/Sao Paulo', 'Brasil']) + ] + ])('should not unpack with wrong size (%s)', (_, struct) => { + expect(() => packAndUnpack(struct, { useUtc: true })).toThrowErrorMatchingSnapshot() + }) + + it.each([ + [ + 'DateTimeWithZoneOffset', + new Structure(0x49, [ + int(1655212878), int(183_000_000), int(120 * 60) + ]), + new DateTime(2022, 6, 14, 15, 21, 18, 183_000_000, 120 * 60) + ], + [ + 'DateTimeWithZoneId', + new Structure(0x69, [ + int(1655212878), int(183_000_000), 'Europe/Berlin' + ]), + new DateTime(2022, 6, 14, 15, 21, 18, 183_000_000, 2 * 60 * 60, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Australia', + new Structure(0x69, [ + int(1655212878), int(183_000_000), 'Australia/Eucla' + ]), + new DateTime(2022, 6, 14, 22, 6, 18, 183_000_000, 8 * 60 * 60 + 45 * 60, 'Australia/Eucla') + ] + ])('should unpack temporal types (%s)', (_, struct, object) => { + const unpacked = packAndUnpack(struct, { disableLosslessIntegers: true, useUtc: true}) + expect(unpacked).toEqual(object) + }) + + it.each([ + [ + 'DateTimeWithZoneOffset/0x46', + new Structure(0x46, [1, 2, 3]) + ], + [ + 'DateTimeWithZoneId/0x66', + new Structure(0x66, [1, 2, 'America/Sao Paulo']) + ] + ])('should unpack deprecated temporal types as unknown structs (%s)', (_, struct) => { + const unpacked = packAndUnpack(struct, { disableLosslessIntegers: true, useUtc: true}) + expect(unpacked).toEqual(struct) + }) + }) + + describe('non-utc', () => { + it.each([ + [ + 'DateTimeWithZoneOffset/0x49', + new Structure(0x49, [1, 2, 3]) + ], + [ + 'DateTimeWithZoneId/0x69', + new Structure(0x69, [1, 2, 'America/Sao Paulo']) + ] + ])('should unpack utc temporal types as unknown structs (%s)', (_, struct) => { + const unpacked = packAndUnpack(struct, { disableLosslessIntegers: true }) + expect(unpacked).toEqual(struct) + }) + + it.each([ + [ + 'DateTimeWithZoneOffset', + new Structure(0x46, [int(1), int(2), int(3)]), + new DateTime(1970, 1, 1, 0, 0, 1, 2, 3) + ], + [ + 'DateTimeWithZoneId', + new Structure(0x66, [int(1), int(2), 'America/Sao Paulo']), + new DateTime(1970, 1, 1, 0, 0, 1, 2, undefined, 'America/Sao Paulo') + ] + ])('should unpack temporal types without utc fix (%s)', (_, struct, object) => { + const unpacked = packAndUnpack(struct, { disableLosslessIntegers: true }) + expect(unpacked).toEqual(object) + }) + + it.each([ + ['DateTimeWithZoneId', new DateTime(1, 1, 1, 1, 1, 1, 1, undefined, 'America/Sao Paulo')], + ['DateTime', new DateTime(1, 1, 1, 1, 1, 1, 1, 1)] + ])('should pack temporal types (no utc) (%s)', (_, object) => { + const unpacked = packAndUnpack(object, { disableLosslessIntegers: true }) + expect(unpacked).toEqual(object) + }) + }) +}) + +function packAndUnpack ( + val, + { bufferSize = 128, disableLosslessIntegers = false, useBigInt = false, useUtc = false} = {} +) { + const buffer = alloc(bufferSize) + const packer = new Packer(buffer) + packer.useUtc = useUtc + packer.packable(val)() + buffer.reset() + const unpacker = new Unpacker(disableLosslessIntegers, useBigInt) + unpacker.useUtc = useUtc + return unpacker.unpack(buffer) +} diff --git a/packages/bolt-connection/test/test-utils.js b/packages/bolt-connection/test/test-utils.js index 8e095fdfa..b9e112e03 100644 --- a/packages/bolt-connection/test/test-utils.js +++ b/packages/bolt-connection/test/test-utils.js @@ -106,6 +106,10 @@ class MessageRecordingConnection extends Connection { expect(this.observers.length).toEqual(expected) expect(this.flushes.length).toEqual(expected) } + + get version () { + return 4.3 + } } function spyProtocolWrite (protocol, callRealMethod = false) { diff --git a/packages/core/src/temporal-types.ts b/packages/core/src/temporal-types.ts index a120e464f..d687f250a 100644 --- a/packages/core/src/temporal-types.ts +++ b/packages/core/src/temporal-types.ts @@ -707,21 +707,25 @@ function verifyTimeZoneArguments( const offsetDefined = timeZoneOffsetSeconds || timeZoneOffsetSeconds === 0 const idDefined = timeZoneId && timeZoneId !== '' - if (offsetDefined && !idDefined) { - assertNumberOrInteger(timeZoneOffsetSeconds, 'Time zone offset in seconds') - return [timeZoneOffsetSeconds, undefined] - } else if (!offsetDefined && idDefined) { - assertString(timeZoneId, 'Time zone ID') - return [undefined, timeZoneId] - } else if (offsetDefined && idDefined) { - throw newError( - `Unable to create DateTime with both time zone offset and id. Please specify either of them. Given offset: ${timeZoneOffsetSeconds} and id: ${timeZoneId}` - ) - } else { + if (!offsetDefined && !idDefined) { throw newError( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Unable to create DateTime without either time zone offset or id. Please specify either of them. Given offset: ${timeZoneOffsetSeconds} and id: ${timeZoneId}` ) } + + const result: [NumberOrInteger | undefined | null, string | undefined | null] = [undefined, undefined] + if (offsetDefined) { + assertNumberOrInteger(timeZoneOffsetSeconds, 'Time zone offset in seconds') + result[0] = timeZoneOffsetSeconds + } + + if (idDefined) { + assertString(timeZoneId, 'Time zone ID') + result[1] = timeZoneId + } + + return result } /** diff --git a/packages/neo4j-driver/test/temporal-types.test.js b/packages/neo4j-driver/test/temporal-types.test.js index d074aaee1..5398e4121 100644 --- a/packages/neo4j-driver/test/temporal-types.test.js +++ b/packages/neo4j-driver/test/temporal-types.test.js @@ -1008,9 +1008,6 @@ describe('#integration temporal-types', () => { expect( () => new neo4j.types.DateTime(1, 2, 3, 4, 5, 6, 7, null, null) ).toThrow() - expect( - () => new neo4j.types.DateTime(1, 2, 3, 4, 5, 6, 7, 8, 'UK') - ).toThrow() }, 60000) it('should convert standard Date to neo4j LocalTime', () => { diff --git a/packages/testkit-backend/src/cypher-native-binders.js b/packages/testkit-backend/src/cypher-native-binders.js index f7852e174..96b647252 100644 --- a/packages/testkit-backend/src/cypher-native-binders.js +++ b/packages/testkit-backend/src/cypher-native-binders.js @@ -103,6 +103,42 @@ export function nativeToCypher (x) { } } } + + if (neo4j.isDate(x)) { + return structResponse('CypherDate', { + year: x.year, + month: x.month, + day: x.day + }) + } else if (neo4j.isDateTime(x) || neo4j.isLocalDateTime(x)) { + return structResponse('CypherDateTime', { + year: x.year, + month: x.month, + day: x.day, + hour: x.hour, + minute: x.minute, + second: x.second, + nanosecond: x.nanosecond, + utc_offset_s: x.timeZoneOffsetSeconds || (x.timeZoneId == null ? undefined : 0), + timezone_id: x.timeZoneId + }) + } else if (neo4j.isTime(x) || neo4j.isLocalTime(x)) { + return structResponse('CypherTime', { + hour: x.hour, + minute: x.minute, + second: x.second, + nanosecond: x.nanosecond, + utc_offset_s: x.timeZoneOffsetSeconds + }) + } else if (neo4j.isDuration(x)) { + return structResponse('CypherDuration', { + months: x.months, + days: x.days, + seconds: x.seconds, + nanoseconds: x.nanoseconds + }) + } + // If all failed, interpret as a map const map = {} for (const [key, value] of Object.entries(x)) { @@ -116,26 +152,88 @@ export function nativeToCypher (x) { throw Error(err) } +function structResponse (name, data) { + const map = {} + for (const [key, value] of Object.entries(data)) { + map[key] = typeof value === 'bigint' || neo4j.isInt(value) + ? neo4j.int(value).toNumber() + : value + } + return { name, data: map } +} + export function cypherToNative (c) { const { name, - data: { value } + data } = c switch (name) { case 'CypherString': - return value + return data.value case 'CypherInt': - return BigInt(value) + return BigInt(data.value) case 'CypherFloat': - return value + return data.value case 'CypherNull': - return value + return data.value case 'CypherBool': - return value + return data.value case 'CypherList': - return value.map(cypherToNative) + return data.value.map(cypherToNative) + case 'CypherDateTime': + if (data.utc_offset_s == null && data.timezone_id == null) { + return new neo4j.LocalDateTime( + data.year, + data.month, + data.day, + data.hour, + data.minute, + data.second, + data.nanosecond + ) + } + return new neo4j.DateTime( + data.year, + data.month, + data.day, + data.hour, + data.minute, + data.second, + data.nanosecond, + data.utc_offset_s, + data.timezone_id + ) + case 'CypherTime': + if (data.utc_offset_s == null) { + return new neo4j.LocalTime( + data.hour, + data.minute, + data.second, + data.nanosecond + ) + } + return new neo4j.Time( + data.hour, + data.minute, + data.second, + data.nanosecond, + data.utc_offset_s + ) + case 'CypherDate': + return new neo4j.Date( + data.year, + data.month, + data.day + ) + case 'CypherDuration': + return new neo4j.Duration( + data.months, + data.days, + data.seconds, + data.nanoseconds + ) case 'CypherMap': - return Object.entries(value).reduce((acc, [key, val]) => { + return Object.entries(data.value).reduce((acc, [key, val]) => { acc[key] = cypherToNative(val) return acc }, {}) diff --git a/packages/testkit-backend/src/request-handlers.js b/packages/testkit-backend/src/request-handlers.js index 681df2ca4..8f5264bf4 100644 --- a/packages/testkit-backend/src/request-handlers.js +++ b/packages/testkit-backend/src/request-handlers.js @@ -331,6 +331,8 @@ export function GetFeatures (_context, _params, wire) { 'Feature:Auth:Bearer', 'Feature:API:SSLConfig', 'Feature:API:SSLSchemes', + 'Feature:API:Type.Temporal', + 'Feature:Bolt:Patch:UTC', 'AuthorizationExpiredTreatment', 'ConfHint:connection.recv_timeout_seconds', 'Feature:Impersonation', diff --git a/packages/testkit-backend/src/skipped-tests/common.js b/packages/testkit-backend/src/skipped-tests/common.js index c72fe2db2..9efe41d7e 100644 --- a/packages/testkit-backend/src/skipped-tests/common.js +++ b/packages/testkit-backend/src/skipped-tests/common.js @@ -1,6 +1,18 @@ import skip, { ifEquals, ifEndsWith } from './skip' const skippedTests = [ + skip( + 'Driver does not return offset for old DateTime implementations', + ifEquals('neo4j.datatypes.test_temporal_types.TestDataTypes.test_nested_datetime'), + ifEquals('neo4j.datatypes.test_temporal_types.TestDataTypes.test_should_echo_all_timezone_ids'), + ifEquals('neo4j.datatypes.test_temporal_types.TestDataTypes.test_cypher_created_datetime') + ), + skip( + 'Using numbers out of bound', + ifEquals('neo4j.datatypes.test_temporal_types.TestDataTypes.test_should_echo_temporal_type'), + ifEquals('neo4j.datatypes.test_temporal_types.TestDataTypes.test_nested_duration'), + ifEquals('neo4j.datatypes.test_temporal_types.TestDataTypes.test_duration_components') + ), skip( 'Fails when because tx function could start with not broken transations', ifEndsWith('test_should_write_successfully_on_leader_switch_using_tx_function'),