diff --git a/package-lock.json b/package-lock.json index 689654ce3..19f6b737e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "root", "devDependencies": { + "@types/node": "^24.0.10", "husky": "^8.0.3", "lerna": "^4.0.0", "lint-staged": "^14.0.0", @@ -1628,6 +1629,16 @@ "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==", "dev": true }, + "node_modules/@types/node": { + "version": "24.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz", + "integrity": "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz", @@ -9963,6 +9974,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true, + "license": "MIT" + }, "node_modules/unique-filename": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", @@ -11795,6 +11813,15 @@ "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==", "dev": true }, + "@types/node": { + "version": "24.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz", + "integrity": "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==", + "dev": true, + "requires": { + "undici-types": "~7.8.0" + } + }, "@types/normalize-package-data": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz", @@ -18004,6 +18031,12 @@ "which-boxed-primitive": "^1.0.2" } }, + "undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true + }, "unique-filename": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", diff --git a/package.json b/package.json index 614a285c3..88dc27cf0 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "devDependencies": { + "@types/node": "^24.0.10", "husky": "^8.0.3", "lerna": "^4.0.0", "lint-staged": "^14.0.0", diff --git a/packages/core/src/graph-types.ts b/packages/core/src/graph-types.ts index b1f025c21..9b0aa16b6 100644 --- a/packages/core/src/graph-types.ts +++ b/packages/core/src/graph-types.ts @@ -16,6 +16,7 @@ */ import Integer from './integer' import { stringify } from './json' +import { Rules, GenericConstructor, as } from './mapping.highlevel' type StandardDate = Date /** @@ -82,6 +83,24 @@ class Node identity.toString()) } + /** + * Hydrates an object of a given type with the properties of the node + * + * @param {GenericConstructor | Rules} constructorOrRules Constructor for the desired type or {@link Rules} for the hydration + * @param {Rules} [rules] {@link Rules} for the hydration + * @returns {T} + * + * @experimental Part of the Record Object Mapping preview feature + */ + as (rules: Rules): T + as (genericConstructor: GenericConstructor): T + as (genericConstructor: GenericConstructor, rules?: Rules): T + as (constructorOrRules: GenericConstructor | Rules, rules?: Rules): T { + return as({ + get: (key) => this.properties[key] + }, constructorOrRules, rules) + } + /** * @ignore */ @@ -199,6 +218,24 @@ class Relationship end.toString()) } + /** + * Hydrates an object of a given type with the properties of the relationship + * + * @param {GenericConstructor | Rules} constructorOrRules Constructor for the desired type or {@link Rules} for the hydration + * @param {Rules} [rules] {@link Rules} for the hydration + * @returns {T} + * + * @experimental Part of the Record Object Mapping preview feature + */ + as (rules: Rules): T + as (genericConstructor: GenericConstructor): T + as (genericConstructor: GenericConstructor, rules?: Rules): T + as (constructorOrRules: GenericConstructor | Rules, rules?: Rules): T { + return as({ + get: (key) => this.properties[key] + }, constructorOrRules, rules) + } + /** * @ignore */ @@ -320,6 +357,24 @@ class UnboundRelationship | Rules} constructorOrRules Constructor for the desired type or {@link Rules} for the hydration + * @param {Rules} [rules] {@link Rules} for the hydration + * @returns {T} + * + * @experimental Part of the Record Object Mapping preview feature + */ + as (rules: Rules): T + as (genericConstructor: GenericConstructor): T + as (genericConstructor: GenericConstructor, rules?: Rules): T + as (constructorOrRules: GenericConstructor | Rules, rules?: Rules): T { + return as({ + get: (key) => this.properties[key] + }, constructorOrRules, rules) + } + /** * @ignore */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 81f9bc53c..f5c453d09 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -83,7 +83,7 @@ import NotificationFilter, { notificationFilterMinimumSeverityLevel, NotificationFilterMinimumSeverityLevel } from './notification-filter' -import Result, { QueryResult, ResultObserver } from './result' +import Result, { MappedQueryResult, QueryResult, ResultObserver } from './result' import EagerResult from './result-eager' import ConnectionProvider, { Releasable } from './connection-provider' import Connection from './connection' @@ -103,6 +103,10 @@ import resultTransformers, { ResultTransformer } from './result-transformers' import ClientCertificate, { clientCertificateProviders, ClientCertificateProvider, ClientCertificateProviders, RotatingClientCertificateProvider, resolveCertificateProvider } from './client-certificate' import * as internal from './internal' // todo: removed afterwards import Vector, { VectorType, vector, isVector } from './vector' +import { StandardCase } from './mapping.nameconventions' +import { Rule, Rules, RecordObjectMapping } from './mapping.highlevel' +import { rule } from './mapping.rulesfactories' +import mappingDecorators from './mapping.decorators' import UnsupportedType, { isUnsupportedType } from './unsupported-type' /** @@ -191,6 +195,10 @@ const forExport = { notificationFilterMinimumSeverityLevel, clientCertificateProviders, resolveCertificateProvider, + rule, + mappingDecorators, + RecordObjectMapping, + StandardCase, UnsupportedType, isUnsupportedType, isVector, @@ -275,9 +283,13 @@ export { resolveCertificateProvider, isVector, Vector, + vector, + rule, + mappingDecorators, + RecordObjectMapping, + StandardCase, UnsupportedType, isUnsupportedType, - vector } export type { @@ -285,6 +297,7 @@ export type { NumberOrInteger, NotificationPosition, QueryResult, + MappedQueryResult, ResultObserver, TransactionConfig, BookmarkManager, @@ -309,7 +322,9 @@ export type { ClientCertificateProvider, ClientCertificateProviders, RotatingClientCertificateProvider, - VectorType + VectorType, + Rule, + Rules } export default forExport diff --git a/packages/core/src/internal/observers.ts b/packages/core/src/internal/observers.ts index 07e5b919b..e20a6185b 100644 --- a/packages/core/src/internal/observers.ts +++ b/packages/core/src/internal/observers.ts @@ -16,6 +16,7 @@ */ import Record from '../record' +import { GenericResultObserver } from '../result' import ResultSummary from '../result-summary' interface StreamObserver { @@ -115,7 +116,7 @@ export interface ResultStreamObserver extends StreamObserver { * @param {function(metadata: Object)} observer.onCompleted - Handle stream tail, the summary. * @param {function(error: Object)} observer.onError - Handle errors, should always be provided. */ - subscribe: (observer: ResultObserver) => void + subscribe: (observer: GenericResultObserver) => void } export class CompletedObserver implements ResultStreamObserver { diff --git a/packages/core/src/mapping.decorators.ts b/packages/core/src/mapping.decorators.ts new file mode 100644 index 000000000..96d7adf57 --- /dev/null +++ b/packages/core/src/mapping.decorators.ts @@ -0,0 +1,219 @@ +import { Rule, rulesRegistry } from './mapping.highlevel' +import { rule } from './mapping.rulesfactories' + +/** + * Class Decorator Factory that enables the Neo4j Driver to map result records to this class + * + * @returns {Function} Class Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function mappedClass () { + return (_: any, context: any) => { + rulesRegistry[context.name] = context.metadata + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a boolean. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function booleanProperty (config?: Rule) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asBoolean(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a string. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function stringProperty (config?: Rule) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asString(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a number. + * + * @param {Rule & { acceptBigInt?: boolean }} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function numberProperty (config?: Rule & { acceptBigInt?: boolean }) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asNumber(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a BigInt. + * + * @param {Rule & { acceptNumber?: boolean }} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function bigIntProperty (config?: Rule & { acceptNumber?: boolean }) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asBigInt(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a Node. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function nodeProperty (config?: Rule) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asNode(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a Relationship. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function relationshipProperty (config?: Rule) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asRelationship(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a Path. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function pathProperty (config?: Rule) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asPath(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a Point. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function pointProperty (config?: Rule) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asPoint(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a Duration. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function durationProperty (config?: Rule & { stringify?: boolean }) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asDuration(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a List + * + * @param {Rule & { apply?: Rule }} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function listProperty (config?: Rule & { apply?: Rule }) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asList({ apply: { ...context.metadata[context.name] }, ...config }) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a Vector + * + * @param {Rule & { asTypedList?: boolean }} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function vectorProperty (config?: Rule & { asTypedList?: boolean }) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asVector(config) + } +} + +/** + * Property Decorator Factory that sets this property to optional. + * NOTE: Should be put above a type decorator. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function optionalProperty () { + return (_: any, context: any) => { + context.metadata[context.name] = { optional: true, ...context.metadata[context.name] } + } +} + +/** + * Property Decorator Factory that sets a custom parameter name to map this property to. + * NOTE: Should be put above a type decorator. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function mapPropertyFromName (name: string) { + return (_: any, context: any) => { + context.metadata[context.name] = { from: name, ...context.metadata[context.name] } + } +} + +/** + * Property Decorator Factory that sets the Neo4j Driver to convert this property to another type. + * NOTE: Should be put above a type decorator of type Node or Relationship. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function convertPropertyToType (type: any) { + return (_: any, context: any) => { + context.metadata[context.name] = { convert: (node: any) => node.as(type), ...context.metadata[context.name] } + } +} + +const forExport = { + booleanProperty, + stringProperty, + numberProperty, + bigIntProperty, + nodeProperty, + relationshipProperty, + pathProperty, + pointProperty, + durationProperty, + listProperty, + vectorProperty, + optionalProperty, + mapPropertyFromName, + convertPropertyToType, + mappedClass +} + +export default forExport diff --git a/packages/core/src/mapping.highlevel.ts b/packages/core/src/mapping.highlevel.ts new file mode 100644 index 000000000..f08c85457 --- /dev/null +++ b/packages/core/src/mapping.highlevel.ts @@ -0,0 +1,188 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { newError } from './error' +import { nameConventions } from './mapping.nameconventions' + +/** + * constructor function of any class + */ +export type GenericConstructor = new (...args: any[]) => T + +export interface Rule { + optional?: boolean + from?: string + convert?: (recordValue: any, field: string) => any + validate?: (recordValue: any, field: string) => void +} + +export type Rules = Record + +export let rulesRegistry: Record = {} + +let nameMapping: (name: string) => string = (name) => name + +function register (constructor: GenericConstructor, rules: Rules): void { + rulesRegistry[constructor.name] = rules +} + +function clearMappingRegistry (): void { + rulesRegistry = {} +} + +function translateIdentifiers (translationFunction: (name: string) => string): void { + nameMapping = translationFunction +} + +function getCaseTranslator (databaseConvention: string, codeConvention: string): ((name: string) => string) { + const keys = Object.keys(nameConventions) + if (!keys.includes(databaseConvention)) { + throw newError( + `Naming convention ${databaseConvention} is not recognized, + please provide a recognized name convention or manually provide a translation function.` + ) + } + if (!keys.includes(codeConvention)) { + throw newError( + `Naming convention ${codeConvention} is not recognized, + please provide a recognized name convention or manually provide a translation function.` + ) + } + // @ts-expect-error + return (name: string) => nameConventions[databaseConvention].encode(nameConventions[codeConvention].tokenize(name)) +} + +export const RecordObjectMapping = Object.freeze({ + /** + * Clears all registered type mappings from the record object mapping registry. + * @experimental Part of the Record Object Mapping preview feature + */ + clearMappingRegistry, + /** + * Creates a translation function from record key names to object property names, for use with the {@link translateIdentifiers} function + * + * Recognized naming conventions are "camelCase", "PascalCase", "snake_case", "kebab-case", "SCREAMING_SNAKE_CASE" + * + * @experimental Part of the Record Object Mapping preview feature + * @param {string} databaseConvention The naming convention in use in database result Records + * @param {string} codeConvention The naming convention in use in JavaScript object properties + * @returns {function} translation function + */ + getCaseTranslator, + /** + * Registers a set of {@link Rules} to be used by {@link hydrated} for the provided class when no other rules are specified. This registry exists in global memory, not the driver instance. + * + * @example + * // The following code: + * const summary = await driver.executeQuery('CREATE (p:Person{ name: $name }) RETURN p', { name: 'Person1'}, { + * resultTransformer: neo4j.resultTransformers.hydrated(Person, personClassRules) + * }) + * + * can instead be written: + * neo4j.RecordObjectMapping.register(Person, personClassRules) + * + * const summary = await driver.executeQuery('CREATE (p:Person{ name: $name }) RETURN p', { name: 'Person1'}, { + * resultTransformer: neo4j.resultTransformers.hydrated(Person) + * }) + * + * @experimental Part of the Record Object Mapping preview feature + * @param {GenericConstructor} constructor The constructor function of the class to set rules for + * @param {Rules} rules The rules to set for the provided class + */ + register, + /** + * Sets a default name translation from record keys to object properties. + * If providing a function, provide a function that maps FROM your object properties names TO record key names. + * + * The function getCaseTranslator can be used to provide a prewritten translation function between some common naming conventions. + * + * @example + * //if the keys on records from the database are in ALLCAPS + * RecordObjectMapping.translateIdentifiers((name) => name.toUpperCase()) + * + * //if you utilize PacalCase in the database and camelCase in JavaScript code. + * RecordObjectMapping.translateIdentifiers(mapping.getCaseTranslator("PascalCase", "camelCase")) + * + * //if a type has one odd mapping you can override the translation with the rule + * const personRules = { + * firstName: neo4j.rule.asString(), + * bornAt: neo4j.rule.asNumber({ acceptBigInt: true, optional: true }) + * weird_name-property: neo4j.rule.asString({from: 'homeTown'}) + * } + * //These rules can then be used by providing them to a hydratedResultsMapper + * record.as(personRules) + * //or by registering them to the mapping registry + * RecordObjectMapping.register(Person, personRules) + * + * @experimental Part of the Record Object Mapping preview feature + * @param {function} translationFunction A function translating the names of your JS object property names to record key names + */ + translateIdentifiers +}) + +interface Gettable { get: (key: string) => V } + +export function as (gettable: Gettable, constructorOrRules: GenericConstructor | Rules, rules?: Rules): T { + const GenericConstructor = typeof constructorOrRules === 'function' ? constructorOrRules : Object + const theRules = getRules(constructorOrRules, rules) + const visitedKeys: string[] = [] + + const obj = new GenericConstructor() + + for (const [key, rule] of Object.entries(theRules ?? {})) { + visitedKeys.push(key) + _apply(gettable, obj, key, rule) + } + + for (const key of Object.getOwnPropertyNames(obj)) { + if (!visitedKeys.includes(key)) { + _apply(gettable, obj, key, theRules?.[key]) + } + } + + return obj as unknown as T +} + +function _apply (gettable: Gettable, obj: T, key: string, rule?: Rule): void { + const mappedkey = nameMapping(key) + const value = gettable.get(rule?.from ?? mappedkey) + const field = `${obj.constructor.name}#${key}` + const processedValue = valueAs(value, field, rule) + // @ts-expect-error + obj[key] = processedValue ?? obj[key] +} + +export function valueAs (value: unknown, field: string, rule?: Rule): unknown { + if (rule?.optional === true && value == null) { + return value + } + + if (typeof rule?.validate === 'function') { + rule.validate(value, field) + } + + return ((rule?.convert) != null) ? rule.convert(value, field) : value +} +function getRules (constructorOrRules: Rules | GenericConstructor, rules: Rules | undefined): Rules | undefined { + const rulesDefined = typeof constructorOrRules === 'object' ? constructorOrRules : rules + if (rulesDefined != null) { + return rulesDefined + } + return typeof constructorOrRules !== 'object' ? rulesRegistry[constructorOrRules.name] : undefined +} diff --git a/packages/core/src/mapping.nameconventions.ts b/packages/core/src/mapping.nameconventions.ts new file mode 100644 index 000000000..75b2b00cd --- /dev/null +++ b/packages/core/src/mapping.nameconventions.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface NameConvention { + tokenize: (name: string) => string[] + encode: (tokens: string[]) => string +} + +export enum StandardCase { + SnakeCase = 'snake_case', + KebabCase = 'kebab-case', + ScreamingSnakeCase = 'SCREAMING_SNAKE_CASE', + PascalCase = 'PascalCase', + CamelCase = 'camelCase' +} + +export const nameConventions = { + snake_case: { + tokenize: (name: string) => name.split('_'), + encode: (tokens: string[]) => tokens.join('_') + }, + 'kebab-case': { + tokenize: (name: string) => name.split('-'), + encode: (tokens: string[]) => tokens.join('-') + }, + PascalCase: { + tokenize: (name: string) => name.split(/(?=[A-Z])/).map((token) => token.toLowerCase()), + encode: (tokens: string[]) => { + let name: string = '' + for (let token of tokens) { + token = token.charAt(0).toUpperCase() + token.slice(1) + name += token + } + return name + } + }, + camelCase: { + tokenize: (name: string) => name.split(/(?=[A-Z])/).map((token) => token.toLowerCase()), + encode: (tokens: string[]) => { + let name: string = '' + for (let [i, token] of tokens.entries()) { + if (i !== 0) { + token = token.charAt(0).toUpperCase() + token.slice(1) + } + name += token + } + return name + } + }, + SCREAMING_SNAKE_CASE: { + tokenize: (name: string) => name.split('_').map((token) => token.toLowerCase()), + encode: (tokens: string[]) => tokens.join('_').toUpperCase() + } +} diff --git a/packages/core/src/mapping.rulesfactories.ts b/packages/core/src/mapping.rulesfactories.ts new file mode 100644 index 000000000..84591a8d2 --- /dev/null +++ b/packages/core/src/mapping.rulesfactories.ts @@ -0,0 +1,405 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Rule, valueAs } from './mapping.highlevel' +import { StandardDate, isNode, isPath, isRelationship, isUnboundRelationship } from './graph-types' +import { isPoint } from './spatial-types' +import { Date, DateTime, Duration, LocalDateTime, LocalTime, Time, isDate, isDateTime, isDuration, isLocalDateTime, isLocalTime, isTime } from './temporal-types' +import Vector from './vector' + +/** + * @property {function(rule: ?Rule)} asString Create a {@link Rule} that validates the value is a String. + * + * @property {function(rule: ?Rule & { acceptBigInt?: boolean })} asNumber Create a {@link Rule} that validates the value is a Number. + * + * @property {function(rule: ?Rule & { acceptNumber?: boolean })} AsBigInt Create a {@link Rule} that validates the value is a BigInt. + * + * @property {function(rule: ?Rule)} asNode Create a {@link Rule} that validates the value is a {@link Node}. + * + * @property {function(rule: ?Rule)} asRelationship Create a {@link Rule} that validates the value is a {@link Relationship}. + * + * @property {function(rule: ?Rule)} asPath Create a {@link Rule} that validates the value is a {@link Path}. + * + * @property {function(rule: ?Rule & { stringify?: boolean })} asDuration Create a {@link Rule} that validates the value is a {@link Duration}. + * + * @property {function(rule: ?Rule & { stringify?: boolean })} asLocalTime Create a {@link Rule} that validates the value is a {@link LocalTime}. + * + * @property {function(rule: ?Rule & { stringify?: boolean })} asLocalDateTime Create a {@link Rule} that validates the value is a {@link LocalDateTime}. + * + * @property {function(rule: ?Rule & { stringify?: boolean })} asTime Create a {@link Rule} that validates the value is a {@link Time}. + * + * @property {function(rule: ?Rule & { stringify?: boolean })} asDateTime Create a {@link Rule} that validates the value is a {@link DateTime}. + * + * @property {function(rule: ?Rule & { stringify?: boolean })} asDate Create a {@link Rule} that validates the value is a {@link Date}. + * + * @property {function(rule: ?Rule)} asPoint Create a {@link Rule} that validates the value is a {@link Point}. + * + * @property {function(rule: ?Rule & { apply?: Rule })} asList Create a {@link Rule} that validates the value is a List. + * + * @property {function(rule: ?Rule & { asTypedList: boolean })} asVector Create a {@link Rule} that validates the value is a List. + * + * @experimental Part of the Record Object Mapping preview feature + */ +export const rule = Object.freeze({ + /** + * Create a {@link Rule} that validates the value is a Boolean. + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asBoolean (rule?: Rule): Rule { + return { + validate: (value, field) => { + if (typeof value !== 'boolean') { + throw new TypeError(`${field} should be a boolean but received ${typeof value}`) + } + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a String. + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asString (rule?: Rule): Rule { + return { + validate: (value, field) => { + if (typeof value !== 'string') { + throw new TypeError(`${field} should be a string but received ${typeof value}`) + } + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Number}. + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule & { acceptBigInt?: boolean }} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asNumber (rule?: Rule & { acceptBigInt?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (typeof value === 'object' && value.low !== undefined && value.high !== undefined && Object.keys(value).length === 2) { + throw new TypeError('Number returned as Object. To use asNumber mapping, set disableLosslessIntegers or useBigInt in driver config object') + } + if (typeof value !== 'number' && (rule?.acceptBigInt !== true || typeof value !== 'bigint')) { + throw new TypeError(`${field} should be a number but received ${typeof value}`) + } + }, + convert: (value: number | bigint) => { + if (typeof value === 'bigint') { + return Number(value) + } + return value + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link BigInt}. + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule & { acceptNumber?: boolean }} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asBigInt (rule?: Rule & { acceptNumber?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (typeof value !== 'bigint' && (rule?.acceptNumber !== true || typeof value !== 'number')) { + throw new TypeError(`${field} should be a bigint but received ${typeof value}`) + } + }, + convert: (value: number | bigint) => { + if (typeof value === 'number') { + return BigInt(value) + } + return value + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Node}. + * + * @example + * const actingJobsRules: Rules = { + * // Converts the person node to a Person object in accordance with provided rules + * person: neo4j.rule.asNode({ + * convert: (node: Node) => node.as(Person, personRules) + * }), + * // Returns the movie node as a Node + * movie: neo4j.rule.asNode({}), + * } + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asNode (rule?: Rule): Rule { + return { + validate: (value: any, field: string) => { + if (!isNode(value)) { + throw new TypeError(`${field} should be a Node but received ${typeof value}`) + } + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Relationship}. + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule. + * @returns {Rule} A new rule for the value + */ + asRelationship (rule?: Rule): Rule { + return { + validate: (value: any, field: string) => { + if (!isRelationship(value)) { + throw new TypeError(`${field} should be a Relationship but received ${typeof value}`) + } + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is an {@link UnboundRelationship} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asUnboundRelationship (rule?: Rule): Rule { + return { + validate: (value: any, field: string) => { + if (!isUnboundRelationship(value)) { + throw new TypeError(`${field} should be a UnboundRelationship but received ${typeof value}`) + } + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Path} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asPath (rule?: Rule): Rule { + return { + validate: (value: any, field: string) => { + if (!isPath(value)) { + throw new TypeError(`${field} should be a Path but received ${typeof value}`) + } + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Point} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asPoint (rule?: Rule): Rule { + return { + validate: (value: any, field: string) => { + if (!isPoint(value)) { + throw new TypeError(`${field} should be a Point but received ${typeof value}`) + } + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Duration} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asDuration (rule?: Rule & { stringify?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (!isDuration(value)) { + throw new TypeError(`${field} should be a Duration but received ${typeof value}`) + } + }, + convert: (value: Duration) => rule?.stringify === true ? value.toString() : value, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link LocalTime} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asLocalTime (rule?: Rule & { stringify?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (!isLocalTime(value)) { + throw new TypeError(`${field} should be a LocalTime but received ${typeof value}`) + } + }, + convert: (value: LocalTime) => rule?.stringify === true ? value.toString() : value, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Time} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asTime (rule?: Rule & { stringify?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (!isTime(value)) { + throw new TypeError(`${field} should be a Time but received ${typeof value}`) + } + }, + convert: (value: Time) => rule?.stringify === true ? value.toString() : value, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Date} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asDate (rule?: Rule & { stringify?: boolean, toStandardDate?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (!isDate(value)) { + throw new TypeError(`${field} should be a Date but received ${typeof value}`) + } + }, + convert: (value: Date) => convertStdDate(value, rule), + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link LocalDateTime} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asLocalDateTime (rule?: Rule & { stringify?: boolean, toStandardDate?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (!isLocalDateTime(value)) { + throw new TypeError(`${field} should be a LocalDateTime but received ${typeof value}`) + } + }, + convert: (value: LocalDateTime) => convertStdDate(value, rule), + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link DateTime} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asDateTime (rule?: Rule & { stringify?: boolean, toStandardDate?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (!isDateTime(value)) { + throw new TypeError(`${field} should be a DateTime but received ${typeof value}`) + } + }, + convert: (value: DateTime) => convertStdDate(value, rule), + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a List. Optionally taking a rule for hydrating the contained values. + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule & { apply?: Rule }} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asList (rule?: Rule & { apply?: Rule }): Rule { + return { + validate: (value: any, field: string) => { + if (!Array.isArray(value)) { + throw new TypeError(`${field} should be a list but received ${typeof value}`) + } + }, + convert: (list: any[], field: string) => { + if (rule?.apply != null) { + return list.map((value, index) => valueAs(value, `${field}[${index}]`, rule.apply)) + } + return list + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a Vector. + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule & { asTypedList?: boolean }} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asVector (rule?: Rule & { asTypedList?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (!(value instanceof Vector)) { + throw new TypeError(`${field} should be a vector but received ${typeof value}`) + } + }, + convert: (value: Vector) => { + if (rule?.asTypedList === true) { + return value._typedArray + } + return value + }, + ...rule + } + } +}) + +interface ConvertableToStdDateOrStr { toStandardDate: () => StandardDate, toString: () => string } + +function convertStdDate (value: V, rule?: { stringify?: boolean, toStandardDate?: boolean }): string | V | StandardDate { + if (rule != null) { + if (rule.stringify === true) { + return value.toString() + } else if (rule.toStandardDate === true) { + return value.toStandardDate() + } + } + return value +} diff --git a/packages/core/src/record.ts b/packages/core/src/record.ts index 0b5dfc374..d8052189c 100644 --- a/packages/core/src/record.ts +++ b/packages/core/src/record.ts @@ -16,6 +16,7 @@ */ import { newError } from './error' +import { Rules, GenericConstructor, as } from './mapping.highlevel' type RecordShape = { [K in Key]: Value @@ -132,6 +133,20 @@ class Record< return resultArray } + as (rules: Rules): T + as (genericConstructor: GenericConstructor): T + as (genericConstructor: GenericConstructor, rules?: Rules): T + /** + * Maps the record to a provided type and/or according to provided Rules. + * + * @param {GenericConstructor | Rules} constructorOrRules + * @param {Rules} rules + * @returns {T} + */ + as (constructorOrRules: GenericConstructor | Rules, rules?: Rules): T { + return as(this, constructorOrRules, rules) + } + /** * Iterate over results. Each iteration will yield an array * of exactly two items - the key, and the value (in order). diff --git a/packages/core/src/result-transformers.ts b/packages/core/src/result-transformers.ts index 96cf26799..47394533e 100644 --- a/packages/core/src/result-transformers.ts +++ b/packages/core/src/result-transformers.ts @@ -22,6 +22,7 @@ import ResultSummary from './result-summary' import { newError } from './error' import { NumberOrInteger } from './graph-types' import Integer from './integer' +import { GenericConstructor, Rules } from './mapping.highlevel' type ResultTransformer = (result: Result) => Promise /** @@ -258,6 +259,46 @@ class ResultTransformers { summary (): ResultTransformer> { return summary } + + hydrated (rules: Rules): ResultTransformer<{ records: T[], summary: ResultSummary }> + hydrated (genericConstructor: GenericConstructor, rules?: Rules): ResultTransformer<{ records: T[], summary: ResultSummary }> + /** + * Creates a {@link ResultTransformer} which maps each record of the result to a hydrated object of a provided type and/or according to provided rules. + * + * @example + * + * class Person { + * constructor (name) { + * this.name = name + * } + * + * const personRules: Rules = { + * name: neo4j.rule.asString() + * } + * + * const summary = await driver.executeQuery('CREATE (p:Person{ name: $name }) RETURN p', { name: 'Person1'}, { + * resultTransformer: neo4j.resultTransformers.hydrated(Person, personClassRules) + * }) + * + * // Alternatively, the rules can be registered in the mapping registry. + * // This registry exists in global memory and will persist even between driver instances. + * + * neo4j.RecordObjectMapping.register(Person, PersonRules) + * + * // after registering the rule the transformer will follow them when mapping to the provided type + * const summary = await driver.executeQuery('CREATE (p:Person{ name: $name }) RETURN p', { name: 'Person1'}, { + * resultTransformer: neo4j.resultTransformers.hydrated(Person) + * }) + * + * // A hydrated can be used without providing or registering Rules beforehand, but in such case the mapping will be done without any type validation + * + * @returns {ResultTransformer>} The result transformer + * @see {@link Driver#executeQuery} + * @experimental Part of the Record Object Mapping preview feature + */ + hydrated (constructorOrRules: GenericConstructor | Rules, rules?: Rules): ResultTransformer<{ records: T[], summary: ResultSummary }> { + return async result => await result.as(constructorOrRules as unknown as GenericConstructor, rules).then() + } } /** diff --git a/packages/core/src/result.ts b/packages/core/src/result.ts index 390857ea6..5d2748e23 100644 --- a/packages/core/src/result.ts +++ b/packages/core/src/result.ts @@ -24,6 +24,7 @@ import { observer, util, connectionHolder } from './internal' import { newError, PROTOCOL_ERROR } from './error' import { NumberOrInteger } from './graph-types' import Integer from './integer' +import { GenericConstructor, Rules } from './mapping.highlevel' const { EMPTY_CONNECTION_HOLDER } = connectionHolder @@ -51,20 +52,28 @@ const DEFAULT_ON_COMPLETED = (summary: ResultSummary): void => {} */ const DEFAULT_ON_KEYS = (keys: string[]): void => {} +interface GenericQueryResult { + records: R[] + summary: ResultSummary +} + /** * The query result is the combination of the {@link ResultSummary} and * the array {@link Record[]} produced by the query */ -interface QueryResult { - records: Array> - summary: ResultSummary -} +interface QueryResult extends GenericQueryResult> {} + +/** + * The query result is the combination of the {@link ResultSummary} and + * an array of mapped objects produced by the query. + */ +interface MappedQueryResult extends GenericQueryResult {} /** * Interface to observe updates on the Result which is being produced. * */ -interface ResultObserver { +interface GenericResultObserver { /** * Receive the keys present on the record whenever this information is available * @@ -76,7 +85,7 @@ interface ResultObserver { * Receive the each record present on the {@link @Result} * @param {Record} record The {@link Record} produced */ - onNext?: (record: Record) => void + onNext?: (record: R) => void /** * Called when the result is fully received @@ -91,29 +100,47 @@ interface ResultObserver { onError?: (error: Error) => void } +interface ResultObserver extends GenericResultObserver> {} + /** * Defines a ResultObserver interface which can be used to enqueue records and dequeue * them until the result is fully received. * @access private */ -interface QueuedResultObserver extends ResultObserver { - dequeue: () => Promise> - dequeueUntilDone: () => Promise> - head: () => Promise> +interface QueuedResultObserver extends GenericResultObserver { + dequeue: () => Promise> + dequeueUntilDone: () => Promise> + head: () => Promise> size: number } +function captureStacktrace (): string | null { + const error = new Error('') + if (error.stack != null) { + return error.stack.replace(/^Error(\n\r)*/, '') // we don't need the 'Error\n' part, if only it exists + } + return null +} + /** - * A stream of {@link Record} representing the result of a query. - * Can be consumed eagerly as {@link Promise} resolved with array of records and {@link ResultSummary} - * summary, or rejected with error that contains {@link string} code and {@link string} message. - * Alternatively can be consumed lazily using {@link Result#subscribe} function. - * @access public + * @private + * @param {Error} error The error + * @param {string| null} newStack The newStack + * @returns {void} */ -class Result implements Promise> { +function replaceStacktrace (error: Error, newStack?: string | null): void { + if (newStack != null) { + // Error.prototype.toString() concatenates error.name and error.message nicely + // then we add the rest of the stack trace + // eslint-disable-next-line @typescript-eslint/no-base-to-string + error.stack = error.toString() + '\n' + newStack + } +} + +class GenericResult> implements Promise { private readonly _stack: string | null private readonly _streamObserverPromise: Promise - private _p: Promise | null + private _p: Promise | null private readonly _query: Query private readonly _parameters: any private readonly _connectionHolder: connectionHolder.ConnectionHolder @@ -121,6 +148,7 @@ class Result implements Promise implements Promise(rules: Rules): MappedResult + as (genericConstructor: GenericConstructor, rules?: Rules): MappedResult + /** + * Maps the records of this result to a provided type and/or according to provided Rules. + * + * NOTE: This modifies the Result object itself, and can not be run on a Result that is already being consumed. + * + * @example + * class Person { + * constructor ( + * public readonly name: string, + * public readonly born?: number + * ) {} + * } + * + * const personRules: Rules = { + * name: rule.asString(), + * born: rule.asNumber({ acceptBigInt: true, optional: true }) + * } + * + * await session.executeRead(async (tx: Transaction) => { + * let txres = tx.run(`MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) + * WHERE id(p) <> id(c) + * RETURN p.name as name, p.born as born`).as(personRules) + * + * @param {GenericConstructor | Rules} constructorOrRules + * @param {Rules} rules + * @returns {MappedResult} + */ + as (constructorOrRules: GenericConstructor | Rules, rules?: Rules): MappedResult { + if (this._p != null) { + throw newError('Cannot call .as() on a Result that is being consumed') + } + // @ts-expect-error + this._mapper = r => r.as(constructorOrRules, rules) + // @ts-expect-error + return this + } + /** * Returns a promise for the field keys. * @@ -215,16 +283,20 @@ class Result implements Promise> { + private _getOrCreatePromise (): Promise { if (this._p == null) { this._p = new Promise((resolve, reject) => { - const records: Array> = [] + const records: R[] = [] const observer = { - onNext: (record: Record) => { - records.push(record) + onNext: (record: R) => { + if (this._mapper != null) { + records.push(this._mapper(record) as unknown as R) + } else { + records.push(record as unknown as R) + } }, onCompleted: (summary: ResultSummary) => { - resolve({ records, summary }) + resolve({ records, summary } as unknown as T) }, onError: (error: Error) => { reject(error) @@ -243,9 +315,9 @@ class Result implements Promise, ResultSummary>} The async iterator for the Results + * @returns {PeekableAsyncIterator} The async iterator for the Results */ - [Symbol.asyncIterator] (): PeekableAsyncIterator, ResultSummary> { + [Symbol.asyncIterator] (): PeekableAsyncIterator { if (!this.isOpen()) { const error = newError('Result is already consumed') return { @@ -348,9 +420,9 @@ class Result implements Promise, TResult2 = never>( + then( onFulfilled?: - | ((value: QueryResult) => TResult1 | PromiseLike) + | ((value: T) => TResult1 | PromiseLike) | null, onRejected?: ((reason: any) => TResult2 | PromiseLike) | null ): Promise { @@ -367,7 +439,7 @@ class Result implements Promise( onRejected?: ((reason: any) => TResult | PromiseLike) | null - ): Promise | TResult> { + ): Promise { return this._getOrCreatePromise().catch(onRejected) } @@ -379,7 +451,7 @@ class Result implements Promise void) | null): Promise> { + finally (onfinally?: (() => void) | null): Promise { return this._getOrCreatePromise().finally(onfinally) } @@ -394,7 +466,7 @@ class Result implements Promise): void { + subscribe (observer: GenericResultObserver): void { this._subscribe(observer) .catch(() => {}) } @@ -412,11 +484,11 @@ class Result implements Promise} The result stream observer. */ - _subscribe (observer: ResultObserver, paused: boolean = false): Promise { + _subscribe (observer: GenericResultObserver, paused: boolean = false): Promise { const _observer = this._decorateObserver(observer) return this._streamObserverPromise @@ -442,7 +514,7 @@ class Result implements Promise): GenericResultObserver { const onCompletedOriginal = observer.onCompleted ?? DEFAULT_ON_COMPLETED const onErrorOriginal = observer.onError ?? DEFAULT_ON_ERROR const onKeysOriginal = observer.onKeys ?? DEFAULT_ON_KEYS @@ -537,7 +609,7 @@ class Result implements Promise any | undefined } - function createResolvablePromise (): ResolvablePromise> { + function createResolvablePromise (): ResolvablePromise> { const resolvablePromise: any = {} resolvablePromise.promise = new Promise((resolve, reject) => { resolvablePromise.resolve = resolve @@ -546,13 +618,13 @@ class Result implements Promise | Error + type QueuedResultElementOrError = IteratorResult | Error function isError (elementOrError: QueuedResultElementOrError): elementOrError is Error { return elementOrError instanceof Error } - async function dequeue (): Promise> { + async function dequeue (): Promise> { if (buffer.length > 0) { const element = buffer.shift() ?? newError('Unexpected empty buffer', PROTOCOL_ERROR) onQueueSizeChanged() @@ -567,12 +639,16 @@ class Result implements Promise> | null + resolvable: ResolvablePromise> | null } = { resolvable: null } const observer = { - onNext: (record: Record) => { - observer._push({ done: false, value: record }) + onNext: (record: any) => { + if (this._mapper != null) { + observer._push({ done: false, value: this._mapper(record) }) + } else { + observer._push({ done: false, value: record }) + } }, onCompleted: (summary: ResultSummary) => { observer._push({ done: true, value: summary }) @@ -633,29 +709,27 @@ class Result implements Promise extends GenericResult, QueryResult> { -function captureStacktrace (): string | null { - const error = new Error('') - if (error.stack != null) { - return error.stack.replace(/^Error(\n\r)*/, '') // we don't need the 'Error\n' part, if only it exists - } - return null } /** - * @private - * @param {Error} error The error - * @param {string| null} newStack The newStack - * @returns {void} + * A stream of mapped Objects representing the result of a query as mapped with a Record Object Mapping function. + * Can be consumed eagerly as {@link Promise} resolved with array of records and {@link ResultSummary} + * summary, or rejected with error that contains {@link string} code and {@link string} message. + * Alternatively can be consumed lazily using {@link MappedResult#subscribe} function. + * @access public */ -function replaceStacktrace (error: Error, newStack?: string | null): void { - if (newStack != null) { - // Error.prototype.toString() concatenates error.name and error.message nicely - // then we add the rest of the stack trace - // eslint-disable-next-line @typescript-eslint/no-base-to-string - error.stack = error.toString() + '\n' + newStack - } +class MappedResult extends GenericResult> { + } export default Result -export type { QueryResult, ResultObserver } +export type { MappedQueryResult, QueryResult, ResultObserver, GenericResultObserver } diff --git a/packages/core/test/mapping.highlevel.test.ts b/packages/core/test/mapping.highlevel.test.ts new file mode 100644 index 000000000..32e613609 --- /dev/null +++ b/packages/core/test/mapping.highlevel.test.ts @@ -0,0 +1,177 @@ +/** + * 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 { Date, DateTime, Duration, RecordObjectMapping, Node, Relationship, Rules, rule, Time, Vector } from '../src' +import { as } from '../src/mapping.highlevel' + +describe('#unit Record Object Mapping', () => { + describe('as', () => { + it('should use rules set with register', () => { + class Person { + name + constructor ( + name: String + ) { + this.name = name + } + } + + const personRules: Rules = { + name: rule.asString({ from: 'firstname' }) + } + + const gettable = { + get: (index: string) => { + if (index === 'firstname') { + return 'hi' + } + if (index === 'name') { + return 'hello' + } + return undefined + } + } + + RecordObjectMapping.register(Person, personRules) + // @ts-expect-error + expect(as(gettable, Person).name).toBe('hi') + RecordObjectMapping.clearMappingRegistry() + // @ts-expect-error + expect(as(gettable, Person).name).toBe('hello') + }) + it('should perform typechecks according to rules', () => { + const personRules: Rules = { + name: rule.asNumber({ from: 'firstname' }) + } + + const gettable = { + get: (index: string) => { + if (index === 'firstname') { + return 'hi' + } + if (index === 'name') { + return 'hello' + } + return undefined + } + } + // @ts-expect-error + expect(() => as(gettable, personRules)).toThrow('Object#name should be a number but received string') + }) + it('should be able to read all property types', () => { + class Person { + name + constructor ( + name: String + ) { + this.name = name + } + } + + const personRules: Rules = { + name: rule.asString({ from: 'firstname' }) + } + const rules: Rules = { + number: rule.asNumber(), + string: rule.asString(), + bigint: rule.asBigInt(), + date: rule.asDate(), + dateTime: rule.asDateTime(), + duration: rule.asDuration(), + time: rule.asTime(), + list: rule.asList({ apply: rule.asString() }), + node: rule.asNode({ convert: (node) => node.as(Person, personRules) }), + rel: rule.asRelationship({ convert: (rel) => rel.as(Person, personRules) }), + vec: rule.asVector(), + convertedVec: rule.asVector({ asTypedList: true, from: 'vec' }) + } + + class mapped { + string: string + number: number + bigint: BigInt + date: Date + dateTime: DateTime + duration: Duration + time: Time + list: string[] + node: Person + rel: Person + vec: Vector + convertedVec: Int32Array + } + + const gettable = { + get: (index: string) => { + switch (index) { + case 'string': + return 'hi' + case 'number': + return 1 + case 'bigint': + return BigInt(1) + case 'date': + return new Date(1, 1, 1) + case 'dateTime': + return new DateTime(1, 1, 1, 1, 1, 1, 1, 1) + case 'duration': + return new Duration(1, 1, 1, 1) + case 'time': + return new Time(1, 1, 1, 1, 1) + case 'list': + return ['hello'] + case 'node': + return new Node(1, [], { firstname: 'hi' }) + case 'rel': + return new Relationship(2, 1, 1, 'test', { firstname: 'bye' }) + case 'vec': + return new Vector(Int32Array.from([0, 1, 2])) + default: + return undefined + } + } + } + + // @ts-expect-error + const result = as(gettable, rules) + + expect(result.string).toBe('hi') + + expect(result.number).toBe(1) + + expect(result.bigint).toBe(BigInt(1)) + + expect(result.list[0]).toBe('hello') + + expect(result.date.toString()).toBe('0001-01-01') + + expect(result.dateTime.toString()).toBe('0001-01-01T01:01:01.000000001+00:00:01') + + expect(result.duration.toString()).toBe('P1M1DT1.000000001S') + + expect(result.time.toString()).toBe('01:01:01.000000001+00:00:01') + + expect(result.node.name).toBe('hi') + + expect(result.rel.name).toBe('bye') + + expect(result.vec._typedArray[0]).toBe(0) + + expect(result.convertedVec[2]).toBe(2) + }) + }) +}) diff --git a/packages/core/test/mapping.nameconventions.test.ts b/packages/core/test/mapping.nameconventions.test.ts new file mode 100644 index 000000000..6d4c11a2a --- /dev/null +++ b/packages/core/test/mapping.nameconventions.test.ts @@ -0,0 +1,37 @@ +/** + * 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 { RecordObjectMapping, StandardCase } from '../src' + +describe('#unit getCaseTranslator', () => { + // Each convention has "tokenize" and "encode" functions, so testing each twice is sufficient. + it('camelCase to SCREAMING_SNAKE_CASE', () => { + expect(RecordObjectMapping.getCaseTranslator(StandardCase.CamelCase, 'SCREAMING_SNAKE_CASE')('I_AM_COOL')).toBe('iAmCool') + }) + it('SCREAMING_SNAKE_CASE to PascalCase', () => { + expect(RecordObjectMapping.getCaseTranslator(StandardCase.ScreamingSnakeCase, 'PascalCase')('IAmCool')).toBe('I_AM_COOL') + }) + it('PascalCase to snake_case', () => { + expect(RecordObjectMapping.getCaseTranslator(StandardCase.PascalCase, 'snake_case')('i_am_cool')).toBe('IAmCool') + }) + it('snake_case to kebab-case', () => { + expect(RecordObjectMapping.getCaseTranslator(StandardCase.SnakeCase, 'kebab-case')('i-am-cool')).toBe('i_am_cool') + }) + it('kebab-case to camelCase', () => { + expect(RecordObjectMapping.getCaseTranslator(StandardCase.KebabCase, 'camelCase')('iAmCool')).toBe('i-am-cool') + }) +}) diff --git a/packages/neo4j-driver-deno/lib/core/graph-types.ts b/packages/neo4j-driver-deno/lib/core/graph-types.ts index 9d754f9e2..29cfb1d36 100644 --- a/packages/neo4j-driver-deno/lib/core/graph-types.ts +++ b/packages/neo4j-driver-deno/lib/core/graph-types.ts @@ -16,6 +16,7 @@ */ import Integer from './integer.ts' import { stringify } from './json.ts' +import { Rules, GenericConstructor, as } from './mapping.highlevel.ts' type StandardDate = Date /** @@ -82,6 +83,24 @@ class Node identity.toString()) } + /** + * Hydrates an object of a given type with the properties of the node + * + * @param {GenericConstructor | Rules} constructorOrRules Constructor for the desired type or {@link Rules} for the hydration + * @param {Rules} [rules] {@link Rules} for the hydration + * @returns {T} + * + * @experimental Part of the Record Object Mapping preview feature + */ + as (rules: Rules): T + as (genericConstructor: GenericConstructor): T + as (genericConstructor: GenericConstructor, rules?: Rules): T + as (constructorOrRules: GenericConstructor | Rules, rules?: Rules): T { + return as({ + get: (key) => this.properties[key] + }, constructorOrRules, rules) + } + /** * @ignore */ @@ -199,6 +218,24 @@ class Relationship end.toString()) } + /** + * Hydrates an object of a given type with the properties of the relationship + * + * @param {GenericConstructor | Rules} constructorOrRules Constructor for the desired type or {@link Rules} for the hydration + * @param {Rules} [rules] {@link Rules} for the hydration + * @returns {T} + * + * @experimental Part of the Record Object Mapping preview feature + */ + as (rules: Rules): T + as (genericConstructor: GenericConstructor): T + as (genericConstructor: GenericConstructor, rules?: Rules): T + as (constructorOrRules: GenericConstructor | Rules, rules?: Rules): T { + return as({ + get: (key) => this.properties[key] + }, constructorOrRules, rules) + } + /** * @ignore */ @@ -320,6 +357,24 @@ class UnboundRelationship | Rules} constructorOrRules Constructor for the desired type or {@link Rules} for the hydration + * @param {Rules} [rules] {@link Rules} for the hydration + * @returns {T} + * + * @experimental Part of the Record Object Mapping preview feature + */ + as (rules: Rules): T + as (genericConstructor: GenericConstructor): T + as (genericConstructor: GenericConstructor, rules?: Rules): T + as (constructorOrRules: GenericConstructor | Rules, rules?: Rules): T { + return as({ + get: (key) => this.properties[key] + }, constructorOrRules, rules) + } + /** * @ignore */ diff --git a/packages/neo4j-driver-deno/lib/core/index.ts b/packages/neo4j-driver-deno/lib/core/index.ts index 23daf8c40..f429f418b 100644 --- a/packages/neo4j-driver-deno/lib/core/index.ts +++ b/packages/neo4j-driver-deno/lib/core/index.ts @@ -83,7 +83,7 @@ import NotificationFilter, { notificationFilterMinimumSeverityLevel, NotificationFilterMinimumSeverityLevel } from './notification-filter.ts' -import Result, { QueryResult, ResultObserver } from './result.ts' +import Result, { MappedQueryResult, QueryResult, ResultObserver } from './result.ts' import EagerResult from './result-eager.ts' import ConnectionProvider, { Releasable } from './connection-provider.ts' import Connection from './connection.ts' @@ -103,6 +103,10 @@ import resultTransformers, { ResultTransformer } from './result-transformers.ts' import ClientCertificate, { clientCertificateProviders, ClientCertificateProvider, ClientCertificateProviders, RotatingClientCertificateProvider, resolveCertificateProvider } from './client-certificate.ts' import * as internal from './internal/index.ts' import Vector, { VectorType, vector, isVector } from './vector.ts' +import { StandardCase } from './mapping.nameconventions.ts' +import { Rule, Rules, RecordObjectMapping } from './mapping.highlevel.ts' +import { rule } from './mapping.rulesfactories.ts' +import mappingDecorators from './mapping.decorators.ts' import UnsupportedType, { isUnsupportedType } from './unsupported-type.ts' /** @@ -191,6 +195,10 @@ const forExport = { notificationFilterMinimumSeverityLevel, clientCertificateProviders, resolveCertificateProvider, + rule, + mappingDecorators, + RecordObjectMapping, + StandardCase, UnsupportedType, isUnsupportedType, isVector, @@ -275,9 +283,13 @@ export { resolveCertificateProvider, isVector, Vector, + vector, + rule, + mappingDecorators, + RecordObjectMapping, + StandardCase, UnsupportedType, isUnsupportedType, - vector } export type { @@ -285,6 +297,7 @@ export type { NumberOrInteger, NotificationPosition, QueryResult, + MappedQueryResult, ResultObserver, TransactionConfig, BookmarkManager, @@ -309,7 +322,9 @@ export type { ClientCertificateProvider, ClientCertificateProviders, RotatingClientCertificateProvider, - VectorType + VectorType, + Rule, + Rules } export default forExport diff --git a/packages/neo4j-driver-deno/lib/core/internal/observers.ts b/packages/neo4j-driver-deno/lib/core/internal/observers.ts index cc3223f0a..91a53e206 100644 --- a/packages/neo4j-driver-deno/lib/core/internal/observers.ts +++ b/packages/neo4j-driver-deno/lib/core/internal/observers.ts @@ -16,6 +16,7 @@ */ import Record from '../record.ts' +import { GenericResultObserver } from '../result.ts' import ResultSummary from '../result-summary.ts' interface StreamObserver { @@ -115,7 +116,7 @@ export interface ResultStreamObserver extends StreamObserver { * @param {function(metadata: Object)} observer.onCompleted - Handle stream tail, the summary. * @param {function(error: Object)} observer.onError - Handle errors, should always be provided. */ - subscribe: (observer: ResultObserver) => void + subscribe: (observer: GenericResultObserver) => void } export class CompletedObserver implements ResultStreamObserver { diff --git a/packages/neo4j-driver-deno/lib/core/mapping.decorators.ts b/packages/neo4j-driver-deno/lib/core/mapping.decorators.ts new file mode 100644 index 000000000..e650ddce4 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/mapping.decorators.ts @@ -0,0 +1,219 @@ +import { Rule, rulesRegistry } from './mapping.highlevel.ts' +import { rule } from './mapping.rulesfactories.ts' + +/** + * Class Decorator Factory that enables the Neo4j Driver to map result records to this class + * + * @returns {Function} Class Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function mappedClass () { + return (_: any, context: any) => { + rulesRegistry[context.name] = context.metadata + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a boolean. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function booleanProperty (config?: Rule) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asBoolean(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a string. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function stringProperty (config?: Rule) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asString(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a number. + * + * @param {Rule & { acceptBigInt?: boolean }} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function numberProperty (config?: Rule & { acceptBigInt?: boolean }) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asNumber(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a BigInt. + * + * @param {Rule & { acceptNumber?: boolean }} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function bigIntProperty (config?: Rule & { acceptNumber?: boolean }) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asBigInt(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a Node. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function nodeProperty (config?: Rule) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asNode(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a Relationship. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function relationshipProperty (config?: Rule) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asRelationship(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a Path. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function pathProperty (config?: Rule) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asPath(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a Point. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function pointProperty (config?: Rule) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asPoint(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a Duration. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function durationProperty (config?: Rule & { stringify?: boolean }) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asDuration(config) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a List + * + * @param {Rule & { apply?: Rule }} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function listProperty (config?: Rule & { apply?: Rule }) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asList({ apply: { ...context.metadata[context.name] }, ...config }) + } +} + +/** + * Property Decorator Factory that enables the Neo4j Driver to map this property to a Vector + * + * @param {Rule & { asTypedList?: boolean }} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function vectorProperty (config?: Rule & { asTypedList?: boolean }) { + return (_: any, context: any) => { + context.metadata[context.name] = rule.asVector(config) + } +} + +/** + * Property Decorator Factory that sets this property to optional. + * NOTE: Should be put above a type decorator. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function optionalProperty () { + return (_: any, context: any) => { + context.metadata[context.name] = { optional: true, ...context.metadata[context.name] } + } +} + +/** + * Property Decorator Factory that sets a custom parameter name to map this property to. + * NOTE: Should be put above a type decorator. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function mapPropertyFromName (name: string) { + return (_: any, context: any) => { + context.metadata[context.name] = { from: name, ...context.metadata[context.name] } + } +} + +/** + * Property Decorator Factory that sets the Neo4j Driver to convert this property to another type. + * NOTE: Should be put above a type decorator of type Node or Relationship. + * + * @param {Rule} config + * @returns {Function} Property Decorator + * @experimental Part of the Record Object Mapping preview feature + */ +function convertPropertyToType (type: any) { + return (_: any, context: any) => { + context.metadata[context.name] = { convert: (node: any) => node.as(type), ...context.metadata[context.name] } + } +} + +const forExport = { + booleanProperty, + stringProperty, + numberProperty, + bigIntProperty, + nodeProperty, + relationshipProperty, + pathProperty, + pointProperty, + durationProperty, + listProperty, + vectorProperty, + optionalProperty, + mapPropertyFromName, + convertPropertyToType, + mappedClass +} + +export default forExport diff --git a/packages/neo4j-driver-deno/lib/core/mapping.highlevel.ts b/packages/neo4j-driver-deno/lib/core/mapping.highlevel.ts new file mode 100644 index 000000000..520dc9505 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/mapping.highlevel.ts @@ -0,0 +1,188 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { newError } from './error.ts' +import { nameConventions } from './mapping.nameconventions.ts' + +/** + * constructor function of any class + */ +export type GenericConstructor = new (...args: any[]) => T + +export interface Rule { + optional?: boolean + from?: string + convert?: (recordValue: any, field: string) => any + validate?: (recordValue: any, field: string) => void +} + +export type Rules = Record + +export let rulesRegistry: Record = {} + +let nameMapping: (name: string) => string = (name) => name + +function register (constructor: GenericConstructor, rules: Rules): void { + rulesRegistry[constructor.name] = rules +} + +function clearMappingRegistry (): void { + rulesRegistry = {} +} + +function translateIdentifiers (translationFunction: (name: string) => string): void { + nameMapping = translationFunction +} + +function getCaseTranslator (databaseConvention: string, codeConvention: string): ((name: string) => string) { + const keys = Object.keys(nameConventions) + if (!keys.includes(databaseConvention)) { + throw newError( + `Naming convention ${databaseConvention} is not recognized, + please provide a recognized name convention or manually provide a translation function.` + ) + } + if (!keys.includes(codeConvention)) { + throw newError( + `Naming convention ${codeConvention} is not recognized, + please provide a recognized name convention or manually provide a translation function.` + ) + } + // @ts-expect-error + return (name: string) => nameConventions[databaseConvention].encode(nameConventions[codeConvention].tokenize(name)) +} + +export const RecordObjectMapping = Object.freeze({ + /** + * Clears all registered type mappings from the record object mapping registry. + * @experimental Part of the Record Object Mapping preview feature + */ + clearMappingRegistry, + /** + * Creates a translation function from record key names to object property names, for use with the {@link translateIdentifiers} function + * + * Recognized naming conventions are "camelCase", "PascalCase", "snake_case", "kebab-case", "SCREAMING_SNAKE_CASE" + * + * @experimental Part of the Record Object Mapping preview feature + * @param {string} databaseConvention The naming convention in use in database result Records + * @param {string} codeConvention The naming convention in use in JavaScript object properties + * @returns {function} translation function + */ + getCaseTranslator, + /** + * Registers a set of {@link Rules} to be used by {@link hydrated} for the provided class when no other rules are specified. This registry exists in global memory, not the driver instance. + * + * @example + * // The following code: + * const summary = await driver.executeQuery('CREATE (p:Person{ name: $name }) RETURN p', { name: 'Person1'}, { + * resultTransformer: neo4j.resultTransformers.hydrated(Person, personClassRules) + * }) + * + * can instead be written: + * neo4j.RecordObjectMapping.register(Person, personClassRules) + * + * const summary = await driver.executeQuery('CREATE (p:Person{ name: $name }) RETURN p', { name: 'Person1'}, { + * resultTransformer: neo4j.resultTransformers.hydrated(Person) + * }) + * + * @experimental Part of the Record Object Mapping preview feature + * @param {GenericConstructor} constructor The constructor function of the class to set rules for + * @param {Rules} rules The rules to set for the provided class + */ + register, + /** + * Sets a default name translation from record keys to object properties. + * If providing a function, provide a function that maps FROM your object properties names TO record key names. + * + * The function getCaseTranslator can be used to provide a prewritten translation function between some common naming conventions. + * + * @example + * //if the keys on records from the database are in ALLCAPS + * RecordObjectMapping.translateIdentifiers((name) => name.toUpperCase()) + * + * //if you utilize PacalCase in the database and camelCase in JavaScript code. + * RecordObjectMapping.translateIdentifiers(mapping.getCaseTranslator("PascalCase", "camelCase")) + * + * //if a type has one odd mapping you can override the translation with the rule + * const personRules = { + * firstName: neo4j.rule.asString(), + * bornAt: neo4j.rule.asNumber({ acceptBigInt: true, optional: true }) + * weird_name-property: neo4j.rule.asString({from: 'homeTown'}) + * } + * //These rules can then be used by providing them to a hydratedResultsMapper + * record.as(personRules) + * //or by registering them to the mapping registry + * RecordObjectMapping.register(Person, personRules) + * + * @experimental Part of the Record Object Mapping preview feature + * @param {function} translationFunction A function translating the names of your JS object property names to record key names + */ + translateIdentifiers +}) + +interface Gettable { get: (key: string) => V } + +export function as (gettable: Gettable, constructorOrRules: GenericConstructor | Rules, rules?: Rules): T { + const GenericConstructor = typeof constructorOrRules === 'function' ? constructorOrRules : Object + const theRules = getRules(constructorOrRules, rules) + const visitedKeys: string[] = [] + + const obj = new GenericConstructor() + + for (const [key, rule] of Object.entries(theRules ?? {})) { + visitedKeys.push(key) + _apply(gettable, obj, key, rule) + } + + for (const key of Object.getOwnPropertyNames(obj)) { + if (!visitedKeys.includes(key)) { + _apply(gettable, obj, key, theRules?.[key]) + } + } + + return obj as unknown as T +} + +function _apply (gettable: Gettable, obj: T, key: string, rule?: Rule): void { + const mappedkey = nameMapping(key) + const value = gettable.get(rule?.from ?? mappedkey) + const field = `${obj.constructor.name}#${key}` + const processedValue = valueAs(value, field, rule) + // @ts-expect-error + obj[key] = processedValue ?? obj[key] +} + +export function valueAs (value: unknown, field: string, rule?: Rule): unknown { + if (rule?.optional === true && value == null) { + return value + } + + if (typeof rule?.validate === 'function') { + rule.validate(value, field) + } + + return ((rule?.convert) != null) ? rule.convert(value, field) : value +} +function getRules (constructorOrRules: Rules | GenericConstructor, rules: Rules | undefined): Rules | undefined { + const rulesDefined = typeof constructorOrRules === 'object' ? constructorOrRules : rules + if (rulesDefined != null) { + return rulesDefined + } + return typeof constructorOrRules !== 'object' ? rulesRegistry[constructorOrRules.name] : undefined +} diff --git a/packages/neo4j-driver-deno/lib/core/mapping.nameconventions.ts b/packages/neo4j-driver-deno/lib/core/mapping.nameconventions.ts new file mode 100644 index 000000000..75b2b00cd --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/mapping.nameconventions.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface NameConvention { + tokenize: (name: string) => string[] + encode: (tokens: string[]) => string +} + +export enum StandardCase { + SnakeCase = 'snake_case', + KebabCase = 'kebab-case', + ScreamingSnakeCase = 'SCREAMING_SNAKE_CASE', + PascalCase = 'PascalCase', + CamelCase = 'camelCase' +} + +export const nameConventions = { + snake_case: { + tokenize: (name: string) => name.split('_'), + encode: (tokens: string[]) => tokens.join('_') + }, + 'kebab-case': { + tokenize: (name: string) => name.split('-'), + encode: (tokens: string[]) => tokens.join('-') + }, + PascalCase: { + tokenize: (name: string) => name.split(/(?=[A-Z])/).map((token) => token.toLowerCase()), + encode: (tokens: string[]) => { + let name: string = '' + for (let token of tokens) { + token = token.charAt(0).toUpperCase() + token.slice(1) + name += token + } + return name + } + }, + camelCase: { + tokenize: (name: string) => name.split(/(?=[A-Z])/).map((token) => token.toLowerCase()), + encode: (tokens: string[]) => { + let name: string = '' + for (let [i, token] of tokens.entries()) { + if (i !== 0) { + token = token.charAt(0).toUpperCase() + token.slice(1) + } + name += token + } + return name + } + }, + SCREAMING_SNAKE_CASE: { + tokenize: (name: string) => name.split('_').map((token) => token.toLowerCase()), + encode: (tokens: string[]) => tokens.join('_').toUpperCase() + } +} diff --git a/packages/neo4j-driver-deno/lib/core/mapping.rulesfactories.ts b/packages/neo4j-driver-deno/lib/core/mapping.rulesfactories.ts new file mode 100644 index 000000000..4b2208951 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/mapping.rulesfactories.ts @@ -0,0 +1,405 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Rule, valueAs } from './mapping.highlevel.ts' +import { StandardDate, isNode, isPath, isRelationship, isUnboundRelationship } from './graph-types.ts' +import { isPoint } from './spatial-types.ts' +import { Date, DateTime, Duration, LocalDateTime, LocalTime, Time, isDate, isDateTime, isDuration, isLocalDateTime, isLocalTime, isTime } from './temporal-types.ts' +import Vector from './vector.ts' + +/** + * @property {function(rule: ?Rule)} asString Create a {@link Rule} that validates the value is a String. + * + * @property {function(rule: ?Rule & { acceptBigInt?: boolean })} asNumber Create a {@link Rule} that validates the value is a Number. + * + * @property {function(rule: ?Rule & { acceptNumber?: boolean })} AsBigInt Create a {@link Rule} that validates the value is a BigInt. + * + * @property {function(rule: ?Rule)} asNode Create a {@link Rule} that validates the value is a {@link Node}. + * + * @property {function(rule: ?Rule)} asRelationship Create a {@link Rule} that validates the value is a {@link Relationship}. + * + * @property {function(rule: ?Rule)} asPath Create a {@link Rule} that validates the value is a {@link Path}. + * + * @property {function(rule: ?Rule & { stringify?: boolean })} asDuration Create a {@link Rule} that validates the value is a {@link Duration}. + * + * @property {function(rule: ?Rule & { stringify?: boolean })} asLocalTime Create a {@link Rule} that validates the value is a {@link LocalTime}. + * + * @property {function(rule: ?Rule & { stringify?: boolean })} asLocalDateTime Create a {@link Rule} that validates the value is a {@link LocalDateTime}. + * + * @property {function(rule: ?Rule & { stringify?: boolean })} asTime Create a {@link Rule} that validates the value is a {@link Time}. + * + * @property {function(rule: ?Rule & { stringify?: boolean })} asDateTime Create a {@link Rule} that validates the value is a {@link DateTime}. + * + * @property {function(rule: ?Rule & { stringify?: boolean })} asDate Create a {@link Rule} that validates the value is a {@link Date}. + * + * @property {function(rule: ?Rule)} asPoint Create a {@link Rule} that validates the value is a {@link Point}. + * + * @property {function(rule: ?Rule & { apply?: Rule })} asList Create a {@link Rule} that validates the value is a List. + * + * @property {function(rule: ?Rule & { asTypedList: boolean })} asVector Create a {@link Rule} that validates the value is a List. + * + * @experimental Part of the Record Object Mapping preview feature + */ +export const rule = Object.freeze({ + /** + * Create a {@link Rule} that validates the value is a Boolean. + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asBoolean (rule?: Rule): Rule { + return { + validate: (value, field) => { + if (typeof value !== 'boolean') { + throw new TypeError(`${field} should be a boolean but received ${typeof value}`) + } + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a String. + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asString (rule?: Rule): Rule { + return { + validate: (value, field) => { + if (typeof value !== 'string') { + throw new TypeError(`${field} should be a string but received ${typeof value}`) + } + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Number}. + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule & { acceptBigInt?: boolean }} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asNumber (rule?: Rule & { acceptBigInt?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (typeof value === 'object' && value.low !== undefined && value.high !== undefined && Object.keys(value).length === 2) { + throw new TypeError('Number returned as Object. To use asNumber mapping, set disableLosslessIntegers or useBigInt in driver config object') + } + if (typeof value !== 'number' && (rule?.acceptBigInt !== true || typeof value !== 'bigint')) { + throw new TypeError(`${field} should be a number but received ${typeof value}`) + } + }, + convert: (value: number | bigint) => { + if (typeof value === 'bigint') { + return Number(value) + } + return value + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link BigInt}. + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule & { acceptNumber?: boolean }} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asBigInt (rule?: Rule & { acceptNumber?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (typeof value !== 'bigint' && (rule?.acceptNumber !== true || typeof value !== 'number')) { + throw new TypeError(`${field} should be a bigint but received ${typeof value}`) + } + }, + convert: (value: number | bigint) => { + if (typeof value === 'number') { + return BigInt(value) + } + return value + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Node}. + * + * @example + * const actingJobsRules: Rules = { + * // Converts the person node to a Person object in accordance with provided rules + * person: neo4j.rule.asNode({ + * convert: (node: Node) => node.as(Person, personRules) + * }), + * // Returns the movie node as a Node + * movie: neo4j.rule.asNode({}), + * } + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asNode (rule?: Rule): Rule { + return { + validate: (value: any, field: string) => { + if (!isNode(value)) { + throw new TypeError(`${field} should be a Node but received ${typeof value}`) + } + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Relationship}. + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule. + * @returns {Rule} A new rule for the value + */ + asRelationship (rule?: Rule): Rule { + return { + validate: (value: any, field: string) => { + if (!isRelationship(value)) { + throw new TypeError(`${field} should be a Relationship but received ${typeof value}`) + } + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is an {@link UnboundRelationship} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asUnboundRelationship (rule?: Rule): Rule { + return { + validate: (value: any, field: string) => { + if (!isUnboundRelationship(value)) { + throw new TypeError(`${field} should be a UnboundRelationship but received ${typeof value}`) + } + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Path} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asPath (rule?: Rule): Rule { + return { + validate: (value: any, field: string) => { + if (!isPath(value)) { + throw new TypeError(`${field} should be a Path but received ${typeof value}`) + } + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Point} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asPoint (rule?: Rule): Rule { + return { + validate: (value: any, field: string) => { + if (!isPoint(value)) { + throw new TypeError(`${field} should be a Point but received ${typeof value}`) + } + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Duration} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asDuration (rule?: Rule & { stringify?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (!isDuration(value)) { + throw new TypeError(`${field} should be a Duration but received ${typeof value}`) + } + }, + convert: (value: Duration) => rule?.stringify === true ? value.toString() : value, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link LocalTime} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asLocalTime (rule?: Rule & { stringify?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (!isLocalTime(value)) { + throw new TypeError(`${field} should be a LocalTime but received ${typeof value}`) + } + }, + convert: (value: LocalTime) => rule?.stringify === true ? value.toString() : value, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Time} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asTime (rule?: Rule & { stringify?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (!isTime(value)) { + throw new TypeError(`${field} should be a Time but received ${typeof value}`) + } + }, + convert: (value: Time) => rule?.stringify === true ? value.toString() : value, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Date} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asDate (rule?: Rule & { stringify?: boolean, toStandardDate?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (!isDate(value)) { + throw new TypeError(`${field} should be a Date but received ${typeof value}`) + } + }, + convert: (value: Date) => convertStdDate(value, rule), + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link LocalDateTime} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asLocalDateTime (rule?: Rule & { stringify?: boolean, toStandardDate?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (!isLocalDateTime(value)) { + throw new TypeError(`${field} should be a LocalDateTime but received ${typeof value}`) + } + }, + convert: (value: LocalDateTime) => convertStdDate(value, rule), + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link DateTime} + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asDateTime (rule?: Rule & { stringify?: boolean, toStandardDate?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (!isDateTime(value)) { + throw new TypeError(`${field} should be a DateTime but received ${typeof value}`) + } + }, + convert: (value: DateTime) => convertStdDate(value, rule), + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a List. Optionally taking a rule for hydrating the contained values. + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule & { apply?: Rule }} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asList (rule?: Rule & { apply?: Rule }): Rule { + return { + validate: (value: any, field: string) => { + if (!Array.isArray(value)) { + throw new TypeError(`${field} should be a list but received ${typeof value}`) + } + }, + convert: (list: any[], field: string) => { + if (rule?.apply != null) { + return list.map((value, index) => valueAs(value, `${field}[${index}]`, rule.apply)) + } + return list + }, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a Vector. + * + * @experimental Part of the Record Object Mapping preview feature + * @param {Rule & { asTypedList?: boolean }} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asVector (rule?: Rule & { asTypedList?: boolean }): Rule { + return { + validate: (value: any, field: string) => { + if (!(value instanceof Vector)) { + throw new TypeError(`${field} should be a vector but received ${typeof value}`) + } + }, + convert: (value: Vector) => { + if (rule?.asTypedList === true) { + return value._typedArray + } + return value + }, + ...rule + } + } +}) + +interface ConvertableToStdDateOrStr { toStandardDate: () => StandardDate, toString: () => string } + +function convertStdDate (value: V, rule?: { stringify?: boolean, toStandardDate?: boolean }): string | V | StandardDate { + if (rule != null) { + if (rule.stringify === true) { + return value.toString() + } else if (rule.toStandardDate === true) { + return value.toStandardDate() + } + } + return value +} diff --git a/packages/neo4j-driver-deno/lib/core/record.ts b/packages/neo4j-driver-deno/lib/core/record.ts index f71b09731..9b669c042 100644 --- a/packages/neo4j-driver-deno/lib/core/record.ts +++ b/packages/neo4j-driver-deno/lib/core/record.ts @@ -16,6 +16,7 @@ */ import { newError } from './error.ts' +import { Rules, GenericConstructor, as } from './mapping.highlevel.ts' type RecordShape = { [K in Key]: Value @@ -132,6 +133,20 @@ class Record< return resultArray } + as (rules: Rules): T + as (genericConstructor: GenericConstructor): T + as (genericConstructor: GenericConstructor, rules?: Rules): T + /** + * Maps the record to a provided type and/or according to provided Rules. + * + * @param {GenericConstructor | Rules} constructorOrRules + * @param {Rules} rules + * @returns {T} + */ + as (constructorOrRules: GenericConstructor | Rules, rules?: Rules): T { + return as(this, constructorOrRules, rules) + } + /** * Iterate over results. Each iteration will yield an array * of exactly two items - the key, and the value (in order). diff --git a/packages/neo4j-driver-deno/lib/core/result-transformers.ts b/packages/neo4j-driver-deno/lib/core/result-transformers.ts index 58de26c9e..6a59fbc4f 100644 --- a/packages/neo4j-driver-deno/lib/core/result-transformers.ts +++ b/packages/neo4j-driver-deno/lib/core/result-transformers.ts @@ -22,6 +22,7 @@ import ResultSummary from './result-summary.ts' import { newError } from './error.ts' import { NumberOrInteger } from './graph-types.ts' import Integer from './integer.ts' +import { GenericConstructor, Rules } from './mapping.highlevel.ts' type ResultTransformer = (result: Result) => Promise /** @@ -258,6 +259,46 @@ class ResultTransformers { summary (): ResultTransformer> { return summary } + + hydrated (rules: Rules): ResultTransformer<{ records: T[], summary: ResultSummary }> + hydrated (genericConstructor: GenericConstructor, rules?: Rules): ResultTransformer<{ records: T[], summary: ResultSummary }> + /** + * Creates a {@link ResultTransformer} which maps each record of the result to a hydrated object of a provided type and/or according to provided rules. + * + * @example + * + * class Person { + * constructor (name) { + * this.name = name + * } + * + * const personRules: Rules = { + * name: neo4j.rule.asString() + * } + * + * const summary = await driver.executeQuery('CREATE (p:Person{ name: $name }) RETURN p', { name: 'Person1'}, { + * resultTransformer: neo4j.resultTransformers.hydrated(Person, personClassRules) + * }) + * + * // Alternatively, the rules can be registered in the mapping registry. + * // This registry exists in global memory and will persist even between driver instances. + * + * neo4j.RecordObjectMapping.register(Person, PersonRules) + * + * // after registering the rule the transformer will follow them when mapping to the provided type + * const summary = await driver.executeQuery('CREATE (p:Person{ name: $name }) RETURN p', { name: 'Person1'}, { + * resultTransformer: neo4j.resultTransformers.hydrated(Person) + * }) + * + * // A hydrated can be used without providing or registering Rules beforehand, but in such case the mapping will be done without any type validation + * + * @returns {ResultTransformer>} The result transformer + * @see {@link Driver#executeQuery} + * @experimental Part of the Record Object Mapping preview feature + */ + hydrated (constructorOrRules: GenericConstructor | Rules, rules?: Rules): ResultTransformer<{ records: T[], summary: ResultSummary }> { + return async result => await result.as(constructorOrRules as unknown as GenericConstructor, rules).then() + } } /** diff --git a/packages/neo4j-driver-deno/lib/core/result.ts b/packages/neo4j-driver-deno/lib/core/result.ts index eea7bce30..598242afe 100644 --- a/packages/neo4j-driver-deno/lib/core/result.ts +++ b/packages/neo4j-driver-deno/lib/core/result.ts @@ -24,6 +24,7 @@ import { observer, util, connectionHolder } from './internal/index.ts' import { newError, PROTOCOL_ERROR } from './error.ts' import { NumberOrInteger } from './graph-types.ts' import Integer from './integer.ts' +import { GenericConstructor, Rules } from './mapping.highlevel.ts' const { EMPTY_CONNECTION_HOLDER } = connectionHolder @@ -51,20 +52,28 @@ const DEFAULT_ON_COMPLETED = (summary: ResultSummary): void => {} */ const DEFAULT_ON_KEYS = (keys: string[]): void => {} +interface GenericQueryResult { + records: R[] + summary: ResultSummary +} + /** * The query result is the combination of the {@link ResultSummary} and * the array {@link Record[]} produced by the query */ -interface QueryResult { - records: Array> - summary: ResultSummary -} +interface QueryResult extends GenericQueryResult> {} + +/** + * The query result is the combination of the {@link ResultSummary} and + * an array of mapped objects produced by the query. + */ +interface MappedQueryResult extends GenericQueryResult {} /** * Interface to observe updates on the Result which is being produced. * */ -interface ResultObserver { +interface GenericResultObserver { /** * Receive the keys present on the record whenever this information is available * @@ -76,7 +85,7 @@ interface ResultObserver { * Receive the each record present on the {@link @Result} * @param {Record} record The {@link Record} produced */ - onNext?: (record: Record) => void + onNext?: (record: R) => void /** * Called when the result is fully received @@ -91,29 +100,47 @@ interface ResultObserver { onError?: (error: Error) => void } +interface ResultObserver extends GenericResultObserver> {} + /** * Defines a ResultObserver interface which can be used to enqueue records and dequeue * them until the result is fully received. * @access private */ -interface QueuedResultObserver extends ResultObserver { - dequeue: () => Promise> - dequeueUntilDone: () => Promise> - head: () => Promise> +interface QueuedResultObserver extends GenericResultObserver { + dequeue: () => Promise> + dequeueUntilDone: () => Promise> + head: () => Promise> size: number } +function captureStacktrace (): string | null { + const error = new Error('') + if (error.stack != null) { + return error.stack.replace(/^Error(\n\r)*/, '') // we don't need the 'Error\n' part, if only it exists + } + return null +} + /** - * A stream of {@link Record} representing the result of a query. - * Can be consumed eagerly as {@link Promise} resolved with array of records and {@link ResultSummary} - * summary, or rejected with error that contains {@link string} code and {@link string} message. - * Alternatively can be consumed lazily using {@link Result#subscribe} function. - * @access public + * @private + * @param {Error} error The error + * @param {string| null} newStack The newStack + * @returns {void} */ -class Result implements Promise> { +function replaceStacktrace (error: Error, newStack?: string | null): void { + if (newStack != null) { + // Error.prototype.toString() concatenates error.name and error.message nicely + // then we add the rest of the stack trace + // eslint-disable-next-line @typescript-eslint/no-base-to-string + error.stack = error.toString() + '\n' + newStack + } +} + +class GenericResult> implements Promise { private readonly _stack: string | null private readonly _streamObserverPromise: Promise - private _p: Promise | null + private _p: Promise | null private readonly _query: Query private readonly _parameters: any private readonly _connectionHolder: connectionHolder.ConnectionHolder @@ -121,6 +148,7 @@ class Result implements Promise implements Promise(rules: Rules): MappedResult + as (genericConstructor: GenericConstructor, rules?: Rules): MappedResult + /** + * Maps the records of this result to a provided type and/or according to provided Rules. + * + * NOTE: This modifies the Result object itself, and can not be run on a Result that is already being consumed. + * + * @example + * class Person { + * constructor ( + * public readonly name: string, + * public readonly born?: number + * ) {} + * } + * + * const personRules: Rules = { + * name: rule.asString(), + * born: rule.asNumber({ acceptBigInt: true, optional: true }) + * } + * + * await session.executeRead(async (tx: Transaction) => { + * let txres = tx.run(`MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) + * WHERE id(p) <> id(c) + * RETURN p.name as name, p.born as born`).as(personRules) + * + * @param {GenericConstructor | Rules} constructorOrRules + * @param {Rules} rules + * @returns {MappedResult} + */ + as (constructorOrRules: GenericConstructor | Rules, rules?: Rules): MappedResult { + if (this._p != null) { + throw newError('Cannot call .as() on a Result that is being consumed') + } + // @ts-expect-error + this._mapper = r => r.as(constructorOrRules, rules) + // @ts-expect-error + return this + } + /** * Returns a promise for the field keys. * @@ -215,16 +283,20 @@ class Result implements Promise> { + private _getOrCreatePromise (): Promise { if (this._p == null) { this._p = new Promise((resolve, reject) => { - const records: Array> = [] + const records: R[] = [] const observer = { - onNext: (record: Record) => { - records.push(record) + onNext: (record: R) => { + if (this._mapper != null) { + records.push(this._mapper(record) as unknown as R) + } else { + records.push(record as unknown as R) + } }, onCompleted: (summary: ResultSummary) => { - resolve({ records, summary }) + resolve({ records, summary } as unknown as T) }, onError: (error: Error) => { reject(error) @@ -243,9 +315,9 @@ class Result implements Promise, ResultSummary>} The async iterator for the Results + * @returns {PeekableAsyncIterator} The async iterator for the Results */ - [Symbol.asyncIterator] (): PeekableAsyncIterator, ResultSummary> { + [Symbol.asyncIterator] (): PeekableAsyncIterator { if (!this.isOpen()) { const error = newError('Result is already consumed') return { @@ -348,9 +420,9 @@ class Result implements Promise, TResult2 = never>( + then( onFulfilled?: - | ((value: QueryResult) => TResult1 | PromiseLike) + | ((value: T) => TResult1 | PromiseLike) | null, onRejected?: ((reason: any) => TResult2 | PromiseLike) | null ): Promise { @@ -367,7 +439,7 @@ class Result implements Promise( onRejected?: ((reason: any) => TResult | PromiseLike) | null - ): Promise | TResult> { + ): Promise { return this._getOrCreatePromise().catch(onRejected) } @@ -379,7 +451,7 @@ class Result implements Promise void) | null): Promise> { + finally (onfinally?: (() => void) | null): Promise { return this._getOrCreatePromise().finally(onfinally) } @@ -394,7 +466,7 @@ class Result implements Promise): void { + subscribe (observer: GenericResultObserver): void { this._subscribe(observer) .catch(() => {}) } @@ -412,11 +484,11 @@ class Result implements Promise} The result stream observer. */ - _subscribe (observer: ResultObserver, paused: boolean = false): Promise { + _subscribe (observer: GenericResultObserver, paused: boolean = false): Promise { const _observer = this._decorateObserver(observer) return this._streamObserverPromise @@ -442,7 +514,7 @@ class Result implements Promise): GenericResultObserver { const onCompletedOriginal = observer.onCompleted ?? DEFAULT_ON_COMPLETED const onErrorOriginal = observer.onError ?? DEFAULT_ON_ERROR const onKeysOriginal = observer.onKeys ?? DEFAULT_ON_KEYS @@ -537,7 +609,7 @@ class Result implements Promise any | undefined } - function createResolvablePromise (): ResolvablePromise> { + function createResolvablePromise (): ResolvablePromise> { const resolvablePromise: any = {} resolvablePromise.promise = new Promise((resolve, reject) => { resolvablePromise.resolve = resolve @@ -546,13 +618,13 @@ class Result implements Promise | Error + type QueuedResultElementOrError = IteratorResult | Error function isError (elementOrError: QueuedResultElementOrError): elementOrError is Error { return elementOrError instanceof Error } - async function dequeue (): Promise> { + async function dequeue (): Promise> { if (buffer.length > 0) { const element = buffer.shift() ?? newError('Unexpected empty buffer', PROTOCOL_ERROR) onQueueSizeChanged() @@ -567,12 +639,16 @@ class Result implements Promise> | null + resolvable: ResolvablePromise> | null } = { resolvable: null } const observer = { - onNext: (record: Record) => { - observer._push({ done: false, value: record }) + onNext: (record: any) => { + if (this._mapper != null) { + observer._push({ done: false, value: this._mapper(record) }) + } else { + observer._push({ done: false, value: record }) + } }, onCompleted: (summary: ResultSummary) => { observer._push({ done: true, value: summary }) @@ -633,29 +709,27 @@ class Result implements Promise extends GenericResult, QueryResult> { -function captureStacktrace (): string | null { - const error = new Error('') - if (error.stack != null) { - return error.stack.replace(/^Error(\n\r)*/, '') // we don't need the 'Error\n' part, if only it exists - } - return null } /** - * @private - * @param {Error} error The error - * @param {string| null} newStack The newStack - * @returns {void} + * A stream of mapped Objects representing the result of a query as mapped with a Record Object Mapping function. + * Can be consumed eagerly as {@link Promise} resolved with array of records and {@link ResultSummary} + * summary, or rejected with error that contains {@link string} code and {@link string} message. + * Alternatively can be consumed lazily using {@link MappedResult#subscribe} function. + * @access public */ -function replaceStacktrace (error: Error, newStack?: string | null): void { - if (newStack != null) { - // Error.prototype.toString() concatenates error.name and error.message nicely - // then we add the rest of the stack trace - // eslint-disable-next-line @typescript-eslint/no-base-to-string - error.stack = error.toString() + '\n' + newStack - } +class MappedResult extends GenericResult> { + } export default Result -export type { QueryResult, ResultObserver } +export type { MappedQueryResult, QueryResult, ResultObserver, GenericResultObserver } diff --git a/packages/neo4j-driver-deno/lib/mod.ts b/packages/neo4j-driver-deno/lib/mod.ts index cf250cc1a..d0faac4df 100644 --- a/packages/neo4j-driver-deno/lib/mod.ts +++ b/packages/neo4j-driver-deno/lib/mod.ts @@ -113,7 +113,13 @@ import { resolveCertificateProvider, vector, VectorType, - Vector + Vector, + Rule, + Rules, + rule, + RecordObjectMapping, + StandardCase, + MappedQueryResult } from './core/index.ts' // @deno-types=./bolt-connection/types/index.d.ts import { DirectConnectionProvider, RoutingConnectionProvider } from './bolt-connection/index.js' @@ -212,8 +218,7 @@ function driver ( routing = true break default: - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - throw new Error(`Unknown scheme: ${parsedUrl.scheme ?? 'null'}`) + throw new Error(`Unknown scheme: ${(parsedUrl.scheme as string) ?? 'null'}`) } // Encryption enabled on URL, propagate trust to the config. @@ -265,7 +270,6 @@ function driver ( routingContext: parsedUrl.query }) } else { - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions if (!isEmptyObjectOrNull(parsedUrl.query)) { throw new Error( `Parameters are not supported with none routed scheme. Given URL: '${url}'` @@ -451,7 +455,10 @@ const forExport = { notificationFilterMinimumSeverityLevel, clientCertificateProviders, Vector, - vector + vector, + rule, + RecordObjectMapping, + StandardCase } export { @@ -525,7 +532,10 @@ export { notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, clientCertificateProviders, - vector + vector, + rule, + RecordObjectMapping, + StandardCase } export type { QueryResult, @@ -558,6 +568,9 @@ export type { ClientCertificateProviders, RotatingClientCertificateProvider, Vector, - VectorType + VectorType, + Rule, + Rules, + MappedQueryResult } export default forExport diff --git a/packages/neo4j-driver-lite/src/index.ts b/packages/neo4j-driver-lite/src/index.ts index f3087fab7..d29e1c68a 100644 --- a/packages/neo4j-driver-lite/src/index.ts +++ b/packages/neo4j-driver-lite/src/index.ts @@ -113,7 +113,13 @@ import { resolveCertificateProvider, vector, VectorType, - Vector + Vector, + Rule, + Rules, + rule, + RecordObjectMapping, + StandardCase, + MappedQueryResult } from 'neo4j-driver-core' import { DirectConnectionProvider, RoutingConnectionProvider } from 'neo4j-driver-bolt-connection' @@ -211,8 +217,7 @@ function driver ( routing = true break default: - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - throw new Error(`Unknown scheme: ${parsedUrl.scheme ?? 'null'}`) + throw new Error(`Unknown scheme: ${(parsedUrl.scheme as string) ?? 'null'}`) } // Encryption enabled on URL, propagate trust to the config. @@ -264,7 +269,6 @@ function driver ( routingContext: parsedUrl.query }) } else { - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions if (!isEmptyObjectOrNull(parsedUrl.query)) { throw new Error( `Parameters are not supported with none routed scheme. Given URL: '${url}'` @@ -450,7 +454,10 @@ const forExport = { notificationFilterMinimumSeverityLevel, clientCertificateProviders, Vector, - vector + vector, + rule, + RecordObjectMapping, + StandardCase } export { @@ -524,7 +531,10 @@ export { notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, clientCertificateProviders, - vector + vector, + rule, + RecordObjectMapping, + StandardCase } export type { QueryResult, @@ -557,6 +567,9 @@ export type { ClientCertificateProviders, RotatingClientCertificateProvider, Vector, - VectorType + VectorType, + Rule, + Rules, + MappedQueryResult } export default forExport diff --git a/packages/neo4j-driver/src/index.js b/packages/neo4j-driver/src/index.js index 360e5e69b..48d18aad7 100644 --- a/packages/neo4j-driver/src/index.js +++ b/packages/neo4j-driver/src/index.js @@ -84,7 +84,13 @@ import { isVector, Vector, VectorType, - vector + vector, + Rule, + Rules, + rule, + mappingDecorators, + RecordObjectMapping, + StandardCase } from 'neo4j-driver-core' import { DirectConnectionProvider, @@ -290,7 +296,9 @@ const types = { Time, Integer, Vector, - VectorType + VectorType, + Rule, + Rules } /** @@ -414,7 +422,11 @@ const forExport = { notificationFilterMinimumSeverityLevel, clientCertificateProviders, isVector, - vector + vector, + rule, + mappingDecorators, + RecordObjectMapping, + StandardCase } export { @@ -492,6 +504,12 @@ export { isVector, vector, Vector, - VectorType + VectorType, + Rule, + Rules, + rule, + mappingDecorators, + RecordObjectMapping, + StandardCase } export default forExport diff --git a/packages/neo4j-driver/test/examples.test.js b/packages/neo4j-driver/test/examples.test.js index cfec25faf..a24bc347c 100644 --- a/packages/neo4j-driver/test/examples.test.js +++ b/packages/neo4j-driver/test/examples.test.js @@ -1564,6 +1564,154 @@ describe('#integration examples', () => { }) }) + describe('Record Object Mapping', () => { + it('Record mapping', async () => { + const driver = neo4j.driver(uri, sharedNeo4j.authToken, { disableLosslessIntegers: true }) + + // Setting up the contents of the database for the test. + await driver.executeQuery( + `MERGE (p1:Person {name: $name1, born: $born1}) + MERGE (p2:Person {name: $name2, born: $born2}) + MERGE (m:Movie {title: $title, release: $release, tagline: $tagline}) + MERGE (p1)-[:ACTED_IN {characterName: $char1}]->(m) + MERGE (p2)-[:ACTED_IN {characterName: $char2}]->(m) + `, { + name1: 'Max', + born1: 2024, + name2: 'TBD', + born2: 2030, + release: 2015, + title: 'Neo4j JavaScript Driver', + tagline: 'The best driver for the best database!', + char1: 'current dev', + char2: 'next dev' + }) + + // A few dummy classes for the test. + class ActingJobs { + constructor ( + Person, + Movie, + Costars + ) { + this.Person = Person + this.Movie = Movie + this.Costars = Costars + } + } + + class Movie { + constructor ( + Title, + Released, + Tagline + ) { + this.Title = Title + this.Released = Released + this.Tagline = Tagline + } + } + + class Person { + constructor ( + Name, + Born + ) { + this.Name = Name + this.Born = Born + } + } + + class Role { + constructor ( + Name + ) { + this.Name = Name + } + } + + // Create rules for the hydration of the created types + const personRules = { + Name: neo4j.rule.asString(), + Born: neo4j.rule.asNumber({ acceptBigInt: true, optional: true }) + } + + const movieRules = { + Title: neo4j.rule.asString(), + Released: neo4j.rule.asNumber({ acceptBigInt: true, optional: true, from: 'release' }), + Tagline: neo4j.rule.asString({ optional: true }) + } + + const roleRules = { + Name: neo4j.rule.asString({ from: 'characterName' }) + } + + const actingJobsRules = { + // The following rule unpacks the person node from the result into a Person object. + // The rules for the types don't need to be provided as we will be registering the rules for Person, Role and Movie in the mapping registry + Person: neo4j.rule.asNode({ + convert: (node) => node.as(Person) + }), + Role: neo4j.rule.asRelationship({ + convert: (rel) => rel.as(Role) + }), + Movie: neo4j.rule.asNode({ + convert: (node) => node.as(Movie) + }), + Costars: neo4j.rule.asList({ + apply: neo4j.rule.asNode({ + convert: (node) => node.as(Person) + }) + }) + } + + // Register the rules for the custom types in the mapping registry. + // This is optional, but not doing it means that rules must be provided for every conversion. + neo4j.RecordObjectMapping.register(Role, roleRules) + neo4j.RecordObjectMapping.register(Person, personRules) + neo4j.RecordObjectMapping.register(Movie, movieRules) + neo4j.RecordObjectMapping.register(ActingJobs, actingJobsRules) + + // The code uses PascalCase for property names, while the cypher has camelCase. This issue can be solved with the following line. + neo4j.RecordObjectMapping.translateIdentifiers(neo4j.RecordObjectMapping.getCaseTranslator('camelCase', 'PascalCase')) + + const session = driver.session() + + const res = await session.executeRead(async (tx) => { + const txres = tx.run( + `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) + WHERE id(p) <> id(c) AND p.name = "Max" + RETURN p AS person, r as role, m AS movie, COLLECT(c) AS costars` + ) + return txres.as(ActingJobs) + }) + + expect(res.records[0].Person.Born).toBe(2024) + expect(res.records[0].Role.Name).toBe('current dev') + expect(res.records[0].Costars[0].Name).toBe('TBD') + + session.close() + + // alternatively, conversions can be performed with hydrated + const executeQueryRes = await driver.executeQuery( + `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) + WHERE id(p) <> id(c) AND p.name = "Max" + RETURN p AS person, r as role, m AS movie, COLLECT(c) AS costars`, + {}, + { resultTransformer: neo4j.resultTransformers.hydrated(ActingJobs) } + ) + + expect(executeQueryRes.records[0].Person.Born).toBe(2024) + expect(executeQueryRes.records[0].Role.Name).toBe('current dev') + expect(executeQueryRes.records[0].Costars[0].Name).toBe('TBD') + + // The following line removes all rules from the mapping registry, this is run here just to not interfere with other tests. + neo4j.RecordObjectMapping.clearMappingRegistry() + + driver.close() + }) + }) + it('should control flow by resume and pause the stream', async () => { const driver = driverGlobal const callCostlyApi = async () => {} diff --git a/packages/neo4j-driver/test/record-object-mapping.test.js b/packages/neo4j-driver/test/record-object-mapping.test.js new file mode 100644 index 000000000..f91fe8195 --- /dev/null +++ b/packages/neo4j-driver/test/record-object-mapping.test.js @@ -0,0 +1,309 @@ +/** + * 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 neo4j, { Date, Duration, Time, Point, DateTime, LocalDateTime, LocalTime } from '../src' +import sharedNeo4j from './internal/shared-neo4j' + +describe('#integration record object mapping', () => { + let driverGlobal + + class ActingJobs { + person + movie + } + class Movie { + title + released + tagline + } + + class Person { + name + born + } + + class Role { + name + } + + // Create rules for the hydration of the created types + const personRules = { + name: neo4j.rule.asString(), + born: neo4j.rule.asNumber({ acceptBigInt: true, optional: true }) + } + + const movieRules = { + title: neo4j.rule.asString(), + released: neo4j.rule.asNumber({ acceptBigInt: true, optional: true, from: 'release' }), + tagline: neo4j.rule.asString({ optional: true }) + } + + const roleRules = { + name: neo4j.rule.asString({ from: 'characterName' }) + } + + const actingJobsRules = { + person: neo4j.rule.asNode({ + convert: (node) => node.as(Person) + }), + role: neo4j.rule.asRelationship({ + convert: (rel) => rel.as(Role) + }), + movie: neo4j.rule.asNode({ + convert: (node) => node.as(Movie) + }), + costars: neo4j.rule.asList({ + apply: neo4j.rule.asNode({ + convert: (node) => node.as(Person) + }) + }) + } + + const actingJobsNestedRules = { + person: neo4j.rule.asNode({ + convert: (node) => node.as(Person, personRules) + }), + role: neo4j.rule.asRelationship({ + convert: (rel) => rel.as(Role, roleRules) + }), + movie: neo4j.rule.asNode({ + convert: (node) => node.as(Movie, movieRules) + }), + costars: neo4j.rule.asList({ + apply: neo4j.rule.asNode({ + convert: (node) => node.as(Person, personRules) + }) + }) + } + const uri = `bolt://${sharedNeo4j.hostnameWithBoltPort}` + + beforeAll(() => { + driverGlobal = neo4j.driver(uri, sharedNeo4j.authToken, { disableLosslessIntegers: true }) + }) + + afterAll(async () => { + await driverGlobal.close() + }) + + it('map transaction result with registered mappings', async () => { + if (typeof jasmine === 'undefined') { + return + } + neo4j.RecordObjectMapping.register(Role, roleRules) + neo4j.RecordObjectMapping.register(Person, personRules) + neo4j.RecordObjectMapping.register(Movie, movieRules) + neo4j.RecordObjectMapping.register(ActingJobs, actingJobsRules) + const session = driverGlobal.session() + await session.executeWrite(async (tx) => { + if (typeof jasmine === 'undefined') { + return + } + return await tx.run(`MERGE (p1:Person {name: $name1, born: $born1}) + MERGE (p2:Person {name: $name2, born: $born2}) + MERGE (m:Movie {title: $title, release: 2015, tagline: $tagline}) + MERGE (p1)-[:ACTED_IN {characterName: $char1}]->(m) + MERGE (p2)-[:ACTED_IN {characterName: $char2}]->(m) + `, { + name1: 'Max', + born1: 2024, + name2: 'TBD', + born2: 2030, + title: 'Neo4j JavaScript Driver', + tagline: 'The best driver for the best database!', + char1: 'current dev', + char2: 'next dev' + }) + }) + const res = await session.executeRead(async (tx) => { + const txres = tx.run( + `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) + WHERE id(p) <> id(c) AND p.name = "Max" + RETURN p AS person, r as role, m AS movie, COLLECT(c) AS costars` + ).as(ActingJobs) + return await txres + }) + + expect(res.records[0].person.born).toBe(2024) + expect(res.records[0].role.name).toBe('current dev') + expect(res.records[0].costars[0].name).toBe('TBD') + + neo4j.RecordObjectMapping.clearMappingRegistry() + await session.close() + }) + + it('map transaction result with mapping rules object', async () => { + if (typeof jasmine === 'undefined') { + return + } + const session = driverGlobal.session() + await session.executeWrite(async (tx) => { + return await tx.run(`MERGE (p1:Person {name: $name1, born: $born1}) + MERGE (p2:Person {name: $name2, born: $born2}) + MERGE (m:Movie {title: $title, release: 2015, tagline: $tagline}) + MERGE (p1)-[:ACTED_IN {characterName: $char1}]->(m) + MERGE (p2)-[:ACTED_IN {characterName: $char2}]->(m) + `, { + name1: 'Max', + born1: 2024, + name2: 'TBD', + born2: 2030, + title: 'Neo4j JavaScript Driver', + tagline: 'The best driver for the best database!', + char1: 'current dev', + char2: 'next dev' + }) + }) + const res = await session.executeRead(async (tx) => { + const txres = tx.run( + `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) + WHERE id(p) <> id(c) AND p.name = "Max" + RETURN p AS person, r as role, m AS movie, COLLECT(c) AS costars` + ).as(ActingJobs, actingJobsNestedRules) + return await txres + }) + + expect(res.records[0].person.born).toBe(2024) + expect(res.records[0].role.name).toBe('current dev') + expect(res.records[0].costars[0].name).toBe('TBD') + + await session.close() + }) + + it('map duration', async () => { + const session = driverGlobal.session() + + const res = await session.executeRead(async (tx) => { + const txres = tx.run( + 'RETURN $obj as obj', + { + obj: new Duration(1, 1, 1, 1) + } + ) + return txres.as({ obj: neo4j.rule.asDuration() }) + }) + expect(res.records[0].obj.months).toBe(1) + + session.close() + }) + + it('map local time', async () => { + const session = driverGlobal.session() + + const res = await session.executeRead(async (tx) => { + const txres = tx.run( + 'RETURN $obj as obj', + { + obj: new LocalTime(1, 1, 1, 1) + } + ) + return txres.as({ obj: neo4j.rule.asLocalTime() }) + }) + expect(res.records[0].obj.hour).toBe(1) + + session.close() + }) + + it('map time', async () => { + const session = driverGlobal.session() + + const res = await session.executeRead(async (tx) => { + const txres = tx.run( + 'RETURN $obj as obj', + { + obj: new Time(1, 1, 1, 1, 42) + } + ) + return txres.as({ obj: neo4j.rule.asTime() }) + }) + expect(res.records[0].obj.hour).toBe(1) + expect(res.records[0].obj.timeZoneOffsetSeconds).toBe(42) + + session.close() + }) + + it('map date', async () => { + const session = driverGlobal.session() + + const res = await session.executeRead(async (tx) => { + const txres = tx.run( + 'RETURN $obj as obj', + { + obj: new Date(1, 1, 1, 1) + } + ) + return txres.as({ obj: neo4j.rule.asDate() }) + }) + expect(res.records[0].obj.month).toBe(1) + + session.close() + }) + + it('map datetime', async () => { + const session = driverGlobal.session() + + const res = await session.executeRead(async (tx) => { + const txres = tx.run( + 'RETURN $obj as obj', + { + obj: new DateTime(1, 1, 1, 1, 1, 1, 1, 42) + } + ) + return txres.as({ obj: neo4j.rule.asDateTime() }) + }) + expect(res.records[0].obj.month).toBe(1) + expect(res.records[0].obj.hour).toBe(1) + expect(res.records[0].obj.timeZoneOffsetSeconds).toBe(42) + + session.close() + }) + + it('map local datetime', async () => { + const session = driverGlobal.session() + + const res = await session.executeRead(async (tx) => { + const txres = tx.run( + 'RETURN $obj as obj', + { + obj: new LocalDateTime(1, 1, 1, 1, 1, 1, 1) + } + ) + return txres.as({ obj: neo4j.rule.asLocalDateTime() }) + }) + expect(res.records[0].obj.month).toBe(1) + expect(res.records[0].obj.hour).toBe(1) + + session.close() + }) + + it('map point', async () => { + const session = driverGlobal.session() + + const res = await session.executeRead(async (tx) => { + const txres = tx.run( + 'RETURN $obj as obj', + { + obj: new Point(4326, 32.812493, 42.983216) + } + ) + return txres.as({ obj: neo4j.rule.asPoint() }) + }) + expect(res.records[0].obj.x).toBe(32.812493) + expect(res.records[0].obj.srid).toBe(4326) + + session.close() + }) +}) diff --git a/packages/neo4j-driver/types/index.d.ts b/packages/neo4j-driver/types/index.d.ts index 25ee9441a..d1fccf5ac 100644 --- a/packages/neo4j-driver/types/index.d.ts +++ b/packages/neo4j-driver/types/index.d.ts @@ -99,6 +99,12 @@ import { ClientCertificateProviders, RotatingClientCertificateProvider, clientCertificateProviders, + Rule, + Rules, + rule, + RecordObjectMapping, + StandardCase, + MappedQueryResult, types as coreTypes, isVector } from 'neo4j-driver-core' @@ -409,7 +415,13 @@ export type { ClientCertificate, ClientCertificateProvider, ClientCertificateProviders, - RotatingClientCertificateProvider + RotatingClientCertificateProvider, + Rule, + Rules, + rule, + RecordObjectMapping, + StandardCase, + MappedQueryResult } export default forExport diff --git a/packages/testkit-backend/package.json b/packages/testkit-backend/package.json index 9ce635ab2..9b583cf1a 100644 --- a/packages/testkit-backend/package.json +++ b/packages/testkit-backend/package.json @@ -15,7 +15,8 @@ "start::deno": "deno run --allow-read --allow-write --allow-net --allow-env --allow-sys --allow-run deno/index.ts", "clean": "rm -fr node_modules public/index.js", "prepare": "npm run build", - "node": "node" + "node": "node", + "deno": "deno run --allow-read --allow-write --allow-net --allow-env --allow-sys --allow-run" }, "repository": { "type": "git",