phwt
phwt

Reputation: 1402

Conditional map "null" of non-nullable types, enums, and collections with AutoMapper in C#

I'm developing a PATCH REST endpoint to do partial update to an EF Core entity. All properties in the request DTO (EntityRequestDto) are marked as nullable, while the actual entity (Entity) will be marked as either required or nullable according to the designed data structure.

The application is developed using repository pattern (Controller > Service > Repository), so the update flow will be like this:

  1. Controller receives EntityRequestDto through the PATCH endpoint and passes it to the service layer
  2. Service receives EntityRequestDto to perform the following actions:
    1. Contact the repository layer to retrieve the existing entity from the database
    2. Map EntityRequestDto to Entity (retrieved in step 2.1) to update existing values (from request DTO to the entity) using AutoMapper
    3. Validate the updated Entity
    4. Contact the repository layer again to save changes made to the Entity
  3. Repository layer receives the updated Entity from the service layer and save changes to the database

The above steps are equal to the following code:

[Authorize]
[Route("[controller]")]
public class EntityController(IEntityService service) : ControllerBase
{
    [HttpPatch("{id}")]
    public IActionResult Update(Guid id, [FromBody] EntityRequestDto requestDto) =>
        Ok(service.UpdateAsync(id, requestDto).Result);
}

public class EntityService(IEntityRepository repository): IEntityService
{
    public async Task<EntityResponseDto?> UpdateAsync(Guid id, EntityRequestDto requestDto)
    {
        var existingEntity = await repository.GetAsync(id);

        if (existingEntity is null) return null;

        mapper.Map(requestDto, existingEntity);
        await validator.ValidateAndThrowAsync(existingEntity);
        await repository.UpdateAsync(existingEntity);

        return mapper.Map<Entity, EntityResponseDto>(existingEntity);
    }
}

public class EntityRepository(EntityContext context) : IEntityRepository
{
    public async Task<Entity?> UpdateAsync(Entity entity)
    {
        await _context.SaveChangesAsync();
        return entity;
    }
}

Complications

The service layer relies on AutoMapper to do the partial update to help replace the properties present in the request DTO to the existing entity, while leaving other properties untouched.

I have encountered issues with non-nullable types, enums, and collections, so for the below entity model:

public enum EntityType
{
    TYPE_1,
    TYPE_2
}

public class Entity
{
    public required Guid Id { get; set; }
    public required EntityType Type { get; set; }
    public required int IntegerProperty { get; set; }
    public required string StringProperty { get; set; }
    public List<EntityItems> EntityItems { get; set; } = [];
}

public class EntityRequestDto
{
    public EntityType? Type { get; set; }
    public int? IntegerProperty { get; set; }
    public string? StringProperty { get; set; }
    public List<EntityItems> EntityItems { get; set; } = null;
}

public class EntityItems
{
    public required Guid Id { get; set; }
    public required string Name { get; set; }

    public required Guid EntityId { get; set; }
    public Entity Entity { get; set; } = null!;
}

Will requires following mapping between the request DTO and the entity to perform a partial update, otherwise null or the default value will get mapped into the existing entity (which should be untouched since they are not present in the request DTO).

public class Mapper : Profile
{
    public Mapper()
    {
        // If an integer property in the request DTO is `null`, do not map and preserve the value from the existing entity (prevent the default value of `0` get mapped into the entity)
        profile.CreateMap<int?, int>().ConvertUsing((src, dest) => src ?? dest);

        // If an enum property in the request DTO is `null`, do not map and preserve the value from the existing entity (prevent the default value of first enum constant or position `0` get mapped into the entity)
        profile.CreateMap<EntityType?, EntityType>().ConvertUsing((src, dest) => src ?? dest);

        // If `EntityItems` from the `EntityRequestDto` is `null`, do not map it to the entity (which will remove all existing items in the entity)
        profile.CreateMap<EntityRequestDto, Entity>().ForMember(dest => dest.EntityItems, opt =>
            {
                opt.PreCondition((src) => src.EntityItems != null);
                opt.MapFrom(src => src.EntityItems);
            });

        // For other properties, do not map if the value in the `EntityRequestDto` is `null`
        profile.CreateMap<EntityRequestDto, Entity>().PreserveReferences()
               .EqualityComparison((requestDto, entity) => requestDto.Id == entity.Id)
               .ForAllMembers(option => option.Condition((_, _, sourceMember) => sourceMember != null));
    }
}

Additionally, the following packages are used and configured in the Program.cs like this:

public static class Program
{
    private static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        builder.Services.AddAutoMapper((serviceProvider, config) =>
        {
            config.AddCollectionMappers();
            config.UseEntityFrameworkCoreModel<EntityContext>(serviceProvider);
        }, typeof(Mapper).Assembly);

        // ...
    }
}

Question

As you can see, this seems like a workaround for because a single entity requires multiple mappings for non-nullable types, enums, and collections that are present inside the entity model.

I must do this for every entity in the system, which could potentially create a bug (for example, introduced a new enums, forgot to map, the property unintentionally updated to 0 on every update)

I am wondering what is the proper method to perform a partial update in a scenario like this using AutoMapper?

Upvotes: 0

Views: 78

Answers (1)

phwt
phwt

Reputation: 1402

Tried tinkering around and settled on using utility functions, still not ideal for me but at least it make the code less cluttered and would like to share it here.

The AutoMapperUtility utility class looks like this with three utility functions for dealing with non-nullable types, enums, and entities (along with its DTOs)

using AutoMapper;
using AutoMapper.EquivalencyExpression;

public static class AutoMapperUtility
{
    public static void RegisterNonNullableTypeMappings(Profile profile)
    {
        profile.CreateMap<bool?, bool>().ConvertUsing((src, dest) => src ?? dest);
        profile.CreateMap<decimal?, decimal>().ConvertUsing((src, dest) => src ?? dest);
        profile.CreateMap<double?, double>().ConvertUsing((src, dest) => src ?? dest);
        profile.CreateMap<int?, int>().ConvertUsing((src, dest) => src ?? dest);
        profile.CreateMap<Guid?, Guid>().ConvertUsing((src, dest) => src ?? dest);
        // Other non-nullable types here
    }

    public static void CreateEnumMap<TEnum>(Profile profile) where TEnum : struct =>
        profile.CreateMap<TEnum?, TEnum>().ConvertUsing((src, dest) => src ?? dest);

    public static void CreateEntityMap<TEntity, TRequestDto, TResponseDto>(Profile profile)
        where TEntity : Entity
        where TRequestDto : RequestDto
        where TResponseDto : ResponseDto
    {
        profile.CreateMap<TRequestDto, TEntity>().PreserveReferences()
               .EqualityComparison((requestDto, entity) => requestDto.Id == entity.Id)
               .ForAllMembers(option => option.Condition((_, _, sourceMember) => sourceMember != null));

        profile.CreateMap<TEntity, TResponseDto>().PreserveReferences()
               .EqualityComparison((entity, responseDto) => entity.Id == responseDto.Id)
               .ForAllMembers(option => option.Condition((_, _, sourceMember) => sourceMember != null));
    }
}

The usage is similar to the below, the AutoMapperUtility.RegisterNonNullableTypeMappings only need to be registered once throughout the entire application.

using AutoMapper;

public class Mapper : Profile
{
    public Mapper()
    {
        AutoMapperUtility.RegisterNonNullableTypeMappings(this);

        // Mapping for `Entity` and related types
        AutoMapperUtility.CreateEntityMapWithNullChecking<Entity, EntityRequestDto, EntityResponseDto>(this);
        AutoMapperUtility.CreateEnumMap<EntityType>(this);

        // Still did not find a way to make a utility function that's shorter than manually mapping it
        CreateMap<EntityRequestDto, Entity>().ForMember(dest =>
            dest.EntityItems, opt => { opt.PreCondition((src) => src.EntityItems != null); opt.MapFrom(src => src.EntityItems);
        });
    }
}

Upvotes: 0

Related Questions