Reputation: 1337
I have an EFCore setup where in a class, I have a navigation property. As far as I can tell I followed the examples from microsoft to the dot, yet my added entity is not persisted. The setup is as follows. The summary is: I set up everything as the documentation suggests, yet when I try to save the Aggregate root, I get an error saying "expected 1 row to be affected but 0 were affected". After a lot of digging through the past few days, I found that while the dbContext identifies the navigation property field as being modified, it does not identify the Aggregate root as identified. My suspicion is that due to this, the aggregate root is not updated, and my SaveChagesAsync fails silently. I am completelly stuck as to what I am doing wrong at this point. I am using .NET 8.0.4 right now, as far as I'm aware it could also be a bug in the latest versions since I did have a similar setup a couple of weeks back work perfectly fine.
I have a DataSheet class, which is an Aggregate root entity. In this class, I have a list of categories, which is set up with a private backing field and a public IReadOnlyCollection, going by the examples from Ardalis's repositories.
public class DataSheet: Aggregate<Guid>
{
private readonly List<Category> _categories = new();
public IReadOnlyCollection<Category> Categories => _categories.AsReadOnly();
.... more code here
public void CreateCategory(string name)
{
var orderIndex = (uint)_categories.Count;
var category = new Category(name);
_categories.Add(category);
}
}
The Category class, is an entity which belongs to the DataSheet aggregate, and has a simple structure.
public class Category: Entity<Guid>
{
public string Name { get; init; }
private Category() { }
public Category(string name): base(Guid.NewGuid())
{
Name = name;
}
}
I have set the navigation property access mode to be a field, just like in the microsoft documentation:
public class DataSheetConfiguration: IEntityTypeConfiguration<DataSheet>
{
public void Configure(EntityTypeBuilder<DataSheet> builder)
{
builder.HasKey(b => b.Id);
var categoryNavigation = builder.Metadata.FindNavigation(nameof(DataSheet.Categories));
categoryNavigation.SetPropertyAccessMode(PropertyAccessMode.Field);
}
}
And the CategoryConfigration:
public class CategoryConfiguration: IEntityTypeConfiguration<Category>
{
public void Configure(EntityTypeBuilder<Category> builder)
{
builder.HasKey(c => c.Id);
builder.Property(c => c.Name);
}
}
Then in my DataContext I add these configurations:
public class DataContext : IdentityDbContext<ApplicationUser>
{
... other DbSets
public DbSet<DataSheet> DataSheets { get; set; } = null!;
public DbSet<Category> Categories { get; set; } = null!;
public DataContext() { }
public DataContext(DbContextOptions<DataContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfiguration(new DataSheetConfiguration());
modelBuilder.ApplyConfiguration(new CategoryConfiguration());
}
}
Now, in my Appplication layer, when I try to add a new category using the repository SaveChangesAsync function, I get an error saying that it was expected that 1 row would be affected, but that in reality 0 rows were affected.
var spec = new CompleteDataSheet(request.DataSheetId);
var sheet = await _dataSheetService.GetAsync(spec, cancellationToken);
sheet.CreateCategory(request.Name);
await _dataSheetService.UpdateAsync(sheet, cancellationToken);
public sealed class CompleteDataSheet: Specification<DataSheet>
{
public CompleteDataSheet(Guid id)
{
Query
.Where(b => b.Id == id)
.Include(b => b.Categories);
}
}
Here, the spec is defined to get the DataSheet by it's id and include the Categories items. The GetAsync succeeds in getting the sheet, and the Categories list is empty. After calling the CreateCategory, a new item shows in the Categories list. To persist my changes I then call the UpdateAsync function, which uses the repository to persist the changes.
When I went through the code from Ardalis.Specification package and the RepositoryBaseOfT class in the EntityFrameworkCore library, I see the error, however it otherwise fails silently.
After a lot of digging, I found that while the Categories field in the dbContext has a state of "Modified", the DataSheet entity has a state of Unchanged. When I forcefully set all the items in the Categories to a state of "Added" by itterating through it and programatically changing the state, then the new items get persisted. This however is not an solution from a design perspective.
Previously I had a navigation property work fine, however in that use case, all the items in the list were being initialized on the entity initialization, and they only got updated, not added new instances.
What is wrong in my setup that does not allow the DataSheet aggregate to detect the items in it's collection as changed or added and persist them?
Upvotes: 1
Views: 142
Reputation: 1337
I have found the reason for this bug and it is caused by the way the Id of the Category is created. In my Category class posted in the question, I show that it inherits from a base Entity class, and on construction, it generates it's own Guid value. This however was not reflected in the CategoryConfiguration, where the Id was specified as key, but it was not set to be .ValueGeneratedNever().
Because this was lacking, EFCore expects then that any new item should have an id of default value and then it would add it. Given that my entity was coming with an already existing Id, efcore then interprets this as being an update operation, however, since the object is not yet present in the database, then it will fail silently but without giving an error saying that it could not find any object with that Id.
Changing the CategoryConfiguration to say that the Id property is .ValueGeneratedNever has fixed everything. Including the navigation property. Unfortunatelly if there would be a more informative error message this would have been solved sooner. Luckly I remembered that I had to do this to the other relationship which I kept referencing and gave it a try.
Upvotes: 0
Reputation: 1445
First of all, just use a property without backing fields and without an expression body.
public class DataSheet: Aggregate<Guid>
{
public ICollection<Category> Categories { get; init; }
}
And your Category Entity like this:
public class Category: Entity<Guid>
{
public string Name { get; init; }
public DataSheet DataSheet { get; init; }
public Guid DataSheetId { get; init; }
}
Then you don´t need these lines at all, because EF will automatically detect the Navigation Properties:
var categoryNavigation = builder
.Metadata
.FindNavigation(nameof(DataSheet.Categories));
categoryNavigation.SetPropertyAccessMode(PropertyAccessMode.Field);
Upvotes: 0