How to explicitly add a foreign key to a property with a converter

Viewed 46

I'm using DDD patterns while using ef core and I've a problem to configure an entity to generate a foreign key in this particular scenario.

Suppose a class like this

public class GasStation : Entity, IAggregateRoot, IPlace
{
   public ImportFeed ImportFeed { get; private set; }
   ...

where ImportFeed is a special class similar to an Enum

public class ImportFeed : Enumeration
{
   public static ImportFeed None = new(1, nameof(None), "No feed.");
   public static ImportFeed ItalianMiseGasStations = new(2, nameof(ItalianMiseGasStations), "Italian Gas Stations");
   public static ImportFeed ItalianMiseGasStationPrices = new(3, nameof(ItalianMiseGasStationPrices), "Italian Gas Stations Prices");

   /// <summary>
   /// The description of the import feed.
   /// </summary>
   public string? Description
   {
      get;
      private set;
   }

   public ImportFeed(int id, string name, string description) : base(id, name)
   {
      Description = description;
   }
}

Enumeration class is similar to enum as concept, basically I never load such entities from DB but define them as static properties but they are anyway stored on Db to maintain relationships between tables.

public abstract class Enumeration : IComparable
{
   public int Id { get; private set; }

   public string Name { get; private set; }

   protected Enumeration(int id, string name)
   {
      Id = id > 0 ? id : throw new DomainException(nameof(id));
      Name = string.IsNullOrWhiteSpace(name) ? throw new DomainException(nameof(name)) : name;
   }

   public override string ToString() => Name;

   public static IEnumerable<T> GetAll<T>() where T : Enumeration
   {
      var fields = typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly);

      return fields.Select(f => f.GetValue(null)).Cast<T>();
   }

   public override bool Equals(object? obj)
   {
      if (obj is not Enumeration otherValue) return false;

      var typeMatches = GetType().Equals(obj.GetType());
      var valueMatches = Id.Equals(otherValue.Id);

      return typeMatches && valueMatches;
   }

   public override int GetHashCode() => Id.GetHashCode();

   public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue)
   {
      var absoluteDifference = Math.Abs(firstValue.Id - secondValue.Id);
      return absoluteDifference;
   }

   public static bool IsValid<T>(int value) where T : Enumeration
      => Parse<T, int>(value, "value", item => item.Id == value, false) is not null;

   public static T FromValue<T>(int value) where T : Enumeration
      => Parse<T, int>(value, "value", item => item.Id == value)!;

   public static T FromDisplayName<T>(string displayName) where T : Enumeration
      => Parse<T, string>(displayName, "display name", item => item.Name == displayName)!;

   private static T? Parse<T, K>(K value, string description, Func<T, bool> predicate, bool raiseError = true) where T : Enumeration
   {
      var matchingItem = GetAll<T>().FirstOrDefault(predicate);

      if (raiseError && matchingItem == null)
         throw new InvalidOperationException($"'{value}' is not a valid {description} in {typeof(T)}");

      return matchingItem;
   }

   public int CompareTo(object? other) => Id.CompareTo(((Enumeration?)other)?.Id);
}

My entity configuration is something like this:

class GasStationEntityTypeConfiguration : BaseEntityTypeConfiguration<GasStation>
{
   public override string TableName => "GasStations";

   public override void ConfigureColumns(EntityTypeBuilder<GasStation> config)
   {
      config.HasIndex(b => new { b.ImportFeed, b.ImportFeedEntryId });

      config.Property(b => b.Name)
          .IsRequired();

      config.Property(b => b.Location)
         .IsRequired()
         .HasColumnName("Location")
         .HasConversion(new GeoPointConverter());

      config.Property(b => b.ImportFeed)
         .IsRequired()
         .HasColumnName("ImportFeedId")
         .HasConversion(b => b.Id, b => Enumeration.FromValue<ImportFeed>(b));

      config.HasOne(b => b.ImportFeed)
          .WithMany()
          .HasForeignKey(b => b.ImportFeed)
          .IsRequired();
   }
}

base class just define the Id mapping and hide some properties that doesn't have to be mapped.

Now as you see I have a custom converter for the ImportFeed property because I don't want to load it from db and instead I just store the "enum id" and doing so I specify that the ColumnName for the ImportFeed property is ImportFeedId.

What I want to do is to create a foreing key to the ImportFeeds table but with the code above, it generates the error

The property or navigation 'ImportFeed' cannot be added to the entity type 'GasStation' because a property or navigation with the same name already exists on entity type 'GasStation'.

I tried different combinations but it keeps generating this problem, the only way to avoid that is to change

config.HasOne(b => b.ImportFeed)
   .WithMany()
   .HasForeignKey(b => b.ImportFeed)
   .IsRequired();

to

config.HasOne<ImportFeed>()
   .WithMany()
   .HasForeignKey("ImportFeedId")
   .IsRequired();

like I tried to do as first because I don't need a navigation property for that foreign key since I've already one. The problem is that this snippets generates a new column ImportFeedId1 and create a foreign key on that...

enter image description here

How am I supposed to create a foreign key using code-first (without attributes) in this scenario? Is this something not supported?

I know I can manually add it in a custom migration but of course I don't want to do that Thanks.

1 Answers

I'm kind of in the same boat, been using Enumerations for lookup tables and am trying to set static objects of each status to the aggregates having EF to handle the saving however the only way I got it working is by using a converter which then isn't working properly with setting up a FK. The only solutions I've thought are:

  1. Configure the conversion in the object + a FK relationship setup which will create a StatusId & StatusId1, then manually amend the migration to keep one of the two FKs
  2. Don't bother setting up the FK for the enum values and leave the responsibility of using the correct enum values to the domain instead of the db #2 makes more sense to me from a DDD perspective however I am not happy with either.

Have you found any solutions on how to combine the converter with a FK? It seems to remove the FK as soon as it is added to a property.

Update 15/09/2022: As I said in the comment below I found a solution by creating a generic wrapper entity for enums that is used to create the fk which works great.

Here is a working example:

Enumeration class:

public class OrderStatus : Enumeration
{
    public static readonly OrderStatus New = new(1, nameof(New));
    public static readonly OrderStatus Processing = new(2, nameof(Processing));

    private OrderStatus(byte id, string name)
        : base(id, name)
    {
    }
}

Enumeration wrapper entity class:

public sealed class EnumerationEntity<TEnum>
where TEnum : Enumeration
{
private EnumerationEntity()
{
    //used by EF
}

public EnumerationEntity(TEnum enumeration)
{
    Id = enumeration;
    Name = enumeration.Name;
}

public TEnum Id { get; init; }
public string Name { get; init; }
}

Entity using the enumeration:

public class Order
{
    public int Id { get; set; }

    public OrderStatus OrderStatus { get; set; }
}

DbContext:

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

    // public virtual DbSet<Blog> Blogs { get; set; }
    public virtual DbSet<Order> Invites { get; set; }


    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Enumeration Entry that creates the Status table properly and configures the FK
        modelBuilder.Entity<EnumerationEntity<OrderStatus>>(builder =>
        {
            // setup the enumeration entity
            builder.ToTable(nameof(OrderStatus));

            builder.Property(d => d.Id)
                .HasConversion(d => d.Id, d => Enumeration.FromValue<OrderStatus>(d));

            builder.Property(ct => ct.Name)
                .HasMaxLength(200)
                .IsRequired();

            // populate enum values
            builder.HasData(
                new EnumerationEntity<OrderStatus>(OrderStatus.New),
                new EnumerationEntity<OrderStatus>(OrderStatus.Processing));
        });

        modelBuilder.Entity<Order>(d =>
        {
            // setup shadow property where the OrderStatus.Id will be assigned to
            d.Property(x => x.OrderStatus)
                .HasColumnName($"{nameof(OrderStatus)}Id")
                .HasConversion(d => d.Id, d => Enumeration.FromValue<OrderStatus>(d))
                .IsRequired();

            // configure FK relationship
            d.HasOne<EnumerationEntity<OrderStatus>>()
                .WithMany()
                .HasForeignKey(d => d.OrderStatus)
                .IsRequired();
        });
    }
}

The above will result in this migration:

public partial class Initial : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.CreateTable(
            name: "OrderStatus",
            columns: table => new
            {
                Id = table.Column<byte>(type: "tinyint", nullable: false),
                Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false)
            },
            constraints: table =>
            {
                table.PrimaryKey("PK_OrderStatus", x => x.Id);
            });

        migrationBuilder.CreateTable(
            name: "Invites",
            columns: table => new
            {
                Id = table.Column<int>(type: "int", nullable: false)
                    .Annotation("SqlServer:Identity", "1, 1"),
                OrderStatusId = table.Column<byte>(type: "tinyint", nullable: false)
            },
            constraints: table =>
            {
                table.PrimaryKey("PK_Invites", x => x.Id);
                table.ForeignKey(
                    name: "FK_Invites_OrderStatus_OrderStatusId",
                    column: x => x.OrderStatusId,
                    principalTable: "OrderStatus",
                    principalColumn: "Id",
                    onDelete: ReferentialAction.Cascade);
            });

        migrationBuilder.InsertData(
            table: "OrderStatus",
            columns: new[] { "Id", "Name" },
            values: new object[] { (byte)1, "New" });

        migrationBuilder.InsertData(
            table: "OrderStatus",
            columns: new[] { "Id", "Name" },
            values: new object[] { (byte)2, "Processing" });

        migrationBuilder.CreateIndex(
            name: "IX_Invites_OrderStatusId",
            table: "Invites",
            column: "OrderStatusId");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropTable(
            name: "Invites");

        migrationBuilder.DropTable(
            name: "OrderStatus");
    }
}

which as you can see creates the Enumeration table properly with a FK on the Order.OrderStatusId

Related