diff --git a/packages/bolt-connection/src/bolt/bolt-protocol-v1.js b/packages/bolt-connection/src/bolt/bolt-protocol-v1.js index ccb9704e9..f3a7b32a0 100644 --- a/packages/bolt-connection/src/bolt/bolt-protocol-v1.js +++ b/packages/bolt-connection/src/bolt/bolt-protocol-v1.js @@ -87,7 +87,7 @@ export default class BoltProtocol { get transformer () { if (this._transformer === undefined) { - this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config))) + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) } return this._transformer } diff --git a/packages/bolt-connection/src/bolt/bolt-protocol-v2.js b/packages/bolt-connection/src/bolt/bolt-protocol-v2.js index 4abec0941..399457cf2 100644 --- a/packages/bolt-connection/src/bolt/bolt-protocol-v2.js +++ b/packages/bolt-connection/src/bolt/bolt-protocol-v2.js @@ -37,7 +37,7 @@ export default class BoltProtocol extends BoltProtocolV1 { get transformer () { if (this._transformer === undefined) { - this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config))) + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) } return this._transformer } diff --git a/packages/bolt-connection/src/bolt/bolt-protocol-v3.js b/packages/bolt-connection/src/bolt/bolt-protocol-v3.js index 32f0d664b..25dd7ad5a 100644 --- a/packages/bolt-connection/src/bolt/bolt-protocol-v3.js +++ b/packages/bolt-connection/src/bolt/bolt-protocol-v3.js @@ -48,7 +48,7 @@ export default class BoltProtocol extends BoltProtocolV2 { get transformer () { if (this._transformer === undefined) { - this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config))) + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) } return this._transformer } diff --git a/packages/bolt-connection/src/bolt/bolt-protocol-v4x0.js b/packages/bolt-connection/src/bolt/bolt-protocol-v4x0.js index 102034d51..2dfc1f541 100644 --- a/packages/bolt-connection/src/bolt/bolt-protocol-v4x0.js +++ b/packages/bolt-connection/src/bolt/bolt-protocol-v4x0.js @@ -46,7 +46,7 @@ export default class BoltProtocol extends BoltProtocolV3 { get transformer () { if (this._transformer === undefined) { - this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config))) + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) } return this._transformer } diff --git a/packages/bolt-connection/src/bolt/bolt-protocol-v4x1.js b/packages/bolt-connection/src/bolt/bolt-protocol-v4x1.js index 7ae475765..f8a1de208 100644 --- a/packages/bolt-connection/src/bolt/bolt-protocol-v4x1.js +++ b/packages/bolt-connection/src/bolt/bolt-protocol-v4x1.js @@ -67,7 +67,7 @@ export default class BoltProtocol extends BoltProtocolV4 { get transformer () { if (this._transformer === undefined) { - this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config))) + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) } return this._transformer } diff --git a/packages/bolt-connection/src/bolt/bolt-protocol-v4x2.js b/packages/bolt-connection/src/bolt/bolt-protocol-v4x2.js index 07378b53a..96e607164 100644 --- a/packages/bolt-connection/src/bolt/bolt-protocol-v4x2.js +++ b/packages/bolt-connection/src/bolt/bolt-protocol-v4x2.js @@ -34,7 +34,7 @@ export default class BoltProtocol extends BoltProtocolV41 { get transformer () { if (this._transformer === undefined) { - this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config))) + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) } return this._transformer } diff --git a/packages/bolt-connection/src/bolt/bolt-protocol-v4x3.js b/packages/bolt-connection/src/bolt/bolt-protocol-v4x3.js index 0897c6826..3ca55e4f4 100644 --- a/packages/bolt-connection/src/bolt/bolt-protocol-v4x3.js +++ b/packages/bolt-connection/src/bolt/bolt-protocol-v4x3.js @@ -18,9 +18,10 @@ */ import BoltProtocolV42 from './bolt-protocol-v4x2' import RequestMessage from './request-message' -import { RouteObserver } from './stream-observers' +import { LoginObserver, RouteObserver } from './stream-observers' import transformersFactories from './bolt-protocol-v4x3.transformer' +import utcTransformersFactories from './bolt-protocol-v5x0.utc.transformer' import Transformer from './transformer' import { internal } from 'neo4j-driver-core' @@ -37,7 +38,7 @@ export default class BoltProtocol extends BoltProtocolV42 { get transformer () { if (this._transformer === undefined) { - this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config))) + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) } return this._transformer } @@ -75,4 +76,51 @@ 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._transformer = new Transformer(Object.values({ + ...transformersFactories, + ...utcTransformersFactories + }).map(create => create(this._config, this._log))) + } } diff --git a/packages/bolt-connection/src/bolt/bolt-protocol-v4x4.js b/packages/bolt-connection/src/bolt/bolt-protocol-v4x4.js index 6c6e6af6d..bd2c94228 100644 --- a/packages/bolt-connection/src/bolt/bolt-protocol-v4x4.js +++ b/packages/bolt-connection/src/bolt/bolt-protocol-v4x4.js @@ -23,6 +23,7 @@ import RequestMessage from './request-message' import { RouteObserver, ResultStreamObserver } from './stream-observers' import transformersFactories from './bolt-protocol-v4x4.transformer' +import utcTransformersFactories from './bolt-protocol-v5x0.utc.transformer' import Transformer from './transformer' const { @@ -37,7 +38,7 @@ export default class BoltProtocol extends BoltProtocolV43 { get transformer () { if (this._transformer === undefined) { - this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config))) + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) } return this._transformer } @@ -163,4 +164,11 @@ export default class BoltProtocol extends BoltProtocolV43 { return observer } + + _applyUtcPatch () { + this._transformer = new Transformer(Object.values({ + ...transformersFactories, + ...utcTransformersFactories + }).map(create => create(this._config, this._log))) + } } diff --git a/packages/bolt-connection/src/bolt/bolt-protocol-v5x0.js b/packages/bolt-connection/src/bolt/bolt-protocol-v5x0.js index f60c43d89..8cfbefa2b 100644 --- a/packages/bolt-connection/src/bolt/bolt-protocol-v5x0.js +++ b/packages/bolt-connection/src/bolt/bolt-protocol-v5x0.js @@ -20,6 +20,8 @@ import BoltProtocolV44 from './bolt-protocol-v4x4' import transformersFactories from './bolt-protocol-v5x0.transformer' import Transformer from './transformer' +import RequestMessage from './request-message' +import { LoginObserver } from './stream-observers' import { internal } from 'neo4j-driver-core' @@ -34,8 +36,33 @@ export default class BoltProtocol extends BoltProtocolV44 { get transformer () { if (this._transformer === undefined) { - this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config))) + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) } return this._transformer } + + /** + * 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 => this._onLoginCompleted(metadata, onComplete) + }) + + this.write( + RequestMessage.hello(userAgent, authToken, this._serversideRouting), + observer, + true + ) + + return observer + } } diff --git a/packages/bolt-connection/src/bolt/bolt-protocol-v5x0.transformer.js b/packages/bolt-connection/src/bolt/bolt-protocol-v5x0.transformer.js index 148958c08..97620408f 100644 --- a/packages/bolt-connection/src/bolt/bolt-protocol-v5x0.transformer.js +++ b/packages/bolt-connection/src/bolt/bolt-protocol-v5x0.transformer.js @@ -18,8 +18,14 @@ */ import { structure } from '../packstream' -import { Node, Relationship, UnboundRelationship } from 'neo4j-driver-core' +import { + Node, + Relationship, + UnboundRelationship +} from 'neo4j-driver-core' + import v4x4 from './bolt-protocol-v4x4.transformer' +import v5x0Utc from './bolt-protocol-v5x0.utc.transformer' const NODE_STRUCT_SIZE = 4 const RELATIONSHIP_STRUCT_SIZE = 8 @@ -118,6 +124,7 @@ function createUnboundRelationshipTransformer (config) { export default { ...v4x4, + ...v5x0Utc, createNodeTransformer, createRelationshipTransformer, createUnboundRelationshipTransformer diff --git a/packages/bolt-connection/src/bolt/bolt-protocol-v5x0.utc.transformer.js b/packages/bolt-connection/src/bolt/bolt-protocol-v5x0.utc.transformer.js new file mode 100644 index 000000000..94c0a8b1d --- /dev/null +++ b/packages/bolt-connection/src/bolt/bolt-protocol-v5x0.utc.transformer.js @@ -0,0 +1,277 @@ +/** + * 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 { structure } from '../packstream' +import { + DateTime, + isInt, + int, + internal +} from 'neo4j-driver-core' + +import v4x4 from './bolt-protocol-v4x4.transformer' + +import { + epochSecondAndNanoToLocalDateTime +} from './temporal-factory' + +const { + temporalUtil: { + localDateTimeToEpochSecond + } +} = internal + +const DATE_TIME_WITH_ZONE_OFFSET = 0x49 +const DATE_TIME_WITH_ZONE_OFFSET_STRUCT_SIZE = 3 + +const DATE_TIME_WITH_ZONE_ID = 0x69 +const DATE_TIME_WITH_ZONE_ID_STRUCT_SIZE = 3 + +function createDateTimeWithZoneIdTransformer (config, logger) { + const { disableLosslessIntegers, useBigInt } = config + const dateTimeWithZoneIdTransformer = v4x4.createDateTimeWithZoneIdTransformer(config) + return dateTimeWithZoneIdTransformer.extendsWith({ + signature: DATE_TIME_WITH_ZONE_ID, + fromStructure: struct => { + structure.verifyStructSize( + 'DateTimeWithZoneId', + DATE_TIME_WITH_ZONE_ID_STRUCT_SIZE, + struct.size + ) + + const [epochSecond, nano, timeZoneId] = struct.fields + + 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) + }, + toStructure: value => { + 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) + + if (value.timeZoneOffsetSeconds == null) { + logger.warn('DateTime objects without "timeZoneOffsetSeconds" property ' + + 'are prune to bugs related to ambiguous times. For instance, ' + + '2022-10-30T2:30:00[Europe/Berlin] could be GMT+1 or GMT+2.') + } + const utc = epochSecond.subtract(offset) + + const nano = int(value.nanosecond) + const timeZoneId = value.timeZoneId + + return new structure.Structure(DATE_TIME_WITH_ZONE_ID, [utc, nano, timeZoneId]) + } + }) +} + +/** + * 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 createDateTimeWithOffsetTransformer (config) { + const { disableLosslessIntegers, useBigInt } = config + const dateTimeWithOffsetTransformer = v4x4.createDateTimeWithOffsetTransformer(config) + return dateTimeWithOffsetTransformer.extendsWith({ + signature: DATE_TIME_WITH_ZONE_OFFSET, + toStructure: value => { + 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) + return new structure.Structure(DATE_TIME_WITH_ZONE_OFFSET, [utcSecond, nano, timeZoneOffsetSeconds]) + }, + fromStructure: struct => { + structure.verifyStructSize( + 'DateTimeWithZoneOffset', + DATE_TIME_WITH_ZONE_OFFSET_STRUCT_SIZE, + struct.size + ) + + const [utcSecond, nano, timeZoneOffsetSeconds] = struct.fields + + 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) + } + }) +} + +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 +} + +export default { + createDateTimeWithZoneIdTransformer, + createDateTimeWithOffsetTransformer +} diff --git a/packages/bolt-connection/src/bolt/request-message.js b/packages/bolt-connection/src/bolt/request-message.js index 5183b3cae..4a525daa7 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/test/bolt/__snapshots__/bolt-protocol-v4x3.test.js.snap b/packages/bolt-connection/test/bolt/__snapshots__/bolt-protocol-v4x3.test.js.snap index f1c9a01ed..405f0bd6b 100644 --- a/packages/bolt-connection/test/bolt/__snapshots__/bolt-protocol-v4x3.test.js.snap +++ b/packages/bolt-connection/test/bolt/__snapshots__/bolt-protocol-v4x3.test.js.snap @@ -59,3 +59,11 @@ exports[`#unit BoltProtocolV4x3 .unpack() should not unpack with wrong size (Tim exports[`#unit BoltProtocolV4x3 .unpack() should not unpack with wrong size (UnboundRelationship with less fields) 1`] = `"Wrong struct size for UnboundRelationship, expected 3 but was 2"`; exports[`#unit BoltProtocolV4x3 .unpack() should not unpack with wrong size (UnboundRelationship with more fields) 1`] = `"Wrong struct size for UnboundRelationship, expected 3 but was 4"`; + +exports[`#unit BoltProtocolV4x3 utc patch the server accepted the patch should not unpack with wrong size (DateTimeWithZoneId with less fields) 1`] = `"Wrong struct size for DateTimeWithZoneId, expected 3 but was 2"`; + +exports[`#unit BoltProtocolV4x3 utc patch the server accepted the patch should not unpack with wrong size (DateTimeWithZoneId with more fields) 1`] = `"Wrong struct size for DateTimeWithZoneId, expected 3 but was 4"`; + +exports[`#unit BoltProtocolV4x3 utc patch the server accepted the patch should not unpack with wrong size (DateTimeWithZoneOffset with less fields) 1`] = `"Wrong struct size for DateTimeWithZoneOffset, expected 3 but was 2"`; + +exports[`#unit BoltProtocolV4x3 utc patch the server accepted the patch 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/bolt/__snapshots__/bolt-protocol-v4x4.test.js.snap b/packages/bolt-connection/test/bolt/__snapshots__/bolt-protocol-v4x4.test.js.snap index 4068caf7d..74085de44 100644 --- a/packages/bolt-connection/test/bolt/__snapshots__/bolt-protocol-v4x4.test.js.snap +++ b/packages/bolt-connection/test/bolt/__snapshots__/bolt-protocol-v4x4.test.js.snap @@ -59,3 +59,11 @@ exports[`#unit BoltProtocolV4x4 .unpack() should not unpack with wrong size (Tim exports[`#unit BoltProtocolV4x4 .unpack() should not unpack with wrong size (UnboundRelationship with less fields) 1`] = `"Wrong struct size for UnboundRelationship, expected 3 but was 2"`; exports[`#unit BoltProtocolV4x4 .unpack() should not unpack with wrong size (UnboundRelationship with more fields) 1`] = `"Wrong struct size for UnboundRelationship, expected 3 but was 4"`; + +exports[`#unit BoltProtocolV4x4 utc patch the server accepted the patch should not unpack with wrong size (DateTimeWithZoneId with less fields) 1`] = `"Wrong struct size for DateTimeWithZoneId, expected 3 but was 2"`; + +exports[`#unit BoltProtocolV4x4 utc patch the server accepted the patch should not unpack with wrong size (DateTimeWithZoneId with more fields) 1`] = `"Wrong struct size for DateTimeWithZoneId, expected 3 but was 4"`; + +exports[`#unit BoltProtocolV4x4 utc patch the server accepted the patch should not unpack with wrong size (DateTimeWithZoneOffset with less fields) 1`] = `"Wrong struct size for DateTimeWithZoneOffset, expected 3 but was 2"`; + +exports[`#unit BoltProtocolV4x4 utc patch the server accepted the patch 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/bolt/bolt-protocol-v4x3.test.js b/packages/bolt-connection/test/bolt/bolt-protocol-v4x3.test.js index 84982df25..34bc28f27 100644 --- a/packages/bolt-connection/test/bolt/bolt-protocol-v4x3.test.js +++ b/packages/bolt-connection/test/bolt/bolt-protocol-v4x3.test.js @@ -44,7 +44,8 @@ const WRITE = 'WRITE' const { txConfig: { TxConfig }, - bookmarks: { Bookmarks } + bookmarks: { Bookmarks }, + logger: { Logger } } = internal describe('#unit BoltProtocolV4x3', () => { @@ -196,7 +197,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]) @@ -673,4 +674,328 @@ describe('#unit BoltProtocolV4x3', () => { expect(unpacked).toEqual(object) }) }) + + 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.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 packable = protocol.packable(object) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + + 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 packable = protocol.packable(object) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + + 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(loggerFunction) + .toBeCalledWith('warn', + 'DateTime objects without "timeZoneOffsetSeconds" property ' + + 'are prune to bugs related to ambiguous times. For instance, ' + + '2022-10-30T2:30:00[Europe/Berlin] could be GMT+1 or GMT+2.') + + expect(unpackedDateTimeWithoutOffset).toEqual(object) + }) + + it.each([ + [ + 'DateTimeWithZoneOffset with less fields', + new structure.Structure(0x49, [1, 2]) + ], + [ + 'DateTimeWithZoneOffset with more fields', + new structure.Structure(0x49, [1, 2, 3, 4]) + ], + [ + 'DateTimeWithZoneId with less fields', + new structure.Structure(0x69, [1, 2]) + ], + [ + 'DateTimeWithZoneId with more fields', + new structure.Structure(0x69, [1, 2, 'America/Sao Paulo', 'Brasil']) + ] + ])('should not unpack with wrong size (%s)', (_, struct) => { + const packable = protocol.packable(struct) + + expect(packable).not.toThrow() + + buffer.reset() + + expect(() => protocol.unpack(buffer)).toThrowErrorMatchingSnapshot() + }) + + it.each([ + [ + 'DateTimeWithZoneOffset', + new structure.Structure(0x49, [ + 1655212878, 183_000_000, 120 * 60 + ]), + new DateTime(2022, 6, 14, 15, 21, 18, 183_000_000, 120 * 60) + ], + [ + 'DateTimeWithZoneId', + new structure.Structure(0x69, [ + 1655212878, 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.Structure(0x69, [ + 1655212878, 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 packable = protocol.packable(struct) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + expect(unpacked).toEqual(object) + }) + + it.each([ + [ + 'DateTimeWithZoneOffset/0x46', + new structure.Structure(0x46, [1, 2, 3]) + ], + [ + 'DateTimeWithZoneId/0x66', + new structure.Structure(0x66, [1, 2, 'America/Sao Paulo']) + ] + ])('should unpack deprecated temporal types as unknown structs (%s)', (_, struct) => { + const packable = protocol.packable(struct) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + expect(unpacked).toEqual(struct) + }) + }) + + describe('the server did not accept th 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.each([ + [ + 'DateTimeWithZoneOffset/0x49', + new structure.Structure(0x49, [1, 2, 3]) + ], + [ + 'DateTimeWithZoneId/0x69', + new structure.Structure(0x69, [1, 2, 'America/Sao Paulo']) + ] + ])('should unpack utc temporal types as unknown structs (%s)', (_, struct) => { + const packable = protocol.packable(struct) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + expect(unpacked).toEqual(struct) + }) + + it.each([ + [ + 'DateTimeWithZoneOffset', + new structure.Structure(0x46, [1, 2, 3]), + new DateTime(1970, 1, 1, 0, 0, 1, 2, 3) + ], + [ + 'DateTimeWithZoneId', + new structure.Structure(0x66, [1, 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 packable = protocol.packable(struct) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + 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 packable = protocol.packable(object) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + expect(unpacked).toEqual(object) + }) + }) + }) }) 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 80019513f..a0a046ace 100644 --- a/packages/bolt-connection/test/bolt/bolt-protocol-v4x4.test.js +++ b/packages/bolt-connection/test/bolt/bolt-protocol-v4x4.test.js @@ -44,7 +44,8 @@ const WRITE = 'WRITE' const { txConfig: { TxConfig }, - bookmarks: { Bookmarks } + bookmarks: { Bookmarks }, + logger: { Logger } } = internal describe('#unit BoltProtocolV4x4', () => { @@ -270,7 +271,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]) @@ -706,4 +707,328 @@ describe('#unit BoltProtocolV4x4', () => { expect(unpacked).toEqual(object) }) }) + + 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.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 packable = protocol.packable(object) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + + 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 packable = protocol.packable(object) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + + 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(loggerFunction) + .toBeCalledWith('warn', + 'DateTime objects without "timeZoneOffsetSeconds" property ' + + 'are prune to bugs related to ambiguous times. For instance, ' + + '2022-10-30T2:30:00[Europe/Berlin] could be GMT+1 or GMT+2.') + + expect(unpackedDateTimeWithoutOffset).toEqual(object) + }) + + it.each([ + [ + 'DateTimeWithZoneOffset with less fields', + new structure.Structure(0x49, [1, 2]) + ], + [ + 'DateTimeWithZoneOffset with more fields', + new structure.Structure(0x49, [1, 2, 3, 4]) + ], + [ + 'DateTimeWithZoneId with less fields', + new structure.Structure(0x69, [1, 2]) + ], + [ + 'DateTimeWithZoneId with more fields', + new structure.Structure(0x69, [1, 2, 'America/Sao Paulo', 'Brasil']) + ] + ])('should not unpack with wrong size (%s)', (_, struct) => { + const packable = protocol.packable(struct) + + expect(packable).not.toThrow() + + buffer.reset() + + expect(() => protocol.unpack(buffer)).toThrowErrorMatchingSnapshot() + }) + + it.each([ + [ + 'DateTimeWithZoneOffset', + new structure.Structure(0x49, [ + 1655212878, 183_000_000, 120 * 60 + ]), + new DateTime(2022, 6, 14, 15, 21, 18, 183_000_000, 120 * 60) + ], + [ + 'DateTimeWithZoneId', + new structure.Structure(0x69, [ + 1655212878, 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.Structure(0x69, [ + 1655212878, 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 packable = protocol.packable(struct) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + expect(unpacked).toEqual(object) + }) + + it.each([ + [ + 'DateTimeWithZoneOffset/0x46', + new structure.Structure(0x46, [1, 2, 3]) + ], + [ + 'DateTimeWithZoneId/0x66', + new structure.Structure(0x66, [1, 2, 'America/Sao Paulo']) + ] + ])('should unpack deprecated temporal types as unknown structs (%s)', (_, struct) => { + const packable = protocol.packable(struct) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + expect(unpacked).toEqual(struct) + }) + }) + + describe('the server did not accept th 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.each([ + [ + 'DateTimeWithZoneOffset/0x49', + new structure.Structure(0x49, [1, 2, 3]) + ], + [ + 'DateTimeWithZoneId/0x69', + new structure.Structure(0x69, [1, 2, 'America/Sao Paulo']) + ] + ])('should unpack utc temporal types as unknown structs (%s)', (_, struct) => { + const packable = protocol.packable(struct) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + expect(unpacked).toEqual(struct) + }) + + it.each([ + [ + 'DateTimeWithZoneOffset', + new structure.Structure(0x46, [1, 2, 3]), + new DateTime(1970, 1, 1, 0, 0, 1, 2, 3) + ], + [ + 'DateTimeWithZoneId', + new structure.Structure(0x66, [1, 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 packable = protocol.packable(struct) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + 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 packable = protocol.packable(object) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + expect(unpacked).toEqual(object) + }) + }) + }) }) diff --git a/packages/bolt-connection/test/bolt/bolt-protocol-v5x0.test.js b/packages/bolt-connection/test/bolt/bolt-protocol-v5x0.test.js index a8e22faf3..223cac107 100644 --- a/packages/bolt-connection/test/bolt/bolt-protocol-v5x0.test.js +++ b/packages/bolt-connection/test/bolt/bolt-protocol-v5x0.test.js @@ -44,7 +44,8 @@ const WRITE = 'WRITE' const { txConfig: { TxConfig }, - bookmarks: { Bookmarks } + bookmarks: { Bookmarks }, + logger: { Logger } } = internal describe('#unit BoltProtocolV5x0', () => { @@ -407,8 +408,22 @@ describe('#unit BoltProtocolV5x0', () => { ['Time', new Time(1, 1, 1, 1, 1)], ['Date', new Date(1, 1, 1)], ['LocalDateTime', new LocalDateTime(1, 1, 1, 1, 1, 1, 1)], - ['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)], + [ + 'DateTimeWithZoneOffset', + new DateTime(2022, 6, 14, 15, 21, 18, 183_000_000, 120 * 60) + ], + [ + 'DateTimeWithZoneOffset / 1978', + new DateTime(1978, 12, 16, 10, 5, 59, 128000987, -150 * 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') + ], ['Point2D', new Point(1, 1, 1)], ['Point3D', new Point(1, 1, 1, 1)] ])('should pack spatial types and temporal types (%s)', (_, object) => { @@ -428,8 +443,134 @@ describe('#unit BoltProtocolV5x0', () => { buffer.reset() const unpacked = protocol.unpack(buffer) + 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') + ], + [ + 'DateTimeWithZoneId / Istanbul', + new DateTime(1978, 12, 16, 12, 35, 59, 128000987, undefined, 'Europe/Istanbul') + ], + [ + 'DateTimeWithZoneId / Istanbul', + new DateTime(2020, 6, 15, 4, 30, 0, 183_000_000, undefined, 'Pacific/Honolulu') + ] + ])('should pack and unpack DateTimeWithZoneId and without offset (%s)', (_, object) => { + const buffer = alloc(256) + const loggerFunction = jest.fn() + const protocol = new BoltProtocolV5x0( + new utils.MessageRecordingConnection(), + buffer, + { + disableLosslessIntegers: true + }, + undefined, + new Logger('debug', loggerFunction) + ) + + const packable = protocol.packable(object) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + + 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(loggerFunction) + .toBeCalledWith('warn', + 'DateTime objects without "timeZoneOffsetSeconds" property ' + + 'are prune to bugs related to ambiguous times. For instance, ' + + '2022-10-30T2:30:00[Europe/Berlin] could be GMT+1 or GMT+2.') + + expect(unpackedDateTimeWithoutOffset).toEqual(object) + }) }) describe('.unpack()', () => { @@ -621,19 +762,19 @@ describe('#unit BoltProtocolV5x0', () => { ], [ 'DateTimeWithZoneOffset with less fields', - new structure.Structure(0x46, [1, 2]) + new structure.Structure(0x49, [1, 2]) ], [ 'DateTimeWithZoneOffset with more fields', - new structure.Structure(0x46, [1, 2, 3, 4]) + new structure.Structure(0x49, [1, 2, 3, 4]) ], [ 'DateTimeWithZoneId with less fields', - new structure.Structure(0x66, [1, 2]) + new structure.Structure(0x69, [1, 2]) ], [ 'DateTimeWithZoneId with more fields', - new structure.Structure(0x66, [1, 2, 'America/Sao Paulo', 'Brasil']) + new structure.Structure(0x69, [1, 2, 'America/Sao Paulo', 'Brasil']) ] ])('should not unpack with wrong size (%s)', (_, struct) => { const buffer = alloc(256) @@ -690,13 +831,38 @@ describe('#unit BoltProtocolV5x0', () => { ], [ 'DateTimeWithZoneOffset', - new structure.Structure(0x46, [1, 2, 3]), - new DateTime(1970, 1, 1, 0, 0, 1, 2, 3) + new structure.Structure(0x49, [ + 1655212878, 183_000_000, 120 * 60 + ]), + new DateTime(2022, 6, 14, 15, 21, 18, 183_000_000, 120 * 60) + ], + [ + 'DateTimeWithZoneOffset / 1978', + new structure.Structure(0x49, [ + 282659759, 128000987, -150 * 60 + ]), + new DateTime(1978, 12, 16, 10, 5, 59, 128000987, -150 * 60) ], [ 'DateTimeWithZoneId', - new structure.Structure(0x66, [1, 2, 'America/Sao Paulo']), - new DateTime(1970, 1, 1, 0, 0, 1, 2, undefined, 'America/Sao Paulo') + new structure.Structure(0x69, [ + 1655212878, 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.Structure(0x69, [ + 1655212878, 183_000_000, 'Australia/Eucla' + ]), + new DateTime(2022, 6, 14, 22, 6, 18, 183_000_000, 8 * 60 * 60 + 45 * 60, 'Australia/Eucla') + ], + [ + 'DateTimeWithZoneId / Honolulu', + new structure.Structure(0x69, [ + 1592231400, 183_000_000, 'Pacific/Honolulu' + ]), + new DateTime(2020, 6, 15, 4, 30, 0, 183_000_000, -10 * 60 * 60, 'Pacific/Honolulu') ] ])('should unpack spatial types and temporal types (%s)', (_, struct, object) => { const buffer = alloc(256) @@ -717,5 +883,34 @@ describe('#unit BoltProtocolV5x0', () => { const unpacked = protocol.unpack(buffer) expect(unpacked).toEqual(object) }) + + it.each([ + [ + 'DateTimeWithZoneOffset/0x46', + new structure.Structure(0x46, [1, 2, 3]) + ], + [ + 'DateTimeWithZoneId/0x66', + new structure.Structure(0x66, [1, 2, 'America/Sao Paulo']) + ] + ])('should unpack deprecated temporal types as unknown structs (%s)', (_, struct) => { + const buffer = alloc(256) + const protocol = new BoltProtocolV5x0( + new utils.MessageRecordingConnection(), + buffer, + { + disableLosslessIntegers: true + } + ) + + const packable = protocol.packable(struct) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + expect(unpacked).toEqual(struct) + }) }) }) 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 60a5f6ddd..26cc0179e 100644 --- a/packages/core/src/temporal-types.ts +++ b/packages/core/src/temporal-types.ts @@ -670,7 +670,13 @@ export class DateTime { if (this.timeZoneOffsetSeconds === undefined) { throw new Error('Requires DateTime created with time zone offset') } - return util.isoStringToStandardDate(this.toString()) + 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}]` : '', '') + ) } /** @@ -686,10 +692,16 @@ export class DateTime { this.second, this.nanosecond ) + + const timeOffset = this.timeZoneOffsetSeconds != null + ? util.timeZoneOffsetToIsoString(this.timeZoneOffsetSeconds ?? 0) + : '' + const timeZoneStr = this.timeZoneId != null ? `[${this.timeZoneId}]` - : util.timeZoneOffsetToIsoString(this.timeZoneOffsetSeconds ?? 0) - return localDateTimeStr + timeZoneStr + : '' + + return localDateTimeStr + timeOffset + timeZoneStr } } @@ -741,23 +753,25 @@ function verifyTimeZoneArguments ( const offsetDefined = timeZoneOffsetSeconds !== null && timeZoneOffsetSeconds !== undefined const idDefined = timeZoneId !== null && timeZoneId !== undefined && 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( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `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/core/test/temporal-types.test.ts b/packages/core/test/temporal-types.test.ts index 482fbdc5f..10de7564c 100644 --- a/packages/core/test/temporal-types.test.ts +++ b/packages/core/test/temporal-types.test.ts @@ -74,7 +74,41 @@ describe('LocalDateTime', () => { }) describe('DateTime', () => { + describe('constructor', () => { + it('should be able to create a date with zone id and offset', () => { + const datetime = new DateTime(2022, 6, 16, 11, 19, 25, 400004, 2 * 60 * 60, 'Europe/Stockholm') + + expect(datetime.year).toEqual(2022) + expect(datetime.month).toEqual(6) + expect(datetime.day).toEqual(16) + expect(datetime.hour).toEqual(11) + expect(datetime.minute).toEqual(19) + expect(datetime.second).toEqual(25) + expect(datetime.nanosecond).toEqual(400004) + expect(datetime.timeZoneOffsetSeconds).toEqual(2 * 60 * 60) + expect(datetime.timeZoneId).toEqual('Europe/Stockholm') + }) + }) + describe('.toStandardDate()', () => { + it('should convert to a standard date (offset + zone id)', () => { + const datetime = new DateTime(2022, 6, 16, 11, 19, 25, 4000004, 2 * 60 * 60, 'Europe/Stockholm') + + const standardDate = datetime.toStandardDate() + + expect(standardDate.getFullYear()).toEqual(datetime.year) + expect(standardDate.getMonth()).toEqual(datetime.month - 1) + expect(standardDate.getDate()).toEqual(datetime.day) + const offsetInMinutes = offset(standardDate) + const offsetAdjust = offsetInMinutes - (datetime.timeZoneOffsetSeconds ?? 0) / 60 + const hourDiff = Math.abs(offsetAdjust / 60) + const minuteDiff = Math.abs(offsetAdjust % 60) + expect(standardDate.getHours()).toBe(datetime.hour - hourDiff) + expect(standardDate.getMinutes()).toBe(datetime.minute - minuteDiff) + expect(standardDate.getSeconds()).toBe(datetime.second) + expect(standardDate.getMilliseconds()).toBe(Math.round(datetime.nanosecond / 1000000)) + }) + it('should convert to a standard date (offset)', () => { const datetime = new DateTime(2020, 12, 15, 12, 2, 3, 4000000, 120 * 60) @@ -90,7 +124,7 @@ describe('DateTime', () => { expect(standardDate.getHours()).toBe(datetime.hour - hourDiff) expect(standardDate.getMinutes()).toBe(datetime.minute - minuteDiff) expect(standardDate.getSeconds()).toBe(datetime.second) - expect(standardDate.getMilliseconds()).toBe(datetime.nanosecond / 1000000) + expect(standardDate.getMilliseconds()).toBe(Math.round(datetime.nanosecond / 1000000)) }) it('should not convert to a standard date (zoneid)', () => { diff --git a/packages/neo4j-driver/test/temporal-types.test.js b/packages/neo4j-driver/test/temporal-types.test.js index 9815a90f4..395ce09a6 100644 --- a/packages/neo4j-driver/test/temporal-types.test.js +++ b/packages/neo4j-driver/test/temporal-types.test.js @@ -1007,9 +1007,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 a385b5d04..f7de792d9 100644 --- a/packages/testkit-backend/src/cypher-native-binders.js +++ b/packages/testkit-backend/src/cypher-native-binders.js @@ -115,6 +115,42 @@ function valueResponseOfObject (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)) { @@ -123,26 +159,88 @@ function valueResponseOfObject (x) { return valueResponse('CypherMap', map) } +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/feature/common.js b/packages/testkit-backend/src/feature/common.js index 2dc79ad92..26d7e4e12 100644 --- a/packages/testkit-backend/src/feature/common.js +++ b/packages/testkit-backend/src/feature/common.js @@ -19,6 +19,7 @@ const features = [ 'Feature:Auth:Bearer', 'Feature:API:SSLConfig', 'Feature:API:SSLSchemes', + 'Feature:API:Type.Temporal', 'AuthorizationExpiredTreatment', 'ConfHint:connection.recv_timeout_seconds', 'Feature:Impersonation', @@ -28,6 +29,7 @@ const features = [ 'Feature:Bolt:4.3', 'Feature:Bolt:4.4', 'Feature:Bolt:5.0', + 'Feature:Bolt:Patch:UTC', 'Feature:API:ConnectionAcquisitionTimeout', 'Feature:API:Driver:GetServerInfo', 'Feature:API:Driver.VerifyConnectivity', diff --git a/packages/testkit-backend/src/skipped-tests/common.js b/packages/testkit-backend/src/skipped-tests/common.js index 385218ba5..e0602f137 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( 'Testkit implemenation is deprecated', ifEquals('stub.basic_query.test_basic_query.TestBasicQuery.test_5x0_populates_node_only_element_id'),