Skip to content

Commit b828bb7

Browse files
committed
Add TypeHandlerFactory for open-generic type handler registration
1 parent 72a54c4 commit b828bb7

5 files changed

Lines changed: 284 additions & 7 deletions

File tree

Dapper/PublicAPI.Unshipped.txt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
#nullable enable
22
static Dapper.SqlMapper.Settings.PreferTypeHandlersForEnums.get -> bool
3-
static Dapper.SqlMapper.Settings.PreferTypeHandlersForEnums.set -> void
3+
static Dapper.SqlMapper.Settings.PreferTypeHandlersForEnums.set -> void
4+
static Dapper.SqlMapper.AddTypeHandlerFactory(Dapper.SqlMapper.TypeHandlerFactory! factory) -> void
5+
abstract Dapper.SqlMapper.TypeHandlerFactory.CanHandle(System.Type! type) -> bool
6+
abstract Dapper.SqlMapper.TypeHandlerFactory.Create(System.Type! type) -> Dapper.SqlMapper.ITypeHandler!
7+
Dapper.SqlMapper.TypeHandlerFactory
8+
Dapper.SqlMapper.TypeHandlerFactory.TypeHandlerFactory() -> void
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using System;
2+
3+
namespace Dapper
4+
{
5+
public static partial class SqlMapper
6+
{
7+
/// <summary>
8+
/// Creates <see cref="ITypeHandler"/> instances on demand for types it claims via
9+
/// <see cref="CanHandle"/>.
10+
/// </summary>
11+
/// <remarks>
12+
/// Register a factory with <see cref="AddTypeHandlerFactory"/>. When Dapper needs a handler for a
13+
/// type and no direct handler has been registered, it queries each factory in registration order.
14+
/// The first factory whose <see cref="CanHandle"/> returns <see langword="true"/> is asked to
15+
/// <see cref="Create"/> the handler. The created handler is then cached in
16+
/// <see cref="typeHandlers"/> (and in <see cref="TypeHandlerCache{T}"/> for IL-emitted paths),
17+
/// so the factory is only consulted once per type.
18+
/// </remarks>
19+
public abstract class TypeHandlerFactory
20+
{
21+
/// <summary>
22+
/// Returns <see langword="true"/> if this factory can provide a handler for
23+
/// <paramref name="type"/>.
24+
/// </summary>
25+
public abstract bool CanHandle(Type type);
26+
27+
/// <summary>
28+
/// Creates an <see cref="ITypeHandler"/> for <paramref name="type"/>.
29+
/// Only called after <see cref="CanHandle"/> returned <see langword="true"/> for the same type.
30+
/// </summary>
31+
public abstract ITypeHandler Create(Type type);
32+
}
33+
}
34+
}

Dapper/SqlMapper.cs

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ private static void ResetTypeHandlers(bool clone)
269269
lock (typeHandlersSyncLock)
270270
{
271271
typeHandlers = [];
272+
typeHandlerFactories = [];
272273
AddTypeHandlerCore(typeof(DataTable), new DataTableHandler(), clone);
273274
AddTypeHandlerCore(typeof(XmlDocument), new XmlDocumentHandler(), clone);
274275
AddTypeHandlerCore(typeof(XDocument), new XDocumentHandler(), clone);
@@ -337,6 +338,26 @@ public static void RemoveTypeMap(Type type)
337338
SetTypeMap(newCopy);
338339
}
339340

341+
/// <summary>
342+
/// Register a <see cref="TypeHandlerFactory"/> that can create handlers on demand for types it claims
343+
/// via <see cref="TypeHandlerFactory.CanHandle"/>. Factories are queried in registration order when
344+
/// no direct handler is found for a type.
345+
/// </summary>
346+
/// <param name="factory">The factory to register.</param>
347+
public static void AddTypeHandlerFactory(TypeHandlerFactory factory)
348+
{
349+
if (factory is null) throw new ArgumentNullException(nameof(factory));
350+
lock (typeHandlersSyncLock)
351+
{
352+
// Snapshot/mutate/swap keeps reads outside the lock lock-free.
353+
var prev = typeHandlerFactories;
354+
var next = new TypeHandlerFactory[prev.Length + 1];
355+
prev.CopyTo(next, 0);
356+
next[prev.Length] = factory;
357+
typeHandlerFactories = next;
358+
}
359+
}
360+
340361
/// <summary>
341362
/// Configure the specified type to be processed by a custom handler.
342363
/// </summary>
@@ -348,7 +369,17 @@ public static void RemoveTypeMap(Type type)
348369
/// </summary>
349370
/// <param name="type">The type to handle.</param>
350371
/// <returns>Boolean value specifying whether the type will be processed by a custom handler.</returns>
351-
public static bool HasTypeHandler(Type type) => typeHandlers.ContainsKey(type);
372+
public static bool HasTypeHandler(Type type)
373+
{
374+
if (typeHandlers.ContainsKey(type)) return true;
375+
// Also check registered factories; a type has a handler if any factory claims it.
376+
var factories = typeHandlerFactories; // snapshot for thread safety
377+
foreach (var factory in factories)
378+
{
379+
if (factory.CanHandle(type)) return true;
380+
}
381+
return false;
382+
}
352383

353384
/// <summary>
354385
/// Configure the specified type to be processed by a custom handler.
@@ -423,8 +454,37 @@ private static void AddTypeHandlerCore(Type type, ITypeHandler? handler, bool cl
423454
public static void AddTypeHandler<T>(TypeHandler<T> handler) => AddTypeHandlerCore(typeof(T), handler, true);
424455

425456
private static Dictionary<Type, ITypeHandler> typeHandlers;
457+
// Registered factories are queried (in order) when no direct entry is found in typeHandlers.
458+
// Uses a plain array with snapshot/swap so readers outside the lock never block.
459+
private static TypeHandlerFactory[] typeHandlerFactories = [];
426460
private static readonly object typeHandlersSyncLock = new();
427461

462+
/// <summary>
463+
/// Looks up a registered type handler, falling back to registered <see cref="TypeHandlerFactory"/>
464+
/// instances when no direct entry is found. On a factory hit the concrete handler is instantiated,
465+
/// registered in <see cref="typeHandlers"/> and in <see cref="TypeHandlerCache{T}"/> (used by
466+
/// IL-emitted code), so subsequent lookups for the same type are O(1) dictionary reads.
467+
/// </summary>
468+
private static bool TryGetTypeHandler(Type type, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ITypeHandler? handler)
469+
{
470+
if (typeHandlers.TryGetValue(type, out handler)) return true;
471+
472+
var factories = typeHandlerFactories; // snapshot for thread safety
473+
foreach (var factory in factories)
474+
{
475+
if (factory.CanHandle(type))
476+
{
477+
handler = factory.Create(type);
478+
// Cache so TypeHandlerCache<T> (used by IL-emitted code paths) is populated
479+
// and future lookups bypass factory iteration entirely.
480+
AddTypeHandlerCore(type, handler, true);
481+
return true;
482+
}
483+
}
484+
485+
return false;
486+
}
487+
428488
internal const string LinqBinary = "System.Data.Linq.Binary";
429489

430490
private const string ObsoleteInternalUsageOnly = "This method is for internal use only";
@@ -483,7 +543,7 @@ public static void SetDbType(IDataParameter parameter, object value)
483543
{
484544
return DbType.Binary;
485545
}
486-
if (typeHandlers.TryGetValue(type, out handler))
546+
if (TryGetTypeHandler(type, out handler))
487547
{
488548
return DbType.Object;
489549
}
@@ -1971,7 +2031,7 @@ private static Func<DbDataReader, object> GetDeserializer(Type type, DbDataReade
19712031
else if (!(type.IsEnum || type.IsArray || type.FullName == LinqBinary
19722032
|| (type.IsValueType && (underlyingType = Nullable.GetUnderlyingType(type)) is not null && underlyingType.IsEnum)))
19732033
{
1974-
if (typeHandlers.TryGetValue(type, out ITypeHandler? handler))
2034+
if (TryGetTypeHandler(type, out ITypeHandler? handler))
19752035
{
19762036
return GetHandlerDeserializer(handler, type, startBound);
19772037
}
@@ -3130,7 +3190,7 @@ private static Func<DbDataReader, object> GetSimpleValueDeserializer(Type type,
31303190
return val is DBNull ? null! : Enum.ToObject(effectiveType, val);
31313191
};
31323192
}
3133-
if (typeHandlers.TryGetValue(type, out var handler))
3193+
if (TryGetTypeHandler(type, out var handler))
31343194
{
31353195
return r =>
31363196
{
@@ -3192,7 +3252,7 @@ private static T Parse<T>(object? value)
31923252
}
31933253
return (T)Enum.ToObject(type, value);
31943254
}
3195-
if (typeHandlers.TryGetValue(type, out ITypeHandler? handler))
3255+
if (TryGetTypeHandler(type, out ITypeHandler? handler))
31963256
{
31973257
return (T)handler.Parse(type, value)!;
31983258
}
@@ -3805,7 +3865,7 @@ private static void LoadReaderValueOrBranchToDBNullLabel(ILGenerator il, int ind
38053865
{
38063866
TypeCode dataTypeCode = Type.GetTypeCode(colType), unboxTypeCode = Type.GetTypeCode(unboxType);
38073867
bool hasTypeHandler;
3808-
if ((hasTypeHandler = typeHandlers.ContainsKey(unboxType)) || colType == unboxType || dataTypeCode == unboxTypeCode || dataTypeCode == Type.GetTypeCode(nullUnderlyingType))
3868+
if ((hasTypeHandler = TryGetTypeHandler(unboxType, out _)) || colType == unboxType || dataTypeCode == unboxTypeCode || dataTypeCode == Type.GetTypeCode(nullUnderlyingType))
38093869
{
38103870
if (hasTypeHandler)
38113871
{

tests/Dapper.Tests/Providers/SqliteTests.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Microsoft.Data.Sqlite;
22
using System;
3+
using System.Collections.Generic;
34
using System.Data.Common;
45
using System.Linq;
56
using System.Threading;
@@ -82,6 +83,47 @@ public async Task Issue466_SqliteHatesOptimizations_Async()
8283
row = await connection.QueryFirstAsync<HazNameId>("select 42 as Id").ConfigureAwait(false);
8384
Assert.Equal(42, row.Id);
8485
}
86+
87+
[FactSqlite]
88+
public void TypeHandlerFactory_CanParse_Sqlite()
89+
{
90+
using var connection = GetSQLiteConnection();
91+
SqlMapper.ResetTypeHandlers();
92+
SqlMapper.AddTypeHandlerFactory(new ListHandlerFactory());
93+
try
94+
{
95+
Assert.True(SqlMapper.HasTypeHandler(typeof(List<int>)));
96+
Assert.True(SqlMapper.HasTypeHandler(typeof(List<string>)));
97+
98+
var row = connection.QuerySingle<ResultWithLists>(
99+
"SELECT '1|2|3' AS Ids, 'a|b|c' AS Names");
100+
101+
Assert.Equal([1, 2, 3], row.Ids);
102+
Assert.Equal(["a", "b", "c"], row.Names);
103+
}
104+
finally
105+
{
106+
SqlMapper.ResetTypeHandlers();
107+
}
108+
}
109+
110+
[FactSqlite]
111+
public void TypeHandlerFactory_CanSetValue_Sqlite()
112+
{
113+
using var connection = GetSQLiteConnection();
114+
SqlMapper.ResetTypeHandlers();
115+
SqlMapper.AddTypeHandlerFactory(new ListHandlerFactory());
116+
try
117+
{
118+
var ids = new List<int> { 10, 20, 30 };
119+
var result = connection.ExecuteScalar<string>("SELECT @Ids", new { Ids = ids });
120+
Assert.Equal("10|20|30", result);
121+
}
122+
finally
123+
{
124+
SqlMapper.ResetTypeHandlers();
125+
}
126+
}
85127
}
86128

87129
public class SqliteTests : SqliteTypeTestBase

tests/Dapper.Tests/TypeHandlerTests.cs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,5 +875,141 @@ public bool Equals(Issue1959_Raw other)
875875
=> other.Value == Value;
876876
public override string ToString() => Value.ToString();
877877
}
878+
879+
880+
881+
[Fact]
882+
public void TypeHandlerFactory_CanParse()
883+
{
884+
SqlMapper.ResetTypeHandlers();
885+
SqlMapper.AddTypeHandlerFactory(new ListHandlerFactory());
886+
try
887+
{
888+
// Verify the factory registration is reflected by HasTypeHandler for matching types.
889+
Assert.True(SqlMapper.HasTypeHandler(typeof(List<int>)));
890+
Assert.True(SqlMapper.HasTypeHandler(typeof(List<string>)));
891+
// Non-matching types should not be claimed.
892+
Assert.False(SqlMapper.HasTypeHandler(typeof(HashSet<int>)));
893+
894+
// Verify that Dapper can deserialize query results into List<int> and List<string>
895+
// using the handler created by the factory.
896+
var row = connection.QuerySingle<ResultWithLists>(
897+
"SELECT '1|2|3' AS Ids, 'a|b|c' AS Names");
898+
899+
Assert.Equal([1, 2, 3], row.Ids);
900+
Assert.Equal(["a", "b", "c"], row.Names);
901+
}
902+
finally
903+
{
904+
SqlMapper.ResetTypeHandlers();
905+
}
906+
}
907+
908+
[Fact]
909+
public void TypeHandlerFactory_CanSetValue()
910+
{
911+
SqlMapper.ResetTypeHandlers();
912+
SqlMapper.AddTypeHandlerFactory(new ListHandlerFactory());
913+
try
914+
{
915+
// Verify that Dapper serializes a List<int> parameter via the factory-created handler.
916+
var ids = new List<int> { 10, 20, 30 };
917+
var result = connection.ExecuteScalar<string>(
918+
"SELECT @Ids", new { Ids = ids });
919+
920+
Assert.Equal("10|20|30", result);
921+
}
922+
finally
923+
{
924+
SqlMapper.ResetTypeHandlers();
925+
}
926+
}
927+
928+
[Fact]
929+
public void TypeHandlerFactory_ResetClearsRegistration()
930+
{
931+
SqlMapper.ResetTypeHandlers();
932+
SqlMapper.AddTypeHandlerFactory(new ListHandlerFactory());
933+
Assert.True(SqlMapper.HasTypeHandler(typeof(List<int>)));
934+
935+
SqlMapper.ResetTypeHandlers();
936+
937+
// After a reset the factory registration should be gone and the
938+
// handler should no longer be resolved.
939+
Assert.False(SqlMapper.HasTypeHandler(typeof(List<int>)));
940+
}
941+
942+
[Fact]
943+
public void TypeHandlerFactory_NullThrows()
944+
{
945+
Assert.Throws<ArgumentNullException>(() =>
946+
SqlMapper.AddTypeHandlerFactory(null!));
947+
}
948+
949+
[Fact]
950+
public void TypeHandlerFactory_FirstRegisteredWins()
951+
{
952+
SqlMapper.ResetTypeHandlers();
953+
// Register two factories that both claim List<int>; the first one registered should win.
954+
var factory1 = new ListHandlerFactory();
955+
var factory2 = new ListHandlerFactory();
956+
SqlMapper.AddTypeHandlerFactory(factory1);
957+
SqlMapper.AddTypeHandlerFactory(factory2);
958+
959+
try
960+
{
961+
Assert.True(SqlMapper.HasTypeHandler(typeof(List<int>)));
962+
Assert.True(factory1.CanHandleCalls.Contains(typeof(List<int>)));
963+
Assert.False(factory2.CanHandleCalls.Contains(typeof(List<int>)));
964+
}
965+
finally
966+
{
967+
SqlMapper.ResetTypeHandlers();
968+
}
969+
}
970+
}
971+
972+
// A TypeHandlerFactory that creates ListHandler<T> for any List<T> type
973+
public class ListHandlerFactory : SqlMapper.TypeHandlerFactory
974+
{
975+
public HashSet<Type> CanHandleCalls { get; } = new();
976+
977+
public override bool CanHandle(Type type)
978+
{
979+
CanHandleCalls.Add(type);
980+
return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>);
981+
}
982+
983+
public override SqlMapper.ITypeHandler Create(Type type)
984+
{
985+
var elementType = type.GetGenericArguments()[0];
986+
var handlerType = typeof(ListHandler<>).MakeGenericType(elementType);
987+
return (SqlMapper.ITypeHandler)Activator.CreateInstance(handlerType)!;
988+
}
989+
990+
public class ListHandler<T> : SqlMapper.TypeHandler<List<T>>
991+
{
992+
public override void SetValue(IDbDataParameter parameter, List<T>? value)
993+
{
994+
parameter.Value = value is null
995+
? (object)DBNull.Value
996+
: string.Join("|", value.Select(v => Convert.ToString(v)));
997+
}
998+
999+
public override List<T> Parse(object? value)
1000+
{
1001+
if (value is null || value is DBNull) return [];
1002+
return ((string)value).Split('|')
1003+
.Select(s => (T)Convert.ChangeType(s, typeof(T))!)
1004+
.ToList();
1005+
}
1006+
}
1007+
}
1008+
1009+
// Result type for TypeHandlerFactory tests, shared across SQL Server and SQLite test classes.
1010+
public class ResultWithLists
1011+
{
1012+
public List<int>? Ids { get; set; }
1013+
public List<string>? Names { get; set; }
8781014
}
8791015
}

0 commit comments

Comments
 (0)