I'm currently learning EF Core and I came across some strange behaviour which I don't understand.
I have a Blazor component which inherits from a base class EditPageBase
:
public abstract class EditPageBase<TModel, TIdentifier> : ActivePageBase
{
protected EditForm? _form;
protected bool _showOptionsMenu;
[Parameter, SupplyParameterFromQuery(Name = "Id")]
public TIdentifier? Identifier { get; set; }
public TModel? Input { get; set; }
protected HSTrendDbContext DbContext { get; set; } = default!;
protected SemaphoreSlim DbLock { get; } = new(1, 1);
protected override async Task OnInitializedAsync()
{
DbContext = await DbFactory.CreateDbContextAsync();
}
protected override async Task OnParametersSetAsync()
{
await DbLock.WaitAsync();
Input = await LoadEditModeAsync();
DbLock.Release();
if (Input is not null)
{
await CheckActivePageAsync();
}
}
protected async Task<bool> SaveAsync()
{
if (_form is null || _form.EditContext is null || Input is null)
{
return false;
}
if (!_form.EditContext.Validate())
{
return false;
}
try
{
await DbContext.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
throw;
}
Log.Information("Saved: {url}; Identifier: {identifier}; User: {user}", NavigationManager.Uri, Identifier?.ToString() ?? "<NEU>", CurrentUser?.DisplayName ?? "<UNBEKANNT>");
ToastService.ShowSuccess("Datensatz wurde erfolgreich gespeichert");
await OnParametersSetAsync();
return true;
}
protected abstract Task<TModel?> LoadEditModeAsync();
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
DbLock.Dispose();
DbContext.Dispose();
}
}
Now I have a class Recipient
which looks like this:
public class Recipient
{
public int RecipientId { get; set; }
public string Name { get; set; } = string.Empty;
public RecipientCategory Category { get; set; }
public bool SendEmail { get; set; }
public bool UseEmail { get; set; }
public string Email { get; set; } = string.Empty;
public bool IsActive { get; set; }
public IEnumerable<User> Users { get; set; } = new List<User>(); // DO NOT REFACTOR
}
The class User
looks like this:
public class User
{
public int UserId { get; set; }
public string Username { get; set; } = string.Empty;
public Guid? ActiveDirectoryGuid { get; set; }
public string DisplayName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string Salt { get; set; } = string.Empty;
public string Origin { get; set; } = string.Empty;
public string DmsName { get; set; } = string.Empty;
public bool IsFieldSales { get; set; }
public bool IsActive { get; set; }
public bool IsActiveDirectoryAccount => Origin == "ad";
public string? AgentNumber { get; set; }
public decimal ApprovalLimit { get; set; }
public User? Supervisor { get; set; }
public List<UserRole> Roles { get; set; } = [];
public IEnumerable<Recipient> Recipients { get; set; } = new List<Recipient>(); // DO NOT REFACTOR
public List<ResponsibleEntity> ResponsibleEntities { get; set; } = [];
}
The EditPageBase
inherits from another base class which provides the following two methods:
protected virtual async Task OnSearchCostCentersAsync(OptionsSearchEventArgs<CostCenter> e)
{
try
{
var token = InitializeCancellationToken();
using var dbContext = await DbFactory.CreateDbContextAsync(token);
CostCenterFilter filter = new CostCenterFilter()
{
SearchPhrase = e.Text,
};
var results = await dbContext.GetCostCentersAsync(filter, token);
e.Items = results.Items;
}
catch (OperationCanceledException)
{
}
}
protected virtual async Task OnSearchUsersAsync(OptionsSearchEventArgs<User> e)
{
try
{
var token = InitializeCancellationToken();
using var dbContext = await DbFactory.CreateDbContextAsync(token);
UserFilter filter = new UserFilter
{
SearchPhrase = e.Text
};
var results = await dbContext.GetUsersAsync(filter, token);
e.Items = results.Items;
}
catch (OperationCanceledException)
{
}
}
Those are being used to search within the database for users and cost centers. When I use this methods within my Razor component then it tries to create a new row for every user which I assign to my Recipient
object. However it doesn't do the same thing for the cost centers which I use in another class.
public class MainArea
{
public int MainAreaId { get; set; }
public string Name { get; set; } = string.Empty;
public IEnumerable<CostCenter> CostCenters { get; set; } = new List<CostCenter>(); // DO NOT REFACTOR
}
I've created custom configurations for my classes which look like this:
public class MainAreaMapping : IEntityTypeConfiguration<MainArea>
{
public void Configure(EntityTypeBuilder<MainArea> builder)
{
builder.ToTable("InvestmentMainAreas");
builder.Property(x => x.Name)
.HasMaxLength(50);
builder.HasMany(x => x.CostCenters)
.WithMany(x => x.MainAreas)
.UsingEntity<MainAreaCostCenter>
(
j => j
.HasOne(d => d.CostCenter)
.WithMany()
.HasForeignKey(d => d.CostCenterId)
.HasConstraintName("FK_InvestmentMainAreaToCostCenter_CostCenterId"),
j => j
.HasOne(d => d.MainArea)
.WithMany()
.HasForeignKey(d => d.MainAreaId)
.HasConstraintName("FK_InvestmentMainAreaToCostCenter_MainAreaId"),
j =>
{
j.HasKey(t => new { t.MainAreaId, t.CostCenterId });
j.ToTable("InvestmentMainAreaToCostCenter");
}
);
}
}
public class RecipientMapping : IEntityTypeConfiguration<Recipient>
{
public void Configure(EntityTypeBuilder<Recipient> builder)
{
builder.ToTable("Recipients");
builder.Property(x => x.Name)
.HasMaxLength(50);
builder.Property(x => x.Email)
.HasMaxLength(255);
builder.HasMany(x => x.Users)
.WithMany(x => x.Recipients)
.UsingEntity<RecipientUser>
(
j => j
.HasOne(d => d.User)
.WithMany()
.HasForeignKey(d => d.UserId)
.HasConstraintName("FK_RecipientUsers_UserId"),
j => j
.HasOne(d => d.Recipient)
.WithMany()
.HasForeignKey(d => d.RecipientId)
.HasConstraintName("FK_RecipientUsers_RecipientId"),
j =>
{
j.HasKey(t => new { t.UserId, t.RecipientId });
j.ToTable("RecipientUsers");
}
);
}
}
My question now is: why does Entity Framework Core try to insert all users again into the database and why doesn't it try the same for the cost centers? I don't want to create the users twice. I just want to assign them just like I do with the cost centers.
I understand that the problem exists because I load the classes form different DbContext
instances but I don't get why the classes behave differently although the look identical to me and they both use the same base class.
EDIT: when I run this code
foreach (var user in recipient.Users)
{
DbContext.Entry(user).State = EntityState.Unchanged;
}
then the entries won't be created but the assignment will be properly made in the database. But why do I need to do this? And why do I not need to do this for my CostCenters
?
EDIT2: I've created a small sample repository which shows my issue.
I don't understand how I should "attach" the object to the saving DbContext
correctly. See test case UpdateExistingRole