diff --git a/Dapper/SqlMapper.Settings.cs b/Dapper/SqlMapper.Settings.cs
index c19835c11..84468cba1 100644
--- a/Dapper/SqlMapper.Settings.cs
+++ b/Dapper/SqlMapper.Settings.cs
@@ -78,6 +78,15 @@ public static void SetDefaults()
///
/// Indicates whether nulls in data are silently ignored (default) vs actively applied and assigned to members
///
+ ///
+ /// Null-handling contract for custom type handlers when reading: by default a database null never reaches
+ /// ; the member keeps its default value (this also applies to scalar results,
+ /// e.g. QuerySingle<T?>). When this setting is true, a null column assigned to a member whose
+ /// type has a registered handler is passed to the handler as , so the handler can
+ /// decide what null means (for example an "assigned, but null" wrapper). Handlers must therefore tolerate
+ /// when this setting is enabled. When writing, a null parameter value is passed to
+ /// as null regardless of this setting.
+ ///
public static bool ApplyNullValues { get; set; }
///
diff --git a/Dapper/SqlMapper.cs b/Dapper/SqlMapper.cs
index 8f3956cab..27c6fa113 100644
--- a/Dapper/SqlMapper.cs
+++ b/Dapper/SqlMapper.cs
@@ -3648,6 +3648,21 @@ private static void GenerateDeserializerFromMap(Type type, DbDataReader reader,
{
LoadDefaultValue(il, item.MemberType);
}
+ else if (applyNullSetting && typeHandlers.ContainsKey(memberType))
+ {
+ il.Emit(OpCodes.Ldsfld, typeof(DBNull).GetField(nameof(DBNull.Value))!);
+#pragma warning disable 618
+ il.EmitCall(OpCodes.Call, typeof(TypeHandlerCache<>).MakeGenericType(memberType).GetMethod(nameof(TypeHandlerCache.Parse))!, null);
+#pragma warning restore 618
+ if (item.Property is not null)
+ {
+ il.Emit(type.IsValueType ? OpCodes.Call : OpCodes.Callvirt, DefaultTypeMap.GetPropertySetterOrThrow(item.Property, type));
+ }
+ else
+ {
+ il.Emit(OpCodes.Stfld, item.Field!);
+ }
+ }
else if (applyNullSetting && (!memberType.IsValueType || Nullable.GetUnderlyingType(memberType) is not null))
{
// can load a null with this value
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 2f91a3cca..b1e42ba41 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -38,6 +38,7 @@
+
diff --git a/tests/Dapper.Tests/Dapper.Tests.csproj b/tests/Dapper.Tests/Dapper.Tests.csproj
index 93b498618..e9eab6b88 100644
--- a/tests/Dapper.Tests/Dapper.Tests.csproj
+++ b/tests/Dapper.Tests/Dapper.Tests.csproj
@@ -32,6 +32,7 @@
+
diff --git a/tests/Dapper.Tests/FakeDbTests.Issue2086.cs b/tests/Dapper.Tests/FakeDbTests.Issue2086.cs
new file mode 100644
index 000000000..c92e8184a
--- /dev/null
+++ b/tests/Dapper.Tests/FakeDbTests.Issue2086.cs
@@ -0,0 +1,97 @@
+#if !NET481
+using System;
+using System.Collections.Generic;
+using System.Data;
+using pengdows.crud.fakeDb;
+using Xunit;
+
+namespace Dapper.Tests;
+
+public sealed class FakeDbIssue2086Tests
+{
+ [Fact]
+ public void ApplyNullValuesInvokesHandlerForValueTypeMembers()
+ {
+ var oldSetting = SqlMapper.Settings.ApplyNullValues;
+ SqlMapper.Settings.ApplyNullValues = true;
+ SqlMapper.ResetTypeHandlers();
+ SqlMapper.PurgeQueryCache(); // deserializers bake in ApplyNullValues
+ SqlMapper.AddTypeHandler(new SettableStringHandler());
+ try
+ {
+ using var connection = new fakeDbConnection(new FakeDataStore());
+ connection.EnqueueReaderResult(new[]
+ {
+ new Dictionary { ["MiddleName"] = DBNull.Value },
+ });
+ connection.Open();
+
+ var result = connection.QuerySingle("select MiddleName");
+
+ Assert.True(result.MiddleName.IsAssigned);
+ Assert.Null(result.MiddleName.Value);
+ }
+ finally
+ {
+ SqlMapper.Settings.ApplyNullValues = oldSetting;
+ SqlMapper.ResetTypeHandlers();
+ SqlMapper.PurgeQueryCache();
+ }
+ }
+
+ [Fact]
+ public void DefaultSettingDoesNotPassDbNullToHandler()
+ {
+ var oldSetting = SqlMapper.Settings.ApplyNullValues;
+ SqlMapper.Settings.ApplyNullValues = false;
+ SqlMapper.ResetTypeHandlers();
+ SqlMapper.PurgeQueryCache(); // deserializers bake in ApplyNullValues
+ var handler = new SettableStringHandler();
+ SqlMapper.AddTypeHandler(handler);
+ try
+ {
+ using var connection = new fakeDbConnection(new FakeDataStore());
+ connection.EnqueueReaderResult(new[]
+ {
+ new Dictionary { ["MiddleName"] = DBNull.Value },
+ });
+ connection.Open();
+
+ var result = connection.QuerySingle("select MiddleName");
+
+ Assert.False(result.MiddleName.IsAssigned);
+ Assert.Equal(0, handler.ParseCalls);
+ }
+ finally
+ {
+ SqlMapper.Settings.ApplyNullValues = oldSetting;
+ SqlMapper.ResetTypeHandlers();
+ SqlMapper.PurgeQueryCache();
+ }
+ }
+
+ private sealed class Person
+ {
+ public Settable MiddleName { get; set; }
+ }
+
+ private struct Settable
+ {
+ public bool IsAssigned { get; init; }
+ public T? Value { get; init; }
+ }
+
+ private sealed class SettableStringHandler : SqlMapper.TypeHandler>
+ {
+ public int ParseCalls { get; private set; }
+
+ public override Settable Parse(object value)
+ {
+ ParseCalls++;
+ return new() { IsAssigned = true, Value = value is DBNull ? null : (string)value };
+ }
+
+ public override void SetValue(IDbDataParameter parameter, Settable value) => parameter.Value = value.Value;
+ }
+}
+#endif