Skip to content

Expand Record Object Mapping to allow Parameter Mapping#1362

Open
MaxAake wants to merge 32 commits into6.xfrom
parameter-mapping
Open

Expand Record Object Mapping to allow Parameter Mapping#1362
MaxAake wants to merge 32 commits into6.xfrom
parameter-mapping

Conversation

@MaxAake
Copy link
Contributor

@MaxAake MaxAake commented Nov 19, 2025

closes DRIVERS-107

This PR provides support for typechecking and automatic conversion of parameters, as well as using the object mapping registry to register mapping strategies for classes. This lets users directly pass objects even with problematic properties that can not be sent over bolt (like functions), by automatically converting them or by omitting them from the mapping strategy.

This is the 2nd half of the Record Object Mapping feature, which is also marked stabilized and taken out of preview in this PR.

Examples

class Obj {
    constructor (obj) {
        this.string = obj?.string ?? 'hi'
        this.number = obj?.number ?? 1
        this.bigint = obj?.bigint ?? BigInt(1)
        this.date = obj?.date ?? "2024-01-01"
        this.localDate = obj?.localDate ?? "2024-01-01"
        this.dateTime = obj?.dateTime ?? new neo4j.DateTime(1, 1, 1, 1, 1, 1, 1, 0)._toUTC()
        this.localDateTime = obj?.localDateTime ?? new neo4j.LocalDateTime(1, 1, 1, 1, 1, 1, 1).toString()
        this.duration = obj?.duration ?? "P1DT5.00007S"
        this.time = obj?.time ?? "10:11:12.13Z"
        this.localTime = obj?.localTime ?? "10:11:12.0001"
        this.list = obj?.list ?? ["hi"]
        this.function = () => "function string" // bolt cannot send functions
        this.node = new neo4j.Node("123", [], {}) // nor can it send nodes
    }
}

const session = driver.session()
const rules = {
    number: neo4j.rule.asNumber(),
    string: neo4j.rule.asString(),
    bigint: neo4j.rule.asBigInt({acceptNumber: true}),
    date: neo4j.rule.asDate({stringify: true}), // this will ensure date is stored as a Date in the DB and retrieved as a string 
    localDate: neo4j.rule.asDate({stringify: true}),
    dateTime: neo4j.rule.asDateTime({stringify: true}),
    localDateTime: neo4j.rule.asLocalDateTime({stringify: true}),
    duration: neo4j.rule.asDuration({stringify: true}),
    time: neo4j.rule.asTime({from: "dob", stringify: true}),
    localTime: neo4j.rule.asLocalTime({stringify: true}),
    list: neo4j.rule.asList({ apply: neo4j.rule.asString() }),
} // not including function and node here will make the driver skip sending them. They could alternatively be converted to a bolt-compatible type.

neo4j.RecordObjectMapping.register(Obj, rules)

// This allows us to use camelCase for our object properties and snake_case for the properties on our node in the DB
neo4j.RecordObjectMapping.translateIdentifiers(neo4j.RecordObjectMapping.getCaseTranslator("snake_case", "camelCase"))

session.run(
  'MERGE (n {string: $string, number: $number, bigint: $bigint, date: $date, local_date: $local_date, date_time: $date_time, local_date_time: $local_date_time, duration: $duration, dob: $dob, local_time: $local_time, list: $list}) RETURN n',
  new Obj(),
  {}
).as({n: {convert: (n) => new Obj(n.as(rules))}})
.then((res) => {
    console.log(res.records[0])
    session.close()
    driver.close()
})

@MaxAake MaxAake marked this pull request as ready for review January 29, 2026 14:33
@robsdedude robsdedude self-requested a review January 30, 2026 10:03
Copy link
Member

@robsdedude robsdedude left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review part 1. I'll continue next week with the review. I just want to submit my comments to make sure they don't get lost.

* @param {string} str The string to convert
* @returns {LocalDateTime<NumberOrInteger>}
*/
static fromString (str: string): LocalDateTime<NumberOrInteger> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's a list of strings that probably should fail, but don't.

      '+2026-01-05T15:36:42',
      '-2026-01-05T15:36:42',
      '20260-01-05T15:36:42',
      '2026-001-05T15:36:42',

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latter two are still being accepted.

@MaxAake
Copy link
Contributor Author

MaxAake commented Feb 13, 2026

Consider making the optional parameter on the rules also allow for the record to totally lack that key, rather than only allow it to be undefined.

@MaxAake MaxAake requested a review from robsdedude March 4, 2026 08:23
Copy link
Member

@robsdedude robsdedude left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here goes a first round of comments. Submitting them now so they don't get lost until tomorrow when I shall continue.

* @param {string} str The string to convert
* @returns {Date<NumberOrInteger>}
*/
static fromString (str: string): Date<Integer> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell, neo4j.Date.fromString("2026-02-31") works just fine and yields a data that doesn't exist: Feb. 31st 2026. Granted, node JS also parses this date fine but wraps the days around into March

> new Date("2026-02-31")
2026-03-03T00:00:00.000Z

I tested round tripping the invalid neo4j.Date via Bolt and it ends up wrapping like nodeJS does locally, too. I'm fairly certain that's because of bolt's encoding of dates using ordinals.

Note that this behavior will likely cause issues when adding/productizing HTTP Query API support because the server will likely not accept "2026-02-31" as a date string.

I realize that the neo4j.Date constructor was not added in this PR, so there not much to be done here besides raising awareness of the quirk and maybe putting a plan in place to fix it in the next major version.

*/
function validateQueryAndParameters (
query: string | String | { text: string, parameters?: any },
query: string | String | { text: string, parameters?: any, parameterRules?: Rules },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parameterRules missing in doc comment

query: string | String | { text: string, parameters?: any, parameterRules?: Rules },
parameters?: any,
opt?: { skipAsserts: boolean }
opt?: { skipAsserts?: boolean, parameterRules?: Rules }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opt missing in doc comment

* @returns {Time<NumberOrInteger>}
*/
static fromString (str: string): Time<Integer> {
const values = String(str.replace(/,/g, '.')).match(/^[T|t]?(\d{2}):?(\d{2})?:?(\d{2})?(\.\d+)?(Z|\+|-)(\d{0,2}):?(\d{0,2}):?(\d{0,2})$/)
Copy link
Member

@robsdedude robsdedude Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find the timezone offset matching too lenient. Here's a suggested exhaustive list of formats (placeholders with lower case letters - each letter exactly one digit 0-9, other characters are literals):

  • Z
  • +hh
  • +hhmm
  • +hh:mm
  • +hh:mm:ss (note: not ISO conform, but the HTTP Query API server as well as the driver itself might still produce it 😬)
  • -hh
  • -hhmm
  • -hh:mm
  • -hh:mm:ss (note: not ISO conform)

In contrast, the current regex will, among other formats, accept "Z0100", "+123", "+::7", and "-0102:2"

* @returns {LocalTime<NumberOrInteger>}
*/
static fromString (str: string): LocalTime<Integer> {
const values = String(str.replace(/,/g, '.')).match(/^T?(\d{2}):?(\d{2})?:?(\d{2})?(\.\d+)?$/)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you decide to keep that letters in other Regexes are case-insensitive, this leading T should be too for consistency.

* @returns {Date<NumberOrInteger>}
*/
static fromString (str: string): Date<Integer> {
const values = String(str.replace(/,/g, '.')).match(/^(\d+)-(\d+)-(\d+)$/)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does not accept negative years.

* @returns {Date<NumberOrInteger>}
*/
static fromString (str: string): Date<Integer> {
const values = String(str.replace(/,/g, '.')).match(/^(\d+)-(\d+)-(\d+)$/)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dependent on how strict you want this to be:

https://en.wikipedia.org/wiki/ISO_8601#Years

For positive years, this is regex is more lenient that ISO which requires either exactly four digits and no sign XOR more than four digits and a sign.

* @param {string} str The string to convert
* @returns {LocalDateTime<NumberOrInteger>}
*/
static fromString (str: string): LocalDateTime<NumberOrInteger> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latter two are still being accepted.

}
}

function parseTemporalFloat (str: string, field: string, maxLength?: number): number {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
function parseTemporalFloat (str: string, field: string, maxLength?: number): number {
function parseTemporalFloat (str: string, field: string): number {

unused parameter (inside function and at all call sites), or should it be used but isn't?

}

function parseTemporalFloat (str: string, field: string, maxLength?: number): number {
if (str === undefined || str.length === 0) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If str can be undefined, shouldn't the signature read str?: string?

}

function parseTemporalInt (str: string, field: string, maxLength?: number): Integer {
if (str === undefined || str.length === 0 || str === 'undefined') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If str can be undefined, shouldn't the signature read str?: string?

return result
}

function handleTimeDecimals (hourString: string, minuteString: string, secondString: string, decimalString: string): [Integer, Integer, Integer, Integer] {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
function handleTimeDecimals (hourString: string, minuteString: string, secondString: string, decimalString: string): [Integer, Integer, Integer, Integer] {
function handleTimeDecimals (hourString?: string, minuteString?: string, secondString?: string, decimalString?: string): [Integer, Integer, Integer, Integer] {

Either this or get rid of the undefined checks inside the function, me thinks.

let minutes
let seconds
let nanoseconds
if (minuteString === undefined || secondString === '') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (minuteString === undefined || secondString === '') {
if (minuteString === undefined || minuteString === '') {

maybe?

let nanoseconds
if (minuteString === undefined || secondString === '') {
hours = parseTemporalInt(hourString, 'hours')
minutes = int(decimalString !== undefined ? Math.round(parseFloat('0' + decimalString) * 60) : 0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about once at the top:

  decimalInt = decimalString !== undefined ? parseFloat('0' + decimalString) : 0

and then saving a whole bunch of undefined checks and string parsing calls.

let minutes
let seconds
let nanoseconds
if (minuteString === undefined || secondString === '') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way the regexs are written rn, minuteString can be undefined while secondString isn't, I believe. If so, that'd lead to secondString being ignored erroneously.

They also allow for T01::.2. They probably shouldn't, but if they do, at least the .2 should mean 0.2 seconds, not 0.2 hours.

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