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