Reputation: 1402
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:
EntityRequestDto
through the PATCH
endpoint and passes it to the service layerEntityRequestDto
to perform the following actions:
EntityRequestDto
to Entity
(retrieved in step 2.1) to update existing values (from request DTO to the entity) using AutoMapperEntity
Entity
Entity
from the service layer and save changes to the databaseThe 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;
}
}
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);
// ...
}
}
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
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