Search code examples
asp.net-mvcentity-framework-coremany-to-manycrud

What is the proper way to update/delete many-to-many relationships in Entity Framework Core?


Having two entities that have a many-to-many relationship between them using the approach where Entity Framework creates the associative table how do I do the update/delete for the two entities?

For example if I have the entities:

public class Trip
{
    [Key]
    public Guid TripId { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }

    [ValidateNever]
    public IEnumerable<Tag> Tags { get; set; }
}

public class Tag
{
    [Key]
    public Guid TagId { get; set; }
    public string Name { get; set; }

    [ValidateNever]
    public IEnumerable<Trip> Trips { get; set; }
}

When I create/update a trip I want to select a list of tags.

To add a trip entity to the db I wrote something like:

public async Task<Trip> AddTripAsync(Trip trip)
{
    foreach(var tag in trip.Tags)
    {
        _dbContext.Attach(tag);
    }
    _dbContext.Trips.Add(trip);
    await _dbContext.SaveChangesAsync();
    return trip;
}

It works well.

How can I update the Trip entity? I can't figure out how to remove records from the associative table created by EF I tried something like this:

public async Task<EntityOne> Update(Trip trip){
    // gets the trip from db including tag objects list
    Trip? tripFromDb = await GetTripAsync(temp => temp.TripId == trip.TripId, "Tags");
    if (tripFromDb == null)
    {
        return trip;
    }
    tripFromDb.Name = trip.Name;
    tripFromDb.Description = trip.Description;
    foreach (var tag in trip.Tags)
    {
        var tagFromDb = tripFromDb.Tags.SingleOrDefault(x => x.TagId == tag.TagId);
        if (tagFromDb != null)
        {
            // if the tag already exists in the trip's tags list don't try to add it again
            _dbContext.Attach(tagFromDb);
        }
        else
        {
            tripFromDb.Tags.Add(tag);
        }
    }
    
    await _dbContext.SaveChangesAsync();    
    
    return entityToCreate;
}

And nothing changes. Can the update be done this way or do I have to create the TripsTags associative table myself for this?

EDIT: This fixed it:

public async Task<EntityOne> Update(Trip trip){
    // gets the trip from db including tag objects list
    //Trip? tripFromDb = await GetTripAsync(temp => temp.TripId == trip.TripId, "Tags");
    Trip? tripFromDb = _dbContext.Trips.FirstOrDefault(t => t.TripId == trip.TripId);
    if (tripFromDb == null)
    {
        return trip;
    }


    tripFromDb.Name = trip.Name;
    tripFromDb.Description = trip.Description;
    _dbContext.Entry(tripFromDb).Collection("Tags").Load();
    var existingTagIds = tripFromDb.Tags.Select(x => x.TagId).ToList();
    var updatedTagIds = trip.Tags.Select(x => x.TagId).ToList();
    var tagIdsToAdd = updatedTagIds.Except(existingTagIds);
    var tagIdsToRemove = existingTagIds.Except(updatedTagIds);

    if (tagIdsToRemove.Any())
    {
       var tagsToRemove = tripFromDb.Tags.Where(x => tagIdsToRemove.Contains(x.TagId)).ToList();
       foreach(var tag in tagsToRemove)
           tripFromDb.Tags.Remove(tag);
    }

    if (tagIdsToAdd.Any())
    {
        var tagsToAdd = await _dbContext.Tags.Where(x => tagIdsToAdd.Contains(x.TagId)).ToListAsync();
        foreach(var tag in tagsToAdd)
            tripFromDb.Tags.Add(tag);
    }

    
    await _dbContext.SaveChangesAsync();    
    
    return tripFromDb;
}

Solution

  • Entities represent data state. When they are detached from a DbContext, then they should only be relied on as a point in time snapshot of the data. If that detached entity was serialized and sent to a view, then passed back to the server in a Request, then it cannot, and should never be trusted to be representative of actual data state as it may be stale, it may not be complete, or at worst, it may be tampered with. Personally I do not recommend ever using entities as DTOs/ViewModels in this way as methods written to work with entities should never need to worry about whether they are passed real, tracked entities versus detached or reconstituted fields cast as an entity.

    To get to the crux of the problem though, a many-to-many relationship can be treated by EF much like a many-to-one "reference" where that joining table just needs the two FKs as a composite key. A has a collection of Bs, and each B has a collection of As. Chances are that the deserialized fake "entity" will have an "A" with a collection of "B"s, however those "B" instances won't contain information about the "A"s that they belong to. They will be cut down stubs of the entities they once represented due to serialization depth going to the client and data available when coming back. This isn't something suited to being "attached" to the DbContext in such an incomplete and untrustworthy state. To handle updating the relationships we need to be a bit explicit to determine what associations need to be added or removed:

    public async Task<EntityOne> Update(Trip trip){
        // gets the trip from db including tag objects list
        Trip? tripFromDb = await GetTripAsync(temp => temp.TripId == trip.TripId, "Tags");
        if (tripFromDb == null)
        {
            return trip;
        }
    
    
        tripFromDb.Name = trip.Name;
        tripFromDb.Description = trip.Description;
    
        var existingTagIds = tripFromDb.Tags.Select(x => x.TagId).ToList();
        var updatedTagIds = trip.Tags.Select(x => x.TagId).ToList();
        var tagIdsToAdd = updatedTagIds.Except(existingTagIds);
        var tagIdsToRemove = existingTagIds.Except(updatedTagIds);
    
        if (tagIdsToRemove.Any())
        {
           var tagsToRemove = tripFromDb.Tags.Where(x => tagIdsToRemove.Contains(x.TagId)).ToList();
           foreach(var tag in tagsToRemove)
               tripFromDb.Tags.Remove(tag);
        }
    
        if (tagIdsToAdd.Any())
        {
            var tagsToAdd = await _dbContext.Tags.Where(x => tagIdsToAdd.Contains(x.TagId)).ToListAsync();
            foreach(var tag in tagsToAdd)
                tripFromDb.Tags.Add(tag);
        }
    
        
        await _dbContext.SaveChangesAsync();    
        
        return tripFromDb;
    }
    

    The difference here is that we use the provided data coming in the request to determine which references might have been added or removed, then use that to update the real current data state. The passed in detached "entity" itself isn't trusted, or suited to be re-attached and persisted. Where there are associations to add, we fetch those Tags by ID and associate them. EF will take care of the joining records automatically.