Skip to content

Version 6.x.x Smart Enums

Pawel Gerr edited this page Oct 8, 2023 · 1 revision

This library provides some interfaces, classes, Roslyn Source Generators, Roslyn Analyzers and Roslyn CodeFixes for implementation of Smart Enums. The library comes with some Roslyn Analyzers and Code Fixes to guide the software developer through the implementation. Furthermore, additional Nuget packages add support for System.Text.Json, Newtonsoft.Json, MessagePack, Entity Framework Core and ASP.NET Core Model Binding.

Requirements

  • C# 11 (or higher) for generated code
  • SDK 7.0.102 (or higher) for building projects

Getting started

Required Nuget package: Thinktecture.Runtime.Extensions

Smart Enum without additional properties and methods. In this example the Smart Enum has the underlying-type string. The underlying type can be anything, not just primitive types like a string or an int. The items must be public static readonly fields. The provided constructor argument (e.g. Groceries) is assigned to a property Key which is generated by a Roslyn Source Generator.

What you implement

// Smart Enum with a string as the underlying type
public sealed partial class ProductType : IEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");
}

// Smart Enum with an int as the underlying type
public sealed partial class ProductGroup : IEnum<int>
{
   public static readonly ProductGroup Apple = new(1);
   public static readonly ProductGroup Orange = new(2);
}

What is implemented for you

Behind the scenes a Roslyn Source Generator, which comes with the library, generates additional code. Some of the features that are now available are ...

// a private constructor which takes the key and additional members (if we had any)
public sealed partial class ProductType : IEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries");
   ...

------------

// a property for iteration over all items
IReadOnlyList<ProductType> allTypes = ProductType.Items;

------------

// getting the item with specific name, i.e. its key
// throw UnknownEnumIdentifierException if the provided key doesn't match to any item
ProductType productType = ProductType.Get("Groceries");

// Alternatively, using an explicit cast (behaves the same as with Get)
ProductType productType = (ProductType)"Groceries";

------------

// the same as above but returns a bool instead of throwing an exception (dictionary-style)
bool found = ProductType.TryGet("Groceries", out ProductType productType);

------------

// similar to TryGet but returns a ValidationResult instead of a boolean.
ValidationResult? validationResult = ProductType.Validate("Groceries", out productType);

if (validationResult == ValidationResult.Success)
{
    logger.Information("Product type {Type} found with Validate", productType);
}
else
{
    logger.Warning("Failed to fetch the product type with Validate. Validation result: {ValidationResult}", validationResult.ErrorMessage);
}

------------

// implicit conversion to the type of the key
string key = ProductType.Groceries; // "Groceries"

------------

// Equality comparison with 'Equals' 
// which compares the keys using default or custom 'IEqualityComparer<T>'
bool equal = ProductType.Groceries.Equals(ProductType.Groceries);

------------

// Equality comparison with '==' and '!='
bool equal = ProductType.Groceries == ProductType.Groceries;
bool notEqual = ProductType.Groceries != ProductType.Groceries;

------------

// Hash code
int hashCode = ProductType.Groceries.GetHashCode();

------------

// 'ToString' implementation
string key = ProductType.Groceries.ToString(); // "Groceries"

------------

ILogger logger = ...;

// Switch-case (Action)
productType.Switch(ProductType.Groceries, () => logger.Information("Switch with Action: Groceries"),
                   ProductType.Housewares, () => logger.Information("Switch with Action: Housewares"));
                   
// Switch-case with parameter (Action<TParam>) to prevent closures
productType.Switch(logger,
                   ProductType.Groceries, static l => l.Information("Switch with Action: Groceries"),
                   ProductType.Housewares, static l => l.Information("Switch with Action: Housewares"));

// Switch case returning a value (Func<TResult>)
var returnValue = productType.Switch(ProductType.Groceries, static () => "Switch with Func<T>: Groceries",
                                     ProductType.Housewares, static () => "Switch with Func<T>: Housewares");

// Switch case with parameter returning a value (Func<TParam, TResult>) to prevent closures
returnValue = productType.Switch(logger,
                                 ProductType.Groceries, static l => "Switch with Func<T>: Groceries",
                                 ProductType.Housewares, static l => "Switch with Func<T>: Housewares");

// Map an item to another instance
returnValue = productType.Map(ProductType.Groceries, "Map: Groceries",
                              ProductType.Housewares, "Map: Housewares");
------------

// Implements IParsable<T> which is especially helpful with minimal web apis.
// This feature can be disabled if it doesn't make sense (see EnumGenerationAttribute).
bool parsed = ProductType.TryParse("Groceries", null, out var parsedProductType);

------------

// Implements IFormattable if the underlyng type (like int) is an IFormattable itself.
// This feature can be disabled if it doesn't make sense (see EnumGenerationAttribute).
var formatted = ProductGroup.Apple.ToString("000", CultureInfo.InvariantCulture); // 001

------------

// Implements IComparable and IComparable<T> if the underlyng type (like int) is an IComparable itself.
// This feature can be disabled if it doesn't make sense (see EnumGenerationAttribute).
var comparison = ProductGroup.Apple.CompareTo(ProductGroup.Orange); // -1

// Implements comparison operators (<,<=,>,>=) if the underlyng type (like int) has comparison operators itself.
// This feature can be disabled if it doesn't make sense (see EnumGenerationAttribute).
var isBigger = ProductGroup.Apple > ProductGroup.Orange;       

Additionally, the source generator implements a TypeConverter which are used by some libraries/frameworks like JSON serialization or ASP.NET Core Model Binding.

var typeConverter = TypeDescriptor.GetConverter(typeof(ProductType));

string key = (string)typeConverter.ConvertTo(ProductType.Groceries, typeof(string)); // "Groceries"
ProductType productType = (ProductType)typeConverter.ConvertFrom("Groceries"); // ProductType.Groceries

IEnum<T> vs IValidatableEnum<T>

When working with plain C# enums then we may cast virtually any number to the type of enum. By doing so, we can end up working with invalid items which may lead to bugs.

// plain C# enum
public enum MyEnum
{
   Groceries = 1,
   Housewares = 2
}

// "myEnum" is an invalid/unknown item
MyEnum myEnum = (MyEnum)42;
bool isDefined = Enum.IsDefined(myEnum); // false

Usually, having invalid enumeration items is not a requirement, that's why an implementation of IEnum<T> provides no means to create an invalid item. Still, there are use cases where you might want to create an invalid item for later analysis. One of such use cases could be parsing a CSV file, which may contain invalid data. Using an implementation of IEnum<T>, like our ProductTyp, would lead to a UnknownEnumIdentifierException when trying to convert a string to a ProductType.

// throws UnknownEnumIdentifierException
ProductType productType = ProductType.Get("SomeValue");

// Explicit cast will lead to identical outcome as the line above
ProductType productType = (ProductType)"SomeValue";

The solution in such use cases is to use IValidatableEnum<T> instead of IEnum<T>. A validatable Smart Enum offers not only the same features as IEnum<T> but provides

  • additional property bool IsValid for checking the validity
  • additional (guard) method void EnsureValid() which throws an InvalidOperationException if item is not valid

An implementation of IValidatableEnum<T> can be a class or a readonly struct. An IEnum<T> can be a class only because creation of a struct can bypass any validation by using the constructor or the keyword default.

Implementation of an IValidatableEnum<T>:

public sealed partial class OtherProductType : IValidatableEnum<string>
{
   public static readonly OtherProductType Groceries = new("Groceries");
   public static readonly OtherProductType Housewares = new("Housewares");
}

Creation of an invalid item is now possible.

OtherProductType productType = OtherProductType.Get("SomeValue");

string key = productType.Key; // "SomeValue"
bool isValid = productType.IsValid; // false
productType.EnsureValid(); // throws InvalidOperationException

The creation of invalid items is done by the method CreateInvalidItem which is implemented by the source generator. For more control, it is possible to provide own implementation. There are 2 important conditions:

  • the provided key must not be changed, so it is equal to a valid item. For example, don't change "SomeValue" to "Groceries".
  • the second constructor argument isValid must always be false, i.e., don't try to make an invalid item to a valid item.
public sealed partial class OtherProductType : IValidatableEnum<string>
{
   public static readonly OtherProductType Groceries = new("Groceries");
   public static readonly OtherProductType Housewares = new("Housewares");

   private static OtherProductType CreateInvalidItem(string key)
   {
      return new(key: key, isValid: false);
   }
}

Make use of abstract static members

The property Items and methods Get, TryGet, Validate, Parse and TryParse are implementations of static abstract members of interfaces IEnum<TKey, T>, IKeyedValueObject<T, TKey> and IParsable<T>. All interfaces are implemented by the Source Generators. Use generics to access aforementioned members without knowing the concrete type.

// Use T.Items to get all items.
PrintAllItems<ProductType, string>();

private static void PrintAllItems<T, TKey>()
   where T : IEnum<TKey, T>, IEnum<TKey>
   where TKey : notnull
{
   Console.WriteLine($"Print all items of '{typeof(T).Name}':");

   foreach (T item in T.Items)
   {
      Console.WriteLine($"Item: {item}");
   }
}

------------

// Use T.Get/TryGet/Validate to get the item for provided key.
Get<ProductType, string>("Groceries");

private static void Get<T, TKey>(TKey key)
   where T : IEnum<TKey, T>, IEnum<TKey>
   where TKey : notnull
{
   T item = T.Get(key);

   Console.WriteLine($"Key '{key}' => '{item}'");
}

Adding behavior

The Smart Enums really shine when the enumeration item has to provide additional data (fields/properties) or specific behavior (methods). With plain C# enum there is no other way as to use if-else or switch-case clauses. Having a class like the ProductType, we can add further fields, properties and methods as to any other class.

The ProductType from above has got an additional read-only property RequiresFoodVendorLicense. The value of this new property must be provided via the constructor.

public sealed partial class ProductType : IEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries", requiresFoodVendorLicense: true);
   public static readonly ProductType Housewares = new("Housewares", requiresFoodVendorLicense: false);

   public bool RequiresFoodVendorLicense { get; }
}

Adding a method, which provides the same behavior for all items, requires no special treatment. The method may have any arguments and any return type.

public sealed partial class ProductType : IEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");

   // provides the same behavior for all items
   public void Do()
   {
      // do something
   }
}

If different items must provide different implementations, then we have (at least) 2 options:

  • using delegates (Func<T>, Action, etc.)
  • using derived private classes, i.e., inheritance

Option 1: using delegates

public sealed partial class ProductType : IEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries", DoForGroceries);
   public static readonly ProductType Housewares = new("Housewares", Empty.Action);

   private readonly Action _do;

   public void Do()
   {
      _do();
   }

   private static void DoForGroceries()
   {
      // do something
   }
}

Option 2: inheritance

public partial class ProductType : IEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new HousewaresProductType();

   public virtual void Do()
   {
      // do default stuff
   }

   private sealed class HousewaresProductType : ProductType
   {
      public HousewaresProductType()
         : base("Housewares")
      {
      }

      /// <inheritdoc />
      public override void Do()
      {
         // do something else - or not
      }
   }
}

Generic derived types

Derived types of a Smart Enum can use generics.

public partial class Operator : IEnum<string>
{
   public static readonly Operator Item1 = new("Operator 1");
   public static readonly Operator Item2 = new GenericOperator<int>("Operator 2");
   public static readonly Operator Item3 = new GenericOperator<decimal>("Operator 3");
   public static readonly Operator Item4 = new GenericOperator<int>("Operator 4");

   private sealed class GenericOperator<T> : Operator
   {
      public GenericOperator(string key)
         : base(key)
      {
      }
   }
}

Switch-case/Map

It is not possible to use the standard switch-case clause with non-constant values, so we need a replacement for that. The method Switch provides a pair of arguments for every item, i.e. if the ProductType has 2 items (Groceries, Housewares) then the method Switch will have 2 pairs of arguments. A Roslyn Analyzer ensures that all items are present in the method call exactly once.

ProductType productType = ProductType.Groceries;

// Switch-case with an Action
// Prints "Switch with Action: Groceries"
productType.Switch(ProductType.Groceries, () => Console.WriteLine("Switch with Action: Groceries"),
                   ProductType.Housewares, () => Console.WriteLine("Switch with Action: Housewares"));

// Switch-case with a Func<T>
var returnValue = productType.Switch(ProductType.Groceries, () => "Switch with Func<T>: Groceries",
                                     ProductType.Housewares, () => "Switch with Func<T>: Housewares");

// Prints "Switch with Func<T>: Groceries"
Console.WriteLine(returnValue);

// Map an item to another instance
returnValue = productType.Map(ProductType.Groceries, "Map: Groceries",
                              ProductType.Housewares, "Map: Housewares");

// Prints "Map: Groceries"
Console.WriteLine(returnValue);

There are 2 other overloads to pass an argument to the delegates without creation of a closure. Use tuples, like (ILogger Logger, string OtherParam), to provide more than one value to the delegates.

ILogger logger = ...;

// Switch-case with an Action<TParam>
productType.Switch(logger,
                   ProductType.Groceries, static l => l.Information("Switch with Action: Groceries"),
                   ProductType.Housewares, static l => l.Information("Switch with Action: Housewares"));

// Switch case with a Func<TParam, TResult>
returnValue = productType.Switch(logger,
                                 ProductType.Groceries, static l => "Switch with Func<T>: Groceries",
                                 ProductType.Housewares, static l => "Switch with Func<T>: Housewares");

Customizing

Thanks to Roslyn Source Generators, we can customize virtually anything. Following customization options are currently available.

Changing the key property name

The key property name is Key be default.

string key = ProductType.Groceries.Key; // "Groceries"

Use the EnumGenerationAttribute to change the property name to something else.

[EnumGeneration(KeyPropertyName = "Name")]
public sealed partial class ProductType : IEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");
}

The key property is now renamed to Name.

string name = ProductType.Groceries.Name; // "Groceries"

Validation of the constructor arguments

Although the constructor is implemented by the source generator, still, the arguments can be validated in the partial method ValidateConstructorArguments. Please note, that the key must never be null.

public sealed partial class ProductType : IEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");

   static partial void ValidateConstructorArguments(ref string key)
   {
      if (String.IsNullOrWhiteSpace(key))
         throw new Exception("Key cannot be empty.");

      key = key.Trim();
   }
}

Additional fields and properties are passed to the method as well (see DisplayName below):

public sealed partial class ProductType : IEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries", "Display name for groceries");
   public static readonly ProductType Housewares = new("Housewares", "Display name for housewares");

   public string DisplayName { get; }

   static partial void ValidateConstructorArguments(ref int key, ref string displayName)
   {
        // validate
   }
}

A validatable enum gets an additional parameter bool isValid which is an indication what kind of item is being created. That way the validation may pass (but doesn't have to) invalid input if an invalid item is being created.

public sealed partial class ProductType : IValidatableEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");

   static partial void ValidateConstructorArguments(ref string key, bool isValid)
   {
      if (!isValid)
         return;

      if (String.IsNullOrWhiteSpace(key))
         throw new Exception("Key cannot be empty.");

      key = key.Trim();
   }
}

Custom key comparer

By default, the source generator is using the comparer EqualityComparer<T>.Default for all types except string. If the key is a string, then the comparer StringComparer.OrdinalIgnoreCase is being used.

The reason a string-based Smart Enum is not using EqualityComparer<T>.Default is because I haven't encountered a use case where the comparison of enumeration names must be performed case-sensitive.

Implement the static abstract member KeyEqualityComparer to change the comparer.

public sealed partial class ProductType : IEnum<string>
{
   public static IEqualityComparer<string> KeyEqualityComparer => StringComparer.Ordinal;

   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");
}

Skip implementation of IComparable/IComparable<T>

Use EnumGenerationAttribute to set SkipIComparable to true to disable the implementation of IComparable and IComparable<T>.

[EnumGeneration(SkipIComparable = true)]
public sealed partial class ProductGroup : IEnum<int>
{

Implementation of comparison operators

Use EnumGenerationAttribute to set ComparisonOperators to OperatorsGeneration.None to disable the implementation of comparison operators: >, >=, <, <=. Set the property to OperatorsGeneration.DefaultWithKeyTypeOverloads to generate additional operators to be able to compare a Smart Enum with a value of the underlying type.

[EnumGeneration(ComparisonOperators = OperatorsGeneration.None)]
public sealed partial class ProductGroup : IEnum<int>
{

Skip implementation of IParsable<T>

Use EnumGenerationAttribute to set SkipIParsable to true to disable the implementation of IParsable<T>.

[EnumGeneration(SkipIParsable = true)]
public sealed partial class ProductGroup : IEnum<int>
{

Skip implementation of IFormattable

Use EnumGenerationAttribute to set SkipIFormattable to true to disable the implementation of IFormattable.

[EnumGeneration(SkipIFormattable = true)]
public sealed partial class ProductGroup : IEnum<int>
{

Skip implementation of ToString

Use EnumGenerationAttribute to set SkipToString to true to disable the implementation of the method ToString().

[EnumGeneration(SkipToString = true)]
public sealed partial class ProductGroup : IEnum<int>
{

Hide fields and properties from Source Generator and Analyzer

Use this feature with caution!

A Smart Enum must be immutable. Hiding a member from the Generator and Analyzer means that there is no validation of this member anymore.

Use ValueObjectMemberIgnoreAttribute to hide the.

The attribute starts with ValueObject... instead of EnumMember... because this attribute is being used for both, the Smart Enums and the Value Objects. You can think of a Smart Enum as a "special" Value Object with strictly limited number of items.

public sealed partial class ProductType : IEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");

   // With ValueObjectMemberIgnoreAttribute the Analyzer doesn't emit a compiler error that the member is not read-only.
   [ValueObjectMemberIgnore]
   private string _someValue;
}

JSON serialization

Depending on the concrete JSON library you use, you need a different Nuget package:

  • For System.Text.Json: Thinktecture.Runtime.Extensions.Json
  • For Newtonsoft.Json: Thinktecture.Runtime.Extensions.Newtonsoft.Json

There are 2 options to make the Smart Enums JSON convertible.

Option 1: Make project with Smart Enums depend on corresponding Nuget package

The easiest way is to make Thinktecture.Runtime.Extensions.Json / Thinktecture.Runtime.Extensions.Newtonsoft.Json a dependency of the project(s) the Smart Enums are in. The dependency doesn't have to be a direct one but transitive as well. Both Nuget packages activate generation of additional code that flags the Smart Enum with a JsonConverterAttribute. This way the Smart Enum can be converted to and from JSON without extra code.

Option 2: Register JSON converter with JSON serializer settings

If making previously mentioned Nuget package a dependency of project(s) with Smart Enums is not possible or desirable, then the other option is to register a JSON converter with JSON serializer settings. By using a JSON converter directly, the Nuget package can be installed in any project where the JSON settings are configured.

  • Use ValueObjectJsonConverterFactory with System.Text.Json
  • Use ValueObjectNewtonsoftJsonConverter with Newtonsoft.Json

Don't be confused by the names ValueObjectJsonConverterFactory and ValueObjectNewtonsoftJsonConverter. The attributes are being used for both, the Smart Enums and the Value Objects. You can think of a Smart Enum as a "special" Value Object with strictly limited number of items.

An example for ASP.NET Core application using System.Text.Json:

var webHost = new HostBuilder()
              .ConfigureServices(collection =>
               {
                  collection.AddMvc()
                            .AddJsonOptions(options => options.JsonSerializerOptions
                                                              .Converters
                                                              .Add(new ValueObjectJsonConverterFactory()));
               })

An example for minimal web apis:

var builder = WebApplication.CreateBuilder();

builder.Services
       .ConfigureHttpJsonOptions(options => options.SerializerOptions
                                                   .Converters
                                                   .Add(new ValueObjectJsonConverterFactory()));

The code for Newtonsoft.Json is almost identical:

var webHost = new HostBuilder()
              .ConfigureServices(collection =>
               {
                   collection.AddMvc()
                             .AddNewtonsoftJson(options => options.SerializerSettings
                                                                  .Converters
                                                                  .Add(new ValueObjectNewtonsoftJsonConverter()));
               })

MessagePack serialization

  • Required nuget package: Thinktecture.Runtime.Extensions.MessagePack

There are 2 options to make the Smart Enums MessagePack serializable.

Option 1: Make project with Smart Enums depend on Nuget package

The easiest way is to make Thinktecture.Runtime.Extensions.MessagePack a dependency of the project(s) the Smart Enums are in. The dependency doesn't have to be a direct one but transitive as well. The Nuget package activates generation of additional code that flags the Smart Enums with a MessagePackFormatterAttribute. This way the Smart Enum can be converted to and from MessagePack without extra code.

Option 2: Register MessagePack FormatterResolver with MessagePack serializer options

If making previously mentioned Nuget package a dependency of project(s) with Smart Enums is not possible or desirable, then the other option is to register the MessagePack formatter with MessagePack serializer options. By using the ValueObjectMessageFormatterResolver directly, the Nuget package can be installed in any project where the MessagePack options are configured.

Don't be confused by the names ValueObjectMessageFormatterResolver.Instance. The attribute is being used for both, the Smart Enums and the Value Objects. You can think of a Smart Enum as a "special" Value Object with strictly limited number of items.

An example of a round-trip-serialization of the Smart Enum ProductType.Groceries:

// Use "ValueObjectMessageFormatterResolver.Instance"
var resolver = CompositeResolver.Create(ValueObjectMessageFormatterResolver.Instance, StandardResolver.Instance);
var options = MessagePackSerializerOptions.Standard.WithResolver(resolver);

var productType = ProductType.Groceries;

// Serialize to MessagePack
var bytes = MessagePackSerializer.Serialize(productType, options, CancellationToken.None);

// Deserialize from MessagePack
var deserializedproductType = MessagePackSerializer.Deserialize<ProductType>(bytes, options, CancellationToken.None);

Support for Minimal Web Api Parameter Binding and ASP.NET Core Model Binding

Required nuget package: Thinktecture.Runtime.Extensions.AspNetCore

Having JSON convertible Smart Enums is just half of the equation. If a value is received as a query string parameter, then there is no JSON conversion in play but ASP.NET Core Model Binding. Besides model binding, i.e., conversion from query string to a Smart Enum, there is model validation as well.

Minimal Web Api

The parameter binding of Minimal Web Apis in .NET 7 is still quite primitive in comparison to the model binding of MVC controllers. To make a type bindable it has to implement either TryParse or BindAsync. A Smart Enum implements TryParse (interface IParsable<T>) by default, so it can be used with Minimal Web Apis without any changes.

At the moment, all means (i.e. TryParse and BindAsync) doesn't allow to pass custom validation errors to be returned to the client. The only information we can pass is an indication whether the parameter could be bound or not.

ASP.NET Core MVC (Controllers)

ASP.NET MVC gives us more control during model binding. For example, if we expect from client a ProductType and receive the value SomeValue, then the ASP.NET Core ModelState must be invalid. In this case we can reject (or let ApiControllerAttribute reject) the request.

By rejecting the request, the client gets the status code BadRequest (400) and the error:

{
  "productType": [
    "The enumeration item of type 'ProductType' with identifier 'SomeValue' is not valid."
  ]
}

To help out the Model Binding we have to register the ValueObjectModelBinderProvider with ASP.NET Core. By using the custom model binder, the Nuget package can be installed in any project where ASP.NET Core is configured.

Don't be confused by the name ValueObjectModelBinderProvider. The model binder is being used for both, the Smart Enums and the Value Objects. You can think of a Smart Enum as a "special" Value Object with strictly limited number of items.

var webHost = new HostBuilder()
              .ConfigureServices(collection =>
              {
                   collection.AddMvc(options => options.ModelBinderProviders
                                                       .Insert(0, new ValueObjectModelBinderProvider()));
              })

Support for Entity Framework Core

Optional nuget packages:
Thinktecture.Runtime.Extensions.EntityFrameworkCore5
Thinktecture.Runtime.Extensions.EntityFrameworkCore6
Thinktecture.Runtime.Extensions.EntityFrameworkCore7

Starting with Entity Framework Core 2.1 we've got the feature Value Conversion. By providing a value converter, the EF Core can convert a Smart Enum (like ProductType) to a primitive type (like string) before persisting the value and back to a Smart Enum when reading the value from database.

Option 1: Manual registration of the ValueConverter

The registration of a value converter can be done manually by using one of the method overloads of HasConversion in OnModelCreating.

// Entity
public class Product
{
   // other properties...

   public ProductType ProductType { get; private set; }
}

public class ProductsDbContext : DbContext
{
   protected override void OnModelCreating(ModelBuilder modelBuilder)
   {
      base.OnModelCreating(modelBuilder);

      modelBuilder.Entity<Product>(builder =>
      {
        builder.Property(p => p.ProductType)
               .HasConversion(p => p.Key,
                              key => ProductType.Get(key));
      });
   }
}

Option 2: Registration of the ValueConverter via extension method for ModelBuilder

Alternatively, you can install the appropriate Nuget package for EF Core 5, EF Core 6 or EF Core 7 and use the extension method AddEnumAndValueObjectConverters to register the value converters for you.

public class ProductsDbContext : DbContext
{
   protected override void OnModelCreating(ModelBuilder modelBuilder)
   {
      base.OnModelCreating(modelBuilder);

      modelBuilder.AddEnumAndValueObjectConverters(validateOnWrite: true);
   }
}

You can provide a delegate to adjust the configuration of Smart Enums and Value Objects.

modelBuilder.AddEnumAndValueObjectConverters(validateOnWrite: true,
               configureEnumsAndKeyedValueObjects: property =>
                                                   {
                                                      if (property.ClrType == typeof(ProductType))
                                                         property.SetMaxLength(20);
                                                   });

Option 3: Registration of the ValueConverter via extension method for DbContextOptionsBuilder

The other options is to use the extension method UseValueObjectValueConverter for the DbContextOptionsBuilder.

services
   .AddDbContext<DemoDbContext>(builder => builder
                                           .UseValueObjectValueConverter(validateOnWrite: true,
                                                                         configureEnumsAndKeyedValueObjects: property =>
                                                                               {
                                                                                 if (property.ClrType == typeof(ProductType))
                                                                                    property.SetMaxLength(20);
                                                                               })

Logging (v6.1.0 or higher)

Logging can be activated in the csproj-file. Define the property ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath pointing to an existing(!) folder (like C:\temp\). You can provide a file name (like samples_logs.txt) which is being used as a template for creation of a unique log file name like samples_logs_20230322_220653_19c0d6c18ec14512a1acf97621912abb.txt.

Please note, that there will be more than 1 log file (per project) because IDEs (Rider/VS) usually create 1 Source Generator for constant running in the background, and 1 for each build/rebuild of a project. Unless, ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique is set to false.

With ThinktectureRuntimeExtensions_SourceGenerator_LogLevel you can specify one of the following log levels: Trace, Debug, Information (DEFAULT), Warning, Error.

<Project Sdk="Microsoft.NET.Sdk">
   <PropertyGroup>
      ...

      <ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath>C:\temp\samples_logs.txt</ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath>
      <ThinktectureRuntimeExtensions_SourceGenerator_LogLevel>information</ThinktectureRuntimeExtensions_SourceGenerator_LogLevel>
      <ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique>false</ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique> 
      
   </PropertyGroup>

If the logger throws an exception, for example due to insufficient file system access permissions, then the logger will try to write the exception into a temp file. You can find the file ThinktectureRuntimeExtensionsSourceGenerator.log in the temp folder of the user the IDE/CLI is running with.

Real-world use cases and ideas

I started to write down some examples I used in the past to show the developers the benefits of value objects and smart enums.
More examples will come very soon!

CSV-Importer-Type

Imagine we need an importer for daily and monthly sales.

The CSV for daily sales has following columns: id,datetime,volume. The datetime has a format yyyyMMdd hh:mm.

id,datetime,volume
1,20230425 10:45,345.67

The CSV for monthly sales differs from time to time. It can have either 3 columns volume,datetime,id or 4 columns volume,quantity,id,datetime. If the CSV has 3 columns, then the datetime format is the same in daily imports (yyyyMMdd hh:mm), but if there are 4 columns, then the format is yyyy-MM-dd.

volume,datetime,id
123.45,20230426 11:50,2

OR

volume,quantity,id,datetime
123.45,42,2,2023-04-25

We are interested in id, volume and datetime only.

Regular C#-enum

With regular C#-enums we have to use either switch-case or if-else. The readability of the code is ok, but not the best one. Furthermore, if there will be added another type in the future, say Yearly, then we have to remember to adjust the switch-case otherwise we get ArgumentOutOfRangeException.

public enum SalesCsvImporterType
{
   Daily,
   Monthly
}

// Usage
var type = SalesCsvImporterType.Monthly;
var csv = ...;

using var textReader = new StringReader(csv);
using var csvReader = new CsvReader(textReader, new CsvConfiguration(CultureInfo.InvariantCulture) { HasHeaderRecord = true });

csvReader.Read();
csvReader.ReadHeader();

while (csvReader.Read())
{
   int articleId;
   decimal volume;
   DateTime dateTime;

   switch (type)
   {
      case SalesCsvImporterType.Daily:
         articleId = csvReader.GetField<int>(0);
         volume = csvReader.GetField<decimal>(2);
         dateTime = DateTime.ParseExact(csvReader[1], "yyyyMMdd hh:mm", null);
         break;

      case SalesCsvImporterType.Monthly:
         articleId = csvReader.GetField<int>(2);
         volume = csvReader.GetField<decimal>(0);

         dateTime = csvReader.HeaderRecord?.Length == 3
                       ? DateTime.ParseExact(csvReader[1], "yyyyMMdd hh:mm", null) // same as "Daily"
                       : DateTime.ParseExact(csvReader[3], "yyyy-MM-dd", null);
         break;

      default:
         throw new ArgumentOutOfRangeException();
   }

   logger.Information("CSV ({Type}): Article-Id={Id}, DateTime={DateTime}, Volume={Volume}", type, articleId, dateTime, volume);
}

Smart Enum

As an alternative to switch-case, we can move the parts that differ to a Smart Enum. The befinets are: (1) the actual importer is easier to read and to maintain, and (2) it is impossible to forget to adjust the code if another type, like Yearly, is implemented in the future.

[EnumGeneration(KeyPropertyName = "Name")]
public sealed partial class SalesCsvImporterType : IEnum<string>
{
   // Constructor is generated according to fields and properties of the smart enum.
   // This prevents "forgetting" to provide values to members.
   public static readonly SalesCsvImporterType Daily = new(name: "Daily", articleIdIndex: 0, volumeIndex: 2, GetDateTimeForDaily);
   public static readonly SalesCsvImporterType Monthly = new(name: "Monthly", articleIdIndex: 2, volumeIndex: 0, GetDateTimeForMonthly);

   public int ArticleIdIndex { get; }
   public int VolumeIndex { get; }

   // Altivative: use inheritance instead of delegate to have different implementations for different types
   private readonly Func<CsvReader, DateTime> _getDateTime;
   public DateTime GetDateTime(CsvReader csvReader) => _getDateTime(csvReader);

   private static DateTime GetDateTimeForDaily(CsvReader csvReader)
   {
      return DateTime.ParseExact(csvReader[1] ?? throw new Exception("Invalid CSV"),
                                 "yyyyMMdd hh:mm",
                                 null);
   }

   private static DateTime GetDateTimeForMonthly(CsvReader csvReader)
   {
      return csvReader.HeaderRecord?.Length == 3
                ? GetDateTimeForDaily(csvReader)
                : DateTime.ParseExact(csvReader[3] ?? throw new Exception("Invalid CSV"),
                                      "yyyy-MM-dd",
                                      null);
   }
}

The smart enum SalesCsvImporterType eliminates the need for a switch-case.

var type = SalesCsvImporterType.Monthly;
var csv = ...;

using var textReader = new StringReader(csv);
using var csvReader = new CsvReader(textReader, new CsvConfiguration(CultureInfo.InvariantCulture) { HasHeaderRecord = true });

csvReader.Read();
csvReader.ReadHeader();

while (csvReader.Read())
{
   var articleId = csvReader.GetField<int>(type.ArticleIdIndex);
   var volume = csvReader.GetField<decimal>(type.SalesVolumeIndex);
   var dateTime = type.GetDateTime(csvReader);

   logger.Information("CSV ({Type}): Article-Id={Id}, DateTime={DateTime}, Volume={Volume}", type, articleId, dateTime, volume);
}

Limitations

Source Generation: Aliases are not supported

I'm planing to switch to generic attribute EnumAttribute<T> in the next major version to remove this limitation.

Aliases, like IStringEnum or EnumGen in the examples below, are not supported due to performance reasons during source code generation.

using IStringEnum = Thinktecture.IEnum<string>;
using EnumGen = Thinktecture.EnumGenerationAttribute;

namespace Thinktecture.SmartEnums;

// the alias `IStringEnum` won't be recognized as an `IValidatableEnum<string>` by the Source Generator.
public sealed partial class ProductGroup : IStringEnum
{
}

// the alias `EnumGen` won't be recognized as an `EnumGenerationAttribute` by the Source Generator.
[EnumGen]
public sealed partial class ProductType : IEnum<string>
{
}
Clone this wiki locally