Reputation: 2594
I am using Mapster mapper and EF Core, and I have a Request
entity that has many Materials
.
I am fetching the graph from the database as a tracked graph and using Mapster I map dto to the EF object graph as follows:
dto.Adapt(requestGraph);
It updates the Materials
list, but the state of its items is "detached" instead of "modified", even though I am passing Ids in Materials
list items and when I call SaveChanges() method it throws this exception:
The instance of entity type 'Material' cannot be tracked because another instance with the key value '{Id: 2}' is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached.
Upvotes: 1
Views: 935
Reputation: 35093
This can be a common issue with relying on mappers with object graphs. Mappers can only construct instances or copy values between source and destination, they don't tie into ORMs to automatically fetch updated references. You need to do that manually.
For example if I have an entity graph that I want to update from a DTO where there is a Request which has one or more Material references which are shared between Requests. I have a RequestDTO perhaps with a collection of MaterialDTOs, or just MaterialIDs, I load the existing Request entity and it's graph:
var request = dbContext.Requests
.Include(x => x.Materials)
.Single(x => x.RequestId == requestId);
Then I go to update this from the DTO:
request.Adapt(dto);
Originally I might have had 1 material /w an ID of 10, and the DTO removes that material and adds two others, ID 11 and 12. Without using a mapper, the operations we would do with EF is Remove #10, then Add references to #11 and #12. The mapper cannot do that because it isn't integrated with EF to fetch the tracked references to #11 & 12, it can only do one of two things, update the fields in the existing reference (which would be bad, we don't want to "alter" material #10) or it can remove #10, and add a #11 and #12, but these wont be tracked references fetched from EF, they will be detached instances new
-ed up by the mapper. EF will complain if it happens to be tracking an instance when it tries to associate these when updating the requests, or you will get a constraint violation or inserting duplicate data with a different ID if it hadn't already been tracking an instance.
When using a mapper to update entities that contain references you should have the mapper configured to ignore the references, then update those manually. The method I use is to get the existing and updated IDs to determine which, if any, items need to be added or removed, then fetch any instances to be added from the DbContext:
// Using a mapper config that Ignores dto.Materials -> entity.Materials
request.Adapt(dto);
var existingMaterialIds = request.Materials
.Select(x => x.MaterialId)
.ToList();
var updatedMaterialIds = dto.Materials
.Select(x => x.MaterialId)
.ToList();
var materialIdsToAdd = updatedMaterialIds.Except(existingMaterialIds);
var materialIdsToRemove = existingMaterialIds.Except(updatedMaterialIds);
if (materialIdsToRemove.Any())
{
var materialsToRemove = request.Materials
.Where(x => materialIdsToRemove.Contains(x.MaterialId))
.ToList();
foreach(var material in materialsToRemove)
request.Materials.Remove(material);
}
if (materialIdsToAdd.Any())
{
var materialsToAdd = dbContext.Materials
.Where(x => materialIdsToAdd.Contains(x.MaterialId))
.ToList();
foreach(var material in materialsToAdd)
request.Materials.Add(material);
}
That example can be condensed down, I just formatted it so it's fairly easy to follow what it is doing.
For singular references such as if a Request only references a single Material, a mapper can manage these as long as there is a FK property exposed on the Request and it is told to ignore the navigation property. But for many-to-many collection of references you pretty much need to restrict the mapper to just the value properties and handle the references manually.
Upvotes: 0