dotnet / efcore

EF Core is a modern object-database mapper for .NET. It supports LINQ queries, change tracking, updates, and schema migrations.
https://docs.microsoft.com/ef/
MIT License
13.79k stars 3.19k forks source link

Update on foreign key relation on a read only scenario #32402

Closed vegov closed 11 months ago

vegov commented 11 months ago

File a bug

The bug is that I have two entities. They do not have a FK. However, I use includes in the select query and I use HasForeignKey as shown in the db context. The only thing I do is I do the select, do a save changes, as other tables different than DamagesPayment and DamagesPaymentFile need the save. On the save the EF Core does an update on the IncludeInPaymentFile which is specified as a foreign key. That is not expected. That column is never updated.

The issue is resolved if I mark the entities as detached if I use AsNoTracking in the query.

Include your code

modelBuilder.Entity<DamagesPayment>(entity =>
{
    entity.ToTable("DamagesPayments", "dbo");

    entity.HasOne(x => x.PaymentFile)
        .WithOne()
        .HasForeignKey<DamagesPayment>(x => x.IncludeInPaymentFile)
        .HasPrincipalKey<DamagesPaymentFile>(x => x.FileName)
        .IsRequired(false)
        .OnDelete(DeleteBehavior.Restrict);
});

modelBuilder.Entity<DamagesPaymentFile>(entity =>
{
    entity.ToTable("DamagesPaymentFiles", "dbo");
});

public class DamagesPayment
{
    public Guid Id { get; set; }
    public string DamageNo { get; set; }
    public byte DamageReportNo { get; set; }
    public string? IncludeInPaymentFile { get; set; }
    public DamagesPaymentFile? PaymentFile { get; set; }
}

public class DamagesPaymentFile
{
    public Guid Id { get; set; }
    public DateTime? AccountingDate { get; set; }
    public string FileName { get; set; }
}

public async Task<IReadOnlyList<DamagesPayment>> GetOrderedClaimsInRangeAsync(IReadOnlyList<string> damageNumbers)
{
    var demo = await _context.DamagesPayments
        .Include(x => x.PaymentFile)
        .Where(x => damageNumbers.Contains(x.DamageNo))
        .ToListAsync();

    _context.SaveChanges()

    return demo;
}

Include stack traces

info: 24-Nov-2023 09:11:44.518 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure) 
      Entity Framework Core 6.0.6 initialized 'HBOContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:6.0.4' with options: None
info: 24-Nov-2023 09:11:46.499 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) 
      Executed DbCommand (538ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [d].[Id], [d].[DamageNo], [d].[DamageReportNo], [d].[IncludeInPaymentFile], [d0].[Id], [d0].[AccountingDate], [d0].[FileName]
      FROM [dbo].[DamagesPayments] AS [d]
      LEFT JOIN [dbo].[DamagesPaymentFiles] AS [d0] ON [d].[IncludeInPaymentFile] = [d0].[FileName]
      WHERE [d].[DamageNo] IN (...)
info: 24-Nov-2023 09:11:47.545 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) 
      Executed DbCommand (880ms) [Parameters=[@p1='?' (DbType = Guid), @p0='?' (Size = 450), @p3='?' (DbType = Guid), @p2='?' (Size = 450), @p5='?' (DbType = Guid), @p4='?' (Size = 450), @p7='?' (DbType = Guid), @p6='?' (Size = 450), @p9='?' (DbType = Guid), @p8='?' (Size = 450), @p11='?' (DbType = Guid), @p10='?' (Size = 450), @p13='?' (DbType = Guid), @p12='?' (Size = 450), @p15='?' (DbType = Guid), @p14='?' (Size = 450), @p17='?' (DbType = Guid), @p16='?' (Size = 450), @p19='?' (DbType = Guid), @p18='?' (Size = 450), @p21='?' (DbType = Guid), @p20='?' (Size = 450), @p23='?' (DbType = Guid), @p22='?' (Size = 450), @p25='?' (DbType = Guid), @p24='?' (Size = 450), @p27='?' (DbType = Guid), @p26='?' (Size = 450), @p29='?' (DbType = Guid), @p28='?' (Size = 450), @p31='?' (DbType = Guid), @p30='?' (Size = 450), @p33='?' (DbType = Guid), @p32='?' (Size = 450), @p35='?' (DbType = Guid), @p34='?' (Size = 450), @p37='?' (DbType = Guid), @p36='?' (Size = 450), @p39='?' (DbType = Guid), @p38='?' (Size = 450), @p41='?' (DbType = Guid), @p40='?' (Size = 450), @p43='?' (DbType = Guid), @p42='?' (Size = 450), @p45='?' (DbType = Guid), @p44='?' (Size = 450), @p47='?' (DbType = Guid), @p46='?' (Size = 450), @p49='?' (DbType = Guid), @p48='?' (Size = 450), @p51='?' (DbType = Guid), @p50='?' (Size = 450), @p53='?' (DbType = Guid), @p52='?' (Size = 450), @p55='?' (DbType = Guid), @p54='?' (Size = 450), @p57='?' (DbType = Guid), @p56='?' (Size = 450), @p59='?' (DbType = Guid), @p58='?' (Size = 450), @p61='?' (DbType = Guid), @p60='?' (Size = 450), @p63='?' (DbType = Guid), @p62='?' (Size = 450), @p65='?' (DbType = Guid), @p64='?' (Size = 450), @p67='?' (DbType = Guid), @p66='?' (Size = 450), @p69='?' (DbType = Guid), @p68='?' (Size = 450), @p71='?' (DbType = Guid), @p70='?' (Size = 450)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p0
      WHERE [Id] = @p1;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p2
      WHERE [Id] = @p3;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p4
      WHERE [Id] = @p5;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p6
      WHERE [Id] = @p7;
      SELECT @@ROWCOUNT;
       UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p8
      WHERE [Id] = @p9;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p10
      WHERE [Id] = @p11;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p12
      WHERE [Id] = @p13;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p14
      WHERE [Id] = @p15;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p16
      WHERE [Id] = @p17;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p18
      WHERE [Id] = @p19;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p20
      WHERE [Id] = @p21;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p22
      WHERE [Id] = @p23;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p24
      WHERE [Id] = @p25;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p26
      WHERE [Id] = @p27;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p28
      WHERE [Id] = @p29;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p30
      WHERE [Id] = @p31;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p32
      WHERE [Id] = @p33;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p34
      WHERE [Id] = @p35;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p36
      WHERE [Id] = @p37;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p38
      WHERE [Id] = @p39;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p40
      WHERE [Id] = @p41;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p42
      WHERE [Id] = @p43;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p44
      WHERE [Id] = @p45;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p46
      WHERE [Id] = @p47;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p48
      WHERE [Id] = @p49;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p50
      WHERE [Id] = @p51;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p52
      WHERE [Id] = @p53;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p54
      WHERE [Id] = @p55;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p56
      WHERE [Id] = @p57;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p58
      WHERE [Id] = @p59;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p60
      WHERE [Id] = @p61;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p62
      WHERE [Id] = @p63;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p64
      WHERE [Id] = @p65;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p66
      WHERE [Id] = @p67;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p68
      WHERE [Id] = @p69;
      SELECT @@ROWCOUNT;

      UPDATE [dbo].[DamagesPayments] SET [IncludeInPaymentFile] = @p70
      WHERE [Id] = @p71;
      SELECT @@ROWCOUNT;

Include provider and version information

EF Core version: 6.0.4 Database provider: e.g. Microsoft.EntityFrameworkCore.SqlServer Target framework: e.g. .NET 6.0 Operating system: Windows 10 locally, Linux in prod

ajcvickers commented 11 months ago

@vegov I am not able to reproduce this--see my code below. Please attach a small, runnable project or post a small, runnable code listing that reproduces what you are seeing so that we can investigate.

using (var context = new SomeDbContext())
{
    await context.Database.EnsureDeletedAsync();
    await context.Database.EnsureCreatedAsync();

    context.Add(new DamagesPayment { DamageNo = "X", PaymentFile = new() { FileName = "X" } });

    await context.SaveChangesAsync();
}

using (var context = new SomeDbContext())
{
    var damageNumbers = new List<string> { "X", "Y" };
    var demo = await context.DamagesPayments
        .Include(x => x.PaymentFile)
        .Where(x => damageNumbers.Contains(x.DamageNo))
        .ToListAsync();

    context.SaveChanges();
}

public class SomeDbContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseSqlServer(@"Data Source=(LocalDb)\MSSQLLocalDB;Database=AllTogetherNow")
            .LogTo(Console.WriteLine, LogLevel.Information)
            .EnableSensitiveDataLogging();

    public DbSet<DamagesPayment> DamagesPayments => Set<DamagesPayment>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<DamagesPayment>(entity =>
        {
            entity.ToTable("DamagesPayments", "dbo");

            entity.HasOne(x => x.PaymentFile)
                .WithOne()
                .HasForeignKey<DamagesPayment>(x => x.IncludeInPaymentFile)
                .HasPrincipalKey<DamagesPaymentFile>(x => x.FileName)
                .IsRequired(false)
                .OnDelete(DeleteBehavior.Restrict);
        });

        modelBuilder.Entity<DamagesPaymentFile>(entity => { entity.ToTable("DamagesPaymentFiles", "dbo"); });
    }
}

    public class DamagesPayment
    {
        public Guid Id { get; set; }
        public string DamageNo { get; set; }
        public byte DamageReportNo { get; set; }
        public string? IncludeInPaymentFile { get; set; }
        public DamagesPaymentFile? PaymentFile { get; set; }
    }

    public class DamagesPaymentFile
    {
        public Guid Id { get; set; }
        public DateTime? AccountingDate { get; set; }
        public string FileName { get; set; }
    }
vegov commented 11 months ago

The sad part is that when I take a bare bones example and connect it to our database, I can reproduce it. When I create a local database like you did, the error does not occur. So you can not reproduce it. Do you have any ideas why this might have happened? I scaffolded the two tables from our database using the dotnet ef scaffold command?

In short, the only difference in my code right now is the Connection string. Any ideas what I might be missing, so that I can reproduce it and give it back?

vegov commented 11 months ago

Soo I am happy to say that I wasted half a day to find was was wrong. The relation is configured as a 1:1. However, the actual relation in the database is 1:N. So when there is a second record containing the FK, EF Core decides to update and delete the foreign keys.

I was able to reproduce it. I have submitted a script for a demo database, and a script that should demonstrate the bug.

If you consider this not a bug, maybe add a warning when this happens or my humble opinion should be an exception. Almost lost half the prod database from this little bugger.

BugReport.zip

ajcvickers commented 11 months ago

@vegov A fundamental tenet of EF Core is that it uses the model to work efficiently with the database. If we added checks where the data coming back doesn't match the model that would slow down data access for all the cases where the model is accurate. So we don't do this, but instead require that applications accurately map the model to the database.

vegov commented 11 months ago

Nice, your tenet is - "Screw your prod database, fast". Okay, acceptable.