Oasis.EntityFrameworkCore.Mapper 0.1.1

There is a newer version of this package available.
See the version list below for details.
dotnet add package Oasis.EntityFrameworkCore.Mapper --version 0.1.1
NuGet\Install-Package Oasis.EntityFrameworkCore.Mapper -Version 0.1.1
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Oasis.EntityFrameworkCore.Mapper" Version="0.1.1" />
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add Oasis.EntityFrameworkCore.Mapper --version 0.1.1
#r "nuget: Oasis.EntityFrameworkCore.Mapper, 0.1.1"
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install Oasis.EntityFrameworkCore.Mapper as a Cake Addin
#addin nuget:?package=Oasis.EntityFrameworkCore.Mapper&version=0.1.1

// Install Oasis.EntityFrameworkCore.Mapper as a Cake Tool
#tool nuget:?package=Oasis.EntityFrameworkCore.Mapper&version=0.1.1

EntityFrameworkCore Mapper

Introduction

Oasis.EntityFrameworkCore.Mapper (referred to as the library in the following content) is a library that helps users to automatically map scalar and navigation properties between entity classes and other helper classes (e.g. DTOs generated with ProtoBuf). It's specifically designed for the use case where:

  1. Server loads some data from database to entity class instances using EntityFrameworkCore.
  2. Server creates DTO class instances based on entity data, then send the DTO class from server side to client/browser side.
  3. User manipulates the DTO class instances at client/browser side, add, update or delete something, then pass it back to server to update the database.
  4. Server side update the entity data based on manipulated DTO and save the entities back to database. The library helps in steps 2 and 4 to automatically map scalar and navigation properties between entity class instances and DTOs, to avoid the tedious work of hand-writing the mapping code.

Take a very simple pseudo code example below to demonstrate how it works: Use case: a library system tracks borrowed books by borrowers. Entitiy definitions (In BorrowRecord class apparently BorrowerId is foreign key to Borrower, and BookId is foreign key to Book, database context setup for such things is ignored here):

public sealed class Borrower
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ICollection<BorrowRecord>? BorrowRecords { get; set; }
}
public sealed class Book
{
    public int Id { get; set; }
    public string Name { get; set; }
    public BorrowRecord? BorrowRecord { get; set; }
}
public sealed class BorrowRecord
{
    public int Id { get; set; }
    public int BorrowerId { get; set; }
    public int BookId { get; set; }
    
    public Borrower? Borrower { get; set; }
    public Book? Book { get; set; }
}

The DTO classes are defined as below:

public sealed class BorrowerDTO
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ICollection<BorrowRecordDTO>? BorrowRecords { get; set; }
}
public sealed class BookDTO
{
    public int Id { get; set; }
    public string Name { get; set; }
}
public sealed class BorrowRecordDTO
{
    public int Id { get; set; }
    public int BorrowerId { get; set; }
    public int BookId { get; set; }
}

Assume we have the following books in database: | Id | Name | | --- | --- | | 1 | Book1 | | 2 | Book2 | | 3 | Book3 |

The user of Id 1 has borrowed book 1 and 2, now he/she returns book 2, and borrows book 3. So when querying database and sending DTO data to client, the code is like:

var borrowerInfo = await databaseContext.Set<Borrower>().AsNoTracking().Include(b => b.BorrowRecords).FirstAsync(b => b.Id == 1);

In the server, upon server start up, we need to build a mapper instance and make the mapper interface available anywhere in the server code:

var factory = new MapperBuilderFactory();
var mapperBuilder = factory.Make("SomeName", defaultConfiguration);
mapperBuilder.RegisterTwoWay<Borrower, BorrowerDTO>();
var mapper = mapperBuilder.Build();

We can ignore the details of RegisterTwoWay, "SomeName" or defaultCongfiguration for now, meaning of the relevant code is simply, we create a mapper builder, do some configuration (like registering mapping between Borrower and BorrowerDTO), then in our server, we can start to use the mapper to map an instance of Borrower to BorrowerDTO with 2 statements:

var session = mapper.CreateMappingSession();
var borrowerDTO = session.Map<Borrower, BorrowerDTO>(entity);

Then the DTO instance is ready to be sent to client/browser. So far the library works like nothing but a weakened version of AutoMapper, it's advantage will be demonstrated in later part of this example. At client/browser side, user operates to remove borrowing record for book 2 and add in borrowing record for book 3. In the mean time, the user notices that the borrower's name is wrongly typed, so he/she decides to fix it in the same batch:

borrowerDTO.Name = "Updated Name";
var book2BorrowingRecord = borrowerDTO.BorrowRecords.Single(r => r.BookId == 2);
borrowerDTO.BorrowRecords.Remove(book2BorrowingRecord);
borrowerDTO.BorrowRecords.Add(new BorrowRecordDTO { BookId = 3 });

Or it can also be:

borrowerDTO.Name = "Updated Name";
var book2BorrowingRecord = borrowerDTO.BorrowRecords.Single(r => r.BookId == 2);
book2BorrowingRecord.BookId = 3;

Now the DTO is read to be send back to server to be processed, and the server side should simply process it this way

var session = mapper.CreateMappingToDatabaseSession(databaseContext);
var borrower = await session.MapAsync<BorrowerDTO, Borrower>(borrowerDTO, qb => qb.Include(qb => qb.BorrowRecords))
await databaseContext.SaveChangesAsync();

That's it. Updating scalar properties, adding or removing entities will be automatically handled by MapAsync method of the library. Just save the changes, it will work correctly.

User Interface

The library exposes 4 public classes/interfaces for users:

  • IMapperBuilderFactory: this is the factory interface to generate a MapperBuilder to be configured, which later builds a mapper that does the work. It contains 1 Make method:
    • IMapperBuilder Make(string assemblyName, TypeConfiguration defaultConfiguration): makes the mapper builder to be configured.
      • assemblyName: this is the dynamic assembly name the mapper uses to generate static methods in, it dosn't really matter, any valid assembly name would do.
      • TypeConfiguration: this is the default configuration that will be applied to all mapped entities, it's items are:
        • identityPropertyName, name of identity property, so by default the library will assume any property named as value of this string is the id property (id is important for database records)
        • timestampPropertyName, name of timestamp property, this is supposed to be the optimistic lock column used for concurrency checking. It's OK to set it to null if most tables in the database doesn't have such concurrency check columns.
        • keepEntityOnMappingRemoved, this is a boolean item to decide when a navigation record is removed or replaced, should we keep it in database or remove it from database. By default its value is false, which represents the good database design. Some more detailed information regarding this configuration item can be found in later sections. It's highly recommended to leave it to be the default value.
  • MapperBuilderFactory: this is the implementation of IMapperBuilderFactory, nothing much to explain.
  • IMapperBuilder: this is the builder interface to addin configurations for mapper, it provides several methods:
    • WithFactoryMethod<TEntity>(Expression<Func<TEntity>> factoryMethod, bool throwIfRedundant = false): the library needs to create new instances of mapped entities, for POCOs a default constructor (parameterless) should be there, and the library counts on most entity types to have this parameterless constructor. However, in extreme situations when parameterless constructors don't exist, this interface lets users to register a factory method to build new instances of the entity type. The library doesn't allow repeatedly registering factory methods for the same TEntity type, so the second parameter will make the library throw a relevant exception when set to true, otherwise only the first registration takes effects, later repeated registrations are simply ignored.
    • IMapperBuilder WithFactoryMethod<TList, TItem>(Expression<Func<TList>> factoryMethod, bool throwIfRedundant = false): this method registers a factory method for user defined collection types in case they are of interface types or don't have a default constructor that the library doesn't know how to instantiate such list properties during mapping process. Like ProtoBuf) uses RepeatedField class for collection properties. In case the property is not initialized (This situation doesn't really fit ProtoBuf because it does initialize RepeatedField properties when the message is constructed) when some entities need to be filled in it, the library needs to initialize the customized collection type. The library will automatically initialize a List<T> for ICollection<T>, IList<T> and List<T> types, or try to call the default parameterless constructor of the collection type. If the collection type doesn't fit in the 2 situations, then user must provide a factory method for this collection type, or else a corresponding exception will be thrown at run time.
    • IMapperBuilder WithConfiguration<TEntity>(TypeConfiguration configuration, bool throwIfRedundant = false), the library allows users to customize configurations to specific entity classes, the configuration parameter is exactly the same as that of IMapperBuilderFactory.Make method, except that it will be applied to only one entity type, and overwrites the default setting. Usage of throwIfRedundant parameter is similar to the one of IMapperBuilder.WithFactory method.
    • IMapperBuilder WithScalarConverter<TSource, TTarget>(Expression<Func<TSource?, TTarget?>> expression, bool throwIfRedundant = false), sometimes user need to use class types for scalar properties (like ProtoBuf doesn't support byte array, instead uses a ByteString class instead, and unfortuantely byte array is the best type for concurrency checking property in entity framework core). To be able to map a byte array property to a ByteString class, such scalar converters needs to be defined. Usage of throwIfRedundant parameter is similar to the one of IMapperBuilder.WithFactory method.
    • IMapperBuilder Register<TSource, TTarget>(), this is the method to trigger a register of mapping between 2 types: TSource and TTarget. If users want to map an instance of TSource to an instance of TTarget, they need to register it here in the builder, or else a corresponding exception will be thrown when users try to do the mapping later. Note that the registration is recursive, like in the example in introduction, users only need to expilcitly register mapping between Borrower and BorrowerDTO, registration between navigation properties are automatically done with top level entity registered. Note that to make sure all necessary properties can be successfully mapped, the following notes must be taken into consideration:
      • For mapping scalar properties between 2 entities, like int, long, string, byte[], names of the property must be the same for source and target (e.g. X.A and Y.A, A property will be mapped, X.a and Y.A, property a and A will be considered to have different names so not mapped); Also the two properties must either be of the same type (e.g. int X.A can be mapped to int Y.A, but int X.A will not be mapped to int? Y.A), or have a scalar converter that converts from the source property type to the target property type (e.g. int X.A can be mapped to string Y.A if WithScalarConverter<int, string>(<parameters>) has been called before this registration.
    • IMapperBuilder RegisterTwoWay<TSource, TTarget>(): this is a short cut method for calling Register<A, B>(), then calling Register<B, A>(), nothing much to explain.
    • IMapper Build(): this method builds the mapper to be used. Please note that for every mapper builder instance, this method is only supposed to be called once only.
  • IMapper: this is the interface for mapper, it creates 2 kinds of sessions:
    • IMappingToDatabaseSession CreateMappingToDatabaseSession(DbContext databaseContext): this method creates a session that handles mapping to database.
    • IMappingSession CreateMappingSession(), this method creates a session that handles mapping when database is not involved.
  • IMappingToDatabaseSession: this interface provides one asynchronous method to map to an entity that is supposed to be updated to database:
    • Task<TTarget> MapAsync<TSource, TTarget>(TSource source, Expression<Func<IQueryable<TTarget>, IQueryable<TTarget>>>? includer = default), source is the source object to be mapped from, includer is the Include expression for eager loading by entity framework core. If the source instance is supposed to update some existing record in database, please make sure to eager-load all navigation properties with the include expression. Plus, please don't call AsNoTracking method in this includer expression, it causes problems in database updation; the library will throw an exception is users do so.
  • IMappingSession: this interface provides one synchronous method to map to an entity when database is not needed:
    • TTarget Map<TSource, TTarget>(TSource source), source is the object to be mapped from, and it returns the instance of TTarget that is mapped to, nothing much to explain here.

Highlights

  1. Id and timestamp properties are considered key properties of entities, if explicitly configurated, mapping these properties doesn't need the property names to match. For example, for the following classes:
public class Entity1
{
    public int Id { get; set; }
    public byte[] TimeStamp { get; set; }
}
public class Entity2
{
    public int EntityId { get; set; }
    public byte[] ConcurrencyLock { get; set; }
}

If the following registration is done, then when mapping instances of these entities, id and timestamp properties will be correctly mapped:

mapperBuilder.WithConfiguration<Entity1>(new TypeConfiguration("Id", "TimeStamp")).WithConfiguration<Entity2>(new TypeConfiguration("EntityId", "ConcurrencyLock"));
  1. About TypeConfiguration.keepEntityOnMappingRemoved, in the example in introduction section, if borrowing record of id 2 is removed from borrowerDTO, when mapping back to database, the same record shouldn't be removed from database because it doesn't make sense to keep it anymore. So value of it is set to false by default and it's not recommended to change it to true. The possibility to set it to true is kept in case the database is not well designed like below:
public sealed class Borrower
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ICollection<Book> BorrowRecords { get; set; }
}
public sealed class Book
{
    public int Id { get; set; }
    public string Name { get; set; }
    public Borrower Borrower { get; set; }
}
3. Please not that when mapping reference type properties, a **shallow** copy will be performed.

Once a book is removed from a borrower's borrow records, we don't want to delete the book, in this case keepEntityOnMappingRemoved needs to be set to be true.

Restriction

  1. The library assumes that any entity could only have 1 property as Id property, multiple properties combined id is not supported.
  2. The library requires any entity that will get updated into database to have an Id property, which means every table in the database, as long as it will be mapped using the library, need to have 1 and only 1 column for Id.
  3. The library requires each property to have 1 or 0 column for optimistic lock.

Feedback

There there be any questions 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.

Product Compatible and additional computed target framework versions.
.NET net6.0 is compatible.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
0.8.2 265 10/22/2023
0.8.1 127 10/8/2023
0.8.0 115 10/1/2023
0.7.3 124 9/5/2023
0.7.2 152 9/3/2023
0.7.1 132 8/30/2023
0.7.0 128 8/28/2023
0.6.0 140 7/12/2023
0.5.0 151 6/17/2023
0.4.0 144 5/27/2023
0.3.0 135 5/25/2023
0.2.2 215 3/8/2023
0.2.1 528 4/26/2022
0.1.3 423 4/23/2022
0.1.2 417 4/2/2022
0.1.1 411 3/29/2022
0.1.0 409 3/28/2022