You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
There is an issue with mapping parent (Document) and child (LineItem) entities when using BulkSaveChanges. The error occurs only on SQL Server and not with in-memory SQLite or when using EF Core's native SaveChanges.
Problem
The issue is likely caused by the structure of the database schema, where tables that include EmployeeId also include CompanyId as a FK depite Employee itself has a FK on Company. This setup introduces mapping problems during the bulk save operation when BulkSaveChangesAsync is used.
The error occurs when Document entities with associated LineItem entities are saved. Both Document and LineItem reference CompanyId and EmployeeId fields. During bulk save, these relationships appear to be misaligned, resulting in incorrect mapping of child entities (LineItem) to their parent entity (Document).
Reproduction
The issue can be reproduced with the following setup:
Database Schema: I created the following DB structure:
Scenario: Create 2 companies, each with 23 employees, where each employee has 1 turnover. Generate a Document for each employee, with LineItem entities created for each of their turnovers.
Expected Result: LineItem entities should be correctly mapped to their parent Document.
Actual Result: While 46 Document entities are created (23 employees × 2 companies), their child LineItem entities are incorrectly assigned to documents. For employees with multiple turnovers, the LineItem entities belonging together are grouped and assigned to the same (wrong) document.
Code Example:
usingBogus;usingBogus.Extensions;usingEFCore.BulkExtensions;usingFluentAssertions;usingMicrosoft.Data.Sqlite;usingMicrosoft.EntityFrameworkCore;usingMicrosoft.Extensions.DependencyInjection;usingSystem.ComponentModel.DataAnnotations;usingSystem.ComponentModel.DataAnnotations.Schema;
#region sql
/*CREATE TABLE [Company] ( [Id] INT NOT NULL IDENTITY(1, 1) CONSTRAINT [PK_Company_Id] PRIMARY KEY CLUSTERED ([Id] ASC), [Name] NVARCHAR(250) NOT NULL CONSTRAINT [UIX_Company_Name] UNIQUE NONCLUSTERED ([Name] ASC))CREATE TABLE [Employee] ( [Id] INT NOT NULL IDENTITY(1, 1) CONSTRAINT [PK_Employee_Id] PRIMARY KEY CLUSTERED ([Id] ASC), [CompanyId] INT NOT NULL CONSTRAINT [FK_Employee_CompanyId_Company_Id] FOREIGN KEY ([CompanyId]) REFERENCES [Company] ([Id]))-- the employees can switch the company so it's important to have the company id in the turnover tableCREATE TABLE [Turnover] ( [Id] INT NOT NULL IDENTITY(1, 1) CONSTRAINT [PK_Turnover_Id] PRIMARY KEY CLUSTERED ([Id] ASC), [EmployeeId] INT NOT NULL CONSTRAINT [FK_Turnover_EmployeeId_Employee_Id] FOREIGN KEY ([EmployeeId]) REFERENCES [Employee] ([Id]), [CompanyId] INT NOT NULL CONSTRAINT [FK_Turnover_CompanyId_Company_Id] FOREIGN KEY ([CompanyId]) REFERENCES [Company] ([Id]), [Date] DATE NOT NULL, [Amount] MONEY NOT NULL)-- the employees can switch the company so it's important to have the company id in the document tableCREATE TABLE [Document] ( [Id] INT NOT NULL IDENTITY(1, 1) CONSTRAINT [PK_Document_Id] PRIMARY KEY CLUSTERED ([Id] ASC), [EmployeeId] INT NOT NULL CONSTRAINT [FK_Document_EmployeeId_Employee_Id] FOREIGN KEY ([EmployeeId]) REFERENCES [Employee] ([Id]), [CompanyId] INT NOT NULL CONSTRAINT [FK_Document_CompanyId_Company_Id] FOREIGN KEY ([CompanyId]) REFERENCES [Company] ([Id]), [TotalAmount] MONEY NOT NULL, [CreatedAt] DATETIMEOFFSET NOT NULL CONSTRAINT [DF_Document_CreatedAt_SYSDATETIMEOFFSET_UTC] DEFAULT (SYSDATETIMEOFFSET() AT TIME ZONE 'UTC'))-- reference on turnover + snapshotCREATE TABLE [LineItem] ( [Id] INT NOT NULL IDENTITY(1, 1) CONSTRAINT [PK_LineItem_Id] PRIMARY KEY CLUSTERED ([Id] ASC), [DocumentId] INT NOT NULL CONSTRAINT [FK_LineItem_DocumentId_Document_Id] FOREIGN KEY ([DocumentId]) REFERENCES [Document] ([Id]), [TurnoverId] INT NOT NULL CONSTRAINT [FK_LineItem_TurnoverId_Turnovere_Id] FOREIGN KEY ([TurnoverId]) REFERENCES [Turnover] ([Id]), [Date] DATE NOT NULL, [Amount] MONEY NOT NULL)*/
#endregion
#region scaffold autogenerated
// Scaffold-DbContext -Connection "" -Provider "Microsoft.EntityFrameworkCore.SqlServer" -Context "MyDbContext" -Tables "Company", "Employee", "Turnover", "Document", "LineItem" -DataAnnotations -NoOnConfiguring -Force[Table("Company")][Index("Name",Name="UIX_Company_Name",IsUnique=true)]publicpartialclassCompany{[Key]publicintId{get;set;}[StringLength(250)]publicstringName{get;set;}=null!;[InverseProperty("Company")]publicvirtualICollection<Document>Documents{get;set;}=newList<Document>();[InverseProperty("Company")]publicvirtualICollection<Employee>Employees{get;set;}=newList<Employee>();[InverseProperty("Company")]publicvirtualICollection<Turnover>Turnovers{get;set;}=newList<Turnover>();}[Table("Employee")]publicpartialclassEmployee{[Key]publicintId{get;set;}publicintCompanyId{get;set;}[ForeignKey("CompanyId")][InverseProperty("Employees")]publicvirtualCompanyCompany{get;set;}=null!;[InverseProperty("Employee")]publicvirtualICollection<Document>Documents{get;set;}=newList<Document>();[InverseProperty("Employee")]publicvirtualICollection<Turnover>Turnovers{get;set;}=newList<Turnover>();}[Table("Turnover")]publicpartialclassTurnover{[Key]publicintId{get;set;}publicintEmployeeId{get;set;}publicintCompanyId{get;set;}publicDateOnlyDate{get;set;}[Column(TypeName="money")]publicdecimalAmount{get;set;}[ForeignKey("CompanyId")][InverseProperty("Turnovers")]publicvirtualCompanyCompany{get;set;}=null!;[ForeignKey("EmployeeId")][InverseProperty("Turnovers")]publicvirtualEmployeeEmployee{get;set;}=null!;[InverseProperty("Turnover")]publicvirtualICollection<LineItem>LineItems{get;set;}=newList<LineItem>();}[Table("Document")]publicpartialclassDocument{[Key]publicintId{get;set;}publicintEmployeeId{get;set;}publicintCompanyId{get;set;}[Column(TypeName="money")]publicdecimalTotalAmount{get;set;}publicDateTimeOffsetCreatedAt{get;set;}[ForeignKey("CompanyId")][InverseProperty("Documents")]publicvirtualCompanyCompany{get;set;}=null!;[ForeignKey("EmployeeId")][InverseProperty("Documents")]publicvirtualEmployeeEmployee{get;set;}=null!;[InverseProperty("Document")]publicvirtualICollection<LineItem>LineItems{get;set;}=newList<LineItem>();}[Table("LineItem")]publicpartialclassLineItem{[Key]publicintId{get;set;}publicintDocumentId{get;set;}publicintTurnoverId{get;set;}publicDateOnlyDate{get;set;}[Column(TypeName="money")]publicdecimalAmount{get;set;}[ForeignKey("DocumentId")][InverseProperty("LineItems")]publicvirtualDocumentDocument{get;set;}=null!;[ForeignKey("TurnoverId")][InverseProperty("LineItems")]publicvirtualTurnoverTurnover{get;set;}=null!;}publicpartialclassMyDbContext:DbContext{publicMyDbContext(DbContextOptions<MyDbContext>options):base(options){}publicvirtualDbSet<Company>Companies{get;set;}publicvirtualDbSet<Document>Documents{get;set;}publicvirtualDbSet<Employee>Employees{get;set;}publicvirtualDbSet<LineItem>LineItems{get;set;}publicvirtualDbSet<Turnover>Turnovers{get;set;}protectedoverridevoidOnModelCreating(ModelBuildermodelBuilder){modelBuilder.Entity<Company>(entity =>{entity.HasKey(e =>e.Id).HasName("PK_Company_Id");});modelBuilder.Entity<Document>(entity =>{entity.HasKey(e =>e.Id).HasName("PK_Document_Id");entity.Property(e =>e.CreatedAt).HasDefaultValueSql("((sysdatetimeoffset() AT TIME ZONE 'UTC'))");entity.HasOne(d =>d.Company).WithMany(p =>p.Documents).OnDelete(DeleteBehavior.ClientSetNull).HasConstraintName("FK_Document_CompanyId_Company_Id");entity.HasOne(d =>d.Employee).WithMany(p =>p.Documents).OnDelete(DeleteBehavior.ClientSetNull).HasConstraintName("FK_Document_EmployeeId_Employee_Id");});modelBuilder.Entity<Employee>(entity =>{entity.HasKey(e =>e.Id).HasName("PK_Employee_Id");entity.HasOne(d =>d.Company).WithMany(p =>p.Employees).OnDelete(DeleteBehavior.ClientSetNull).HasConstraintName("FK_Employee_CompanyId_Company_Id");});modelBuilder.Entity<LineItem>(entity =>{entity.HasKey(e =>e.Id).HasName("PK_LineItem_Id");entity.HasOne(d =>d.Document).WithMany(p =>p.LineItems).OnDelete(DeleteBehavior.ClientSetNull).HasConstraintName("FK_LineItem_DocumentId_Document_Id");entity.HasOne(d =>d.Turnover).WithMany(p =>p.LineItems).OnDelete(DeleteBehavior.ClientSetNull).HasConstraintName("FK_LineItem_TurnoverId_Turnovere_Id");});modelBuilder.Entity<Turnover>(entity =>{entity.HasKey(e =>e.Id).HasName("PK_Turnover_Id");entity.HasOne(d =>d.Company).WithMany(p =>p.Turnovers).OnDelete(DeleteBehavior.ClientSetNull).HasConstraintName("FK_Turnover_CompanyId_Company_Id");entity.HasOne(d =>d.Employee).WithMany(p =>p.Turnovers).OnDelete(DeleteBehavior.ClientSetNull).HasConstraintName("FK_Turnover_EmployeeId_Employee_Id");});OnModelCreatingPartial(modelBuilder);}partialvoidOnModelCreatingPartial(ModelBuildermodelBuilder);}
#endregion
#region partial extensions
publicpartialclassMyDbContext:DbContext{partialvoidOnModelCreatingPartial(ModelBuildermodelBuilder){if(Database.IsSqlite()){modelBuilder.Entity<Document>().Property(e =>e.CreatedAt)// SQLite (use for unit tests) does not support (sysdatetimeoffset() AT TIME ZONE 'UTC').HasDefaultValueSql("CURRENT_TIMESTAMP");}}}
#endregion
publicclassBulkInsertTests{privatereadonlyMyDbContext_context;publicBulkInsertTests(){varsqlite=true;// for "Sqlite = false;" it should be ensured that the tables defined in the "SQL" region (see above) existvarservices=newServiceCollection().AddDbContext<MyDbContext>(options =>{options.EnableDetailedErrors().EnableSensitiveDataLogging();if(sqlite){options.UseSqlite(newSqliteConnection("DataSource=:memory:"));}else{options.UseSqlServer("");}});varprovider=services.BuildServiceProvider();_context=provider.GetRequiredService<MyDbContext>();_context.Database.OpenConnection();_context.Database.EnsureCreated();}[Fact(Skip="https://github.com/borisdj/EFCore.BulkExtensions/issues/1622")]publicasyncTaskBulkSaveChanges_Insert_Defaults(){
#region seeding
// 1 company with 5 employeevarcompany=newFaker<Company>().RuleFor(x =>x.Name, f =>f.Company.CompanyName().ClampLength(max:250)).RuleFor(x =>x.Employees,newFaker<Employee>().Generate(100)).Generate();await_context.Companies.AddAsync(company);await_context.SaveChangesAsync();
#endregion
// 1 doc for each employeevardocs=company.Employees.Select(employee =>{returnnewDocument{EmployeeId=employee.Id,CompanyId=company.Id,TotalAmount=0,//CreatedAt = DateTimeOffset.UtcNow,};}).ToList();await_context.Documents.AddRangeAsync(docs);BulkConfigbulkConfig=new(){SetOutputIdentity=true};await_context.BulkSaveChangesAsync(bulkConfig);varactual=await_context.Documents.ToListAsync();actual.ForEach(x =>x.CreatedAt.Should().NotBe(default));}[Fact(Skip="sqlite = false: error with assignment between Document and LineItem (see 'error:'-comments)")]publicasyncTaskBulkSaveChanges_Insert_ParentWithChilds(){// error: it seems that the error occurs because everythere we have the employeeId we also have the companyId because the employee can switch the company
#region seeding
// <companiesCount> companies with <employeesCount> employees each with <turnoversCount> turnovers each// error: we need at least 2 companies with 23 employees each with 1 turnover each to cause the error, if we use less the error doesn't occurconstintcompaniesCount=2;constintemployeesCount=23;constintturnoversCount=1;varcompanies=newFaker<Company>().RuleFor(x =>x.Name, f =>f.Company.CompanyName().ClampLength(max:250)).Generate(companiesCount);companies.ForEach(company =>{varemployees=newFaker<Employee>().RuleFor(x =>x.Company,company).Generate(employeesCount);employees.ForEach(employee =>{varturnovers=newFaker<Turnover>().RuleFor(x =>x.Employee,employee).RuleFor(x =>x.Company,company).RuleFor(x =>x.Date, f =>f.Date.PastDateOnly()).RuleFor(x =>x.Amount, f =>f.Finance.Amount()).Generate(turnoversCount);employee.Turnovers=turnovers;});company.Employees=employees;});await_context.Companies.AddRangeAsync(companies);await_context.SaveChangesAsync();
#endregion
// 1 doc for each employee with 1 line item for each turnoverList<Document>docs=[];foreach(varcompanyincompanies){foreach(varemployeeincompany.Employees){varlineItems=employee.Turnovers.Select(turnover =>{returnnewLineItem{TurnoverId=turnover.Id,Date=turnover.Date,Amount=turnover.Amount,};}).ToList();Documentdoc=new(){EmployeeId=employee.Id,CompanyId=company.Id,TotalAmount=lineItems.Sum(x =>x.Amount),LineItems=lineItems,};docs.Add(doc);}}await_context.Documents.AddRangeAsync(docs);BulkConfigbulkConfig=new(){SetOutputIdentity=true};await_context.BulkSaveChangesAsync(bulkConfig);varactual=await_context.Documents.Include(x =>x.LineItems).ToListAsync();actual.ForEach(doc =>doc.TotalAmount.Should().Be(doc.LineItems.Sum(x =>x.Amount)));
#region error check sql
/* SELECT CASE WHEN Document.Id IS NULL OR LineItems.DocumentId IS NULL THEN 0 ELSE 1 END AS SuccessfulJoin, CASE WHEN Document.CompanyId != LineItems.CompanyId THEN 0 ELSE 1 END AS SameCompany, CASE WHEN Document.EmployeeId != LineItems.EmployeeId THEN 0 ELSE 1 END AS SameEmployee, CASE WHEN Document.Id != LineItems.DocumentId THEN 0 ELSE 1 END AS SameFile, CASE WHEN Document.TotalAmount != LineItems.TotalAmount THEN 0 ELSE 1 END AS SameAmount, * FROM ( SELECT Id, EmployeeId, CompanyId, TotalAmount FROM Document ) AS Document FULL OUTER JOIN ( SELECT DocumentId, EmployeeId, CompanyId, SUM(t.Amount) AS TotalAmount FROM LineItem li INNER JOIN Turnover t ON t.Id = li.TurnoverId GROUP BY DocumentId, EmployeeId, CompanyId ) AS LineItems -- ON LineItems.DocumentId = Document.Id -- ON LineItems.EmployeeId = Document.EmployeeId -- ON LineItems.CompanyId = Document.CompanyId ON LineItems.TotalAmount = Document.TotalAmount ORDER BY 1, 2, 3, 4, 5 */
#endregion
}}
The text was updated successfully, but these errors were encountered:
Description
There is an issue with mapping parent (Document) and child (LineItem) entities when using BulkSaveChanges. The error occurs only on SQL Server and not with in-memory SQLite or when using EF Core's native SaveChanges.
Problem
The issue is likely caused by the structure of the database schema, where tables that include EmployeeId also include CompanyId as a FK depite Employee itself has a FK on Company. This setup introduces mapping problems during the bulk save operation when BulkSaveChangesAsync is used.
The error occurs when Document entities with associated LineItem entities are saved. Both Document and LineItem reference CompanyId and EmployeeId fields. During bulk save, these relationships appear to be misaligned, resulting in incorrect mapping of child entities (LineItem) to their parent entity (Document).
Reproduction
The issue can be reproduced with the following setup:
The text was updated successfully, but these errors were encountered: