Object Mapping in the Neo4j Driver for .NET


Object Mapping in the Neo4j Driver for .NET

Using new functionality, it is possible to turn query results directly into objects with minimal boilerplate.


Before now, the process of turning the results of a Cypher query executed with the official driver into an object has been a laborious affair. It involved writing the same line of code over and over again, with small changes each time, to map individual parts of the record to the properties of the object. A new feature in the .NET driver, currently in preview, allows this mapping to be defined simply, or, in most cases, done automatically.

A feature has been added to the .NET driver for Neo4j which allows for C# objects to quickly be constructed from query results. If no custom configuration is supplied, then the mapping is done by convention, where the names of properties or constructor parameters are used to decide which data from the record to use. However, it is also possible to specify exactly how to map from a record to the object type. With these two options, plus the ability to configure the mapping somewhere between the two, all record-to-object mapping scenarios should be feasible.

Let’s say we have the following Cypher query:

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

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

public class ActingJob
{
public Person Person { get; set; }
public Movie Movie { get; set; }
public List<Person> Costars { get; set; }
}

public class Movie
{
public string Title { get; set; } = “”;
public int Released { get; set; }
public string? Tagline { get; set; }
}

public class Person
{
public string Name { get; set; } = “”;
public int? Born { get; set; }
}

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

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

var (results, _) = await driver
.ExecutableQuery(cypherQuery)
.ExecuteAsync();

var actingJobs = new List<ActingJob>();
foreach (var record in results)
{
var person = new Person
{
Name = record[“person”].As<INode>().Properties[“name”].As<string>(),
Born = record[“person”].As<INode>().Properties.TryGetValue(“born”, out var born) ? born.As<int>() : null
};

var movie = new Movie
{
Title = record[“movie”].As<INode>().Properties[“title”].As<string>(),
Released = record[“movie”].As<INode>().Properties[“released”].As<int>(),
Tagline = record[“movie”].As<INode>().Properties.TryGetValue(“tagline”, out var tl) ? tl.As<string>() : “”
};

var costars = record[“costars”].As<IReadOnlyList<INode>>()
.Select(node => new Person
{
Name = node.Properties[“name”].As<string>(),
Born = node.Properties.TryGetValue(“born”, out var born) ? born.As<int>() : null
})
.ToList();

var job = new ActingJob
{
Person = person,
Movie = movie,
Costars = costars
};

actingJobs.Add(job);
}

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

using Neo4j.Driver.Preview.Mapping; // enable preview mapping features

// …

var actingJobs = await driver
.ExecutableQuery(cypherQuery)
.ExecuteAsync()
.AsObjectsAsync<ActingJob>();

All the same mapping will be done automatically by examining the names and types of the properties in the object and matching them with the fields in the records.

This example uses the default mapper, which is the method used if no mapping configuration is defined. We expect it to work for the vast majority of mapping scenarios. It is possible to give the default mapper “hints”, which further expand the situations covered.

For example, if the Bornproperty in the Personclass were to be renamed to BirthYear, we could still use the default mapper by decorating it with a [MappingSource]attribute, like so:

[MappingSource(“born”)]
public int BirthYear { get; set; }

For full information about the available hints, see the links at the end of this post.

Another situation that may arise is when a class declares all its properties as read-only and sets them from a constructor, as in the following example (please note this code is using the new primary constructors syntax from C# 12):

public class Movie (string title, int released, string? tagline)
{
public string Title => title;
public int Released => released;
public string? Tagline => tagline;
}

In this instance, the default mapper will look at the names of the parameters in the constructor and map from the record in the same way. If it is unable to do so, it will throw an exception. The same mapping attributes that can be used with properties can also be used with constructor parameters.

Alternative mapping methods

As well as the AsObjectsAsyncmethod, which allows the results of a driver-level query execution to be mapped, there are extension methods that allow you to take advantage of the mapping functionality in other scenarios.

If you have an IResultCursor, you can use the ToListAsync<T>method to return a list of mapped objects. Or if you have an IRecord, you can use the AsObject<T>method to map it to an object. These methods will use either the default mapper or any custom mapping you have defined.

Custom Mapping

If a situation arises where the default mapper cannot do what is needed, there are two ways to create custom mappings. The first way is to create a class that implements the IRecordMapper<T>interface, where T is the type to be mapped to (e.g. Movie). This interface would allow you to use your existing boilerplate code and write the code that maps from an IRecordto a T. The advantage of putting this code into its own class is that you can then use the driver’s mapping functionality, and your custom mapping will always be used when available.

The second way is to define a class that implements IMappingProvider. This interface allows you to configure mapping on a property-by-property basis, using a fluent API, and optionally taking advantage of the default mapper.

A full discussion of these two types of custom mapping is beyond the scope of this post, so see the links at the end for more information.

You Can Help!

If you think this stuff looks interesting, please give it a try! It’s still in preview, which means it can be changed as much as we like since it’s not guaranteed that it won’t undergo any breaking changes. It would be really valuable to us to get some feedback from people who have actually used it in a real-world scenario: whether it be things that don’t work how you expect, missing features, or suggestions for ways to improve it, we’re all ears.

Please visit this GitHub discussion to give us your feedback.

Detailed Documentation

To read more detailed information, please see the two PRs that introduced this functionality into the codebase:


Object Mapping in the Neo4j Driver for .NET was originally published in Neo4j Developer Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.