Enums and Lookup Tables with EF Code First

With EntityFramework’s support for enums, there is no longer any need to include lookup tables in the model. But I do want to have them in the database for integrity, even with code first.

I’ve been thinking for some time about to handle enums with code first. The idea behind code first is to be able to write the code as close as possible to how object oriented code is normally written. For enums that means that the enum definition itself is the constraint that is used to ensure that only valid values are used in the code.

For databases it would be possible to use a column constraint, but the normal way is to use a lookup table where the valid values are present. Any column in the database mapped against the enum type is then a foreign key to the lookup table to ensure integrity of the data.

What I would prefer is a solution where the lookup table is present in the database, but not mapped against any entity in the code.

Adding the Lookup Table

Adding the lookup table is simple when using Entity Framework Migrations. I’ve reused my old example on creating a database and updated Entity Framework to 6.0.1 that supports enums. When scaffolding a migration, the CarBodyStyle field (which is an enum) is now recognized by entity framework:

public override void Up()
{
   AddColumn("dbo.Cars", "BodyStyle", c => c.Int(nullable: false));
}

The lookup table and the foreign key can be added by extending the migration code:

public override void Up()
{
  CreateTable("dbo.CarBodyStyles",
    c => new
    {
      Id = c.Int(nullable: false),
      Description = c.String(maxLength: 50)
    }).PrimaryKey(t => t.Id);
 
  Sql("INSERT CarBodyStyles VALUES (0, 'Not Defined') ");
 
  AddColumn("dbo.Cars", "BodyStyle", c => c.Int(nullable: false));
 
  AddForeignKey("dbo.Cars", "BodyStyle", "dbo.CarBodyStyles");
}

The insert of the first value in CarBodyStyles is there to make sure that there is a matching value when the foreign key constraint is added to the Cars table. Without it, the foreign key can’t be added.

Adding Lookup Values

The migration takes care of adding the lookup table to the database, but that’s only the first half of the solution. The second half is to make sure that all the enum values are reflected in the lookup table automatically. The right place to do that is not in a migration, but in the Configuration.Seed() method that is called every time the migrations are applied. It is called even if there are no pending migrations.

protected override void Seed(TestLib.Entities.CarsContext context)
{
  context.Seed<TestLib.Entities.CarBodyStyle>();
}

That looks simple – doesn’t it? All of the work is deferred to the Seed<TEnum>() extension method. The lookup table isn’t mapped to the entity model, so there is no way to use all the nice features of entity framework for this operation. Instead I’m building up a plain Sql string, following the pattern from the Idempotent DB update Scripts post. I know that I’m not escaping data nor using parameterized Sql which leaves the risk för Sql injection. On the other hand I’m only using data from description attributes which is controlled by the developer so if you want to use it and Sql inject yourself – go ahead!

public static class EnumSeeder
{
    /// <summary>
    /// Populate a table with values based on defined enum values.
    /// </summary>
    /// <typeparam name="TEnum">Type of the enum</typeparam>
    /// <param name="context">A DbContext to use to run queries against
    /// the database.</param>
    /// <param name="idField">Id field, that should be populated with
    /// the numeric value of the enum.</param>
    /// <param name="descriptionField">Description field, that should be
    /// populated with the contents of the Description attribute (if
    /// there is any defined).</param>
    /// <param name="tableName">Name of the table. Assumed to be the same
    /// as the enum name plus an "s" for pluralization if nothing
    /// else is defined</param>
    [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", 
      "CA1062:Validate arguments of public methods", MessageId = "0"), 
      System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", 
      "CA1004:GenericMethodsShouldProvideTypeParameter")]
    public static void Seed<TEnum>(this DbContext context, string idField = "Id",
        string descriptionField = "description", string tableName = null)
    {
        if (tableName == null)
        {
            tableName = typeof(TEnum).Name + "s";
        }
 
        var commandBuilder = new StringBuilder();
 
        commandBuilder.AppendFormat("CREATE TABLE #EnumValues (\n" +
            "Id {0} NOT NULL PRIMARY KEY,\n" +
            "Description NVARCHAR(50))\n\n", GetIdType<TEnum>());
 
        AddValues<TEnum>(commandBuilder);
 
        string descriptionUpdate = descriptionField == null ? string.Empty :
            string.Format(CultureInfo.InvariantCulture, 
            "WHEN MATCHED THEN UPDATE\n" +
            "SET dst.{0} = src.Description\n", descriptionField);
 
        string descriptionInsert = descriptionField == null ? string.Empty :
            ", src.Description";
 
        string descriptionInFieldList = descriptionField == null ? string.Empty :
            ", " + descriptionField;
 
        commandBuilder.AppendFormat(CultureInfo.InvariantCulture, 
            "MERGE {0} dst\n" +
            "USING #EnumValues src\n" +
            "ON (src.Id = dst.{1})\n" +
            "{2}" +
            "WHEN NOT MATCHED THEN\n" +
            "INSERT ({1}{3}) VALUES (src.Id{4})\n" +
            "WHEN NOT MATCHED BY SOURCE THEN DELETE;\n\n",
            tableName, idField, descriptionUpdate, descriptionInFieldList, descriptionInsert);
 
        commandBuilder.AppendFormat(CultureInfo.InvariantCulture, "DROP TABLE #EnumValues\n");
 
        context.Database.ExecuteSqlCommand(TransactionalBehavior.DoNotEnsureTransaction,
            commandBuilder.ToString());
    }
 
    private static void AddValues<TEnum>(StringBuilder commandBuilder)
    {
        var values = Enum.GetValues(typeof(TEnum));
 
        if (values.Length > 0)
        {
            commandBuilder.AppendFormat(CultureInfo.InvariantCulture,
                "INSERT #EnumValues VALUES\n");
 
            var descriptions = GetDescriptions<TEnum>();
 
            bool firstValue = true;
            foreach (var v in values)
            {
                if (firstValue)
                {
                    firstValue = false;
                }
                else
                {
                    commandBuilder.AppendFormat(CultureInfo.InvariantCulture, ",\n");
                }
                string valueString = v.ToString();
 
                commandBuilder.AppendFormat(CultureInfo.InvariantCulture, "({0}, '{1}')",
                    (int)v, descriptions[valueString]);
            }
 
            commandBuilder.AppendFormat(CultureInfo.InvariantCulture, "\n\n");
        }
    }
 
    private static IDictionary<string, string> GetDescriptions<TEnum>()
    {
        return typeof(TEnum).GetMembers(BindingFlags.Static | BindingFlags.Public)
            .Select(m => new
            {
                Name = m.Name,
                Description = m.GetCustomAttributes(typeof(DescriptionAttribute), true)
                .Cast<DescriptionAttribute>().SingleOrDefault()
            })
            .ToDictionary(a => a.Name, 
            a => a.Description == null ? null : a.Description.Description);
    }
 
    private static string GetIdType<TEnum>()
    {
        var underlyingType = Enum.GetUnderlyingType(typeof(TEnum));
 
        if(underlyingType == typeof(int))
        {
            return "INT";
        }
 
        if(underlyingType == typeof(short))
        {
            return "SMALLINT";
        }
 
        if(underlyingType == typeof(byte))
        {
            return "TINYINT";
        }
 
        throw new NotImplementedException();
    }
}

2 comments

  1. I will have to take a look at the previous post’s author’s NuGet package, as it look interesting. As to this implementation; I cannot say that I would want to jump through that many hoops for the outcome. That is not to say that the solution isn’t novel, but I wouldn’t stretch it to practical in its current incarnation.

    Thanks,

    Michael

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.