-
Notifications
You must be signed in to change notification settings - Fork 1
Version 6.x.x Smart Enums
- Requirements
- Getting started
-
Customizing
- Changing the key property name
- Validation of the constructor arguments
- Custom key comparer
- Skip implementation of
IComparable
/IComparable<T>
- Implementation of comparison operators
- Skip implementation of
IParsable<T>
- Skip implementation of
IFormattable
- Skip implementation of ToString
- Hide fields and properties from Source Generator and Analyzer
- JSON serialization
- MessagePack serialization
- Support for Minimal Web Api Parameter Binding and ASP.NET Core Model Binding
- Support for Entity Framework Core
- Logging
- Real-world use cases and ideas
- Limitations
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
.
- C# 11 (or higher) for generated code
- SDK 7.0.102 (or higher) for building projects
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.
// 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);
}
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
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 anInvalidOperationException
if item is not valid
An implementation of
IValidatableEnum<T>
can be aclass
or areadonly struct
. AnIEnum<T>
can be aclass
only because creation of astruct
can bypass any validation by using the constructor or the keyworddefault
.
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 befalse
, 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);
}
}
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}'");
}
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
}
}
}
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)
{
}
}
}
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");
Thanks to Roslyn Source Generators, we can customize virtually anything. Following customization options are currently available.
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"
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();
}
}
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 usingEqualityComparer<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");
}
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>
{
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>
{
Use EnumGenerationAttribute
to set SkipIParsable
to true
to disable the implementation of IParsable<T>
.
[EnumGeneration(SkipIParsable = true)]
public sealed partial class ProductGroup : IEnum<int>
{
Use EnumGenerationAttribute
to set SkipIFormattable
to true
to disable the implementation of IFormattable
.
[EnumGeneration(SkipIFormattable = true)]
public sealed partial class ProductGroup : IEnum<int>
{
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>
{
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 ofEnumMember...
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;
}
Depending on the concrete JSON library you use, you need a different Nuget package:
There are 2 options to make the Smart Enums JSON convertible.
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.
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
withSystem.Text.Json
- Use
ValueObjectNewtonsoftJsonConverter
withNewtonsoft.Json
Don't be confused by the names
ValueObjectJsonConverterFactory
andValueObjectNewtonsoftJsonConverter
. 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()));
})
There are 2 options to make the Smart Enums MessagePack serializable.
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.
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);
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.
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 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()));
})
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.
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));
});
}
}
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);
});
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 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 tofalse
.
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.
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!
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.
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);
}
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);
}
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>
{
}