EF Core自引用多对多添加新关联时触发重复键错误
我定义了一个名为Image的EF Core实体,通过如下配置实现自引用多对多关系:
实体类代码
public class Image { public int Id { get; set; } // ... 其他列 ... public List<Image> AlternativeImages { get; set; } }
EF Core实体配置
modelBuilder.Entity<Image>() .HasMany(i => i.AlternativeImages) .WithMany() .UsingEntity(j => j.ToTable("ImageAlternatives"));
问题现象
添加带有任意数量替代图片的Image实体时一切正常,但为已有替代图片的Image实体添加新替代图片时(原有实体无替代图片则无问题),EF Core会尝试重新插入旧关联到连接表,触发唯一键冲突错误:
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes.
---> Npgsql.PostgresException (0x80004005): 23505: duplicate key value violates unique constraint "PK_ImageAlternatives"
DETAIL: Key ("AlternativeImagesId", "ImageId")=(..., ...) already exists.
场景示例
现有ImageA、ImageB、ImageC,若ImageA已将ImageB作为替代图片,当向ImageA的替代图片列表中添加ImageC(同时维护双向引用以保持关系对称),会抛出(ImageAId, ImageBId)键已存在的异常。尽管ImageA和ImageB的跟踪状态均为Unchanged,仅ImageC为Added状态,且用于替代图片列表的ImageA和ImageB实例是上下文已跟踪的同一实例,未被分离或重新创建。
补充说明1:复现代码
实际实现更复杂(Image属于领域模型中的另一聚合根),以下是简化后的更新操作代码可重现该错误:
public async Task UpdateAsync() { using var dbContext = await _dbContextFactory.CreateDbContextAsync(); using var transaction = await dbContext.Database.BeginTransactionAsync(); try { var existingImage = await dbContext.Images .Include(i => i.AlternativeImages) .AsSplitQuery() .FirstOrDefaultAsync(s => s.Id == 10217); var tmp = new Image { ImageUrl = "..." }; existingImage.AddAlternativeImage(tmp); foreach (var entry in dbContext.ChangeTracker.Entries<Image>()) { Console.WriteLine(""); } await dbContext.SaveChangesAsync(); //await transaction.RollbackAsync(); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; } }
AddAlternativeImage方法用于确保双向关系一致性,会将新图片添加到所有现有替代图片的列表中,同时将现有图片添加到新图片的替代列表:
internal bool AddAlternativeImage(Image image) { if (image == null) return false; bool addedSomething = false; // Initialize the collection if it is null. AlternativeImages ??= new List<Image>(); // If the provided image is NOT the main one, try to add it. if (!image.ImageUrl.Equals(this.ImageUrl, StringComparison.OrdinalIgnoreCase)) { if (!AlternativeImages.Any(existing => existing.ImageUrl.Equals(image.ImageUrl, StringComparison.OrdinalIgnoreCase))) { AlternativeImages.Add(image); addedSomething = true; } } // Process the alternative images of 'image' regardless of whether 'image' is the same as the main one. if (image.AlternativeImages != null) { foreach (var alt in image.AlternativeImages) { if (!alt.ImageUrl.Equals(this.ImageUrl, StringComparison.OrdinalIgnoreCase)) { if (!AlternativeImages.Any(existing => existing.ImageUrl.Equals(alt.ImageUrl, StringComparison.OrdinalIgnoreCase))) { AlternativeImages.Add(alt); addedSomething = true; } } } } // Unified list of all alternative images. var unionImages = AlternativeImages.ToList(); foreach (var alt in unionImages) { var newAlternatives = new List<Image>(); alt.AlternativeImages ??= new List<Image>(); // Include the main image. if (!this.ImageUrl.Equals(alt.ImageUrl, StringComparison.OrdinalIgnoreCase)) newAlternatives.Add(this); // Add the other alternative images. foreach (var other in unionImages) { if (!other.ImageUrl.Equals(alt.ImageUrl, StringComparison.OrdinalIgnoreCase)) newAlternatives.Add(other); } // Update on the same list to avoid problems with ef core tracking. if (alt.AlternativeImages == null) alt.AlternativeImages = newAlternatives; else { alt.AlternativeImages?.Clear(); alt.AlternativeImages!.AddRange(newAlternatives); } } return addedSomething; }
补充说明2:连接表SQL定义
-- auto-generated definition create table "ImageAlternatives" ( "AlternativeImagesId" integer not null constraint "FK_ImageAlternatives_Images_AlternativeImagesId" references "Images" on delete cascade, "ImageId" integer not null constraint "FK_ImageAlternatives_Images_ImageId" references "Images" on delete cascade, constraint "PK_ImageAlternatives" primary key ("AlternativeImagesId", "ImageId") );
内容的提问来源于stack exchange,提问作者Alex

