-
Notifications
You must be signed in to change notification settings - Fork 1
Version 7.x.x Smart Enums
- Requirements
- Getting started
-
Customizing
- Key member generation
- Validation of the constructor arguments
- Custom equality comparer
- Custom comparer
- Skip implementation of
IComparable
/IComparable<T>
- Implementation of comparison operators
- Skip implementation of
IParsable<T>
- Skip implementation of
IFormattable
- Skip implementation of ToString
- Convert from/to non-key type
- Hide fields and properties from Source Generator and Analyzer
- JSON serialization
- MessagePack serialization
- Support for Minimal Api Parameter Binding and ASP.NET Core Model Binding
- Support for Entity Framework Core
- Logging
- Real-world use cases and ideas
This library provides an easy way for implementation of Smart Enums. The library comes with some Roslyn Analyzers and Code Fixes to guide the software developers 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
.
Don't be confused when some type start with
ValueObject...
instead ofSmartEnum...
. Some types 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 limited number of items.
- C# 11 (or higher) for generated code
- SDK 7.0.401 (or higher) for building projects
In this example the Smart Enum ProductType
has the underlying-type (key-member type) string
. The underlying type can be anything, not just primitive types like a string
or an int
.
As of version 7 you can define a keyless Smart Enum (see
SalesCsvImporterType
below), i.e. without any underlying type. A keyless Smart Enum has just a subset of the features of a keyed Smart Enum.
[SmartEnum<string>]
public sealed partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries");
public static readonly ProductType Housewares = new("Housewares");
}
-------------
[SmartEnum<int>]
public sealed partial class ProductGroup
{
public static readonly ProductGroup Fruits = new(1);
public static readonly ProductGroup Vegetables = new(2);
}
-------------
[SmartEnum]
public sealed partial class SalesCsvImporterType
{
public static readonly SalesCsvImporterType Daily = new();
public static readonly SalesCsvImporterType Monthly = new();
}
Behind the scenes a Roslyn Source Generator generates additional code. Some of the features that are now available are ...
// A private constructor which takes the key "Groceries" and additional members (if we had any)
[SmartEnum<string>]
public sealed partial class ProductType
{
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.
// Throws UnknownEnumIdentifierException if the provided key doesn't belong to any item
ProductType productType = ProductType.Get("Groceries");
// Alternatively, using an explicit cast (behaves the same as "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 accepts `IFormatProvider` and returns a ValidationError instead of a boolean.
ValidationError? validationError = ProductType.Validate("Groceries", null, out ProductType? productType);
if (validationError is null)
{
logger.Information("Product type {Type} found with Validate", productType);
}
else
{
logger.Warning("Failed to fetch the product type with Validate. Validation error: {validationError}", validationError.ToString());
}
------------
// implicit conversion to the type of the key
string key = ProductType.Groceries; // "Groceries"
------------
// Equality comparison
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 (with "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
var 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 apis.
bool parsed = ProductType.TryParse("Groceries", null, out ProductType? parsedProductType);
------------
// Implements IFormattable if the underlyng type (like int) is an IFormattable itself.
var formatted = ProductGroup.Fruits.ToString("000", CultureInfo.InvariantCulture); // 001
------------
// Implements IComparable and IComparable<T> if the key member type (like int) is an IComparable itself.
var comparison = ProductGroup.Fruits.CompareTo(ProductGroup.Vegetables); // -1
// Implements comparison operators (<,<=,>,>=) if the underlyng type (like int) has comparison operators itself.
var isBigger = ProductGroup.Fruits > ProductGroup.Vegetables;
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 myEnum = (MyEnum)42;
bool isDefined = Enum.IsDefined(myEnum); // false
Usually, having invalid enumeration items is not a requirement, that's why - by default - a Smart Enum 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 a Smart Enum, like our ProductType
, 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 [SmartEnum<T>(IsValidatable = true)]
.
A validatable Smart Enum provides:
- additional property
bool IsValid
for checking the validity - additional (guard) method
void EnsureValid()
which throws anInvalidOperationException
if item is not valid
A validatable Smart Enum can be either a
class
or areadonly struct
. An always-valid Smart Enum can be aclass
only because the creation of astruct
can bypass any validation by using the default constructor or the keyworddefault
.
Implementation of a validatable Smart Enum:
[SmartEnum<string>(IsValidatable = true)]
public sealed partial class OtherProductType
{
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 becomes 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 one.
[SmartEnum<string>(IsValidatable = true)]
public sealed partial class OtherProductType
{
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, TValidationError>
and IParsable<T>
. All interfaces are implemented by the Source Generator. 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, ValidationError>
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, ValidationError>
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 to handle a specific item. Having a Smart Enum, like the ProductType
, we can add further fields, properties and methods as to any other classes.
The ProductType
from above has got an additional read-only property RequiresFoodVendorLicense
. The value of this new property must be provided via the constructor.
[SmartEnum<string>]
public sealed partial class ProductType
{
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.
[SmartEnum<string>]
public sealed partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries");
public static readonly ProductType Housewares = new("Housewares");
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. via inheritance
Option 1: using delegates
[SmartEnum<string>]
public sealed partial class ProductType
{
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
[SmartEnum<string>]
public partial class ProductType
{
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")
{
}
public override void Do()
{
// do something else
}
}
}
Derived types of a Smart Enum can use generics.
[SmartEnum<string>]
public partial class Operator
{
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 member is generated by the source generator.
Use KeyMemberName
, KeyMemberAccessModifier
and KeyMemberKind
to change the generation of the key member.
Example: Let source generator generate field private readonly string _name;
instead of property public string Key { get; }
(Default).
[SmartEnum<string>(KeyMemberName = "_name",
KeyMemberAccessModifier = ValueObjectAccessModifier.Private,
KeyMemberKind = ValueObjectMemberKind.Field)]
public sealed partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries");
public static readonly ProductType Housewares = new("Housewares");
}
--------------------------
----- generate code ------
partial class ProductType
{
...
private readonly string _name;
...
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
.
[SmartEnum<string>]
public sealed partial class ProductType
{
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):
[SmartEnum<string>]
public sealed partial class ProductType
{
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 string 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.
[SmartEnum<string>(IsValidatable = true)]
public sealed partial class ProductType
{
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 default implementation of Equals
and GetHashCode
, except for strings
. If the key member is a string
, then the source generator is using StringComparer.OrdinalIgnoreCase
.
The reason strings are not using default implementation is, because I encountered very few use cases where the comparison must be performed case-sensitive. Most often, case-sensitive string comparisons are bugs because the developers have forgotten to pass appropriate (case-insensitive) comparer.
Use ValueObjectKeyMemberEqualityComparerAttribute<TComparerAccessor, TMember>
to define an equality comparer for comparison of key members and for computation of the hash code. Use one of the predefined ComparerAccessors
or implement a new one (see below).
The example below changes the comparer from OrdinalIngoreCase
to Ordinal
.
[SmartEnum<string>]
[ValueObjectKeyMemberEqualityComparer<ComparerAccessors.StringOrdinal, string>]
public sealed partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries");
public static readonly ProductType Housewares = new("Housewares");
}
Implement the interface IEqualityComparerAccessor<T>
to create a new custom accessor. The accessor has 1 static property that returns an instance of IEqualityComparer<T>
. The generic type T
is the type of the member to compare.
public interface IEqualityComparerAccessor<in T>
{
static abstract IEqualityComparer<T> EqualityComparer { get; }
}
Implementation of an accessor for members of type string
.
public class StringOrdinal : IEqualityComparerAccessor<string>
{
public static IEqualityComparer<string> EqualityComparer => StringComparer.Ordinal;
}
Predefined accessors in static class ComparerAccessors
:
// Predefined:
ComparerAccessors.StringOrdinal
ComparerAccessors.StringOrdinalIgnoreCase
ComparerAccessors.CurrentCulture
ComparerAccessors.CurrentCultureIgnoreCase
ComparerAccessors.InvariantCulture
ComparerAccessors.InvariantCultureIgnoreCase
ComparerAccessors.Default<T>; // e.g. ComparerAccessors.Default<string> or ComparerAccessors.Default<int>
Use ValueObjectKeyMemberComparerAttribute<TComparerAccessor, TMember>
to specify a comparer. Use one of the predefined ComparerAccessors
or implement a new one (see below).
Please note that this section is about implementation of
IComparable<T>
andIComparer<T>
. Don't confuse theIComparer<T>
withIEqualityComparer<T>
which is being used for equality comparison and the computation of the hash code.
[SmartEnum<string>]
[ValueObjectKeyMemberComparer<ComparerAccessors.StringOrdinal, string>]
public sealed partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries");
public static readonly ProductType Housewares = new("Housewares");
}
Implement the interface IComparerAccessor<T>
to create a new custom accessor. The accessor has 1 static property that returns an instance of IComparer<T>
. The generic type T
is the type of the member to compare.
public interface IComparerAccessor<in T>
{
static abstract IComparer<T> Comparer { get; }
}
Implementation of an accessor for members of type string
.
public class StringOrdinal : IComparerAccessor<string>
{
public static IComparer<string> Comparer => StringComparer.OrdinalIgnoreCase;
}
Predefined accessors in static class ComparerAccessors
:
// Predefined:
ComparerAccessors.StringOrdinal
ComparerAccessors.StringOrdinalIgnoreCase
ComparerAccessors.CurrentCulture
ComparerAccessors.CurrentCultureIgnoreCase
ComparerAccessors.InvariantCulture
ComparerAccessors.InvariantCultureIgnoreCase
ComparerAccessors.Default<T>; // e.g. ComparerAccessors.Default<string> or ComparerAccessors.Default<int>
Use SmartEnumAttribute<T>
to set SkipIComparable
to true
to disable the implementation of IComparable
and IComparable<T>
.
[SmartEnum<int>(SkipIComparable = true)]
public sealed partial class ProductGroup
{
Use SmartEnumAttribute<T>
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 key-member type.
[SmartEnum<int>(ComparisonOperators = OperatorsGeneration.None)]
public sealed partial class ProductGroup
{
Use SmartEnumAttribute<T>
to set SkipIParsable
to true
to disable the implementation of IParsable<T>
.
[SmartEnum<int>(SkipIParsable = true)]
public sealed partial class ProductGroup
{
Use SmartEnumAttribute<T>
to set SkipIFormattable
to true
to disable the implementation of IFormattable
.
[SmartEnum<int>(SkipIFormattable = true)]
public sealed partial class ProductGroup
{
Use SmartEnumAttribute<T>
to set SkipToString
to true
to disable the implementation of the method ToString()
.
[SmartEnum<int>(SkipToString = true)]
public sealed partial class ProductGroup
{
With ValueObjectFactoryAttribute<T>
you can implement additional methods to be able to convert an item from/to type T
.
This conversion can be one-way (T
-> Smart Enum) or two-way (T
<-> Smart Enum).
Conversion from a
string
allows ASP.NET Model Binding to bind Smart Enums with any key-member type.
Example: Given is an int
-based Smart Enum MyEnum
.
[SmartEnum<int>]
public sealed partial class MyEnum
{
public static readonly MyEnum Item1 = new(1);
}
The Smart Enum MyEnum
must be convertible from specific strings, e.g. the value =1=
should return Item1
.
By applying ValueObjectFactoryAttribute<string>
the source generator adds the interface IValueObjectFactory<MyEnum, string>
which forces you to implement new method Validate(string, IFormatProvider, out MyEnum)
. With new Validate
we have a one-way conversion, i.e. from string
to MyEnum
.
[SmartEnum<int>]
[ValueObjectFactory<string>]
public sealed partial class MyEnum
{
public static readonly MyEnum Item1 = new(1);
public static ValidationError? Validate(string? value, IFormatProvider? provider, out MyEnum? item)
{
switch (value)
{
case "=1=":
item = Item1;
return null;
...
}
}
}
Two-way conversion is required if the type T
must be used for serialization/deserialization. This can be achieved by using the property UseForSerialization = SerializationFrameworks.All
. This leads to implementation of the interface IValueObjectConvertable<string>
which contains the method T ToValue()
. The serialization frameworks (like System.Text.Json
) specified by SerializationFrameworks
are starting to prefer the newly implemented methods instead of using the key-member type, which is int
in our example.
[SmartEnum<int>]
[ValueObjectFactory<string>(UseForSerialization = SerializationFrameworks.All)]
public sealed partial class EnumWithFactory
{
public static readonly EnumWithFactory Item1 = new(1);
public static readonly EnumWithFactory Item2 = new(2);
public static ValidationError? Validate(string? value, IFormatProvider? provider, out EnumWithFactory? item)
{
switch (value)
{
case "=1=":
item = Item1;
return null;
...
}
}
public string ToValue()
{
return $"={Key}=";
}
}
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 a member.
[SmartEnum<string>]
public sealed partial class ProductType
{
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
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 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.
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 or route parameter to a Smart Enum, there is model validation as well.
See section Convert from/to non-key type to improve model binding by adding a new factory method that converts a Smart Enum from a
string
.
The parameter binding of Minimal 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 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 (invalid) value SomeValue
, then the ASP.NET Core ModelState
must become 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.
Place the "ValueObjectModelBinderProvider" before default providers, so they don't try to bind value objects.
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, EF Core can convert a Smart Enum (like ProductType
) to and from a primitive type (like string
) when persisting the data and 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, EF Core 7 or EF Core 8 and use the extension method AddValueObjectConverters
to register the value converters for you.
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddValueObjectConverters(validateOnWrite: true);
}
}
You can provide a delegate to adjust the configuration of Smart Enums and Value Objects.
modelBuilder.AddValueObjectConverters(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.
Imagine we need an importer for daily and monthly sales.
The CSV for daily sales has the 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 benefits 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.
[SmartEnum<string>(KeyMemberName = "Name")]
public sealed partial class SalesCsvImporterType
{
// 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; }
// Alternative: 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);
}