From 4d655ba645859654d10d33d8a7ee3a76e8e47b1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Bl=C3=BCher?= Date: Mon, 23 Mar 2026 17:21:49 +0100 Subject: [PATCH 1/2] Add Settings.PreferTypeHandlersForEnums opt-in flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: route to TypeHandler before Enum.ToObject Default is false — zero behavior change for existing users. --- Dapper/PublicAPI.Unshipped.txt | 4 +++- Dapper/SqlMapper.Settings.cs | 10 +++++++++- Dapper/SqlMapper.cs | 26 ++++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/Dapper/PublicAPI.Unshipped.txt b/Dapper/PublicAPI.Unshipped.txt index 91b0e1a43..cf9343ccf 100644 --- a/Dapper/PublicAPI.Unshipped.txt +++ b/Dapper/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ -#nullable enable \ No newline at end of file +#nullable enable +static Dapper.SqlMapper.Settings.PreferTypeHandlersForEnums.get -> bool +static Dapper.SqlMapper.Settings.PreferTypeHandlersForEnums.set -> void \ No newline at end of file diff --git a/Dapper/SqlMapper.Settings.cs b/Dapper/SqlMapper.Settings.cs index cbbc3c687..02cd4d7fe 100644 --- a/Dapper/SqlMapper.Settings.cs +++ b/Dapper/SqlMapper.Settings.cs @@ -65,7 +65,7 @@ static Settings() public static void SetDefaults() { CommandTimeout = null; - ApplyNullValues = PadListExpansions = UseIncrementalPseudoPositionalParameterNames = false; + ApplyNullValues = PadListExpansions = UseIncrementalPseudoPositionalParameterNames = PreferTypeHandlersForEnums = false; AllowedCommandBehaviors = DefaultAllowedCommandBehaviors; FetchSize = InListStringSplitCount = -1; } @@ -129,6 +129,14 @@ public static long FetchSize /// public static bool SupportLegacyParameterTokens { get; set; } = true; + /// + /// 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). + /// Default: false (preserves existing behavior). + /// + public static bool PreferTypeHandlersForEnums { get; set; } + private static long s_FetchSize = -1; } } diff --git a/Dapper/SqlMapper.cs b/Dapper/SqlMapper.cs index d23e949a5..838e81e16 100644 --- a/Dapper/SqlMapper.cs +++ b/Dapper/SqlMapper.cs @@ -465,6 +465,10 @@ public static void SetDbType(IDataParameter parameter, object value) 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)) @@ -2751,7 +2755,12 @@ private static bool IsValueTuple(Type? type) => (type?.IsValueType == true 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; we want to box as the underlying type; that's just *hard*; for // simplicity, box as Nullable and call SanitizeParameterValue @@ -3097,7 +3106,16 @@ private static Func GetSimpleValueDeserializer(Type type, #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); @@ -3160,6 +3178,10 @@ private static T Parse(object? value) 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); From 5dd6041fb71f7a26cc6e1f45b5ab19ac61461c7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Bl=C3=BCher?= Date: Mon, 23 Mar 2026 17:47:38 +0100 Subject: [PATCH 2/2] Patch IL-emitted type deserializer and add tests - Fix 6th enum-before-handler location in GetTypeDeserializer IL emit, preserving Nullable 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 --- Dapper/SqlMapper.Settings.cs | 3 +- Dapper/SqlMapper.cs | 35 ++++-- .../PreferTypeHandlersForEnumsTests.cs | 112 ++++++++++++++++++ 3 files changed, 135 insertions(+), 15 deletions(-) create mode 100644 tests/Dapper.Tests/PreferTypeHandlersForEnumsTests.cs diff --git a/Dapper/SqlMapper.Settings.cs b/Dapper/SqlMapper.Settings.cs index 02cd4d7fe..c19835c11 100644 --- a/Dapper/SqlMapper.Settings.cs +++ b/Dapper/SqlMapper.Settings.cs @@ -132,8 +132,7 @@ public static long FetchSize /// /// 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). - /// Default: false (preserves existing behavior). + /// This enables custom enum serialization (e.g. storing enums as strings), while preserving existing behavior. /// public static bool PreferTypeHandlersForEnums { get; set; } diff --git a/Dapper/SqlMapper.cs b/Dapper/SqlMapper.cs index 838e81e16..6210e307e 100644 --- a/Dapper/SqlMapper.cs +++ b/Dapper/SqlMapper.cs @@ -2757,7 +2757,7 @@ private static bool IsValueTuple(Type? type) => (type?.IsValueType == true { if (handler is not null) { - // TypeHandler registered — box as the enum type, handler does conversion + // TypeHandler registered - box as the enum type, handler does conversion checkForNull = nullType is not null; } else if (nullType is not null) @@ -3760,22 +3760,31 @@ private static void LoadReaderValueOrBranchToDBNullLabel(ILGenerator il, int ind 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.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) diff --git a/tests/Dapper.Tests/PreferTypeHandlersForEnumsTests.cs b/tests/Dapper.Tests/PreferTypeHandlersForEnumsTests.cs new file mode 100644 index 000000000..958f0d546 --- /dev/null +++ b/tests/Dapper.Tests/PreferTypeHandlersForEnumsTests.cs @@ -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 { } +#if MSSQLCLIENT + [Collection(NonParallelDefinition.Name)] + public sealed class MicrosoftSqlClientPreferTypeHandlersForEnumsTests : PreferTypeHandlersForEnumsTests { } +#endif + + public abstract class PreferTypeHandlersForEnumsTests : TestBase 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; } + } + + /// + /// A TypeHandler that stores enum values as their name strings + /// and parses them back from strings. + /// + private class StringEnumHandler : SqlMapper.TypeHandler where TEnum : struct, Enum + { + public static readonly StringEnumHandler 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(); + 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( + "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(); + 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( + "SELECT @Value AS Value", new { Value = input }).Single(); + + Assert.Null(result.Value); + } + finally + { + SqlMapper.Settings.PreferTypeHandlersForEnums = oldSetting; + SqlMapper.ResetTypeHandlers(); + SqlMapper.PurgeQueryCache(); + } + } + } +}