Entity Framework Core adding new Entity with required navigation property in one-to-many relationship?

Viewed 1799

Context

I'm working on a project that has Brands and OemModels. There is a one-to-many relationship between them. A Brand can have any number of OemModels, but an OemModel can only ever have 1 brand. For business reasons, OemModels need to be aware of what Brand they belong to.

The project is built using Entity Framework Core 5.0, and the Npgsql 5.0.1 package to connect to a PostgreSQL database. I'm using a code-first approach to generate the database.

This project is also using the optional Nullable Reference Types feature. This means that any navigation property that is pointing to a reference type is essentially [Required], as per the MSDN documentation:

If nullable reference types are enabled, properties will be configured based on the C# nullability of their .NET type: string? will be configured as optional, but string will be configured as required.

Here is my database context class:

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options) 
    {
    }

    public DbSet<Brand> Brands => Set<Brand>();
    public DbSet<OemModel> OemModels => Set<OemModel>();
}

Here are my Model classes:

public class Brand
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public List<OemModel> OemModels { get; set; } = new();
}
public class OemModel
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string OemNumber { get; set; } = string.Empty;

    public int BrandId { get; set; }
    public Brand Brand { get; set; } = null!;
}

I am using the "null!" null forgiving operator as per the MSDN documentation:

As a terser alternative, it is possible to simply initialize the property to null with the help of the null-forgiving operator (!):

public Product Product { get; set; } = null!;

An actual null value will never be observed except as a result of a programming bug, e.g. accessing the navigation property without properly loading the related entity beforehand.

My problem

I am currently having issues when adding models to existing brands in my integration tests.

Assuming the following code already ran successfully:

var firstBrand = new Brand{ Name = "FirstBrand" };
var secondBrand = new Brand{ Name = "SecondBrand" };

_context.Brands.Add(firstBrand);
_context.Brands.Add(secondBrand);

await _context.SaveChangesAsync();

I am unable to add models to these newly created brands:

var firstModel = new Model
{
    Name = "FirstModel",
    OemNumber = "A-1"
};
var secondModel = new Model
{
    Name = "SecondModel",
    OemNumber = "A-2"
};
var thirdModel = new Model
{
    Name = "ThirdModel",
    OemNumber = "B-1"
};

firstBrand.OemModels.Add(firstModel);
firstBrand.OemModels.Add(secondModel);
secondBrand.OemModels.Add(thirdModel);

await _context.SaveChangesAsync();

Note: Both code blocks above are in the same method, without any code between them.

The block of code above fails with the following error message:

Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while updating the entries. See the inner exception for details.

Inner exception:

Npgsql.PostgresException
23505: duplicate key value violates unique constraint "PK_Brands"
   at Npgsql.NpgsqlConnector.<ReadMessage>g__ReadMessageLong|194_0(NpgsqlConnector connector, Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)

I have also tried to explicitly define the Brand and BrandId properties in the models (which I thought made sense, since they are essentially [Required] before of the NRT setting). For example:

var firstModel = new OemModel
{
    Name = "FirstModel",
    OemNumber = "A-1",
    Brand = firstBrand,
    BrandId = firstBrand.Id
};

But it still fails with the same error.

I find this confusing, since my database migration generates the following for my model tables:

migrationBuilder.CreateTable(
    name: "OemModels",
    columns: table => new
    {
        Id = table.Column<int>(type: "integer", nullable: false)
          .Annotation("Npgsql:ValueGenerationStrategy",NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
        Name = table.Column<string>(type: "text", nullable: false),
        OemNumber = table.Column<string>(type: "text", nullable: false),
        BrandId = table.Column<int>(type: "integer", nullable: false)
    },
    constraints: table =>
    {
        table.PrimaryKey("PK_OemModels", x => x.Id);
        table.ForeignKey(
            name: "FK_OemModels_Brands_BrandId",
            column: x => x.BrandId,
            principalTable: "Brands",
            principalColumn: "Id",
            onDelete: ReferentialAction.Cascade);
     });

And:

migrationBuilder.CreateTable(
    name: "Brands",
    columns: table => new
    {
         Id = table.Column<int>(type: "integer", nullable: false)
                .Annotation("Npgsql:ValueGenerationStrategy",NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
         Name = table.Column<string>(type: "text", nullable: false)
     },
     constraints: table =>
     {
         table.PrimaryKey("PK_Brands", x => x.Id);
     });

The OemModel table doesn't have a "PK_Brands", but the exception is raised when I try to save the changes made when adding OemModels to existing brands.

In the debugger, both of my Brands have unique Ids after they're added to the context (as I'd expect), so they're both unique at the time of adding the OemModels to their internal lists. And my understanding is that the models should have their Ids generated automatically if I add them to existing brands and save changes, no?

I'm kind of confused on what the right way of adding OemModels to existing Brands is if the principal Brand is a [Required] property. The way I understand it, the null!; assignment should allow me to create OemModels without specifying a navigation property for its parent Brand when adding it directly to the Brand's list of OemModels, but I get the "PK_Brand" error regardless of if they're defined or not.

1 Answers

Edit 2

Guess you could say I had a "brain fart". It's been a while since I worked with ASP .NET Core and EF Core and totally forgot that it doesn't track assignment-based changes automatically. You have to explicitly call context.[EntityTypeHere].Update(EntityInstance) to mark it as tracked (so that the next call to SaveChanges/async() works.

And, in hindsind "duh, of course Edit 1 was going to work". It's creating the models at the same time as the brands. It's not updating an existing Brand entity!

The simple fix was:


firstBrand.OemModels.Add(firstModel);
firstBrand.OemModels.Add(secondModel);
secondBrand.OemModels.Add(thirdModel);
                
_context.Brands.UpdateRange(firstBrand, secondBrand); // <--- Track updates here.

await _context.SaveChangesAsync();

I used UpdateRange() instead of Update() since I had 2 entities to update.

So, why does this happen? Because ASP .NET uses "disconnected entities".

MSDN covers "Disconnected Entities". Specifically:

However, sometimes entities are queried using one context instance and then saved using a different instance. This often happens in "disconnected" scenarios such as a web application where the entities are queried, sent to the client, modified, sent back to the server in a request, and then saved. In this case, the second context instance needs to know whether the entities are new (should be inserted) or existing (should be updated).

[-Emphasis mine]

Edit 1

This also works, though I'd kind of expect it to since the original answer's code block worked fine:

var firstModel = new OemModel {Name = "FirstModel", OemNumber = "1"};
var secondModel = new OemModel {Name = "SecondModel", OemNumber = "2"};
var thirdModel = new OemModel {Name = "ThirdModel", OemNumber = "3"};

var firstBrand = new Brand {
    Name = "FirstBrand",
    OemModels = new List<OemModel>{firstModel, secondModel}
};
var secondBrand = new Brand {
    Name = "SecondBrand",
    OemModels = new List<OemModel>{thirdModel}
};

_context.Brands.Add(firstBrand);
_context.Brands.Add(secondBrand);

await _context.SaveChangesAsync();

It's a bit simpler than the first one (especially if I add more models for testing purposes).

Although, now I'm even more confused about what I was doing wrong originally...

Original answer

For some reason, this seems to work perfectly:

var firstModel = new OemModel {Name = "FirstModel", OemNumber = "1"};
var secondModel = new OemModel {Name = "SecondModel", OemNumber = "2"};
var thirdModel = new OemModel {Name = "ThirdModel", OemNumber = "3"};

var firstBrand = new Brand {Name = "FirstBrand"};
var secondBrand = new Brand {Name = "SecondBrand"};

_context.Brands.Add(firstBrand);
_context.Brands.Add(secondBrand);

firstBrand.OemModels.Add(firstModel);
firstBrand.OemModels.Add(secondModel);
secondBrand.OemModels.Add(thirdModel);

await _context.SaveChangesAsync();

I'd rather not have this be the accepted answer, however, since I'm not really sure why it works compared to the code in the question, and it doesn't really answer what the proper way of adding Entities to a one-to-many relationship that have [Required] navigation properties is (or why it would be this way).

But I'll leave it as an answer in case anyone runs into the same issue.

Related