Why can't I use the ForeignKeyAttribute to achieve the same as HasForeignKey() in EF Core 7 code-first?

69 views Asked by At

Apologies for the long question. Skip to the code and beyond if you don't want relevant background.

I'm doing my first attempt at EF Core code-first. I'm taking a pseudo domain-driven design approach, and focusing on the models and how I will use them first and foremost, with the database mapping as the secondary concern. My preference is to use annotations as much as possible, and minimize use of the Fluent API. I am aware of the pros and cons of each, including some of the differences in capabilities. What I'm struggling to understand is how my scenario cannot seem to be handled with annotations.

I have an example DbContext from an online course that covers the use of base classes for products in an order system similar to what I'm doing with my Product and Service classes. That part works just fine for me. I think there are two main issues with my approach that seem to have forced me into using the fluent API when I don't think it should be necessary.

  1. The example I have uses just one IEnumerable<LineItem> navigation property on their Order class, and I want two different types of LineItem.
  2. I want both a navigation property to Order from my LineItem inheritors, and to use the Order.Id value as part of a composite primary key.

Here are the most pertinent classes:

public class Order
{
    public int Id { get; set; }
    public IEnumerable<ProductLineItem> Products { get; set; }
    public IEnumerable<ServiceLineItem> Services { get; set; }
}

public class ProductLineItem : LineItem
{
    public Product Product { get; set; }
    public int Quantity { get; set; }
}

public class ServiceLineItem : LineItem
{
    public Service Service { get; set; }
}

[PrimaryKey(nameof(OrderId), nameof(LineNumber))]
public abstract class LineItem
{
    public int OrderId { get; set; } // Needed for composite PK.
    public int LineNumber { get; set; }

    public Order Order { get; set; }
    
    public decimal? Price { get; set; }
    public bool NoCharge { get; set; }
}

For completeness, here are the Product/Service classes:

public class Product : Offering
{
    public decimal Weight { get; set; }
}

public class Service : Offering
{
    public int Duration { get; set; }
}

[PrimaryKey(nameof(Code))]
public abstract class Offering
{
    public string Code { get; set; }
    public string Name { get; set; }
    public decimal? Price { get; set; }
}

After a lot of messing around I have come to the conclusion that either of these approaches with the ModelBuilder work exactly the same with my model, and there is no need for annotations on the classes for it to work:

modelBuilder.Entity<Order>(x =>
{
    x.HasMany(e => e.Products).WithOne(e => e.Order).HasForeignKey(e => e.OrderId);
    x.HasMany(e => e.Services).WithOne(e => e.Order).HasForeignKey(e => e.OrderId);
});

// OR

modelBuilder.Entity<ProductLineItem>().HasOne(e => e.Order).WithMany(e => e.Products).HasForeignKey(e => e.OrderId);
modelBuilder.Entity<ServiceLineItem>().HasOne(e => e.Order).WithMany(e => e.Services).HasForeignKey(e => e.OrderId);

This results in the following initial migration (disregard the Offering/Product/ServiceCode columns. Those are next on my list to fix up):

migrationBuilder.CreateTable(
    name: "LineItems",
    columns: table => new
    {
        OrderId = table.Column<int>(type: "int", nullable: false),
        LineNumber = table.Column<int>(type: "int", nullable: false),
        OfferingCode = table.Column<string>(type: "nvarchar(max)", nullable: false),
        Price = table.Column<decimal>(type: "decimal(7,2)", nullable: true),
        NoCharge = table.Column<bool>(type: "bit", nullable: false),
        Discriminator = table.Column<string>(type: "nvarchar(max)", nullable: false),
        ProductCode = table.Column<string>(type: "nvarchar(450)", nullable: true),
        Quantity = table.Column<int>(type: "int", nullable: true),
        ServiceCode = table.Column<string>(type: "nvarchar(450)", nullable: true)
    },
    constraints: table =>
    {
        table.PrimaryKey("PK_LineItems", x => new { x.OrderId, x.LineNumber });
        table.ForeignKey(
            name: "FK_LineItems_Orders_OrderId",
            column: x => x.OrderId,
            principalTable: "Orders",
            principalColumn: "Id",
            onDelete: ReferentialAction.Cascade);
        table.ForeignKey(
            name: "FK_LineItems_Offerings_ProductCode",
            column: x => x.ProductCode,
            principalTable: "Offerings",
            principalColumn: "Code");
        table.ForeignKey(
            name: "FK_LineItems_Offerings_ServiceCode",
            column: x => x.ServiceCode,
            principalTable: "Offerings",
            principalColumn: "Code");
    });

If I try it any other way I get shadow properties of ServiceLineItem_OrderId1, ProductLineItem_OrderId1, and OrderId1 on the LineItems table.

From all this I consider the following to be true:

  • If Order has just one collection navigation property to LineItem, regardless of inheritance, EF detects the one-to-many relationship and maps it appropriately just based on conventions. LineItem.OrderId is naturally mapped as the FK property for the relationship just from the name.
  • If I put two collection navigation properties to LineItem, the name of LineItem.OrderId is no longer sufficient. Even if I add annotations pointing those properties to it, shadow properties are created. (I don't see how having two one-to-many relationships should change the behavior, but that's not important right now)
  • The use of the fluent API, and specifically the inclusion of HasForeignKey(e => e.OrderId), fixes the issue.
  • The migration/mapping for a single collection navigation property that comes from the convention based one-to-many relationship, and the HasOne().WithMany() or WithMany().HasOne() fluent API are identical.
  • The HasForeignKey() method and ForeignKey attribute are the same thing.

The actual question

So why is it that the combination of property names that adhere to the conventions plus the ForeignKey attribute not enough to prevent EF Core from making shadow properties when explicitly declaring the exact same relationship with the fluent API does?

0

There are 0 answers