Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Dapper/SqlMapper.Settings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ public static void SetDefaults()
/// <summary>
/// Indicates whether nulls in data are silently ignored (default) vs actively applied and assigned to members
/// </summary>
/// <remarks>
/// Null-handling contract for custom type handlers when reading: by default a database null never reaches
/// <see cref="ITypeHandler.Parse"/>; the member keeps its default value (this also applies to scalar results,
/// e.g. <c>QuerySingle&lt;T?&gt;</c>). When this setting is <c>true</c>, a null column assigned to a member whose
/// type has a registered handler is passed to the handler as <see cref="DBNull.Value"/>, so the handler can
/// decide what null means (for example an "assigned, but null" wrapper). Handlers must therefore tolerate
/// <see cref="DBNull"/> when this setting is enabled. When writing, a null parameter value is passed to
/// <see cref="ITypeHandler.SetValue"/> as <c>null</c> regardless of this setting.
/// </remarks>
public static bool ApplyNullValues { get; set; }

/// <summary>
Expand Down
15 changes: 15 additions & 0 deletions Dapper/SqlMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>.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
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
<PackageVersion Include="RepoDb.SqlServer" Version="1.13.1" />
<PackageVersion Include="ServiceStack.OrmLite.SqlServer" Version="10.0.6" />
<PackageVersion Include="Snowflake.Data" Version="5.5.0" />
<PackageVersion Include="pengdows.crud.fakeDb" Version="2.0.5" />
<PackageVersion Include="SqlMarshal" Version="0.5.0" />
<PackageVersion Include="SubSonic" Version="3.0.0.4" />
<PackageVersion Include="Susanoo.SqlServer" Version="1.2.4.2" />
Expand Down
1 change: 1 addition & 0 deletions tests/Dapper.Tests/Dapper.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' != 'net481'">
<PackageReference Include="pengdows.crud.fakeDb" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Snowflake.Data" />
</ItemGroup>
Expand Down
97 changes: 97 additions & 0 deletions tests/Dapper.Tests/FakeDbTests.Issue2086.cs
Original file line number Diff line number Diff line change
@@ -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<string, object?> { ["MiddleName"] = DBNull.Value },
});
connection.Open();

var result = connection.QuerySingle<Person>("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<string, object?> { ["MiddleName"] = DBNull.Value },
});
connection.Open();

var result = connection.QuerySingle<Person>("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<string?> MiddleName { get; set; }
}

private struct Settable<T>
{
public bool IsAssigned { get; init; }
public T? Value { get; init; }
}

private sealed class SettableStringHandler : SqlMapper.TypeHandler<Settable<string?>>
{
public int ParseCalls { get; private set; }

public override Settable<string?> Parse(object value)
{
ParseCalls++;
return new() { IsAssigned = true, Value = value is DBNull ? null : (string)value };
}

public override void SetValue(IDbDataParameter parameter, Settable<string?> value) => parameter.Value = value.Value;
}
}
#endif