Skip to content

Conversation

MaxAake
Copy link
Contributor

@MaxAake MaxAake commented Jan 31, 2025

Continues work by @bigmontz in #1159
⚠️ This a preview feature

Getting Started

Let's say we have the following Cypher query:

 MATCH (p:Person)-[:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) 
 WHERE id(p) <> id(c) 
 RETURN p AS person, m AS movie, COLLECT(c) AS costars

and we are going to load each of the records into Typescript objects like these:

class ActingJobs {
    constructor(
        public readonly person: Person,
        public readonly movie: Movie,
        public readonly costars: Person[]
    ) {
    }
}

class  Movie {
    constructor(
        public readonly title: string,
        public readonly released?: number,
        public readonly tagline?: string
    ){
    }
}

class Person {
    constructor (
        public readonly name: string,
        public readonly born?: number
    ) {

    }
}

Each record in the results will result in an instance of ActingJob with the properties populated from the query results and type validation.

To do this at present, you would write something like the following:

const { records: actingJobsList } = await driver.executeQuery(QUERY, undefined, {
    database: 'neo4j',
    resultTransformer: neo4j.resultTransformers.mappedResultTransformer({ map: (record: Record): ActingJobs | undefined => {
        const person = fromNodeToPerson(record.get('person'))
        const movie = fromNodeToMovie(record.get('movie'))
        const costars = fromNodeListToPersonList(record.get('costars'))

        return new ActingJobs(person, movie, costars)
    } })
})

function fromNodeToPerson (node: any): Person {
    if (isNode(node)) {
        if (typeof node.properties.name !== 'string') {
            throw Error('Person.name is not a string')
        }
        
        if (node.properties.born != null &&  typeof node.properties.born !== 'bigint') {
            throw Error('Person.born is not a number')
        } 
        
        return new Person(node.properties.name, Number(node.properties.born))
    }
    throw Error('Person is not a valid node')
}

function fromNodeToMovie (node: any): Movie {
    if (isNode(node)) {
        if (typeof node.properties.title !== 'string') {
            throw Error('Movie.title is not a string')
        }

        if (node.properties.release != null && typeof node.properties.release !== 'bigint') {
            throw Error('Movie.release is not a string')
        }

        if (node.properties.tagline != null && typeof node.properties.tagline !== 'bigint') {
            throw Error('Movie.tagline is not a string')
        }

        return new Movie(node.properties.title, node.properties.release, node.properties.tagline)        
    }
    throw Error('Movie is not a valid node')
}

function fromNodeListToPersonList (list: any): Person[] {
    if (Array.isArray(list)) {
        return list.map(fromNodeToPerson)
    }
    throw Error('Person list not a valid list.') 
}

Using the new mapping functionality, the same result will be achieved using the following code:

const personRules: Rules = {
    name: neo4j.rules.asString(),
    born: neo4j.rules.asNumber({ acceptBigInt: true, optional: true })
}

const movieRules: Rules = {
    title: neo4j.rules.asString(),
    release: neo4j.rules.asNumber({ acceptBigInt: true, optional: true }),
    tagline: neo4j.rules.asString({ optional: true })
}

const actingJobsRules: Rules = {
    person: neo4j.rules.asNode({
        convert: (node: Node) => node.as(Person, personRules)
    }),
    movie: neo4j.rules.asNode({
        convert: (node: Node) => node.as(Movie, movieRules)
    }),
    costars: neo4j.rules.asList({
        apply: neo4j.rules.asNode({
            convert: (node: Node) => node.as(Person, personRules)
        })
    })
}
const { records: actingJobsList } = await driver.executeQuery(QUERY, undefined, {
    database: 'neo4j',
    resultTransformer: neo4j.resultTransformers.hydrated(ActingJobs, actingJobsRules)
})

The mapping will be done by combining rules definition and object properties created by the constructors. It's not necessary to use both (constructor and rules), but the usage of one of them is needed if you need a filled object since properties not present in instantiated object or rules are ignored by the method.

For example, if values present in the Movie and Person objects are never null and no validation is needed. The code to process the result can be changed to:

const actingJobsRules: Rules = {
    person: neo4j.rules.asNode({
        convert: (node: Node) => node.as(Person)
    }),
    movie: neo4j.rules.asNode({
        convert: (node: Node) => node.as(Movie)
    }),
    costars: neo4j.rules.asList({
        apply: neo4j.rules.asNode({
            convert: (node: Node) => node.as(Person)
        })
    })
}
const { records: actingJobsList } = await driver.executeQuery(QUERY, undefined, {
    database: 'neo4j',
    resultTransformer: neo4j.resultTransformers.hydrated(ActingJobs, actingJobsRules)

Another possible scenario is Movie, Person and ActingJobs be just typescript interfaces. The code to process the result can be changed to:

const personRules: Rules = {
    name: neo4j.rules.asString(),
    born: neo4j.rules.asNumber({ acceptBigInt: true, optional: true })
}

const movieRules: Rules = {
    title: neo4j.rules.asString(),
    release: neo4j.rules.asNumber({ acceptBigInt: true, optional: true }),
    tagline: neo4j.rules.asString({ optional: true })
}

const neo4j.rules: Rules = {
    person: neo4j.rules.asNode({
        convert: (node: Node) => node.as(personRules)
    }),
    movie: neo4j.rules.asNode({
        convert: (node: Node) => node.as(movieRules)
    }),
    costars: neo4j.rules.asList({
        apply: neo4j.rules.asNode({
            convert: (node: Node) => node.as(personRules)
        })
    })
}
const { records: actingJobsList } = await driver.executeQuery(QUERY, undefined, {
    database: 'neo4j',
    resultTransformer: neo4j.resultTransformers.hydrated<ActingJobs>(actingJobsRules)
})

Note: In this scenario, the ActingJobs interface is set to the transformer for auto-complete and code validation, however this is not treated as an object constructor.

Mapping from Result method

Result.as is introduced to the result for enable the mapping occur in the result.
Same rules of usage of constructor and rules are share with the hydrated result transformer`.

const { records: actingJobsList } = await session.executeWrite(tx => tx.run().as(ActingJobs, actingJobsRules))

Direct mapping from an Record, Node and Relationship instance.

Record.as, Node.as and Relationship.as are introduced to enable the mapping of these types only.
Same rules of usage of constructor and rules are share with the hydrated result transformer`.

const actingJobs = record.as(ActingJobs, actingJobsRules) // example with construtor and rules
const person = node.as<Person>(personRules) // example using interfaces
const actedIn = relationship.as(ActedIn) // example without rules

Note: In Node and Relationship only properties are mapped.
_Note: Properties not present in the Record will throw error since Record.get is used under the hood. However, properties which present and with value equals to null or undefined will only throw error when property is not defined as optional in the rules.

Rules and Built-in Rules Factory

The driver provides Rule Factories for all data types which can be returned form a query.
There is no need for creating custom rules in most of cases.
However, all the built-in rules are extensible.

const personRules: Rules = {
    name: neo4j.rules.asString({ 
       convert: name => `Name: $name` // change the convert method in the string rule
    }),
    born: neo4j.rules.asNumber({ acceptBigInt: true, optional: true })
}

The Rule interface is defined as:

interface Rule {
  optional?: boolean
  from?: string
  convert?: (recordValue: any, field: string) => any
  validate?: (recordValue: any, field: string) => void
}

where

  • optional (default: false): indicates with value accept undefined/null as value.
    In this case, the convert method will not be called.
  • from (default: property name on the Rules object): indicates from which field on the Record, Node or Relationship the value will came from.
    Used for cases where the property name in domain object is different to the one present in the database.
  • convert: called to convert value to the domain value.
    Called after value be validated.
    If optional is true and the value is null or undefined, the method is not called.
  • validate: called to validate if values is valid.
    Called before convert and after check if the value is optional.
    If optional is true and the value is null or undefined, the method is not called.

This feature also includes a rules registry, allowing types to be linked to rules so that they do not need to be provided every time a result is transformed. The rules registry is stored in global memory, and not linked to a specific instance of the driver.

// registering the rules
neo4j.mapping.register(Person, personRules)
neo4j.mapping.register(Movie, movieRules)
neo4j.mapping.register(ActingJobs, actingJobsRules)

Running the query:

const { records: actingJobsList } = await driver.executeQuery(QUERY, undefined, {
    database: 'neo4j',
    resultTransformer: neo4j.resultTransformers.hydrated(ActingJobs)
})

An alternative to manually writing rules for a type is also available for typescript 5.2+ users: Decorators with metadata.

NOTE: Many runtimes do not yet fully support decorator metadata. For users of Node.js the following line: (Symbol as { metadata: symbol }).metadata ??= Symbol('Symbol.metadata'); will polyfill the Symbol.metadata property. Without this polyfill or a runtime that otherwise supports symbol metadata, these decorators will not function.

@neo4j.mappingDecorators.mappedClass()
class  Movie {
    @neo4j.mappingDecorators.stringProperty()
    Title: String
    @neo4j.mappingDecorators.optionalProperty()
    @neo4j.mappingDecorators.numberProperty()
    Released?: number
    @neo4j.mappingDecorators.optionalProperty()
    @neo4j.mappingDecorators.stringProperty()
    Tagline?: string
}

@neo4j.mappingDecorators.mappedClass()
class Person {
    @neo4j.mappingDecorators.stringProperty()
    Name: string
    @neo4j.mappingDecorators.optionalProperty()
    @neo4j.mappingDecorators.numberProperty()
    Born?: number
}

@neo4j.mappingDecorators.mappedClass()
class Role {
    @neo4j.mappingDecorators.stringProperty({from: "characterName"})
    Name: string
}

@neo4j.mappingDecorators.mappedClass()
class ActingJobs {
    @neo4j.mappingDecorators.convertPropertyToType(Person)
    @neo4j.mappingDecorators.nodeProperty()
    Person: Person
    @neo4j.mappingDecorators.convertPropertyToType(Movie)
    @neo4j.mappingDecorators.nodeProperty()
    Movie: Movie
    @neo4j.mappingDecorators.convertPropertyToType(Role)
    @neo4j.mappingDecorators.relationshipProperty()
    Role: Role
    @neo4j.mappingDecorators.listProperty()
    @neo4j.mappingDecorators.convertPropertyToType(Person)
    @neo4j.mappingDecorators.nodeProperty()
    Costars: Person[]
}

The above code will both define classes and register rules for them in the rules registry. The order of the decorators is important, with the type expected closest to the property, optional/conversion above that, and the listProperty() decorator on top if the property is a list.

@MaxAake MaxAake changed the base branch from 5.0 to 6.0 February 25, 2025 13:23
@MaxAake MaxAake changed the base branch from 6.x to 5.0 March 10, 2025 09:35
@MaxAake MaxAake changed the base branch from 5.0 to 6.x March 10, 2025 09:36
@MaxAake MaxAake force-pushed the 5.x-high-level-hydration branch from 5ac8467 to 2e2295a Compare September 5, 2025 06:54
@MaxAake MaxAake marked this pull request as ready for review September 24, 2025 08:06
@MaxAake MaxAake changed the title Introduce Result, Record and Graph types mapping Preview: Introduce Result, Record and Graph types mapping Sep 24, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants