This library is still in stablizing phase, it's features and interface may be unstable.
$ dotnet add package Oasis.EntityFrameworkCore.MapperOasis.EntityFramework.Mapper/Oasis.EntityFramework.Mapper (referred to as the library in the following content) is a library that helps users (referred to as "developers" in the following document, as users of this libraries are developers) to automatically map properties between different classes. Unlike AutoMapper which serves general mapping purposes, the library focus on mapping entities of EntityFramework/EntityFrameworkCore.
During implementation of a web application that relies on databases, it is inevitable for developers to deal with data objects extracted from database and DTOs that are supposed to be serialized and sent to the other side of the web. These 2 kinds of objects are usually not defined to be the same classes. For example, Entity Framework uses POCOs for entities, while Google ProtoBuf generates it's own class definitions for run-time efficiency during serialization and transmission advantages. Even without Google ProtoBuf, developers may define different classes from entities for DTOs to ignore some useless fields and do certain conversion before transmitting data extracted from database. The library is implementated for developers to handle this scenario with less coding and more accuracy.
Entities of EntityFramework/EntityFrameworkCore can be considered different from general classes in following ways:
The library focuses on use cases of mapping from/to such classes, and is integrated with EntityFramework/EntityFrameworkCore DbContext for further convenience.
Main features provided by the library includes:
A simple book-borrowing system is made up, and use case examples are developed based on the book-borrowing system to demonstrate how the library helps to save coding efforts. The following picture demonstrates the entities in the book-borrowing system.

For the 5 entities in the system:
Sections below demonstrates usages of the library, all relevant code can be found in the LibrarySample project. Considering length of the descriptions, it is recommended to download the whole project and directly read the test code in LibrarySample folder first, and come back to the descriptions below whenever the code itself isn't descriptive enough.
This test case demonstrates the basic usage of the library on how to insert data into database with it.
// initialize mapper
var factory = MakeDefaultMapperBuilder()
.Register<NewTagDTO, Tag>(MapType.Insert)
.Build();
// create new tag
await ExecuteWithNewDatabaseContext(async databaseContext =>
{
var mapper = factory.MakeToDatabaseMapper(databaseContext);
const string TagName = "English";
var tagDto = new NewTagDTO { Name = TagName };
_ = await mapper.MapAsync<NewTagDTO, Tag>(tagDto, null);
_ = await databaseContext.SaveChangesAsync();
var tag = await databaseContext.Set<Tag>().FirstAsync();
Assert.Equal(TagName, tag.Name);
});
This is a minimal example demonstrates basic usage of the library, the use case is adding a new Tag into the system.
Note that the library is expecting every entity to have an identity property, which represents the primary key column of the corresponding data table in the database. Without this identity property the entity can't be updated by APIs of the the library. So far the library only supports a single scalar property as identity property, combined properties or class type identity property is not supported.
This test case shows usage of scalar converters, concurrency token and the way to update scalar properties using the library.
When mapping from one class to another, the library by default map public instance scalar properties in the 2 classes with exactly the same names and same types (Not to mention the properties must have a public getter/setter). Property name matching is case sensitive. If developers want to support mapping between property of different scalar types (e.g. from properties of type int? to properties of int), a scalar converter must be defined while configuring te mapper like the examples below:
var mapper = MakeDefaultMapperBuilder()
.WithScalarConverter<int?, int>(i => i.HasValue ? i.Value : 0)
// to configure/register more, continue with the fluent interface before calling Build() method.
.Build();
For convenience of users, convertion between certain C# types for scalar properties is supported by default, if the conversion satifies the following conditions:
The feature generally covers the following from type to type mapping cases:
Users only need to specifically define scalar converters that are not covered by the cases above.
Scalar converters can be used to define mapping from a value type to a class type as well, or from a class type to a value type, but can't be used to define mapping from one class type to another class type. One example can be found below.
// initialize mapper
var mapper = MakeDefaultMapperBuilder()
.WithScalarConverter<byte[], ByteString>(arr => ByteString.CopyFrom(arr))
.WithScalarConverter<ByteString, byte[]>(bs => bs.ToByteArray())
.Register<NewBookDTO, Book>(MapType.Insert)
.RegisterTwoWay<Book, UpdateBookDTO>(MapType.Memory, MapType.Upsert)
.Build()
.MakeMapper();
// create new book
const string BookName = "Book 1";
Book book = null!;
await ExecuteWithNewDatabaseContext(async databaseContext =>
{
mapper.DatabaseContext = databaseContext;
var bookDto = new NewBookDTO { Name = BookName };
_ = await mapper.MapAsync<NewBookDTO, Book>(bookDto, null);
_ = await databaseContext.SaveChangesAsync();
book = await databaseContext.Set<Book>().FirstAsync();
Assert.Equal(BookName, book.Name);
});
// update existint book dto
const string UpdatedBookName = "Updated Book 1";
var updateBookDto = mapper.Map<Book, UpdateBookDTO>(book);
Assert.NotNull(updateBookDto.ConcurrencyToken);
Assert.NotEmpty(updateBookDto.ConcurrencyToken);
updateBookDto.Name = UpdatedBookName;
await ExecuteWithNewDatabaseContext(async databaseContext =>
{
mapper.DatabaseContext = databaseContext;
_ = await mapper.MapAsync<UpdateBookDTO, Book>(updateBookDto, null);
_ = await databaseContext.SaveChangesAsync();
book = await databaseContext.Set<Book>().FirstAsync();
Assert.Equal(UpdatedBookName, book.Name);
});
ByteString class is the Google ProtoBuf implementation for byte array, which is usually used as concurrency token type by EntityFramework/EntityFrameworkCore. The requirement to support converting entities to Google ProtoBuf is the original and most important reason for the library to support scalar converters. In the sample code above:
This test case demonstrates basics for updating navigation properties using the library.
In this section, the example code will do mapping for Borrower entity. Definition of this entity is different than those for Tag and Book. Below are the definitions of the 3 entities:
public sealed class Borrower : IEntityBaseWithConcurrencyToken
{
public string IdentityNumber { get; set; } = null!;
public long ConcurrencyToken { get; set; }
public string Name { get; set; } = null!;
public Contact Contact { get; set; } = null!;
public Copy Reserved { get; set; } = null!;
public List<Copy> Borrowed { get; set; } = new List<Copy>();
}
public sealed class Book : IEntityBaseWithId, IEntityBaseWithConcurrencyToken
{
public int Id { get; set; }
public long ConcurrencyToken { get; set; }
public string Name { get; set; } = null!;
public List<Copy> Copies { get; set; } = new List<Copy>();
public List<Tag> Tags { get; set; } = new List<Tag>();
}
public sealed class Tag : IEntityBaseWithId
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public List<Book> Books { get; set; } = new List<Book>();
}
Focus on the 3 definitions are identity and concurrency token properties. For Tag class, the identity property is named "Id" of type int, and it doesn't have a concurrency token property; for Book class, the identity property is named "Id" of type integer, and the concurrency token property if named ConcurrencyToken of type byte[]; for Borrower class, the identity property is named "IdentityNumber" of string type, and the concurrency token property if named ConcurrencyToken of type byte[]. So the the question is obvious, how to tell which property is for identity or concurrency token? The library allows developers to define the default and per-class names for these properties. Take a look at the definition of MakeDefaultMapperBuilder method:
protected static IMapperBuilder MakeDefaultMapperBuilder(string[]? excludedProperties = null)
{
return new MapperBuilderFactory()
.Configure()
.SetKeyPropertyNames(nameof(IEntityBaseWithId.Id), nameof(IEntityBaseWithConcurrencyToken.ConcurrencyToken))
.ExcludedPropertiesByName(excludedProperties)
.Finish()
.MakeMapperBuilder();
}
The method ExcludedPropertiesByName will described in later part. Except that, points of the sample code above include:
var mapper = MakeDefaultMapperBuilder()
.WithScalarConverter<byte[], ByteString>(arr => ByteString.CopyFrom(arr))
.WithScalarConverter<ByteString, byte[]>(bs => bs.ToByteArray())
.Configure<Borrower>()
.SetKeyPropertyNames(nameof(Borrower.IdentityNumber), nameof(Borrower.ConcurrencyToken))
.Finish()
.Configure<NewBorrowerDTO>()
.SetIdentityPropertyName(nameof(NewBorrowerDTO.IdentityNumber))
.Finish()
.Configure<UpdateBorrowerDTO>()
.SetKeyPropertyNames(nameof(UpdateBorrowerDTO.IdentityNumber), nameof(UpdateBorrowerDTO.ConcurrencyToken))
.Finish()
.Register<NewBorrowerDTO, Borrower>(MapType.Insert)
.RegisterTwoWay<Borrower, UpdateBorrowerDTO>(MapType.Memory, MapType.Update)
.Build()
.MakeMapper();
To continue the points for configuration of identity and concurrency token properties:
It's clear from definition of the 3 classes that they have navigation properties. Take Borrower for example, it has a "Contact" property of type Contact, a "Reserved" property of type Copy, and a "Borrowed" property of type List; while UpdateBorrowerDTO class has a "Contact" property of type UpdateContactDTO, a "Reserved" property of type CopyReferenceDTO, and a "Borrowed" property of type pbc::RepeatedField. When registering the mapping from Borrower to UpdateBorrowerDTO, the following things will happen.
As registration process, mapping process will be recursive, too. Similarly a mechanism to prevent infinite loop is implemented.
private async Task AddAndUpateBorrower(IMapper mapper, string address1, string? assertAddress1, string? assertAddress2, string address3, string? assertAddress3)
{
// create new book
const string BorrowerName = "Borrower 1";
Borrower borrower = null!;
await ExecuteWithNewDatabaseContext(async databaseContext =>
{
var borrowerDto = new NewBorrowerDTO { IdentityNumber = "Identity1", Name = BorrowerName, Contact = new NewContactDTO { PhoneNumber = "12345678", Address = address1 } };
_ = await mapper.MapAsync<NewBorrowerDTO, Borrower>(borrowerDto, null, databaseContext);
_ = await databaseContext.SaveChangesAsync();
borrower = await databaseContext.Set<Borrower>().Include(b => b.Contact).FirstAsync();
Assert.Equal(BorrowerName, borrower.Name);
Assert.Equal(assertAddress1, borrower.Contact.Address);
});
// update existing book dto
const string UpdatedBorrowerName = "Updated Borrower 1";
var updateBorrowerDto = mapper.Map<Borrower, UpdateBorrowerDTO>(borrower);
updateBorrowerDto.Name = UpdatedBorrowerName;
Assert.Equal(assertAddress2, updateBorrowerDto.Contact.Address);
updateBorrowerDto.Contact.Address = address3;
await ExecuteWithNewDatabaseContext(async databaseContext =>
{
_ = await mapper.MapAsync<UpdateBorrowerDTO, Borrower>(updateBorrowerDto, b => b.Include(b => b.Contact), databaseContext);
_ = await databaseContext.SaveChangesAsync();
borrower = await databaseContext.Set<Borrower>().Include(b => b.Contact).FirstAsync();
Assert.Equal(UpdatedBorrowerName, borrower.Name);
Assert.Equal(assertAddress3, borrower.Contact.Address);
});
}
In the above sample code:
If necessary, developers can configure the library not to map properties of certain names during mapping. This can be configured at 3 levels
// by default, which was skipped in the description of MakeDefaultMapperBuilder method
new MapperBuilderFactory()
.Configure()
.ExcludedPropertiesByName(excludedProperties)
.Finish()
.MakeMapperBuilder();
// per class, for NewContactDTO in this case
MakeDefaultMapperBuilder()
.Configure<NewContactDTO>()
.ExcludePropertiesByName(nameof(UpdateContactDTO.Address))
.Finish()
// per mapping, for mapping from NewContactDTO to Contact in this case
var mapper = MakeDefaultMapperBuilder()
.Configure<NewContactDTO, Contact>()
.ExcludePropertiesByName(nameof(Contact.Address))
.Finish()
Note that if Configure<A, B>() is called, the library will register mapping from class A to class B, so developers won't need to specify Register<A, B> in later configuration. Of course, if they do specify it, it will be simply ignored as redudant registration, no exceptions will be thrown.
The library need to create new instances of target entities or collection of target entities during mapping from time to time, and by default, it tries to find the default parameterless constructor of class of the target entity. Considering most entity class should have a such constructor, the approach should work. But what if the target entity doesn't have a default parameterless constructor? For this case, developers must name a factory method for such target entities, the library will use the factory method for the class if any defined is registered. The example is as below:
// initialize mapper
var mapper = MakeDefaultMapperBuilder()
.WithScalarConverter<byte[], ByteString>(arr => ByteString.CopyFrom(arr))
.WithFactoryMethod<IBookCopyList>(() => new BookCopyList())
.WithFactoryMethod<IBook>(() => new BookImplementation())
.Register<NewBookDTO, Book>(MapType.Insert)
.Register<Book, IBook>(MapType.Memory)
.Build()
.MakeMapper();
In this case the target entities are interfaces (IBooks and IBookCopyList), which don't have constructors at all. It's apparent that the introduction of WithFactoryMethod extends supported data scope of the library from normal classes with default parameterless constructors to abstract classes and interfaces.
Recursive mapping section describes the way to update a navigation property from its root entity, what it doesn't describe is what happens if developers replace the nevigation propety value with a totally new one. The answer is: the library will replace the old navigation property value with the newly assigned one to behave as expected. As for what happens to the replaced entity, whether it stays in the database or get removed from database, it's out of the library's scope, but up to the database settings. If the nevigation property is set to be cascade on delete, then it will be removed from database upon being replaced, or else it stays in database. For collection type navigation properties, things are a bit complicated. See the graph below:

The graph shows, when loading value for the collection type navigation property, 4 items are loaded: ABCD; but user inputs CDEF for content of the collection type navigation property. It's easy to understand that during mapping of this property, the library would update C and D, insert E and F. The action to take to items A and B hasn't been specified yet. By default, the library assumes that developers originally loaded ABCD from the database, knowlingly removed A and B from the collection, updated C and D, then added E and F. So the correct behavior for this understanding is to remove A and B from the collection. This is a feature to allow developers removing entities with mapping, which could save developers quite some coding effort handling the find and remove logic if without the library. The library allows developers to override this feature to keep the loaded but unmatched entities instead by configuration. It can be configured to an entity, that when mapping to specific collection type navigation properties of this entity, unmatched items will be kept in the collection instead of removed.
mapperBuilder = mapperBuilder
.Configure<Book>()
.KeepUnmatched(nameof(Book.Copies))
.Finish();
Or configured to a mapping scenario when mapping from one specific class to another
mapperBuilder = mapperBuilder
.Configure<UpdateBookDTO, Book>()
.KeepUnmatched(nameof(Book.Copies))
.Finish();
By default, the library supports:
It's possible that more flexible mapping is required, as mapping a property of one name to another of a different name, hence MapProperty method is introduced:
var mapper = MakeDefaultMapperBuilder()
.Configure<Borrower, BorrowerBriefDTO>()
.MapProperty(brief => brief.Phone, borrower => borrower.Contact.PhoneNumber)
.Finish()
This configuration specifies that when mapping an instance of Borrower to an instance of BorrowerBriefDTO, "Phone" property of BorrowerBriefDTO should be mapped as configured by the inline method borrower => borrower.Contact.PhoneNumber.
During mapping, there could be cases where multiple entities share some same instances for nevigation entities. Like in this example, many books may share the same tag. So for the different book instances, it would be ideal if the books can share the same instance for the same tags.
During a call of IMapper.Map or IMapper.MapAsync, the library will only track entities if their classes are involved in loop dependency to identify instances that are already mapped to avoid infinite loops. But it definitely doesn't track entities among such method calls.
Examples in this test case uses a NewBookWithNewTagDTO, which adds new books together with new tags. Business wise this may not make sense, considering books and tags are not-so-connected entities that are supposed to be managed separately. But here we ignore it, and just use this example to demonstrate this feature of the library.
var tag = new NewTagDTO { Name = "Tag1" };
var book1 = new NewBookWithNewTagDTO { Name = "Book1" };
book1.Tags.Add(tag);
var book2 = new NewBookWithNewTagDTO { Name = "Book2" };
book2.Tags.Add(tag);
_ = await mapper.MapAsync<NewBookWithNewTagDTO, Book>(book1, null, databaseContext);
_ = await mapper.MapAsync<NewBookWithNewTagDTO, Book>(book2, null, databaseContext);
_ = await databaseContext.SaveChangesAsync();
In the sample code above we mean to add 2 new books with the same new tag, mapper.MapAsync is called twice. For the first time, the library inserts "Book1" and "Tag1" into the database; for the second time, the library tries to insert "Books2" and "Tag1", which triggers a database exception because name of tag is supposed to be unique in the database. The point is, inserting "Tag1" twice isn't the purpose, but since the same instance appears in 2 different calls to MapAsync, the library doesn't know for the second call, the data presented by the NewTagDTO has been mapped in previous processes that it's not supposed to be inserted again. The library only guarantees to map the same instance once per mapping, with IMapper.Map or IMapper.MapAsync there no way to trace mapped entities between such calls.
To overcome this problem, the library provides a session concept to extend the scope of mapping-only-once scenario. IMapper.StartSession/IToMemoryMapper.StartSession/IToDatabaseMapper.StartSession starts a mapping session which can track mapped from entities among as many calls as possible; Whenever the session is not needed, call the StopSession method to stop it. After that call the mapper works in non-session mode again. I doubt if this use case is needed a lot, but in case it is, the mechanism is provided.
Note that if a POCO has 2 navigagion properties of the same instance (For example, class Class1 has Property Property1 and Property2, values of both are the same instance of class ClassP), a session will be necessary for the same instance of Property1 and Property2 to be mapped to the same database record. Unless Property1 and Property2 gets involved in some loop dependency detected during registering the mapping, the library doesn't track the class instances to avoid redundency. This feature to guarantee that same instances can be mapped to same instances avoids redudent data record being inserted into databases. It's necessary due to nature of use cases of the library, not provided by AutoMapper (which doesn't have this use case), and is the reason for the library to be slower than AutoMapper.
The library trackes both hash code of an entity or identity property value of it (for entity to be updated) to judge if an entity has been mapped from.
The library provides a safety check mechanism to guarantee correct usage of mappings, which limits insertion/updation and mapping to database/memory when mapping from a DTO class to a database entity class. Like for UpdateBookDTO, if it's only supposed to be used to update an existing book into database, never inserting a new book into database; or if mapping from Book to UpdateBookDTO is only supposed to happen between class instances, it won't be used to insert/update data to database contexts. This can be guaranteed with a configuration.
var mapper = MakeDefaultMapperBuilder()
.WithScalarConverter<ByteString, byte[]>(bs => bs.ToByteArray())
.Configure<UpdateBookDTO, Book>()
.SetMapType(MapType.Update)
.Finish()
.Build();
The focus in the code is SetMapType method. If not configured, the default value for all mapping is MemoryAndUpsert, which allows mapping between class instances, and updation and insertion to database. We can also specify we only want to insert new books with NewBookDTO with the following statement
.Configure<NewBookDTO, Book>()
.SetMapType(MapType.Insert)
.Finish()
The thing is NewBookDTO doesn't really have an identity property, so it can't be used to update entities in database anyway, so this configuration may be considered useless. To state that mapping from Book to UpdateBookDTO is only supposed to happen between class instances, not to database contexts, MapType.Memory is for this purpose:
.Configure<Book, UpdateBookDTO>()
.SetMapType(MapType.Memory)
.Finish()
If user code breaches such configurations, a corrsponding exception will be thrown at mapping time. Combined MapType values are also provided to handle some mixed situations, like MemoryAndInsert, MemoryAndUpdate, MemoryAndUpsert. The meanings are straight forward as the names. Note that though type mapping registering and mapping are automatically recursive, this MapType configuration will not be automatically passed on during the recursive registration or mapping (We really don't know if the mapping type of a class should be applied to all its navigation property classes). Which means users must manually configure each mapping if they with to use this mechanism for more robust coding. The default global setting for MapType is MemoryAndUpsert, meaning if not specifically configured, any defined mapping is allowed to be used to map instances to instances, and also insert or update data into database contexts. This can be configured with MapperBuilderFactory.Configure().SetMapType method. To avoid specifically configuring MapType for every mapping, users can do the following so by default any mapping registered is for mapping from instances to instances, users only need to specifically define MapType for map to database cases.
new MapperBuilderFactory()
.Configure()
.SetMapType(MapType.Memory)
<more configuration>
.Finish()
.Build();
Note that a short cut for MapType configuration is provide by passing it as a parameter in IMapperBuilder.Register method and IMapperBuilder.RegisterTwoWays method. IMapperBuilder.RegisterTwoWays will take 2 such parameters, 1 for mapping from source to target, the other for mapping from target to source. Examples of such cases can be found in LibrarySample like the following codes:
var mapper = MakeDefaultMapperBuilder()
.WithScalarConverter<byte[], ByteString>(arr => ByteString.CopyFrom(arr))
.WithScalarConverter<ByteString, byte[]>(bs => bs.ToByteArray())
.Register<NewBookDTO, Book>(MapType.Insert)
.RegisterTwoWay<Book, UpdateBookDTO>(MapType.Memory, MapType.Upsert)
.Build()
.MakeMapper();
Note that this configuration is only optional for writing more robust code, if left as default, the library can function normally as well. When dynamically generating il code when building an IMapperFactory instance, by default 1 method will be generated for mapping from instance to instance, and a different one will be generated for mapping from instance to database context. The library will not generate the method if the MapType is not configured for it (For example, for mapping from Book to UpdateBookDto, if MapType is configured to be MapType.Memory, the method for mapping Book to database context with entity type to be UpdateBookDTO will not be generated.). So though configuring specific MapType for all registered mappings is truoblesome, it will help the the library to initialize faster at run time, and save some memory. If there are a lot of mappings to be registered, this feature could be useful to optimize initialization performance and memory consumption.

The graph above is a concept of program structure of the library.
There there be any questions or suggestions regarding the library, please send an email to keeper013@gmail.com for inquiry. When submitting bugs, it's preferred to submit a C# code file with a unit test to easily reproduce the bug.