diff --git a/packages/core/src/driver.ts b/packages/core/src/driver.ts index 261d6860c..0126c8fda 100644 --- a/packages/core/src/driver.ts +++ b/packages/core/src/driver.ts @@ -49,6 +49,7 @@ import NotificationFilter from './notification-filter' import HomeDatabaseCache from './internal/homedb-cache' import { cacheKey } from './internal/auth-util' import { ProtocolVersion } from './protocol-version' +import { Rules } from './mapping.highlevel' const DEFAULT_MAX_CONNECTION_LIFETIME: number = 60 * 60 * 1000 // 1 hour @@ -368,6 +369,7 @@ class QueryConfig { transactionConfig?: TransactionConfig auth?: AuthToken signal?: AbortSignal + parameterRules?: Rules /** * @constructor @@ -630,7 +632,7 @@ class Driver { transactionConfig: config.transactionConfig, auth: config.auth, signal: config.signal - }, query, parameters) + }, query, parameters, config.parameterRules) } /** diff --git a/packages/core/src/graph-types.ts b/packages/core/src/graph-types.ts index 9b0aa16b6..16d2c91c4 100644 --- a/packages/core/src/graph-types.ts +++ b/packages/core/src/graph-types.ts @@ -18,6 +18,7 @@ import Integer from './integer' import { stringify } from './json' import { Rules, GenericConstructor, as } from './mapping.highlevel' +export const JSDate = Date type StandardDate = Date /** * @typedef {number | Integer | bigint} NumberOrInteger diff --git a/packages/core/src/internal/query-executor.ts b/packages/core/src/internal/query-executor.ts index 53074ef3e..7cd2327a9 100644 --- a/packages/core/src/internal/query-executor.ts +++ b/packages/core/src/internal/query-executor.ts @@ -21,6 +21,7 @@ import Result from '../result' import ManagedTransaction from '../transaction-managed' import { AuthToken, Query } from '../types' import { TELEMETRY_APIS } from './constants' +import { Rules } from '../mapping.highlevel' type SessionFactory = (config: { database?: string, bookmarkManager?: BookmarkManager, impersonatedUser?: string, auth?: AuthToken }) => Session @@ -42,7 +43,7 @@ export default class QueryExecutor { } - public async execute(config: ExecutionConfig, query: Query, parameters?: any): Promise { + public async execute(config: ExecutionConfig, query: Query, parameters?: any, parameterRules?: Rules): Promise { const session = this._createSession({ database: config.database, bookmarkManager: config.bookmarkManager, @@ -65,7 +66,7 @@ export default class QueryExecutor { : session.executeWrite.bind(session) return await executeInTransaction(async (tx: ManagedTransaction) => { - const result = tx.run(query, parameters) + const result = tx.run(query, parameters, parameterRules) return await config.resultTransformer(result) }, config.transactionConfig) } finally { diff --git a/packages/core/src/internal/util.ts b/packages/core/src/internal/util.ts index 4151fd17d..28c2500ec 100644 --- a/packages/core/src/internal/util.ts +++ b/packages/core/src/internal/util.ts @@ -19,6 +19,7 @@ import Integer, { isInt, int } from '../integer' import { NumberOrInteger } from '../graph-types' import { EncryptionLevel } from '../types' import { stringify } from '../json' +import { Rules, validateAndcleanParameters } from '../mapping.highlevel' const ENCRYPTION_ON: EncryptionLevel = 'ENCRYPTION_ON' const ENCRYPTION_OFF: EncryptionLevel = 'ENCRYPTION_OFF' @@ -62,17 +63,17 @@ function isObject (obj: any): boolean { * @throws TypeError when either given query or parameters are invalid. */ function validateQueryAndParameters ( - query: string | String | { text: string, parameters?: any }, + query: string | String | { text: string, parameters?: any, parameterRules?: Rules }, parameters?: any, - opt?: { skipAsserts: boolean } + opt?: { skipAsserts?: boolean, parameterRules?: Rules } ): { validatedQuery: string params: any } { let validatedQuery: string = '' let params = parameters ?? {} + let parameterRules = opt?.parameterRules const skipAsserts: boolean = opt?.skipAsserts ?? false - if (typeof query === 'string') { validatedQuery = query } else if (query instanceof String) { @@ -80,9 +81,11 @@ function validateQueryAndParameters ( } else if (typeof query === 'object' && query.text != null) { validatedQuery = query.text params = query.parameters ?? {} + parameterRules = query.parameterRules } if (!skipAsserts) { + params = validateAndcleanParameters(params, parameterRules) assertCypherQuery(validatedQuery) assertQueryParameters(params) } diff --git a/packages/core/src/mapping.highlevel.ts b/packages/core/src/mapping.highlevel.ts index f08c85457..633e40059 100644 --- a/packages/core/src/mapping.highlevel.ts +++ b/packages/core/src/mapping.highlevel.ts @@ -29,6 +29,7 @@ export interface Rule { optional?: boolean from?: string convert?: (recordValue: any, field: string) => any + parameterConversion?: (objectValue: any) => any validate?: (recordValue: any, field: string) => void } @@ -36,7 +37,7 @@ export type Rules = Record export let rulesRegistry: Record = {} -let nameMapping: (name: string) => string = (name) => name +export let nameMapping: (name: string) => string = (name) => name function register (constructor: GenericConstructor, rules: Rules): void { rulesRegistry[constructor.name] = rules @@ -179,6 +180,49 @@ export function valueAs (value: unknown, field: string, rule?: Rule): unknown { return ((rule?.convert) != null) ? rule.convert(value, field) : value } + +export function optionalParameterConversion (value: unknown, rule?: Rule): unknown { + if (rule?.optional === true && value == null) { + return value + } + return ((rule?.parameterConversion) != null) ? rule.parameterConversion(value) : value +} + +export function validateAndcleanParameters (params: Record, suppliedRules?: Rules): Record { + const cleanedParams: Record = {} + // @ts-expect-error + const parameterRules = getRules(params.constructor, suppliedRules) + if (parameterRules !== undefined) { + for (const key in parameterRules) { + if (!(parameterRules?.[key]?.optional === true)) { + let param = params[key] + if (parameterRules[key]?.parameterConversion !== undefined) { + param = parameterRules[key].parameterConversion(params[key]) + } + if (param === undefined) { + throw newError('Parameter object did not include required parameter.') + } + if (parameterRules[key].validate != null) { + parameterRules[key].validate(param, key) + // @ts-expect-error + if (parameterRules[key].apply !== undefined) { + for (const entryKey in param) { + // @ts-expect-error + parameterRules[key].apply.validate(param[entryKey], entryKey) + } + } + } + const mappedKey = parameterRules[key].from ?? nameMapping(key) + + cleanedParams[mappedKey] = param + } + } + return cleanedParams + } else { + return params + } +} + function getRules (constructorOrRules: Rules | GenericConstructor, rules: Rules | undefined): Rules | undefined { const rulesDefined = typeof constructorOrRules === 'object' ? constructorOrRules : rules if (rulesDefined != null) { diff --git a/packages/core/src/mapping.rulesfactories.ts b/packages/core/src/mapping.rulesfactories.ts index 84591a8d2..850cda761 100644 --- a/packages/core/src/mapping.rulesfactories.ts +++ b/packages/core/src/mapping.rulesfactories.ts @@ -17,11 +17,11 @@ * limitations under the License. */ -import { Rule, valueAs } from './mapping.highlevel' -import { StandardDate, isNode, isPath, isRelationship, isUnboundRelationship } from './graph-types' +import { Rule, valueAs, optionalParameterConversion } from './mapping.highlevel' +import { JSDate, 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' +import Vector, { vector } from './vector' /** * @property {function(rule: ?Rule)} asString Create a {@link Rule} that validates the value is a String. @@ -250,6 +250,7 @@ export const rule = Object.freeze({ } }, convert: (value: Duration) => rule?.stringify === true ? value.toString() : value, + parameterConversion: rule?.stringify === true ? (str: string) => Duration.fromString(str) : undefined, ...rule } }, @@ -268,6 +269,7 @@ export const rule = Object.freeze({ } }, convert: (value: LocalTime) => rule?.stringify === true ? value.toString() : value, + parameterConversion: rule?.stringify === true ? (str: string) => LocalTime.fromString(str) : undefined, ...rule } }, @@ -286,6 +288,7 @@ export const rule = Object.freeze({ } }, convert: (value: Time) => rule?.stringify === true ? value.toString() : value, + parameterConversion: rule?.stringify === true ? (str: string) => Time.fromString(str) : undefined, ...rule } }, @@ -304,6 +307,7 @@ export const rule = Object.freeze({ } }, convert: (value: Date) => convertStdDate(value, rule), + parameterConversion: rule?.stringify === true ? (str: string) => Date.fromStandardDateLocal(new JSDate(str)) : undefined, ...rule } }, @@ -315,6 +319,13 @@ export const rule = Object.freeze({ * @returns {Rule} A new rule for the value */ asLocalDateTime (rule?: Rule & { stringify?: boolean, toStandardDate?: boolean }): Rule { + let parameterConversion + if (rule?.stringify === true) { + parameterConversion = (str: string) => LocalDateTime.fromString(str) + } + if (rule?.toStandardDate === true) { + parameterConversion = (standardDate: StandardDate) => LocalDateTime.fromStandardDate(standardDate) + } return { validate: (value: any, field: string) => { if (!isLocalDateTime(value)) { @@ -322,6 +333,7 @@ export const rule = Object.freeze({ } }, convert: (value: LocalDateTime) => convertStdDate(value, rule), + parameterConversion, ...rule } }, @@ -333,6 +345,13 @@ export const rule = Object.freeze({ * @returns {Rule} A new rule for the value */ asDateTime (rule?: Rule & { stringify?: boolean, toStandardDate?: boolean }): Rule { + let parameterConversion + if (rule?.stringify === true) { + parameterConversion = (str: string) => DateTime.fromString(str) + } + if (rule?.toStandardDate === true) { + parameterConversion = (standardDate: StandardDate) => DateTime.fromStandardDate(standardDate) + } return { validate: (value: any, field: string) => { if (!isDateTime(value)) { @@ -340,6 +359,7 @@ export const rule = Object.freeze({ } }, convert: (value: DateTime) => convertStdDate(value, rule), + parameterConversion, ...rule } }, @@ -363,6 +383,12 @@ export const rule = Object.freeze({ } return list }, + parameterConversion: (list: any[]) => { + if (rule?.apply != null) { + return list.map((value) => optionalParameterConversion(value, rule.apply)) + } + return list + }, ...rule } }, @@ -386,6 +412,7 @@ export const rule = Object.freeze({ } return value }, + parameterConversion: rule?.asTypedList === true ? (typedArray: Int16Array | Int32Array | BigInt64Array | Float32Array | Float64Array) => vector(typedArray) : undefined, ...rule } } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index df3e6dc1b..cb6922535 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -38,6 +38,7 @@ import { RecordShape } from './record' import NotificationFilter from './notification-filter' import { Logger } from './internal/logger' import { cacheKey } from './internal/auth-util' +import { Rules } from './mapping.highlevel' type ConnectionConsumer = (connection: Connection) => Promise | T type ManagedTransactionWork = (tx: ManagedTransaction) => Promise | T @@ -45,6 +46,7 @@ type ManagedTransactionWork = (tx: ManagedTransaction) => Promise | T interface TransactionConfig { timeout?: NumberOrInteger metadata?: object + parameterRules?: Rules } /** @@ -186,11 +188,13 @@ class Session { run ( query: Query, parameters?: any, - transactionConfig?: TransactionConfig + transactionConfig?: TransactionConfig, + parameterRules?: Rules ): Result { const { validatedQuery, params } = validateQueryAndParameters( query, - parameters + parameters, + { parameterRules } ) const autoCommitTxConfig = (transactionConfig != null) ? new TxConfig(transactionConfig, this._log) diff --git a/packages/core/src/temporal-types.ts b/packages/core/src/temporal-types.ts index e2ae73362..0a06f57b1 100644 --- a/packages/core/src/temporal-types.ts +++ b/packages/core/src/temporal-types.ts @@ -94,6 +94,26 @@ export class Duration { Object.freeze(this) } + /** + * Creates a {@link Duration} from an ISO 8601 string + * + * @param {string} str The string to convert + * @returns {Duration} + */ + static fromString (str: string): Duration { + const matches = String(str).match(/P(?:([-?.,\d]+)Y)?(?:([-?.,\d]+)M)?(?:([-?.,\d]+)W)?(?:([-?.,\d]+)D)?T(?:([-?.,\d]+)H)?(?:([-?.,\d]+)M)?(?:([-?.,\d]+)S)?/) + if (matches !== null) { + const dur = new Duration( + ~~parseInt(matches[1]) * 12 + ~~parseInt(matches[2]), + ~~parseInt(matches[3]) * 7 + ~~parseInt(matches[4]), + ~~parseInt(matches[5]) * 3600 + ~~parseInt(matches[6]) * 60 + ~~parseInt(matches[7]), + Math.round((parseFloat(matches[7]) - parseInt(matches[7])) * 10 ** 9) + ) + return dur + } + throw newError('Duration could not be parsed from string') + } + /** * @ignore */ @@ -203,6 +223,25 @@ export class LocalTime { this.nanosecond ) } + + /** + * Creates a {@link LocalTime} from an ISO 8601 string + * + * @param {string} str The string to convert + * @returns {LocalTime} + */ + static fromString (str: string): LocalTime { + const values = String(str).match(/(\d+):(\d+):(\d+).(\d+)/) + if (values !== null) { + return new LocalTime( + parseInt(values[0]), + parseInt(values[1]), + parseInt(values[2]), + Math.round(parseFloat('0.' + values[3]) * 10 ** 9) + ) + } + throw newError('LocalTime could not be parsed from string') + } } Object.defineProperty( @@ -312,6 +351,35 @@ export class Time { ) + util.timeZoneOffsetToIsoString(this.timeZoneOffsetSeconds) ) } + + /** + * Creates a {@link Time} from an ISO 8601 string. + * + * @param {string} str The string to convert + * @returns {Time} + */ + static fromString (str: string): Time { + const values = String(str).match(/(\d+):(\d+):(\d+)(\.\d+)?(Z|\+|-)?(\d*):?(\d*):?(\d*)/) + if (values !== null) { + if (values[5] === 'Z') { + return new Time( + parseInt(values[1]), + parseInt(values[2]), + parseInt(values[3]), + values[4] !== undefined ? Math.round(parseFloat('0.' + values[4]) * 10 ** 9) : 0, + 0 + ) + } + return new Time( + parseInt(values[1]), + parseInt(values[2]), + parseInt(values[3]), + values[4] !== undefined ? Math.round(parseFloat('0.' + values[4]) * 10 ** 9) : 0, + (values[5] === '+' ? 1 : -1) * (parseInt(values[6]) * 3600 + parseInt(values[7]) * 60 + parseInt(values[8])) + ) + } + throw newError('Time could not be parsed from string') + } } Object.defineProperty( @@ -573,6 +641,28 @@ export class LocalDateTime { this.nanosecond ) } + + /** + * Creates a {@link LocalDateTime} from an ISO 8601 string + * + * @param {string} str The string to convert + * @returns {LocalDateTime} + */ + static fromString (str: string): LocalDateTime { + const values = String(str).match(/(\d+)-(\d+)-(\d+)T(\d+):(\d+):(\d+)(\.\d+)?(Z|\+|-)?(\d*):?(\d*):?(\d*)/) + if (values !== null) { + return new LocalDateTime( + parseInt(values[1]), + parseInt(values[2]), + parseInt(values[3]), + parseInt(values[4]), + parseInt(values[5]), + parseInt(values[6]), + Math.round(parseFloat('0' + values[7]) * 10 ** 9) + ) + } + throw newError('Time could not be parsed from string') + } } Object.defineProperty( @@ -749,6 +839,41 @@ export class DateTime { return localDateTimeStr + timeOffset + timeZoneStr } + /** + * Creates a {@link DateTime} from an ISO 8601 string + * + * @param {string} str The string to convert + * @returns {DateTime} + */ + static fromString (str: string): DateTime { + const values = String(str).match(/(\d+)-(\d+)-(\d+)T(\d+):(\d+):(\d+)(\.\d+)?(Z|\+|-)?(\d*):?(\d*):?(\d*)/) + if (values !== null) { + if (values[8] === 'Z') { + return new DateTime( + parseInt(values[1]), + parseInt(values[2]), + parseInt(values[3]), + parseInt(values[4]), + parseInt(values[5]), + parseInt(values[6]), + Math.round(parseFloat('0' + values[7]) * 10 ** 9), + 0 + ) + } + return new DateTime( + parseInt(values[1]), + parseInt(values[2]), + parseInt(values[3]), + parseInt(values[4]), + parseInt(values[5]), + parseInt(values[6]), + Math.round(parseFloat('0.' + values[7]) * 10 ** 9), + (values[8] === '+' ? 1 : -1) * (parseInt(values[9]) * 3600 + parseInt(values[10]) * 60 + parseInt(values[11])) + ) + } + throw newError('Time could not be parsed from string') + } + /** * @private * @returns {number} diff --git a/packages/core/src/transaction-managed.ts b/packages/core/src/transaction-managed.ts index 25cb0b04b..c25fbdba7 100644 --- a/packages/core/src/transaction-managed.ts +++ b/packages/core/src/transaction-managed.ts @@ -19,8 +19,9 @@ import Result from './result' import Transaction from './transaction' import { Query } from './types' import { RecordShape } from './record' +import { Rules } from './mapping.highlevel' -type Run = (query: Query, parameters?: any) => Result +type Run = (query: Query, parameters?: any, parameterRules?: Rules) => Result /** * Represents a transaction that is managed by the transaction executor. @@ -59,8 +60,8 @@ class ManagedTransaction { * @param {Object} parameters - Map with parameters to use in query * @return {Result} New Result */ - run (query: Query, parameters?: any): Result { - return this._run(query, parameters) + run (query: Query, parameters?: any, parameterRules?: Rules): Result { + return this._run(query, parameters, parameterRules) } } diff --git a/packages/core/src/transaction.ts b/packages/core/src/transaction.ts index a1fbc447c..9cd96b55b 100644 --- a/packages/core/src/transaction.ts +++ b/packages/core/src/transaction.ts @@ -38,6 +38,7 @@ import { Query } from './types' import { RecordShape } from './record' import NotificationFilter from './notification-filter' import { TelemetryApis, TELEMETRY_APIS } from './internal/constants' +import { Rules } from './mapping.highlevel' type NonAutoCommitTelemetryApis = Exclude type NonAutoCommitApiTelemetryConfig = ApiTelemetryConfig @@ -196,10 +197,11 @@ class Transaction { * @param {Object} parameters - Map with parameters to use in query * @return {Result} New Result */ - run (query: Query, parameters?: any): Result { + run (query: Query, parameters?: any, parameterRules?: Rules): Result { const { validatedQuery, params } = validateQueryAndParameters( query, - parameters + parameters, + { parameterRules } ) const result = this._state.run(validatedQuery, params, { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 644353b43..46bd3135a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -16,12 +16,13 @@ */ import ClientCertificate, { ClientCertificateProvider } from './client-certificate' +import { Rules } from './mapping.highlevel' import NotificationFilter from './notification-filter' /** * @private */ -export type Query = string | String | { text: string, parameters?: any } +export type Query = string | String | { text: string, parameters?: any, parameterRules?: Rules } export type EncryptionLevel = 'ENCRYPTION_ON' | 'ENCRYPTION_OFF' diff --git a/packages/core/test/driver.test.ts b/packages/core/test/driver.test.ts index 53177c99e..14c150d5e 100644 --- a/packages/core/test/driver.test.ts +++ b/packages/core/test/driver.test.ts @@ -405,7 +405,7 @@ describe('Driver', () => { routing: routing.WRITE, database: undefined, impersonatedUser: undefined - }, query, params) + }, query, params, undefined) }) it('should be able to destruct the result in records, keys and summary', async () => { @@ -431,7 +431,7 @@ describe('Driver', () => { routing: routing.WRITE, database: undefined, impersonatedUser: undefined - }, query, params) + }, query, params, undefined) }) it('should be able get type-safe Records', async () => { @@ -505,7 +505,7 @@ describe('Driver', () => { await driver?.executeQuery(query, params, config) - expect(spiedExecute).toBeCalledWith(buildExpectedConfig(), query, params) + expect(spiedExecute).toBeCalledWith(buildExpectedConfig(), query, params, undefined) }) it('should handle correct type mapping for a custom result transformer', async () => { diff --git a/packages/core/test/internal/query-executor.test.ts b/packages/core/test/internal/query-executor.test.ts index 7b582f015..04b0c5134 100644 --- a/packages/core/test/internal/query-executor.test.ts +++ b/packages/core/test/internal/query-executor.test.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { bookmarkManager, newError, Result, Session, TransactionConfig } from '../../src' +import { bookmarkManager, newError, Result, Rules, Session, TransactionConfig } from '../../src' import QueryExecutor from '../../src/internal/query-executor' import ManagedTransaction from '../../src/transaction-managed' import ResultStreamObserverMock from '../utils/result-stream-observer.mock' @@ -150,7 +150,7 @@ describe('QueryExecutor', () => { await queryExecutor.execute(baseConfig, 'query', { a: 'b' }) expect(spyOnRun).toHaveBeenCalledTimes(1) - expect(spyOnRun).toHaveBeenCalledWith('query', { a: 'b' }) + expect(spyOnRun).toHaveBeenCalledWith('query', { a: 'b' }, undefined) }) it('should return the transformed result', async () => { @@ -353,7 +353,7 @@ describe('QueryExecutor', () => { await queryExecutor.execute(baseConfig, 'query', { a: 'b' }) expect(spyOnRun).toHaveBeenCalledTimes(1) - expect(spyOnRun).toHaveBeenCalledWith('query', { a: 'b' }) + expect(spyOnRun).toHaveBeenCalledWith('query', { a: 'b' }, undefined) }) it('should return the transformed result', async () => { @@ -522,7 +522,7 @@ describe('QueryExecutor', () => { function createManagedTransaction (): { managedTransaction: ManagedTransaction - spyOnRun: jest.SpyInstance + spyOnRun: jest.SpyInstance resultObservers: ResultStreamObserverMock[] results: Result[] } { @@ -531,7 +531,7 @@ describe('QueryExecutor', () => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const managedTransaction = { - run: (query: string, parameters?: any): Result => { + run: (query: string, parameters?: any, parameterRules?: Rules | undefined): Result => { const resultObserver = new ResultStreamObserverMock() resultObservers.push(resultObserver) const result = new Result( diff --git a/packages/core/test/mapping.highlevel.test.ts b/packages/core/test/mapping.highlevel.test.ts index 32e613609..b44dde5ac 100644 --- a/packages/core/test/mapping.highlevel.test.ts +++ b/packages/core/test/mapping.highlevel.test.ts @@ -18,7 +18,7 @@ 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('Record Object Mapping', () => { describe('as', () => { it('should use rules set with register', () => { class Person { @@ -72,6 +72,7 @@ describe('#unit Record Object Mapping', () => { // @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 diff --git a/packages/core/test/mapping.rulesfactories.test.ts b/packages/core/test/mapping.rulesfactories.test.ts new file mode 100644 index 000000000..5f00c2002 --- /dev/null +++ b/packages/core/test/mapping.rulesfactories.test.ts @@ -0,0 +1,64 @@ +import { Date, DateTime, Duration, Time, Vector } from '../src' +import { rule } from '../src/mapping.rulesfactories' + +describe('Rulesfactories', () => { + it.each([ + ['Number', rule.asNumber(), 1, 1], + ['String', rule.asString(), 'hi', 'hi'], + ['BigInt', rule.asBigInt(), BigInt(1), BigInt(1)], + ['Date', rule.asDate(), new Date(1, 1, 1), new Date(1, 1, 1)], + ['DateTime', rule.asDateTime(), new DateTime(1, 1, 1, 1, 1, 1, 1, 1), new DateTime(1, 1, 1, 1, 1, 1, 1, 1)], + ['Duration', rule.asDuration(), new Duration(1, 1, 1, 1), new Duration(1, 1, 1, 1)], + ['Time', rule.asTime(), new Time(1, 1, 1, 1, 1), new Time(1, 1, 1, 1, 1)], + ['Simple List', rule.asList({ apply: rule.asString() }), ['hello'], ['hello']], + [ + 'Complex List', + rule.asList({ apply: rule.asVector({ asTypedList: true }) }), + [Float32Array.from([0.1, 0.2]), Float32Array.from([0.3, 0.4]), Float32Array.from([0.5, 0.6])], + [new Vector(Float32Array.from([0.1, 0.2])), new Vector(Float32Array.from([0.3, 0.4])), new Vector(Float32Array.from([0.5, 0.6]))] + ], + [ + 'Vector', + rule.asVector(), + new Vector(Int32Array.from([0, 1, 2])), + new Vector(Int32Array.from([0, 1, 2])) + ], + [ + 'Converted Vector', + rule.asVector({ asTypedList: true, from: 'vec' }), + Float32Array.from([0.1, 0.2]), + new Vector(Float32Array.from([0.1, 0.2])) + ] + ])('should be able to map %s as property', (_, rule, param, expected) => { + if (rule.parameterConversion != null) { + param = rule.parameterConversion(param) + } + // @ts-expect-error + rule.validate(param) + expect(param).toEqual(expected) + }) + + it.each([ + ['Date', rule.asDate({ stringify: true }), '2024-01-12'], + ['DateTime', rule.asDateTime({ stringify: true }), '2024-01-01T01:01:01-10:23:03'], + ['Duration', rule.asDuration({ stringify: true }), 'PT1H'], + ['Time', rule.asTime({ stringify: true }), '10:10:10Z'], + ['Simple List', rule.asList({ apply: rule.asString() }), ['hello']], + [ + 'Complex List', + rule.asList({ apply: rule.asVector({ asTypedList: true }) }), + [Float32Array.from([0.1, 0.2]), Float32Array.from([0.3, 0.4]), Float32Array.from([0.5, 0.6])] + ], + [ + 'Converted Vector', + rule.asVector({ asTypedList: true, from: 'vec' }), + Float32Array.from([0.1, 0.2]) + ] + ])('mapping %s as property and back should be lossless', (_, rule, param) => { + if (rule.parameterConversion != null && rule.convert != null) { + expect(rule.convert(rule.parameterConversion(param), 'test convertion')).toEqual(param) + } else { + throw new Error('rule lacks parameterConversion and/or convert') + } + }) +}) diff --git a/packages/core/test/session.test.ts b/packages/core/test/session.test.ts index 3824dbfe3..0a07e7620 100644 --- a/packages/core/test/session.test.ts +++ b/packages/core/test/session.test.ts @@ -665,7 +665,7 @@ describe('session', () => { }) expect(status.functionCalled).toEqual(true) - expect(run).toHaveBeenCalledWith(query, params) + expect(run).toHaveBeenCalledWith(query, params, undefined) }) it('should round up sub milliseconds transaction timeouts', async () => { diff --git a/packages/neo4j-driver-deno/lib/core/driver.ts b/packages/neo4j-driver-deno/lib/core/driver.ts index b4db89fb7..693ca62c7 100644 --- a/packages/neo4j-driver-deno/lib/core/driver.ts +++ b/packages/neo4j-driver-deno/lib/core/driver.ts @@ -49,6 +49,7 @@ import NotificationFilter from './notification-filter.ts' import HomeDatabaseCache from './internal/homedb-cache.ts' import { cacheKey } from './internal/auth-util.ts' import { ProtocolVersion } from './protocol-version.ts' +import { Rules } from './mapping.highlevel.ts' const DEFAULT_MAX_CONNECTION_LIFETIME: number = 60 * 60 * 1000 // 1 hour @@ -368,6 +369,7 @@ class QueryConfig { transactionConfig?: TransactionConfig auth?: AuthToken signal?: AbortSignal + parameterRules?: Rules /** * @constructor @@ -630,7 +632,7 @@ class Driver { transactionConfig: config.transactionConfig, auth: config.auth, signal: config.signal - }, query, parameters) + }, query, parameters, config.parameterRules) } /** diff --git a/packages/neo4j-driver-deno/lib/core/graph-types.ts b/packages/neo4j-driver-deno/lib/core/graph-types.ts index 29cfb1d36..0c3ccd7cf 100644 --- a/packages/neo4j-driver-deno/lib/core/graph-types.ts +++ b/packages/neo4j-driver-deno/lib/core/graph-types.ts @@ -18,6 +18,7 @@ import Integer from './integer.ts' import { stringify } from './json.ts' import { Rules, GenericConstructor, as } from './mapping.highlevel.ts' +export const JSDate = Date type StandardDate = Date /** * @typedef {number | Integer | bigint} NumberOrInteger diff --git a/packages/neo4j-driver-deno/lib/core/internal/query-executor.ts b/packages/neo4j-driver-deno/lib/core/internal/query-executor.ts index d26dc9333..efbf0362f 100644 --- a/packages/neo4j-driver-deno/lib/core/internal/query-executor.ts +++ b/packages/neo4j-driver-deno/lib/core/internal/query-executor.ts @@ -21,6 +21,7 @@ import Result from '../result.ts' import ManagedTransaction from '../transaction-managed.ts' import { AuthToken, Query } from '../types.ts' import { TELEMETRY_APIS } from './constants.ts' +import { Rules } from '../mapping.highlevel.ts' type SessionFactory = (config: { database?: string, bookmarkManager?: BookmarkManager, impersonatedUser?: string, auth?: AuthToken }) => Session @@ -42,7 +43,7 @@ export default class QueryExecutor { } - public async execute(config: ExecutionConfig, query: Query, parameters?: any): Promise { + public async execute(config: ExecutionConfig, query: Query, parameters?: any, parameterRules?: Rules): Promise { const session = this._createSession({ database: config.database, bookmarkManager: config.bookmarkManager, @@ -65,7 +66,7 @@ export default class QueryExecutor { : session.executeWrite.bind(session) return await executeInTransaction(async (tx: ManagedTransaction) => { - const result = tx.run(query, parameters) + const result = tx.run(query, parameters, parameterRules) return await config.resultTransformer(result) }, config.transactionConfig) } finally { diff --git a/packages/neo4j-driver-deno/lib/core/internal/util.ts b/packages/neo4j-driver-deno/lib/core/internal/util.ts index be860bf08..4dc6b3049 100644 --- a/packages/neo4j-driver-deno/lib/core/internal/util.ts +++ b/packages/neo4j-driver-deno/lib/core/internal/util.ts @@ -19,6 +19,7 @@ import Integer, { isInt, int } from '../integer.ts' import { NumberOrInteger } from '../graph-types.ts' import { EncryptionLevel } from '../types.ts' import { stringify } from '../json.ts' +import { Rules, validateAndcleanParameters } from '../mapping.highlevel.ts' const ENCRYPTION_ON: EncryptionLevel = 'ENCRYPTION_ON' const ENCRYPTION_OFF: EncryptionLevel = 'ENCRYPTION_OFF' @@ -62,17 +63,17 @@ function isObject (obj: any): boolean { * @throws TypeError when either given query or parameters are invalid. */ function validateQueryAndParameters ( - query: string | String | { text: string, parameters?: any }, + query: string | String | { text: string, parameters?: any, parameterRules?: Rules }, parameters?: any, - opt?: { skipAsserts: boolean } + opt?: { skipAsserts?: boolean, parameterRules?: Rules } ): { validatedQuery: string params: any } { let validatedQuery: string = '' let params = parameters ?? {} + let parameterRules = opt?.parameterRules const skipAsserts: boolean = opt?.skipAsserts ?? false - if (typeof query === 'string') { validatedQuery = query } else if (query instanceof String) { @@ -80,9 +81,11 @@ function validateQueryAndParameters ( } else if (typeof query === 'object' && query.text != null) { validatedQuery = query.text params = query.parameters ?? {} + parameterRules = query.parameterRules } if (!skipAsserts) { + params = validateAndcleanParameters(params, parameterRules) assertCypherQuery(validatedQuery) assertQueryParameters(params) } diff --git a/packages/neo4j-driver-deno/lib/core/mapping.highlevel.ts b/packages/neo4j-driver-deno/lib/core/mapping.highlevel.ts index 520dc9505..f0e2fcc13 100644 --- a/packages/neo4j-driver-deno/lib/core/mapping.highlevel.ts +++ b/packages/neo4j-driver-deno/lib/core/mapping.highlevel.ts @@ -29,6 +29,7 @@ export interface Rule { optional?: boolean from?: string convert?: (recordValue: any, field: string) => any + parameterConversion?: (objectValue: any) => any validate?: (recordValue: any, field: string) => void } @@ -36,7 +37,7 @@ export type Rules = Record export let rulesRegistry: Record = {} -let nameMapping: (name: string) => string = (name) => name +export let nameMapping: (name: string) => string = (name) => name function register (constructor: GenericConstructor, rules: Rules): void { rulesRegistry[constructor.name] = rules @@ -179,6 +180,49 @@ export function valueAs (value: unknown, field: string, rule?: Rule): unknown { return ((rule?.convert) != null) ? rule.convert(value, field) : value } + +export function optionalParameterConversion (value: unknown, rule?: Rule): unknown { + if (rule?.optional === true && value == null) { + return value + } + return ((rule?.parameterConversion) != null) ? rule.parameterConversion(value) : value +} + +export function validateAndcleanParameters (params: Record, suppliedRules?: Rules): Record { + const cleanedParams: Record = {} + // @ts-expect-error + const parameterRules = getRules(params.constructor, suppliedRules) + if (parameterRules !== undefined) { + for (const key in parameterRules) { + if (!(parameterRules?.[key]?.optional === true)) { + let param = params[key] + if (parameterRules[key]?.parameterConversion !== undefined) { + param = parameterRules[key].parameterConversion(params[key]) + } + if (param === undefined) { + throw newError('Parameter object did not include required parameter.') + } + if (parameterRules[key].validate != null) { + parameterRules[key].validate(param, key) + // @ts-expect-error + if (parameterRules[key].apply !== undefined) { + for (const entryKey in param) { + // @ts-expect-error + parameterRules[key].apply.validate(param[entryKey], entryKey) + } + } + } + const mappedKey = parameterRules[key].from ?? nameMapping(key) + + cleanedParams[mappedKey] = param + } + } + return cleanedParams + } else { + return params + } +} + function getRules (constructorOrRules: Rules | GenericConstructor, rules: Rules | undefined): Rules | undefined { const rulesDefined = typeof constructorOrRules === 'object' ? constructorOrRules : rules if (rulesDefined != null) { diff --git a/packages/neo4j-driver-deno/lib/core/mapping.rulesfactories.ts b/packages/neo4j-driver-deno/lib/core/mapping.rulesfactories.ts index 4b2208951..6933e0b0d 100644 --- a/packages/neo4j-driver-deno/lib/core/mapping.rulesfactories.ts +++ b/packages/neo4j-driver-deno/lib/core/mapping.rulesfactories.ts @@ -17,11 +17,11 @@ * limitations under the License. */ -import { Rule, valueAs } from './mapping.highlevel.ts' -import { StandardDate, isNode, isPath, isRelationship, isUnboundRelationship } from './graph-types.ts' +import { Rule, valueAs, optionalParameterConversion } from './mapping.highlevel.ts' +import { JSDate, 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' +import Vector, { vector } from './vector.ts' /** * @property {function(rule: ?Rule)} asString Create a {@link Rule} that validates the value is a String. @@ -250,6 +250,7 @@ export const rule = Object.freeze({ } }, convert: (value: Duration) => rule?.stringify === true ? value.toString() : value, + parameterConversion: rule?.stringify === true ? (str: string) => Duration.fromString(str) : undefined, ...rule } }, @@ -268,6 +269,7 @@ export const rule = Object.freeze({ } }, convert: (value: LocalTime) => rule?.stringify === true ? value.toString() : value, + parameterConversion: rule?.stringify === true ? (str: string) => LocalTime.fromString(str) : undefined, ...rule } }, @@ -286,6 +288,7 @@ export const rule = Object.freeze({ } }, convert: (value: Time) => rule?.stringify === true ? value.toString() : value, + parameterConversion: rule?.stringify === true ? (str: string) => Time.fromString(str) : undefined, ...rule } }, @@ -304,6 +307,7 @@ export const rule = Object.freeze({ } }, convert: (value: Date) => convertStdDate(value, rule), + parameterConversion: rule?.stringify === true ? (str: string) => Date.fromStandardDateLocal(new JSDate(str)) : undefined, ...rule } }, @@ -315,6 +319,13 @@ export const rule = Object.freeze({ * @returns {Rule} A new rule for the value */ asLocalDateTime (rule?: Rule & { stringify?: boolean, toStandardDate?: boolean }): Rule { + let parameterConversion + if (rule?.stringify === true) { + parameterConversion = (str: string) => LocalDateTime.fromString(str) + } + if (rule?.toStandardDate === true) { + parameterConversion = (standardDate: StandardDate) => LocalDateTime.fromStandardDate(standardDate) + } return { validate: (value: any, field: string) => { if (!isLocalDateTime(value)) { @@ -322,6 +333,7 @@ export const rule = Object.freeze({ } }, convert: (value: LocalDateTime) => convertStdDate(value, rule), + parameterConversion, ...rule } }, @@ -333,6 +345,13 @@ export const rule = Object.freeze({ * @returns {Rule} A new rule for the value */ asDateTime (rule?: Rule & { stringify?: boolean, toStandardDate?: boolean }): Rule { + let parameterConversion + if (rule?.stringify === true) { + parameterConversion = (str: string) => DateTime.fromString(str) + } + if (rule?.toStandardDate === true) { + parameterConversion = (standardDate: StandardDate) => DateTime.fromStandardDate(standardDate) + } return { validate: (value: any, field: string) => { if (!isDateTime(value)) { @@ -340,6 +359,7 @@ export const rule = Object.freeze({ } }, convert: (value: DateTime) => convertStdDate(value, rule), + parameterConversion, ...rule } }, @@ -363,6 +383,12 @@ export const rule = Object.freeze({ } return list }, + parameterConversion: (list: any[]) => { + if (rule?.apply != null) { + return list.map((value) => optionalParameterConversion(value, rule.apply)) + } + return list + }, ...rule } }, @@ -386,6 +412,7 @@ export const rule = Object.freeze({ } return value }, + parameterConversion: rule?.asTypedList === true ? (typedArray: Int16Array | Int32Array | BigInt64Array | Float32Array | Float64Array) => vector(typedArray) : undefined, ...rule } } diff --git a/packages/neo4j-driver-deno/lib/core/session.ts b/packages/neo4j-driver-deno/lib/core/session.ts index 1af4f8dea..3e84366ca 100644 --- a/packages/neo4j-driver-deno/lib/core/session.ts +++ b/packages/neo4j-driver-deno/lib/core/session.ts @@ -38,6 +38,7 @@ import { RecordShape } from './record.ts' import NotificationFilter from './notification-filter.ts' import { Logger } from './internal/logger.ts' import { cacheKey } from './internal/auth-util.ts' +import { Rules } from './mapping.highlevel.ts' type ConnectionConsumer = (connection: Connection) => Promise | T type ManagedTransactionWork = (tx: ManagedTransaction) => Promise | T @@ -45,6 +46,7 @@ type ManagedTransactionWork = (tx: ManagedTransaction) => Promise | T interface TransactionConfig { timeout?: NumberOrInteger metadata?: object + parameterRules?: Rules } /** @@ -186,11 +188,13 @@ class Session { run ( query: Query, parameters?: any, - transactionConfig?: TransactionConfig + transactionConfig?: TransactionConfig, + parameterRules?: Rules ): Result { const { validatedQuery, params } = validateQueryAndParameters( query, - parameters + parameters, + { parameterRules } ) const autoCommitTxConfig = (transactionConfig != null) ? new TxConfig(transactionConfig, this._log) diff --git a/packages/neo4j-driver-deno/lib/core/temporal-types.ts b/packages/neo4j-driver-deno/lib/core/temporal-types.ts index 93b289767..abf9d1bd9 100644 --- a/packages/neo4j-driver-deno/lib/core/temporal-types.ts +++ b/packages/neo4j-driver-deno/lib/core/temporal-types.ts @@ -94,6 +94,26 @@ export class Duration { Object.freeze(this) } + /** + * Creates a {@link Duration} from an ISO 8601 string + * + * @param {string} str The string to convert + * @returns {Duration} + */ + static fromString (str: string): Duration { + const matches = String(str).match(/P(?:([-?.,\d]+)Y)?(?:([-?.,\d]+)M)?(?:([-?.,\d]+)W)?(?:([-?.,\d]+)D)?T(?:([-?.,\d]+)H)?(?:([-?.,\d]+)M)?(?:([-?.,\d]+)S)?/) + if (matches !== null) { + const dur = new Duration( + ~~parseInt(matches[1]) * 12 + ~~parseInt(matches[2]), + ~~parseInt(matches[3]) * 7 + ~~parseInt(matches[4]), + ~~parseInt(matches[5]) * 3600 + ~~parseInt(matches[6]) * 60 + ~~parseInt(matches[7]), + Math.round((parseFloat(matches[7]) - parseInt(matches[7])) * 10 ** 9) + ) + return dur + } + throw newError('Duration could not be parsed from string') + } + /** * @ignore */ @@ -203,6 +223,25 @@ export class LocalTime { this.nanosecond ) } + + /** + * Creates a {@link LocalTime} from an ISO 8601 string + * + * @param {string} str The string to convert + * @returns {LocalTime} + */ + static fromString (str: string): LocalTime { + const values = String(str).match(/(\d+):(\d+):(\d+).(\d+)/) + if (values !== null) { + return new LocalTime( + parseInt(values[0]), + parseInt(values[1]), + parseInt(values[2]), + Math.round(parseFloat('0.' + values[3]) * 10 ** 9) + ) + } + throw newError('LocalTime could not be parsed from string') + } } Object.defineProperty( @@ -312,6 +351,35 @@ export class Time { ) + util.timeZoneOffsetToIsoString(this.timeZoneOffsetSeconds) ) } + + /** + * Creates a {@link Time} from an ISO 8601 string. + * + * @param {string} str The string to convert + * @returns {Time} + */ + static fromString (str: string): Time { + const values = String(str).match(/(\d+):(\d+):(\d+)(\.\d+)?(Z|\+|-)?(\d*):?(\d*):?(\d*)/) + if (values !== null) { + if (values[5] === 'Z') { + return new Time( + parseInt(values[1]), + parseInt(values[2]), + parseInt(values[3]), + values[4] !== undefined ? Math.round(parseFloat('0.' + values[4]) * 10 ** 9) : 0, + 0 + ) + } + return new Time( + parseInt(values[1]), + parseInt(values[2]), + parseInt(values[3]), + values[4] !== undefined ? Math.round(parseFloat('0.' + values[4]) * 10 ** 9) : 0, + (values[5] === '+' ? 1 : -1) * (parseInt(values[6]) * 3600 + parseInt(values[7]) * 60 + parseInt(values[8])) + ) + } + throw newError('Time could not be parsed from string') + } } Object.defineProperty( @@ -573,6 +641,28 @@ export class LocalDateTime { this.nanosecond ) } + + /** + * Creates a {@link LocalDateTime} from an ISO 8601 string + * + * @param {string} str The string to convert + * @returns {LocalDateTime} + */ + static fromString (str: string): LocalDateTime { + const values = String(str).match(/(\d+)-(\d+)-(\d+)T(\d+):(\d+):(\d+)(\.\d+)?(Z|\+|-)?(\d*):?(\d*):?(\d*)/) + if (values !== null) { + return new LocalDateTime( + parseInt(values[1]), + parseInt(values[2]), + parseInt(values[3]), + parseInt(values[4]), + parseInt(values[5]), + parseInt(values[6]), + Math.round(parseFloat('0' + values[7]) * 10 ** 9) + ) + } + throw newError('Time could not be parsed from string') + } } Object.defineProperty( @@ -749,6 +839,41 @@ export class DateTime { return localDateTimeStr + timeOffset + timeZoneStr } + /** + * Creates a {@link DateTime} from an ISO 8601 string + * + * @param {string} str The string to convert + * @returns {DateTime} + */ + static fromString (str: string): DateTime { + const values = String(str).match(/(\d+)-(\d+)-(\d+)T(\d+):(\d+):(\d+)(\.\d+)?(Z|\+|-)?(\d*):?(\d*):?(\d*)/) + if (values !== null) { + if (values[8] === 'Z') { + return new DateTime( + parseInt(values[1]), + parseInt(values[2]), + parseInt(values[3]), + parseInt(values[4]), + parseInt(values[5]), + parseInt(values[6]), + Math.round(parseFloat('0' + values[7]) * 10 ** 9), + 0 + ) + } + return new DateTime( + parseInt(values[1]), + parseInt(values[2]), + parseInt(values[3]), + parseInt(values[4]), + parseInt(values[5]), + parseInt(values[6]), + Math.round(parseFloat('0.' + values[7]) * 10 ** 9), + (values[8] === '+' ? 1 : -1) * (parseInt(values[9]) * 3600 + parseInt(values[10]) * 60 + parseInt(values[11])) + ) + } + throw newError('Time could not be parsed from string') + } + /** * @private * @returns {number} diff --git a/packages/neo4j-driver-deno/lib/core/transaction-managed.ts b/packages/neo4j-driver-deno/lib/core/transaction-managed.ts index dbce04ad4..ab9e7a546 100644 --- a/packages/neo4j-driver-deno/lib/core/transaction-managed.ts +++ b/packages/neo4j-driver-deno/lib/core/transaction-managed.ts @@ -19,8 +19,9 @@ import Result from './result.ts' import Transaction from './transaction.ts' import { Query } from './types.ts' import { RecordShape } from './record.ts' +import { Rules } from './mapping.highlevel.ts' -type Run = (query: Query, parameters?: any) => Result +type Run = (query: Query, parameters?: any, parameterRules?: Rules) => Result /** * Represents a transaction that is managed by the transaction executor. @@ -59,8 +60,8 @@ class ManagedTransaction { * @param {Object} parameters - Map with parameters to use in query * @return {Result} New Result */ - run (query: Query, parameters?: any): Result { - return this._run(query, parameters) + run (query: Query, parameters?: any, parameterRules?: Rules): Result { + return this._run(query, parameters, parameterRules) } } diff --git a/packages/neo4j-driver-deno/lib/core/transaction.ts b/packages/neo4j-driver-deno/lib/core/transaction.ts index 39f426b7d..002458b2c 100644 --- a/packages/neo4j-driver-deno/lib/core/transaction.ts +++ b/packages/neo4j-driver-deno/lib/core/transaction.ts @@ -38,6 +38,7 @@ import { Query } from './types.ts' import { RecordShape } from './record.ts' import NotificationFilter from './notification-filter.ts' import { TelemetryApis, TELEMETRY_APIS } from './internal/constants.ts' +import { Rules } from './mapping.highlevel.ts' type NonAutoCommitTelemetryApis = Exclude type NonAutoCommitApiTelemetryConfig = ApiTelemetryConfig @@ -196,10 +197,11 @@ class Transaction { * @param {Object} parameters - Map with parameters to use in query * @return {Result} New Result */ - run (query: Query, parameters?: any): Result { + run (query: Query, parameters?: any, parameterRules?: Rules): Result { const { validatedQuery, params } = validateQueryAndParameters( query, - parameters + parameters, + { parameterRules } ) const result = this._state.run(validatedQuery, params, { diff --git a/packages/neo4j-driver-deno/lib/core/types.ts b/packages/neo4j-driver-deno/lib/core/types.ts index 7cde8ad6f..87dd0f68c 100644 --- a/packages/neo4j-driver-deno/lib/core/types.ts +++ b/packages/neo4j-driver-deno/lib/core/types.ts @@ -16,12 +16,13 @@ */ import ClientCertificate, { ClientCertificateProvider } from './client-certificate.ts' +import { Rules } from './mapping.highlevel.ts' import NotificationFilter from './notification-filter.ts' /** * @private */ -export type Query = string | String | { text: string, parameters?: any } +export type Query = string | String | { text: string, parameters?: any, parameterRules?: Rules } export type EncryptionLevel = 'ENCRYPTION_ON' | 'ENCRYPTION_OFF' diff --git a/packages/neo4j-driver/test/bolt-v3.test.js b/packages/neo4j-driver/test/bolt-v3.test.js index 5d3ce1d6d..7eeb34f81 100644 --- a/packages/neo4j-driver/test/bolt-v3.test.js +++ b/packages/neo4j-driver/test/bolt-v3.test.js @@ -209,8 +209,7 @@ describe('#integration Bolt V3 API', () => { try { await tx.run( 'MATCH (n:Node) SET n.prop = $newValue', - { newValue: 2 }, - { timeout: 1 } + { newValue: 2 } ) } catch (e) { // ClientError on 4.1 and later diff --git a/packages/neo4j-driver/test/record-object-mapping.test.js b/packages/neo4j-driver/test/record-object-mapping.test.js index f91fe8195..cb16a7987 100644 --- a/packages/neo4j-driver/test/record-object-mapping.test.js +++ b/packages/neo4j-driver/test/record-object-mapping.test.js @@ -306,4 +306,36 @@ describe('#integration record object mapping', () => { session.close() }) + + it('map input', async () => { + const session = driverGlobal.session() + + const rules = { + number: neo4j.rule.asNumber(), + string: neo4j.rule.asString(), + bigint: neo4j.rule.asBigInt(), + date: neo4j.rule.asDate(), + dateTime: neo4j.rule.asDateTime(), + duration: neo4j.rule.asDuration(), + time: neo4j.rule.asTime({ from: 'heyaaaa' }), + list: neo4j.rule.asList({ apply: neo4j.rule.asString() }) + } + + const obj = { + string: 'hi', + number: 1, + bigint: BigInt(1), + date: new neo4j.Date(1, 1, 1), + dateTime: new neo4j.DateTime(1, 1, 1, 1, 1, 1, 1, 1), + duration: new neo4j.Duration(1, 1, 1, 1), + time: new neo4j.Time(1, 1, 1, 1, 1), + list: ['hi'] + } + + neo4j.RecordObjectMapping.translateIdentifiers(neo4j.RecordObjectMapping.getCaseTranslator('snake_case', 'camelCase')) + + const res = await session.run('MERGE (n {string: $string, number: $number, bigint: $bigint, date: $date, date_time: $date_time, duration: $duration, time: $heyaaaa, list: $list}) RETURN n', obj, {}, rules) + + expect(res.records[0].get('n').properties.date_time).toEqual(new neo4j.DateTime(1, 1, 1, 1, 1, 1, 1, 1)) + }) })