diff --git a/packages/bolt-connection/src/bolt/bolt-protocol-v5x5.js b/packages/bolt-connection/src/bolt/bolt-protocol-v5x5.js new file mode 100644 index 000000000..464c244a0 --- /dev/null +++ b/packages/bolt-connection/src/bolt/bolt-protocol-v5x5.js @@ -0,0 +1,189 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * 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 BoltProtocolV5x4 from './bolt-protocol-v5x4' + +import transformersFactories from './bolt-protocol-v5x5.transformer' +import Transformer from './transformer' +import RequestMessage from './request-message' +import { LoginObserver, ResultStreamObserver } from './stream-observers' + +import { internal } from 'neo4j-driver-core' + +const { + constants: { BOLT_PROTOCOL_V5_5, FETCH_ALL } +} = internal + +const DEFAULT_DIAGNOSTIC_RECORD = Object.freeze({ + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' +}) + +export default class BoltProtocol extends BoltProtocolV5x4 { + get version () { + return BOLT_PROTOCOL_V5_5 + } + + get transformer () { + if (this._transformer === undefined) { + 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} args The params + * @param {string} args.userAgent The user agent + * @param {any} args.authToken The auth token + * @param {NotificationFilter} args.notificationFilter The notification filters. + * @param {function(error)} args.onError On error callback + * @param {function(onComplete)} args.onComplete On complete callback + * @returns {LoginObserver} The Login observer + */ + initialize ({ userAgent, boltAgent, authToken, notificationFilter, onError, onComplete } = {}) { + const state = {} + const observer = new LoginObserver({ + onError: error => this._onLoginError(error, onError), + onCompleted: metadata => { + state.metadata = metadata + return this._onLoginCompleted(metadata) + } + }) + + this.write( + RequestMessage.hello5x5(userAgent, boltAgent, notificationFilter, this._serversideRouting), + observer, + false + ) + + return this.logon({ + authToken, + onComplete: metadata => onComplete({ ...metadata, ...state.metadata }), + onError, + flush: true + }) + } + + beginTransaction ({ + bookmarks, + txConfig, + database, + mode, + impersonatedUser, + notificationFilter, + beforeError, + afterError, + beforeComplete, + afterComplete + } = {}) { + const observer = new ResultStreamObserver({ + server: this._server, + beforeError, + afterError, + beforeComplete, + afterComplete + }) + observer.prepareToHandleSingleResponse() + + this.write( + RequestMessage.begin5x5({ bookmarks, txConfig, database, mode, impersonatedUser, notificationFilter }), + observer, + true + ) + + return observer + } + + run ( + query, + parameters, + { + bookmarks, + txConfig, + database, + mode, + impersonatedUser, + notificationFilter, + beforeKeys, + afterKeys, + beforeError, + afterError, + beforeComplete, + afterComplete, + flush = true, + reactive = false, + fetchSize = FETCH_ALL, + highRecordWatermark = Number.MAX_VALUE, + lowRecordWatermark = Number.MAX_VALUE + } = {} + ) { + const observer = new ResultStreamObserver({ + server: this._server, + reactive, + fetchSize, + moreFunction: this._requestMore.bind(this), + discardFunction: this._requestDiscard.bind(this), + beforeKeys, + afterKeys, + beforeError, + afterError, + beforeComplete, + afterComplete, + highRecordWatermark, + lowRecordWatermark, + enrichMetadata: BoltProtocol._enrichMetadata + }) + + const flushRun = reactive + this.write( + RequestMessage.runWithMetadata5x5(query, parameters, { + bookmarks, + txConfig, + database, + mode, + impersonatedUser, + notificationFilter + }), + observer, + flushRun && flush + ) + + if (!reactive) { + this.write(RequestMessage.pull({ n: fetchSize }), observer, flush) + } + + return observer + } + + /** + * + * @param {object} metadata + * @returns {object} + */ + static _enrichMetadata (metadata) { + if (Array.isArray(metadata.statuses)) { + metadata.statuses = metadata.statuses.map(status => ({ + ...status, + diagnostic_record: status.diagnostic_record !== null ? { ...DEFAULT_DIAGNOSTIC_RECORD, ...status.diagnostic_record } : null + })) + } + + return metadata + } +} diff --git a/packages/bolt-connection/src/bolt/bolt-protocol-v5x5.transformer.js b/packages/bolt-connection/src/bolt/bolt-protocol-v5x5.transformer.js new file mode 100644 index 000000000..8b5456f0b --- /dev/null +++ b/packages/bolt-connection/src/bolt/bolt-protocol-v5x5.transformer.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * 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 v5x3 from './bolt-protocol-v5x3.transformer' + +export default { + ...v5x3 +} diff --git a/packages/bolt-connection/src/bolt/create.js b/packages/bolt-connection/src/bolt/create.js index 1e0fbc9cd..ed1c502e7 100644 --- a/packages/bolt-connection/src/bolt/create.js +++ b/packages/bolt-connection/src/bolt/create.js @@ -29,6 +29,7 @@ import BoltProtocolV5x1 from './bolt-protocol-v5x1' import BoltProtocolV5x2 from './bolt-protocol-v5x2' import BoltProtocolV5x3 from './bolt-protocol-v5x3' import BoltProtocolV5x4 from './bolt-protocol-v5x4' +import BoltProtocolV5x5 from './bolt-protocol-v5x5' // eslint-disable-next-line no-unused-vars import { Chunker, Dechunker } from '../channel' import ResponseHandler from './response-handler' @@ -229,6 +230,14 @@ function createProtocol ( log, onProtocolError, serversideRouting) + case 5.5: + return new BoltProtocolV5x5(server, + chunker, + packingConfig, + createResponseHandler, + log, + onProtocolError, + serversideRouting) default: throw newError('Unknown Bolt protocol version: ' + version) } diff --git a/packages/bolt-connection/src/bolt/handshake.js b/packages/bolt-connection/src/bolt/handshake.js index 92425826e..ae7aaa913 100644 --- a/packages/bolt-connection/src/bolt/handshake.js +++ b/packages/bolt-connection/src/bolt/handshake.js @@ -76,7 +76,7 @@ function parseNegotiatedResponse (buffer, log) { */ function newHandshakeBuffer () { return createHandshakeMessage([ - [version(5, 4), version(5, 0)], + [version(5, 5), version(5, 0)], [version(4, 4), version(4, 2)], version(4, 1), version(3, 0) diff --git a/packages/bolt-connection/src/bolt/request-message.js b/packages/bolt-connection/src/bolt/request-message.js index c5d166b01..1a08d100b 100644 --- a/packages/bolt-connection/src/bolt/request-message.js +++ b/packages/bolt-connection/src/bolt/request-message.js @@ -172,15 +172,7 @@ export default class RequestMessage { static hello5x2 (userAgent, notificationFilter = null, routing = null) { const metadata = { user_agent: userAgent } - if (notificationFilter) { - if (notificationFilter.minimumSeverityLevel) { - metadata.notifications_minimum_severity = notificationFilter.minimumSeverityLevel - } - - if (notificationFilter.disabledCategories) { - metadata.notifications_disabled_categories = notificationFilter.disabledCategories - } - } + appendLegacyNotificationFilterToMetadata(metadata, notificationFilter) if (routing) { metadata.routing = routing @@ -217,16 +209,45 @@ export default class RequestMessage { } } - if (notificationFilter) { - if (notificationFilter.minimumSeverityLevel) { - metadata.notifications_minimum_severity = notificationFilter.minimumSeverityLevel - } + appendLegacyNotificationFilterToMetadata(metadata, notificationFilter) + + if (routing) { + metadata.routing = routing + } + + return new RequestMessage( + HELLO, + [metadata], + () => `HELLO ${json.stringify(metadata)}` + ) + } + + /** + * Create a new HELLO message. + * @param {string} userAgent the user agent. + * @param {string} boltAgent the bolt agent. + * @param {NotificationFilter} notificationFilter the notification filter configured + * @param {Object} routing server side routing, set to routing context to turn on server side routing (> 4.1) + * @return {RequestMessage} new HELLO message. + */ + static hello5x5 (userAgent, boltAgent, notificationFilter = null, routing = null) { + const metadata = { } - if (notificationFilter.disabledCategories) { - metadata.notifications_disabled_categories = notificationFilter.disabledCategories + if (userAgent) { + metadata.user_agent = userAgent + } + + if (boltAgent) { + metadata.bolt_agent = { + product: boltAgent.product, + platform: boltAgent.platform, + language: boltAgent.language, + language_details: boltAgent.languageDetails } } + appendGqlNotificationFilterToMetadata(metadata, notificationFilter) + if (routing) { metadata.routing = routing } @@ -284,6 +305,27 @@ export default class RequestMessage { ) } + /** + * Create a new BEGIN message. + * @param {Bookmarks} bookmarks the bookmarks. + * @param {TxConfig} txConfig the configuration. + * @param {string} database the database name. + * @param {string} mode the access mode. + * @param {string} impersonatedUser the impersonated user. + * @param {NotificationFilter} notificationFilter the notification filter + * @return {RequestMessage} new BEGIN message. + */ + static begin5x5 ({ bookmarks, txConfig, database, mode, impersonatedUser, notificationFilter } = {}) { + const metadata = buildTxMetadata(bookmarks, txConfig, database, mode, impersonatedUser, notificationFilter, { + appendNotificationFilter: appendGqlNotificationFilterToMetadata + }) + return new RequestMessage( + BEGIN, + [metadata], + () => `BEGIN ${json.stringify(metadata)}` + ) + } + /** * Get a COMMIT message. * @return {RequestMessage} the COMMIT message. @@ -304,11 +346,13 @@ export default class RequestMessage { * Create a new RUN message with additional metadata. * @param {string} query the cypher query. * @param {Object} parameters the query parameters. - * @param {Bookmarks} bookmarks the bookmarks. - * @param {TxConfig} txConfig the configuration. - * @param {string} database the database name. - * @param {string} mode the access mode. - * @param {string} impersonatedUser the impersonated user. + * @param {Object} extra - extra params + * @param {Bookmarks} extra.bookmarks the bookmarks. + * @param {TxConfig} extra.txConfig the configuration. + * @param {string} extra.database the database name. + * @param {string} extra.mode the access mode. + * @param {string} extra.impersonatedUser the impersonated user. + * @param {notificationFilter} extra.notificationFilter the notification filter * @return {RequestMessage} new RUN message with additional metadata. */ static runWithMetadata ( @@ -325,6 +369,35 @@ export default class RequestMessage { ) } + /** + * Create a new RUN message with additional metadata. + * @param {string} query the cypher query. + * @param {Object} parameters the query parameters. + * @param {Object} extra - extra params + * @param {Bookmarks} extra.bookmarks the bookmarks. + * @param {TxConfig} extra.txConfig the configuration. + * @param {string} extra.database the database name. + * @param {string} extra.mode the access mode. + * @param {string} extra.impersonatedUser the impersonated user. + * @param {notificationFilter} extra.notificationFilter the notification filter + * @return {RequestMessage} new RUN message with additional metadata. + */ + static runWithMetadata5x5 ( + query, + parameters, + { bookmarks, txConfig, database, mode, impersonatedUser, notificationFilter } = {} + ) { + const metadata = buildTxMetadata(bookmarks, txConfig, database, mode, impersonatedUser, notificationFilter, { + appendNotificationFilter: appendGqlNotificationFilterToMetadata + }) + return new RequestMessage( + RUN, + [query, parameters, metadata], + () => + `RUN ${query} ${json.stringify(parameters)} ${json.stringify(metadata)}` + ) + } + /** * Get a GOODBYE message. * @return {RequestMessage} the GOODBYE message. @@ -437,9 +510,11 @@ export default class RequestMessage { * @param {string} mode the access mode. * @param {string} impersonatedUser the impersonated user mode. * @param {notificationFilter} notificationFilter the notification filter + * @param {Object} functions Transformation functions applied to metadata + * @param {function(metadata,notificationFilter):void} functions.appendNotificationFilter Changes metadata by appending the Notification Filter to it. * @return {Object} a metadata object. */ -function buildTxMetadata (bookmarks, txConfig, database, mode, impersonatedUser, notificationFilter) { +function buildTxMetadata (bookmarks, txConfig, database, mode, impersonatedUser, notificationFilter, functions = {}) { const metadata = {} if (!bookmarks.isEmpty()) { metadata.bookmarks = bookmarks.values() @@ -459,15 +534,9 @@ function buildTxMetadata (bookmarks, txConfig, database, mode, impersonatedUser, if (mode === ACCESS_MODE_READ) { metadata.mode = READ_MODE } - if (notificationFilter) { - if (notificationFilter.minimumSeverityLevel) { - metadata.notifications_minimum_severity = notificationFilter.minimumSeverityLevel - } - if (notificationFilter.disabledCategories) { - metadata.notifications_disabled_categories = notificationFilter.disabledCategories - } - } + const appendNotificationFilter = functions.appendNotificationFilter ?? appendLegacyNotificationFilterToMetadata + appendNotificationFilter(metadata, notificationFilter) return metadata } @@ -485,6 +554,38 @@ function buildStreamMetadata (stmtId, n) { return metadata } +function appendLegacyNotificationFilterToMetadata (metadata, notificationFilter) { + if (notificationFilter) { + if (notificationFilter.minimumSeverityLevel) { + metadata.notifications_minimum_severity = notificationFilter.minimumSeverityLevel + } + + if (notificationFilter.disabledCategories) { + metadata.notifications_disabled_categories = notificationFilter.disabledCategories + } + + if (notificationFilter.disabledClassifications) { + metadata.notifications_disabled_categories = notificationFilter.disabledClassifications + } + } +} + +function appendGqlNotificationFilterToMetadata (metadata, notificationFilter) { + if (notificationFilter) { + if (notificationFilter.minimumSeverityLevel) { + metadata.notifications_minimum_severity = notificationFilter.minimumSeverityLevel + } + + if (notificationFilter.disabledCategories) { + metadata.notifications_disabled_classifications = notificationFilter.disabledCategories + } + + if (notificationFilter.disabledClassifications) { + metadata.notifications_disabled_classifications = notificationFilter.disabledClassifications + } + } +} + // constants for messages that never change const PULL_ALL_MESSAGE = new RequestMessage(PULL_ALL, [], () => 'PULL_ALL') const RESET_MESSAGE = new RequestMessage(RESET, [], () => 'RESET') diff --git a/packages/bolt-connection/src/bolt/stream-observers.js b/packages/bolt-connection/src/bolt/stream-observers.js index 98bedc85a..336cbb30a 100644 --- a/packages/bolt-connection/src/bolt/stream-observers.js +++ b/packages/bolt-connection/src/bolt/stream-observers.js @@ -24,17 +24,18 @@ import { internal } from 'neo4j-driver-core' import RawRoutingTable from './routing-table-raw' +import { functional } from '../lang' const { constants: { FETCH_ALL } } = internal const { PROTOCOL_ERROR } = error class StreamObserver { - onNext (rawRecord) {} + onNext (rawRecord) { } - onError (_error) {} + onError (_error) { } - onCompleted (meta) {} + onCompleted (meta) { } } /** @@ -62,6 +63,7 @@ class ResultStreamObserver extends StreamObserver { * @param {function(keys: string[]): Promise|void} param.afterKeys - * @param {function(metadata: Object): Promise|void} param.beforeComplete - * @param {function(metadata: Object): Promise|void} param.afterComplete - + * @param {function(metadata: Object): Promise|void} param.enrichMetadata - */ constructor ({ reactive = false, @@ -76,7 +78,8 @@ class ResultStreamObserver extends StreamObserver { afterComplete, server, highRecordWatermark = Number.MAX_VALUE, - lowRecordWatermark = Number.MAX_VALUE + lowRecordWatermark = Number.MAX_VALUE, + enrichMetadata } = {}) { super() @@ -96,6 +99,7 @@ class ResultStreamObserver extends StreamObserver { this._afterKeys = afterKeys this._beforeComplete = beforeComplete this._afterComplete = afterComplete + this._enrichMetadata = enrichMetadata || functional.identity this._queryId = null this._moreFunction = moreFunction @@ -107,6 +111,8 @@ class ResultStreamObserver extends StreamObserver { this._setState(reactive ? _states.READY : _states.READY_STREAMING) this._setupAutoPull() this._paused = false + this._pulled = !reactive + this._haveRecordStreamed = false } /** @@ -137,6 +143,7 @@ class ResultStreamObserver extends StreamObserver { * @param {Array} rawRecord - An array with the raw record */ onNext (rawRecord) { + this._haveRecordStreamed = true const record = new Record(this._fieldKeys, rawRecord, this._fieldLookup) if (this._observers.some(o => o.onNext)) { this._observers.forEach(o => { @@ -245,11 +252,18 @@ class ResultStreamObserver extends StreamObserver { } _handlePullSuccess (meta) { - const completionMetadata = Object.assign( + const completionMetadata = this._enrichMetadata(Object.assign( this._server ? { server: this._server } : {}, this._meta, + { + stream_summary: { + have_records_streamed: this._haveRecordStreamed, + pulled: this._pulled, + has_keys: this._fieldKeys.length > 0 + } + }, meta - ) + )) if (![undefined, null, 'r', 'w', 'rw', 's'].includes(completionMetadata.type)) { this.onError( @@ -303,7 +317,9 @@ class ResultStreamObserver extends StreamObserver { for (let i = 0; i < meta.fields.length; i++) { this._fieldLookup[meta.fields[i]] = i } + } + if (meta.fields != null) { // remove fields key from metadata object delete meta.fields } @@ -392,6 +408,7 @@ class ResultStreamObserver extends StreamObserver { if (this._discard) { this._discardFunction(this._queryId, this) } else { + this._pulled = true this._moreFunction(this._queryId, this._fetchSize, this) } this._setState(_states.STREAMING) @@ -501,7 +518,7 @@ class ResetObserver extends StreamObserver { this.onError( newError( 'Received RECORD when resetting: received record is: ' + - json.stringify(record), + json.stringify(record), PROTOCOL_ERROR ) ) @@ -602,9 +619,9 @@ class ProcedureRouteObserver extends StreamObserver { this.onError( newError( 'Illegal response from router. Received ' + - this._records.length + - ' records but expected only one.\n' + - json.stringify(this._records), + this._records.length + + ' records but expected only one.\n' + + json.stringify(this._records), PROTOCOL_ERROR ) ) @@ -637,7 +654,7 @@ class RouteObserver extends StreamObserver { this.onError( newError( 'Received RECORD when resetting: received record is: ' + - json.stringify(record), + json.stringify(record), PROTOCOL_ERROR ) ) @@ -678,7 +695,7 @@ const _states = { name: () => { return 'READY_STREAMING' }, - pull: () => {} + pull: () => { } }, READY: { // reactive start state @@ -710,7 +727,7 @@ const _states = { name: () => { return 'STREAMING' }, - pull: () => {} + pull: () => { } }, FAILED: { onError: _error => { @@ -719,13 +736,13 @@ const _states = { name: () => { return 'FAILED' }, - pull: () => {} + pull: () => { } }, SUCCEEDED: { name: () => { return 'SUCCEEDED' }, - pull: () => {} + pull: () => { } } } diff --git a/packages/bolt-connection/test/bolt/__snapshots__/bolt-protocol-v5x5.test.js.snap b/packages/bolt-connection/test/bolt/__snapshots__/bolt-protocol-v5x5.test.js.snap new file mode 100644 index 000000000..fb80b2d02 --- /dev/null +++ b/packages/bolt-connection/test/bolt/__snapshots__/bolt-protocol-v5x5.test.js.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#unit BoltProtocolV5x5 .packable() should resultant function not pack graph types (Node) 1`] = `"It is not allowed to pass nodes in query parameters, given: (c:a {a:"b"})"`; + +exports[`#unit BoltProtocolV5x5 .packable() should resultant function not pack graph types (Path) 1`] = `"It is not allowed to pass paths in query parameters, given: [object Object]"`; + +exports[`#unit BoltProtocolV5x5 .packable() should resultant function not pack graph types (Relationship) 1`] = `"It is not allowed to pass relationships in query parameters, given: (e)-[:a {b:"c"}]->(f)"`; + +exports[`#unit BoltProtocolV5x5 .packable() should resultant function not pack graph types (UnboundRelationship) 1`] = `"It is not allowed to pass unbound relationships in query parameters, given: -[:a {b:"c"}]->"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (Date with less fields) 1`] = `"Wrong struct size for Date, expected 1 but was 0"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (Date with more fields) 1`] = `"Wrong struct size for Date, expected 1 but was 2"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (DateTimeWithZoneId with less fields) 1`] = `"Wrong struct size for DateTimeWithZoneId, expected 3 but was 2"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (DateTimeWithZoneId with more fields) 1`] = `"Wrong struct size for DateTimeWithZoneId, expected 3 but was 4"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (DateTimeWithZoneOffset with less fields) 1`] = `"Wrong struct size for DateTimeWithZoneOffset, expected 3 but was 2"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (DateTimeWithZoneOffset with more fields) 1`] = `"Wrong struct size for DateTimeWithZoneOffset, expected 3 but was 4"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (Duration with less fields) 1`] = `"Wrong struct size for Duration, expected 4 but was 3"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (Duration with more fields) 1`] = `"Wrong struct size for Duration, expected 4 but was 5"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (LocalDateTime with less fields) 1`] = `"Wrong struct size for LocalDateTime, expected 2 but was 1"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (LocalDateTime with more fields) 1`] = `"Wrong struct size for LocalDateTime, expected 2 but was 3"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (LocalTime with less fields) 1`] = `"Wrong struct size for LocalTime, expected 1 but was 0"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (LocalTime with more fields) 1`] = `"Wrong struct size for LocalTime, expected 1 but was 2"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (Node with less fields) 1`] = `"Wrong struct size for Node, expected 4 but was 3"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (Node with more fields) 1`] = `"Wrong struct size for Node, expected 4 but was 5"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (Path with less fields) 1`] = `"Wrong struct size for Path, expected 3 but was 2"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (Path with more fields) 1`] = `"Wrong struct size for Path, expected 3 but was 4"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (Point with less fields) 1`] = `"Wrong struct size for Point2D, expected 3 but was 2"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (Point with more fields) 1`] = `"Wrong struct size for Point2D, expected 3 but was 4"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (Point3D with less fields) 1`] = `"Wrong struct size for Point3D, expected 4 but was 3"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (Point3D with more fields) 1`] = `"Wrong struct size for Point3D, expected 4 but was 5"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (Relationship with less fields) 1`] = `"Wrong struct size for Relationship, expected 8 but was 5"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (Relationship with more fields) 1`] = `"Wrong struct size for Relationship, expected 8 but was 9"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (Time with less fields) 1`] = `"Wrong struct size for Time, expected 2 but was 1"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (Time with more fileds) 1`] = `"Wrong struct size for Time, expected 2 but was 3"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (UnboundRelationship with less fields) 1`] = `"Wrong struct size for UnboundRelationship, expected 4 but was 3"`; + +exports[`#unit BoltProtocolV5x5 .unpack() should not unpack with wrong size (UnboundRelationship with more fields) 1`] = `"Wrong struct size for UnboundRelationship, expected 4 but was 5"`; diff --git a/packages/bolt-connection/test/bolt/behaviour/notification-filter.js b/packages/bolt-connection/test/bolt/behaviour/notification-filter.js index 800dd768a..527ca8b72 100644 --- a/packages/bolt-connection/test/bolt/behaviour/notification-filter.js +++ b/packages/bolt-connection/test/bolt/behaviour/notification-filter.js @@ -18,6 +18,7 @@ import { internal, notificationFilterDisabledCategory, + notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel } from 'neo4j-driver-core' import RequestMessage from '../../../src/bolt/request-message' @@ -138,6 +139,50 @@ export function shouldSupportNotificationFilterOnInitialize (createProtocol) { } } +/** + * @param {function(recorder:MessageRecorder):BoltProtocolV1} createProtocol The protocol factory + */ +export function shouldSupportGqlNotificationFilterOnInitialize (createProtocol) { + it.each( + notificationFilterFixture() + )('should send notificationsFilter=%o on initialize', (notificationFilter) => { + const recorder = new utils.MessageRecordingConnection() + const protocol = createProtocol(recorder) + utils.spyProtocolWrite(protocol) + const userAgent = 'js-driver-123' + const authToken = { type: 'none' } + + const observer = protocol.initialize({ userAgent, authToken, notificationFilter }) + + protocol.verifyMessageCount(2) + + expect(protocol.messages[0]).toBeMessage( + RequestMessage.hello5x5(userAgent, undefined, notificationFilter) + ) + expect(protocol.messages[1]).toBeMessage( + RequestMessage.logon(authToken) + ) + + verifyObserversAndFlushes(protocol, observer) + }) + + function verifyObserversAndFlushes (protocol, observer) { + expect(protocol.observers.length).toBe(2) + + // hello observer + const helloObserver = protocol.observers[0] + expect(helloObserver).toBeInstanceOf(LoginObserver) + expect(helloObserver).not.toBe(observer) + + // login observer + const loginObserver = protocol.observers[1] + expect(loginObserver).toBeInstanceOf(LoginObserver) + expect(loginObserver).toBe(observer) + + expect(protocol.flushes).toEqual([false, true]) + } +} + /** * @param {function(recorder:MessageRecorder):BoltProtocolV1} createProtocol The protocol factory */ @@ -178,6 +223,46 @@ export function shouldSupportNotificationFilterOnBeginTransaction (createProtoco }) } +/** + * @param {function(recorder:MessageRecorder):BoltProtocolV1} createProtocol The protocol factory + */ +export function shouldSupportGqlNotificationFilterOnBeginTransaction (createProtocol) { + it.each( + notificationFilterFixture() + )('should send notificationsFilter=%o on beginning a transaction', (notificationFilter) => { + const recorder = new utils.MessageRecordingConnection() + const protocol = createProtocol(recorder) + utils.spyProtocolWrite(protocol) + + const database = 'testdb' + const bookmarks = new Bookmarks([ + 'neo4j:bookmark:v1:tx1', + 'neo4j:bookmark:v1:tx2' + ]) + const txConfig = new TxConfig({ + timeout: 5000, + metadata: { x: 1, y: 'something' } + }) + + const observer = protocol.beginTransaction({ + bookmarks, + txConfig, + database, + notificationFilter, + mode: WRITE + }) + + protocol.verifyMessageCount(1) + + expect(protocol.messages[0]).toBeMessage( + RequestMessage.begin5x5({ bookmarks, txConfig, database, mode: WRITE, notificationFilter }) + ) + + expect(protocol.observers).toEqual([observer]) + expect(protocol.flushes).toEqual([true]) + }) +} + /** * @param {function(recorder:MessageRecorder):BoltProtocolV1} createProtocol The protocol factory */ @@ -227,6 +312,55 @@ export function shouldSupportNotificationFilterOnRun (createProtocol) { }) } +/** + * @param {function(recorder:MessageRecorder):BoltProtocolV1} createProtocol The protocol factory + */ +export function shouldSupportGqlNotificationFilterOnRun (createProtocol) { + it.each( + notificationFilterFixture() + )('should send notificationsFilter=%o on run', (notificationFilter) => { + const recorder = new utils.MessageRecordingConnection() + const protocol = createProtocol(recorder) + utils.spyProtocolWrite(protocol) + + const database = 'testdb' + const bookmarks = new Bookmarks([ + 'neo4j:bookmark:v1:tx1', + 'neo4j:bookmark:v1:tx2' + ]) + const txConfig = new TxConfig({ + timeout: 5000, + metadata: { x: 1, y: 'something' } + }) + const query = 'RETURN $x, $y' + const parameters = { x: 'x', y: 'y' } + + const observer = protocol.run(query, parameters, { + bookmarks, + txConfig, + database, + mode: WRITE, + notificationFilter + }) + + protocol.verifyMessageCount(2) + + expect(protocol.messages[0]).toBeMessage( + RequestMessage.runWithMetadata5x5(query, parameters, { + bookmarks, + txConfig, + database, + mode: WRITE, + notificationFilter + }) + ) + + expect(protocol.messages[1]).toBeMessage(RequestMessage.pull()) + expect(protocol.observers).toEqual([observer, observer]) + expect(protocol.flushes).toEqual([false, true]) + }) +} + export function notificationFilterFixture () { return [ undefined, @@ -242,8 +376,11 @@ export function notificationFilterFixture () { function notificationFilterSetFixture () { const minimumSeverityLevelSet = Object.values(notificationFilterMinimumSeverityLevel) const disabledCategories = Object.values(notificationFilterDisabledCategory) + const disabledClassifications = Object.values(notificationFilterDisabledClassification) const disabledCategoriesSet = [...disabledCategories.keys()] .map(length => disabledCategories.slice(0, length + 1)) + const disabledClassificationsSet = [...disabledCategories.keys()] + .map(length => disabledCategories.slice(0, length + 1)) /** Polyfill flatMap for Node10 tests */ if (!minimumSeverityLevelSet.flatMap) { @@ -256,9 +393,14 @@ function notificationFilterSetFixture () { {}, ...minimumSeverityLevelSet.map(minimumSeverityLevel => ({ minimumSeverityLevel })), ...disabledCategoriesSet.map(disabledCategories => ({ disabledCategories })), + ...disabledClassificationsSet.map(disabledClassifications => ({ disabledClassifications })), ...minimumSeverityLevelSet.flatMap( minimumSeverityLevel => disabledCategories.map( - disabledCategories => ({ minimumSeverityLevel, disabledCategories }))) + disabledCategories => ({ minimumSeverityLevel, disabledCategories }))), + ...minimumSeverityLevelSet.flatMap( + minimumSeverityLevel => disabledClassifications.map( + disabledClassifications => ({ minimumSeverityLevel, disabledClassifications }))) + ] } diff --git a/packages/bolt-connection/test/bolt/bolt-protocol-v5x5.test.js b/packages/bolt-connection/test/bolt/bolt-protocol-v5x5.test.js new file mode 100644 index 000000000..19670087a --- /dev/null +++ b/packages/bolt-connection/test/bolt/bolt-protocol-v5x5.test.js @@ -0,0 +1,1499 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * 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 BoltProtocolV5x5 from '../../src/bolt/bolt-protocol-v5x5' +import RequestMessage from '../../src/bolt/request-message' +import { v2, structure } from '../../src/packstream' +import utils from '../test-utils' +import { LoginObserver, RouteObserver } from '../../src/bolt/stream-observers' +import fc from 'fast-check' +import { + Date, + DateTime, + Duration, + LocalDateTime, + LocalTime, + Path, + PathSegment, + Point, + Relationship, + Time, + UnboundRelationship, + Node, + internal +} from 'neo4j-driver-core' + +import { alloc } from '../../src/channel' +import { notificationFilterBehaviour, telemetryBehaviour } from './behaviour' + +const WRITE = 'WRITE' + +const { + txConfig: { TxConfig }, + bookmarks: { Bookmarks }, + logger: { Logger }, + temporalUtil +} = internal + +describe('#unit BoltProtocolV5x5', () => { + beforeEach(() => { + expect.extend(utils.matchers) + }) + + telemetryBehaviour.protocolSupportsTelemetry(newProtocol) + + it('should request routing information', () => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x5(recorder, null, false) + utils.spyProtocolWrite(protocol) + const routingContext = { someContextParam: 'value' } + const databaseName = 'name' + + const observer = protocol.requestRoutingInformation({ + routingContext, + databaseName + }) + + protocol.verifyMessageCount(1) + expect(protocol.messages[0]).toBeMessage( + RequestMessage.routeV4x4(routingContext, [], { databaseName, impersonatedUser: null }) + ) + expect(protocol.observers).toEqual([observer]) + expect(observer).toEqual(expect.any(RouteObserver)) + expect(protocol.flushes).toEqual([true]) + }) + + it('should request routing information sending bookmarks', () => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x5(recorder, null, false) + utils.spyProtocolWrite(protocol) + const routingContext = { someContextParam: 'value' } + const listOfBookmarks = ['a', 'b', 'c'] + const bookmarks = new Bookmarks(listOfBookmarks) + const databaseName = 'name' + + const observer = protocol.requestRoutingInformation({ + routingContext, + databaseName, + sessionContext: { bookmarks } + }) + + protocol.verifyMessageCount(1) + expect(protocol.messages[0]).toBeMessage( + RequestMessage.routeV4x4(routingContext, listOfBookmarks, { databaseName, impersonatedUser: null }) + ) + expect(protocol.observers).toEqual([observer]) + expect(observer).toEqual(expect.any(RouteObserver)) + expect(protocol.flushes).toEqual([true]) + }) + + it('should run a query', () => { + const database = 'testdb' + const bookmarks = new Bookmarks([ + 'neo4j:bookmark:v1:tx1', + 'neo4j:bookmark:v1:tx2' + ]) + const txConfig = new TxConfig({ + timeout: 5000, + metadata: { x: 1, y: 'something' } + }) + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x5(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const query = 'RETURN $x, $y' + const parameters = { x: 'x', y: 'y' } + + const observer = protocol.run(query, parameters, { + bookmarks, + txConfig, + database, + mode: WRITE + }) + + protocol.verifyMessageCount(2) + + expect(protocol.messages[0]).toBeMessage( + RequestMessage.runWithMetadata(query, parameters, { + bookmarks, + txConfig, + database, + mode: WRITE + }) + ) + expect(protocol.messages[1]).toBeMessage(RequestMessage.pull()) + expect(protocol.observers).toEqual([observer, observer]) + expect(protocol.flushes).toEqual([false, true]) + }) + + it('should run a with impersonated user', () => { + const database = 'testdb' + const impersonatedUser = 'the impostor' + const bookmarks = new Bookmarks([ + 'neo4j:bookmark:v1:tx1', + 'neo4j:bookmark:v1:tx2' + ]) + const txConfig = new TxConfig({ + timeout: 5000, + metadata: { x: 1, y: 'something' } + }) + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x5(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const query = 'RETURN $x, $y' + const parameters = { x: 'x', y: 'y' } + + const observer = protocol.run(query, parameters, { + bookmarks, + txConfig, + database, + mode: WRITE, + impersonatedUser + }) + + protocol.verifyMessageCount(2) + + expect(protocol.messages[0]).toBeMessage( + RequestMessage.runWithMetadata(query, parameters, { + bookmarks, + txConfig, + database, + mode: WRITE, + impersonatedUser + }) + ) + expect(protocol.messages[1]).toBeMessage(RequestMessage.pull()) + expect(protocol.observers).toEqual([observer, observer]) + expect(protocol.flushes).toEqual([false, true]) + }) + + it('should begin a transaction', () => { + const database = 'testdb' + const bookmarks = new Bookmarks([ + 'neo4j:bookmark:v1:tx1', + 'neo4j:bookmark:v1:tx2' + ]) + const txConfig = new TxConfig({ + timeout: 5000, + metadata: { x: 1, y: 'something' } + }) + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x5(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const observer = protocol.beginTransaction({ + bookmarks, + txConfig, + database, + mode: WRITE + }) + + protocol.verifyMessageCount(1) + expect(protocol.messages[0]).toBeMessage( + RequestMessage.begin({ bookmarks, txConfig, database, mode: WRITE }) + ) + expect(protocol.observers).toEqual([observer]) + expect(protocol.flushes).toEqual([true]) + }) + + it('should begin a transaction with impersonated user', () => { + const database = 'testdb' + const impersonatedUser = 'the impostor' + const bookmarks = new Bookmarks([ + 'neo4j:bookmark:v1:tx1', + 'neo4j:bookmark:v1:tx2' + ]) + const txConfig = new TxConfig({ + timeout: 5000, + metadata: { x: 1, y: 'something' } + }) + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x5(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const observer = protocol.beginTransaction({ + bookmarks, + txConfig, + database, + mode: WRITE, + impersonatedUser + }) + + protocol.verifyMessageCount(1) + expect(protocol.messages[0]).toBeMessage( + RequestMessage.begin({ bookmarks, txConfig, database, mode: WRITE, impersonatedUser }) + ) + expect(protocol.observers).toEqual([observer]) + expect(protocol.flushes).toEqual([true]) + }) + + it('should return correct bolt version number', () => { + const protocol = new BoltProtocolV5x5(null, null, false) + + expect(protocol.version).toBe(5.5) + }) + + it('should update metadata', () => { + const metadata = { t_first: 1, t_last: 2, db_hits: 3, some_other_key: 4 } + const protocol = new BoltProtocolV5x5(null, null, false) + + const transformedMetadata = protocol.transformMetadata(metadata) + + expect(transformedMetadata).toEqual({ + result_available_after: 1, + result_consumed_after: 2, + db_hits: 3, + some_other_key: 4 + }) + }) + + it('should initialize connection', () => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x5(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const clientName = 'js-driver/1.2.3' + const boltAgent = { + product: 'neo4j-javascript/5.6', + platform: 'netbsd 1.1.1; Some arch', + languageDetails: 'Node/16.0.1 (v8 1.7.0)' + } + const authToken = { username: 'neo4j', password: 'secret' } + + const observer = protocol.initialize({ userAgent: clientName, boltAgent, authToken }) + + protocol.verifyMessageCount(2) + expect(protocol.messages[0]).toBeMessage( + RequestMessage.hello5x3(clientName, boltAgent) + ) + expect(protocol.messages[1]).toBeMessage( + RequestMessage.logon(authToken) + ) + + expect(protocol.observers.length).toBe(2) + + // hello observer + const helloObserver = protocol.observers[0] + expect(helloObserver).toBeInstanceOf(LoginObserver) + expect(helloObserver).not.toBe(observer) + + // login observer + const loginObserver = protocol.observers[1] + expect(loginObserver).toBeInstanceOf(LoginObserver) + expect(loginObserver).toBe(observer) + + expect(protocol.flushes).toEqual([false, true]) + }) + + it.each([ + 'javascript-driver/5.5.0', + '', + undefined, + null + ])('should always use the user agent set by the user', (userAgent) => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x5(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const boltAgent = { + product: 'neo4j-javascript/5.6', + platform: 'netbsd 1.1.1; Some arch', + languageDetails: 'Node/16.0.1 (v8 1.7.0)' + } + const authToken = { username: 'neo4j', password: 'secret' } + + const observer = protocol.initialize({ userAgent, boltAgent, authToken }) + + protocol.verifyMessageCount(2) + expect(protocol.messages[0]).toBeMessage( + RequestMessage.hello5x3(userAgent, boltAgent) + ) + expect(protocol.messages[1]).toBeMessage( + RequestMessage.logon(authToken) + ) + + expect(protocol.observers.length).toBe(2) + + // hello observer + const helloObserver = protocol.observers[0] + expect(helloObserver).toBeInstanceOf(LoginObserver) + expect(helloObserver).not.toBe(observer) + + // login observer + const loginObserver = protocol.observers[1] + expect(loginObserver).toBeInstanceOf(LoginObserver) + expect(loginObserver).toBe(observer) + + expect(protocol.flushes).toEqual([false, true]) + }) + + it.each( + [true, false] + )('should logon to the server [flush=%s]', (flush) => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x5(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const authToken = { username: 'neo4j', password: 'secret' } + + const observer = protocol.logon({ authToken, flush }) + + protocol.verifyMessageCount(1) + + expect(protocol.messages[0]).toBeMessage( + RequestMessage.logon(authToken) + ) + + expect(protocol.observers).toEqual([observer]) + expect(protocol.flushes).toEqual([flush]) + }) + + it.each( + [true, false] + )('should logoff from the server [flush=%s]', (flush) => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x5(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const observer = protocol.logoff({ flush }) + + protocol.verifyMessageCount(1) + + expect(protocol.messages[0]).toBeMessage( + RequestMessage.logoff() + ) + + expect(protocol.observers).toEqual([observer]) + expect(protocol.flushes).toEqual([flush]) + }) + + it('should begin a transaction', () => { + const bookmarks = new Bookmarks([ + 'neo4j:bookmark:v1:tx1', + 'neo4j:bookmark:v1:tx2' + ]) + const txConfig = new TxConfig({ + timeout: 5000, + metadata: { x: 1, y: 'something' } + }) + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x5(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const observer = protocol.beginTransaction({ + bookmarks, + txConfig, + mode: WRITE + }) + + protocol.verifyMessageCount(1) + expect(protocol.messages[0]).toBeMessage( + RequestMessage.begin({ bookmarks, txConfig, mode: WRITE }) + ) + expect(protocol.observers).toEqual([observer]) + expect(protocol.flushes).toEqual([true]) + }) + + it('should commit', () => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x5(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const observer = protocol.commitTransaction() + + protocol.verifyMessageCount(1) + expect(protocol.messages[0]).toBeMessage(RequestMessage.commit()) + expect(protocol.observers).toEqual([observer]) + expect(protocol.flushes).toEqual([true]) + }) + + it('should rollback', () => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x5(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const observer = protocol.rollbackTransaction() + + protocol.verifyMessageCount(1) + expect(protocol.messages[0]).toBeMessage(RequestMessage.rollback()) + expect(protocol.observers).toEqual([observer]) + expect(protocol.flushes).toEqual([true]) + }) + + it('should support logoff', () => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x5(recorder, null, false) + + expect(protocol.supportsReAuth).toBe(true) + }) + + describe('unpacker configuration', () => { + test.each([ + [false, false], + [false, true], + [true, false], + [true, true] + ])( + 'should create unpacker with disableLosslessIntegers=%p and useBigInt=%p', + (disableLosslessIntegers, useBigInt) => { + const protocol = new BoltProtocolV5x5(null, null, { + disableLosslessIntegers, + useBigInt + }) + expect(protocol._unpacker._disableLosslessIntegers).toBe( + disableLosslessIntegers + ) + expect(protocol._unpacker._useBigInt).toBe(useBigInt) + } + ) + }) + + describe('notificationFilter', () => { + notificationFilterBehaviour.shouldSupportGqlNotificationFilterOnInitialize(newProtocol) + notificationFilterBehaviour.shouldSupportGqlNotificationFilterOnBeginTransaction(newProtocol) + notificationFilterBehaviour.shouldSupportGqlNotificationFilterOnRun(newProtocol) + }) + + describe('watermarks', () => { + it('.run() should configure watermarks', () => { + const recorder = new utils.MessageRecordingConnection() + const protocol = utils.spyProtocolWrite( + new BoltProtocolV5x5(recorder, null, false) + ) + + const query = 'RETURN $x, $y' + const parameters = { x: 'x', y: 'y' } + const observer = protocol.run(query, parameters, { + bookmarks: Bookmarks.empty(), + txConfig: TxConfig.empty(), + lowRecordWatermark: 100, + highRecordWatermark: 200 + }) + + expect(observer._lowRecordWatermark).toEqual(100) + expect(observer._highRecordWatermark).toEqual(200) + }) + }) + + describe('packstream', () => { + it('should configure v2 packer', () => { + const protocol = new BoltProtocolV5x5(null, null, false) + expect(protocol.packer()).toBeInstanceOf(v2.Packer) + }) + + it('should configure v2 unpacker', () => { + const protocol = new BoltProtocolV5x5(null, null, false) + expect(protocol.unpacker()).toBeInstanceOf(v2.Unpacker) + }) + }) + + describe('.packable()', () => { + it.each([ + ['Node', new Node(1, ['a'], { a: 'b' }, 'c')], + ['Relationship', new Relationship(1, 2, 3, 'a', { b: 'c' }, 'd', 'e', 'f')], + ['UnboundRelationship', new UnboundRelationship(1, 'a', { b: 'c' }, '1')], + ['Path', new Path(new Node(1, [], {}), new Node(2, [], {}), [])] + ])('should resultant function not pack graph types (%s)', (_, graphType) => { + const protocol = new BoltProtocolV5x5( + new utils.MessageRecordingConnection(), + null, + false + ) + + const packable = protocol.packable(graphType) + + expect(packable).toThrowErrorMatchingSnapshot() + }) + + it.each([ + ['Duration', new Duration(1, 1, 1, 1)], + ['LocalTime', new LocalTime(1, 1, 1, 1)], + ['Time', new Time(1, 1, 1, 1, 1)], + ['Date', new Date(1, 1, 1)], + ['LocalDateTime', new LocalDateTime(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) => { + const buffer = alloc(256) + const protocol = new BoltProtocolV5x5( + new utils.MessageRecordingConnection(), + buffer, + { + disableLosslessIntegers: true + } + ) + + 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') + ], + [ + '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') + ], + [ + 'DateWithWithZoneId / Berlin before common era', + new DateTime(-2020, 6, 15, 4, 30, 0, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateWithWithZoneId / Max Date', + new DateTime(99_999, 12, 31, 23, 59, 59, 999_999_999, undefined, 'Pacific/Kiritimati') + ], + [ + 'DateWithWithZoneId / Min Date', + new DateTime(-99_999, 12, 31, 23, 59, 59, 999_999_999, undefined, 'Pacific/Samoa') + ], + [ + 'DateWithWithZoneId / Ambiguous date between 00 and 99', + new DateTime(50, 12, 31, 23, 59, 59, 999_999_999, undefined, 'Pacific/Samoa') + ] + ])('should pack and unpack DateTimeWithZoneId and without offset (%s)', (_, object) => { + const buffer = alloc(256) + const loggerFunction = jest.fn() + const protocol = new BoltProtocolV5x5( + new utils.MessageRecordingConnection(), + buffer, + { + disableLosslessIntegers: true + }, + undefined, + new Logger('debug', loggerFunction) + ) + + const packable = protocol.packable(object) + + expect(packable).not.toThrow() + 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.') + + 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(unpackedDateTimeWithoutOffset).toEqual(object) + }) + + it('should pack and unpack DateTimeWithOffset', () => { + fc.assert( + fc.property( + fc.date({ + min: temporalUtil.newDate(utils.MIN_UTC_IN_MS + utils.ONE_DAY_IN_MS), + max: temporalUtil.newDate(utils.MAX_UTC_IN_MS - utils.ONE_DAY_IN_MS) + }), + fc.integer({ min: 0, max: 999_999 }), + utils.arbitraryTimeZoneId(), + (date, nanoseconds, timeZoneId) => { + const object = new DateTime( + date.getUTCFullYear(), + date.getUTCMonth() + 1, + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds(), + date.getUTCMilliseconds() * 1_000_000 + nanoseconds, + undefined, + timeZoneId + ) + const buffer = alloc(256) + const loggerFunction = jest.fn() + const protocol = new BoltProtocolV5x5( + new utils.MessageRecordingConnection(), + buffer, + { + disableLosslessIntegers: true + }, + undefined, + new Logger('debug', loggerFunction) + ) + + const packable = protocol.packable(object) + + expect(packable).not.toThrow() + 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.') + + 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(unpackedDateTimeWithoutOffset).toEqual(object) + }) + ) + }) + + it('should pack and unpack DateTimeWithZoneIdAndNoOffset', () => { + fc.assert( + fc.property(fc.date(), date => { + const object = DateTime.fromStandardDate(date) + const buffer = alloc(256) + const loggerFunction = jest.fn() + const protocol = new BoltProtocolV5x5( + 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() + + expect(unpacked).toEqual(object) + }) + ) + }) + }) + + describe('.unpack()', () => { + it.each([ + [ + 'Node', + new structure.Structure(0x4e, [1, ['a'], { c: 'd' }, 'elementId']), + new Node(1, ['a'], { c: 'd' }, 'elementId') + ], + [ + 'Relationship', + new structure.Structure(0x52, [1, 2, 3, '4', { 5: 6 }, 'elementId', 'node1', 'node2']), + new Relationship(1, 2, 3, '4', { 5: 6 }, 'elementId', 'node1', 'node2') + ], + [ + 'UnboundRelationship', + new structure.Structure(0x72, [1, '2', { 3: 4 }, 'elementId']), + new UnboundRelationship(1, '2', { 3: 4 }, 'elementId') + ], + [ + 'Path', + new structure.Structure( + 0x50, + [ + [ + new structure.Structure(0x4e, [1, ['2'], { 3: '4' }, 'node1']), + new structure.Structure(0x4e, [4, ['5'], { 6: 7 }, 'node2']), + new structure.Structure(0x4e, [2, ['3'], { 4: '5' }, 'node3']) + ], + [ + new structure.Structure(0x52, [3, 1, 4, 'reltype1', { 4: '5' }, 'rel1', 'node1', 'node2']), + new structure.Structure(0x52, [5, 4, 2, 'reltype2', { 6: 7 }, 'rel2', 'node2', 'node3']) + ], + [1, 1, 2, 2] + ] + ), + new Path( + new Node(1, ['2'], { 3: '4' }, 'node1'), + new Node(2, ['3'], { 4: '5' }, 'node3'), + [ + new PathSegment( + new Node(1, ['2'], { 3: '4' }, 'node1'), + new Relationship(3, 1, 4, 'reltype1', { 4: '5' }, 'rel1', 'node1', 'node2'), + new Node(4, ['5'], { 6: 7 }, 'node2') + ), + new PathSegment( + new Node(4, ['5'], { 6: 7 }, 'node2'), + new Relationship(5, 4, 2, 'reltype2', { 6: 7 }, 'rel2', 'node2', 'node3'), + new Node(2, ['3'], { 4: '5' }, 'node3') + ) + ] + ) + ] + ])('should unpack graph types (%s)', (_, struct, graphObject) => { + const buffer = alloc(256) + const protocol = new BoltProtocolV5x5( + new utils.MessageRecordingConnection(), + buffer, + false + ) + + const packable = protocol.packable(struct) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + expect(unpacked).toEqual(graphObject) + }) + + it.each([ + [ + 'Node with less fields', + new structure.Structure(0x4e, [1, ['a'], { c: 'd' }]) + ], + [ + 'Node with more fields', + new structure.Structure(0x4e, [1, ['a'], { c: 'd' }, '1', 'b']) + ], + [ + 'Relationship with less fields', + new structure.Structure(0x52, [1, 2, 3, '4', { 5: 6 }]) + ], + [ + 'Relationship with more fields', + new structure.Structure(0x52, [1, 2, 3, '4', { 5: 6 }, '1', '2', '3', '4']) + ], + [ + 'UnboundRelationship with less fields', + new structure.Structure(0x72, [1, '2', { 3: 4 }]) + ], + [ + 'UnboundRelationship with more fields', + new structure.Structure(0x72, [1, '2', { 3: 4 }, '1', '2']) + ], + [ + 'Path with less fields', + new structure.Structure( + 0x50, + [ + [ + new structure.Structure(0x4e, [1, ['2'], { 3: '4' }]), + new structure.Structure(0x4e, [4, ['5'], { 6: 7 }]), + new structure.Structure(0x4e, [2, ['3'], { 4: '5' }]) + ], + [ + new structure.Structure(0x52, [3, 1, 4, 'rel1', { 4: '5' }]), + new structure.Structure(0x52, [5, 4, 2, 'rel2', { 6: 7 }]) + ] + ] + ) + ], + [ + 'Path with more fields', + new structure.Structure( + 0x50, + [ + [ + new structure.Structure(0x4e, [1, ['2'], { 3: '4' }]), + new structure.Structure(0x4e, [4, ['5'], { 6: 7 }]), + new structure.Structure(0x4e, [2, ['3'], { 4: '5' }]) + ], + [ + new structure.Structure(0x52, [3, 1, 4, 'rel1', { 4: '5' }]), + new structure.Structure(0x52, [5, 4, 2, 'rel2', { 6: 7 }]) + ], + [1, 1, 2, 2], + 'a' + ] + ) + ], + [ + 'Point with less fields', + new structure.Structure(0x58, [1, 2]) + ], + [ + 'Point with more fields', + new structure.Structure(0x58, [1, 2, 3, 4]) + ], + [ + 'Point3D with less fields', + new structure.Structure(0x59, [1, 2, 3]) + ], + + [ + 'Point3D with more fields', + new structure.Structure(0x59, [1, 2, 3, 4, 6]) + ], + [ + 'Duration with less fields', + new structure.Structure(0x45, [1, 2, 3]) + ], + [ + 'Duration with more fields', + new structure.Structure(0x45, [1, 2, 3, 4, 5]) + ], + [ + 'LocalTime with less fields', + new structure.Structure(0x74, []) + ], + [ + 'LocalTime with more fields', + new structure.Structure(0x74, [1, 2]) + ], + [ + 'Time with less fields', + new structure.Structure(0x54, [1]) + ], + [ + 'Time with more fileds', + new structure.Structure(0x54, [1, 2, 3]) + ], + [ + 'Date with less fields', + new structure.Structure(0x44, []) + ], + [ + 'Date with more fields', + new structure.Structure(0x44, [1, 2]) + ], + [ + 'LocalDateTime with less fields', + new structure.Structure(0x64, [1]) + ], + [ + 'LocalDateTime with more fields', + new structure.Structure(0x64, [1, 2, 3]) + ], + [ + '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 buffer = alloc(256) + const protocol = new BoltProtocolV5x5( + new utils.MessageRecordingConnection(), + buffer, + false + ) + + const packable = protocol.packable(struct) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + expect(() => unpacked instanceof structure.Structure).toThrowErrorMatchingSnapshot() + }) + + it.each([ + [ + 'Point', + new structure.Structure(0x58, [1, 2, 3]), + new Point(1, 2, 3) + ], + [ + 'Point3D', + new structure.Structure(0x59, [1, 2, 3, 4]), + new Point(1, 2, 3, 4) + ], + [ + 'Duration', + new structure.Structure(0x45, [1, 2, 3, 4]), + new Duration(1, 2, 3, 4) + ], + [ + 'LocalTime', + new structure.Structure(0x74, [1]), + new LocalTime(0, 0, 0, 1) + ], + [ + 'Time', + new structure.Structure(0x54, [1, 2]), + new Time(0, 0, 0, 1, 2) + ], + [ + 'Date', + new structure.Structure(0x44, [1]), + new Date(1970, 1, 2) + ], + [ + 'LocalDateTime', + new structure.Structure(0x64, [1, 2]), + new LocalDateTime(1970, 1, 1, 0, 0, 1, 2) + ], + [ + 'DateTimeWithZoneOffset', + 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(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') + ], + [ + 'DateTimeWithZoneId / Midnight', + new structure.Structure(0x69, [ + 1685397950, 183_000_000, 'Europe/Berlin' + ]), + new DateTime(2023, 5, 30, 0, 5, 50, 183_000_000, 2 * 60 * 60, 'Europe/Berlin') + ] + ])('should unpack spatial types and temporal types (%s)', (_, struct, object) => { + const buffer = alloc(256) + const protocol = new BoltProtocolV5x5( + 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(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 BoltProtocolV5x5( + 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) + }) + }) + + describe('result metadata enrichment', () => { + it('run should configure BoltProtocolV5x5._enrichMetadata as enrichMetadata', () => { + const database = 'testdb' + const bookmarks = new Bookmarks([ + 'neo4j:bookmark:v1:tx1', + 'neo4j:bookmark:v1:tx2' + ]) + const txConfig = new TxConfig({ + timeout: 5000, + metadata: { x: 1, y: 'something' } + }) + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x5(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const query = 'RETURN $x, $y' + const parameters = { x: 'x', y: 'y' } + + const observer = protocol.run(query, parameters, { + bookmarks, + txConfig, + database, + mode: WRITE + }) + + expect(observer._enrichMetadata).toBe(BoltProtocolV5x5._enrichMetadata) + }) + + describe('BoltProtocolV5x5._enrichMetadata', () => { + it('should handle empty metadata', () => { + const metadata = BoltProtocolV5x5._enrichMetadata({}) + + expect(metadata).toEqual({}) + }) + + it('should handle metadata with random objects', () => { + const metadata = BoltProtocolV5x5._enrichMetadata({ + a: 1133, + b: 345 + }) + + expect(metadata).toEqual({ + a: 1133, + b: 345 + }) + }) + + it('should handle metadata not change notifications ', () => { + const metadata = BoltProtocolV5x5._enrichMetadata({ + a: 1133, + b: 345, + notifications: [ + { + severity: 'WARNING', + category: 'HINT' + } + ] + }) + + expect(metadata).toEqual({ + a: 1133, + b: 345, + notifications: [ + { + severity: 'WARNING', + category: 'HINT' + } + ] + }) + }) + + it.each([ + [null, null], + [undefined, undefined], + [[], []], + [statusesWithDiagnosticRecord(null, null), statusesWithDiagnosticRecord(null, null)], + [statusesWithDiagnosticRecord(undefined, undefined), statusesWithDiagnosticRecord({ + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' + }, + { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' + })], + [ + statusesWithDiagnosticRecord({ + OPERATION: 'A' + }), + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B' + }), + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/' + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C' + }), + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C' + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C', + _status_parameters: { d: 'E' } + }), + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C', + _status_parameters: { d: 'E' } + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C', + _status_parameters: { d: 'E' }, + _severity: 'F' + }), + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C', + _status_parameters: { d: 'E' }, + _severity: 'F' + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C', + _status_parameters: { d: 'E' }, + _severity: 'F', + _classification: 'G' + }), + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C', + _status_parameters: { d: 'E' }, + _severity: 'F', + _classification: 'G' + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C', + _status_parameters: { d: 'E' }, + _severity: 'F', + _classification: 'G', + _position: { + offset: 1, + line: 2, + column: 3 + } + }), + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C', + _status_parameters: { d: 'E' }, + _severity: 'F', + _classification: 'G', + _position: { + offset: 1, + line: 2, + column: 3 + } + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: null + }), + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null + }), + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: '/' + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null + }), + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null, + _status_parameters: null + }), + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null, + _status_parameters: null + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null, + _status_parameters: null, + _severity: null + }), + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null, + _status_parameters: null, + _severity: null + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null, + _status_parameters: null, + _severity: null, + _classification: null + }), + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null, + _status_parameters: null, + _severity: null, + _classification: null + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null, + _status_parameters: null, + _severity: null, + _classification: null, + _position: null + }), + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null, + _status_parameters: null, + _severity: null, + _classification: null, + _position: null + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: undefined, + OPERATION_CODE: undefined, + CURRENT_SCHEMA: undefined, + _status_parameters: undefined, + _severity: undefined, + _classification: undefined, + _position: undefined + }), + statusesWithDiagnosticRecord({ + OPERATION: undefined, + OPERATION_CODE: undefined, + CURRENT_SCHEMA: undefined, + _status_parameters: undefined, + _severity: undefined, + _classification: undefined, + _position: undefined + }) + ] + ])('should handle statuses (%o) ', (statuses, expectedStatuses) => { + const metadata = BoltProtocolV5x5._enrichMetadata({ + a: 1133, + b: 345, + statuses + }) + + expect(metadata).toEqual({ + a: 1133, + b: 345, + statuses: expectedStatuses + }) + }) + }) + + function statusesWithDiagnosticRecord (...diagnosticRecords) { + return diagnosticRecords.map(diagnosticRecord => { + return { + gql_status: '00000', + status_description: 'note: successful completion', + diagnostic_record: diagnosticRecord + } + }) + } + }) + + function newProtocol (recorder) { + return new BoltProtocolV5x5(recorder, null, false, undefined, undefined, () => {}) + } +}) diff --git a/packages/bolt-connection/test/bolt/index.test.js b/packages/bolt-connection/test/bolt/index.test.js index 909bef52e..043c37e1f 100644 --- a/packages/bolt-connection/test/bolt/index.test.js +++ b/packages/bolt-connection/test/bolt/index.test.js @@ -33,6 +33,7 @@ import BoltProtocolV5x1 from '../../src/bolt/bolt-protocol-v5x1' import BoltProtocolV5x2 from '../../src/bolt/bolt-protocol-v5x2' import BoltProtocolV5x3 from '../../src/bolt/bolt-protocol-v5x3' import BoltProtocolV5x4 from '../../src/bolt/bolt-protocol-v5x4' +import BoltProtocolV5x5 from '../../src/bolt/bolt-protocol-v5x5' const { logger: { Logger } @@ -46,13 +47,13 @@ describe('#unit Bolt', () => { const writtenBuffer = channel.written[0] const boltMagicPreamble = '60 60 b0 17' - const protocolVersion5x4to5x0 = '00 04 04 05' + const protocolVersion5x5to5x0 = '00 05 05 05' const protocolVersion4x4to4x2 = '00 02 04 04' const protocolVersion4x1 = '00 00 01 04' const protocolVersion3 = '00 00 00 03' expect(writtenBuffer.toHex()).toEqual( - `${boltMagicPreamble} ${protocolVersion5x4to5x0} ${protocolVersion4x4to4x2} ${protocolVersion4x1} ${protocolVersion3}` + `${boltMagicPreamble} ${protocolVersion5x5to5x0} ${protocolVersion4x4to4x2} ${protocolVersion4x1} ${protocolVersion3}` ) }) @@ -390,7 +391,8 @@ describe('#unit Bolt', () => { v(5.1, BoltProtocolV5x1), v(5.2, BoltProtocolV5x2), v(5.3, BoltProtocolV5x3), - v(5.4, BoltProtocolV5x4) + v(5.4, BoltProtocolV5x4), + v(5.5, BoltProtocolV5x5) ] availableProtocols.forEach(lambda) diff --git a/packages/bolt-connection/test/bolt/request-message.test.js b/packages/bolt-connection/test/bolt/request-message.test.js index d644a39bd..b425163d6 100644 --- a/packages/bolt-connection/test/bolt/request-message.test.js +++ b/packages/bolt-connection/test/bolt/request-message.test.js @@ -646,6 +646,212 @@ describe('#unit RequestMessage', () => { }) }) + describe('BoltV5.5', () => { + describe('hello5x5', () => { + it.each( + gqlNotificationFilterFixtures() + )('should create HELLO message where notificationFilters=%o', (notificationFilter, expectedNotificationFilter) => { + const userAgent = 'my-driver/1.0.2' + const boltAgent = { + product: 'neo4j-javascript/5.6', + platform: 'netbsd 1.1.1; Some arch', + languageDetails: 'Node/16.0.1 (v8 1.7.0)' + } + + const expectedFields = { + user_agent: userAgent, + bolt_agent: { + product: 'neo4j-javascript/5.6', + platform: 'netbsd 1.1.1; Some arch', + language_details: 'Node/16.0.1 (v8 1.7.0)' + }, + ...expectedNotificationFilter + } + + const message = RequestMessage.hello5x5(userAgent, boltAgent, notificationFilter) + + expect(message.signature).toEqual(0x01) + expect(message.fields).toEqual([ + expectedFields + ]) + expect(message.toString()).toEqual( + `HELLO ${json.stringify(expectedFields)}` + ) + }) + + it('should create HELLO with NodeJS Bolt Agent', () => { + const userAgent = 'my-driver/1.0.2' + const boltAgent = { + product: 'neo4j-javascript/5.6', + platform: 'netbsd 1.1.1; Some arch', + languageDetails: 'Node/16.0.1 (v8 1.7.0)' + } + + const expectedFields = { + user_agent: userAgent, + bolt_agent: { + product: 'neo4j-javascript/5.6', + platform: 'netbsd 1.1.1; Some arch', + language_details: 'Node/16.0.1 (v8 1.7.0)' + } + } + + const message = RequestMessage.hello5x5(userAgent, boltAgent) + + expect(message.signature).toEqual(0x01) + expect(message.fields).toEqual([ + expectedFields + ]) + expect(message.toString()).toEqual( + `HELLO ${json.stringify(expectedFields)}` + ) + }) + + it('should create HELLO with Browser Bolt Agent', () => { + const userAgent = 'my-driver/1.0.2' + + const boltAgent = { + product: 'neo4j-javascript/5.3', + platform: 'Macintosh; Intel Mac OS X 10_15_7' + } + + const expectedFields = { + user_agent: userAgent, + bolt_agent: { + product: 'neo4j-javascript/5.3', + platform: 'Macintosh; Intel Mac OS X 10_15_7' + } + } + + const message = RequestMessage.hello5x5(userAgent, boltAgent) + + expect(message.signature).toEqual(0x01) + expect(message.fields).toEqual([ + expectedFields + ]) + expect(message.toString()).toEqual( + `HELLO ${json.stringify(expectedFields)}` + ) + }) + + it('should create HELLO with Deno Bolt Agent', () => { + const userAgent = 'my-driver/1.0.2' + + const boltAgent = { + product: 'neo4j-javascript/5.3', + platform: 'macos 14.1; myArch', + languageDetails: 'Deno/1.19.1 (v8 8.1.39)' + } + + const expectedFields = { + user_agent: userAgent, + bolt_agent: { + product: 'neo4j-javascript/5.3', + platform: 'macos 14.1; myArch', + language_details: 'Deno/1.19.1 (v8 8.1.39)' + } + } + + const message = RequestMessage.hello5x5(userAgent, boltAgent) + + expect(message.signature).toEqual(0x01) + expect(message.fields).toEqual([ + expectedFields + ]) + expect(message.toString()).toEqual( + `HELLO ${json.stringify(expectedFields)}` + ) + }) + }) + + describe('begin5x5', () => { + it.each( + gqlNotificationFilterFixtures() + )('should create BEGIN message where notificationFilters=%o', (notificationFilter, expectedNotificationFilter) => { + ;[READ, WRITE].forEach(mode => { + const bookmarks = new Bookmarks([ + 'neo4j:bookmark:v1:tx1', + 'neo4j:bookmark:v1:tx10' + ]) + const impersonatedUser = 'the impostor' + const txConfig = new TxConfig({ timeout: 42, metadata: { key: 42 } }) + + const message = RequestMessage.begin5x5({ bookmarks, txConfig, mode, impersonatedUser, notificationFilter }) + + const expectedMode = {} + if (mode === READ) { + expectedMode.mode = 'r' + } + const expectedMetadata = { + bookmarks: bookmarks.values(), + tx_timeout: int(42), + tx_metadata: { key: 42 }, + imp_user: impersonatedUser, + ...expectedMode, + ...expectedNotificationFilter + } + + expect(message.signature).toEqual(0x11) + expect(message.fields).toEqual([expectedMetadata]) + expect(message.toString()).toEqual( + `BEGIN ${json.stringify(expectedMetadata)}` + ) + }) + }) + }) + + describe('run5x5', () => { + it.each( + gqlNotificationFilterFixtures() + )('should create RUN message where notificationFilters=%o', (notificationFilter, expectedNotificationFilter) => { + ;[READ, WRITE].forEach(mode => { + const query = 'RETURN $x' + const parameters = { x: 42 } + const bookmarks = new Bookmarks([ + 'neo4j:bookmark:v1:tx1', + 'neo4j:bookmark:v1:tx10', + 'neo4j:bookmark:v1:tx100' + ]) + const txConfig = new TxConfig({ + timeout: 999, + metadata: { a: 'a', b: 'b' } + }) + const impersonatedUser = 'the impostor' + + const message = RequestMessage.runWithMetadata5x5(query, parameters, { + bookmarks, + txConfig, + mode, + impersonatedUser, + notificationFilter + }) + + const expectedMode = {} + if (mode === READ) { + expectedMode.mode = 'r' + } + + const expectedMetadata = { + bookmarks: bookmarks.values(), + tx_timeout: int(999), + tx_metadata: { a: 'a', b: 'b' }, + imp_user: impersonatedUser, + ...expectedMode, + ...expectedNotificationFilter + } + + expect(message.signature).toEqual(0x10) + expect(message.fields).toEqual([query, parameters, expectedMetadata]) + expect(message.toString()).toEqual( + `RUN ${query} ${json.stringify(parameters)} ${json.stringify( + expectedMetadata + )}` + ) + }) + }) + }) + }) + function notificationFilterFixtures () { return notificationFilterBehaviour.notificationFilterFixture() .map(notificationFilter => { @@ -658,6 +864,31 @@ describe('#unit RequestMessage', () => { if (notificationFilter.disabledCategories) { expectedNotificationFilter.notifications_disabled_categories = notificationFilter.disabledCategories } + + if (notificationFilter.disabledClassifications) { + expectedNotificationFilter.notifications_disabled_categories = notificationFilter.disabledClassifications + } + } + return [notificationFilter, expectedNotificationFilter] + }) + } + + function gqlNotificationFilterFixtures () { + return notificationFilterBehaviour.notificationFilterFixture() + .map(notificationFilter => { + const expectedNotificationFilter = {} + if (notificationFilter) { + if (notificationFilter.minimumSeverityLevel) { + expectedNotificationFilter.notifications_minimum_severity = notificationFilter.minimumSeverityLevel + } + + if (notificationFilter.disabledCategories) { + expectedNotificationFilter.notifications_disabled_classifications = notificationFilter.disabledCategories + } + + if (notificationFilter.disabledClassifications) { + expectedNotificationFilter.notifications_disabled_classifications = notificationFilter.disabledClassifications + } } return [notificationFilter, expectedNotificationFilter] }) diff --git a/packages/bolt-connection/test/bolt/stream-observer.test.js b/packages/bolt-connection/test/bolt/stream-observer.test.js index caaf24b25..11c3ac3cb 100644 --- a/packages/bolt-connection/test/bolt/stream-observer.test.js +++ b/packages/bolt-connection/test/bolt/stream-observer.test.js @@ -151,7 +151,12 @@ describe('#unit ResultStreamObserver', () => { expect(receivedMetaData).toEqual({ metaDataField1: 'value1', - metaDataField2: 'value2' + metaDataField2: 'value2', + stream_summary: { + have_records_streamed: false, + has_keys: true, + pulled: true + } }) }) @@ -227,7 +232,15 @@ describe('#unit ResultStreamObserver', () => { expect(received.onNext[1].toObject()).toEqual({ A: 11, B: 22, C: 33 }) expect(received.onNext[2].toObject()).toEqual({ A: 111, B: 222, C: 333 }) expect(received.onKeys).toEqual([['A', 'B', 'C']]) - expect(received.onCompleted).toEqual([{ key: 42, has_more: false }]) + expect(received.onCompleted).toEqual([{ + key: 42, + has_more: false, + stream_summary: { + has_keys: true, + have_records_streamed: true, + pulled: true + } + }]) expect(received.onError).toEqual([]) }) @@ -256,7 +269,15 @@ describe('#unit ResultStreamObserver', () => { expect(received[1].toObject()).toEqual({ A: 1, B: 2, C: 3 }) expect(received[2].toObject()).toEqual({ A: 11, B: 22, C: 33 }) expect(received[3].toObject()).toEqual({ A: 111, B: 222, C: 333 }) - expect(received[4]).toEqual({ key: 42, has_more: false }) + expect(received[4]).toEqual({ + key: 42, + has_more: false, + stream_summary: { + has_keys: true, + have_records_streamed: true, + pulled: true + } + }) }) it('should inform all the pre-existing events of an error stream to the subscriber', () => { @@ -680,6 +701,191 @@ describe('#unit ResultStreamObserver', () => { ) }) }) + + describe('metadata.stream_summary', () => { + it('should notify stream without keys, pulled or record received', async () => { + const streamObserver = new ResultStreamObserver({ reactive: true, discardFunction: jest.fn() }) + const received = [] + const observer = { + onCompleted: metadata => received.push(metadata), + onError: error => received.push(error), + onNext: record => received.push(record), + onKeys: keys => received.push(keys) + } + + streamObserver.subscribe(observer) + + streamObserver.cancel() + streamObserver.onCompleted({ fields: [] }) + + await new Promise((resolve, reject) => { + setImmediate(() => { + try { + streamObserver.onCompleted({ key: 42, has_more: false }) + resolve() + } catch (e) { + reject(e) + } + }) + }) + + expect(received[received.length - 1]).toEqual({ + key: 42, + has_more: false, + stream_summary: { + has_keys: false, + have_records_streamed: false, + pulled: false + } + }) + }) + + it('should notify stream keys, but without pulled or record received', async () => { + const streamObserver = new ResultStreamObserver({ reactive: true, discardFunction: jest.fn() }) + const received = [] + const observer = { + onCompleted: metadata => received.push(metadata), + onError: error => received.push(error), + onNext: record => received.push(record), + onKeys: keys => received.push(keys) + } + + streamObserver.subscribe(observer) + + streamObserver.cancel() + streamObserver.onCompleted({ fields: ['A'] }) + + await new Promise((resolve, reject) => { + setImmediate(() => { + try { + streamObserver.onCompleted({ key: 42, has_more: false }) + resolve() + } catch (e) { + reject(e) + } + }) + }) + + expect(received[received.length - 1]).toEqual({ + key: 42, + has_more: false, + stream_summary: { + has_keys: true, + have_records_streamed: false, + pulled: false + } + }) + }) + + it('should notify stream pulled, but without keys or record received', async () => { + const streamObserver = new ResultStreamObserver({ reactive: false }) + const received = [] + const observer = { + onCompleted: metadata => received.push(metadata), + onError: error => received.push(error), + onNext: record => received.push(record), + onKeys: keys => received.push(keys) + } + + streamObserver.subscribe(observer) + + streamObserver.onCompleted({ fields: [] }) + + await new Promise((resolve, reject) => { + setImmediate(() => { + try { + streamObserver.onCompleted({ key: 42, has_more: false }) + resolve() + } catch (e) { + reject(e) + } + }) + }) + + expect(received[received.length - 1]).toEqual({ + key: 42, + has_more: false, + stream_summary: { + has_keys: false, + have_records_streamed: false, + pulled: true + } + }) + }) + + it('should notify stream pulled and keys received, but no record received', async () => { + const streamObserver = new ResultStreamObserver({ reactive: false }) + const received = [] + const observer = { + onCompleted: metadata => received.push(metadata), + onError: error => received.push(error), + onNext: record => received.push(record), + onKeys: keys => received.push(keys) + } + + streamObserver.subscribe(observer) + + streamObserver.onCompleted({ fields: ['A'] }) + + await new Promise((resolve, reject) => { + setImmediate(() => { + try { + streamObserver.onCompleted({ key: 42, has_more: false }) + resolve() + } catch (e) { + reject(e) + } + }) + }) + + expect(received[received.length - 1]).toEqual({ + key: 42, + has_more: false, + stream_summary: { + has_keys: true, + have_records_streamed: false, + pulled: true + } + }) + }) + + it('should notify stream pulled, keys received and record received', async () => { + const streamObserver = new ResultStreamObserver({ reactive: false }) + const received = [] + const observer = { + onCompleted: metadata => received.push(metadata), + onError: error => received.push(error), + onNext: record => received.push(record), + onKeys: keys => received.push(keys) + } + + streamObserver.subscribe(observer) + + streamObserver.onCompleted({ fields: ['A'] }) + streamObserver.onNext([1]) + + await new Promise((resolve, reject) => { + setImmediate(() => { + try { + streamObserver.onCompleted({ key: 42, has_more: false }) + resolve() + } catch (e) { + reject(e) + } + }) + }) + + expect(received[received.length - 1]).toEqual({ + key: 42, + has_more: false, + stream_summary: { + has_keys: true, + have_records_streamed: true, + pulled: true + } + }) + }) + }) }) describe('#unit RouteObserver', () => { diff --git a/packages/core/src/driver.ts b/packages/core/src/driver.ts index e045204cb..38a93f184 100644 --- a/packages/core/src/driver.ts +++ b/packages/core/src/driver.ts @@ -901,6 +901,11 @@ function validateConfig (config: any, log: Logger): any { 'where a new connection is created while it is acquired' ) } + + if (config.notificationFilter?.disabledCategories != null && config.notificationFilter?.disabledClassifications != null) { + throw new Error('The notificationFilter can\'t have both "disabledCategories" and "disabledClassifications" configured at the same time.') + } + return config } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index af9677287..44eaf59d2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -57,20 +57,26 @@ import { isPoint, Point } from './spatial-types' import ResultSummary, { queryType, ServerInfo, - Notification, - NotificationPosition, Plan, ProfiledPlan, QueryStatistics, - Stats, + Stats +} from './result-summary' +import Notification, { + NotificationPosition, NotificationSeverityLevel, + NotificationClassification, NotificationCategory, + GqlStatusObject, notificationCategory, + notificationClassification, notificationSeverityLevel -} from './result-summary' +} from './notification' import NotificationFilter, { notificationFilterDisabledCategory, NotificationFilterDisabledCategory, + notificationFilterDisabledClassification, + NotificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, NotificationFilterMinimumSeverityLevel } from './notification-filter' @@ -148,6 +154,7 @@ const forExport = { queryType, ServerInfo, Notification, + GqlStatusObject, Plan, ProfiledPlan, QueryStatistics, @@ -168,8 +175,10 @@ const forExport = { routing, resultTransformers, notificationCategory, + notificationClassification, notificationSeverityLevel, notificationFilterDisabledCategory, + notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, clientCertificateProviders, resolveCertificateProvider @@ -217,6 +226,7 @@ export { queryType, ServerInfo, Notification, + GqlStatusObject, Plan, ProfiledPlan, QueryStatistics, @@ -239,8 +249,10 @@ export { routing, resultTransformers, notificationCategory, + notificationClassification, notificationSeverityLevel, notificationFilterDisabledCategory, + notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, clientCertificateProviders, resolveCertificateProvider @@ -265,9 +277,11 @@ export type { RecordShape, ResultTransformer, NotificationCategory, + NotificationClassification, NotificationSeverityLevel, NotificationFilter, NotificationFilterDisabledCategory, + NotificationFilterDisabledClassification, NotificationFilterMinimumSeverityLevel, ClientCertificate, ClientCertificateProvider, diff --git a/packages/core/src/internal/constants.ts b/packages/core/src/internal/constants.ts index 9855854b9..e9cf978bf 100644 --- a/packages/core/src/internal/constants.ts +++ b/packages/core/src/internal/constants.ts @@ -36,6 +36,7 @@ const BOLT_PROTOCOL_V5_1: number = 5.1 const BOLT_PROTOCOL_V5_2: number = 5.2 const BOLT_PROTOCOL_V5_3: number = 5.3 const BOLT_PROTOCOL_V5_4: number = 5.4 +const BOLT_PROTOCOL_V5_5: number = 5.5 const TELEMETRY_APIS = { MANAGED_TRANSACTION: 0, @@ -68,5 +69,6 @@ export { BOLT_PROTOCOL_V5_2, BOLT_PROTOCOL_V5_3, BOLT_PROTOCOL_V5_4, + BOLT_PROTOCOL_V5_5, TELEMETRY_APIS } diff --git a/packages/core/src/internal/util.ts b/packages/core/src/internal/util.ts index 6007065e8..4151fd17d 100644 --- a/packages/core/src/internal/util.ts +++ b/packages/core/src/internal/util.ts @@ -15,7 +15,7 @@ * limitations under the License. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -import Integer, { isInt } from '../integer' +import Integer, { isInt, int } from '../integer' import { NumberOrInteger } from '../graph-types' import { EncryptionLevel } from '../types' import { stringify } from '../json' @@ -259,6 +259,23 @@ function equals (a: unknown, b: unknown): boolean { return false } +/** + * Converts (Integer | bigint) to number. + * + * @private + * @param {NumberOrInteger} value The number or integer + * @returns {number} The number + */ +function toNumber (value: NumberOrInteger): number { + if (value instanceof Integer) { + return value.toNumber() + } else if (typeof value === 'bigint') { + return int(value).toNumber() + } else { + return value + } +} + export { isEmptyObjectOrNull, isObject, @@ -268,6 +285,7 @@ export { assertNumber, assertNumberOrInteger, assertValidDate, + toNumber, validateQueryAndParameters, equals, ENCRYPTION_ON, diff --git a/packages/core/src/json.ts b/packages/core/src/json.ts index e0fa61d83..cba26b0ba 100644 --- a/packages/core/src/json.ts +++ b/packages/core/src/json.ts @@ -17,13 +17,17 @@ import { isBrokenObject, getBrokenObjectReason } from './internal/object-util' +interface StringifyOpts { + useCustomToString?: boolean +} + /** * Custom version on JSON.stringify that can handle values that normally don't support serialization, such as BigInt. * @private * @param val A JavaScript value, usually an object or array, to be converted. * @returns A JSON string representing the given value. */ -export function stringify (val: any): string { +export function stringify (val: any, opts?: StringifyOpts): string { return JSON.stringify(val, (_, value) => { if (isBrokenObject(value)) { return { @@ -31,9 +35,18 @@ export function stringify (val: any): string { __reason__: getBrokenObjectReason(value) } } + if (typeof value === 'bigint') { return `${value}n` } + + if (opts?.useCustomToString === true && + typeof value === 'object' && + !Array.isArray(value) && + typeof value.toString === 'function' && + value.toString !== Object.prototype.toString) { + return value?.toString() + } return value }) } diff --git a/packages/core/src/notification-filter.ts b/packages/core/src/notification-filter.ts index fcd1dd761..0426ca4b4 100644 --- a/packages/core/src/notification-filter.ts +++ b/packages/core/src/notification-filter.ts @@ -16,8 +16,9 @@ */ import { NotificationCategory, + NotificationClassification, NotificationSeverityLevel -} from './result-summary' +} from './notification' type ExcludeUnknown = Exclude type OFF = 'OFF' @@ -56,6 +57,19 @@ const notificationFilterDisabledCategory: EnumRecord +/** + * @typedef {NotificationFilterDisabledCategory} NotificationFilterDisabledClassification + * @experimental + */ +/** + * Constants that represents the disabled classifications in the {@link NotificationFilter} + * + * @type {notificationFilterDisabledCategory} + * @experimental + */ +const notificationFilterDisabledClassification: EnumRecord = notificationFilterDisabledCategory + /** * The notification filter object which can be configured in * the session and driver creation. @@ -67,6 +81,7 @@ Object.freeze(notificationFilterDisabledCategory) class NotificationFilter { minimumSeverityLevel?: NotificationFilterMinimumSeverityLevel disabledCategories?: NotificationFilterDisabledCategory[] + disabledClassifications?: NotificationFilterDisabledClassification[] /** * @constructor @@ -83,10 +98,26 @@ class NotificationFilter { /** * Categories the user would like to opt-out of receiving. + * + * + * This property is equivalent to {@link NotificationFilter#disabledClassifications} + * and it must not be enabled at same time. + * * @type {?NotificationFilterDisabledCategory[]} */ this.disabledCategories = undefined + /** + * Classifications the user would like to opt-out of receiving. + * + * This property is equivalent to {@link NotificationFilter#disabledCategories} + * and it must not be enabled at same time. + * + * @type {?NotificationFilterDisabledClassification[]} + * @experimental + */ + this.disabledClassifications = undefined + throw new Error('Not implemented') } } @@ -95,10 +126,12 @@ export default NotificationFilter export { notificationFilterMinimumSeverityLevel, - notificationFilterDisabledCategory + notificationFilterDisabledCategory, + notificationFilterDisabledClassification } export type { NotificationFilterMinimumSeverityLevel, - NotificationFilterDisabledCategory + NotificationFilterDisabledCategory, + NotificationFilterDisabledClassification } diff --git a/packages/core/src/notification.ts b/packages/core/src/notification.ts new file mode 100644 index 000000000..894800fbf --- /dev/null +++ b/packages/core/src/notification.ts @@ -0,0 +1,602 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * 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 * as json from './json' +import { util } from './internal' +import { NumberOrInteger } from './graph-types' + +interface NotificationPosition { + offset?: number + line?: number + column?: number +} + +type UnknownGqlStatus = `${'01' | '02' | '03' | '50'}N42` + +const unknownGqlStatus: Record = { + WARNING: { + gql_status: '01N42', + status_description: 'warn: unknown warning' + }, + NO_DATA: { + gql_status: '02N42', + status_description: 'note: no data - unknown subcondition' + }, + INFORMATION: { + gql_status: '03N42', + status_description: 'info: unknown notification' + }, + ERROR: { + gql_status: '50N42', + status_description: 'error: general processing exception - unknown error' + } +} + +type NotificationSeverityLevel = 'WARNING' | 'INFORMATION' | 'UNKNOWN' +/** + * @typedef {'WARNING' | 'INFORMATION' | 'UNKNOWN'} NotificationSeverityLevel + */ +/** + * Constants that represents the Severity level in the {@link Notification} + */ +const notificationSeverityLevel: { [key in NotificationSeverityLevel]: key } = { + WARNING: 'WARNING', + INFORMATION: 'INFORMATION', + UNKNOWN: 'UNKNOWN' +} + +Object.freeze(notificationSeverityLevel) +const severityLevels = Object.values(notificationSeverityLevel) + +type NotificationCategory = 'HINT' | 'UNRECOGNIZED' | 'UNSUPPORTED' | 'PERFORMANCE' | +'TOPOLOGY' | 'SECURITY' | 'DEPRECATION' | 'GENERIC' | 'UNKNOWN' +/** + * @typedef {'HINT' | 'UNRECOGNIZED' | 'UNSUPPORTED' |'PERFORMANCE' | 'TOPOLOGY' | 'SECURITY' | 'DEPRECATION' | 'GENERIC' | 'UNKNOWN' } NotificationCategory + */ +/** + * Constants that represents the Category in the {@link Notification} + */ +const notificationCategory: { [key in NotificationCategory]: key } = { + HINT: 'HINT', + UNRECOGNIZED: 'UNRECOGNIZED', + UNSUPPORTED: 'UNSUPPORTED', + PERFORMANCE: 'PERFORMANCE', + DEPRECATION: 'DEPRECATION', + TOPOLOGY: 'TOPOLOGY', + SECURITY: 'SECURITY', + GENERIC: 'GENERIC', + UNKNOWN: 'UNKNOWN' +} + +Object.freeze(notificationCategory) +const categories = Object.values(notificationCategory) + +type NotificationClassification = NotificationCategory +/** + * @typedef {NotificationCategory} NotificationClassification + * @experimental + */ +/** + * Constants that represents the Classification in the {@link GqlStatusObject} + * @type {notificationCategory} + * @experimental + */ +const notificationClassification = notificationCategory + +/** + * Class for Cypher notifications + * @access public + */ +class Notification { + code: string + title: string + description: string + severity: string + position: NotificationPosition | {} + severityLevel: NotificationSeverityLevel + category: NotificationCategory + rawSeverityLevel: string + rawCategory?: string + + /** + * Create a Notification instance + * @constructor + * @param {Object} notification - Object with notification data + */ + constructor (notification: any) { + /** + * The code + * @type {string} + * @public + */ + this.code = notification.code + /** + * The title + * @type {string} + * @public + */ + this.title = notification.title + /** + * The description + * @type {string} + * @public + */ + this.description = notification.description + /** + * The raw severity + * + * Use {@link Notification#rawSeverityLevel} for the raw value or {@link Notification#severityLevel} for an enumerated value. + * + * @type {string} + * @public + * @deprecated This property will be removed in 6.0. + */ + this.severity = notification.severity + /** + * The position which the notification had occur. + * + * @type {NotificationPosition} + * @public + */ + this.position = _constructPosition(notification.position) + + /** + * The severity level + * + * @type {NotificationSeverityLevel} + * @public + * @example + * const { summary } = await session.run("RETURN 1") + * + * for (const notification of summary.notifications) { + * switch(notification.severityLevel) { + * case neo4j.notificationSeverityLevel.INFORMATION: // or simply 'INFORMATION' + * console.info(`${notification.title} - ${notification.description}`) + * break + * case neo4j.notificationSeverityLevel.WARNING: // or simply 'WARNING' + * console.warn(`${notification.title} - ${notification.description}`) + * break + * case neo4j.notificationSeverityLevel.UNKNOWN: // or simply 'UNKNOWN' + * default: + * // the raw info came from the server could be found at notification.rawSeverityLevel + * console.log(`${notification.title} - ${notification.description}`) + * break + * } + * } + */ + this.severityLevel = _asEnumerableSeverity(notification.severity) + + /** + * The severity level returned by the server without any validation. + * + * @type {string} + * @public + */ + this.rawSeverityLevel = notification.severity + + /** + * The category + * + * @type {NotificationCategory} + * @public + * @example + * const { summary } = await session.run("RETURN 1") + * + * for (const notification of summary.notifications) { + * switch(notification.category) { + * case neo4j.notificationCategory.QUERY: // or simply 'QUERY' + * console.info(`${notification.title} - ${notification.description}`) + * break + * case neo4j.notificationCategory.PERFORMANCE: // or simply 'PERFORMANCE' + * console.warn(`${notification.title} - ${notification.description}`) + * break + * case neo4j.notificationCategory.UNKNOWN: // or simply 'UNKNOWN' + * default: + * // the raw info came from the server could be found at notification.rawCategory + * console.log(`${notification.title} - ${notification.description}`) + * break + * } + * } + */ + this.category = _asEnumerableClassification(notification.category) + + /** + * The category returned by the server without any validation. + * + * @type {string|undefined} + * @public + */ + this.rawCategory = notification.category + } +} + +interface NotificationDiagnosticRecord { + OPERATION: string + OPERATION_CODE: string + CURRENT_SCHEMA: string + _severity?: string + _classification?: string + _position?: { + offset: NumberOrInteger + line: NumberOrInteger + column: NumberOrInteger + } + _status_parameters?: Record + [key: string]: unknown +} + +/** + * Representation for GqlStatusObject found when executing a query. + *

+ * This object represents a status of query execution. + * This status is a superset of {@link Notification}. + * + * @experimental + * @public + */ +class GqlStatusObject { + public readonly gqlStatus: string + public readonly statusDescription: string + public readonly diagnosticRecord: NotificationDiagnosticRecord + public readonly position?: NotificationPosition + public readonly severity: NotificationSeverityLevel + public readonly rawSeverity?: string + public readonly classification: NotificationClassification + public readonly rawClassification?: string + public readonly isNotification: boolean + + /** + * + * @param rawGqlStatusObject + * @private + */ + constructor (rawGqlStatusObject: any) { + /** + * The GQLSTATUS + * + * @type {string} + * @public + */ + this.gqlStatus = rawGqlStatusObject.gql_status + + /** + * The GQLSTATUS description + * + * @type {string} + * @public + */ + this.statusDescription = rawGqlStatusObject.status_description + + /** + * The diagnostic record as it is. + * + * @type {object} + * @public + */ + this.diagnosticRecord = rawGqlStatusObject.diagnostic_record ?? {} + + /** + * The position at which the notification had occurred. + * + * @type {NotificationPosition | undefined} + * @public + */ + this.position = this.diagnosticRecord._position != null ? _constructPosition(this.diagnosticRecord._position) : undefined + + /** + * The severity + * + * @type {NotificationSeverityLevel} + * @public + * @example + * const { summary } = await session.run("RETURN 1") + * + * for (const gqlStatusObject of summary.gqlStatusObjects) { + * switch(gqlStatusObject.severity) { + * case neo4j.notificationSeverityLevel.INFORMATION: // or simply 'INFORMATION' + * console.info(gqlStatusObject.statusDescription) + * break + * case neo4j.notificationSeverityLevel.WARNING: // or simply 'WARNING' + * console.warn(gqlStatusObject.statusDescription) + * break + * case neo4j.notificationSeverityLevel.UNKNOWN: // or simply 'UNKNOWN' + * default: + * // the raw info came from the server could be found at gqlStatusObject.rawSeverity + * console.log(gqlStatusObject.statusDescription) + * break + * } + * } + */ + this.severity = _asEnumerableSeverity(this.diagnosticRecord._severity) + + /** + * The severity returned in the diagnostic record from the server without any validation. + * + * @type {string | undefined} + * @public + */ + this.rawSeverity = this.diagnosticRecord._severity + + /** + * The classification + * + * @type {NotificationClassification} + * @public + * @example + * const { summary } = await session.run("RETURN 1") + * + * for (const gqlStatusObject of summary.gqlStatusObjects) { + * switch(gqlStatusObject.classification) { + * case neo4j.notificationClassification.QUERY: // or simply 'QUERY' + * console.info(gqlStatusObject.statusDescription) + * break + * case neo4j.notificationClassification.PERFORMANCE: // or simply 'PERFORMANCE' + * console.warn(gqlStatusObject.statusDescription) + * break + * case neo4j.notificationClassification.UNKNOWN: // or simply 'UNKNOWN' + * default: + * // the raw info came from the server can be found at notification.rawCategory + * console.log(gqlStatusObject.statusDescription) + * break + * } + * } + */ + this.classification = _asEnumerableClassification(this.diagnosticRecord._classification) + + /** + * The category returned by the server without any validation. + * + * @type {string|undefined} + * @public + */ + this.rawClassification = this.diagnosticRecord._classification + + /** + * Indicates if this object represents a notification and it can be filtered using + * NotificationFilter. + * + * Only GqlStatusObject which is Notification has meaningful position, severity and + * classification. + * + * @type {boolean} + * @public + */ + this.isNotification = rawGqlStatusObject.neo4j_code != null + Object.freeze(this) + } + + /** + * The json string representation of the diagnostic record. + * The goal of this method is provide a serialized object for human inspection. + * + * @type {string} + * @public + */ + public get diagnosticRecordAsJsonString (): string { + return json.stringify(this.diagnosticRecord, { useCustomToString: true }) + } +} + +/** + * + * @private + * @param status + * @returns {Notification|undefined} + */ +function polyfillNotification (status: any): Notification | undefined { + // Non notification status should have neo4j_code + if (status.neo4j_code == null) { + return undefined + } + + return new Notification({ + code: status.neo4j_code, + title: status.title, + description: status.status_description, + severity: status.diagnostic_record?._severity, + category: status.diagnostic_record?._classification, + position: status.diagnostic_record?._position + }) +} + +/** + * @private + * @param notification + * @returns {GqlStatusObject} + */ +function polyfillGqlStatusObject (notification: any): GqlStatusObject { + const defaultStatus = notification.severity === notificationSeverityLevel.WARNING ? unknownGqlStatus.WARNING : unknownGqlStatus.INFORMATION + const polyfilledRawObj: any & { diagnostic_record: NotificationDiagnosticRecord } = { + gql_status: defaultStatus.gql_status, + status_description: notification.description ?? defaultStatus.status_description, + neo4j_code: notification.code, + title: notification.title, + diagnostic_record: { + ...rawPolyfilledDiagnosticRecord + } + } + + if (notification.severity != null) { + polyfilledRawObj.diagnostic_record._severity = notification.severity + } + + if (notification.category != null) { + polyfilledRawObj.diagnostic_record._classification = notification.category + } + + if (notification.position != null) { + polyfilledRawObj.diagnostic_record._position = notification.position + } + + return new GqlStatusObject(polyfilledRawObj) +} + +const rawPolyfilledDiagnosticRecord = { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' +} + +Object.freeze(rawPolyfilledDiagnosticRecord) + +/** + * This objects are used for polyfilling the first status on the status list + * + * @private + */ +const staticGqlStatusObjects = { + SUCCESS: new GqlStatusObject({ + gql_status: '00000', + status_description: 'note: successful completion', + diagnostic_record: rawPolyfilledDiagnosticRecord + }), + NO_DATA: new GqlStatusObject({ + gql_status: '02000', + status_description: 'note: no data', + diagnostic_record: rawPolyfilledDiagnosticRecord + }), + NO_DATA_UNKNOWN_SUBCONDITION: new GqlStatusObject({ + ...unknownGqlStatus.NO_DATA, + diagnostic_record: rawPolyfilledDiagnosticRecord + }), + OMITTED_RESULT: new GqlStatusObject({ + gql_status: '00001', + status_description: 'note: successful completion - omitted result', + diagnostic_record: rawPolyfilledDiagnosticRecord + }) +} + +Object.freeze(staticGqlStatusObjects) + +/** + * + * @private + * @param metadata + * @returns + */ +function buildGqlStatusObjectFromMetadata (metadata: any): [GqlStatusObject, ...GqlStatusObject[]] { + function getGqlStatusObjectFromStreamSummary (summary: any): GqlStatusObject { + if (summary?.have_records_streamed === true) { + return staticGqlStatusObjects.SUCCESS + } + + if (summary?.has_keys === false) { + return staticGqlStatusObjects.OMITTED_RESULT + } + + if (summary?.pulled === true) { + return staticGqlStatusObjects.NO_DATA + } + + return staticGqlStatusObjects.NO_DATA_UNKNOWN_SUBCONDITION + } + + if (metadata.statuses != null) { + return metadata.statuses.map((status: unknown) => new GqlStatusObject(status)) + } + + const clientGenerated = getGqlStatusObjectFromStreamSummary(metadata.stream_summary) + const polyfilledObjects = [clientGenerated, ...(metadata.notifications?.map(polyfillGqlStatusObject) ?? []) as GqlStatusObject[]] + + return polyfilledObjects.sort((a: GqlStatusObject, b: GqlStatusObject) => calculateWeight(a) - calculateWeight(b)) as [GqlStatusObject, ...GqlStatusObject[]] +} + +const gqlStatusWeightByClass = Object.freeze({ + '02': 0, + '01': 1, + '00': 2 +}) +/** + * GqlStatus weight + * + * @private + */ +function calculateWeight (gqlStatusObject: GqlStatusObject): number { + const gqlClass = gqlStatusObject.gqlStatus?.slice(0, 2) + // @ts-expect-error + return gqlStatusWeightByClass[gqlClass] ?? 9999 +} + +/** + * + * @private + * @param metadata + * @returns + */ +function buildNotificationsFromMetadata (metadata: any): Notification[] { + if (metadata.notifications != null) { + return metadata.notifications.map((n: any) => new Notification(n)) + } + + if (metadata.statuses != null) { + return metadata.statuses.map(polyfillNotification).filter((n: unknown) => n != null) + } + + return [] +} + +/** + * + * @private + * @param pos + * @returns {NotificationPosition} + */ +function _constructPosition (pos: any): NotificationPosition { + if (pos == null) { + return {} + } + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + return { + offset: util.toNumber(pos.offset!), + line: util.toNumber(pos.line!), + column: util.toNumber(pos.column!) + } + /* eslint-enable @typescript-eslint/no-non-null-assertion */ +} + +function _asEnumerableSeverity (severity: any): NotificationSeverityLevel { + return severityLevels.includes(severity) + ? severity + : notificationSeverityLevel.UNKNOWN +} + +function _asEnumerableClassification (classification: any): NotificationClassification { + return categories.includes(classification) + ? classification + : notificationClassification.UNKNOWN +} + +export default Notification + +export { + notificationSeverityLevel, + notificationCategory, + notificationClassification, + Notification, + GqlStatusObject, + polyfillGqlStatusObject, + polyfillNotification, + buildGqlStatusObjectFromMetadata, + buildNotificationsFromMetadata +} + +export type { + NotificationPosition, + NotificationSeverityLevel, + NotificationCategory, + NotificationClassification, + NotificationDiagnosticRecord +} diff --git a/packages/core/src/result-summary.ts b/packages/core/src/result-summary.ts index c909cae1f..4b2cfc5fc 100644 --- a/packages/core/src/result-summary.ts +++ b/packages/core/src/result-summary.ts @@ -15,8 +15,10 @@ * limitations under the License. */ -import Integer, { int } from './integer' +import Integer from './integer' import { NumberOrInteger } from './graph-types' +import { util } from './internal' +import Notification, { GqlStatusObject, buildGqlStatusObjectFromMetadata, buildNotificationsFromMetadata } from './notification' /** * A ResultSummary instance contains structured metadata for a {@link Result}. @@ -30,6 +32,7 @@ class ResultSummary { plan: Plan | false profile: ProfiledPlan | false notifications: Notification[] + gqlStatusObjects: [GqlStatusObject, ...GqlStatusObject[]] server: ServerInfo resultConsumedAfter: T resultAvailableAfter: T @@ -105,7 +108,30 @@ class ResultSummary { * @type {Array} * @public */ - this.notifications = this._buildNotifications(metadata.notifications) + this.notifications = buildNotificationsFromMetadata(metadata) + + /** + * A list of GqlStatusObjects that arise when executing the query. + * + * The list always contains at least 1 status representing the Success, No Data or Omitted Result. + * + * When discarding records while connected to a non-gql aware server and using a RxSession, + * the driver might not be able to tell apart Success and No Data. + * + * All other status are notifications like warnings about problematic queries or other valuable + * information that can be presented in a client. + * + * The GqlStatusObjects will be presented in the following order: + * + * - A “no data” (02xxx) has precedence over a warning; + * - A warning (01xxx) has precedence over a success. + * - A success (00xxx) has precedence over anything informational (03xxx) + * + * @type {Array} + * @public + * @experimental + */ + this.gqlStatusObjects = buildGqlStatusObjectFromMetadata(metadata) /** * The basic information of the server where the result is obtained from. @@ -136,15 +162,6 @@ class ResultSummary { this.database = { name: metadata.db ?? null } } - _buildNotifications (notifications: any[]): Notification[] { - if (notifications == null) { - return [] - } - return notifications.map(function (n: any): Notification { - return new Notification(n) - }) - } - /** * Check if the result summary has a plan * @return {boolean} @@ -358,9 +375,9 @@ class QueryStatistics { // To camelCase const camelCaseIndex = index.replace(/(-\w)/g, m => m[1].toUpperCase()) if (camelCaseIndex in this._stats) { - this._stats[camelCaseIndex] = intValue(statistics[index]) + this._stats[camelCaseIndex] = util.toNumber(statistics[index]) } else if (camelCaseIndex === 'systemUpdates') { - this._systemUpdates = intValue(statistics[index]) + this._systemUpdates = util.toNumber(statistics[index]) } else if (camelCaseIndex === 'containsSystemUpdates') { this._containsSystemUpdates = statistics[index] } else if (camelCaseIndex === 'containsUpdates') { @@ -411,195 +428,6 @@ class QueryStatistics { } } -interface NotificationPosition { - offset?: number - line?: number - column?: number -} - -type NotificationSeverityLevel = 'WARNING' | 'INFORMATION' | 'UNKNOWN' -/** - * @typedef {'WARNING' | 'INFORMATION' | 'UNKNOWN'} NotificationSeverityLevel - */ -/** - * Constants that represents the Severity level in the {@link Notification} - */ -const notificationSeverityLevel: { [key in NotificationSeverityLevel]: key } = { - WARNING: 'WARNING', - INFORMATION: 'INFORMATION', - UNKNOWN: 'UNKNOWN' -} - -Object.freeze(notificationSeverityLevel) -const severityLevels = Object.values(notificationSeverityLevel) - -type NotificationCategory = 'HINT' | 'UNRECOGNIZED' | 'UNSUPPORTED' | 'PERFORMANCE' | -'TOPOLOGY' | 'SECURITY' | 'DEPRECATION' | 'GENERIC' | 'UNKNOWN' -/** - * @typedef {'HINT' | 'UNRECOGNIZED' | 'UNSUPPORTED' |'PERFORMANCE' | 'TOPOLOGY' | 'SECURITY' | 'DEPRECATION' | 'GENERIC' | 'UNKNOWN' } NotificationCategory - */ -/** - * Constants that represents the Category in the {@link Notification} - */ -const notificationCategory: { [key in NotificationCategory]: key } = { - HINT: 'HINT', - UNRECOGNIZED: 'UNRECOGNIZED', - UNSUPPORTED: 'UNSUPPORTED', - PERFORMANCE: 'PERFORMANCE', - DEPRECATION: 'DEPRECATION', - TOPOLOGY: 'TOPOLOGY', - SECURITY: 'SECURITY', - GENERIC: 'GENERIC', - UNKNOWN: 'UNKNOWN' -} - -Object.freeze(notificationCategory) -const categories = Object.values(notificationCategory) - -/** - * Class for Cypher notifications - * @access public - */ -class Notification { - code: string - title: string - description: string - severity: string - position: NotificationPosition | {} - severityLevel: NotificationSeverityLevel - category: NotificationCategory - rawSeverityLevel: string - rawCategory?: string - - /** - * Create a Notification instance - * @constructor - * @param {Object} notification - Object with notification data - */ - constructor (notification: any) { - /** - * The code - * @type {string} - * @public - */ - this.code = notification.code - /** - * The title - * @type {string} - * @public - */ - this.title = notification.title - /** - * The description - * @type {string} - * @public - */ - this.description = notification.description - /** - * The raw severity - * - * Use {@link Notification#rawSeverityLevel} for the raw value or {@link Notification#severityLevel} for an enumerated value. - * - * @type {string} - * @public - * @deprecated This property will be removed in 6.0. - */ - this.severity = notification.severity - /** - * The position which the notification had occur. - * - * @type {NotificationPosition} - * @public - */ - this.position = Notification._constructPosition(notification.position) - - /** - * The severity level - * - * @type {NotificationSeverityLevel} - * @public - * @example - * const { summary } = await session.run("RETURN 1") - * - * for (const notification of summary.notifications) { - * switch(notification.severityLevel) { - * case neo4j.notificationSeverityLevel.INFORMATION: // or simply 'INFORMATION' - * console.info(`${notification.title} - ${notification.description}`) - * break - * case neo4j.notificationSeverityLevel.WARNING: // or simply 'WARNING' - * console.warn(`${notification.title} - ${notification.description}`) - * break - * case neo4j.notificationSeverityLevel.UNKNOWN: // or simply 'UNKNOWN' - * default: - * // the raw info came from the server could be found at notification.rawSeverityLevel - * console.log(`${notification.title} - ${notification.description}`) - * break - * } - * } - */ - this.severityLevel = severityLevels.includes(notification.severity) - ? notification.severity - : notificationSeverityLevel.UNKNOWN - - /** - * The severity level returned by the server without any validation. - * - * @type {string} - * @public - */ - this.rawSeverityLevel = notification.severity - - /** - * The category - * - * @type {NotificationCategory} - * @public - * @example - * const { summary } = await session.run("RETURN 1") - * - * for (const notification of summary.notifications) { - * switch(notification.category) { - * case neo4j.notificationCategory.QUERY: // or simply 'QUERY' - * console.info(`${notification.title} - ${notification.description}`) - * break - * case neo4j.notificationCategory.PERFORMANCE: // or simply 'PERFORMANCE' - * console.warn(`${notification.title} - ${notification.description}`) - * break - * case neo4j.notificationCategory.UNKNOWN: // or simply 'UNKNOWN' - * default: - * // the raw info came from the server could be found at notification.rawCategory - * console.log(`${notification.title} - ${notification.description}`) - * break - * } - * } - */ - this.category = categories.includes(notification.category) - ? notification.category - : notificationCategory.UNKNOWN - - /** - * The category returned by the server without any validation. - * - * @type {string|undefined} - * @public - */ - this.rawCategory = notification.category - } - - static _constructPosition (pos: NotificationPosition): NotificationPosition { - if (pos == null) { - return {} - } - /* eslint-disable @typescript-eslint/no-non-null-assertion */ - return { - offset: intValue(pos.offset!), - line: intValue(pos.line!), - column: intValue(pos.column!) - } - /* eslint-enable @typescript-eslint/no-non-null-assertion */ - } -} - /** * Class for exposing server info from a result. * @access public @@ -642,16 +470,6 @@ class ServerInfo { } } -function intValue (value: NumberOrInteger): number { - if (value instanceof Integer) { - return value.toNumber() - } else if (typeof value === 'bigint') { - return int(value).toNumber() - } else { - return value - } -} - function valueOrDefault ( key: string, values: { [key: string]: NumberOrInteger } | false, @@ -659,7 +477,7 @@ function valueOrDefault ( ): number { if (values !== false && key in values) { const value = values[key] - return intValue(value) + return util.toNumber(value) } else { return defaultValue } @@ -679,18 +497,10 @@ const queryType = { export { queryType, ServerInfo, - Notification, Plan, ProfiledPlan, QueryStatistics, - Stats, - notificationSeverityLevel, - notificationCategory -} -export type { - NotificationPosition, - NotificationSeverityLevel, - NotificationCategory + Stats } export default ResultSummary diff --git a/packages/core/test/__snapshots__/json.test.ts.snap b/packages/core/test/__snapshots__/json.test.ts.snap index 6f85f5a26..14abb06b7 100644 --- a/packages/core/test/__snapshots__/json.test.ts.snap +++ b/packages/core/test/__snapshots__/json.test.ts.snap @@ -1,7 +1,223 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`json .stringify should handle Date 1`] = `"{"year":1999,"month":4,"day":12}"`; + +exports[`json .stringify should handle Date in list 1`] = `"[{"year":1999,"month":4,"day":12}]"`; + +exports[`json .stringify should handle Date in object 1`] = `"{"key":{"year":1999,"month":4,"day":12}}"`; + +exports[`json .stringify should handle DateTime 1`] = `"{"year":2024,"month":6,"day":13,"hour":10,"minute":0,"second":30,"nanosecond":134,"timeZoneOffsetSeconds":-3600,"timeZoneId":"Europe/Berlin"}"`; + +exports[`json .stringify should handle DateTime in list 1`] = `"[{"year":2024,"month":6,"day":13,"hour":10,"minute":0,"second":30,"nanosecond":134,"timeZoneOffsetSeconds":-3600,"timeZoneId":"Europe/Berlin"}]"`; + +exports[`json .stringify should handle DateTime in object 1`] = `"{"key":{"year":2024,"month":6,"day":13,"hour":10,"minute":0,"second":30,"nanosecond":134,"timeZoneOffsetSeconds":-3600,"timeZoneId":"Europe/Berlin"}}"`; + +exports[`json .stringify should handle Duration 1`] = `"{"months":10,"days":2,"seconds":{"low":35,"high":0},"nanoseconds":{"low":100,"high":0}}"`; + +exports[`json .stringify should handle Duration in list 1`] = `"[{"months":10,"days":2,"seconds":{"low":35,"high":0},"nanoseconds":{"low":100,"high":0}}]"`; + +exports[`json .stringify should handle Duration in object 1`] = `"{"key":{"months":10,"days":2,"seconds":{"low":35,"high":0},"nanoseconds":{"low":100,"high":0}}}"`; + +exports[`json .stringify should handle Integer 1`] = `"{"low":5,"high":0}"`; + +exports[`json .stringify should handle Integer in list 1`] = `"[{"low":5,"high":0}]"`; + +exports[`json .stringify should handle Integer in object 1`] = `"{"key":{"low":5,"high":0}}"`; + +exports[`json .stringify should handle LocalDateTime 1`] = `"{"year":1999,"month":4,"day":28,"hour":12,"minute":40,"second":12,"nanosecond":301}"`; + +exports[`json .stringify should handle LocalDateTime in list 1`] = `"[{"year":1999,"month":4,"day":28,"hour":12,"minute":40,"second":12,"nanosecond":301}]"`; + +exports[`json .stringify should handle LocalDateTime in object 1`] = `"{"key":{"year":1999,"month":4,"day":28,"hour":12,"minute":40,"second":12,"nanosecond":301}}"`; + +exports[`json .stringify should handle LocalTime 1`] = `"{"hour":2,"minute":30,"second":25,"nanosecond":150}"`; + +exports[`json .stringify should handle LocalTime in list 1`] = `"[{"hour":2,"minute":30,"second":25,"nanosecond":150}]"`; + +exports[`json .stringify should handle LocalTime in object 1`] = `"{"key":{"hour":2,"minute":30,"second":25,"nanosecond":150}}"`; + +exports[`json .stringify should handle Node 1`] = `"{"identity":1,"labels":["Person"],"properties":{"name":"Mr. Bauer"},"elementId":"myId"}"`; + +exports[`json .stringify should handle Node in list 1`] = `"[{"identity":1,"labels":["Person"],"properties":{"name":"Mr. Bauer"},"elementId":"myId"}]"`; + +exports[`json .stringify should handle Node in object 1`] = `"{"key":{"identity":1,"labels":["Person"],"properties":{"name":"Mr. Bauer"},"elementId":"myId"}}"`; + +exports[`json .stringify should handle Path 1`] = `"{"start":{"identity":1,"labels":["Person"],"properties":{"name":"Antonio"},"elementId":"antonioId"},"end":{"identity":2,"labels":["Person"],"properties":{"name":"Mr. Bauer"},"elementId":"mrBauerId"},"segments":[{"start":{"identity":1,"labels":["Person"],"properties":{"name":"Antonio"},"elementId":"antonioId"},"relationship":{"identity":3,"start":1,"end":2,"type":"PLAY_FOOTBALL","properties":{"since":1897},"elementId":"relId","startNodeElementId":"antonioId","endNodeElementId":"mrBauerId"},"end":{"identity":2,"labels":["Person"],"properties":{"name":"Mr. Bauer"},"elementId":"mrBauerId"}}],"length":1}"`; + +exports[`json .stringify should handle Path in list 1`] = `"[{"start":{"identity":1,"labels":["Person"],"properties":{"name":"Antonio"},"elementId":"antonioId"},"end":{"identity":2,"labels":["Person"],"properties":{"name":"Mr. Bauer"},"elementId":"mrBauerId"},"segments":[{"start":{"identity":1,"labels":["Person"],"properties":{"name":"Antonio"},"elementId":"antonioId"},"relationship":{"identity":3,"start":1,"end":2,"type":"PLAY_FOOTBALL","properties":{"since":1897},"elementId":"relId","startNodeElementId":"antonioId","endNodeElementId":"mrBauerId"},"end":{"identity":2,"labels":["Person"],"properties":{"name":"Mr. Bauer"},"elementId":"mrBauerId"}}],"length":1}]"`; + +exports[`json .stringify should handle Path in object 1`] = `"{"key":{"start":{"identity":1,"labels":["Person"],"properties":{"name":"Antonio"},"elementId":"antonioId"},"end":{"identity":2,"labels":["Person"],"properties":{"name":"Mr. Bauer"},"elementId":"mrBauerId"},"segments":[{"start":{"identity":1,"labels":["Person"],"properties":{"name":"Antonio"},"elementId":"antonioId"},"relationship":{"identity":3,"start":1,"end":2,"type":"PLAY_FOOTBALL","properties":{"since":1897},"elementId":"relId","startNodeElementId":"antonioId","endNodeElementId":"mrBauerId"},"end":{"identity":2,"labels":["Person"],"properties":{"name":"Mr. Bauer"},"elementId":"mrBauerId"}}],"length":1}}"`; + +exports[`json .stringify should handle Point 1`] = `"{"srid":4979,"x":1,"y":2,"z":3}"`; + +exports[`json .stringify should handle Point in list 1`] = `"[{"srid":4979,"x":1,"y":2,"z":3}]"`; + +exports[`json .stringify should handle Point in object 1`] = `"{"key":{"srid":4979,"x":1,"y":2,"z":3}}"`; + +exports[`json .stringify should handle Relationship 1`] = `"{"identity":1,"start":2,"end":3,"type":"FRIENDSHIP","properties":{"started":1999},"elementId":"myId","startNodeElementId":"startId","endNodeElementId":"endId"}"`; + +exports[`json .stringify should handle Relationship in list 1`] = `"[{"identity":1,"start":2,"end":3,"type":"FRIENDSHIP","properties":{"started":1999},"elementId":"myId","startNodeElementId":"startId","endNodeElementId":"endId"}]"`; + +exports[`json .stringify should handle Relationship in object 1`] = `"{"key":{"identity":1,"start":2,"end":3,"type":"FRIENDSHIP","properties":{"started":1999},"elementId":"myId","startNodeElementId":"startId","endNodeElementId":"endId"}}"`; + +exports[`json .stringify should handle Time 1`] = `"{"hour":12,"minute":50,"second":23,"nanosecond":300,"timeZoneOffsetSeconds":3600}"`; + +exports[`json .stringify should handle Time in list 1`] = `"[{"hour":12,"minute":50,"second":23,"nanosecond":300,"timeZoneOffsetSeconds":3600}]"`; + +exports[`json .stringify should handle Time in object 1`] = `"{"key":{"hour":12,"minute":50,"second":23,"nanosecond":300,"timeZoneOffsetSeconds":3600}}"`; + +exports[`json .stringify should handle UnboundRelationship 1`] = `"{"identity":1,"type":"ALONE","properties":{"since":2001},"elementId":"myId"}"`; + +exports[`json .stringify should handle UnboundRelationship in list 1`] = `"[{"identity":1,"type":"ALONE","properties":{"since":2001},"elementId":"myId"}]"`; + +exports[`json .stringify should handle UnboundRelationship in object 1`] = `"{"key":{"identity":1,"type":"ALONE","properties":{"since":2001},"elementId":"myId"}}"`; + +exports[`json .stringify should handle bigint 1`] = `""3n""`; + +exports[`json .stringify should handle bigint in list 1`] = `"["3n"]"`; + +exports[`json .stringify should handle bigint in object 1`] = `"{"key":"3n"}"`; + +exports[`json .stringify should handle list 1`] = `"["1",2,{"tres":3}]"`; + +exports[`json .stringify should handle list in list 1`] = `"[["1",2,{"tres":3}]]"`; + +exports[`json .stringify should handle list in object 1`] = `"{"key":["1",2,{"tres":3}]}"`; + +exports[`json .stringify should handle number 1`] = `"2"`; + +exports[`json .stringify should handle number in list 1`] = `"[2]"`; + +exports[`json .stringify should handle number in object 1`] = `"{"key":2}"`; + +exports[`json .stringify should handle object 1`] = `"{"identity":123,"labels":["a"],"properties":{"key":"value"},"elementId":"abc"}"`; + +exports[`json .stringify should handle object in list 1`] = `"[{"identity":123,"labels":["a"],"properties":{"key":"value"},"elementId":"abc"}]"`; + +exports[`json .stringify should handle object in object 1`] = `"{"key":{"identity":123,"labels":["a"],"properties":{"key":"value"},"elementId":"abc"}}"`; + +exports[`json .stringify should handle object with custom toString 1`] = `"{"identity":"1"}"`; + +exports[`json .stringify should handle object with custom toString in list 1`] = `"[{"identity":"1"}]"`; + +exports[`json .stringify should handle object with custom toString in object 1`] = `"{"key":{"identity":"1"}}"`; + exports[`json .stringify should handle objects created with createBrokenObject 1`] = `"{"__isBrokenObject__":true,"__reason__":{"code":"N/A","name":"Neo4jError","retriable":false}}"`; exports[`json .stringify should handle objects created with createBrokenObject in list 1`] = `"[{"__isBrokenObject__":true,"__reason__":{"code":"N/A","name":"Neo4jError","retriable":false}}]"`; exports[`json .stringify should handle objects created with createBrokenObject inside other object 1`] = `"{"number":1,"broken":{"__isBrokenObject__":true,"__reason__":{"code":"N/A","name":"Neo4jError","retriable":false}}}"`; + +exports[`json .stringify should handle string 1`] = `""my string""`; + +exports[`json .stringify should handle string in list 1`] = `"["my string"]"`; + +exports[`json .stringify should handle string in object 1`] = `"{"key":"my string"}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle Date 1`] = `""1999-04-12""`; + +exports[`json .stringify when opts.useCustomToString=true should handle Date in list 1`] = `"["1999-04-12"]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle Date in object 1`] = `"{"key":"1999-04-12"}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle DateTime 1`] = `""2024-06-13T10:00:30.000000134-01:00[Europe/Berlin]""`; + +exports[`json .stringify when opts.useCustomToString=true should handle DateTime in list 1`] = `"["2024-06-13T10:00:30.000000134-01:00[Europe/Berlin]"]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle DateTime in object 1`] = `"{"key":"2024-06-13T10:00:30.000000134-01:00[Europe/Berlin]"}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle Duration 1`] = `""P10M2DT35.000000100S""`; + +exports[`json .stringify when opts.useCustomToString=true should handle Duration in list 1`] = `"["P10M2DT35.000000100S"]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle Duration in object 1`] = `"{"key":"P10M2DT35.000000100S"}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle Integer 1`] = `""5""`; + +exports[`json .stringify when opts.useCustomToString=true should handle Integer in list 1`] = `"["5"]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle Integer in object 1`] = `"{"key":"5"}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle LocalDateTime 1`] = `""1999-04-28T12:40:12.000000301""`; + +exports[`json .stringify when opts.useCustomToString=true should handle LocalDateTime in list 1`] = `"["1999-04-28T12:40:12.000000301"]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle LocalDateTime in object 1`] = `"{"key":"1999-04-28T12:40:12.000000301"}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle LocalTime 1`] = `""02:30:25.000000150""`; + +exports[`json .stringify when opts.useCustomToString=true should handle LocalTime in list 1`] = `"["02:30:25.000000150"]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle LocalTime in object 1`] = `"{"key":"02:30:25.000000150"}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle Node 1`] = `""(myId:Person {name:\\"Mr. Bauer\\"})""`; + +exports[`json .stringify when opts.useCustomToString=true should handle Node in list 1`] = `"["(myId:Person {name:\\"Mr. Bauer\\"})"]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle Node in object 1`] = `"{"key":"(myId:Person {name:\\"Mr. Bauer\\"})"}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle Path 1`] = `"{"start":"(antonioId:Person {name:\\"Antonio\\"})","end":"(mrBauerId:Person {name:\\"Mr. Bauer\\"})","segments":[{"start":"(antonioId:Person {name:\\"Antonio\\"})","relationship":"(antonioId)-[:PLAY_FOOTBALL {since:1897}]->(mrBauerId)","end":"(mrBauerId:Person {name:\\"Mr. Bauer\\"})"}],"length":1}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle Path in list 1`] = `"[{"start":"(antonioId:Person {name:\\"Antonio\\"})","end":"(mrBauerId:Person {name:\\"Mr. Bauer\\"})","segments":[{"start":"(antonioId:Person {name:\\"Antonio\\"})","relationship":"(antonioId)-[:PLAY_FOOTBALL {since:1897}]->(mrBauerId)","end":"(mrBauerId:Person {name:\\"Mr. Bauer\\"})"}],"length":1}]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle Path in object 1`] = `"{"key":{"start":"(antonioId:Person {name:\\"Antonio\\"})","end":"(mrBauerId:Person {name:\\"Mr. Bauer\\"})","segments":[{"start":"(antonioId:Person {name:\\"Antonio\\"})","relationship":"(antonioId)-[:PLAY_FOOTBALL {since:1897}]->(mrBauerId)","end":"(mrBauerId:Person {name:\\"Mr. Bauer\\"})"}],"length":1}}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle Point 1`] = `""Point{srid=4979.0, x=1.0, y=2.0, z=3.0}""`; + +exports[`json .stringify when opts.useCustomToString=true should handle Point in list 1`] = `"["Point{srid=4979.0, x=1.0, y=2.0, z=3.0}"]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle Point in object 1`] = `"{"key":"Point{srid=4979.0, x=1.0, y=2.0, z=3.0}"}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle Relationship 1`] = `""(startId)-[:FRIENDSHIP {started:1999}]->(endId)""`; + +exports[`json .stringify when opts.useCustomToString=true should handle Relationship in list 1`] = `"["(startId)-[:FRIENDSHIP {started:1999}]->(endId)"]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle Relationship in object 1`] = `"{"key":"(startId)-[:FRIENDSHIP {started:1999}]->(endId)"}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle Time 1`] = `""12:50:23.000000300+01:00""`; + +exports[`json .stringify when opts.useCustomToString=true should handle Time in list 1`] = `"["12:50:23.000000300+01:00"]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle Time in object 1`] = `"{"key":"12:50:23.000000300+01:00"}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle UnboundRelationship 1`] = `""-[:ALONE {since:2001}]->""`; + +exports[`json .stringify when opts.useCustomToString=true should handle UnboundRelationship in list 1`] = `"["-[:ALONE {since:2001}]->"]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle UnboundRelationship in object 1`] = `"{"key":"-[:ALONE {since:2001}]->"}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle bigint 1`] = `""3n""`; + +exports[`json .stringify when opts.useCustomToString=true should handle bigint in list 1`] = `"["3n"]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle bigint in object 1`] = `"{"key":"3n"}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle list 1`] = `"["1",2,{"tres":3}]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle list in list 1`] = `"[["1",2,{"tres":3}]]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle list in object 1`] = `"{"key":["1",2,{"tres":3}]}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle number 1`] = `"2"`; + +exports[`json .stringify when opts.useCustomToString=true should handle number in list 1`] = `"[2]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle number in object 1`] = `"{"key":2}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle object 1`] = `"{"identity":123,"labels":["a"],"properties":{"key":"value"},"elementId":"abc"}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle object in list 1`] = `"[{"identity":123,"labels":["a"],"properties":{"key":"value"},"elementId":"abc"}]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle object in object 1`] = `"{"key":{"identity":123,"labels":["a"],"properties":{"key":"value"},"elementId":"abc"}}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle object with custom toString 1`] = `""My identity is One""`; + +exports[`json .stringify when opts.useCustomToString=true should handle object with custom toString in list 1`] = `"["My identity is One"]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle object with custom toString in object 1`] = `"{"key":"My identity is One"}"`; + +exports[`json .stringify when opts.useCustomToString=true should handle string 1`] = `""my string""`; + +exports[`json .stringify when opts.useCustomToString=true should handle string in list 1`] = `"["my string"]"`; + +exports[`json .stringify when opts.useCustomToString=true should handle string in object 1`] = `"{"key":"my string"}"`; diff --git a/packages/core/test/driver.test.ts b/packages/core/test/driver.test.ts index f4e810efb..e4d7994fb 100644 --- a/packages/core/test/driver.test.ts +++ b/packages/core/test/driver.test.ts @@ -24,7 +24,7 @@ import { ConfiguredCustomResolver } from '../src/internal/resolver' import { LogLevel } from '../src/types' import resultTransformers from '../src/result-transformers' import Record, { RecordShape } from '../src/record' -import { validNotificationFilters } from './utils/notification-filters.fixtures' +import { invalidNotificationFilters, validNotificationFilters } from './utils/notification-filters.fixtures' describe('Driver', () => { let driver: Driver | null @@ -603,6 +603,21 @@ describe('Driver', () => { await driver.close() }) + + it.each( + invalidNotificationFilters() + )('should fail on invalid notification filters', async (notificationFilter?: NotificationFilter) => { + const createConnectionProviderMock = jest.fn(mockCreateConnectonProvider(connectionProvider)) + + expect(() => new Driver( + META_INFO, + { notificationFilter }, + createConnectionProviderMock, + createSession + )).toThrow(new Error('The notificationFilter can\'t have both "disabledCategories" and "disabledClassifications" configured at the same time.')) + + expect(createConnectionProviderMock).not.toHaveBeenCalled() + }) }) describe('config', () => { diff --git a/packages/core/test/json.test.ts b/packages/core/test/json.test.ts index e0dd140e4..88b64ce3a 100644 --- a/packages/core/test/json.test.ts +++ b/packages/core/test/json.test.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { json, newError } from '../src' +import { Date, DateTime, Duration, LocalDateTime, LocalTime, Node, Path, PathSegment, Point, Relationship, Time, UnboundRelationship, int, json, newError } from '../src' import { createBrokenObject } from '../src/internal/object-util' describe('json', () => { @@ -43,5 +43,62 @@ describe('json', () => { broken })).toMatchSnapshot() }) + + it.each(commonTypesFixture())('should handle %s', (_, value) => { + expect(json.stringify(value)).toMatchSnapshot() + }) + + it.each(commonTypesFixture())('should handle %s in list', (_, value) => { + expect(json.stringify([value])).toMatchSnapshot() + }) + + it.each(commonTypesFixture())('should handle %s in object', (_, value) => { + expect(json.stringify({ key: value })).toMatchSnapshot() + }) + + describe('when opts.useCustomToString=true', () => { + it.each(commonTypesFixture())('should handle %s', (_, value) => { + expect(json.stringify(value, { useCustomToString: true })).toMatchSnapshot() + }) + + it.each(commonTypesFixture())('should handle %s in list', (_, value) => { + expect(json.stringify([value], { useCustomToString: true })).toMatchSnapshot() + }) + + it.each(commonTypesFixture())('should handle %s in object', (_, value) => { + expect(json.stringify({ key: value }, { useCustomToString: true })).toMatchSnapshot() + }) + }) }) }) + +function commonTypesFixture (): Array<[string, unknown]> { + return [ + ['number', 2], + ['bigint', BigInt(3)], + ['Integer', int(5)], + ['string', 'my string'], + ['object', { identity: 123, labels: ['a'], properties: { key: 'value' }, elementId: 'abc' }], + ['object with custom toString', { identity: '1', toString () { return 'My identity is One' } }], + ['list', ['1', 2, { tres: 3 }]], + ['Node', new Node(1, ['Person'], { name: 'Mr. Bauer' }, 'myId')], + ['Relationship', new Relationship(1, 2, 3, 'FRIENDSHIP', { started: 1999 }, 'myId', 'startId', 'endId')], + ['UnboundRelationship', new UnboundRelationship(1, 'ALONE', { since: 2001 }, 'myId')], + ['Path', new Path( + new Node(1, ['Person'], { name: 'Antonio' }, 'antonioId'), + new Node(2, ['Person'], { name: 'Mr. Bauer' }, 'mrBauerId'), + [new PathSegment( + new Node(1, ['Person'], { name: 'Antonio' }, 'antonioId'), + new Relationship(3, 1, 2, 'PLAY_FOOTBALL', { since: 1897 }, 'relId', 'antonioId', 'mrBauerId'), + new Node(2, ['Person'], { name: 'Mr. Bauer' }, 'mrBauerId') + )]) + ], + ['Point', new Point(4979, 1, 2, 3)], + ['Duration', new Duration(10, 2, 35, 100)], + ['LocalTime', new LocalTime(2, 30, 25, 150)], + ['Time', new Time(12, 50, 23, 300, 3600)], + ['Date', new Date(1999, 4, 12)], + ['LocalDateTime', new LocalDateTime(1999, 4, 28, 12, 40, 12, 301)], + ['DateTime', new DateTime(2024, 6, 13, 10, 0, 30, 134, -3600, 'Europe/Berlin')] + ] +} diff --git a/packages/core/test/notification-filter.test.ts b/packages/core/test/notification-filter.test.ts index c0f65661e..c629428ed 100644 --- a/packages/core/test/notification-filter.test.ts +++ b/packages/core/test/notification-filter.test.ts @@ -17,6 +17,8 @@ import { NotificationFilterDisabledCategory, notificationFilterDisabledCategory, + NotificationFilterDisabledClassification, + notificationFilterDisabledClassification, NotificationFilterMinimumSeverityLevel, notificationFilterMinimumSeverityLevel } from '../src/notification-filter' @@ -61,6 +63,30 @@ describe('notificationFilterDisabledCategory', () => { }) }) +describe('notificationFilterDisabledClassification', () => { + it('should have keys equals to values', () => { + for (const [key, value] of Object.entries(notificationFilterDisabledClassification)) { + expect(key).toEqual(value) + } + }) + + it('should values be assignable to NotificationFilterDisabledClassification', () => { + for (const [, value] of Object.entries(notificationFilterDisabledClassification)) { + const assignableValue: NotificationFilterDisabledClassification = value + expect(assignableValue).toBeDefined() + } + }) + + it.each(getValidNotificationsCategories())('should have %s as key', (category) => { + const keys = Object.keys(notificationFilterDisabledCategory) + expect(keys.includes(category)).toBe(true) + }) + + it('should be notificationFilterDisabledCategory', () => { + expect(notificationFilterDisabledClassification).toBe(notificationFilterDisabledCategory) + }) +}) + function getValidNotificationsSeverityLevels (): NotificationFilterMinimumSeverityLevel[] { return [ 'OFF', diff --git a/packages/core/test/notification.test.ts b/packages/core/test/notification.test.ts new file mode 100644 index 000000000..d3227afd4 --- /dev/null +++ b/packages/core/test/notification.test.ts @@ -0,0 +1,1528 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * 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 { Node, int } from '../src' +import * as json from '../src/json' +import { + Notification, + GqlStatusObject, + NotificationSeverityLevel, + NotificationCategory, + notificationSeverityLevel, + notificationCategory, + notificationClassification, + NotificationClassification, + polyfillGqlStatusObject, + polyfillNotification, + buildGqlStatusObjectFromMetadata, + buildNotificationsFromMetadata +} from '../src/notification' + +describe('Notification', () => { + describe('.severityLevel', () => { + it.each(getValidSeverityLevels())('should fill severityLevel with the rawSeverityLevel equals to %s', rawSeverityLevel => { + const rawNotification = { + severity: rawSeverityLevel + } + + const notification = new Notification(rawNotification) + + expect(notification.severityLevel).toBe(rawSeverityLevel) + expect(notification.rawSeverityLevel).toBe(rawSeverityLevel) + }) + + it.each([ + 'UNKNOWN', + null, + undefined, + 'I_AM_NOT_OKAY', + 'information' + ])('should fill severityLevel UNKNOWN if the rawSeverityLevel equals to %s', rawSeverityLevel => { + const rawNotification = { + severity: rawSeverityLevel + } + + const notification = new Notification(rawNotification) + + expect(notification.severityLevel).toBe('UNKNOWN') + expect(notification.rawSeverityLevel).toBe(rawSeverityLevel) + }) + }) + + describe('.category', () => { + it.each(getValidCategories())('should fill category with the rawCategory equals to %s', rawCategory => { + const rawNotification = { + category: rawCategory + } + + const notification = new Notification(rawNotification) + + expect(notification.category).toBe(rawCategory) + expect(notification.rawCategory).toBe(rawCategory) + }) + + it.each([ + 'UNKNOWN', + undefined, + null, + 'DUNNO', + 'deprecation' + ])('should fill category with UNKNOWN the rawCategory equals to %s', rawCategory => { + const rawNotification = { + category: rawCategory + } + + const notification = new Notification(rawNotification) + + expect(notification.category).toBe('UNKNOWN') + expect(notification.rawCategory).toBe(rawCategory) + }) + }) + + describe('polyfillNotification()', () => { + it.each([ + getSuccessStatus(), + getNoDataStatus(), + getOmittedResultStatus(), + getNoDataUnknownSubconditionStatus() + ])('should return undefined when status is not a notification (%o)', (status: any) => { + expect(polyfillNotification(status)).toBeUndefined() + }) + + it.each(getValidCategories())('should polyfill severity WARNING', (category) => { + const status = { + neo4j_code: 'Neo.Notification.Warning.Code', + gql_status: '01N42', + status_description: 'Description', + title: 'Notification Title', + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _severity: 'WARNING', + _classification: category, + _position: { + offset: 0, + line: 0, + column: 0 + }, + _status_parameters: {} + } + } + + const notification = polyfillNotification(status) + + expect(notification).toEqual(new Notification({ + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category + })) + }) + + it.each(getValidCategories())('should polyfill severity INFORMATION', (category) => { + const status = { + neo4j_code: 'Neo.Notification.Warning.Code', + gql_status: '03N42', + title: 'Notification Title', + status_description: 'Description', + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _severity: 'INFORMATION', + _classification: category, + _position: { + offset: 0, + line: 0, + column: 0 + }, + _status_parameters: {} + } + } + + const notification = polyfillNotification(status) + + expect(notification).toEqual(new Notification({ + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'INFORMATION', + position: { + offset: 0, + line: 0, + column: 0 + }, + category + })) + }) + + it.each([ + 'UNKNOWN', + null, + undefined, + 'I_AM_NOT_OKAY', + 'information' + ])('should polyfill severity UNKNOWN', (severity) => { + const status = { + neo4j_code: 'Neo.Notification.Warning.Code', + gql_status: '03N42', + title: 'Notification Title', + status_description: 'Description', + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _severity: severity, + _classification: 'UNSUPPORTED', + _position: { + offset: 1, + line: 2, + column: 3 + }, + _status_parameters: {} + } + } + + const notification = polyfillNotification(status) + + expect(notification).toEqual(new Notification({ + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity, + position: { + offset: 1, + line: 2, + column: 3 + }, + category: 'UNSUPPORTED' + })) + }) + + it('should polyfill when diagnostic record is not present', () => { + const status = { + neo4j_code: 'Neo.Notification.Warning.Code', + gql_status: '03N42', + title: 'Notification Title', + status_description: 'Description' + } + + const notification = polyfillNotification(status) + + expect(notification).toEqual(new Notification({ + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description' + })) + }) + }) +}) + +describe('GqlStatusObject', () => { + describe('constructor', () => { + it('should fill gqlStatus with raw.gql_status', () => { + const gqlStatus = '00001' + const rawGqlStatusObject = { + gql_status: gqlStatus + } + + const gqlStatusObject = new GqlStatusObject(rawGqlStatusObject) + + expect(gqlStatusObject.gqlStatus).toBe(gqlStatus) + }) + + it('should fill statusDescription with raw.status_description', () => { + const statusDescription = 'some gql standard status description' + const rawGqlStatusObject = { + status_description: statusDescription + } + + const gqlStatusObject = new GqlStatusObject(rawGqlStatusObject) + + expect(gqlStatusObject.statusDescription).toBe(statusDescription) + }) + + it('should fill diagnosticRecord with raw.diagnostic_record', () => { + const diagnosticRecord = { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _severity: '', + _classification: '', + _position: { + offset: 0, + line: 0, + column: 0 + }, + _status_parameters: {} + } + const rawGqlStatusObject = { + diagnostic_record: diagnosticRecord + } + + const gqlStatusObject = new GqlStatusObject(rawGqlStatusObject) + + expect(gqlStatusObject.diagnosticRecord).toBe(diagnosticRecord) + }) + + it('should fill position with values from raw.diagnostic_record', () => { + const diagnosticRecord = { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _severity: '', + _classification: '', + _position: { + offset: 0, + line: 0, + column: 0 + }, + _status_parameters: {} + } + + const rawGqlStatusObject = { + diagnostic_record: diagnosticRecord + } + + const gqlStatusObject = new GqlStatusObject(rawGqlStatusObject) + + expect(gqlStatusObject.position).toEqual(diagnosticRecord._position) + }) + + it.each(getValidSeverityLevels())('should fill severity with values came from raw.diagnostic_record (%s)', (severity) => { + const diagnosticRecord = { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _severity: severity, + _classification: '', + _position: { + offset: 0, + line: 0, + column: 0 + }, + _status_parameters: {} + } + + const rawGqlStatusObject = { + diagnostic_record: diagnosticRecord + } + + const gqlStatusObject = new GqlStatusObject(rawGqlStatusObject) + + expect(gqlStatusObject.severity).toEqual(severity) + expect(gqlStatusObject.rawSeverity).toEqual(severity) + }) + + it.each([ + 'UNKNOWN', + null, + undefined, + 'I_AM_NOT_OKAY', + 'information' + ])('should fill severity UNKNOWN if the raw.diagnostic_record._severity equals to %s', severity => { + const diagnosticRecord = { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _severity: severity, + _classification: '', + _position: { + offset: 0, + line: 0, + column: 0 + }, + _status_parameters: {} + } + + const rawGqlStatusObject = { + diagnostic_record: diagnosticRecord + } + + const gqlStatusObject = new GqlStatusObject(rawGqlStatusObject) + + expect(gqlStatusObject.severity).toEqual(notificationSeverityLevel.UNKNOWN) + expect(gqlStatusObject.rawSeverity).toEqual(severity) + }) + + it.each(getValidClassifications())('should fill classification with values came from raw.diagnostic_record (%s)', (classification) => { + const diagnosticRecord = { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _severity: '', + _classification: classification, + _position: { + offset: 0, + line: 0, + column: 0 + }, + _status_parameters: {} + } + + const rawGqlStatusObject = { + diagnostic_record: diagnosticRecord + } + + const gqlStatusObject = new GqlStatusObject(rawGqlStatusObject) + + expect(gqlStatusObject.classification).toEqual(classification) + expect(gqlStatusObject.rawClassification).toEqual(classification) + }) + + it.each([ + 'UNKNOWN', + undefined, + null, + 'DUNNO', + 'deprecation' + ])('should fill classification UNKNOWN if the raw.diagnostic_record._classification equals to %s', classification => { + const diagnosticRecord = { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _severity: '', + _classification: classification, + _position: { + offset: 0, + line: 0, + column: 0 + }, + _status_parameters: {} + } + + const rawGqlStatusObject = { + diagnostic_record: diagnosticRecord + } + + const gqlStatusObject = new GqlStatusObject(rawGqlStatusObject) + + expect(gqlStatusObject.classification).toEqual(notificationClassification.UNKNOWN) + expect(gqlStatusObject.rawClassification).toEqual(classification) + }) + }) + + describe('diagnosticRecordAsJsonString()', () => { + it('should stringify diagnosticRecord', () => { + const diagnosticRecord = { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _severity: '', + _classification: '', + _position: { + offset: 0, + line: 0, + column: 0 + }, + _status_parameters: { + some_node: new Node(int(1), ['ABC'], { bla: 'string' }, 'myElementId') + } + } + const rawGqlStatusObject = { + diagnostic_record: diagnosticRecord + } + + const gqlStatusObject = new GqlStatusObject(rawGqlStatusObject) + + expect(gqlStatusObject.diagnosticRecordAsJsonString).toBe(json.stringify(diagnosticRecord, { useCustomToString: true })) + }) + }) + + describe('polyfillGqlStatusObject()', () => { + it.each(getValidCategories())('should polyfill severity WARNING', (category) => { + const rawNotification = { + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category + } + + const gqlStatusObject = polyfillGqlStatusObject(rawNotification) + + expect(gqlStatusObject).toEqual(new GqlStatusObject({ + neo4j_code: rawNotification.code, + gql_status: '01N42', + status_description: rawNotification.description, + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _severity: 'WARNING', + _classification: category, + _position: { + offset: 0, + line: 0, + column: 0 + } + } + })) + }) + + it.each(getValidCategories())('should polyfill severity WARNING and no description', (category) => { + const rawNotification = { + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category + } + + const gqlStatusObject = polyfillGqlStatusObject(rawNotification) + + expect(gqlStatusObject).toEqual(new GqlStatusObject({ + neo4j_code: rawNotification.code, + gql_status: '01N42', + status_description: 'warn: unknown warning', + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _severity: 'WARNING', + _classification: category, + _position: { + offset: 0, + line: 0, + column: 0 + } + } + })) + }) + + it.each(getValidCategories())('should polyfill severity INFORMATION', (category) => { + const rawNotification = { + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'INFORMATION', + position: { + offset: 0, + line: 0, + column: 0 + }, + category + } + + const gqlStatusObject = polyfillGqlStatusObject(rawNotification) + + expect(gqlStatusObject).toEqual(new GqlStatusObject({ + neo4j_code: rawNotification.code, + gql_status: '03N42', + status_description: rawNotification.description, + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _severity: 'INFORMATION', + _classification: category, + _position: { + offset: 0, + line: 0, + column: 0 + } + } + })) + }) + + it.each(getValidCategories())('should polyfill severity INFORMATION and no description', (category) => { + const rawNotification = { + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + severity: 'INFORMATION', + position: { + offset: 0, + line: 0, + column: 0 + }, + category + } + + const gqlStatusObject = polyfillGqlStatusObject(rawNotification) + + expect(gqlStatusObject).toEqual(new GqlStatusObject({ + neo4j_code: rawNotification.code, + gql_status: '03N42', + status_description: 'info: unknown notification', + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _severity: 'INFORMATION', + _classification: category, + _position: { + offset: 0, + line: 0, + column: 0 + } + } + })) + }) + + it.each([ + 'UNKNOWN', + null, + undefined, + 'I_AM_NOT_OKAY', + 'information' + ])('should polyfill severity UNKNOWN', (severity) => { + const rawNotification = { + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity, + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'UNSUPPORTED' + } + + const gqlStatusObject = polyfillGqlStatusObject(rawNotification) + + expect(gqlStatusObject).toEqual(new GqlStatusObject({ + neo4j_code: rawNotification.code, + gql_status: '03N42', + status_description: rawNotification.description, + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _severity: severity != null ? severity : undefined, + _classification: rawNotification.category, + _position: { + offset: 0, + line: 0, + column: 0 + } + } + })) + }) + + it.each([ + 'UNKNOWN', + null, + undefined, + 'I_AM_NOT_OKAY', + 'information' + ])('should polyfill UNKNOWN and no description', (severity) => { + const rawNotification = { + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + severity, + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'UNSUPPORTED' + } + + const gqlStatusObject = polyfillGqlStatusObject(rawNotification) + + expect(gqlStatusObject).toEqual(new GqlStatusObject({ + neo4j_code: rawNotification.code, + gql_status: '03N42', + status_description: 'info: unknown notification', + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _severity: severity != null ? severity : undefined, + _classification: rawNotification.category, + _position: { + offset: 0, + line: 0, + column: 0 + } + } + })) + }) + }) +}) + +describe('buildGqlStatusObjectFromMetadata', () => { + it.each([ + { + statuses: getValidStatus(), + notifications: [{ + severity: 'WARNING', + description: 'Some description', + code: 'Neo.Notification.Warning.Code', + title: 'The title', + category: 'DEPRECATION', + position: { + offset: 10, + line: 13, + column: 123 + } + }] + }, + { + statuses: [ + { + gql_status: '00000', + status_description: 'successful completion — omitted', + diagnostic_record: { + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' + } + } + ], + notifications: [{ + severity: 'WARNING', + description: 'Some description', + code: 'Neo.Notification.Warning.Code', + title: 'The title', + category: 'DEPRECATION', + position: { + offset: 10, + line: 13, + column: 123 + } + }] + }, + { + statuses: [ + { + gql_status: '00000', + status_description: 'successful completion — omitted', + diagnostic_record: { + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' + } + } + ] + }, + { + statuses: [] + }, + { + statuses: [], + notifications: [ + { + severity: 'WARNING', + description: 'Some description', + code: 'Neo.Notification.Warning.Code', + title: 'The title', + category: 'DEPRECATION', + position: { + offset: 10, + line: 13, + column: 123 + } + } + ] + } + ])('should build from statuses when available', (metadata: any) => { + const expectedStatuses = metadata.statuses.map((status: any) => new GqlStatusObject(status)) + + expect(buildGqlStatusObjectFromMetadata(metadata)).toEqual(expectedStatuses) + }) + + it.each([ + // SUCCESS + [ + getSuccessStatusObject(), 0, { + stream_summary: { + have_records_streamed: true + } + } + ], + [ + getSuccessStatusObject(), 0, { + stream_summary: { + have_records_streamed: true + }, + notifications: [] + } + ], + [ + getSuccessStatusObject(), 1, { + stream_summary: { + have_records_streamed: true + }, + notifications: [{ + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }] + } + ], + [ + getSuccessStatusObject(), 2, { + stream_summary: { + have_records_streamed: true + }, + notifications: [{ + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }, { + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }] + } + ], + [ + getSuccessStatusObject(), 2, { + stream_summary: { + have_records_streamed: true + }, + notifications: [{ + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }, { + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }, { + code: 'Neo.Notification.Info.Code', + title: 'Notification Title', + description: 'Description', + severity: 'INFORMATION', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }] + } + ], + [ + getSuccessStatusObject(), 0, { + stream_summary: { + have_records_streamed: true + }, + notifications: [{ + code: 'Neo.Notification.Info.Code', + title: 'Notification Title', + description: 'Description', + severity: 'INFORMATION', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }, { + code: 'Neo.Notification.Info.Code', + title: 'Notification Title', + description: 'Description', + severity: 'INFORMATION', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }] + } + ], + // NO DATA + [ + getNoDataStatusObject(), 0, { + stream_summary: { + have_records_streamed: false, + pulled: true, + has_keys: true + } + } + ], + [ + getNoDataStatusObject(), 0, { + stream_summary: { + have_records_streamed: false, + pulled: true, + has_keys: true + }, + notifications: [] + } + ], + [ + getNoDataStatusObject(), 0, { + stream_summary: { + have_records_streamed: false, + pulled: true, + has_keys: true + }, + notifications: [{ + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }] + } + ], + [ + getNoDataStatusObject(), 0, { + stream_summary: { + have_records_streamed: false, + pulled: true, + has_keys: true + }, + notifications: [{ + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }, + { + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }] + } + ], + // OMITTED RESULT + [ + getOmittedResultStatusObject(), 0, { + stream_summary: { + have_records_streamed: false, + pulled: true, + has_keys: false + } + } + ], + [ + getOmittedResultStatusObject(), 0, { + stream_summary: { + have_records_streamed: false, + pulled: false, + has_keys: false + } + } + ], + [ + getOmittedResultStatusObject(), 0, { + stream_summary: { + have_records_streamed: false, + pulled: true, + has_keys: false + }, + notifications: [] + } + ], + [ + getOmittedResultStatusObject(), 1, { + stream_summary: { + have_records_streamed: false, + pulled: true, + has_keys: false + }, + notifications: [{ + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }] + } + ], + [ + getOmittedResultStatusObject(), 2, { + stream_summary: { + have_records_streamed: false, + pulled: true, + has_keys: false + }, + notifications: [{ + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }, + { + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }] + } + ], + [ + getOmittedResultStatusObject(), 1, { + stream_summary: { + have_records_streamed: false, + pulled: true, + has_keys: false + }, + notifications: [{ + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }, + { + code: 'Neo.Notification.Information.Code', + title: 'Notification Title', + description: 'Description', + severity: 'INFORMATION', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }] + } + ], + // NO DATA - UNKNOWN SUBCONDITION + [ + getNoDataUnknownSubconditionStatusObject(), 0, { + stream_summary: { + have_records_streamed: false, + pulled: false, + has_keys: true + } + } + ], + [ + getNoDataUnknownSubconditionStatusObject(), 0, { + stream_summary: { + have_records_streamed: false, + pulled: false, + has_keys: true + }, + notifications: [] + } + ], + [ + getNoDataUnknownSubconditionStatusObject(), 0, { + stream_summary: { + have_records_streamed: false, + pulled: false, + has_keys: true + }, + notifications: [{ + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }] + } + ] + ])('should build from notifications when statuses not available', (filledObject: GqlStatusObject, position: number, metadata: any) => { + const notifications = metadata.notifications != null ? metadata.notifications : [] + const expectedStatuses = notifications.map(polyfillGqlStatusObject) + expectedStatuses.splice(position, 0, filledObject) + + expect(buildGqlStatusObjectFromMetadata(metadata)).toEqual(expectedStatuses) + }) +}) + +describe('buildNotificationsFromMetadata', () => { + it.each([ + [ + { + } + ], + [ + { + notifications: [] + } + ], + [ + { + notifications: [], + statuses: getValidNotificationStatus() + } + ], + [ + { + notifications: [{ + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }] + } + ], + [ + { + notifications: [{ + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }], + statuses: getValidNotificationStatus() + } + ], + [ + { + notifications: [{ + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }, { + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }] + } + ], + [ + { + notifications: [{ + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }, { + code: 'Neo.Notification.Warning.Code', + title: 'Notification Title', + description: 'Description', + severity: 'WARNING', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }, { + code: 'Neo.Notification.Info.Code', + title: 'Notification Title', + description: 'Description', + severity: 'INFORMATION', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }] + } + ], + [ + { + stream_summary: { + have_records_streamed: true + }, + notifications: [{ + code: 'Neo.Notification.Info.Code', + title: 'Notification Title', + description: 'Description', + severity: 'INFORMATION', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }, { + code: 'Neo.Notification.Info.Code', + title: 'Notification Title', + description: 'Description', + severity: 'INFORMATION', + position: { + offset: 0, + line: 0, + column: 0 + }, + category: 'TOPOLOGY' + }], + statuses: [getSuccessStatusObject()] + } + ] + ])('should build from notifications when available', (metadata: any) => { + const notifications = metadata.notifications != null ? metadata.notifications : [] + const expectedNotifications = notifications.map((notification: any) => new Notification(notification)) + + expect(buildNotificationsFromMetadata(metadata)).toEqual(expectedNotifications) + }) + + it.each([ + { + statuses: getValidStatus() + }, + { + statuses: [ + { + gql_status: '00000', + status_description: 'successful completion — omitted', + diagnostic_record: { + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' + } + } + ] + }, + { + statuses: [] + } + ])('should build from statuses when notifications not available', (metadata: any) => { + const expectedNotifications = metadata.statuses.map(polyfillNotification) + .filter((notification: unknown) => notification != null) + + expect(buildNotificationsFromMetadata(metadata)).toEqual(expectedNotifications) + }) +}) + +describe('notificationSeverityLevel', () => { + it('should have keys equals to values', () => { + for (const [key, value] of Object.entries(notificationSeverityLevel)) { + expect(key).toEqual(value) + } + }) + + it('should have values assignable to NotificationSeverityLevel', () => { + for (const [, value] of Object.entries(notificationSeverityLevel)) { + const assignableValue: NotificationSeverityLevel = value + expect(assignableValue).toBeDefined() + } + }) + + it.each(getValidSeverityLevels())('should have %s as key', (severity) => { + const keys = Object.keys(notificationSeverityLevel) + expect(keys.includes(severity)).toBe(true) + }) +}) + +describe('notificationCategory', () => { + it('should have keys equals to values', () => { + for (const [key, value] of Object.entries(notificationCategory)) { + expect(key).toEqual(value) + } + }) + + it('should values be assignable to NotificationCategory', () => { + for (const [, value] of Object.entries(notificationCategory)) { + const assignableValue: NotificationCategory = value + expect(assignableValue).toBeDefined() + } + }) + + it.each(getValidCategories())('should have %s as key', (category) => { + const keys = Object.keys(notificationCategory) + expect(keys.includes(category)).toBe(true) + }) +}) + +describe('notificationClassification', () => { + it('should have keys equals to values', () => { + for (const [key, value] of Object.entries(notificationClassification)) { + expect(key).toEqual(value) + } + }) + + it('should values be assignable to NotificationClassification', () => { + for (const [, value] of Object.entries(notificationClassification)) { + const assignableValue: NotificationCategory = value + expect(assignableValue).toBeDefined() + } + }) + + it.each(getValidCategories())('should have %s as key', (category) => { + const keys = Object.keys(notificationClassification) + expect(keys.includes(category)).toBe(true) + }) + + it('should be notificationCategory', () => { + expect(notificationClassification).toBe(notificationCategory) + }) +}) + +function getValidSeverityLevels (): NotificationSeverityLevel[] { + return [ + 'WARNING', + 'INFORMATION', + 'UNKNOWN' + ] +} + +function getValidCategories (): NotificationCategory[] { + return [ + 'HINT', + 'UNRECOGNIZED', + 'UNSUPPORTED', + 'PERFORMANCE', + 'TOPOLOGY', + 'SECURITY', + 'DEPRECATION', + 'GENERIC', + 'UNKNOWN' + ] +} + +function getValidClassifications (): NotificationClassification[] { + return [ + 'HINT', + 'UNRECOGNIZED', + 'UNSUPPORTED', + 'PERFORMANCE', + 'TOPOLOGY', + 'SECURITY', + 'DEPRECATION', + 'GENERIC', + 'UNKNOWN' + ] +} + +function getValidStatus (): any[] { + return [ + { + gql_status: '00000', + status_description: 'note: successful completion', + diagnostic_record: { + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _status_parameters: {}, + _severity: '', + _classification: '', + _position: { + offset: -1, + line: -1, + column: -1 + } + } + }, + ...getValidNotificationStatus() + ] +} + +function getValidNotificationStatus (): any [] { + return [ + { + gql_status: '01N00', + status_description: 'warn: feature deprecated', + neo4j_code: 'Neo.Some.Warning.Code', + title: 'the title', + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _status_parameters: {}, + _severity: 'WARNING', + _classification: 'DEPRECATION' + } + }, + { + gql_status: '03N60', + status_description: 'info: subquery variable shadowing', + neo4j_code: 'Neo.Some.Informational.Code', + title: 'the title', + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _status_parameters: {}, + _severity: 'INFORMATION', + _classification: 'HINT' + } + } + ] +} + +function getSuccessStatus (): any { + return { + gql_status: '00000', + status_description: 'note: successful completion', + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' + } + } +} + +function getSuccessStatusObject (): GqlStatusObject { + return new GqlStatusObject(getSuccessStatus()) +} + +function getNoDataStatus (): any { + return { + gql_status: '02000', + status_description: 'note: no data', + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' + } + } +} + +function getNoDataStatusObject (): GqlStatusObject { + return new GqlStatusObject(getNoDataStatus()) +} + +function getOmittedResultStatus (): any { + return { + gql_status: '00001', + status_description: 'note: successful completion - omitted result', + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' + } + } +} + +function getOmittedResultStatusObject (): GqlStatusObject { + return new GqlStatusObject(getOmittedResultStatus()) +} + +function getNoDataUnknownSubconditionStatus (): any { + return { + gql_status: '02N42', + status_description: 'note: no data - unknown subcondition', + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' + } + } +} + +function getNoDataUnknownSubconditionStatusObject (): GqlStatusObject { + return new GqlStatusObject(getNoDataUnknownSubconditionStatus()) +} diff --git a/packages/core/test/result-summary.test.ts b/packages/core/test/result-summary.test.ts index f9cfa4aad..69c28595f 100644 --- a/packages/core/test/result-summary.test.ts +++ b/packages/core/test/result-summary.test.ts @@ -18,11 +18,6 @@ import { int } from '../src' import { ServerInfo, - Notification, - NotificationSeverityLevel, - NotificationCategory, - notificationSeverityLevel, - notificationCategory, ProfiledPlan, QueryStatistics, Stats @@ -60,108 +55,6 @@ describe('ServerInfo', () => { ) }) -describe('Notification', () => { - describe('.severityLevel', () => { - it.each(getValidSeverityLevels())('should fill severityLevel with the rawSeverityLevel equals to %s', rawSeverityLevel => { - const rawNotification = { - severity: rawSeverityLevel - } - - const notification = new Notification(rawNotification) - - expect(notification.severityLevel).toBe(rawSeverityLevel) - expect(notification.rawSeverityLevel).toBe(rawSeverityLevel) - }) - - it.each([ - 'UNKNOWN', - null, - undefined, - 'I_AM_NOT_OKAY', - 'information' - ])('should fill severityLevel UNKNOWN if the rawSeverityLevel equals to %s', rawSeverityLevel => { - const rawNotification = { - severity: rawSeverityLevel - } - - const notification = new Notification(rawNotification) - - expect(notification.severityLevel).toBe('UNKNOWN') - expect(notification.rawSeverityLevel).toBe(rawSeverityLevel) - }) - }) - - describe('.category', () => { - it.each(getValidCategories())('should fill category with the rawCategory equals to %s', rawCategory => { - const rawNotification = { - category: rawCategory - } - - const notification = new Notification(rawNotification) - - expect(notification.category).toBe(rawCategory) - expect(notification.rawCategory).toBe(rawCategory) - }) - - it.each([ - 'UNKNOWN', - undefined, - null, - 'DUNNO', - 'deprecation' - ])('should fill category with UNKNOWN the rawCategory equals to %s', rawCategory => { - const rawNotification = { - category: rawCategory - } - - const notification = new Notification(rawNotification) - - expect(notification.category).toBe('UNKNOWN') - expect(notification.rawCategory).toBe(rawCategory) - }) - }) -}) - -describe('notificationSeverityLevel', () => { - it('should have keys equals to values', () => { - for (const [key, value] of Object.entries(notificationSeverityLevel)) { - expect(key).toEqual(value) - } - }) - - it('should have values assignable to NotificationSeverityLevel', () => { - for (const [, value] of Object.entries(notificationSeverityLevel)) { - const assignableValue: NotificationSeverityLevel = value - expect(assignableValue).toBeDefined() - } - }) - - it.each(getValidSeverityLevels())('should have %s as key', (severity) => { - const keys = Object.keys(notificationSeverityLevel) - expect(keys.includes(severity)).toBe(true) - }) -}) - -describe('notificationCategory', () => { - it('should have keys equals to values', () => { - for (const [key, value] of Object.entries(notificationCategory)) { - expect(key).toEqual(value) - } - }) - - it('should values be assignable to NotificationCategory', () => { - for (const [, value] of Object.entries(notificationCategory)) { - const assignableValue: NotificationCategory = value - expect(assignableValue).toBeDefined() - } - }) - - it.each(getValidCategories())('should have %s as key', (category) => { - const keys = Object.keys(notificationCategory) - expect(keys.includes(category)).toBe(true) - }) -}) - describe('ProfilePlan', () => { describe.each([ 'dbHits', @@ -358,25 +251,3 @@ describe('QueryStatistics', () => { }) }) }) - -function getValidSeverityLevels (): NotificationSeverityLevel[] { - return [ - 'WARNING', - 'INFORMATION', - 'UNKNOWN' - ] -} - -function getValidCategories (): NotificationCategory[] { - return [ - 'HINT', - 'UNRECOGNIZED', - 'UNSUPPORTED', - 'PERFORMANCE', - 'TOPOLOGY', - 'SECURITY', - 'DEPRECATION', - 'GENERIC', - 'UNKNOWN' - ] -} diff --git a/packages/core/test/utils/notification-filters.fixtures.ts b/packages/core/test/utils/notification-filters.fixtures.ts index 508d1125d..3d36e33ae 100644 --- a/packages/core/test/utils/notification-filters.fixtures.ts +++ b/packages/core/test/utils/notification-filters.fixtures.ts @@ -15,6 +15,7 @@ * limitations under the License. */ import { NotificationFilter, notificationFilterDisabledCategory, notificationFilterMinimumSeverityLevel } from '../../src' +import { notificationFilterDisabledClassification } from '../../src/notification-filter' export function validNotificationFilters (): Array { return [ @@ -40,6 +41,54 @@ export function validNotificationFilters (): Array { + return [ + { + disabledCategories: [], + disabledClassifications: [] + }, + { + disabledCategories: [notificationFilterDisabledCategory.GENERIC, notificationFilterDisabledCategory.PERFORMANCE], + disabledClassifications: [] + }, + { + disabledCategories: [], + disabledClassifications: [notificationFilterDisabledClassification.GENERIC, notificationFilterDisabledClassification.PERFORMANCE] + }, + { + minimumSeverityLevel: notificationFilterMinimumSeverityLevel.INFORMATION, + disabledCategories: [], + disabledClassifications: [] + }, + { + minimumSeverityLevel: notificationFilterMinimumSeverityLevel.INFORMATION, + disabledCategories: [notificationFilterDisabledCategory.GENERIC, notificationFilterDisabledCategory.PERFORMANCE], + disabledClassifications: [] + }, + { + minimumSeverityLevel: notificationFilterMinimumSeverityLevel.INFORMATION, + disabledCategories: [], + disabledClassifications: [notificationFilterDisabledClassification.GENERIC, notificationFilterDisabledClassification.PERFORMANCE] } ] } diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x5.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x5.js new file mode 100644 index 000000000..afb04cee2 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x5.js @@ -0,0 +1,189 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * 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 BoltProtocolV5x4 from './bolt-protocol-v5x4.js' + +import transformersFactories from './bolt-protocol-v5x5.transformer.js' +import Transformer from './transformer.js' +import RequestMessage from './request-message.js' +import { LoginObserver, ResultStreamObserver } from './stream-observers.js' + +import { internal } from '../../core/index.ts' + +const { + constants: { BOLT_PROTOCOL_V5_5, FETCH_ALL } +} = internal + +const DEFAULT_DIAGNOSTIC_RECORD = Object.freeze({ + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' +}) + +export default class BoltProtocol extends BoltProtocolV5x4 { + get version () { + return BOLT_PROTOCOL_V5_5 + } + + get transformer () { + if (this._transformer === undefined) { + 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} args The params + * @param {string} args.userAgent The user agent + * @param {any} args.authToken The auth token + * @param {NotificationFilter} args.notificationFilter The notification filters. + * @param {function(error)} args.onError On error callback + * @param {function(onComplete)} args.onComplete On complete callback + * @returns {LoginObserver} The Login observer + */ + initialize ({ userAgent, boltAgent, authToken, notificationFilter, onError, onComplete } = {}) { + const state = {} + const observer = new LoginObserver({ + onError: error => this._onLoginError(error, onError), + onCompleted: metadata => { + state.metadata = metadata + return this._onLoginCompleted(metadata) + } + }) + + this.write( + RequestMessage.hello5x5(userAgent, boltAgent, notificationFilter, this._serversideRouting), + observer, + false + ) + + return this.logon({ + authToken, + onComplete: metadata => onComplete({ ...metadata, ...state.metadata }), + onError, + flush: true + }) + } + + beginTransaction ({ + bookmarks, + txConfig, + database, + mode, + impersonatedUser, + notificationFilter, + beforeError, + afterError, + beforeComplete, + afterComplete + } = {}) { + const observer = new ResultStreamObserver({ + server: this._server, + beforeError, + afterError, + beforeComplete, + afterComplete + }) + observer.prepareToHandleSingleResponse() + + this.write( + RequestMessage.begin5x5({ bookmarks, txConfig, database, mode, impersonatedUser, notificationFilter }), + observer, + true + ) + + return observer + } + + run ( + query, + parameters, + { + bookmarks, + txConfig, + database, + mode, + impersonatedUser, + notificationFilter, + beforeKeys, + afterKeys, + beforeError, + afterError, + beforeComplete, + afterComplete, + flush = true, + reactive = false, + fetchSize = FETCH_ALL, + highRecordWatermark = Number.MAX_VALUE, + lowRecordWatermark = Number.MAX_VALUE + } = {} + ) { + const observer = new ResultStreamObserver({ + server: this._server, + reactive, + fetchSize, + moreFunction: this._requestMore.bind(this), + discardFunction: this._requestDiscard.bind(this), + beforeKeys, + afterKeys, + beforeError, + afterError, + beforeComplete, + afterComplete, + highRecordWatermark, + lowRecordWatermark, + enrichMetadata: BoltProtocol._enrichMetadata + }) + + const flushRun = reactive + this.write( + RequestMessage.runWithMetadata5x5(query, parameters, { + bookmarks, + txConfig, + database, + mode, + impersonatedUser, + notificationFilter + }), + observer, + flushRun && flush + ) + + if (!reactive) { + this.write(RequestMessage.pull({ n: fetchSize }), observer, flush) + } + + return observer + } + + /** + * + * @param {object} metadata + * @returns {object} + */ + static _enrichMetadata (metadata) { + if (Array.isArray(metadata.statuses)) { + metadata.statuses = metadata.statuses.map(status => ({ + ...status, + diagnostic_record: status.diagnostic_record !== null ? { ...DEFAULT_DIAGNOSTIC_RECORD, ...status.diagnostic_record } : null + })) + } + + return metadata + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x5.transformer.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x5.transformer.js new file mode 100644 index 000000000..5967460db --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x5.transformer.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * 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 v5x3 from './bolt-protocol-v5x3.transformer.js' + +export default { + ...v5x3 +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/create.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/create.js index 82cbfae31..3e1050afe 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/create.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/create.js @@ -29,6 +29,7 @@ import BoltProtocolV5x1 from './bolt-protocol-v5x1.js' import BoltProtocolV5x2 from './bolt-protocol-v5x2.js' import BoltProtocolV5x3 from './bolt-protocol-v5x3.js' import BoltProtocolV5x4 from './bolt-protocol-v5x4.js' +import BoltProtocolV5x5 from './bolt-protocol-v5x5.js' // eslint-disable-next-line no-unused-vars import { Chunker, Dechunker } from '../channel/index.js' import ResponseHandler from './response-handler.js' @@ -229,6 +230,14 @@ function createProtocol ( log, onProtocolError, serversideRouting) + case 5.5: + return new BoltProtocolV5x5(server, + chunker, + packingConfig, + createResponseHandler, + log, + onProtocolError, + serversideRouting) default: throw newError('Unknown Bolt protocol version: ' + version) } diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/handshake.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/handshake.js index ade55d3cb..de214d2c1 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/handshake.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/handshake.js @@ -76,7 +76,7 @@ function parseNegotiatedResponse (buffer, log) { */ function newHandshakeBuffer () { return createHandshakeMessage([ - [version(5, 4), version(5, 0)], + [version(5, 5), version(5, 0)], [version(4, 4), version(4, 2)], version(4, 1), version(3, 0) diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/request-message.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/request-message.js index cc164ccce..43812e795 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/request-message.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/request-message.js @@ -172,15 +172,7 @@ export default class RequestMessage { static hello5x2 (userAgent, notificationFilter = null, routing = null) { const metadata = { user_agent: userAgent } - if (notificationFilter) { - if (notificationFilter.minimumSeverityLevel) { - metadata.notifications_minimum_severity = notificationFilter.minimumSeverityLevel - } - - if (notificationFilter.disabledCategories) { - metadata.notifications_disabled_categories = notificationFilter.disabledCategories - } - } + appendLegacyNotificationFilterToMetadata(metadata, notificationFilter) if (routing) { metadata.routing = routing @@ -217,16 +209,45 @@ export default class RequestMessage { } } - if (notificationFilter) { - if (notificationFilter.minimumSeverityLevel) { - metadata.notifications_minimum_severity = notificationFilter.minimumSeverityLevel - } + appendLegacyNotificationFilterToMetadata(metadata, notificationFilter) + + if (routing) { + metadata.routing = routing + } + + return new RequestMessage( + HELLO, + [metadata], + () => `HELLO ${json.stringify(metadata)}` + ) + } + + /** + * Create a new HELLO message. + * @param {string} userAgent the user agent. + * @param {string} boltAgent the bolt agent. + * @param {NotificationFilter} notificationFilter the notification filter configured + * @param {Object} routing server side routing, set to routing context to turn on server side routing (> 4.1) + * @return {RequestMessage} new HELLO message. + */ + static hello5x5 (userAgent, boltAgent, notificationFilter = null, routing = null) { + const metadata = { } - if (notificationFilter.disabledCategories) { - metadata.notifications_disabled_categories = notificationFilter.disabledCategories + if (userAgent) { + metadata.user_agent = userAgent + } + + if (boltAgent) { + metadata.bolt_agent = { + product: boltAgent.product, + platform: boltAgent.platform, + language: boltAgent.language, + language_details: boltAgent.languageDetails } } + appendGqlNotificationFilterToMetadata(metadata, notificationFilter) + if (routing) { metadata.routing = routing } @@ -284,6 +305,27 @@ export default class RequestMessage { ) } + /** + * Create a new BEGIN message. + * @param {Bookmarks} bookmarks the bookmarks. + * @param {TxConfig} txConfig the configuration. + * @param {string} database the database name. + * @param {string} mode the access mode. + * @param {string} impersonatedUser the impersonated user. + * @param {NotificationFilter} notificationFilter the notification filter + * @return {RequestMessage} new BEGIN message. + */ + static begin5x5 ({ bookmarks, txConfig, database, mode, impersonatedUser, notificationFilter } = {}) { + const metadata = buildTxMetadata(bookmarks, txConfig, database, mode, impersonatedUser, notificationFilter, { + appendNotificationFilter: appendGqlNotificationFilterToMetadata + }) + return new RequestMessage( + BEGIN, + [metadata], + () => `BEGIN ${json.stringify(metadata)}` + ) + } + /** * Get a COMMIT message. * @return {RequestMessage} the COMMIT message. @@ -304,11 +346,13 @@ export default class RequestMessage { * Create a new RUN message with additional metadata. * @param {string} query the cypher query. * @param {Object} parameters the query parameters. - * @param {Bookmarks} bookmarks the bookmarks. - * @param {TxConfig} txConfig the configuration. - * @param {string} database the database name. - * @param {string} mode the access mode. - * @param {string} impersonatedUser the impersonated user. + * @param {Object} extra - extra params + * @param {Bookmarks} extra.bookmarks the bookmarks. + * @param {TxConfig} extra.txConfig the configuration. + * @param {string} extra.database the database name. + * @param {string} extra.mode the access mode. + * @param {string} extra.impersonatedUser the impersonated user. + * @param {notificationFilter} extra.notificationFilter the notification filter * @return {RequestMessage} new RUN message with additional metadata. */ static runWithMetadata ( @@ -325,6 +369,35 @@ export default class RequestMessage { ) } + /** + * Create a new RUN message with additional metadata. + * @param {string} query the cypher query. + * @param {Object} parameters the query parameters. + * @param {Object} extra - extra params + * @param {Bookmarks} extra.bookmarks the bookmarks. + * @param {TxConfig} extra.txConfig the configuration. + * @param {string} extra.database the database name. + * @param {string} extra.mode the access mode. + * @param {string} extra.impersonatedUser the impersonated user. + * @param {notificationFilter} extra.notificationFilter the notification filter + * @return {RequestMessage} new RUN message with additional metadata. + */ + static runWithMetadata5x5 ( + query, + parameters, + { bookmarks, txConfig, database, mode, impersonatedUser, notificationFilter } = {} + ) { + const metadata = buildTxMetadata(bookmarks, txConfig, database, mode, impersonatedUser, notificationFilter, { + appendNotificationFilter: appendGqlNotificationFilterToMetadata + }) + return new RequestMessage( + RUN, + [query, parameters, metadata], + () => + `RUN ${query} ${json.stringify(parameters)} ${json.stringify(metadata)}` + ) + } + /** * Get a GOODBYE message. * @return {RequestMessage} the GOODBYE message. @@ -437,9 +510,11 @@ export default class RequestMessage { * @param {string} mode the access mode. * @param {string} impersonatedUser the impersonated user mode. * @param {notificationFilter} notificationFilter the notification filter + * @param {Object} functions Transformation functions applied to metadata + * @param {function(metadata,notificationFilter):void} functions.appendNotificationFilter Changes metadata by appending the Notification Filter to it. * @return {Object} a metadata object. */ -function buildTxMetadata (bookmarks, txConfig, database, mode, impersonatedUser, notificationFilter) { +function buildTxMetadata (bookmarks, txConfig, database, mode, impersonatedUser, notificationFilter, functions = {}) { const metadata = {} if (!bookmarks.isEmpty()) { metadata.bookmarks = bookmarks.values() @@ -459,15 +534,9 @@ function buildTxMetadata (bookmarks, txConfig, database, mode, impersonatedUser, if (mode === ACCESS_MODE_READ) { metadata.mode = READ_MODE } - if (notificationFilter) { - if (notificationFilter.minimumSeverityLevel) { - metadata.notifications_minimum_severity = notificationFilter.minimumSeverityLevel - } - if (notificationFilter.disabledCategories) { - metadata.notifications_disabled_categories = notificationFilter.disabledCategories - } - } + const appendNotificationFilter = functions.appendNotificationFilter ?? appendLegacyNotificationFilterToMetadata + appendNotificationFilter(metadata, notificationFilter) return metadata } @@ -485,6 +554,38 @@ function buildStreamMetadata (stmtId, n) { return metadata } +function appendLegacyNotificationFilterToMetadata (metadata, notificationFilter) { + if (notificationFilter) { + if (notificationFilter.minimumSeverityLevel) { + metadata.notifications_minimum_severity = notificationFilter.minimumSeverityLevel + } + + if (notificationFilter.disabledCategories) { + metadata.notifications_disabled_categories = notificationFilter.disabledCategories + } + + if (notificationFilter.disabledClassifications) { + metadata.notifications_disabled_categories = notificationFilter.disabledClassifications + } + } +} + +function appendGqlNotificationFilterToMetadata (metadata, notificationFilter) { + if (notificationFilter) { + if (notificationFilter.minimumSeverityLevel) { + metadata.notifications_minimum_severity = notificationFilter.minimumSeverityLevel + } + + if (notificationFilter.disabledCategories) { + metadata.notifications_disabled_classifications = notificationFilter.disabledCategories + } + + if (notificationFilter.disabledClassifications) { + metadata.notifications_disabled_classifications = notificationFilter.disabledClassifications + } + } +} + // constants for messages that never change const PULL_ALL_MESSAGE = new RequestMessage(PULL_ALL, [], () => 'PULL_ALL') const RESET_MESSAGE = new RequestMessage(RESET, [], () => 'RESET') diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/stream-observers.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/stream-observers.js index c4d3b6b79..fb75d83ab 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/stream-observers.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/stream-observers.js @@ -24,17 +24,18 @@ import { internal } from '../../core/index.ts' import RawRoutingTable from './routing-table-raw.js' +import { functional } from '../lang/index.js' const { constants: { FETCH_ALL } } = internal const { PROTOCOL_ERROR } = error class StreamObserver { - onNext (rawRecord) {} + onNext (rawRecord) { } - onError (_error) {} + onError (_error) { } - onCompleted (meta) {} + onCompleted (meta) { } } /** @@ -62,6 +63,7 @@ class ResultStreamObserver extends StreamObserver { * @param {function(keys: string[]): Promise|void} param.afterKeys - * @param {function(metadata: Object): Promise|void} param.beforeComplete - * @param {function(metadata: Object): Promise|void} param.afterComplete - + * @param {function(metadata: Object): Promise|void} param.enrichMetadata - */ constructor ({ reactive = false, @@ -76,7 +78,8 @@ class ResultStreamObserver extends StreamObserver { afterComplete, server, highRecordWatermark = Number.MAX_VALUE, - lowRecordWatermark = Number.MAX_VALUE + lowRecordWatermark = Number.MAX_VALUE, + enrichMetadata } = {}) { super() @@ -96,6 +99,7 @@ class ResultStreamObserver extends StreamObserver { this._afterKeys = afterKeys this._beforeComplete = beforeComplete this._afterComplete = afterComplete + this._enrichMetadata = enrichMetadata || functional.identity this._queryId = null this._moreFunction = moreFunction @@ -107,6 +111,8 @@ class ResultStreamObserver extends StreamObserver { this._setState(reactive ? _states.READY : _states.READY_STREAMING) this._setupAutoPull() this._paused = false + this._pulled = !reactive + this._haveRecordStreamed = false } /** @@ -137,6 +143,7 @@ class ResultStreamObserver extends StreamObserver { * @param {Array} rawRecord - An array with the raw record */ onNext (rawRecord) { + this._haveRecordStreamed = true const record = new Record(this._fieldKeys, rawRecord, this._fieldLookup) if (this._observers.some(o => o.onNext)) { this._observers.forEach(o => { @@ -245,11 +252,18 @@ class ResultStreamObserver extends StreamObserver { } _handlePullSuccess (meta) { - const completionMetadata = Object.assign( + const completionMetadata = this._enrichMetadata(Object.assign( this._server ? { server: this._server } : {}, this._meta, + { + stream_summary: { + have_records_streamed: this._haveRecordStreamed, + pulled: this._pulled, + has_keys: this._fieldKeys.length > 0 + } + }, meta - ) + )) if (![undefined, null, 'r', 'w', 'rw', 's'].includes(completionMetadata.type)) { this.onError( @@ -303,7 +317,9 @@ class ResultStreamObserver extends StreamObserver { for (let i = 0; i < meta.fields.length; i++) { this._fieldLookup[meta.fields[i]] = i } + } + if (meta.fields != null) { // remove fields key from metadata object delete meta.fields } @@ -392,6 +408,7 @@ class ResultStreamObserver extends StreamObserver { if (this._discard) { this._discardFunction(this._queryId, this) } else { + this._pulled = true this._moreFunction(this._queryId, this._fetchSize, this) } this._setState(_states.STREAMING) @@ -501,7 +518,7 @@ class ResetObserver extends StreamObserver { this.onError( newError( 'Received RECORD when resetting: received record is: ' + - json.stringify(record), + json.stringify(record), PROTOCOL_ERROR ) ) @@ -602,9 +619,9 @@ class ProcedureRouteObserver extends StreamObserver { this.onError( newError( 'Illegal response from router. Received ' + - this._records.length + - ' records but expected only one.\n' + - json.stringify(this._records), + this._records.length + + ' records but expected only one.\n' + + json.stringify(this._records), PROTOCOL_ERROR ) ) @@ -637,7 +654,7 @@ class RouteObserver extends StreamObserver { this.onError( newError( 'Received RECORD when resetting: received record is: ' + - json.stringify(record), + json.stringify(record), PROTOCOL_ERROR ) ) @@ -678,7 +695,7 @@ const _states = { name: () => { return 'READY_STREAMING' }, - pull: () => {} + pull: () => { } }, READY: { // reactive start state @@ -710,7 +727,7 @@ const _states = { name: () => { return 'STREAMING' }, - pull: () => {} + pull: () => { } }, FAILED: { onError: _error => { @@ -719,13 +736,13 @@ const _states = { name: () => { return 'FAILED' }, - pull: () => {} + pull: () => { } }, SUCCEEDED: { name: () => { return 'SUCCEEDED' }, - pull: () => {} + pull: () => { } } } diff --git a/packages/neo4j-driver-deno/lib/core/driver.ts b/packages/neo4j-driver-deno/lib/core/driver.ts index 3f7a81b36..1e13f0ce3 100644 --- a/packages/neo4j-driver-deno/lib/core/driver.ts +++ b/packages/neo4j-driver-deno/lib/core/driver.ts @@ -901,6 +901,11 @@ function validateConfig (config: any, log: Logger): any { 'where a new connection is created while it is acquired' ) } + + if (config.notificationFilter?.disabledCategories != null && config.notificationFilter?.disabledClassifications != null) { + throw new Error('The notificationFilter can\'t have both "disabledCategories" and "disabledClassifications" configured at the same time.') + } + return config } diff --git a/packages/neo4j-driver-deno/lib/core/index.ts b/packages/neo4j-driver-deno/lib/core/index.ts index ad3dd1f76..45ebafe00 100644 --- a/packages/neo4j-driver-deno/lib/core/index.ts +++ b/packages/neo4j-driver-deno/lib/core/index.ts @@ -57,20 +57,26 @@ import { isPoint, Point } from './spatial-types.ts' import ResultSummary, { queryType, ServerInfo, - Notification, - NotificationPosition, Plan, ProfiledPlan, QueryStatistics, - Stats, + Stats +} from './result-summary.ts' +import Notification, { + NotificationPosition, NotificationSeverityLevel, + NotificationClassification, NotificationCategory, + GqlStatusObject, notificationCategory, + notificationClassification, notificationSeverityLevel -} from './result-summary.ts' +} from './notification.ts' import NotificationFilter, { notificationFilterDisabledCategory, NotificationFilterDisabledCategory, + notificationFilterDisabledClassification, + NotificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, NotificationFilterMinimumSeverityLevel } from './notification-filter.ts' @@ -148,6 +154,7 @@ const forExport = { queryType, ServerInfo, Notification, + GqlStatusObject, Plan, ProfiledPlan, QueryStatistics, @@ -168,8 +175,10 @@ const forExport = { routing, resultTransformers, notificationCategory, + notificationClassification, notificationSeverityLevel, notificationFilterDisabledCategory, + notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, clientCertificateProviders, resolveCertificateProvider @@ -217,6 +226,7 @@ export { queryType, ServerInfo, Notification, + GqlStatusObject, Plan, ProfiledPlan, QueryStatistics, @@ -239,8 +249,10 @@ export { routing, resultTransformers, notificationCategory, + notificationClassification, notificationSeverityLevel, notificationFilterDisabledCategory, + notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, clientCertificateProviders, resolveCertificateProvider @@ -265,9 +277,11 @@ export type { RecordShape, ResultTransformer, NotificationCategory, + NotificationClassification, NotificationSeverityLevel, NotificationFilter, NotificationFilterDisabledCategory, + NotificationFilterDisabledClassification, NotificationFilterMinimumSeverityLevel, ClientCertificate, ClientCertificateProvider, diff --git a/packages/neo4j-driver-deno/lib/core/internal/constants.ts b/packages/neo4j-driver-deno/lib/core/internal/constants.ts index 9855854b9..e9cf978bf 100644 --- a/packages/neo4j-driver-deno/lib/core/internal/constants.ts +++ b/packages/neo4j-driver-deno/lib/core/internal/constants.ts @@ -36,6 +36,7 @@ const BOLT_PROTOCOL_V5_1: number = 5.1 const BOLT_PROTOCOL_V5_2: number = 5.2 const BOLT_PROTOCOL_V5_3: number = 5.3 const BOLT_PROTOCOL_V5_4: number = 5.4 +const BOLT_PROTOCOL_V5_5: number = 5.5 const TELEMETRY_APIS = { MANAGED_TRANSACTION: 0, @@ -68,5 +69,6 @@ export { BOLT_PROTOCOL_V5_2, BOLT_PROTOCOL_V5_3, BOLT_PROTOCOL_V5_4, + BOLT_PROTOCOL_V5_5, TELEMETRY_APIS } diff --git a/packages/neo4j-driver-deno/lib/core/internal/util.ts b/packages/neo4j-driver-deno/lib/core/internal/util.ts index 37c30e68c..be860bf08 100644 --- a/packages/neo4j-driver-deno/lib/core/internal/util.ts +++ b/packages/neo4j-driver-deno/lib/core/internal/util.ts @@ -15,7 +15,7 @@ * limitations under the License. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -import Integer, { isInt } from '../integer.ts' +import Integer, { isInt, int } from '../integer.ts' import { NumberOrInteger } from '../graph-types.ts' import { EncryptionLevel } from '../types.ts' import { stringify } from '../json.ts' @@ -259,6 +259,23 @@ function equals (a: unknown, b: unknown): boolean { return false } +/** + * Converts (Integer | bigint) to number. + * + * @private + * @param {NumberOrInteger} value The number or integer + * @returns {number} The number + */ +function toNumber (value: NumberOrInteger): number { + if (value instanceof Integer) { + return value.toNumber() + } else if (typeof value === 'bigint') { + return int(value).toNumber() + } else { + return value + } +} + export { isEmptyObjectOrNull, isObject, @@ -268,6 +285,7 @@ export { assertNumber, assertNumberOrInteger, assertValidDate, + toNumber, validateQueryAndParameters, equals, ENCRYPTION_ON, diff --git a/packages/neo4j-driver-deno/lib/core/json.ts b/packages/neo4j-driver-deno/lib/core/json.ts index 22bb4ad6c..25142ec12 100644 --- a/packages/neo4j-driver-deno/lib/core/json.ts +++ b/packages/neo4j-driver-deno/lib/core/json.ts @@ -17,13 +17,17 @@ import { isBrokenObject, getBrokenObjectReason } from './internal/object-util.ts' +interface StringifyOpts { + useCustomToString?: boolean +} + /** * Custom version on JSON.stringify that can handle values that normally don't support serialization, such as BigInt. * @private * @param val A JavaScript value, usually an object or array, to be converted. * @returns A JSON string representing the given value. */ -export function stringify (val: any): string { +export function stringify (val: any, opts?: StringifyOpts): string { return JSON.stringify(val, (_, value) => { if (isBrokenObject(value)) { return { @@ -31,9 +35,18 @@ export function stringify (val: any): string { __reason__: getBrokenObjectReason(value) } } + if (typeof value === 'bigint') { return `${value}n` } + + if (opts?.useCustomToString === true && + typeof value === 'object' && + !Array.isArray(value) && + typeof value.toString === 'function' && + value.toString !== Object.prototype.toString) { + return value?.toString() + } return value }) } diff --git a/packages/neo4j-driver-deno/lib/core/notification-filter.ts b/packages/neo4j-driver-deno/lib/core/notification-filter.ts index bf265eafb..1523782b4 100644 --- a/packages/neo4j-driver-deno/lib/core/notification-filter.ts +++ b/packages/neo4j-driver-deno/lib/core/notification-filter.ts @@ -16,8 +16,9 @@ */ import { NotificationCategory, + NotificationClassification, NotificationSeverityLevel -} from './result-summary.ts' +} from './notification.ts' type ExcludeUnknown = Exclude type OFF = 'OFF' @@ -56,6 +57,19 @@ const notificationFilterDisabledCategory: EnumRecord +/** + * @typedef {NotificationFilterDisabledCategory} NotificationFilterDisabledClassification + * @experimental + */ +/** + * Constants that represents the disabled classifications in the {@link NotificationFilter} + * + * @type {notificationFilterDisabledCategory} + * @experimental + */ +const notificationFilterDisabledClassification: EnumRecord = notificationFilterDisabledCategory + /** * The notification filter object which can be configured in * the session and driver creation. @@ -67,6 +81,7 @@ Object.freeze(notificationFilterDisabledCategory) class NotificationFilter { minimumSeverityLevel?: NotificationFilterMinimumSeverityLevel disabledCategories?: NotificationFilterDisabledCategory[] + disabledClassifications?: NotificationFilterDisabledClassification[] /** * @constructor @@ -83,10 +98,26 @@ class NotificationFilter { /** * Categories the user would like to opt-out of receiving. + * + * + * This property is equivalent to {@link NotificationFilter#disabledClassifications} + * and it must not be enabled at same time. + * * @type {?NotificationFilterDisabledCategory[]} */ this.disabledCategories = undefined + /** + * Classifications the user would like to opt-out of receiving. + * + * This property is equivalent to {@link NotificationFilter#disabledCategories} + * and it must not be enabled at same time. + * + * @type {?NotificationFilterDisabledClassification[]} + * @experimental + */ + this.disabledClassifications = undefined + throw new Error('Not implemented') } } @@ -95,10 +126,12 @@ export default NotificationFilter export { notificationFilterMinimumSeverityLevel, - notificationFilterDisabledCategory + notificationFilterDisabledCategory, + notificationFilterDisabledClassification } export type { NotificationFilterMinimumSeverityLevel, - NotificationFilterDisabledCategory + NotificationFilterDisabledCategory, + NotificationFilterDisabledClassification } diff --git a/packages/neo4j-driver-deno/lib/core/notification.ts b/packages/neo4j-driver-deno/lib/core/notification.ts new file mode 100644 index 000000000..3f9ee7269 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/notification.ts @@ -0,0 +1,602 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * 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 * as json from './json.ts' +import { util } from './internal/index.ts' +import { NumberOrInteger } from './graph-types.ts' + +interface NotificationPosition { + offset?: number + line?: number + column?: number +} + +type UnknownGqlStatus = `${'01' | '02' | '03' | '50'}N42` + +const unknownGqlStatus: Record = { + WARNING: { + gql_status: '01N42', + status_description: 'warn: unknown warning' + }, + NO_DATA: { + gql_status: '02N42', + status_description: 'note: no data - unknown subcondition' + }, + INFORMATION: { + gql_status: '03N42', + status_description: 'info: unknown notification' + }, + ERROR: { + gql_status: '50N42', + status_description: 'error: general processing exception - unknown error' + } +} + +type NotificationSeverityLevel = 'WARNING' | 'INFORMATION' | 'UNKNOWN' +/** + * @typedef {'WARNING' | 'INFORMATION' | 'UNKNOWN'} NotificationSeverityLevel + */ +/** + * Constants that represents the Severity level in the {@link Notification} + */ +const notificationSeverityLevel: { [key in NotificationSeverityLevel]: key } = { + WARNING: 'WARNING', + INFORMATION: 'INFORMATION', + UNKNOWN: 'UNKNOWN' +} + +Object.freeze(notificationSeverityLevel) +const severityLevels = Object.values(notificationSeverityLevel) + +type NotificationCategory = 'HINT' | 'UNRECOGNIZED' | 'UNSUPPORTED' | 'PERFORMANCE' | +'TOPOLOGY' | 'SECURITY' | 'DEPRECATION' | 'GENERIC' | 'UNKNOWN' +/** + * @typedef {'HINT' | 'UNRECOGNIZED' | 'UNSUPPORTED' |'PERFORMANCE' | 'TOPOLOGY' | 'SECURITY' | 'DEPRECATION' | 'GENERIC' | 'UNKNOWN' } NotificationCategory + */ +/** + * Constants that represents the Category in the {@link Notification} + */ +const notificationCategory: { [key in NotificationCategory]: key } = { + HINT: 'HINT', + UNRECOGNIZED: 'UNRECOGNIZED', + UNSUPPORTED: 'UNSUPPORTED', + PERFORMANCE: 'PERFORMANCE', + DEPRECATION: 'DEPRECATION', + TOPOLOGY: 'TOPOLOGY', + SECURITY: 'SECURITY', + GENERIC: 'GENERIC', + UNKNOWN: 'UNKNOWN' +} + +Object.freeze(notificationCategory) +const categories = Object.values(notificationCategory) + +type NotificationClassification = NotificationCategory +/** + * @typedef {NotificationCategory} NotificationClassification + * @experimental + */ +/** + * Constants that represents the Classification in the {@link GqlStatusObject} + * @type {notificationCategory} + * @experimental + */ +const notificationClassification = notificationCategory + +/** + * Class for Cypher notifications + * @access public + */ +class Notification { + code: string + title: string + description: string + severity: string + position: NotificationPosition | {} + severityLevel: NotificationSeverityLevel + category: NotificationCategory + rawSeverityLevel: string + rawCategory?: string + + /** + * Create a Notification instance + * @constructor + * @param {Object} notification - Object with notification data + */ + constructor (notification: any) { + /** + * The code + * @type {string} + * @public + */ + this.code = notification.code + /** + * The title + * @type {string} + * @public + */ + this.title = notification.title + /** + * The description + * @type {string} + * @public + */ + this.description = notification.description + /** + * The raw severity + * + * Use {@link Notification#rawSeverityLevel} for the raw value or {@link Notification#severityLevel} for an enumerated value. + * + * @type {string} + * @public + * @deprecated This property will be removed in 6.0. + */ + this.severity = notification.severity + /** + * The position which the notification had occur. + * + * @type {NotificationPosition} + * @public + */ + this.position = _constructPosition(notification.position) + + /** + * The severity level + * + * @type {NotificationSeverityLevel} + * @public + * @example + * const { summary } = await session.run("RETURN 1") + * + * for (const notification of summary.notifications) { + * switch(notification.severityLevel) { + * case neo4j.notificationSeverityLevel.INFORMATION: // or simply 'INFORMATION' + * console.info(`${notification.title} - ${notification.description}`) + * break + * case neo4j.notificationSeverityLevel.WARNING: // or simply 'WARNING' + * console.warn(`${notification.title} - ${notification.description}`) + * break + * case neo4j.notificationSeverityLevel.UNKNOWN: // or simply 'UNKNOWN' + * default: + * // the raw info came from the server could be found at notification.rawSeverityLevel + * console.log(`${notification.title} - ${notification.description}`) + * break + * } + * } + */ + this.severityLevel = _asEnumerableSeverity(notification.severity) + + /** + * The severity level returned by the server without any validation. + * + * @type {string} + * @public + */ + this.rawSeverityLevel = notification.severity + + /** + * The category + * + * @type {NotificationCategory} + * @public + * @example + * const { summary } = await session.run("RETURN 1") + * + * for (const notification of summary.notifications) { + * switch(notification.category) { + * case neo4j.notificationCategory.QUERY: // or simply 'QUERY' + * console.info(`${notification.title} - ${notification.description}`) + * break + * case neo4j.notificationCategory.PERFORMANCE: // or simply 'PERFORMANCE' + * console.warn(`${notification.title} - ${notification.description}`) + * break + * case neo4j.notificationCategory.UNKNOWN: // or simply 'UNKNOWN' + * default: + * // the raw info came from the server could be found at notification.rawCategory + * console.log(`${notification.title} - ${notification.description}`) + * break + * } + * } + */ + this.category = _asEnumerableClassification(notification.category) + + /** + * The category returned by the server without any validation. + * + * @type {string|undefined} + * @public + */ + this.rawCategory = notification.category + } +} + +interface NotificationDiagnosticRecord { + OPERATION: string + OPERATION_CODE: string + CURRENT_SCHEMA: string + _severity?: string + _classification?: string + _position?: { + offset: NumberOrInteger + line: NumberOrInteger + column: NumberOrInteger + } + _status_parameters?: Record + [key: string]: unknown +} + +/** + * Representation for GqlStatusObject found when executing a query. + *

+ * This object represents a status of query execution. + * This status is a superset of {@link Notification}. + * + * @experimental + * @public + */ +class GqlStatusObject { + public readonly gqlStatus: string + public readonly statusDescription: string + public readonly diagnosticRecord: NotificationDiagnosticRecord + public readonly position?: NotificationPosition + public readonly severity: NotificationSeverityLevel + public readonly rawSeverity?: string + public readonly classification: NotificationClassification + public readonly rawClassification?: string + public readonly isNotification: boolean + + /** + * + * @param rawGqlStatusObject + * @private + */ + constructor (rawGqlStatusObject: any) { + /** + * The GQLSTATUS + * + * @type {string} + * @public + */ + this.gqlStatus = rawGqlStatusObject.gql_status + + /** + * The GQLSTATUS description + * + * @type {string} + * @public + */ + this.statusDescription = rawGqlStatusObject.status_description + + /** + * The diagnostic record as it is. + * + * @type {object} + * @public + */ + this.diagnosticRecord = rawGqlStatusObject.diagnostic_record ?? {} + + /** + * The position at which the notification had occurred. + * + * @type {NotificationPosition | undefined} + * @public + */ + this.position = this.diagnosticRecord._position != null ? _constructPosition(this.diagnosticRecord._position) : undefined + + /** + * The severity + * + * @type {NotificationSeverityLevel} + * @public + * @example + * const { summary } = await session.run("RETURN 1") + * + * for (const gqlStatusObject of summary.gqlStatusObjects) { + * switch(gqlStatusObject.severity) { + * case neo4j.notificationSeverityLevel.INFORMATION: // or simply 'INFORMATION' + * console.info(gqlStatusObject.statusDescription) + * break + * case neo4j.notificationSeverityLevel.WARNING: // or simply 'WARNING' + * console.warn(gqlStatusObject.statusDescription) + * break + * case neo4j.notificationSeverityLevel.UNKNOWN: // or simply 'UNKNOWN' + * default: + * // the raw info came from the server could be found at gqlStatusObject.rawSeverity + * console.log(gqlStatusObject.statusDescription) + * break + * } + * } + */ + this.severity = _asEnumerableSeverity(this.diagnosticRecord._severity) + + /** + * The severity returned in the diagnostic record from the server without any validation. + * + * @type {string | undefined} + * @public + */ + this.rawSeverity = this.diagnosticRecord._severity + + /** + * The classification + * + * @type {NotificationClassification} + * @public + * @example + * const { summary } = await session.run("RETURN 1") + * + * for (const gqlStatusObject of summary.gqlStatusObjects) { + * switch(gqlStatusObject.classification) { + * case neo4j.notificationClassification.QUERY: // or simply 'QUERY' + * console.info(gqlStatusObject.statusDescription) + * break + * case neo4j.notificationClassification.PERFORMANCE: // or simply 'PERFORMANCE' + * console.warn(gqlStatusObject.statusDescription) + * break + * case neo4j.notificationClassification.UNKNOWN: // or simply 'UNKNOWN' + * default: + * // the raw info came from the server can be found at notification.rawCategory + * console.log(gqlStatusObject.statusDescription) + * break + * } + * } + */ + this.classification = _asEnumerableClassification(this.diagnosticRecord._classification) + + /** + * The category returned by the server without any validation. + * + * @type {string|undefined} + * @public + */ + this.rawClassification = this.diagnosticRecord._classification + + /** + * Indicates if this object represents a notification and it can be filtered using + * NotificationFilter. + * + * Only GqlStatusObject which is Notification has meaningful position, severity and + * classification. + * + * @type {boolean} + * @public + */ + this.isNotification = rawGqlStatusObject.neo4j_code != null + Object.freeze(this) + } + + /** + * The json string representation of the diagnostic record. + * The goal of this method is provide a serialized object for human inspection. + * + * @type {string} + * @public + */ + public get diagnosticRecordAsJsonString (): string { + return json.stringify(this.diagnosticRecord, { useCustomToString: true }) + } +} + +/** + * + * @private + * @param status + * @returns {Notification|undefined} + */ +function polyfillNotification (status: any): Notification | undefined { + // Non notification status should have neo4j_code + if (status.neo4j_code == null) { + return undefined + } + + return new Notification({ + code: status.neo4j_code, + title: status.title, + description: status.status_description, + severity: status.diagnostic_record?._severity, + category: status.diagnostic_record?._classification, + position: status.diagnostic_record?._position + }) +} + +/** + * @private + * @param notification + * @returns {GqlStatusObject} + */ +function polyfillGqlStatusObject (notification: any): GqlStatusObject { + const defaultStatus = notification.severity === notificationSeverityLevel.WARNING ? unknownGqlStatus.WARNING : unknownGqlStatus.INFORMATION + const polyfilledRawObj: any & { diagnostic_record: NotificationDiagnosticRecord } = { + gql_status: defaultStatus.gql_status, + status_description: notification.description ?? defaultStatus.status_description, + neo4j_code: notification.code, + title: notification.title, + diagnostic_record: { + ...rawPolyfilledDiagnosticRecord + } + } + + if (notification.severity != null) { + polyfilledRawObj.diagnostic_record._severity = notification.severity + } + + if (notification.category != null) { + polyfilledRawObj.diagnostic_record._classification = notification.category + } + + if (notification.position != null) { + polyfilledRawObj.diagnostic_record._position = notification.position + } + + return new GqlStatusObject(polyfilledRawObj) +} + +const rawPolyfilledDiagnosticRecord = { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' +} + +Object.freeze(rawPolyfilledDiagnosticRecord) + +/** + * This objects are used for polyfilling the first status on the status list + * + * @private + */ +const staticGqlStatusObjects = { + SUCCESS: new GqlStatusObject({ + gql_status: '00000', + status_description: 'note: successful completion', + diagnostic_record: rawPolyfilledDiagnosticRecord + }), + NO_DATA: new GqlStatusObject({ + gql_status: '02000', + status_description: 'note: no data', + diagnostic_record: rawPolyfilledDiagnosticRecord + }), + NO_DATA_UNKNOWN_SUBCONDITION: new GqlStatusObject({ + ...unknownGqlStatus.NO_DATA, + diagnostic_record: rawPolyfilledDiagnosticRecord + }), + OMITTED_RESULT: new GqlStatusObject({ + gql_status: '00001', + status_description: 'note: successful completion - omitted result', + diagnostic_record: rawPolyfilledDiagnosticRecord + }) +} + +Object.freeze(staticGqlStatusObjects) + +/** + * + * @private + * @param metadata + * @returns + */ +function buildGqlStatusObjectFromMetadata (metadata: any): [GqlStatusObject, ...GqlStatusObject[]] { + function getGqlStatusObjectFromStreamSummary (summary: any): GqlStatusObject { + if (summary?.have_records_streamed === true) { + return staticGqlStatusObjects.SUCCESS + } + + if (summary?.has_keys === false) { + return staticGqlStatusObjects.OMITTED_RESULT + } + + if (summary?.pulled === true) { + return staticGqlStatusObjects.NO_DATA + } + + return staticGqlStatusObjects.NO_DATA_UNKNOWN_SUBCONDITION + } + + if (metadata.statuses != null) { + return metadata.statuses.map((status: unknown) => new GqlStatusObject(status)) + } + + const clientGenerated = getGqlStatusObjectFromStreamSummary(metadata.stream_summary) + const polyfilledObjects = [clientGenerated, ...(metadata.notifications?.map(polyfillGqlStatusObject) ?? []) as GqlStatusObject[]] + + return polyfilledObjects.sort((a: GqlStatusObject, b: GqlStatusObject) => calculateWeight(a) - calculateWeight(b)) as [GqlStatusObject, ...GqlStatusObject[]] +} + +const gqlStatusWeightByClass = Object.freeze({ + '02': 0, + '01': 1, + '00': 2 +}) +/** + * GqlStatus weight + * + * @private + */ +function calculateWeight (gqlStatusObject: GqlStatusObject): number { + const gqlClass = gqlStatusObject.gqlStatus?.slice(0, 2) + // @ts-expect-error + return gqlStatusWeightByClass[gqlClass] ?? 9999 +} + +/** + * + * @private + * @param metadata + * @returns + */ +function buildNotificationsFromMetadata (metadata: any): Notification[] { + if (metadata.notifications != null) { + return metadata.notifications.map((n: any) => new Notification(n)) + } + + if (metadata.statuses != null) { + return metadata.statuses.map(polyfillNotification).filter((n: unknown) => n != null) + } + + return [] +} + +/** + * + * @private + * @param pos + * @returns {NotificationPosition} + */ +function _constructPosition (pos: any): NotificationPosition { + if (pos == null) { + return {} + } + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + return { + offset: util.toNumber(pos.offset!), + line: util.toNumber(pos.line!), + column: util.toNumber(pos.column!) + } + /* eslint-enable @typescript-eslint/no-non-null-assertion */ +} + +function _asEnumerableSeverity (severity: any): NotificationSeverityLevel { + return severityLevels.includes(severity) + ? severity + : notificationSeverityLevel.UNKNOWN +} + +function _asEnumerableClassification (classification: any): NotificationClassification { + return categories.includes(classification) + ? classification + : notificationClassification.UNKNOWN +} + +export default Notification + +export { + notificationSeverityLevel, + notificationCategory, + notificationClassification, + Notification, + GqlStatusObject, + polyfillGqlStatusObject, + polyfillNotification, + buildGqlStatusObjectFromMetadata, + buildNotificationsFromMetadata +} + +export type { + NotificationPosition, + NotificationSeverityLevel, + NotificationCategory, + NotificationClassification, + NotificationDiagnosticRecord +} diff --git a/packages/neo4j-driver-deno/lib/core/result-summary.ts b/packages/neo4j-driver-deno/lib/core/result-summary.ts index 7fffd9ee5..cf0a8552a 100644 --- a/packages/neo4j-driver-deno/lib/core/result-summary.ts +++ b/packages/neo4j-driver-deno/lib/core/result-summary.ts @@ -15,8 +15,10 @@ * limitations under the License. */ -import Integer, { int } from './integer.ts' +import Integer from './integer.ts' import { NumberOrInteger } from './graph-types.ts' +import { util } from './internal/index.ts' +import Notification, { GqlStatusObject, buildGqlStatusObjectFromMetadata, buildNotificationsFromMetadata } from './notification.ts' /** * A ResultSummary instance contains structured metadata for a {@link Result}. @@ -30,6 +32,7 @@ class ResultSummary { plan: Plan | false profile: ProfiledPlan | false notifications: Notification[] + gqlStatusObjects: [GqlStatusObject, ...GqlStatusObject[]] server: ServerInfo resultConsumedAfter: T resultAvailableAfter: T @@ -105,7 +108,30 @@ class ResultSummary { * @type {Array} * @public */ - this.notifications = this._buildNotifications(metadata.notifications) + this.notifications = buildNotificationsFromMetadata(metadata) + + /** + * A list of GqlStatusObjects that arise when executing the query. + * + * The list always contains at least 1 status representing the Success, No Data or Omitted Result. + * + * When discarding records while connected to a non-gql aware server and using a RxSession, + * the driver might not be able to tell apart Success and No Data. + * + * All other status are notifications like warnings about problematic queries or other valuable + * information that can be presented in a client. + * + * The GqlStatusObjects will be presented in the following order: + * + * - A “no data” (02xxx) has precedence over a warning; + * - A warning (01xxx) has precedence over a success. + * - A success (00xxx) has precedence over anything informational (03xxx) + * + * @type {Array} + * @public + * @experimental + */ + this.gqlStatusObjects = buildGqlStatusObjectFromMetadata(metadata) /** * The basic information of the server where the result is obtained from. @@ -136,15 +162,6 @@ class ResultSummary { this.database = { name: metadata.db ?? null } } - _buildNotifications (notifications: any[]): Notification[] { - if (notifications == null) { - return [] - } - return notifications.map(function (n: any): Notification { - return new Notification(n) - }) - } - /** * Check if the result summary has a plan * @return {boolean} @@ -358,9 +375,9 @@ class QueryStatistics { // To camelCase const camelCaseIndex = index.replace(/(-\w)/g, m => m[1].toUpperCase()) if (camelCaseIndex in this._stats) { - this._stats[camelCaseIndex] = intValue(statistics[index]) + this._stats[camelCaseIndex] = util.toNumber(statistics[index]) } else if (camelCaseIndex === 'systemUpdates') { - this._systemUpdates = intValue(statistics[index]) + this._systemUpdates = util.toNumber(statistics[index]) } else if (camelCaseIndex === 'containsSystemUpdates') { this._containsSystemUpdates = statistics[index] } else if (camelCaseIndex === 'containsUpdates') { @@ -411,195 +428,6 @@ class QueryStatistics { } } -interface NotificationPosition { - offset?: number - line?: number - column?: number -} - -type NotificationSeverityLevel = 'WARNING' | 'INFORMATION' | 'UNKNOWN' -/** - * @typedef {'WARNING' | 'INFORMATION' | 'UNKNOWN'} NotificationSeverityLevel - */ -/** - * Constants that represents the Severity level in the {@link Notification} - */ -const notificationSeverityLevel: { [key in NotificationSeverityLevel]: key } = { - WARNING: 'WARNING', - INFORMATION: 'INFORMATION', - UNKNOWN: 'UNKNOWN' -} - -Object.freeze(notificationSeverityLevel) -const severityLevels = Object.values(notificationSeverityLevel) - -type NotificationCategory = 'HINT' | 'UNRECOGNIZED' | 'UNSUPPORTED' | 'PERFORMANCE' | -'TOPOLOGY' | 'SECURITY' | 'DEPRECATION' | 'GENERIC' | 'UNKNOWN' -/** - * @typedef {'HINT' | 'UNRECOGNIZED' | 'UNSUPPORTED' |'PERFORMANCE' | 'TOPOLOGY' | 'SECURITY' | 'DEPRECATION' | 'GENERIC' | 'UNKNOWN' } NotificationCategory - */ -/** - * Constants that represents the Category in the {@link Notification} - */ -const notificationCategory: { [key in NotificationCategory]: key } = { - HINT: 'HINT', - UNRECOGNIZED: 'UNRECOGNIZED', - UNSUPPORTED: 'UNSUPPORTED', - PERFORMANCE: 'PERFORMANCE', - DEPRECATION: 'DEPRECATION', - TOPOLOGY: 'TOPOLOGY', - SECURITY: 'SECURITY', - GENERIC: 'GENERIC', - UNKNOWN: 'UNKNOWN' -} - -Object.freeze(notificationCategory) -const categories = Object.values(notificationCategory) - -/** - * Class for Cypher notifications - * @access public - */ -class Notification { - code: string - title: string - description: string - severity: string - position: NotificationPosition | {} - severityLevel: NotificationSeverityLevel - category: NotificationCategory - rawSeverityLevel: string - rawCategory?: string - - /** - * Create a Notification instance - * @constructor - * @param {Object} notification - Object with notification data - */ - constructor (notification: any) { - /** - * The code - * @type {string} - * @public - */ - this.code = notification.code - /** - * The title - * @type {string} - * @public - */ - this.title = notification.title - /** - * The description - * @type {string} - * @public - */ - this.description = notification.description - /** - * The raw severity - * - * Use {@link Notification#rawSeverityLevel} for the raw value or {@link Notification#severityLevel} for an enumerated value. - * - * @type {string} - * @public - * @deprecated This property will be removed in 6.0. - */ - this.severity = notification.severity - /** - * The position which the notification had occur. - * - * @type {NotificationPosition} - * @public - */ - this.position = Notification._constructPosition(notification.position) - - /** - * The severity level - * - * @type {NotificationSeverityLevel} - * @public - * @example - * const { summary } = await session.run("RETURN 1") - * - * for (const notification of summary.notifications) { - * switch(notification.severityLevel) { - * case neo4j.notificationSeverityLevel.INFORMATION: // or simply 'INFORMATION' - * console.info(`${notification.title} - ${notification.description}`) - * break - * case neo4j.notificationSeverityLevel.WARNING: // or simply 'WARNING' - * console.warn(`${notification.title} - ${notification.description}`) - * break - * case neo4j.notificationSeverityLevel.UNKNOWN: // or simply 'UNKNOWN' - * default: - * // the raw info came from the server could be found at notification.rawSeverityLevel - * console.log(`${notification.title} - ${notification.description}`) - * break - * } - * } - */ - this.severityLevel = severityLevels.includes(notification.severity) - ? notification.severity - : notificationSeverityLevel.UNKNOWN - - /** - * The severity level returned by the server without any validation. - * - * @type {string} - * @public - */ - this.rawSeverityLevel = notification.severity - - /** - * The category - * - * @type {NotificationCategory} - * @public - * @example - * const { summary } = await session.run("RETURN 1") - * - * for (const notification of summary.notifications) { - * switch(notification.category) { - * case neo4j.notificationCategory.QUERY: // or simply 'QUERY' - * console.info(`${notification.title} - ${notification.description}`) - * break - * case neo4j.notificationCategory.PERFORMANCE: // or simply 'PERFORMANCE' - * console.warn(`${notification.title} - ${notification.description}`) - * break - * case neo4j.notificationCategory.UNKNOWN: // or simply 'UNKNOWN' - * default: - * // the raw info came from the server could be found at notification.rawCategory - * console.log(`${notification.title} - ${notification.description}`) - * break - * } - * } - */ - this.category = categories.includes(notification.category) - ? notification.category - : notificationCategory.UNKNOWN - - /** - * The category returned by the server without any validation. - * - * @type {string|undefined} - * @public - */ - this.rawCategory = notification.category - } - - static _constructPosition (pos: NotificationPosition): NotificationPosition { - if (pos == null) { - return {} - } - /* eslint-disable @typescript-eslint/no-non-null-assertion */ - return { - offset: intValue(pos.offset!), - line: intValue(pos.line!), - column: intValue(pos.column!) - } - /* eslint-enable @typescript-eslint/no-non-null-assertion */ - } -} - /** * Class for exposing server info from a result. * @access public @@ -642,16 +470,6 @@ class ServerInfo { } } -function intValue (value: NumberOrInteger): number { - if (value instanceof Integer) { - return value.toNumber() - } else if (typeof value === 'bigint') { - return int(value).toNumber() - } else { - return value - } -} - function valueOrDefault ( key: string, values: { [key: string]: NumberOrInteger } | false, @@ -659,7 +477,7 @@ function valueOrDefault ( ): number { if (values !== false && key in values) { const value = values[key] - return intValue(value) + return util.toNumber(value) } else { return defaultValue } @@ -679,18 +497,10 @@ const queryType = { export { queryType, ServerInfo, - Notification, Plan, ProfiledPlan, QueryStatistics, - Stats, - notificationSeverityLevel, - notificationCategory -} -export type { - NotificationPosition, - NotificationSeverityLevel, - NotificationCategory + Stats } export default ResultSummary diff --git a/packages/neo4j-driver-deno/lib/mod.ts b/packages/neo4j-driver-deno/lib/mod.ts index b8e652221..41f775d59 100644 --- a/packages/neo4j-driver-deno/lib/mod.ts +++ b/packages/neo4j-driver-deno/lib/mod.ts @@ -57,11 +57,16 @@ import { Neo4jError, Node, Notification, + GqlStatusObject, notificationCategory, NotificationCategory, + notificationClassification, + NotificationClassification, NotificationFilter, NotificationFilterDisabledCategory, notificationFilterDisabledCategory, + NotificationFilterDisabledClassification, + notificationFilterDisabledClassification, AuthTokenManager, AuthTokenAndExpiration, staticAuthTokenManager, @@ -412,6 +417,7 @@ const forExport = { ProfiledPlan, QueryStatistics, Notification, + GqlStatusObject, ServerInfo, Session, Transaction, @@ -429,8 +435,10 @@ const forExport = { bookmarkManager, resultTransformers, notificationCategory, + notificationClassification, notificationSeverityLevel, notificationFilterDisabledCategory, + notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, clientCertificateProviders } @@ -480,6 +488,7 @@ export { ProfiledPlan, QueryStatistics, Notification, + GqlStatusObject, ServerInfo, Session, Transaction, @@ -497,8 +506,10 @@ export { bookmarkManager, resultTransformers, notificationCategory, + notificationClassification, notificationSeverityLevel, notificationFilterDisabledCategory, + notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, clientCertificateProviders } @@ -522,9 +533,11 @@ export type { RecordShape, ResultTransformer, NotificationCategory, + NotificationClassification, NotificationSeverityLevel, NotificationFilter, NotificationFilterDisabledCategory, + NotificationFilterDisabledClassification, NotificationFilterMinimumSeverityLevel, ClientCertificate, ClientCertificateProvider, diff --git a/packages/neo4j-driver-lite/src/index.ts b/packages/neo4j-driver-lite/src/index.ts index a9b440367..2e078aa48 100644 --- a/packages/neo4j-driver-lite/src/index.ts +++ b/packages/neo4j-driver-lite/src/index.ts @@ -57,11 +57,16 @@ import { Neo4jError, Node, Notification, + GqlStatusObject, notificationCategory, NotificationCategory, + notificationClassification, + NotificationClassification, NotificationFilter, NotificationFilterDisabledCategory, notificationFilterDisabledCategory, + NotificationFilterDisabledClassification, + notificationFilterDisabledClassification, AuthTokenManager, AuthTokenAndExpiration, staticAuthTokenManager, @@ -411,6 +416,7 @@ const forExport = { ProfiledPlan, QueryStatistics, Notification, + GqlStatusObject, ServerInfo, Session, Transaction, @@ -428,8 +434,10 @@ const forExport = { bookmarkManager, resultTransformers, notificationCategory, + notificationClassification, notificationSeverityLevel, notificationFilterDisabledCategory, + notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, clientCertificateProviders } @@ -479,6 +487,7 @@ export { ProfiledPlan, QueryStatistics, Notification, + GqlStatusObject, ServerInfo, Session, Transaction, @@ -496,8 +505,10 @@ export { bookmarkManager, resultTransformers, notificationCategory, + notificationClassification, notificationSeverityLevel, notificationFilterDisabledCategory, + notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, clientCertificateProviders } @@ -521,9 +532,11 @@ export type { RecordShape, ResultTransformer, NotificationCategory, + NotificationClassification, NotificationSeverityLevel, NotificationFilter, NotificationFilterDisabledCategory, + NotificationFilterDisabledClassification, NotificationFilterMinimumSeverityLevel, ClientCertificate, ClientCertificateProvider, diff --git a/packages/neo4j-driver/src/index.js b/packages/neo4j-driver/src/index.js index e0b2b4aa4..911ad9fcd 100644 --- a/packages/neo4j-driver/src/index.js +++ b/packages/neo4j-driver/src/index.js @@ -59,6 +59,7 @@ import { ProfiledPlan, QueryStatistics, Notification, + GqlStatusObject, ServerInfo, Result, EagerResult, @@ -70,8 +71,10 @@ import { routing, resultTransformers, notificationCategory, + notificationClassification, notificationSeverityLevel, notificationFilterDisabledCategory, + notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, staticAuthTokenManager, clientCertificateProviders, @@ -377,6 +380,7 @@ const forExport = { ProfiledPlan, QueryStatistics, Notification, + GqlStatusObject, ServerInfo, Record, Node, @@ -446,6 +450,7 @@ export { ProfiledPlan, QueryStatistics, Notification, + GqlStatusObject, ServerInfo, Record, Node, @@ -464,8 +469,10 @@ export { bookmarkManager, resultTransformers, notificationCategory, + notificationClassification, notificationSeverityLevel, notificationFilterDisabledCategory, + notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, clientCertificateProviders } diff --git a/packages/neo4j-driver/test/types/export.test.ts b/packages/neo4j-driver/test/types/export.test.ts index 5dd72ac53..9b2f88ac7 100644 --- a/packages/neo4j-driver/test/types/export.test.ts +++ b/packages/neo4j-driver/test/types/export.test.ts @@ -120,6 +120,7 @@ const instanceOfDriverPlan: boolean = dummy instanceof driver.Plan const instanceOfDriverProfiledPlan: boolean = dummy instanceof driver.ProfiledPlan const instanceOfDriverQueryStatistics: boolean = dummy instanceof driver.QueryStatistics const instanceOfDriverNotification: boolean = dummy instanceof driver.Notification +const instanceOfDriverGqlStatusObject: boolean = dummy instanceof driver.GqlStatusObject const instanceOfDriverServerInfo: boolean = dummy instanceof driver.ServerInfo const instanceOfDriverSession: boolean = dummy instanceof driver.Session const instanceOfDriverTransaction: boolean = dummy instanceof driver.Transaction diff --git a/packages/neo4j-driver/test/types/index.test.ts b/packages/neo4j-driver/test/types/index.test.ts index bbc4dcf35..2dcf2dd86 100644 --- a/packages/neo4j-driver/test/types/index.test.ts +++ b/packages/neo4j-driver/test/types/index.test.ts @@ -39,10 +39,14 @@ import { NotificationSeverityLevel, notificationCategory, NotificationCategory, + notificationClassification, + NotificationClassification, notificationFilterMinimumSeverityLevel, NotificationFilterMinimumSeverityLevel, NotificationFilterDisabledCategory, notificationFilterDisabledCategory, + NotificationFilterDisabledClassification, + notificationFilterDisabledClassification, authTokenManagers } from '../../types/index' @@ -159,6 +163,25 @@ const unrecognizedCategory: NotificationCategory = notificationCategory.UNRECOGN const unsupportedCategory: NotificationCategory = notificationCategory.UNSUPPORTED const unknownCategory: NotificationCategory = notificationCategory.UNKNOWN +const hintClassificationString: string = notificationClassification.HINT +const deprecationClassificationString: string = notificationClassification.DEPRECATION +const performanceClassificationString: string = notificationClassification.PERFORMANCE +const topologyClassificationString: string = notificationClassification.TOPOLOGY +const securityClassificationString: string = notificationClassification.SECURITY +const genericClassificationString: string = notificationClassification.GENERIC +const unrecognizedClassificationString: string = notificationClassification.UNRECOGNIZED +const unsupportedClassificationString: string = notificationClassification.UNSUPPORTED +const unknownClassificationString: string = notificationClassification.UNKNOWN +const hintClassification: NotificationClassification = notificationClassification.HINT +const deprecationClassification: NotificationClassification = notificationClassification.DEPRECATION +const performanceClassification: NotificationClassification = notificationClassification.PERFORMANCE +const topologyClassification: NotificationClassification = notificationClassification.TOPOLOGY +const securityClassification: NotificationClassification = notificationClassification.SECURITY +const genericClassification: NotificationClassification = notificationClassification.GENERIC +const unrecognizedClassification: NotificationClassification = notificationClassification.UNRECOGNIZED +const unsupportedClassification: NotificationClassification = notificationClassification.UNSUPPORTED +const unknownClassification: NotificationClassification = notificationClassification.UNKNOWN + const offNotificationFilterMinimumSeverityLevelString: string = notificationFilterMinimumSeverityLevel.OFF const warningNotificationFilterMinimumSeverityLevelString: string = notificationFilterMinimumSeverityLevel.WARNING const infoNotificationFilterMinimumSeverityLevelString: string = notificationFilterMinimumSeverityLevel.INFORMATION @@ -178,3 +201,16 @@ const performanceDisabledCategory: NotificationFilterDisabledCategory = notifica const genericDisabledCategory: NotificationFilterDisabledCategory = notificationFilterDisabledCategory.GENERIC const unrecognizedDisabledCategory: NotificationFilterDisabledCategory = notificationFilterDisabledCategory.UNRECOGNIZED const unsupportedDisabledCategory: NotificationFilterDisabledCategory = notificationFilterDisabledCategory.UNSUPPORTED + +const hintDisabledClassificationString: string = notificationFilterDisabledClassification.HINT +const deprecationDisabledClassificationString: string = notificationFilterDisabledClassification.DEPRECATION +const performanceDisabledClassificationString: string = notificationFilterDisabledClassification.PERFORMANCE +const genericDisabledClassificationString: string = notificationFilterDisabledClassification.GENERIC +const unrecognizedDisabledClassificationString: string = notificationFilterDisabledClassification.UNRECOGNIZED +const unsupportedDisabledClassificationString: string = notificationFilterDisabledClassification.UNSUPPORTED +const hintDisabledClassification: NotificationFilterDisabledClassification = notificationFilterDisabledClassification.HINT +const deprecationDisabledClassification: NotificationFilterDisabledClassification = notificationFilterDisabledClassification.DEPRECATION +const performanceDisabledClassification: NotificationFilterDisabledClassification = notificationFilterDisabledClassification.PERFORMANCE +const genericDisabledClassification: NotificationFilterDisabledClassification = notificationFilterDisabledClassification.GENERIC +const unrecognizedDisabledClassification: NotificationFilterDisabledClassification = notificationFilterDisabledClassification.UNRECOGNIZED +const unsupportedDisabledClassification: NotificationFilterDisabledClassification = notificationFilterDisabledClassification.UNSUPPORTED diff --git a/packages/neo4j-driver/types/index.d.ts b/packages/neo4j-driver/types/index.d.ts index 18c9a5cbf..659ea07bf 100644 --- a/packages/neo4j-driver/types/index.d.ts +++ b/packages/neo4j-driver/types/index.d.ts @@ -55,6 +55,7 @@ import { RecordShape, ResultSummary, Notification, + GqlStatusObject, NotificationPosition, Plan, ProfiledPlan, @@ -77,13 +78,17 @@ import { resultTransformers, ResultTransformer, notificationCategory, + notificationClassification, notificationSeverityLevel, NotificationCategory, + NotificationClassification, NotificationSeverityLevel, NotificationFilter, NotificationFilterDisabledCategory, + NotificationFilterDisabledClassification, NotificationFilterMinimumSeverityLevel, notificationFilterDisabledCategory, + notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, AuthTokenManager, AuthTokenAndExpiration, @@ -253,6 +258,7 @@ declare const forExport: { ProfiledPlan: typeof ProfiledPlan QueryStatistics: typeof QueryStatistics Notification: typeof Notification + GqlStatusObject: typeof GqlStatusObject ServerInfo: typeof ServerInfo NotificationPosition: NotificationPosition Session: typeof Session @@ -284,8 +290,10 @@ declare const forExport: { bookmarkManager: typeof bookmarkManager resultTransformers: typeof resultTransformers notificationCategory: typeof notificationCategory + notificationClassification: typeof notificationClassification notificationSeverityLevel: typeof notificationSeverityLevel notificationFilterDisabledCategory: typeof notificationFilterDisabledCategory + notificationFilterDisabledClassification: typeof notificationFilterDisabledClassification notificationFilterMinimumSeverityLevel: typeof notificationFilterMinimumSeverityLevel logging: typeof logging clientCertificateProviders: typeof clientCertificateProviders @@ -330,6 +338,7 @@ export { ProfiledPlan, QueryStatistics, Notification, + GqlStatusObject, ServerInfo, NotificationPosition, Session, @@ -361,8 +370,10 @@ export { bookmarkManager, resultTransformers, notificationCategory, + notificationClassification, notificationSeverityLevel, notificationFilterDisabledCategory, + notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, logging, clientCertificateProviders @@ -378,9 +389,11 @@ export type { RecordShape, ResultTransformer, NotificationCategory, + NotificationClassification, NotificationSeverityLevel, NotificationFilter, NotificationFilterDisabledCategory, + NotificationFilterDisabledClassification, NotificationFilterMinimumSeverityLevel, AuthTokenManager, AuthTokenAndExpiration, diff --git a/packages/testkit-backend/src/feature/common.js b/packages/testkit-backend/src/feature/common.js index 772e1b267..fe7f7a277 100644 --- a/packages/testkit-backend/src/feature/common.js +++ b/packages/testkit-backend/src/feature/common.js @@ -24,6 +24,7 @@ const features = [ 'Feature:Bolt:5.2', 'Feature:Bolt:5.3', 'Feature:Bolt:5.4', + 'Feature:Bolt:5.5', 'Feature:Bolt:Patch:UTC', 'Feature:API:ConnectionAcquisitionTimeout', 'Feature:API:Driver.ExecuteQuery', @@ -34,6 +35,7 @@ const features = [ 'Feature:API:Driver.VerifyAuthentication', 'Feature:API:Driver.VerifyConnectivity', 'Feature:API:Session:NotificationsConfig', + 'Feature:API:Summary:GqlStatusObjects', 'Feature:API:Liveness.Check', 'Optimization:AuthPipelining', 'Optimization:EagerTransactionBegin', diff --git a/packages/testkit-backend/src/skipped-tests/common.js b/packages/testkit-backend/src/skipped-tests/common.js index e36d4c9f4..2f1e6f6ee 100644 --- a/packages/testkit-backend/src/skipped-tests/common.js +++ b/packages/testkit-backend/src/skipped-tests/common.js @@ -55,7 +55,7 @@ const skippedTests = [ ), skip( 'ResultSummary.notifications defaults to empty array instead of return null/undefined', - ifEquals('stub.summary.test_summary.TestSummary.test_no_notifications'), + ifEquals('stub.summary.test_summary.TestSummaryNotifications4x4.test_no_notifications'), ifEquals('neo4j.test_summary.TestSummary.test_no_notification_info') ), skip( diff --git a/packages/testkit-backend/src/summary-binder.js b/packages/testkit-backend/src/summary-binder.js index 62af6c1d0..9f13f75a5 100644 --- a/packages/testkit-backend/src/summary-binder.js +++ b/packages/testkit-backend/src/summary-binder.js @@ -47,6 +47,18 @@ function mapNotification (notification) { } } +function mapGqlStatusObject (binder) { + return (gqlStatusObject) => { + return { + ...gqlStatusObject, + position: gqlStatusObject.position || null, + rawSeverity: gqlStatusObject.rawSeverity !== undefined ? gqlStatusObject.rawSeverity : null, + rawClassification: gqlStatusObject.rawClassification !== undefined ? gqlStatusObject.rawClassification : null, + diagnosticRecord: binder.objectToCypher(gqlStatusObject.diagnosticRecord) + } + } +} + export function nativeToTestkitSummary (summary, binder) { return { ...binder.objectMemberBitIntToNumber(summary), @@ -62,6 +74,7 @@ export function nativeToTestkitSummary (summary, binder) { counters: mapCounters(summary.counters), plan: mapPlan(summary.plan), profile: mapProfile(summary.profile, false, binder), - notifications: summary.notifications.map(mapNotification) + notifications: summary.notifications.map(mapNotification), + gqlStatusObjects: summary.gqlStatusObjects.map(mapGqlStatusObject(binder)) } }