diff --git a/packages/core/src/graph-types.ts b/packages/core/src/graph-types.ts index b1f025c21..1001d0486 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,22 @@ class Node identity.toString()) } + /** + * Hydrates an object of a given type with the properties of the node + * + * @param {GenericConstructor | Rules} constructorOrRules Contructor for the desired type or {@link Rules} for the hydration + * @param {Rules} [rules] {@link Rules} for the hydration + * @returns {T} + */ + 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 +216,22 @@ class Relationship end.toString()) } + /** + * Hydrates an object of a given type with the properties of the relationship + * + * @param {GenericConstructor | Rules} constructorOrRules Contructor for the desired type or {@link Rules} for the hydration + * @param {Rules} [rules] {@link Rules} for the hydration + * @returns {T} + */ + 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 +353,22 @@ class UnboundRelationship | Rules} constructorOrRules Contructor for the desired type or {@link Rules} for the hydration + * @param {Rules} [rules] {@link Rules} for the hydration + * @returns {T} + */ + 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 5c3871b9e..83e5d52e2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -101,6 +101,8 @@ import * as json from './json' 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 { Rule, Rules, mapping } from './mapping.highlevel' +import { RulesFactories } from './mapping.rulesfactories' /** * Object containing string constants representing predefined {@link Neo4jError} codes. @@ -186,7 +188,9 @@ const forExport = { notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, clientCertificateProviders, - resolveCertificateProvider + resolveCertificateProvider, + RulesFactories, + mapping } export { @@ -263,7 +267,9 @@ export { notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, clientCertificateProviders, - resolveCertificateProvider + resolveCertificateProvider, + RulesFactories, + mapping } export type { @@ -294,7 +300,9 @@ export type { ClientCertificate, ClientCertificateProvider, ClientCertificateProviders, - RotatingClientCertificateProvider + RotatingClientCertificateProvider, + Rule, + Rules } 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..12725a881 --- /dev/null +++ b/packages/core/src/mapping.highlevel.ts @@ -0,0 +1,142 @@ +/** + * 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 { NameConvention, 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 + +interface nameMapper { + from: NameConvention | undefined + to: NameConvention | undefined +} + +const rulesRegistry: Record = {} + +const nameMapping: nameMapper = { + from: undefined, + to: undefined +} + +/** + * Registers a set of {@link Rules} to be used by {@link hydratedResultTransformer} 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.hydratedResultTransformer(Person, personClassRules) + * }) + * + * can instead be written: + * neo4j.mapping.register(Person, personClassRules) + * + * const summary = await driver.executeQuery('CREATE (p:Person{ name: $name }) RETURN p', { name: 'Person1'}, { + * resultTransformer: neo4j.resultTransformers.hydratedResultTransformer(Person) + * }) + * + * + * @param {GenericConstructor} constructor The constructor function of the class to set rules for + * @param {Rules} rules The rules to set for the provided class + */ +export function register (constructor: GenericConstructor, rules: Rules): void { + rulesRegistry[constructor.toString()] = rules +} + +export function setDatabaseNameMapping (newMapping: NameConvention): void { + nameMapping.from = newMapping +} + +export function setCodeNameMapping (newMapping: NameConvention): void { + nameMapping.to = newMapping +} + +export const mapping = { + register, + setDatabaseNameMapping, + setCodeNameMapping, + nameConventions +} + +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 vistedKeys: string[] = [] + + const obj = new GenericConstructor() + + for (const [key, rule] of Object.entries(theRules ?? {})) { + vistedKeys.push(key) + if (nameMapping.from !== undefined && nameMapping.to !== undefined) { + _apply(gettable, obj, nameMapping.from.encode(nameMapping.to.tokenize(key)), rule) + } else { + _apply(gettable, obj, key, rule) + } + } + + for (const key of Object.getOwnPropertyNames(obj)) { + const mappedkey = (nameMapping.from !== undefined && nameMapping.to !== undefined) ? nameMapping.to.encode(nameMapping.from.tokenize(key)) : key + if (!vistedKeys.includes(mappedkey)) { + _apply(gettable, obj, key, theRules?.[mappedkey]) + } + } + + return obj as unknown as T +} + +function _apply (gettable: Gettable, obj: T, key: string, rule?: Rule): void { + const value = gettable.get(rule?.from ?? key) + const field = `${obj.constructor.name}#${key}` + const processedValue = valueAs(value, field, rule) + const mappedkey = (nameMapping.from !== undefined && nameMapping.to !== undefined) ? nameMapping.to.encode(nameMapping.from.tokenize(key)) : key + // @ts-expect-error + obj[mappedkey] = 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.toString()] : undefined +} diff --git a/packages/core/src/mapping.nameconventions.ts b/packages/core/src/mapping.nameconventions.ts new file mode 100644 index 000000000..73c01aad8 --- /dev/null +++ b/packages/core/src/mapping.nameconventions.ts @@ -0,0 +1,43 @@ +export interface NameConvention { + tokenize: (name: string) => string[] + encode: (tokens: string[]) => string +} + +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 + } + }, + SNAKE_CAPS: { + 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..f63ff44a8 --- /dev/null +++ b/packages/core/src/mapping.rulesfactories.ts @@ -0,0 +1,345 @@ +/** + * 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' + +/** + * @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 & { toString?: boolean })} asDuration Create a {@link Rule} that validates the value is a {@link Duration}. + * + * @property {function(rule: ?Rule & { toString?: boolean })} asLocalTime Create a {@link Rule} that validates the value is a {@link LocalTime}. + * + * @property {function(rule: ?Rule & { toString?: boolean })} asLocalDateTime Create a {@link Rule} that validates the value is a {@link LocalDateTime}. + * + * @property {function(rule: ?Rule & { toString?: boolean })} asTime Create a {@link Rule} that validates the value is a {@link Time}. + * + * @property {function(rule: ?Rule & { toString?: boolean })} asDateTime Create a {@link Rule} that validates the value is a {@link DateTime}. + * + * @property {function(rule: ?Rule & { toString?: 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. + */ +export const RulesFactories = Object.freeze({ + /** + * Create a {@link Rule} that validates the value is a String. + * + * @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}. + * + * @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 to true 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}. + * + * @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.RulesFactories.asNode({ + * convert: (node: Node) => node.as(Person, personRules) + * }), + * // Returns the movie node as a Node + * movie: neo4j.RulesFactories.asNode({}), + * } + * + * @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}. + * + * @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} + * + * @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} + * + * @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} + * + * @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} + * + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asDuration (rule?: Rule & { toString?: 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?.toString === true ? value.toString() : value, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link LocalTime} + * + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asLocalTime (rule?: Rule & { toString?: 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?.toString === true ? value.toString() : value, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Time} + * + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asTime (rule?: Rule & { toString?: 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?.toString === true ? value.toString() : value, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Date} + * + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asDate (rule?: Rule & { toString?: 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} + * + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asLocalDateTime (rule?: Rule & { toString?: 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} + * + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asDateTime (rule?: Rule & { toString?: 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. + * + * @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 + } + } +}) + +interface ConvertableToStdDateOrStr { toStandardDate: () => StandardDate, toString: () => string } + +function convertStdDate (value: V, rule?: { toString?: boolean, toStandardDate?: boolean }): string | V | StandardDate { + if (rule != null) { + if (rule.toString === 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..9231fbf45 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,13 @@ class Record< return resultArray } + as (rules: Rules): T + as (genericConstructor: GenericConstructor): T + as (genericConstructor: GenericConstructor, rules?: Rules): 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 1cf22d701..1c7cdde35 100644 --- a/packages/core/src/result-transformers.ts +++ b/packages/core/src/result-transformers.ts @@ -20,6 +20,7 @@ import Result from './result' import EagerResult from './result-eager' import ResultSummary from './result-summary' import { newError } from './error' +import { GenericConstructor, Rules } from './mapping.highlevel' import { NumberOrInteger } from './graph-types' import Integer from './integer' @@ -266,6 +267,46 @@ class ResultTransformers { summary (): ResultTransformer> { return summary } + + hydratedResultTransformer (rules: Rules): ResultTransformer<{ records: T[], summary: ResultSummary }> + hydratedResultTransformer (genericConstructor: GenericConstructor, rules?: Rules): ResultTransformer<{ records: T[], summary: ResultSummary }> + /** + * Creates a {@link ResultTransformer} which maps the result to a hydrated object + * + * @example + * + * class Person { + * constructor (name) { + * this.name = name + * } + * + * const personRules: Rules = { + * name: neo4j.RulesFactories.asString() + * } + * + * const summary = await driver.executeQuery('CREATE (p:Person{ name: $name }) RETURN p', { name: 'Person1'}, { + * resultTransformer: neo4j.resultTransformers.hydratedResultTransformer(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.mapping.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.hydratedResultTransformer(Person) + * }) + * + * // A hydratedResultTransformer 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 This is a preview feature + */ + hydratedResultTransformer (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 a83a116c7..ffe3d286a 100644 --- a/packages/core/src/result.ts +++ b/packages/core/src/result.ts @@ -23,6 +23,7 @@ import { Query, PeekableAsyncIterator } from './types' import { observer, util, connectionHolder } from './internal' import { newError, PROTOCOL_ERROR } from './error' import { NumberOrInteger } from './graph-types' +import { GenericConstructor, Rules } from './mapping.highlevel' import Integer from './integer' const { EMPTY_CONNECTION_HOLDER } = connectionHolder @@ -60,6 +61,8 @@ interface QueryResult { summary: ResultSummary } +export interface MappedResult extends Result> {} + /** * Interface to observe updates on the Result which is being produced. * @@ -121,6 +124,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 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: RulesFactories.asString(), + * born: RulesFactories.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. * @@ -221,7 +265,11 @@ class Result implements Promise> = [] const observer = { onNext: (record: Record) => { - records.push(record) + if (this._mapper != null) { + records.push(this._mapper(record) as unknown as Record) + } else { + records.push(record as unknown as Record) + } }, onCompleted: (summary: ResultSummary) => { resolve({ records, summary }) @@ -572,7 +620,11 @@ class Result implements Promise { - observer._push({ done: false, value: record }) + 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 }) diff --git a/packages/neo4j-driver-deno/lib/core/graph-types.ts b/packages/neo4j-driver-deno/lib/core/graph-types.ts index 9d754f9e2..15d88d3ef 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,22 @@ class Node identity.toString()) } + /** + * Hydrates an object of a given type with the properties of the node + * + * @param {GenericConstructor | Rules} constructorOrRules Contructor for the desired type or {@link Rules} for the hydration + * @param {Rules} [rules] {@link Rules} for the hydration + * @returns {T} + */ + 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 +216,22 @@ class Relationship end.toString()) } + /** + * Hydrates an object of a given type with the properties of the relationship + * + * @param {GenericConstructor | Rules} constructorOrRules Contructor for the desired type or {@link Rules} for the hydration + * @param {Rules} [rules] {@link Rules} for the hydration + * @returns {T} + */ + 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 +353,22 @@ class UnboundRelationship | Rules} constructorOrRules Contructor for the desired type or {@link Rules} for the hydration + * @param {Rules} [rules] {@link Rules} for the hydration + * @returns {T} + */ + 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 1a0a7203f..32b46c514 100644 --- a/packages/neo4j-driver-deno/lib/core/index.ts +++ b/packages/neo4j-driver-deno/lib/core/index.ts @@ -101,6 +101,8 @@ import * as json from './json.ts' 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 { Rule, Rules, mapping } from './mapping.highlevel.ts' +import { RulesFactories } from './mapping.rulesfactories.ts' /** * Object containing string constants representing predefined {@link Neo4jError} codes. @@ -186,7 +188,9 @@ const forExport = { notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, clientCertificateProviders, - resolveCertificateProvider + resolveCertificateProvider, + RulesFactories, + mapping } export { @@ -263,7 +267,9 @@ export { notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, clientCertificateProviders, - resolveCertificateProvider + resolveCertificateProvider, + RulesFactories, + mapping } export type { @@ -294,7 +300,9 @@ export type { ClientCertificate, ClientCertificateProvider, ClientCertificateProviders, - RotatingClientCertificateProvider + RotatingClientCertificateProvider, + Rule, + Rules } 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..bd5d6a460 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/mapping.highlevel.ts @@ -0,0 +1,142 @@ +/** + * 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 { NameConvention, 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 + +interface nameMapper { + from: NameConvention | undefined + to: NameConvention | undefined +} + +const rulesRegistry: Record = {} + +const nameMapping: nameMapper = { + from: undefined, + to: undefined +} + +/** + * Registers a set of {@link Rules} to be used by {@link hydratedResultTransformer} 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.hydratedResultTransformer(Person, personClassRules) + * }) + * + * can instead be written: + * neo4j.mapping.register(Person, personClassRules) + * + * const summary = await driver.executeQuery('CREATE (p:Person{ name: $name }) RETURN p', { name: 'Person1'}, { + * resultTransformer: neo4j.resultTransformers.hydratedResultTransformer(Person) + * }) + * + * + * @param {GenericConstructor} constructor The constructor function of the class to set rules for + * @param {Rules} rules The rules to set for the provided class + */ +export function register (constructor: GenericConstructor, rules: Rules): void { + rulesRegistry[constructor.toString()] = rules +} + +export function setDatabaseNameMapping (newMapping: NameConvention): void { + nameMapping.from = newMapping +} + +export function setCodeNameMapping (newMapping: NameConvention): void { + nameMapping.to = newMapping +} + +export const mapping = { + register, + setDatabaseNameMapping, + setCodeNameMapping, + nameConventions +} + +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 vistedKeys: string[] = [] + + const obj = new GenericConstructor() + + for (const [key, rule] of Object.entries(theRules ?? {})) { + vistedKeys.push(key) + if (nameMapping.from !== undefined && nameMapping.to !== undefined) { + _apply(gettable, obj, nameMapping.from.encode(nameMapping.to.tokenize(key)), rule) + } else { + _apply(gettable, obj, key, rule) + } + } + + for (const key of Object.getOwnPropertyNames(obj)) { + const mappedkey = (nameMapping.from !== undefined && nameMapping.to !== undefined) ? nameMapping.to.encode(nameMapping.from.tokenize(key)) : key + if (!vistedKeys.includes(mappedkey)) { + _apply(gettable, obj, key, theRules?.[mappedkey]) + } + } + + return obj as unknown as T +} + +function _apply (gettable: Gettable, obj: T, key: string, rule?: Rule): void { + const value = gettable.get(rule?.from ?? key) + const field = `${obj.constructor.name}#${key}` + const processedValue = valueAs(value, field, rule) + const mappedkey = (nameMapping.from !== undefined && nameMapping.to !== undefined) ? nameMapping.to.encode(nameMapping.from.tokenize(key)) : key + // @ts-expect-error + obj[mappedkey] = 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.toString()] : 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..73c01aad8 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/mapping.nameconventions.ts @@ -0,0 +1,43 @@ +export interface NameConvention { + tokenize: (name: string) => string[] + encode: (tokens: string[]) => string +} + +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 + } + }, + SNAKE_CAPS: { + 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..b13437ae8 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/mapping.rulesfactories.ts @@ -0,0 +1,345 @@ +/** + * 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' + +/** + * @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 & { toString?: boolean })} asDuration Create a {@link Rule} that validates the value is a {@link Duration}. + * + * @property {function(rule: ?Rule & { toString?: boolean })} asLocalTime Create a {@link Rule} that validates the value is a {@link LocalTime}. + * + * @property {function(rule: ?Rule & { toString?: boolean })} asLocalDateTime Create a {@link Rule} that validates the value is a {@link LocalDateTime}. + * + * @property {function(rule: ?Rule & { toString?: boolean })} asTime Create a {@link Rule} that validates the value is a {@link Time}. + * + * @property {function(rule: ?Rule & { toString?: boolean })} asDateTime Create a {@link Rule} that validates the value is a {@link DateTime}. + * + * @property {function(rule: ?Rule & { toString?: 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. + */ +export const RulesFactories = Object.freeze({ + /** + * Create a {@link Rule} that validates the value is a String. + * + * @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}. + * + * @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 to true 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}. + * + * @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.RulesFactories.asNode({ + * convert: (node: Node) => node.as(Person, personRules) + * }), + * // Returns the movie node as a Node + * movie: neo4j.RulesFactories.asNode({}), + * } + * + * @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}. + * + * @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} + * + * @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} + * + * @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} + * + * @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} + * + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asDuration (rule?: Rule & { toString?: 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?.toString === true ? value.toString() : value, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link LocalTime} + * + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asLocalTime (rule?: Rule & { toString?: 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?.toString === true ? value.toString() : value, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Time} + * + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asTime (rule?: Rule & { toString?: 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?.toString === true ? value.toString() : value, + ...rule + } + }, + /** + * Create a {@link Rule} that validates the value is a {@link Date} + * + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asDate (rule?: Rule & { toString?: 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} + * + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asLocalDateTime (rule?: Rule & { toString?: 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} + * + * @param {Rule} rule Configurations for the rule + * @returns {Rule} A new rule for the value + */ + asDateTime (rule?: Rule & { toString?: 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. + * + * @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 + } + } +}) + +interface ConvertableToStdDateOrStr { toStandardDate: () => StandardDate, toString: () => string } + +function convertStdDate (value: V, rule?: { toString?: boolean, toStandardDate?: boolean }): string | V | StandardDate { + if (rule != null) { + if (rule.toString === 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..40fa621a8 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,13 @@ class Record< return resultArray } + as (rules: Rules): T + as (genericConstructor: GenericConstructor): T + as (genericConstructor: GenericConstructor, rules?: Rules): 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 f48e160ce..dc1ff9b66 100644 --- a/packages/neo4j-driver-deno/lib/core/result-transformers.ts +++ b/packages/neo4j-driver-deno/lib/core/result-transformers.ts @@ -20,6 +20,7 @@ import Result from './result.ts' import EagerResult from './result-eager.ts' import ResultSummary from './result-summary.ts' import { newError } from './error.ts' +import { GenericConstructor, Rules } from './mapping.highlevel.ts' import { NumberOrInteger } from './graph-types.ts' import Integer from './integer.ts' @@ -266,6 +267,46 @@ class ResultTransformers { summary (): ResultTransformer> { return summary } + + hydratedResultTransformer (rules: Rules): ResultTransformer<{ records: T[], summary: ResultSummary }> + hydratedResultTransformer (genericConstructor: GenericConstructor, rules?: Rules): ResultTransformer<{ records: T[], summary: ResultSummary }> + /** + * Creates a {@link ResultTransformer} which maps the result to a hydrated object + * + * @example + * + * class Person { + * constructor (name) { + * this.name = name + * } + * + * const personRules: Rules = { + * name: neo4j.RulesFactories.asString() + * } + * + * const summary = await driver.executeQuery('CREATE (p:Person{ name: $name }) RETURN p', { name: 'Person1'}, { + * resultTransformer: neo4j.resultTransformers.hydratedResultTransformer(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.mapping.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.hydratedResultTransformer(Person) + * }) + * + * // A hydratedResultTransformer 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 This is a preview feature + */ + hydratedResultTransformer (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 901d70e2c..204d93d6d 100644 --- a/packages/neo4j-driver-deno/lib/core/result.ts +++ b/packages/neo4j-driver-deno/lib/core/result.ts @@ -23,6 +23,7 @@ import { Query, PeekableAsyncIterator } from './types.ts' import { observer, util, connectionHolder } from './internal/index.ts' import { newError, PROTOCOL_ERROR } from './error.ts' import { NumberOrInteger } from './graph-types.ts' +import { GenericConstructor, Rules } from './mapping.highlevel.ts' import Integer from './integer.ts' const { EMPTY_CONNECTION_HOLDER } = connectionHolder @@ -60,6 +61,8 @@ interface QueryResult { summary: ResultSummary } +export interface MappedResult extends Result> {} + /** * Interface to observe updates on the Result which is being produced. * @@ -121,6 +124,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 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: RulesFactories.asString(), + * born: RulesFactories.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. * @@ -221,7 +265,11 @@ class Result implements Promise> = [] const observer = { onNext: (record: Record) => { - records.push(record) + if (this._mapper != null) { + records.push(this._mapper(record) as unknown as Record) + } else { + records.push(record as unknown as Record) + } }, onCompleted: (summary: ResultSummary) => { resolve({ records, summary }) @@ -572,7 +620,11 @@ class Result implements Promise { - observer._push({ done: false, value: record }) + 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 }) diff --git a/packages/neo4j-driver-deno/lib/mod.ts b/packages/neo4j-driver-deno/lib/mod.ts index 41f775d59..bdc601b23 100644 --- a/packages/neo4j-driver-deno/lib/mod.ts +++ b/packages/neo4j-driver-deno/lib/mod.ts @@ -108,7 +108,11 @@ import { ClientCertificateProviders, RotatingClientCertificateProvider, clientCertificateProviders, - resolveCertificateProvider + resolveCertificateProvider, + Rule, + Rules, + RulesFactories, + mapping } from './core/index.ts' // @deno-types=./bolt-connection/types/index.d.ts import { DirectConnectionProvider, RoutingConnectionProvider } from './bolt-connection/index.js' @@ -440,7 +444,9 @@ const forExport = { notificationFilterDisabledCategory, notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, - clientCertificateProviders + clientCertificateProviders, + RulesFactories, + mapping } export { @@ -511,7 +517,9 @@ export { notificationFilterDisabledCategory, notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, - clientCertificateProviders + clientCertificateProviders, + RulesFactories, + mapping } export type { QueryResult, @@ -542,6 +550,8 @@ export type { ClientCertificate, ClientCertificateProvider, ClientCertificateProviders, - RotatingClientCertificateProvider + RotatingClientCertificateProvider, + Rule, + Rules } export default forExport diff --git a/packages/neo4j-driver-lite/src/index.ts b/packages/neo4j-driver-lite/src/index.ts index 2e078aa48..c37e8f5fc 100644 --- a/packages/neo4j-driver-lite/src/index.ts +++ b/packages/neo4j-driver-lite/src/index.ts @@ -108,7 +108,11 @@ import { ClientCertificateProviders, RotatingClientCertificateProvider, clientCertificateProviders, - resolveCertificateProvider + resolveCertificateProvider, + Rule, + Rules, + RulesFactories, + mapping } from 'neo4j-driver-core' import { DirectConnectionProvider, RoutingConnectionProvider } from 'neo4j-driver-bolt-connection' @@ -439,7 +443,9 @@ const forExport = { notificationFilterDisabledCategory, notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, - clientCertificateProviders + clientCertificateProviders, + RulesFactories, + mapping } export { @@ -510,7 +516,9 @@ export { notificationFilterDisabledCategory, notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, - clientCertificateProviders + clientCertificateProviders, + RulesFactories, + mapping } export type { QueryResult, @@ -541,6 +549,8 @@ export type { ClientCertificate, ClientCertificateProvider, ClientCertificateProviders, - RotatingClientCertificateProvider + RotatingClientCertificateProvider, + Rule, + Rules } export default forExport diff --git a/packages/neo4j-driver/src/index.js b/packages/neo4j-driver/src/index.js index 911ad9fcd..660ecf58b 100644 --- a/packages/neo4j-driver/src/index.js +++ b/packages/neo4j-driver/src/index.js @@ -78,7 +78,10 @@ import { notificationFilterMinimumSeverityLevel, staticAuthTokenManager, clientCertificateProviders, - resolveCertificateProvider + resolveCertificateProvider, + Rules, + RulesFactories, + mapping } from 'neo4j-driver-core' import { DirectConnectionProvider, @@ -282,7 +285,8 @@ const types = { LocalDateTime, LocalTime, Time, - Integer + Integer, + Rules } /** @@ -402,7 +406,9 @@ const forExport = { notificationSeverityLevel, notificationFilterDisabledCategory, notificationFilterMinimumSeverityLevel, - clientCertificateProviders + clientCertificateProviders, + RulesFactories, + mapping } export { @@ -474,6 +480,8 @@ export { notificationFilterDisabledCategory, notificationFilterDisabledClassification, notificationFilterMinimumSeverityLevel, - clientCertificateProviders + clientCertificateProviders, + RulesFactories, + mapping } export default forExport diff --git a/packages/testkit-backend/package.json b/packages/testkit-backend/package.json index 45ae6e92b..e01cccb1e 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",