4

I've been searching for a solution to write a "generic" update method in EF Core which updates all changed properties of an entity including a related collection. The reason for this is that I store translations for the name of an entity in a different table. I found this solution which seemed to work just fine at the beginning but then I noticed that I get an error "Database operation expected to affect 1 row(s) but actually affected 0 row(s)" when the only thing I changed in the entity was adding a new name translation in the related table TblProjectTranslations. Here is my code:

public async Task UpdateProjectAsync(TblProject updatedEntity)
{
    TblProject dbEntity = Context.TblProjects
        .Include(dbEntity => dbEntity.TblProjectTranslations)
        .SingleOrDefault(dbEntity => dbEntity.ProjectId == updatedEntity.ProjectId);

    if (dbEntity != null)
    {
        Context.Entry(dbEntity).CurrentValues.SetValues(updatedEntity);

        foreach (TblProjectTranslation dbTranslation in dbEntity.TblProjectTranslations.ToList())
        {
            if (!updatedEntity.TblProjectTranslations
                .Any(translation => translation.ProjectId == dbTranslation.ProjectId && translation.Language == dbTranslation.Language))
            {
                Context.TblProjectTranslations.Remove(dbTranslation);
            }
        }                  

        foreach (TblProjectTranslation newTranslation in updatedEntity.TblProjectTranslations)
        {
            TblProjectTranslation dbTranslation = dbEntity.TblProjectTranslations
                .SingleOrDefault(dbTranslation => dbTranslation.ProjectId == newTranslation.ProjectId && dbTranslation.Language == newTranslation.Language);

            if (dbTranslation != null)
            {
                Context.Entry(dbTranslation).CurrentValues.SetValues(newTranslation);
            }
            else
            {
                dbEntity.TblProjectTranslations.Add(newTranslation);
            }
        }

        await Context.SaveChangesAsync();
    }
}

Here are the reverse engineered EF Core classes:

public partial class TblProject
{
    public TblProject()
    {
        TblProjectTranslations = new HashSet<TblProjectTranslation>();
    }

    public int ProjectId { get; set; }

    public virtual ICollection<TblProjectTranslation> TblProjectTranslations { get; set; }
}

public partial class TblProjectTranslation
{
    public int ProjectId { get; set; }
    public string Language { get; set; }
    public string ProjectName { get; set; }

    public virtual TblProject Project { get; set; }
}

Here is how they are defined in OnModelCreating:

modelBuilder.Entity<TblProject>(entity =>
{
    entity.HasKey(e => e.ProjectId);

    entity.ToTable("TBL_project");

    entity.Property(e => e.ProjectId).HasColumnName("project_ID");
});

modelBuilder.Entity<TblProjectTranslation>(entity =>
{
    entity.HasKey(e => new { e.ProjectId, e.Language });

    entity.ToTable("TBL_project_translation");

    entity.HasIndex(e => e.ProjectId, "IX_TBL_project_translation_project_ID");

    entity.Property(e => e.ProjectId).HasColumnName("project_ID");

    entity.Property(e => e.Language)
        .HasMaxLength(5)
        .HasColumnName("language")
        .HasDefaultValueSql("('-')");

    entity.Property(e => e.ProjectName)
        .IsRequired()
        .HasMaxLength(50)
        .HasColumnName("project_name")
        .HasDefaultValueSql("('-')");

    entity.HasOne(d => d.Project)
        .WithMany(p => p.TblProjectTranslations)
        .HasForeignKey(d => d.ProjectId)
        .OnDelete(DeleteBehavior.ClientSetNull)
        .HasConstraintName("TBL_project_translation_TBL_project");
});

What I do not understand is that dbEntity.TblProjectTranslations.Add(newTranslation) seems to be the problem. When I replace this line with await Context.TblProjectTranslations.AddAsync(newTranslation), the error "magically" disappears, but aren't both ways supposed to do basically the same thing? Here is an example for an updatedEntity and a dbEntity which I captured while debugging the function right before the problem occurred:

updatedEntity:
ProjectId: 41
TblProjectTranslations: 1 Entry with:
    Language: "en"
    Project: null
    ProjectId: 41
    ProjectName: "TestNameEN"

dbEntity:
ProjectId: 41
TblProjectTranslations: No entries

What is going on here? I do not even have any triggers in the database in both of those tables which seemed to be the cause of this error for some other people.

Chris
  • 1,417
  • 4
  • 21
  • 53
  • An async method you need to block until all data is received using an await. – jdweng Feb 04 '22 at 15:29
  • @jdweng I know about that but where in my code do you see me not using await where I should be using it? – Chris Feb 04 '22 at 15:30
  • You said that it worked when you replaced the line. So do what worked. There is no await in the posted code, only in your comments. – jdweng Feb 04 '22 at 15:39
  • @jdweng But the await is not the main difference. Those are two completely different function calls (`dbEntity.TblProjectTranslations.Add` and `Context.TblProjectTranslations.AddAsync`). One is async, one is not, so I only await one of them. My question is about why `dbEntity.TblProjectTranslations.Add` causes an error and `Context.TblProjectTranslations.AddAsync` does not while (afaik) they should do the same thing. – Chris Feb 04 '22 at 15:41
  • You are asking two questions 1) The exception due to missing await. 2) why zero rows affected. The await has nothing to do with the number of rows affected. The number of row affected is due to your conditional statement failing : dbTranslation.ProjectId == newTranslation.ProjectId && dbTranslation.Language == newTranslation.Language – jdweng Feb 04 '22 at 15:45
  • @jdweng Sorry but I think we are not talking about the same thing. The conditional statement has nothing to do with this error because it evaluates to exactly the same in both cases. I've been debugging this multiple times already and when I call `Context.TblProjectTranslations.AddAsync(newTranslation)` it works but when I replace that line with `dbEntity.TblProjectTranslations.Add(newTranslation)` it doesn't work. There is no "missing await" anywhere. – Chris Feb 04 '22 at 15:47
  • There is a difference between Context and dbEntity. Context is a static method while dbEntity is an instance of the class. Doesn't look like you are taking the instance and writing to the database. – jdweng Feb 04 '22 at 16:12
  • @jdweng Context is not a static method. It is simply the current `DbContext` instance which is injected via dependency injection to the class which implements the `UpdateProjectAsync` function. – Chris Feb 04 '22 at 18:17
  • DbContext is static in the sense it doesn't require an instance of the object. – jdweng Feb 04 '22 at 21:35
  • 1
    Use EF logging to see the generated queries. – David Browne - Microsoft Feb 04 '22 at 22:19
  • @DavidBrowne-Microsoft Thanks for the suggestion! It seems like although I am calling `dbEntity.TblProjectTranslations.Add(newTranslation)`, EF Core generates an UPDATE statement which obviously fails because there is nothing to update yet in the database. Do you have any idea why it does this? – Chris Feb 07 '22 at 08:45
  • @Chris What is the PK of `TblProjectTranslation`? Composite by `(ProjectId, Language)`? – Ivan Stoev Feb 07 '22 at 09:42
  • 1
    @IvanStoev Yes, exactly. I also looked into the change tracker already and found that after executing `dbEntity.TblProjectTranslations.Add(newTranslation)`, the following change was added: `{TblProjectTranslation {ProjectId: 41, Language: de} Modified FK {ProjectId: 41}}` – Chris Feb 07 '22 at 09:45
  • 1
    @Chris And the state is `Added`? Or `Modified` at that point? Hmm, looks like `Modified` from what you just wrote. I have to try to reproduce to see if the composite PK might be triggering some EFC bug, because the code looks correct and in general should work. Calling `await AddAsync` or `Add` shouldn't matter. – Ivan Stoev Feb 07 '22 at 09:58
  • @IvanStoev I am not 100% sure as I don't know where to check the state, but when I expand the entry in the VS watch window and scroll down, the `State` property has `Modified` as value. – Chris Feb 07 '22 at 10:02
  • 1
    The only logical reason I see for such behavior would be if an existing entity has been removed first by `Context.TblProjectTranslations.Remove(dbTranslation)` code and then added with the same key (the case of `Language` might matter due to different C# and db string comparison rules). Ok, what would be nice if you provide some sample data reproducing the issue. i.e. `updatedEntity` and `dbEntity` with their Ids and the content of their collections (with `ProjectId` and `Language` properties). – Ivan Stoev Feb 07 '22 at 10:08
  • 1
    @IvanStoev After debugging this many times I am 100% sure that I do not remove anything before adding a new translation. To reproduce this, I added an entity which does not yet have any translations. The problem occurs right when I add the first translation for the entity name via the frontend. I added some sample data to my question. The `updatedEntity` is created/mapped via AutoMapper from the DTO which came from the frontend. – Chris Feb 07 '22 at 10:31
  • Sorry, cannot reproduce. Doing everything as you said, then using the update code exactly as is in the question, and getting the same entry, but with state `Added`. Then `SaveChanges` does insert w/o errors as expected. The model classes are exactly the same as shown , and just added `modelBuilder.Entity().HasKey(e => e.ProjectId);` and `modelBuilder.Entity().HasKey(e => new { e.ProjectId, e.Language });` to `OnModelCreating`. – Ivan Stoev Feb 07 '22 at 12:09
  • 1
    @IvanStoev Could other properties possibly cause this? I left out other properties of the entity for the sake of simplicity but those are really just integers, strings and two nullable booleans. None of them changed in the update when the problem occurred. – Chris Feb 07 '22 at 12:27
  • 1
    @IvanStoev I added the corresponding code from `OnModelCreating` to my question. – Chris Feb 07 '22 at 12:34
  • @Chris Thanks, that helped to finally reproduce it. See the answer below. – Ivan Stoev Feb 07 '22 at 18:32

1 Answers1

7

After playing a bit with the sample model, code and explanations, I finally was able to reproduce it. The culprit seems to be the composite key and the default value for the string part of it:

entity.HasKey(e => new { e.ProjectId, e.Language }); // (1)

entity.Property(e => e.Language)
    .HasMaxLength(5)
    .HasColumnName("language")
    .HasDefaultValueSql("('-')"); // (2)

Same happens if you use

.HasDefaultValue("-")

This combination somehow is causing the EF to consider the item added to the parent collection navigation property

dbEntity.TblProjectTranslations.Add(newTranslation);

as Modified instead of Added, which later leads to the error in questtion.

Since I can't find such behavior explanation in the EF Core documentation, and in general it looks wrong, I would suggest to go and report it to the EF Core GitHub issue tracker.

Meanwhile, the possible workarounds I see are

  • Remove .HasDefaultValueSql / .HasDefaultValue for the key column

  • Instead of adding the child to the parent collection, add it directly to the context or the corresponding DbSet (optionally set the reference navigation property in advance "just in case"):

newTranslation.Project = dbEntity;
Context.Add(newTranslation);
Ivan Stoev
  • 195,425
  • 15
  • 312
  • 343
  • this was the key to me figuring out my problem so i appreciate your answer. only thing i'd add is that per my research efcore now (or always?) assumes a value is generated so the last bit to my problem was actually specifying `ValueGeneratedNever` which now yields an insertion instead of an update – MrTristan Jun 17 '22 at 23:48