diff --git a/Dapper/PublicAPI.Shipped.txt b/Dapper/PublicAPI.Shipped.txt index 36cdc1c7..82ed88b2 100644 --- a/Dapper/PublicAPI.Shipped.txt +++ b/Dapper/PublicAPI.Shipped.txt @@ -318,4 +318,7 @@ static Dapper.SqlMapper.SetTypeName(this System.Data.DataTable! table, string! t static Dapper.SqlMapper.ThrowDataException(System.Exception! ex, int index, System.Data.IDataReader! reader, object? value) -> void static Dapper.SqlMapper.TypeHandlerCache.Parse(object! value) -> T? static Dapper.SqlMapper.TypeHandlerCache.SetValue(System.Data.IDbDataParameter! parameter, object! value) -> void -static Dapper.SqlMapper.TypeMapProvider -> System.Func! \ No newline at end of file +static Dapper.SqlMapper.TypeMapProvider -> System.Func! +static Dapper.SqlMapper.CurrentAbstractTypeMap.get -> System.Func! +static Dapper.SqlMapper.AddAbstractTypeMap(System.Func!, System.Func!>! combiner) -> void +static Dapper.SqlMapper.SetAbstractTypeMap(System.Func? map) -> void \ No newline at end of file diff --git a/Dapper/SqlMapper.TypeDeserializerCache.cs b/Dapper/SqlMapper.TypeDeserializerCache.cs index 2c2f646d..d674c39f 100644 --- a/Dapper/SqlMapper.TypeDeserializerCache.cs +++ b/Dapper/SqlMapper.TypeDeserializerCache.cs @@ -43,7 +43,15 @@ internal static Func GetReader(Type type, DbDataReader rea found = (TypeDeserializerCache?)byType[type]; if (found is null) { - byType[type] = found = new TypeDeserializerCache(type); + var mapped = SqlMapper.abstractTypeMap?.Invoke(type); + if( mapped != null && mapped != type ) + { + byType[type] = byType[mapped] = found = new TypeDeserializerCache(mapped); + } + else + { + byType[type] = found = new TypeDeserializerCache(type); + } } } } diff --git a/Dapper/SqlMapper.cs b/Dapper/SqlMapper.cs index 5f5974f7..3092fe6b 100644 --- a/Dapper/SqlMapper.cs +++ b/Dapper/SqlMapper.cs @@ -243,6 +243,7 @@ static SqlMapper() [typeof(SqlMoney?)] = TypeMapEntry.DecimalFieldValue, }; ResetTypeHandlers(false); + abstractTypeMap = t => null; } /// @@ -496,6 +497,56 @@ public static void SetDbType(IDataParameter parameter, object value) return DbType.Object; } + private static Func abstractTypeMap; + + /// + /// Gets the current abstract to concrete mapper (can be null). + /// Use to combine + /// it with a any new mapping. This function must simply return null (or the type itself) if + /// the type has no mapping or must not be mapped. + /// + /// + /// Once a type has been mapped, it will keep its original mapping until is called. + /// + public static Func CurrentAbstractTypeMap => abstractTypeMap; + + /// + /// Updates with a new one that should combine the + /// current one with any new mappings in a thread safe manner. + /// + /// + /// The may be called more than once in case of concurrent calls. + /// + /// A function that must combine its input with any rules and returns a new mapper. + public static void AddAbstractTypeMap(Func, Func> combiner) + { + var spinWait = new SpinWait(); + while( true ) + { + var current = abstractTypeMap; + if( Interlocked.CompareExchange(ref abstractTypeMap, combiner(current), current) == current ) + { + return; + } + spinWait.SpinOnce(); + } + } + + /// + /// Sets the to a new function, regardless of its current + /// value. Use to safely + /// combine a new mapper with the current one. + /// + /// + /// Once a type has been mapped, it will keep its original mapping until is called. + /// + /// The new mapping function to set. Null to reset it. + public static void SetAbstractTypeMap(Func? map) + { + if (map == null) map = t => null; + abstractTypeMap = map; + } + /// /// Obtains the data as a list; if it is *already* a list, the original object is returned without /// any duplication; otherwise, ToList() is invoked. diff --git a/docs/index.md b/docs/index.md index 9ebb9efb..d960b282 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,6 +24,16 @@ Note: to get the latest pre-release build, add ` -Pre` to the end of the command (note: new PRs will not be merged until they add release note wording here) +### 2.2.0 + +- adds support for Type mapping (resolves issue #1104) by allowing a + requested type to be mapped to another type. This adds to the the + static `SqlMapper`: + - a new `CurrentAbstractTypeMap` mapper function. + - a `SetAbstractTypeMap` to replace it. + - a `AddAbstractTypeMap` that combines a new mapping to the existing one (thread safe). + Once a type has been mapped, it will keep its original mapping until `PurgeQueryCache` is called. + ### 2.1.4 - add untyped `GridReader.ReadUnbufferedAsync` API (#1958 via @mgravell) diff --git a/tests/Dapper.Tests/AbstractTypeMappingTests.cs b/tests/Dapper.Tests/AbstractTypeMappingTests.cs new file mode 100644 index 00000000..67353927 --- /dev/null +++ b/tests/Dapper.Tests/AbstractTypeMappingTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Data; +using System.Linq; +using Xunit; + +namespace Dapper.Tests +{ + [Collection(NonParallelDefinition.Name)] + public sealed class SystemSqlClientAbstractTypeMappingTests : AbstractTypeMappingTests { } +#if MSSQLCLIENT + [Collection(NonParallelDefinition.Name)] + public sealed class MicrosoftSqlClientAbstractTypeMappingTests : AbstractTypeMappingTests { } +#endif + + public abstract class AbstractTypeMappingTests : TestBase where TProvider : DatabaseProvider + { + [Fact] + public void TestAbstractTypeMapping() + { + var previousMapping = SqlMapper.CurrentAbstractTypeMap; + SqlMapper.PurgeQueryCache(); + try + { + SqlMapper.SetAbstractTypeMap(t => t == typeof(AbstractTypeMapping.IThing) ? typeof(AbstractTypeMapping.Thing) : null); + + var thing = connection.Query("select 'Hello!' Name, 42 Power").First(); + Assert.Equal(42, thing.Power); + Assert.Equal("Hello!", thing.Name); + + var list = connection.Query("select 'Hello!' Name, 42 Power union all select 'World!' Name, 3712 Power") + .ToList(); + Assert.Equal(42, list[0].Power); + Assert.Equal("Hello!", list[0].Name); + Assert.Equal(3712, list[1].Power); + Assert.Equal("World!", list[1].Name); + + var firstThing = connection.QueryFirstOrDefault("select 'Hello!' Name, 42 Power"); + Assert.True(firstThing != null); + Assert.Equal(42, firstThing.Power); + Assert.Equal("Hello!", firstThing.Name); + } + finally + { + SqlMapper.SetAbstractTypeMap( previousMapping ); + SqlMapper.PurgeQueryCache(); + } + } + + [Fact] + public void TestAbstractTypeMappingCombination() + { + var previousMapping = SqlMapper.CurrentAbstractTypeMap; + SqlMapper.PurgeQueryCache(); + try + { + // IThing is mapped to Thing. + SqlMapper.SetAbstractTypeMap(t => t == typeof(AbstractTypeMapping.IThing) ? typeof(AbstractTypeMapping.Thing) : null); + + // "Override": IThing is mapped to ThingMultiplier. + SqlMapper.AddAbstractTypeMap(current => + { + return t => + { + if (t == typeof(AbstractTypeMapping.IThing)) return typeof(AbstractTypeMapping.ThingMultiplier); + return current?.Invoke(t); + }; + }); + + var thing = connection.Query("select 'Hello!' Name, 42 Power").First(); + Assert.Equal(84, thing.Power); + Assert.Equal("Hello!", thing.Name); + } + finally + { + SqlMapper.SetAbstractTypeMap( previousMapping ); + SqlMapper.PurgeQueryCache(); + } + } + + public static class AbstractTypeMapping + { + public interface IThing + { + int Power { get; } + + string? Name { get; } + } + + public class Thing : IThing + { + public int Power { get; set; } + + public string? Name { get; set; } + } + + public class ThingMultiplier : IThing + { + int _power; + + public int Power { get => _power * 2; set => _power = value; } + + public string? Name { get; set; } + } + } + + } +}