AspNetCore7 Blazor WASM app paired with an AspNetCore7 API with EF Core 7, using Automapper between Model and DTO.
When I attempt to execute a PUT
endpoint method, I get the following error: The instance of entity type 'UserLocation' cannot be tracked because another instance with the same key value for {'Id'} is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the conflicting key values.
My Model:
public class ApiUser : IdentityUser
{
...
public virtual List<UserLocation> Locations { get; set; } = new();
}
public class UserLocation
{
public Guid Id { get; set; }
[ForeignKey(nameof(User))]
public string UserId { get; set; }
public virtual ApiUser User { get; set; }
...
public virtual UserLocationStat Stat { get; set; }
}
[PrimaryKey(nameof(UserLocationId))]
public class UserLocationStat
{
[ForeignKey(nameof(Location))]
public Guid UserLocationId { get; set; }
public virtual UserLocation Location { get; set; }
...
}
My DTOs:
public class UserEditAccountDto
{
public string Id { get; set; } = string.Empty;
public List<UserLocationDto> Locations { get; set; } = new();
}
public class UserLocationDto : UdbObjectDto
{
public Guid Id { get; set; }
public string? UserId { get; set; }
...
public UserLocationStatDto? Stat { get; set; }
}
public class UserLocationStatDto
{
public Guid UserLocationId { get; set; }
...
}
Automapper service extension:
public static void ConfigureAutoMapper(this IServiceCollection services)
{
services.AddAutoMapper(Assembly.GetExecutingAssembly());
}
Automapper initializer:
public class MapperInitializer : Profile
{
public MapperInitializer()
{
CreateMap<ApiUser, UserEditAccountDto>().ReverseMap();
CreateMap<UserLocation, UserLocationDto>().ReverseMap();
CreateMap<UserLocationStat, UserLocationStatDto>().ReverseMap();
}
}
API Endpoint:
[HttpPut]
[Route("edit-user")]
public async Task<IActionResult> EditUser(UserEditAccountDto userEditAccountDto)
{
//get user for update
var apiUser = await _context.Users
.Where(x => x.Id == userEditAccountDto.Id)
.Include(x => x.Locations).ThenInclude(loc => loc.Stat)
.FirstOrDefaultAsync();
if (apiUser == null)
return BadRequest();
//map dto to entity
_mapper.Map(userEditAccountDto, apiUser);
//SAVE
await _context.SaveChangesAsync();
return NoContent();
}
If I remove the line _mapper.Map(userEditAccountDto, apiUser);
and just manually update a property of apiUser
the save operation await _context.SaveChangesAsync();
works. It seems it's an Automapper issue. Either I don't understand how to properly use Automapper, or my model/DTOs aren't set up properly. Can someone with more experience take a look and advise?
This is probably a limitation or issue with the fact that the DTO/entities are an aggregate root. (contains UserLocations) You are eager loading the existing user locations with the entity which is a good required first step and missing that would have been an obvious problem.
Automapper's behaviour when dealing with aggregate roots varies depending on whether it has mapping configurations set up for the related entities. In a like-for-like copy scenario (A.Child -> B.Child) if Automapper has a mapping configuration for the Child (in your case UserLocationStat and UserLocation with their respective DTOs) then the copy will be a deep copy where the values from A's child will be copied into B's reference. If Automapper does not have a mapping declared for the Children, then it will shallow copy which would normally see B's child reference set to A's, but given we are having automapper transition from DTO to entity, that would probably result in a new UserLocationStat/UserLocation entity being created and added to B. (Which would likely result in an error around already tracked instances) You would want the first scenario, so ensuring that your Automapper configuration has mapping declarations for UserLocationStat and UserLocation. This is potentially what is missing in your case.
A bigger problem will be if your request is doing things like adding Locations to a user in the request through the DTO. In a normal parent-child relationship (think Orders and OrderItems) where the child collection is essentially owned by the parent, this is Ok provided that the child doesn't itself reference anything that isn't owned. If UserLocations are or contain something like a lookup reference which is a row that would be referenced through several different User's Locations, adding a UserLocationStat for a UserLocation new to that User but referencing an existing row will cause a problem. Automapper does not know "hey, this is an existing record I want to create an association to this User, fetch the UserLocation (or other related entity) from the DbContext and create a new UserLocationStat linking to it". It would attempt to create a new UserLocationStat and a new UserLocation. If the DbContext happened to be tracking the UserLocation you run into the same problems despite having the deep copy mapping.
In a nutshell, copying data in aggregate roots with Automapper is going to be extremely situational, and probably should be avoided except for situations where you are doing like-for-like copying where ideally only values within the aggregate are being changed, not adding/removing relationships, especially anything involving references shared between other aggregates/entities. Automapper represents a black box when investigating how data is moving between a DTO and Entity so ideally it should only really be relied on for simple scenarios. For more complex entities and scenarios I recommend using code to manually handle updates as it's much easier to follow what is being done for various scenarios and spot potential issues.