Fix/prefer typehandlers for enums (#2200)

* Add Settings.PreferTypeHandlersForEnums opt-in flag

When enabled, Dapper checks for a registered TypeHandler for enum types
before falling back to the default integer boxing behavior. This allows
custom enum serialization (e.g. storing enums as strings via TypeHandlers)
to work on both reads and writes.

Patched locations:
- LookupDbType: return DbType.Object with handler when flag is on
- CreateParamInfoGenerator: skip integer boxing when handler exists
- GetSimpleValueDeserializer: route to TypeHandler before Enum.ToObject
- Parse<T>: route to TypeHandler before Enum.ToObject

Default is false — zero behavior change for existing users.

* Patch IL-emitted type deserializer and add tests

- Fix 6th enum-before-handler location in GetTypeDeserializer IL emit,
  preserving Nullable<T> wrapping for nullable enum properties
- Fix em-dash in comment (non-ASCII)
- Add two tests: round-trip write+read via StringEnumHandler, and
  nullable enum with null value

* Update SDK version from 10.0.102 to 8.0.100

* Update global.json

we have fixed the CI glitch separately

---------

Co-authored-by: Marc Gravell <marc.gravell@gmail.com>
This commit is contained in:
Andi
2026-05-16 22:08:03 +02:00
committed by GitHub
parent 512f024667
commit 2a8faab235
5 changed files with 169 additions and 17 deletions
+3 -1
View File
@@ -1 +1,3 @@
#nullable enable
#nullable enable
static Dapper.SqlMapper.Settings.PreferTypeHandlersForEnums.get -> bool
static Dapper.SqlMapper.Settings.PreferTypeHandlersForEnums.set -> void
+8 -1
View File
@@ -65,7 +65,7 @@ namespace Dapper
public static void SetDefaults()
{
CommandTimeout = null;
ApplyNullValues = PadListExpansions = UseIncrementalPseudoPositionalParameterNames = false;
ApplyNullValues = PadListExpansions = UseIncrementalPseudoPositionalParameterNames = PreferTypeHandlersForEnums = false;
AllowedCommandBehaviors = DefaultAllowedCommandBehaviors;
FetchSize = InListStringSplitCount = -1;
}
@@ -129,6 +129,13 @@ namespace Dapper
/// </summary>
public static bool SupportLegacyParameterTokens { get; set; } = true;
/// <summary>
/// When true, Dapper checks for a registered TypeHandler for enum types before
/// falling back to the default behavior of sending enums as their underlying integer type.
/// This enables custom enum serialization (e.g. storing enums as strings), while preserving existing behavior.
/// </summary>
public static bool PreferTypeHandlersForEnums { get; set; }
private static long s_FetchSize = -1;
}
}
+45 -14
View File
@@ -465,6 +465,10 @@ namespace Dapper
if (nullUnderlyingType is not null) type = nullUnderlyingType;
if (type.IsEnum && !typeMap.ContainsKey(type))
{
if (Settings.PreferTypeHandlersForEnums && typeHandlers.TryGetValue(type, out handler))
{
return DbType.Object;
}
type = Enum.GetUnderlyingType(type);
}
if (typeMap.TryGetValue(type, out var mapEntry))
@@ -2755,7 +2759,12 @@ namespace Dapper
if ((nullType ?? propType).IsEnum)
{
if (nullType is not null)
if (handler is not null)
{
// TypeHandler registered - box as the enum type, handler does conversion
checkForNull = nullType is not null;
}
else if (nullType is not null)
{
// Nullable<SomeEnum>; we want to box as the underlying type; that's just *hard*; for
// simplicity, box as Nullable<SomeEnum> and call SanitizeParameterValue
@@ -3101,7 +3110,16 @@ namespace Dapper
#pragma warning restore 618
if (effectiveType.IsEnum)
{ // assume the value is returned as the correct type (int/byte/etc), but box back to the typed enum
{
if (Settings.PreferTypeHandlersForEnums && typeHandlers.TryGetValue(type, out var enumHandler))
{
return r =>
{
var val = r.GetValue(index);
return val is DBNull ? null! : enumHandler.Parse(type, val)!;
};
}
// assume the value is returned as the correct type (int/byte/etc), but box back to the typed enum
return r =>
{
var val = r.GetValue(index);
@@ -3164,6 +3182,10 @@ namespace Dapper
type = Nullable.GetUnderlyingType(type) ?? type;
if (type.IsEnum)
{
if (Settings.PreferTypeHandlersForEnums && typeHandlers.TryGetValue(type, out ITypeHandler? enumHandler))
{
return (T)enumHandler.Parse(type, value)!;
}
if (value is float || value is double || value is decimal)
{
value = Convert.ChangeType(value, Enum.GetUnderlyingType(type), CultureInfo.InvariantCulture);
@@ -3742,22 +3764,31 @@ namespace Dapper
if (unboxType.IsEnum)
{
Type numericType = Enum.GetUnderlyingType(unboxType);
if (colType == typeof(string))
if (Settings.PreferTypeHandlersForEnums && typeHandlers.ContainsKey(unboxType))
{
stringEnumLocal ??= il.DeclareLocal(typeof(string));
il.Emit(OpCodes.Castclass, typeof(string)); // stack is now [...][string]
il.Emit(OpCodes.Stloc, stringEnumLocal); // stack is now [...]
il.Emit(OpCodes.Ldtoken, unboxType); // stack is now [...][enum-type-token]
il.EmitCall(OpCodes.Call, typeof(Type).GetMethod(nameof(Type.GetTypeFromHandle))!, null);// stack is now [...][enum-type]
il.Emit(OpCodes.Ldloc, stringEnumLocal); // stack is now [...][enum-type][string]
il.Emit(OpCodes.Ldc_I4_1); // stack is now [...][enum-type][string][true]
il.EmitCall(OpCodes.Call, enumParse, null); // stack is now [...][enum-as-object]
il.Emit(OpCodes.Unbox_Any, unboxType); // stack is now [...][typed-value]
#pragma warning disable 618
il.EmitCall(OpCodes.Call, typeof(TypeHandlerCache<>).MakeGenericType(unboxType).GetMethod(nameof(TypeHandlerCache<int>.Parse))!, null); // stack is now [...][typed-value]
#pragma warning restore 618
}
else
{
FlexibleConvertBoxedFromHeadOfStack(il, colType, unboxType, numericType);
Type numericType = Enum.GetUnderlyingType(unboxType);
if (colType == typeof(string))
{
stringEnumLocal ??= il.DeclareLocal(typeof(string));
il.Emit(OpCodes.Castclass, typeof(string)); // stack is now [...][string]
il.Emit(OpCodes.Stloc, stringEnumLocal); // stack is now [...]
il.Emit(OpCodes.Ldtoken, unboxType); // stack is now [...][enum-type-token]
il.EmitCall(OpCodes.Call, typeof(Type).GetMethod(nameof(Type.GetTypeFromHandle))!, null);// stack is now [...][enum-type]
il.Emit(OpCodes.Ldloc, stringEnumLocal); // stack is now [...][enum-type][string]
il.Emit(OpCodes.Ldc_I4_1); // stack is now [...][enum-type][string][true]
il.EmitCall(OpCodes.Call, enumParse, null); // stack is now [...][enum-as-object]
il.Emit(OpCodes.Unbox_Any, unboxType); // stack is now [...][typed-value]
}
else
{
FlexibleConvertBoxedFromHeadOfStack(il, colType, unboxType, numericType);
}
}
if (nullUnderlyingType is not null)
+1 -1
View File
@@ -3,4 +3,4 @@
"version": "10.0.102",
"rollForward": "latestMajor"
}
}
}
@@ -0,0 +1,112 @@
using System;
using System.Data;
using System.Linq;
using Xunit;
namespace Dapper.Tests
{
[Collection(NonParallelDefinition.Name)]
public sealed class SystemSqlClientPreferTypeHandlersForEnumsTests : PreferTypeHandlersForEnumsTests<SystemSqlClientProvider> { }
#if MSSQLCLIENT
[Collection(NonParallelDefinition.Name)]
public sealed class MicrosoftSqlClientPreferTypeHandlersForEnumsTests : PreferTypeHandlersForEnumsTests<MicrosoftSqlClientProvider> { }
#endif
public abstract class PreferTypeHandlersForEnumsTests<TProvider> : TestBase<TProvider> where TProvider : DatabaseProvider
{
private enum Color
{
Red = 1,
Green = 2,
Blue = 3
}
private class ColorResult
{
public Color Value { get; set; }
}
private class NullableColorResult
{
public Color? Value { get; set; }
}
/// <summary>
/// A TypeHandler that stores enum values as their name strings
/// and parses them back from strings.
/// </summary>
private class StringEnumHandler<TEnum> : SqlMapper.TypeHandler<TEnum> where TEnum : struct, Enum
{
public static readonly StringEnumHandler<TEnum> Instance = new();
public int ParseCallCount;
public int SetValueCallCount;
public override TEnum Parse(object? value)
{
ParseCallCount++;
return (TEnum)Enum.Parse(typeof(TEnum), (string)value!);
}
public override void SetValue(IDbDataParameter parameter, TEnum value)
{
SetValueCallCount++;
parameter.DbType = DbType.AnsiString;
parameter.Value = value.ToString();
}
}
[Fact]
public void EnumTypeHandler_WriteAndRead_UsesHandlerWhenEnabled()
{
var handler = new StringEnumHandler<Color>();
var oldSetting = SqlMapper.Settings.PreferTypeHandlersForEnums;
try
{
SqlMapper.ResetTypeHandlers();
SqlMapper.AddTypeHandler(typeof(Color), handler);
SqlMapper.Settings.PreferTypeHandlersForEnums = true;
SqlMapper.PurgeQueryCache();
// Round-trip: write as string, read back via handler
var result = connection.Query<ColorResult>(
"SELECT @Value AS Value", new { Value = Color.Green }).Single();
Assert.Equal(Color.Green, result.Value);
Assert.True(handler.SetValueCallCount > 0, "SetValue should have been called");
Assert.True(handler.ParseCallCount > 0, "Parse should have been called");
}
finally
{
SqlMapper.Settings.PreferTypeHandlersForEnums = oldSetting;
SqlMapper.ResetTypeHandlers();
SqlMapper.PurgeQueryCache();
}
}
[Fact]
public void EnumTypeHandler_NullableWithNull_ReturnsNull()
{
var handler = new StringEnumHandler<Color>();
var oldSetting = SqlMapper.Settings.PreferTypeHandlersForEnums;
try
{
SqlMapper.ResetTypeHandlers();
SqlMapper.AddTypeHandler(typeof(Color), handler);
SqlMapper.Settings.PreferTypeHandlersForEnums = true;
SqlMapper.PurgeQueryCache();
Color? input = null;
var result = connection.Query<NullableColorResult>(
"SELECT @Value AS Value", new { Value = input }).Single();
Assert.Null(result.Value);
}
finally
{
SqlMapper.Settings.PreferTypeHandlersForEnums = oldSetting;
SqlMapper.ResetTypeHandlers();
SqlMapper.PurgeQueryCache();
}
}
}
}