This is another post in my series on strongly-typed IDs. In the first and second posts, I looked at the reasons for using strongly-typed IDs, and how to add converters to interface nicely with ASP.NET Core. In the previous post I looked at ways of using strongly-typed IDs with EF Core. Unfortunately, there was a significant issue with the approach I outlined: querying by a strongly-typed ID could result in client-side evaluation. The workarounds I proposed only partially fixed the problem.
In this post, I show a workaround that seems to solve the issue. EF Core is not my speciality, so it's possible there's some hidden issues, but from my testing so far it works perfectly! 🤞 The secret-sauce is ValueConverterSelector
.
Strongly-typed IDs in EF Core
As a quick recap, the solution I proposed in the previous post centred around value converters. As their name suggests, these can be used to convert instances of one type (for example a strongly-typed ID like OrderId
) into a type that is supported by a database provider (for example a Guid
or an int
).
In the last post I showed an example implementation of a custom ValueConverter
for an OrderId
that is stored in the database as a Guid
. The version below is slightly modified to be a nested class of the strongly-typed ID OrderId
, which is how we would generate it if using the "snippet" approach from my second post. For this post, we don't need the [StronglyTypedIdEfValueConverter]
attribute I previously described.
public readonly struct OrderId
{
// Not shown: the OrderId implementation and other converters
public class StronglyTypedIdEfValueConverter : ValueConverter<OrderId, Guid>
{
public StronglyTypedIdEfValueConverter(ConverterMappingHints mappingHints = null)
: base(id => id.Value, value => new OrderId(value), mappingHints)
{
}
}
}
You could manually map every use of OrderId
in your EF Core model properties to use this converter as before. But as well as being verbose, this would leave you with the client-side evaluation problem from the last post.
Instead, we're going to look at one of the "internal" services of EF Core - the ValueConverterSelector
. If you're not interested in why the solution works and just want to see the final code, skip ahead.
A semi-deep dive into ValueConverterSelector
- handling built-in conversions
After reaching the conclusion of my last post, I felt like I had hit a brick wall trying to get strongly-typed IDs to work smoothly. There were all sorts of work arounds you could use, but ultimately you were going to get a sub-par experience no matter what.
This got me thinking: EF Core has all sorts of "built-in" value converters that convert between primitive types. These do conversions like Char
to string
, number to byte[]
, or string
to Guid
. Using these value converters doesn't trigger the client-side evaluation problem, and they doesn't require you to register them against each property - they're used automatically.
These converters aren't built into the BCL or anything, so they must be registered somewhere in EF Core. After a bit of searching, I tracked the answer down to the ValueConverterSelector
class and the IValueConverterSelector
interface.
I've reproduced the interface (from version 2.2.4) below, as the xmldocs describe exactly what this type does:
/// <summary>
/// A registry of ValueConverterInfo that can be used to find
/// the preferred converter to use to convert to and from a given model type
/// to a type that the database provider supports.
/// </summary>
public interface IValueConverterSelector
{
/// <summary>
/// Returns the list of ValueConverterInfo instances that can be
/// used to convert the given model type. Converters nearer the front of
/// the list should be used in preference to converters nearer the end.
/// </summary>
IEnumerable<ValueConverterInfo> Select(Type modelClrType, Type providerClrType = null);
}
EF Core uses an implementation of this interface to find the value converters for built-in types. It appears to use these types early in the query generation pipeline, so they don't cause the client-side evaluation issues you see with custom value converters.
The below code is a snippet taken from the Select()
method of the default implementation, ValueConverterSelector
. This method is essentially a giant if
/else
statement that finds all the applicable converters for a given modelClrType
(the type used in your EF Core entities) and providerClrType
(the type stored in the database).
Given the number of built-in converters, this method is big, so I've only shown a snippet of it below:
private readonly ConcurrentDictionary<(Type ModelClrType, Type ProviderClrType), ValueConverterInfo> _converters
= new ConcurrentDictionary<(Type ModelClrType, Type ProviderClrType), ValueConverterInfo>();
public virtual IEnumerable<ValueConverterInfo> Select(Type modelClrType, Type providerClrType = null)
{
// Extract the "real" type T from Nullable<T> if required
var underlyingModelType = modelClrType.UnwrapNullableType();
var underlyingProviderType = providerClrType?.UnwrapNullableType();
// lots of code...
if (underlyingModelType == typeof(Guid))
{
if (underlyingProviderType == null
|| underlyingProviderType == typeof(byte[]))
{
yield return _converters.GetOrAdd(
(underlyingModelType, typeof(byte[])),
k => GuidToBytesConverter.DefaultInfo);
}
if (underlyingProviderType == null
|| underlyingProviderType == typeof(string))
{
yield return _converters.GetOrAdd(
(underlyingModelType, typeof(string)),
k => GuidToStringConverter.DefaultInfo);
}
}
// lots more code...
}
So what is this code doing? First, the method "unwraps" any nullable types - so if the type is a Guid?
, it returns a Guid
and so on. If the type is not nullable, this is a no-op. It's worth noting that the providerClrType
can be null
: null
here means "give me all the value converters for the modelClrType".
After unwrapping the types, we enter the nested if
/else
statements - I've shown the if statement for Guid
above. There are two built-in converters for Guid
: the GuidToBytesConverter
, and the GuidToStringConverter
. If the underlyingProviderType
is null
or the correct type, the method uses yield return
to return a default instance of ValueConverterInfo
.
The implementation uses a
ConcurrentDictionary
to avoid creating multipleValueConverterInfo
objects, keyed on theunderlyingModelType
andunderlyingProviderType
.
The ValueConverterInfo
object is a simple DTO that contains a factory method for creating a ValueConverter
instance:
public readonly struct ValueConverterInfo
{
private readonly Func<ValueConverterInfo, ValueConverter> _factory;
public Type ModelClrType { get; }
public Type ProviderClrType { get; }
public ConverterMappingHints MappingHints { get; }
public ValueConverter Create() => _factory(this);
}
If we look at one of the built-in value converters GuidToStringConverter
for example, we see the DefaultInfo
property that returns a ValueConverterInfo
object:
public class GuidToStringConverter : StringGuidConverter<Guid, string>
{
public GuidToStringConverter(ConverterMappingHints mappingHints = null)
: base(ToString(), ToGuid(), _defaultHints.With(mappingHints))
{ }
public static ValueConverterInfo DefaultInfo { get; }
= new ValueConverterInfo(
typeof(Guid),
typeof(string),
i => new GuidToStringConverter(i.MappingHints),
_defaultHints);
}
I haven't shown the base classes involved, so the code above isn't entirely complete, but the DefaultInfo
property implementation is pretty simple. It creates a new ValueConverterInfo
object, providing the ModelClrType
(Guid
) the ProviderClrType
(string
), a function for creating a new GuidToStringConverter
given the current ValueConverterInfo
instance (i.e. call the constructor), and the default mapping hints to use (for controlling the size of the string
column in the database etc).
That's as far as I went digging into the ValueConverterSelector
. I haven't worked out quite how it fits in to the overall EF Core query translation system (other than it's used in the ITypeMappingSource
implementations), but I know enough now to be dangerous - lets get back to fixing the original problem, strongly-typed IDs.
Creating a custom ValueConverterSelector
for strongly-typed IDs
To recap, we have a number of strongly-typed IDs that are used in our EF Core entities. For each strongly-typed ID we have a nested ValueConverter
implementation. In this section, we're going to create a custom ValueConverterSelector
to automatically register our value converters so they're used in the same way as the built-in value converters.
Luckily, the ValueConverterSelector
implementation isn't sealed, and the Select()
method is even virtual, so we can easily create our own implementation, while preserving the existing behaviour for built-in converters. The following code is the entire StronglyTypedIdValueConverterSelector
- I'll walk through and explain it afterwards.
public class StronglyTypedIdValueConverterSelector : ValueConverterSelector
{
// The dictionary in the base type is private, so we need our own one here.
private readonly ConcurrentDictionary<(Type ModelClrType, Type ProviderClrType), ValueConverterInfo> _converters
= new ConcurrentDictionary<(Type ModelClrType, Type ProviderClrType), ValueConverterInfo>();
public StronglyTypedIdValueConverterSelector(ValueConverterSelectorDependencies dependencies) : base(dependencies)
{ }
public override IEnumerable<ValueConverterInfo> Select(Type modelClrType, Type providerClrType = null)
{
var baseConverters = base.Select(modelClrType, providerClrType);
foreach (var converter in baseConverters)
{
yield return converter;
}
// Extract the "real" type T from Nullable<T> if required
var underlyingModelType = UnwrapNullableType(modelClrType);
var underlyingProviderType = UnwrapNullableType(providerClrType);
// 'null' means 'get any value converters for the modelClrType'
if (underlyingProviderType is null || underlyingProviderType == typeof(Guid))
{
// Try and get a nested class with the expected name.
var converterType = underlyingModelType.GetNestedType("StronglyTypedIdEfValueConverter");
if (converterType != null)
{
yield return _converters.GetOrAdd(
(underlyingModelType, typeof(Guid)),
k =>
{
// Create an instance of the converter whenever it's requested.
Func<ValueConverterInfo, ValueConverter> factory =
info => (ValueConverter) Activator.CreateInstance(converterType, info.MappingHints);
// Build the info for our strongly-typed ID => Guid converter
return new ValueConverterInfo(modelClrType, typeof(Guid), factory);
}
);
}
}
}
private static Type UnwrapNullableType(Type type)
{
if (type is null) { return null; }
return Nullable.GetUnderlyingType(type) ?? type;
}
}
The StronglyTypedIdValueConverterSelector
is written to follow the same patterns as the ValueConverterSelector
it overrides, so I've created a ConcurrentDictionary<>
for tracking the value converters in the same way the base class does. The dictionary in the base class is private
so we have to create a new instance of it here, but that's not a big deal. The constructor passes through the required ValueConverterSelectorDependencies
object to the base class.
The meat of the implementation is in the Select
method. We start by fetching all of the applicable built-in value converters by calling base.Select()
, and yield return
on all of the returned implementations. That preserves existing behaviour.
Next, we have to "unwrap" nullable types, just as the base class did. We call the simple static UnwrapNullableType()
method defined at the end of the class. If the provider type is either null
or Guid
, then we try and create a converter, otherwise we're done.
When testing the converter, I found that the method was only ever called with
providerClrType=null
. That's likely due to something specific about my models, I just thought I'd point it out.
Assuming the if()
branch returns true
, we now need to see if the modelClrType
is a strongly-typed ID type with a value converter implementation. This is where the change to the value converter implementation at the start of this post makes sense:
public readonly struct OrderId
{
public class StronglyTypedIdEfValueConverter : ValueConverter<OrderId, Guid> { }
}
By creating the value converter as a nested class, and using the same name across all strongly-typed ID types (StronglyTypedIdEfValueConverter
), we can both fetch the converter type and test for a strongly-typed ID at the same time with a small bit of reflection:
var converterType = underlyingModelType.GetNestedType("StronglyTypedIdEfValueConverter");
if (converterType != null)
{
// we have a Type for the converter
}
At this point we know we have a value converter for the modelClrType
, so we need to create the correct ValueConverterInfo
and yield return
it. The base class simplifies this code by using a static DefaultInfo
property, but we'd have to invoke a similar method using reflection, and it all gets a bit more hassle than it's worth. Instead, I opted for creating a factory function that creates an instance of the converter by calling Activator.CreateInstance()
, passing in the required ConverterMappingHints
argument:
Func<ValueConverterInfo, ValueConverter> factory =
info => (ValueConverter) Activator.CreateInstance(converterType, info.MappingHints);
Don't be fooled by the fact that the
StronglyTypedIdEfValueConverter
mappingHints
parameter has a default value ofnull
. Even though you don't need to provide this value when invoking the constructor normally, you must provide it when invoking the method using reflection (andActivator.CreateInstance()
)
Finally, we can create an instance of ValueConverterInfo
, add it to the dictionary, and yield return
it.
This implementation looks a bit complicated because of the reflection required, but I'm pretty confident there's nothing untoward going on there. All that remains is for us to replace the default instance of IValueConverterSelector
with our custom class.
Replacing the default IValueConverterSelector
with a custom implementation
Replacing "framework" EF Core services is relatively painless thanks to the ReplaceService
method exposed by DbContextOptionsBuilder
. You can call this method as part of your EF Core configuration in Startup.ConfigureServices
, in the AddDbContext<>
configuration method:
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<ApplicationDbContext>(options =>
options
.ReplaceService<IValueConverterSelector, StronglyTypedIdEfValueConverter>() // add this line
.UseSqlServer(
Configuration.GetConnectionString("DefaultConnection")));
}
That's it.
No more custom DbContext.OnModelCreating
code.
No marker attributes.
No more implicit/explicit conversions to force use of the value converter.
And most importantly, no more client-side evaluation.
I'm actually kind of surprised by how well it works, but it all does seem to work. Even the following code (which was broken in the implementation from my last post), works:
public Order GetOrder(OrderId orderId)
{
return _dbContext.Orders
.Where(order => order.Id == orderId)
.FirstOrDefault();
}
public Order GetOrderUsingFind(OrderId orderId)
{
return _dbContext.Orders
.Find(orderId);
}
Both of these usages generate the same SQL, which has a server-side where
clause:
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (6ms) [Parameters=[@__get_Item_0='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30']
SELECT TOP(1) [e].[OrderId], [e].[Name]
FROM [Orders] AS [e]
WHERE [e].[OrderId] = @__get_Item_0
Success! One more reason to use strongly-typed IDs in your next ASP.NET Core app 😃.
Summary
In this post I describe a solution to using strongly-typed IDs in your EF Core entities by using value converters and a custom IValueConverterSelector
. The base ValueConverterSelector
in the EF Core framework is used to register all built-in value conversions between primitive types. By deriving from this class, we can add our strongly-typed ID converters to this list, and get seamless conversion throughout our EF Core queries. As well as reducing the configuration required, this solves the client-side evaluation problem that plagued the previous implementation.