From 6f85d5bcd541ed655e0fa3bca4a47e45edf4fece Mon Sep 17 00:00:00 2001 From: MaxAake <61233757+MaxAake@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:19:46 +0200 Subject: [PATCH 01/11] first draft --- .../modules/ROOT/content-nav.adoc | 1 + .../modules/ROOT/pages/object-mapping.adoc | 229 ++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 javascript-manual/modules/ROOT/pages/object-mapping.adoc diff --git a/javascript-manual/modules/ROOT/content-nav.adoc b/javascript-manual/modules/ROOT/content-nav.adoc index bf545d8d..7e09d045 100644 --- a/javascript-manual/modules/ROOT/content-nav.adoc +++ b/javascript-manual/modules/ROOT/content-nav.adoc @@ -15,6 +15,7 @@ * xref:query-advanced.adoc[Further query mechanisms] * xref:performance.adoc[Performance recommendations] * xref:browser-websockets.adoc[Usage within a browser (WebSockets)] +* xref:object-mapping.adoc[Type checking and mapping] * *Reference* diff --git a/javascript-manual/modules/ROOT/pages/object-mapping.adoc b/javascript-manual/modules/ROOT/pages/object-mapping.adoc new file mode 100644 index 00000000..950d94ef --- /dev/null +++ b/javascript-manual/modules/ROOT/pages/object-mapping.adoc @@ -0,0 +1,229 @@ += JavaScript Driver Record and Parameter Object Mapping + +Neither JavaScript nor Neo4j are strongly typed, which can lead to mistakes where a property is set to the wrong type, or an unexpected type is returned from the database and causes errors in client code. + +The driver offers Object Mapping to alleviate this. Object Mapping enables users to map the data in their records into classes with known propety types, and to map these classes into parameters for their queries. + +[NOTE] +==== +The classes being mapped must have a constructor function that can be called without arguments, and all properties that are mapped must be publicly accessible. Mapping values into a constructor may be a future feature. +==== + +== Mapping Rules + +Given the following query: +[source, cypher] +---- +MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) +WHERE id(p) <> id(c) +RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars +---- + +And the following classes: +[source, javascript] +---- +class ActingJob { + person: Person + movie: Movie + roles: Roles + costars: Person[] + + function toString(): string { + return `${this.person.name} acted in ${this.movie.title} as the role(s) ${this.roles.roleNames.toString()} along with ${this.costars.map((p:person) => p.name).toString()}` + } +} + +class Movie { + title: string + release?: number + tagline?: string +} + +class Person { + name: string + born?: number +} + +class Roles { + roleNames: string[] +} +---- + +This is a set of Rules objects to allow the query result to be mapped into a list of ActingJobs +[source, javascript] +---- +const personRules: Rules = { + name: rule.asString(), + born: rule.asNumber({ isInteger: true, optional: true }) +} +---- +This sets string typechecking for the `name` property and Number typechecking for `born`. In addition, the born property is set to be optional and be an Integer (this will automatically convert between an integer in the database and a JavaScript number in client code). + +[source, javascript] +---- +const movieRules: Rules = { + title: rule.asString({}), + release: rule.asNumber({ isInteger: true, optional: true, from: "released" }), + tagline: rule.asString({ optional: true }) +} + +const rolesRules = { + roleNames: rule.asList({apply: rule.asString(), from: "roles"}) +} +---- +The Movie rules are rather similar to those for Person, but it introduces the extra rule config `from`. This simply changes the key the property is matched from, useful when there is an inconsistency in the name between code and data. +Since actors have a list of roles for each movie they acted in, we have to set that the roles are a list, each entry of which is a string. + +Now we put these rules together to create the rules to map the query result to an ActingJob object. +[source, javascript] +---- +const actingJobRules: Rules = { + person: rule.asNode({ + convert: (node: Node) => node.as(Person, personRules) + }), + roles: rule.asRelationship({ + convert: (rel: Relationship) => rel.as(Roles, rolesRules) + }), + movie: rule.asNode({ + convert: (node: Node) => node.as(Movie, movieRules) + }), + costars: rule.asList({ + apply: rule.asNode({ + convert: (node: Node) => node.as(Person, personRules) + }) + }) +} +---- +This introduces the functions `asNode`, `asRelationship`. These perform typechecking, then take optional configurations to convert into other types. + +A record mapped according to these Rules is then known to obey these rules and function as an ActingJob object, and IDEs like VSCode will be able to provide reasonable hints and help. + +The mapping rules can be used to map Result objects, individual Records or be used to by a special ResultTransformer to map the results of a query. + +Each of the mapping methods take the type to map to and/or a set of rules. If only the class is provided, then the driver will make a best effort, but cannot ensure type safety. If only a Rules object is supplied, then the Result or Record will be mapped, but the driver cannot provide the IDE with type hints. The recommended usage is to utilize both, later in this document there is a convenience function to make this simpler. + +=== Usage with transaction functions +[source, javascript] +---- +const res = await session.executeRead(async (tx) => { + return tx.run( + `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) + WHERE id(p) <> id(c) + RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars`).as(ActingJob, actingJobRules) //The Result is here converted to a MappedResult and can be consumed, subscribed to or awaited like a normal result, but all Records are replaced with ActingJobs. +}); + +res.records[0].person.born //after being awaited, res is a MappedQueryResult, containing an array of ActingJobs and a ResultSummary. You and your IDE can now feel safe in knowing that this is a Number. +---- + +=== Usage with driver.executeQuery() +[source, javascript] +---- +const res = await driver.executeQuery>(`MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) + WHERE id(p) <> id(c) + RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars`, {}, {resultTransformer: neo4j.resultTransformers.hydrated(ActingJob, actingJobRules)}) +---- + +=== Usage with session.run() +[source, javascript] +---- +const res2 = await session.run( + `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) + WHERE id(p) <> id(c) + RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars` + ).as(ActingJob, actingJobRules) +---- + +=== Nested Rules +The `asObject` rule factory allows nesting in the created rules. + +It's useful when you're not just returning one object in your records, and allows you to map your result into multiple objects without returning full nodes or relationships to transform. + +Much like other instances when you can pass rules, you can also pass a class' constructor to have the `asObject` conversion convert to that class. + +With `asObject` we can create a set of rules for ActingJobs that can be used without returning whole Nodes and Relationships. + +[source, javascript] +---- +const actingJobRulesObject: Rules = { + person: rule.asObject(Person, personRules), + role: rule.asObject(Role, roleRules), + movie: rule.asObject(Movie, movieRules), + costars: rule.asList({ + apply: rule.asObject(Person, personRules), + }) +} + +await session.run( + `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) + WHERE id(p) <> id(c) + RETURN {name: p.name, born: p.born} AS person, {name: r.characterName} as role, {released: m.release, title: m.title, tagline: m.tagline} AS movie, COLLECT({name: c.name, born: c.born}) AS costars` +).as(ActingJob, actingJobRulesObject) +---- + +This setup saves on bandwitdth, especially if the nodes contain large properties like vectors which are not used for mapping. + +== Registering per-type default rules. +In the examples above, we have always provided both `ActingJob` and `actingJobRules`, which requires these rules to be available in every part of the code where a query is run. + +The following will register this pair in global memory, meaning it persists between driver instances. This means you can run this function for every type you need mapped, and never need to explicitly provide rules to the mappers in your code unless you want the default case overridden. +[source, javascript] +---- +recordObjectMapping.register(ActingJob, actingJobRules) +---- + +This includes inside the rules themselves. When writing the `actingJobRules`, we had to set the rule for the `person` property as +[source, javascript] +---- +person: rule.asNode({ + convert: (node: Node) => node.as(Person, personRules) + }) +---- +however, if we know we have registed `personRules` to `Person` with `recordObjectMapping.register(ActingJob, actingJobRules)` we can replace that with: +[source, javascript] +---- +person: rule.asNode({ + convert: (node: Node) => node.as(Person) + }) +---- + +== Mapping Rules for Parameters + +The Object Mapping rules also function for Object Parameter Mapping. Allowing the same rules used to map records into object to be used to map objects to query parameters. + +If your domain object is already a simple JavaScript object with all the properties in the correct format to be sent as parameters there is of course no need for any mapping to occur. But if you store temporal data as Neo4j temporal types in the database but use them as strings in your Domain Object, or if your Domain Object includes functions or properties which can not or should not be sent as parameters, the mapping rules can assist in sanitizing the data for transmission. + +[source, javascript] +---- +class TestObj { + number: number + string: string + date: string + list: string[] + constructor(number, string, date, list) { + this.number = number + this.string = string + this.date = date + this.list = list + } + function() { + return "function string" + } // the driver cannot send functions over bolt +} +const rules = { + number: neo4j.rule.asNumber({isInteger: true}), + string: neo4j.rule.asString(), + date: neo4j.rule.asDate({stringify: true}), // this will ensure date is stored as a Date in the DB and retrieved as a string + list: neo4j.rule.asList({ apply: neo4j.rule.asString() }), + function: { + parameterConversion: () => undefined, // explicitly skips the function when sending parameters + optional: true // Record mapping will attempt to get all properties of the object from the Record, we need to explicitly mark that it's okay that there is no function. This is not needed if this is only mapped to parameter objects, and never used to map Records to TestObjs + }, +} + +neo4j.RecordObjectMapping.register(TestObj, rules) // Register the rules object to the TestObj class + +const res = await driver.executeQuery>( + 'RETURN $string as string, $number as number, $big_int as big_int, $date as date, $dob as dob, $list as list', + new TestObj(1, "hello", ["good", "bye"], "2026-09-02"), // executeQuery automatically applies the TestObj rules to this object. + {resultTransformer: neo4j.resultTransformers.hydrated(TestObj)}) +---- From 74effa6d3c7fa39cdbe7fe11c864314eb5a06c1a Mon Sep 17 00:00:00 2001 From: MaxAake <61233757+MaxAake@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:36:26 +0200 Subject: [PATCH 02/11] mark code as typescript --- .../modules/ROOT/pages/object-mapping.adoc | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/javascript-manual/modules/ROOT/pages/object-mapping.adoc b/javascript-manual/modules/ROOT/pages/object-mapping.adoc index 950d94ef..28511d1f 100644 --- a/javascript-manual/modules/ROOT/pages/object-mapping.adoc +++ b/javascript-manual/modules/ROOT/pages/object-mapping.adoc @@ -20,7 +20,7 @@ RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars ---- And the following classes: -[source, javascript] +[source, typescript] ---- class ActingJob { person: Person @@ -50,7 +50,7 @@ class Roles { ---- This is a set of Rules objects to allow the query result to be mapped into a list of ActingJobs -[source, javascript] +[source, typescript] ---- const personRules: Rules = { name: rule.asString(), @@ -59,7 +59,7 @@ const personRules: Rules = { ---- This sets string typechecking for the `name` property and Number typechecking for `born`. In addition, the born property is set to be optional and be an Integer (this will automatically convert between an integer in the database and a JavaScript number in client code). -[source, javascript] +[source, typescript] ---- const movieRules: Rules = { title: rule.asString({}), @@ -75,7 +75,7 @@ The Movie rules are rather similar to those for Person, but it introduces the ex Since actors have a list of roles for each movie they acted in, we have to set that the roles are a list, each entry of which is a string. Now we put these rules together to create the rules to map the query result to an ActingJob object. -[source, javascript] +[source, typescript] ---- const actingJobRules: Rules = { person: rule.asNode({ @@ -103,7 +103,7 @@ The mapping rules can be used to map Result objects, individual Records or be us Each of the mapping methods take the type to map to and/or a set of rules. If only the class is provided, then the driver will make a best effort, but cannot ensure type safety. If only a Rules object is supplied, then the Result or Record will be mapped, but the driver cannot provide the IDE with type hints. The recommended usage is to utilize both, later in this document there is a convenience function to make this simpler. === Usage with transaction functions -[source, javascript] +[source, typescript] ---- const res = await session.executeRead(async (tx) => { return tx.run( @@ -116,7 +116,7 @@ res.records[0].person.born //after being awaited, res is a MappedQueryResult, co ---- === Usage with driver.executeQuery() -[source, javascript] +[source, typescript] ---- const res = await driver.executeQuery>(`MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) WHERE id(p) <> id(c) @@ -124,7 +124,7 @@ const res = await driver.executeQuery>(`MATCH (p:Pe ---- === Usage with session.run() -[source, javascript] +[source, typescript] ---- const res2 = await session.run( `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) @@ -142,7 +142,7 @@ Much like other instances when you can pass rules, you can also pass a class' co With `asObject` we can create a set of rules for ActingJobs that can be used without returning whole Nodes and Relationships. -[source, javascript] +[source, typescript] ---- const actingJobRulesObject: Rules = { person: rule.asObject(Person, personRules), @@ -166,20 +166,20 @@ This setup saves on bandwitdth, especially if the nodes contain large properties In the examples above, we have always provided both `ActingJob` and `actingJobRules`, which requires these rules to be available in every part of the code where a query is run. The following will register this pair in global memory, meaning it persists between driver instances. This means you can run this function for every type you need mapped, and never need to explicitly provide rules to the mappers in your code unless you want the default case overridden. -[source, javascript] +[source, typescript] ---- recordObjectMapping.register(ActingJob, actingJobRules) ---- This includes inside the rules themselves. When writing the `actingJobRules`, we had to set the rule for the `person` property as -[source, javascript] +[source, typescript] ---- person: rule.asNode({ convert: (node: Node) => node.as(Person, personRules) }) ---- however, if we know we have registed `personRules` to `Person` with `recordObjectMapping.register(ActingJob, actingJobRules)` we can replace that with: -[source, javascript] +[source, typescript] ---- person: rule.asNode({ convert: (node: Node) => node.as(Person) @@ -192,7 +192,7 @@ The Object Mapping rules also function for Object Parameter Mapping. Allowing th If your domain object is already a simple JavaScript object with all the properties in the correct format to be sent as parameters there is of course no need for any mapping to occur. But if you store temporal data as Neo4j temporal types in the database but use them as strings in your Domain Object, or if your Domain Object includes functions or properties which can not or should not be sent as parameters, the mapping rules can assist in sanitizing the data for transmission. -[source, javascript] +[source, typescript] ---- class TestObj { number: number From 52e96628c5cc51670eac1a815d10c70858bd26ba Mon Sep 17 00:00:00 2001 From: MaxAake <61233757+MaxAake@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:22:41 +0200 Subject: [PATCH 03/11] improved phrasing --- .../modules/ROOT/pages/object-mapping.adoc | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/javascript-manual/modules/ROOT/pages/object-mapping.adoc b/javascript-manual/modules/ROOT/pages/object-mapping.adoc index 28511d1f..5fd1c87b 100644 --- a/javascript-manual/modules/ROOT/pages/object-mapping.adoc +++ b/javascript-manual/modules/ROOT/pages/object-mapping.adoc @@ -57,7 +57,7 @@ const personRules: Rules = { born: rule.asNumber({ isInteger: true, optional: true }) } ---- -This sets string typechecking for the `name` property and Number typechecking for `born`. In addition, the born property is set to be optional and be an Integer (this will automatically convert between an integer in the database and a JavaScript number in client code). +This sets string type validation for the `name` property and Number type validation for `born`. In addition, the born property is set to be optional and be an Integer (this will automatically convert between an integer in the database and a JavaScript number in client code). [source, typescript] ---- @@ -71,8 +71,8 @@ const rolesRules = { roleNames: rule.asList({apply: rule.asString(), from: "roles"}) } ---- -The Movie rules are rather similar to those for Person, but it introduces the extra rule config `from`. This simply changes the key the property is matched from, useful when there is an inconsistency in the name between code and data. -Since actors have a list of roles for each movie they acted in, we have to set that the roles are a list, each entry of which is a string. +The Movie rules are rather similar to those for Person, but it introduces the extra rule config `from`. This simply changes the key the property is matched from, for when there is an inconsistency in the name used in client code and in the database. +The `asList` rule will apply the rule in it's `apply` parameter to every entry in the list. Now we put these rules together to create the rules to map the query result to an ActingJob object. [source, typescript] @@ -94,13 +94,14 @@ const actingJobRules: Rules = { }) } ---- -This introduces the functions `asNode`, `asRelationship`. These perform typechecking, then take optional configurations to convert into other types. +This introduces the functions `asNode`, `asRelationship`. These create rules that allow conversion of Relationships and Nodes into other types. -A record mapped according to these Rules is then known to obey these rules and function as an ActingJob object, and IDEs like VSCode will be able to provide reasonable hints and help. +The mapping rules can be used to map Result objects, individual Records or be used to by a `hydrated` ResultTransformer to map the results of a query. -The mapping rules can be used to map Result objects, individual Records or be used to by a special ResultTransformer to map the results of a query. - -Each of the mapping methods take the type to map to and/or a set of rules. If only the class is provided, then the driver will make a best effort, but cannot ensure type safety. If only a Rules object is supplied, then the Result or Record will be mapped, but the driver cannot provide the IDE with type hints. The recommended usage is to utilize both, later in this document there is a convenience function to make this simpler. +[NOTE] +==== +The funtions under `rule` create objects of the `Rule` class, it is possible to create custom `Rule` objects that perform more advanced type validation or conversions. The `Rule` type is exported by the driver and documented in the API docs. +==== === Usage with transaction functions [source, typescript] @@ -134,13 +135,9 @@ const res2 = await session.run( ---- === Nested Rules -The `asObject` rule factory allows nesting in the created rules. - -It's useful when you're not just returning one object in your records, and allows you to map your result into multiple objects without returning full nodes or relationships to transform. - -Much like other instances when you can pass rules, you can also pass a class' constructor to have the `asObject` conversion convert to that class. +The `asObject` rule factory allows createing nested rules. It allows to mapping of results into multiple objects without returning full nodes or relationships to transform. -With `asObject` we can create a set of rules for ActingJobs that can be used without returning whole Nodes and Relationships. +With `asObject`, a set of rules for ActingJobs that can be used without returning whole Nodes and Relationships can be created. [source, typescript] ---- @@ -163,9 +160,9 @@ await session.run( This setup saves on bandwitdth, especially if the nodes contain large properties like vectors which are not used for mapping. == Registering per-type default rules. -In the examples above, we have always provided both `ActingJob` and `actingJobRules`, which requires these rules to be available in every part of the code where a query is run. +In the examples above, both `ActingJob` and `actingJobRules` have been provided together, which requires these rules to be available in every part of the code where a query is run. -The following will register this pair in global memory, meaning it persists between driver instances. This means you can run this function for every type you need mapped, and never need to explicitly provide rules to the mappers in your code unless you want the default case overridden. +The following code registers the rules to the object, meaning the rules do not need to be provided when using Object Mapping except to override the default. [source, typescript] ---- recordObjectMapping.register(ActingJob, actingJobRules) @@ -188,9 +185,9 @@ person: rule.asNode({ == Mapping Rules for Parameters -The Object Mapping rules also function for Object Parameter Mapping. Allowing the same rules used to map records into object to be used to map objects to query parameters. +The Object Mapping rules also function for Object Parameter Mapping, allowing the same rules used to map records into object to be used to map objects to query parameters. -If your domain object is already a simple JavaScript object with all the properties in the correct format to be sent as parameters there is of course no need for any mapping to occur. But if you store temporal data as Neo4j temporal types in the database but use them as strings in your Domain Object, or if your Domain Object includes functions or properties which can not or should not be sent as parameters, the mapping rules can assist in sanitizing the data for transmission. +If a domain object is a simple JavaScript object with all the properties in the correct format to be sent as parameters there is no need for any mapping to occur, but if temporal data is stored as Neo4j temporal types in the database but used as strings in client code, or if a Domain Object includes functions or properties which can not or should not be sent as parameters, the mapping rules can assist in sanitizing the data for transmission. [source, typescript] ---- From d5e848d8a5b0f0bc72fc9c0bf1528060f35f66dc Mon Sep 17 00:00:00 2001 From: MaxAake <61233757+MaxAake@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:21:29 +0200 Subject: [PATCH 04/11] Update object-mapping.adoc --- .../modules/ROOT/pages/object-mapping.adoc | 60 ++++++++++++------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/javascript-manual/modules/ROOT/pages/object-mapping.adoc b/javascript-manual/modules/ROOT/pages/object-mapping.adoc index 5fd1c87b..7b859ce5 100644 --- a/javascript-manual/modules/ROOT/pages/object-mapping.adoc +++ b/javascript-manual/modules/ROOT/pages/object-mapping.adoc @@ -2,7 +2,7 @@ Neither JavaScript nor Neo4j are strongly typed, which can lead to mistakes where a property is set to the wrong type, or an unexpected type is returned from the database and causes errors in client code. -The driver offers Object Mapping to alleviate this. Object Mapping enables users to map the data in their records into classes with known propety types, and to map these classes into parameters for their queries. +Object Mapping enables users to map the data in their records into classes with known propety types, and to map these classes into parameters for their queries. [NOTE] ==== @@ -27,8 +27,14 @@ class ActingJob { movie: Movie roles: Roles costars: Person[] + constructor(person: Person, movie: Movie, roles: Roles, costars: Person[]) { + this.person = person + this.movie = movie + this.roles = roles + this.costars = costars + } - function toString(): string { + toString(): string { return `${this.person.name} acted in ${this.movie.title} as the role(s) ${this.roles.roleNames.toString()} along with ${this.costars.map((p:person) => p.name).toString()}` } } @@ -36,16 +42,26 @@ class ActingJob { class Movie { title: string release?: number - tagline?: string + constructor(title: string, release: number) { + this.title = title + this.release = release + } } class Person { name: string born?: number + constructor(name: string, born: number) { + this.name = name + this.born = born + } } class Roles { roleNames: string[] + constructor(roleNames: string[]) { + this.roleNames = roleNames + } } ---- @@ -64,7 +80,6 @@ This sets string type validation for the `name` property and Number type validat const movieRules: Rules = { title: rule.asString({}), release: rule.asNumber({ isInteger: true, optional: true, from: "released" }), - tagline: rule.asString({ optional: true }) } const rolesRules = { @@ -106,11 +121,12 @@ The funtions under `rule` create objects of the `Rule` class, it is possible to === Usage with transaction functions [source, typescript] ---- -const res = await session.executeRead(async (tx) => { +await session.executeRead(async (tx) => { return tx.run( - `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) - WHERE id(p) <> id(c) - RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars`).as(ActingJob, actingJobRules) //The Result is here converted to a MappedResult and can be consumed, subscribed to or awaited like a normal result, but all Records are replaced with ActingJobs. + `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) + WHERE id(p) <> id(c) + RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars` + ).as(ActingJob, actingJobRules) //The Result is here converted to a MappedResult and can be consumed, subscribed to or awaited like a normal result, but all Records are replaced with ActingJobs. }); res.records[0].person.born //after being awaited, res is a MappedQueryResult, containing an array of ActingJobs and a ResultSummary. You and your IDE can now feel safe in knowing that this is a Number. @@ -119,19 +135,23 @@ res.records[0].person.born //after being awaited, res is a MappedQueryResult, co === Usage with driver.executeQuery() [source, typescript] ---- -const res = await driver.executeQuery>(`MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) - WHERE id(p) <> id(c) - RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars`, {}, {resultTransformer: neo4j.resultTransformers.hydrated(ActingJob, actingJobRules)}) +await driver.executeQuery>( + `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) + WHERE id(p) <> id(c) + RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars`, + {}, + {resultTransformer: neo4j.resultTransformers.hydrated(ActingJob, actingJobRules)} +) ---- === Usage with session.run() [source, typescript] ---- -const res2 = await session.run( +await session.run( `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) - WHERE id(p) <> id(c) - RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars` - ).as(ActingJob, actingJobRules) + WHERE id(p) <> id(c) + RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars` +).as(ActingJob, actingJobRules) ---- === Nested Rules @@ -143,7 +163,7 @@ With `asObject`, a set of rules for ActingJobs that can be used without returnin ---- const actingJobRulesObject: Rules = { person: rule.asObject(Person, personRules), - role: rule.asObject(Role, roleRules), + roles: rule.asObject(Roles, rolesRules), movie: rule.asObject(Movie, movieRules), costars: rule.asList({ apply: rule.asObject(Person, personRules), @@ -153,7 +173,7 @@ const actingJobRulesObject: Rules = { await session.run( `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) WHERE id(p) <> id(c) - RETURN {name: p.name, born: p.born} AS person, {name: r.characterName} as role, {released: m.release, title: m.title, tagline: m.tagline} AS movie, COLLECT({name: c.name, born: c.born}) AS costars` + RETURN {name: p.name, born: p.born} AS person, {roles: r.roles} as roles, {released: m.release, title: m.title} AS movie, COLLECT({name: c.name, born: c.born}) AS costars` ).as(ActingJob, actingJobRulesObject) ---- @@ -196,7 +216,7 @@ class TestObj { string: string date: string list: string[] - constructor(number, string, date, list) { + constructor(number: number, string: string, date: string, list: string[]) { this.number = number this.string = string this.date = date @@ -219,8 +239,8 @@ const rules = { neo4j.RecordObjectMapping.register(TestObj, rules) // Register the rules object to the TestObj class -const res = await driver.executeQuery>( +const res = await driver.executeQuery>( 'RETURN $string as string, $number as number, $big_int as big_int, $date as date, $dob as dob, $list as list', - new TestObj(1, "hello", ["good", "bye"], "2026-09-02"), // executeQuery automatically applies the TestObj rules to this object. + new TestObj(1, "hello", "2026-09-02", ["good", "bye"]), // executeQuery automatically applies the TestObj rules to this object. {resultTransformer: neo4j.resultTransformers.hydrated(TestObj)}) ---- From c386639ba0168e2c1711b118b632f4324be2e50a Mon Sep 17 00:00:00 2001 From: Stefano Ottolenghi Date: Thu, 17 Sep 2026 16:19:03 +0200 Subject: [PATCH 05/11] first mwe --- .../modules/ROOT/content-nav.adoc | 2 +- .../modules/ROOT/pages/object-mapping.adoc | 72 +++++++++++++++++-- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/javascript-manual/modules/ROOT/content-nav.adoc b/javascript-manual/modules/ROOT/content-nav.adoc index 7e09d045..2aa1a0ce 100644 --- a/javascript-manual/modules/ROOT/content-nav.adoc +++ b/javascript-manual/modules/ROOT/content-nav.adoc @@ -15,7 +15,7 @@ * xref:query-advanced.adoc[Further query mechanisms] * xref:performance.adoc[Performance recommendations] * xref:browser-websockets.adoc[Usage within a browser (WebSockets)] -* xref:object-mapping.adoc[Type checking and mapping] +* xref:object-mapping.adoc[Type checking and object mapping] * *Reference* diff --git a/javascript-manual/modules/ROOT/pages/object-mapping.adoc b/javascript-manual/modules/ROOT/pages/object-mapping.adoc index 7b859ce5..d8724237 100644 --- a/javascript-manual/modules/ROOT/pages/object-mapping.adoc +++ b/javascript-manual/modules/ROOT/pages/object-mapping.adoc @@ -1,21 +1,83 @@ -= JavaScript Driver Record and Parameter Object Mapping += Type checking and object mapping Neither JavaScript nor Neo4j are strongly typed, which can lead to mistakes where a property is set to the wrong type, or an unexpected type is returned from the database and causes errors in client code. -Object Mapping enables users to map the data in their records into classes with known propety types, and to map these classes into parameters for their queries. +Object mapping helps contain such mistakes. It allows you to map result records into classes with properties of explicit types, and to submit typed query parameters. [NOTE] ==== -The classes being mapped must have a constructor function that can be called without arguments, and all properties that are mapped must be publicly accessible. Mapping values into a constructor may be a future feature. +The classes being mapped must have a constructor function that can be called without arguments, and all properties that are mapped must be publicly accessible. ==== +== Read from the database + +[source, typescript] +---- +import neo4j, {Rules, rule, MappedQueryResult, Node} from 'neo4j-driver' + +class Movie { // <.> + title: string + release?: number + constructor(title: string, release: number) { + this.title = title + this.release = release + } +}; +class MovieNode { // <.> + movie: Movie + constructor(movie: Movie) { + this.movie = movie + } +} + +const movieRules: Rules = { // <.> + title: rule.asString({}), + release: rule.asNumber({ isInteger: true, optional: true, from: "released" }) +}; +const movieNodeRules: Rules = { // <.> + movie: rule.asNode({ + convert: (node: Node) => node.as(Movie, movieRules) + }), +}; + +(async () => { + + // URI examples: 'neo4j://localhost', 'neo4j+s://xxx.databases.neo4j.io' + const URI = '' + const USER = '' + const PASSWORD = '' + let driver = neo4j.driver(URI, neo4j.auth.basic(USER, PASSWORD)) + + let res = await driver.executeQuery>( // <.> + `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie`, + {}, + {resultTransformer: neo4j.resultTransformers.hydrated(MovieNode, movieNodeRules)} // <.> + ) + + console.log(res.records[0]) + // MovieNode { movie: Movie { title: 'Cloud atlas', release: 2013 } } + + await driver.close() + +})().catch((e: Error) => { + console.log(e) +}); +---- + +<.> l +<.> l +<.> l +<.> l +<.> l +<.> l + == Mapping Rules Given the following query: [source, cypher] ---- -MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) -WHERE id(p) <> id(c) +MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) +WHERE id(p) <> id(c) RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars ---- From 768dc33b87cb0fd0d6648103626ca8335c49a8e7 Mon Sep 17 00:00:00 2001 From: Stefano Ottolenghi Date: Fri, 18 Sep 2026 17:35:25 +0200 Subject: [PATCH 06/11] draft --- .../modules/ROOT/pages/object-mapping.adoc | 462 +++++++++++------- 1 file changed, 281 insertions(+), 181 deletions(-) diff --git a/javascript-manual/modules/ROOT/pages/object-mapping.adoc b/javascript-manual/modules/ROOT/pages/object-mapping.adoc index d8724237..2971ea8d 100644 --- a/javascript-manual/modules/ROOT/pages/object-mapping.adoc +++ b/javascript-manual/modules/ROOT/pages/object-mapping.adoc @@ -2,20 +2,106 @@ Neither JavaScript nor Neo4j are strongly typed, which can lead to mistakes where a property is set to the wrong type, or an unexpected type is returned from the database and causes errors in client code. -Object mapping helps contain such mistakes. It allows you to map result records into classes with properties of explicit types, and to submit typed query parameters. - -[NOTE] -==== -The classes being mapped must have a constructor function that can be called without arguments, and all properties that are mapped must be publicly accessible. -==== +Object mapping helps contain such mistakes. +It allows you to map result records into classes with properties of explicit types, and to submit typed query parameters. == Read from the database +To map records into objects of a specific type, define a class having the same attributes as the keys returned by the query. The class attributes must match exactly the query return keys (case included). + +=== Queries returning properties + +.Class and mapping definition +[source, typescript] +---- +class Movie { // <.> + title: string + release?: number + constructor(title: string, release: number) { + this.title = title + this.release = release + } +}; + +const movieRules: Rules = { // <.> + title: rule.asString({}), + release: rule.asNumber({ isInteger: true, optional: true, from: "released" }) // <.> +}; +---- + +<.> Each record from the query result must conform to the `Movie` class definition. +<.> `Rules` is a map-like object holding the typed mappings between the JavaScript class definitions and the database schema. Each property is specified via a link:https://neo4j.com/docs/api/javascript-driver/current/variable/index.html#static-variable-rule[`rule`] call. +<.> Type-specific (optional) attributes allow you to tweak the mapping behavior. For example, `release` is set to be optional, will automatically be converted from an integer in the database to a JavaScript number on the client, and is matched to the node property `released` even if the class attribute is called differently. For more information on allowed parameters, see link:https://neo4j.com/docs/api/javascript-driver/current/variable/index.html#static-variable-rule[API docs -> `rule`]. + +.Query execution +[source, typescript] +---- +let res = await driver.executeQuery>(` // <.> + MERGE (m:Movie {title: "Cloud atlas", released: 2013}) + RETURN m.title AS title, m.released AS released + `, {}, + {resultTransformer: neo4j.resultTransformers.hydrated(Movie, movieRules)} // <.> +) + +console.log(res.records[0]) +// Movie { title: 'Cloud atlas', release: 2013 } +---- + +<.> The return type `MappedQueryResult` is optional. If given, it must match the type provided in the `.hydrated()` call later. +<.> The link:https://neo4j.com/docs/api/javascript-driver/current/class/lib6/result-transformers.js~ResultTransformers.html#instance-method-hydrated[`.hydrated()`] call maps each return record to a `Movie` object according to `movieRules.` + +.A full example [source, typescript] ---- import neo4j, {Rules, rule, MappedQueryResult, Node} from 'neo4j-driver' -class Movie { // <.> +class Movie { + title: string + release?: number + constructor(title: string, release: number) { + this.title = title + this.release = release + } +}; + +const movieRules: Rules = { + title: rule.asString({}), + release: rule.asNumber({ isInteger: true, optional: true, from: "released" }) +}; + +(async () => { + + // URI examples: 'neo4j://localhost', 'neo4j+s://xxx.databases.neo4j.io' + const URI = '' + const USER = '' + const PASSWORD = '' + let driver = neo4j.driver(URI, neo4j.auth.basic(USER, PASSWORD)) + + let res = await driver.executeQuery>(` + MERGE (m:Movie {title: "Cloud atlas", released: 2013}) + RETURN m.title AS title, m.released AS released + `, {}, + {resultTransformer: neo4j.resultTransformers.hydrated(Movie, movieRules)} + ) + + console.log(res.records[0]) + // Movie { title: 'Cloud atlas', release: 2013 } + + await driver.close() + +})().catch((e: Error) => { + console.log(e) +}); +---- + +=== Queries returning nodes or relationships + +For queries returning graph entities (nodes/relationships), you need two classes: one with the inner properties of the node (ex. `Movie`), and one wrapping the node object itself (ex. `MovieNode`), which references the first class. + +.Class definitions +[source, typescript] +---- +class Movie { // <1> title: string release?: number constructor(title: string, release: number) { @@ -23,18 +109,74 @@ class Movie { // <.> this.release = release } }; -class MovieNode { // <.> +class MovieNode { // <1> movie: Movie constructor(movie: Movie) { this.movie = movie } -} +}; +---- -const movieRules: Rules = { // <.> +<.> Each record from the query result must conform to the `MovieNode` class definition, and the contents of nodes must conform to the `Movie` class. The mapper invokes class constructors with all arguments set to undefined and then populates them later, so don't rely on constructors to manipulate attributes. + +.Mapping definitions +[source, typescript] +---- +const movieRules: Rules = { // <1> + title: rule.asString({}), + release: rule.asNumber({ isInteger: true, optional: true, from: "released" }) // <2> +}; +const movieNodeRules: Rules = { // <1> + movie: rule.asNode({ + convert: (node: Node) => node.as(Movie, movieRules) + }), +}; +---- + +<.> `movieRules` and `movieNodeRules` are map-like objects holding the typed mappings between the JavaScript class definitions and the database schema. Properties are specified via link:https://neo4j.com/docs/api/javascript-driver/current/variable/index.html#static-variable-rule[`rule`] calls. +<.> Type-specific (optional) attributes allow you to tweak the mapping behavior. For example, `release` is set to be optional, will automatically be converted from an integer in the database to a JavaScript number on the client, and is matched to the node property `released` even if the class attribute is called differently. For more information on allowed parameters, see link:https://neo4j.com/docs/api/javascript-driver/current/variable/index.html#static-variable-rule[API docs -> `rule`]. + +.Query execution +[source, typescript] +---- +let res = await driver.executeQuery>( // <.> + `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie`, + {}, + {resultTransformer: neo4j.resultTransformers.hydrated(MovieNode, movieNodeRules)} // <.> +) + +console.log(res.records[0]) +// MovieNode { movie: Movie { title: 'Cloud atlas', release: 2013 } } +---- + +<.> The return type `MappedQueryResult` is optional. If given, it must match the type provided in the `.hydrated()` call later. +<.> The link:https://neo4j.com/docs/api/javascript-driver/current/class/lib6/result-transformers.js~ResultTransformers.html#instance-method-hydrated[`.hydrated()`] call maps each return record to a `MovieNode` object according to `movieNodeRules.` + +.A full example +[source, typescript] +---- +import neo4j, {Rules, rule, MappedQueryResult, Node} from 'neo4j-driver' + +class Movie { + title: string + release?: number + constructor(title: string, release: number) { + this.title = title + this.release = release + } +}; +class MovieNode { + movie: Movie + constructor(movie: Movie) { + this.movie = movie + } +}; + +const movieRules: Rules = { title: rule.asString({}), release: rule.asNumber({ isInteger: true, optional: true, from: "released" }) }; -const movieNodeRules: Rules = { // <.> +const movieNodeRules: Rules = { movie: rule.asNode({ convert: (node: Node) => node.as(Movie, movieRules) }), @@ -64,184 +206,184 @@ const movieNodeRules: Rules = { // <.> }); ---- -<.> l -<.> l -<.> l -<.> l -<.> l -<.> l +=== Queries returning objects -== Mapping Rules - -Given the following query: -[source, cypher] ----- -MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) -WHERE id(p) <> id(c) -RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars ----- +With `rule.asObject()` you can create mappings for results returning subsets of properties. +Excluding some properties from return can save bandwidth, especially if the nodes contain large properties (such as vectors) which are not relevant for the mapping. -And the following classes: +.Class definitions [source, typescript] ---- -class ActingJob { - person: Person - movie: Movie - roles: Roles - costars: Person[] - constructor(person: Person, movie: Movie, roles: Roles, costars: Person[]) { - this.person = person - this.movie = movie - this.roles = roles - this.costars = costars - } - - toString(): string { - return `${this.person.name} acted in ${this.movie.title} as the role(s) ${this.roles.roleNames.toString()} along with ${this.costars.map((p:person) => p.name).toString()}` - } -} - -class Movie { - title: string - release?: number - constructor(title: string, release: number) { - this.title = title - this.release = release - } -} - class Person { - name: string - born?: number - constructor(name: string, born: number) { - this.name = name - this.born = born - } -} - + name: string + born?: number + constructor(name: string, born: number) { + this.name = name + this.born = born + } +}; +class Movie { + title: string + release?: number + constructor(title: string, release: number) { + this.title = title + this.release = release + } +}; class Roles { - roleNames: string[] - constructor(roleNames: string[]) { - this.roleNames = roleNames - } -} + roleNames: string[] + constructor(roleNames: string[]) { + this.roleNames = roleNames + } +}; +class ActingJob { + person: Person + movie: Movie + roles: Roles + constructor(person: Person, movie: Movie, roles: Roles) { + this.person = person + this.movie = movie + this.roles = roles + } +}; ---- -This is a set of Rules objects to allow the query result to be mapped into a list of ActingJobs +Properties for each objects are specified via link:https://neo4j.com/docs/api/javascript-driver/current/variable/index.html#static-variable-rule[`rule`] calls. + +.Mapping definitions [source, typescript] ---- const personRules: Rules = { name: rule.asString(), born: rule.asNumber({ isInteger: true, optional: true }) -} ----- -This sets string type validation for the `name` property and Number type validation for `born`. In addition, the born property is set to be optional and be an Integer (this will automatically convert between an integer in the database and a JavaScript number in client code). - -[source, typescript] ----- +}; const movieRules: Rules = { title: rule.asString({}), release: rule.asNumber({ isInteger: true, optional: true, from: "released" }), -} - +}; const rolesRules = { roleNames: rule.asList({apply: rule.asString(), from: "roles"}) -} +}; +const actingRules: Rules = { + person: rule.asObject(Person, personRules), + movie: rule.asObject(Movie, movieRules), + roles: rule.asObject(Roles, rolesRules), + costars: rule.asList({ + apply: rule.asObject(Person, personRules), // <.> + }) +}; ---- -The Movie rules are rather similar to those for Person, but it introduces the extra rule config `from`. This simply changes the key the property is matched from, for when there is an inconsistency in the name used in client code and in the database. -The `asList` rule will apply the rule in it's `apply` parameter to every entry in the list. -Now we put these rules together to create the rules to map the query result to an ActingJob object. +<.> The `.asList()` rule applies the rule given in its `apply` parameter to every entry in the list. Here, each `costars` entry is made into a `Person` object. + +.Query execution [source, typescript] ---- -const actingJobRules: Rules = { - person: rule.asNode({ - convert: (node: Node) => node.as(Person, personRules) - }), - roles: rule.asRelationship({ - convert: (rel: Relationship) => rel.as(Roles, rolesRules) - }), - movie: rule.asNode({ - convert: (node: Node) => node.as(Movie, movieRules) - }), - costars: rule.asList({ - apply: rule.asNode({ - convert: (node: Node) => node.as(Person, personRules) - }) - }) -} +let res = await driver.executeQuery>(` // <.> + MATCH (p:Person)-[r:ACTED_IN]->(m:Movie) + RETURN + {name: p.name, born: p.born} AS person, + {roles: r.roles} AS roles, + {released: m.release, title: m.title} AS movie, + COLLECT({name: c.name, born: c.born}) AS costars + `, {}, + {resultTransformer: neo4j.resultTransformers.hydrated(ActingJob, actingRules)} // <.> +) + +console.log(res.records[0]) ---- -This introduces the functions `asNode`, `asRelationship`. These create rules that allow conversion of Relationships and Nodes into other types. -The mapping rules can be used to map Result objects, individual Records or be used to by a `hydrated` ResultTransformer to map the results of a query. +<.> The return type `MappedQueryResult` is optional. If given, it must match the type provided in the `.hydrated()` call later. +<.> The link:https://neo4j.com/docs/api/javascript-driver/current/class/lib6/result-transformers.js~ResultTransformers.html#instance-method-hydrated[`.hydrated()`] call maps each return record to a `ActingJob` object according to `actingRules.` -[NOTE] -==== -The funtions under `rule` create objects of the `Rule` class, it is possible to create custom `Rule` objects that perform more advanced type validation or conversions. The `Rule` type is exported by the driver and documented in the API docs. -==== -=== Usage with transaction functions +== Write to the database + +The Object Mapping rules also function for Object Parameter Mapping, allowing the same rules used to map records into object to be used to map objects to query parameters. + +If a domain object is a simple JavaScript object with all the properties in the correct format to be sent as parameters there is no need for any mapping to occur, but if temporal data is stored as Neo4j temporal types in the database but used as strings in client code, or if a Domain Object includes functions or properties which can not or should not be sent as parameters, the mapping rules can assist in sanitizing the data for transmission. + [source, typescript] ---- -await session.executeRead(async (tx) => { - return tx.run( - `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) - WHERE id(p) <> id(c) - RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars` - ).as(ActingJob, actingJobRules) //The Result is here converted to a MappedResult and can be consumed, subscribed to or awaited like a normal result, but all Records are replaced with ActingJobs. -}); +class TestObj { + number: number + string: string + date: string + list: string[] + constructor(number: number, string: string, date: string, list: string[]) { + this.number = number + this.string = string + this.date = date + this.list = list + } + function() { + return "function string" + } // the driver cannot send functions over bolt +} +const rules = { + number: neo4j.rule.asNumber({isInteger: true}), + string: neo4j.rule.asString(), + date: neo4j.rule.asDate({stringify: true}), // this will ensure date is stored as a Date in the DB and retrieved as a string + list: neo4j.rule.asList({ apply: neo4j.rule.asString() }), + function: { + parameterConversion: () => undefined, // explicitly skips the function when sending parameters + optional: true // Record mapping will attempt to get all properties of the object from the Record, we need to explicitly mark that it's okay that there is no function. This is not needed if this is only mapped to parameter objects, and never used to map Records to TestObjs + }, +} -res.records[0].person.born //after being awaited, res is a MappedQueryResult, containing an array of ActingJobs and a ResultSummary. You and your IDE can now feel safe in knowing that this is a Number. +neo4j.RecordObjectMapping.register(TestObj, rules) // Register the rules object to the TestObj class + +const res = await driver.executeQuery>( + 'RETURN $string as string, $number as number, $big_int as big_int, $date as date, $dob as dob, $list as list', + new TestObj(1, "hello", "2026-09-02", ["good", "bye"]), // executeQuery automatically applies the TestObj rules to this object. + {resultTransformer: neo4j.resultTransformers.hydrated(TestObj)}) ---- +== Mapping with different driver's APIs + === Usage with driver.executeQuery() + [source, typescript] ---- -await driver.executeQuery>( - `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) - WHERE id(p) <> id(c) - RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars`, +await driver.executeQuery>( + `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie` {}, - {resultTransformer: neo4j.resultTransformers.hydrated(ActingJob, actingJobRules)} + {resultTransformer: neo4j.resultTransformers.hydrated(movieNode, movieNodeRules)} ) ---- -=== Usage with session.run() +=== Usage with transaction functions + [source, typescript] ---- -await session.run( - `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) - WHERE id(p) <> id(c) - RETURN p AS person, r as roles, m AS movie, COLLECT(c) AS costars` -).as(ActingJob, actingJobRules) +await session.executeRead(async (tx) => { + return await tx.run( + `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie` + ).as(movieNode, movieNodeRules) // <.> +}); ---- -=== Nested Rules -The `asObject` rule factory allows createing nested rules. It allows to mapping of results into multiple objects without returning full nodes or relationships to transform. +<.> The `Result` is converted to a `MappedResult` and can be consumed, subscribed to, or awaited like a normal result. The difference is that `Record` objects are replaced with `movieNode` objects. -With `asObject`, a set of rules for ActingJobs that can be used without returning whole Nodes and Relationships can be created. +=== Usage with session.run() [source, typescript] ---- -const actingJobRulesObject: Rules = { - person: rule.asObject(Person, personRules), - roles: rule.asObject(Roles, rolesRules), - movie: rule.asObject(Movie, movieRules), - costars: rule.asList({ - apply: rule.asObject(Person, personRules), - }) -} - await session.run( - `MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) - WHERE id(p) <> id(c) - RETURN {name: p.name, born: p.born} AS person, {roles: r.roles} as roles, {released: m.release, title: m.title} AS movie, COLLECT({name: c.name, born: c.born}) AS costars` -).as(ActingJob, actingJobRulesObject) + `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie` +).as(movieNode, movieNodeRules) // <.> ---- -This setup saves on bandwitdth, especially if the nodes contain large properties like vectors which are not used for mapping. +<.> The `Result` is converted to a `MappedResult` and can be consumed, subscribed to, or awaited like a normal result. The difference is that `Record` objects are replaced with `movieNode` objects. + + +== Mapping behavior -== Registering per-type default rules. +- The mapper invokes class constructors with all arguments set to undefined and then populates them later, so don't rely on constructors to manipulate attributes. +- You can create custom `Rule` objects performing more advanced type validation or conversions. The `Rule` type is exported by the driver; for more information see link:[API docs -> `Rule`]. + + +=== Registering per-type default rules. In the examples above, both `ActingJob` and `actingJobRules` have been provided together, which requires these rules to be available in every part of the code where a query is run. The following code registers the rules to the object, meaning the rules do not need to be provided when using Object Mapping except to override the default. @@ -264,45 +406,3 @@ person: rule.asNode({ convert: (node: Node) => node.as(Person) }) ---- - -== Mapping Rules for Parameters - -The Object Mapping rules also function for Object Parameter Mapping, allowing the same rules used to map records into object to be used to map objects to query parameters. - -If a domain object is a simple JavaScript object with all the properties in the correct format to be sent as parameters there is no need for any mapping to occur, but if temporal data is stored as Neo4j temporal types in the database but used as strings in client code, or if a Domain Object includes functions or properties which can not or should not be sent as parameters, the mapping rules can assist in sanitizing the data for transmission. - -[source, typescript] ----- -class TestObj { - number: number - string: string - date: string - list: string[] - constructor(number: number, string: string, date: string, list: string[]) { - this.number = number - this.string = string - this.date = date - this.list = list - } - function() { - return "function string" - } // the driver cannot send functions over bolt -} -const rules = { - number: neo4j.rule.asNumber({isInteger: true}), - string: neo4j.rule.asString(), - date: neo4j.rule.asDate({stringify: true}), // this will ensure date is stored as a Date in the DB and retrieved as a string - list: neo4j.rule.asList({ apply: neo4j.rule.asString() }), - function: { - parameterConversion: () => undefined, // explicitly skips the function when sending parameters - optional: true // Record mapping will attempt to get all properties of the object from the Record, we need to explicitly mark that it's okay that there is no function. This is not needed if this is only mapped to parameter objects, and never used to map Records to TestObjs - }, -} - -neo4j.RecordObjectMapping.register(TestObj, rules) // Register the rules object to the TestObj class - -const res = await driver.executeQuery>( - 'RETURN $string as string, $number as number, $big_int as big_int, $date as date, $dob as dob, $list as list', - new TestObj(1, "hello", "2026-09-02", ["good", "bye"]), // executeQuery automatically applies the TestObj rules to this object. - {resultTransformer: neo4j.resultTransformers.hydrated(TestObj)}) ----- From b8928bb1ce5791b2a5ad7a16467a9500991e45a5 Mon Sep 17 00:00:00 2001 From: Stefano Ottolenghi Date: Fri, 18 Sep 2026 17:48:00 +0200 Subject: [PATCH 07/11] reshuffle --- .../modules/ROOT/pages/object-mapping.adoc | 101 +++++++++++------- 1 file changed, 62 insertions(+), 39 deletions(-) diff --git a/javascript-manual/modules/ROOT/pages/object-mapping.adoc b/javascript-manual/modules/ROOT/pages/object-mapping.adoc index 2971ea8d..32713f23 100644 --- a/javascript-manual/modules/ROOT/pages/object-mapping.adoc +++ b/javascript-manual/modules/ROOT/pages/object-mapping.adoc @@ -5,10 +5,12 @@ Neither JavaScript nor Neo4j are strongly typed, which can lead to mistakes wher Object mapping helps contain such mistakes. It allows you to map result records into classes with properties of explicit types, and to submit typed query parameters. +[[read]] == Read from the database To map records into objects of a specific type, define a class having the same attributes as the keys returned by the query. The class attributes must match exactly the query return keys (case included). +[[queries-properties]] === Queries returning properties .Class and mapping definition @@ -50,6 +52,7 @@ console.log(res.records[0]) <.> The return type `MappedQueryResult` is optional. If given, it must match the type provided in the `.hydrated()` call later. <.> The link:https://neo4j.com/docs/api/javascript-driver/current/class/lib6/result-transformers.js~ResultTransformers.html#instance-method-hydrated[`.hydrated()`] call maps each return record to a `Movie` object according to `movieRules.` +//// .A full example [source, typescript] ---- @@ -93,7 +96,9 @@ const movieRules: Rules = { console.log(e) }); ---- +//// +[[queries-nodes-rels]] === Queries returning nodes or relationships For queries returning graph entities (nodes/relationships), you need two classes: one with the inner properties of the node (ex. `Movie`), and one wrapping the node object itself (ex. `MovieNode`), which references the first class. @@ -152,6 +157,7 @@ console.log(res.records[0]) <.> The return type `MappedQueryResult` is optional. If given, it must match the type provided in the `.hydrated()` call later. <.> The link:https://neo4j.com/docs/api/javascript-driver/current/class/lib6/result-transformers.js~ResultTransformers.html#instance-method-hydrated[`.hydrated()`] call maps each return record to a `MovieNode` object according to `movieNodeRules.` +//// .A full example [source, typescript] ---- @@ -205,7 +211,9 @@ const movieNodeRules: Rules = { console.log(e) }); ---- +//// +[[queries-objects]] === Queries returning objects With `rule.asObject()` you can create mappings for results returning subsets of properties. @@ -296,7 +304,48 @@ console.log(res.records[0]) <.> The return type `MappedQueryResult` is optional. If given, it must match the type provided in the `.hydrated()` call later. <.> The link:https://neo4j.com/docs/api/javascript-driver/current/class/lib6/result-transformers.js~ResultTransformers.html#instance-method-hydrated[`.hydrated()`] call maps each return record to a `ActingJob` object according to `actingRules.` +[[drivers-apis]] +== Mapping with different driver's APIs + +[[driver-executequery]] +=== Usage with driver.executeQuery() + +[source, typescript] +---- +await driver.executeQuery>( + `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie` + {}, + {resultTransformer: neo4j.resultTransformers.hydrated(movieNode, movieNodeRules)} +) +---- + +[[driver-tx-func]] +=== Usage with transaction functions + +[source, typescript] +---- +await session.executeRead(async (tx) => { + return await tx.run( + `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie` + ).as(movieNode, movieNodeRules) // <.> +}); +---- + +<.> The `Result` is converted to a `MappedResult` and can be consumed, subscribed to, or awaited like a normal result. The difference is that `Record` objects are replaced with `movieNode` objects. + +[[driver-session-run]] +=== Usage with session.run() + +[source, typescript] +---- +await session.run( + `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie` +).as(movieNode, movieNodeRules) // <.> +---- + +<.> The `Result` is converted to a `MappedResult` and can be consumed, subscribed to, or awaited like a normal result. The difference is that `Record` objects are replaced with `movieNode` objects. +[[write]] == Write to the database The Object Mapping rules also function for Object Parameter Mapping, allowing the same rules used to map records into object to be used to map objects to query parameters. @@ -339,49 +388,12 @@ const res = await driver.executeQuery>( {resultTransformer: neo4j.resultTransformers.hydrated(TestObj)}) ---- -== Mapping with different driver's APIs - -=== Usage with driver.executeQuery() - -[source, typescript] ----- -await driver.executeQuery>( - `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie` - {}, - {resultTransformer: neo4j.resultTransformers.hydrated(movieNode, movieNodeRules)} -) ----- - -=== Usage with transaction functions - -[source, typescript] ----- -await session.executeRead(async (tx) => { - return await tx.run( - `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie` - ).as(movieNode, movieNodeRules) // <.> -}); ----- - -<.> The `Result` is converted to a `MappedResult` and can be consumed, subscribed to, or awaited like a normal result. The difference is that `Record` objects are replaced with `movieNode` objects. - -=== Usage with session.run() - -[source, typescript] ----- -await session.run( - `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie` -).as(movieNode, movieNodeRules) // <.> ----- - -<.> The `Result` is converted to a `MappedResult` and can be consumed, subscribed to, or awaited like a normal result. The difference is that `Record` objects are replaced with `movieNode` objects. - - +[[mapping-behavior]] == Mapping behavior - The mapper invokes class constructors with all arguments set to undefined and then populates them later, so don't rely on constructors to manipulate attributes. - You can create custom `Rule` objects performing more advanced type validation or conversions. The `Rule` type is exported by the driver; for more information see link:[API docs -> `Rule`]. - +- global state === Registering per-type default rules. In the examples above, both `ActingJob` and `actingJobRules` have been provided together, which requires these rules to be available in every part of the code where a query is run. @@ -406,3 +418,14 @@ person: rule.asNode({ convert: (node: Node) => node.as(Person) }) ---- + + +// Alternatively, the rules can be registered in the mapping registry. +// This registry exists in global memory and will persist even between driver instances. + +neo4j.RecordObjectMapping.register(Person, PersonRules) + +// after registering the rule the transformer will follow them when mapping to the provided type +const summary = await driver.executeQuery('CREATE (p:Person{ name: $name }) RETURN p', { name: 'Person1'}, { + resultTransformer: neo4j.resultTransformers.hydrated(Person) +}) From 7fd05646b5da77268464a991aebd11145fb1fc3a Mon Sep 17 00:00:00 2001 From: Stefano Ottolenghi Date: Mon, 21 Sep 2026 16:49:37 +0200 Subject: [PATCH 08/11] final --- .../modules/ROOT/pages/object-mapping.adoc | 164 +++++++++++------- 1 file changed, 97 insertions(+), 67 deletions(-) diff --git a/javascript-manual/modules/ROOT/pages/object-mapping.adoc b/javascript-manual/modules/ROOT/pages/object-mapping.adoc index 32713f23..bc9967c5 100644 --- a/javascript-manual/modules/ROOT/pages/object-mapping.adoc +++ b/javascript-manual/modules/ROOT/pages/object-mapping.adoc @@ -1,7 +1,6 @@ = Type checking and object mapping Neither JavaScript nor Neo4j are strongly typed, which can lead to mistakes where a property is set to the wrong type, or an unexpected type is returned from the database and causes errors in client code. - Object mapping helps contain such mistakes. It allows you to map result records into classes with properties of explicit types, and to submit typed query parameters. @@ -276,9 +275,7 @@ const actingRules: Rules = { person: rule.asObject(Person, personRules), movie: rule.asObject(Movie, movieRules), roles: rule.asObject(Roles, rolesRules), - costars: rule.asList({ - apply: rule.asObject(Person, personRules), // <.> - }) + costars: rule.asList({ apply: rule.asObject(Person, personRules) }) // <.> }; ---- @@ -304,6 +301,51 @@ console.log(res.records[0]) <.> The return type `MappedQueryResult` is optional. If given, it must match the type provided in the `.hydrated()` call later. <.> The link:https://neo4j.com/docs/api/javascript-driver/current/class/lib6/result-transformers.js~ResultTransformers.html#instance-method-hydrated[`.hydrated()`] call maps each return record to a `ActingJob` object according to `actingRules.` +[[type-defaults]] +=== Registering per-type default rules + +The previous examples provided `ActingJob` and `actingRules` together, which requires the rules objects to be available in every part of the code where a query is run. + +To avoid this, you can link the rules to the object, so that you don't have to provide the rules anywhere else (except to override the default). +This registry exists in global memory and is thus shared between driver instances. + +.Register rules to their objects +[source, typescript, test-skip] +---- +recordObjectMapping.register(Person, personRules) +recordObjectMapping.register(Movie, movieRules) +recordObjectMapping.register(Roles, rolesRules) +---- + +The object rules and the `.hydrate()` call can then be stripped of their rules objects: + +.Simplified rules +[source, typescript, test-skip] +---- +const actingRules: Rules = { + person: rule.asObject(Person), + movie: rule.asObject(Movie), + roles: rule.asObject(Roles), + costars: rule.asList({ apply: rule.asObject(Person) }) +}; +---- + +.Simplified query execution +[source, typescript] +---- +let res = await driver.executeQuery>(` + MATCH (p:Person)-[r:ACTED_IN]->(m:Movie) + RETURN + {name: p.name, born: p.born} AS person, + {roles: r.roles} AS roles, + {released: m.release, title: m.title} AS movie, + COLLECT({name: c.name, born: c.born}) AS costars + `, {}, + {resultTransformer: neo4j.resultTransformers.hydrated(ActingJob)} +) +---- + + [[drivers-apis]] == Mapping with different driver's APIs @@ -348,84 +390,72 @@ await session.run( [[write]] == Write to the database -The Object Mapping rules also function for Object Parameter Mapping, allowing the same rules used to map records into object to be used to map objects to query parameters. +The mapping features allow you map objects into database entities when providing them as query parameters. -If a domain object is a simple JavaScript object with all the properties in the correct format to be sent as parameters there is no need for any mapping to occur, but if temporal data is stored as Neo4j temporal types in the database but used as strings in client code, or if a Domain Object includes functions or properties which can not or should not be sent as parameters, the mapping rules can assist in sanitizing the data for transmission. +Client objects having properties of a native type (ex. strings) can be mapped directly; on the other hand, mapping rules can help sanitize or transform other types of data (ex. numbers, temporal types) before sending them to the server. [source, typescript] ---- -class TestObj { - number: number - string: string - date: string - list: string[] - constructor(number: number, string: string, date: string, list: string[]) { - this.number = number - this.string = string - this.date = date - this.list = list - } - function() { - return "function string" - } // the driver cannot send functions over bolt -} -const rules = { - number: neo4j.rule.asNumber({isInteger: true}), - string: neo4j.rule.asString(), - date: neo4j.rule.asDate({stringify: true}), // this will ensure date is stored as a Date in the DB and retrieved as a string - list: neo4j.rule.asList({ apply: neo4j.rule.asString() }), - function: { - parameterConversion: () => undefined, // explicitly skips the function when sending parameters - optional: true // Record mapping will attempt to get all properties of the object from the Record, we need to explicitly mark that it's okay that there is no function. This is not needed if this is only mapped to parameter objects, and never used to map Records to TestObjs - }, +class Person { + name: string + created?: string + + constructor(name: string, created: string) { + this.name = name + this.created = created + } +}; + +const rules = { // <.> + name: rule.asString(), + created: neo4j.rule.asDate({ stringify: true }), // stored as Date in the DB; mapped as string } -neo4j.RecordObjectMapping.register(TestObj, rules) // Register the rules object to the TestObj class +neo4j.RecordObjectMapping.register(Person, rules) // <.> -const res = await driver.executeQuery>( - 'RETURN $string as string, $number as number, $big_int as big_int, $date as date, $dob as dob, $list as list', - new TestObj(1, "hello", "2026-09-02", ["good", "bye"]), // executeQuery automatically applies the TestObj rules to this object. - {resultTransformer: neo4j.resultTransformers.hydrated(TestObj)}) +const res = await driver.executeQuery>(` + MERGE (p:Person {name: $name, created: $created}) + RETURN p.name AS name, p.created AS created + `, new Person("Bob the Builder", "1999-04-12"), // <.> + { resultTransformer: neo4j.resultTransformers.hydrated(Person) }) ---- -[[mapping-behavior]] -== Mapping behavior - -- The mapper invokes class constructors with all arguments set to undefined and then populates them later, so don't rely on constructors to manipulate attributes. -- You can create custom `Rule` objects performing more advanced type validation or conversions. The `Rule` type is exported by the driver; for more information see link:[API docs -> `Rule`]. -- global state +<.> Rules and class definitions work similarly as for xref:#read[reading data]. +<.> The `rules` object must be registered into the mapping registry, associated with the class the rules refer to. +<.> An object of type `Person` is sent as query parameter. -=== Registering per-type default rules. -In the examples above, both `ActingJob` and `actingJobRules` have been provided together, which requires these rules to be available in every part of the code where a query is run. +[TIP] +==== +If a class you use for mapping has **function properties**, you need to exclude such properties from mapping, as the driver has no way of meaningfully serializing functions. -The following code registers the rules to the object, meaning the rules do not need to be provided when using Object Mapping except to override the default. [source, typescript] ---- -recordObjectMapping.register(ActingJob, actingJobRules) ----- +class Person { + constructor() { + this.function = () => "test" // <.> + } + function2() { // <.> + return "function string" + } +}; -This includes inside the rules themselves. When writing the `actingJobRules`, we had to set the rule for the `person` property as -[source, typescript] ----- -person: rule.asNode({ - convert: (node: Node) => node.as(Person, personRules) - }) ----- -however, if we know we have registed `personRules` to `Person` with `recordObjectMapping.register(ActingJob, actingJobRules)` we can replace that with: -[source, typescript] ----- -person: rule.asNode({ - convert: (node: Node) => node.as(Person) - }) +const rules = { + function: { + parameterConversion: () => undefined, // <.> + optional: true // <.> + } +} ---- +<.> This function is a property, and the driver will attempt to map it. +<.> This function is a method, and will not cause issues. +<.> With an undefined `parameterConversion`, the function property is skipped when sending objects instances as parameters. +<.> As the record mapping attempts to fill all the object's properties when receiving results, the function property must be optional. +==== -// Alternatively, the rules can be registered in the mapping registry. -// This registry exists in global memory and will persist even between driver instances. - -neo4j.RecordObjectMapping.register(Person, PersonRules) +[[mapping-behavior]] +== Mapping behavior -// after registering the rule the transformer will follow them when mapping to the provided type -const summary = await driver.executeQuery('CREATE (p:Person{ name: $name }) RETURN p', { name: 'Person1'}, { - resultTransformer: neo4j.resultTransformers.hydrated(Person) -}) +- The mapper invokes class constructors with all arguments set to undefined and then populates them later, so don't rely on constructors to manipulate attributes. +- You can create custom `Rule` objects performing more advanced type validation or conversions. The `Rule` type is exported by the driver. +////; for more information see link:[API docs -> `Rule`]. From dc59f125dc01ca6c10baf10ba52c82b8cf3d2a56 Mon Sep 17 00:00:00 2001 From: Stefano Ottolenghi Date: Wed, 23 Sep 2026 18:50:43 +0200 Subject: [PATCH 09/11] testable examples --- .../modules/ROOT/pages/object-mapping.adoc | 323 +++++++++++------- 1 file changed, 192 insertions(+), 131 deletions(-) diff --git a/javascript-manual/modules/ROOT/pages/object-mapping.adoc b/javascript-manual/modules/ROOT/pages/object-mapping.adoc index bc9967c5..35567751 100644 --- a/javascript-manual/modules/ROOT/pages/object-mapping.adoc +++ b/javascript-manual/modules/ROOT/pages/object-mapping.adoc @@ -4,6 +4,16 @@ Neither JavaScript nor Neo4j are strongly typed, which can lead to mistakes wher Object mapping helps contain such mistakes. It allows you to map result records into classes with properties of explicit types, and to submit typed query parameters. +[TIP] +==== +The examples in this page assume mapping symbols are imported into your application: + +[source, typescript] +---- +import neo4j, {Rules, rule, MappedQueryResult, Node, RecordObjectMapping} from 'neo4j-driver' +---- +==== + [[read]] == Read from the database @@ -13,7 +23,7 @@ To map records into objects of a specific type, define a class having the same a === Queries returning properties .Class and mapping definition -[source, typescript] +[source, typescript, test-id=queries-props] ---- class Movie { // <.> title: string @@ -35,7 +45,7 @@ const movieRules: Rules = { // <.> <.> Type-specific (optional) attributes allow you to tweak the mapping behavior. For example, `release` is set to be optional, will automatically be converted from an integer in the database to a JavaScript number on the client, and is matched to the node property `released` even if the class attribute is called differently. For more information on allowed parameters, see link:https://neo4j.com/docs/api/javascript-driver/current/variable/index.html#static-variable-rule[API docs -> `rule`]. .Query execution -[source, typescript] +[source, typescript, test-id=queries-props] ---- let res = await driver.executeQuery>(` // <.> MERGE (m:Movie {title: "Cloud atlas", released: 2013}) @@ -51,59 +61,13 @@ console.log(res.records[0]) <.> The return type `MappedQueryResult` is optional. If given, it must match the type provided in the `.hydrated()` call later. <.> The link:https://neo4j.com/docs/api/javascript-driver/current/class/lib6/result-transformers.js~ResultTransformers.html#instance-method-hydrated[`.hydrated()`] call maps each return record to a `Movie` object according to `movieRules.` -//// -.A full example -[source, typescript] ----- -import neo4j, {Rules, rule, MappedQueryResult, Node} from 'neo4j-driver' - -class Movie { - title: string - release?: number - constructor(title: string, release: number) { - this.title = title - this.release = release - } -}; - -const movieRules: Rules = { - title: rule.asString({}), - release: rule.asNumber({ isInteger: true, optional: true, from: "released" }) -}; - -(async () => { - - // URI examples: 'neo4j://localhost', 'neo4j+s://xxx.databases.neo4j.io' - const URI = '' - const USER = '' - const PASSWORD = '' - let driver = neo4j.driver(URI, neo4j.auth.basic(USER, PASSWORD)) - - let res = await driver.executeQuery>(` - MERGE (m:Movie {title: "Cloud atlas", released: 2013}) - RETURN m.title AS title, m.released AS released - `, {}, - {resultTransformer: neo4j.resultTransformers.hydrated(Movie, movieRules)} - ) - - console.log(res.records[0]) - // Movie { title: 'Cloud atlas', release: 2013 } - - await driver.close() - -})().catch((e: Error) => { - console.log(e) -}); ----- -//// - [[queries-nodes-rels]] === Queries returning nodes or relationships -For queries returning graph entities (nodes/relationships), you need two classes: one with the inner properties of the node (ex. `Movie`), and one wrapping the node object itself (ex. `MovieNode`), which references the first class. +For queries returning graph entities (nodes/relationships), you need two classes: one with the inner properties of the node (ex. `Movie`), and one wrapping the node object itself (ex. `movieNode`), which references the first class. .Class definitions -[source, typescript] +[source, typescript, test-id=queries-nodes] ---- class Movie { // <1> title: string @@ -113,7 +77,7 @@ class Movie { // <1> this.release = release } }; -class MovieNode { // <1> +class movieNode { // <1> movie: Movie constructor(movie: Movie) { this.movie = movie @@ -121,10 +85,10 @@ class MovieNode { // <1> }; ---- -<.> Each record from the query result must conform to the `MovieNode` class definition, and the contents of nodes must conform to the `Movie` class. The mapper invokes class constructors with all arguments set to undefined and then populates them later, so don't rely on constructors to manipulate attributes. +<.> Each record from the query result must conform to the `movieNode` class definition, and the contents of nodes must conform to the `Movie` class. The mapper invokes class constructors with all arguments set to undefined and then populates them later, so don't rely on constructors to manipulate attributes. .Mapping definitions -[source, typescript] +[source, typescript, test-id=queries-nodes] ---- const movieRules: Rules = { // <1> title: rule.asString({}), @@ -141,76 +105,20 @@ const movieNodeRules: Rules = { // <1> <.> Type-specific (optional) attributes allow you to tweak the mapping behavior. For example, `release` is set to be optional, will automatically be converted from an integer in the database to a JavaScript number on the client, and is matched to the node property `released` even if the class attribute is called differently. For more information on allowed parameters, see link:https://neo4j.com/docs/api/javascript-driver/current/variable/index.html#static-variable-rule[API docs -> `rule`]. .Query execution -[source, typescript] +[source, typescript, test-id=queries-nodes] ---- -let res = await driver.executeQuery>( // <.> +let res = await driver.executeQuery>( // <.> `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie`, {}, - {resultTransformer: neo4j.resultTransformers.hydrated(MovieNode, movieNodeRules)} // <.> + {resultTransformer: neo4j.resultTransformers.hydrated(movieNode, movieNodeRules)} // <.> ) console.log(res.records[0]) -// MovieNode { movie: Movie { title: 'Cloud atlas', release: 2013 } } ----- - -<.> The return type `MappedQueryResult` is optional. If given, it must match the type provided in the `.hydrated()` call later. -<.> The link:https://neo4j.com/docs/api/javascript-driver/current/class/lib6/result-transformers.js~ResultTransformers.html#instance-method-hydrated[`.hydrated()`] call maps each return record to a `MovieNode` object according to `movieNodeRules.` - -//// -.A full example -[source, typescript] +// movieNode { movie: Movie { title: 'Cloud atlas', release: 2013 } } ---- -import neo4j, {Rules, rule, MappedQueryResult, Node} from 'neo4j-driver' - -class Movie { - title: string - release?: number - constructor(title: string, release: number) { - this.title = title - this.release = release - } -}; -class MovieNode { - movie: Movie - constructor(movie: Movie) { - this.movie = movie - } -}; -const movieRules: Rules = { - title: rule.asString({}), - release: rule.asNumber({ isInteger: true, optional: true, from: "released" }) -}; -const movieNodeRules: Rules = { - movie: rule.asNode({ - convert: (node: Node) => node.as(Movie, movieRules) - }), -}; - -(async () => { - - // URI examples: 'neo4j://localhost', 'neo4j+s://xxx.databases.neo4j.io' - const URI = '' - const USER = '' - const PASSWORD = '' - let driver = neo4j.driver(URI, neo4j.auth.basic(USER, PASSWORD)) - - let res = await driver.executeQuery>( // <.> - `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie`, - {}, - {resultTransformer: neo4j.resultTransformers.hydrated(MovieNode, movieNodeRules)} // <.> - ) - - console.log(res.records[0]) - // MovieNode { movie: Movie { title: 'Cloud atlas', release: 2013 } } - - await driver.close() - -})().catch((e: Error) => { - console.log(e) -}); ----- -//// +<.> The return type `MappedQueryResult` is optional. If given, it must match the type provided in the `.hydrated()` call later. +<.> The link:https://neo4j.com/docs/api/javascript-driver/current/class/lib6/result-transformers.js~ResultTransformers.html#instance-method-hydrated[`.hydrated()`] call maps each return record to a `movieNode` object according to `movieNodeRules.` [[queries-objects]] === Queries returning objects @@ -219,7 +127,7 @@ With `rule.asObject()` you can create mappings for results returning subsets of Excluding some properties from return can save bandwidth, especially if the nodes contain large properties (such as vectors) which are not relevant for the mapping. .Class definitions -[source, typescript] +[source, typescript, test-id=queries-objects] ---- class Person { name: string @@ -258,7 +166,7 @@ class ActingJob { Properties for each objects are specified via link:https://neo4j.com/docs/api/javascript-driver/current/variable/index.html#static-variable-rule[`rule`] calls. .Mapping definitions -[source, typescript] +[source, typescript, test-id=queries-objects] ---- const personRules: Rules = { name: rule.asString(), @@ -282,10 +190,10 @@ const actingRules: Rules = { <.> The `.asList()` rule applies the rule given in its `apply` parameter to every entry in the list. Here, each `costars` entry is made into a `Person` object. .Query execution -[source, typescript] +[source, typescript, test-id=queries-objects] ---- let res = await driver.executeQuery>(` // <.> - MATCH (p:Person)-[r:ACTED_IN]->(m:Movie) + MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) RETURN {name: p.name, born: p.born} AS person, {roles: r.roles} AS roles, @@ -309,18 +217,69 @@ The previous examples provided `ActingJob` and `actingRules` together, which req To avoid this, you can link the rules to the object, so that you don't have to provide the rules anywhere else (except to override the default). This registry exists in global memory and is thus shared between driver instances. +//// +.Class and rules definitions +[source, typescript, test-id=obj-defaults] +---- +class Person { + name: string + born?: number + constructor(name: string, born: number) { + this.name = name + this.born = born + } +}; +class Movie { + title: string + release?: number + constructor(title: string, release: number) { + this.title = title + this.release = release + } +}; +class Roles { + roleNames: string[] + constructor(roleNames: string[]) { + this.roleNames = roleNames + } +}; +class ActingJob { + person: Person + movie: Movie + roles: Roles + constructor(person: Person, movie: Movie, roles: Roles) { + this.person = person + this.movie = movie + this.roles = roles + } +}; + +const personRules: Rules = { + name: rule.asString(), + born: rule.asNumber({ isInteger: true, optional: true }) +}; +const movieRules: Rules = { + title: rule.asString({}), + release: rule.asNumber({ isInteger: true, optional: true, from: "released" }), +}; +const rolesRules = { + roleNames: rule.asList({apply: rule.asString(), from: "roles"}) +}; +---- +//// + .Register rules to their objects -[source, typescript, test-skip] +[source, typescript, test-id=obj-defaults] ---- -recordObjectMapping.register(Person, personRules) -recordObjectMapping.register(Movie, movieRules) -recordObjectMapping.register(Roles, rolesRules) +neo4j.RecordObjectMapping.register(Person, personRules) +neo4j.RecordObjectMapping.register(Movie, movieRules) +neo4j.RecordObjectMapping.register(Roles, rolesRules) ---- The object rules and the `.hydrate()` call can then be stripped of their rules objects: .Simplified rules -[source, typescript, test-skip] +[source, typescript, test-id=obj-defaults] ---- const actingRules: Rules = { person: rule.asObject(Person), @@ -331,10 +290,10 @@ const actingRules: Rules = { ---- .Simplified query execution -[source, typescript] +[source, typescript, test-id=obj-defaults] ---- let res = await driver.executeQuery>(` - MATCH (p:Person)-[r:ACTED_IN]->(m:Movie) + MATCH (p:Person)-[r:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(c:Person) RETURN {name: p.name, born: p.born} AS person, {roles: r.roles} AS roles, @@ -352,10 +311,44 @@ let res = await driver.executeQuery>(` [[driver-executequery]] === Usage with driver.executeQuery() -[source, typescript] +//// +.Class and mapping definitions +[source, typescript, test-id=api-executequery] +---- +class Movie { + title: string + release?: number + constructor(title: string, release: number) { + this.title = title + this.release = release + } +}; +class movieNode { + movie: Movie + constructor(movie: Movie) { + this.movie = movie + } +}; +---- + +[source, typescript, test-id=api-executequery] +---- +const movieRules: Rules = { + title: rule.asString({}), + release: rule.asNumber({ isInteger: true, optional: true, from: "released" }) +}; +const movieNodeRules: Rules = { + movie: rule.asNode({ + convert: (node: Node) => node.as(Movie, movieRules) + }), +}; +---- +//// + +[source, typescript, test-id=api-executequery] ---- await driver.executeQuery>( - `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie` + `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie`, {}, {resultTransformer: neo4j.resultTransformers.hydrated(movieNode, movieNodeRules)} ) @@ -364,9 +357,43 @@ await driver.executeQuery>( [[driver-tx-func]] === Usage with transaction functions -[source, typescript] +//// +.Class and mapping definitions +[source, typescript, test-id=api-tx-func] ---- -await session.executeRead(async (tx) => { +class Movie { + title: string + release?: number + constructor(title: string, release: number) { + this.title = title + this.release = release + } +}; +class movieNode { + movie: Movie + constructor(movie: Movie) { + this.movie = movie + } +}; +---- + +[source, typescript, test-id=api-tx-func] +---- +const movieRules: Rules = { + title: rule.asString({}), + release: rule.asNumber({ isInteger: true, optional: true, from: "released" }) +}; +const movieNodeRules: Rules = { + movie: rule.asNode({ + convert: (node: Node) => node.as(Movie, movieRules) + }), +}; +---- +//// + +[source, typescript, test-id=api-tx-func] +---- +await session.executeWrite(async (tx) => { return await tx.run( `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie` ).as(movieNode, movieNodeRules) // <.> @@ -378,7 +405,41 @@ await session.executeRead(async (tx) => { [[driver-session-run]] === Usage with session.run() -[source, typescript] +//// +.Class and mapping definitions +[source, typescript, test-id=api-session-run] +---- +class Movie { + title: string + release?: number + constructor(title: string, release: number) { + this.title = title + this.release = release + } +}; +class movieNode { + movie: Movie + constructor(movie: Movie) { + this.movie = movie + } +}; +---- + +[source, typescript, test-id=api-session-run] +---- +const movieRules: Rules = { + title: rule.asString({}), + release: rule.asNumber({ isInteger: true, optional: true, from: "released" }) +}; +const movieNodeRules: Rules = { + movie: rule.asNode({ + convert: (node: Node) => node.as(Movie, movieRules) + }), +}; +---- +//// + +[source, typescript, test-id=api-session-run] ---- await session.run( `MERGE (m:Movie {title: "Cloud atlas", released: 2013}) RETURN m AS movie` From 588614f719190f10886e660e867de09bf2f6289c Mon Sep 17 00:00:00 2001 From: Stefano Ottolenghi Date: Wed, 23 Sep 2026 18:50:51 +0200 Subject: [PATCH 10/11] fix example --- javascript-manual/modules/ROOT/pages/object-mapping.adoc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/javascript-manual/modules/ROOT/pages/object-mapping.adoc b/javascript-manual/modules/ROOT/pages/object-mapping.adoc index 35567751..7032a5ae 100644 --- a/javascript-manual/modules/ROOT/pages/object-mapping.adoc +++ b/javascript-manual/modules/ROOT/pages/object-mapping.adoc @@ -492,8 +492,9 @@ If a class you use for mapping has **function properties**, you need to exclude [source, typescript] ---- class Person { + function1: Function constructor() { - this.function = () => "test" // <.> + this.function1 = () => "test" // <.> } function2() { // <.> return "function string" From be65daf5282e575e8cdb9bf9a6c22ab651a18b54 Mon Sep 17 00:00:00 2001 From: Stefano Date: Thu, 24 Sep 2026 10:08:21 +0200 Subject: [PATCH 11/11] Apply suggestion from @stefano-ottolenghi --- javascript-manual/modules/ROOT/pages/object-mapping.adoc | 1 - 1 file changed, 1 deletion(-) diff --git a/javascript-manual/modules/ROOT/pages/object-mapping.adoc b/javascript-manual/modules/ROOT/pages/object-mapping.adoc index 7032a5ae..3afab1e2 100644 --- a/javascript-manual/modules/ROOT/pages/object-mapping.adoc +++ b/javascript-manual/modules/ROOT/pages/object-mapping.adoc @@ -520,4 +520,3 @@ const rules = { - The mapper invokes class constructors with all arguments set to undefined and then populates them later, so don't rely on constructors to manipulate attributes. - You can create custom `Rule` objects performing more advanced type validation or conversions. The `Rule` type is exported by the driver. -////; for more information see link:[API docs -> `Rule`].