Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mapping preview #729

Merged
merged 34 commits into from Sep 27, 2023
Merged

Conversation

RichardIrons-neo4j
Copy link
Contributor

@RichardIrons-neo4j RichardIrons-neo4j commented Sep 14, 2023

🔮 Preview Feature: Record to Object Mapping

This PR adds preview functionality to allow IRecords to be mapped to C# objects so that developers do not need to examine the contents of records and nodes, reducing boilerplate code significantly.

To use this functionality you must add the following line to your code:

using Neo4j.Driver.Preview.Mapping;

Getting Started

Let's say we have the following Cypher query:

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

and we are going to load each of the records into 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 jobs = 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
    };

    jobs.Add(job);
}

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

var jobs = 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. The default "smart" mapper is used here; however, the method of mapping may be specified to varying levels of explicitness (see below).

As well as the ExecutableQuery API, the mapping functionality can be used with other ways of retrieving records from a database.

Cursor extension method

There is an extension method added to the IResultCursor interface, very similar to the existing ToListAsync() method, called ToListAsync<T>(). Take this code snippet:

var jobs = await session.ExecuteReadAsync(
    async tx =>
    {
        var cursor = await tx.RunAsync(cypherQuery);
        return await cursor.ToListAsync();
    });

This will result in movies containing a List<IRecord>. At this point any mapping code must be written to turn these records into objects. By simply changing the last statement to:

return await cursor.ToListAsync<ActingJob>();

the mapping code will be invoked and jobs will now contain an IReadOnlyList<ActingJob> with every record mapped to an instance just as before, according to whatever mapping strategy has been set, or the default mapper.

Direct mapping from an IRecord instance

An extension method has been added to the IRecord interface:

var job = record.AsObject<ActingJob>();

This means that anywhere you have an instance of IRecord, you can invoke the mapping code and get an instance of the class you want back. Again, the global mapping configuration will be used.

Global mapping configuration

The mapping is controlled by global configuration. By default the default smart mapper will be used for all mapping. The configuration stores a mapping strategy for each type to be mapped - for example, one strategy for class Movie, one for class Person, etc. The default mapper is forgiving as to slightly different structures of the records that are to be mapped. Custom strategies are as flexible as you make them.

Mapping configuration exists once and all instances throughout the process will use this configuration. If there are instances of the same type being mapped throughout the code, then the configuration need be changed in only one place in order to change how mapping is performed in every instance.

Default mapper

This is a smart mapper that will base the mapping on (case-insensitive) field and property names. Because the record does not contain fields named Title, Released and Tagline, but it does contain an entity (a node or relationship) that has those properties, then those properties will be used. This will cover almost all cases. The default mapper uses reflection to examine properties only once per type to be mapped, with the results cached for the lifetime of the process. Fields will not be mapped, only public properties.

Hints

It is possible to decorate your classes with some attributes to affect how the default mapper will map records to that type. Note that only the default mapper is affected by these attributes; if you write your own mapper, then any examination of these attributes would need to be implemented explicitly. The attributes are:

[MappingIgnored]

This attribute simply makes the default mapper ignore the property, for example:

[MappingIgnored]
public int Age { get; set; }

This means that the default mapper will not spend any time searching records for a value to put in this property. Note that omitting this attribute will likely not cause any problems - properties for which a value cannot be found in the record are ignored.

[MappingSource]

This attribute tells the default mapper where to look in the record to retrieve the value. Use this to specify a different field than the property name:

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

The path may also contain a dot, in which case the value before the dot is used to identify a field in the record that is either an entity (a node or relationship) or a dictionary, and the value after the dot is used as a key within that structure:

[MappingSource("car.marque")]
public string CarMake { get; set; }

You can also use this attribute to tell the mapper to populate the property with the labels of a node specified by the path:

[MappingSource("person", EntityMappingSource.NodeLabel]
public string PersonLabel { get; set; }

If there are multiple labels on the node then the string will contain a comma-separated list of all of them. Alternatively, you can capture the labels directly into a list like so:

[MappingSource("person", EntityMappingSource.NodeLabel]
public List<string> PersonLabels { get; set; }

Finally, this attribute can be used to specify that the property should be populated with the type of a relationship in the record:

[MappingSource("relationship", EntityMappingSource.RelationshipType]
public string RelationshipType { get; set; }

If the appropriate value specified by this attribute cannot be found on the record, then the property will be ignored.

Custom mappers

The mapping strategy for each object type can be customised completely to account for unusual or complex mapping scenarios.

IRecordMapper<T>

The first way to do this is to create a class that implements IRecordMapper<T>, where T is the type to be mapped to. This interface contains one method:

T Map(IRecord record);

Your implementation should simply accept the passed IRecord and use whatever methods you like to return in instance of T. To make the mapping code use this class, register it at the time of process startup like this:

RecordObjectMapping.Register(new MyMovieMapper()); // implements IRecordMapper<Movie>

The mapping code will see that this class is meant for mapping to the Movie class and will use it in all instances where data is being mapped to a Movie object.

IMappingProvider

By implementing this interface you can specify mappers for many types using a fluent interface. First create a class that implements IMappingProvider. When you implement the CreateMappers method, you will be passed an IMappingRegistry which provides a fluent API for registering mappers to different types. You call the RegisterMapping<T> method once for each type whose mapping you want to specify, passing in a delegate that will be called to perform individual property mappings. A very simple implementation of this might look like this:

public void CreateMappers(IMappingRegistry registry)
{
    registry
        .RegisterMapping<Movie>(mb => mb.UseDefaultMapping())
        .RegisterMapping<Person>(mb => mb.UseDefaultMapping());
}

Note that this example would have no effect, since it instructs the mapper to use the default mapping for each type. However, one can then override mapping for specific properties, like so:

public void CreateMappers(IMappingRegistry registry)
{
    registry
        .RegisterMapping<Movie>(
            mb =>
            {
                mb
                    .UseDefaultMapping();
                    .Map(m => m.Title, "theatrical-title");
            })
        .RegisterMapping<Person>(
            mb =>
            {
                mb
                    .UseDefaultMapping()
                    .Map(m => m.Name, "name", converter: o => o.As<string>().ToUpper())
                    .Map(m => m.Labels, "person", EntityMappingSource.NodeLabel);
            });
}

In this instance, the mapper is instructed to use the theatrical-title field to populate the Title property (more simply achieved with the [MappingPath] attribute), and the Person.Name property will be converted to upper case before being stored in the object. The labels from the person node will be put into the Labels property of the Person object.

Another example is as follows:

public class MyMappingProvider : IMappingProvider
{
    /// <inheritdoc />
    public void CreateMappers(IMappingRegistry registry)
    {
        registry
            .RegisterMapping<SomeObject>(
                builder =>
                {
                    builder
                        .Map(x => x.MovieName, "title", converter: s => s.As<string>().ToUpper())
                        .Map(x => x.ValueCount, record => record.Values.Count);
                })
            .RegisterMapping<YetAnotherMovie>(
                builder => builder.MapWholeObject(
                    r => new YetAnotherMovie(
                        r["m"].As<INode>()["title"].As<string>(),
                        r["m"].As<INode>()["released"].As<int>())));
    }
}

In this example, the ValueCount property on SomeObject is populated by examining the record directly, rather than providing a key to a field. The YetAnotherMovie class is mapped using the MapWholeObject method, which allows you to accept an IRecord and return an instance of the type.

⭐ New Features

The following extension methods have been added in order to make code that deals with the IRecord and IEntity (implemented by both INode and IRelationship) interfaces more readable - for example, when writing custom mapping code. The Neo4j.Driver.Preview.Mapping namespace must be referenced to use these methods.

IRecord.AsObject<T>()

Invokes the mapping functionality and returns an instance of T mapped from the record. T must have a parameterless constructor.

IRecord.GetValue<T>(string key)

Gets the value of the given key from the record, converting it to the given type. For example, the code record["name"].As<string>() becomes record.GetValue<string>("name").

IRecord.GetXXXX(string key)

This is a group of extension methods for the most commonly used primitive types: GetString, GetInt, GetLong, GetDouble, GetFloat and GetBool. These just call GetValue<T> for the type named in the method name, so record["name"].As<string>() becomes record.GetString("name").

IEntity.GetValue<T>(string key)

Gets the value of the given key from the entity, converting it to the given type. For example, the code entity["name"].As<string>() becomes entity.GetValue<string>("name").

IEntity.GetXXXX(string key)

The same group of methods as IRecord.GetXXXX, but for an entity.

IRecord.GetEntity(string key)

Gets the IEntity identified by the given key from the record. When combined with the other methods, this can lead to more readable code that gets values from entities within records, so for example this code:

var name = record["person"].As<IEntity>().Properties["name"].As<string>();

becomes:

var name = record.GetEntity("person").GetString("name");

Copy link
Contributor

@AndyHeap-NeoTech AndyHeap-NeoTech left a comment

Choose a reason for hiding this comment

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

Other than reflecting in the commit/discussion text any renaming that was done yesterday it looks good.

…sions.cs


Americanization

Co-authored-by: grant lodge <6323995+thelonelyvulpes@users.noreply.github.com>
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.

None yet

3 participants