From 2d8fb348148293974be90967c67c072c5956bd3c Mon Sep 17 00:00:00 2001 From: Benjamin Vettori Date: Mon, 23 Mar 2026 21:48:31 +0100 Subject: [PATCH 1/4] Remove static mapper * Removes the static Mapper class since it does not cover all features (parameters, ignore, usemap) and setting up Profiles with a single instance has the same effect as using the static mapper. * Moved parts of the static mapper, which are still used, to the Mapify class. * Removed unittests for the static mapper. * Updated the readme +semver:major --- .../MapifyEfCoreDefaultValueBehaviorTests.cs | 45 +- ...fyEfCoreProjectionTests.ImplicitMapping.cs | 30 +- ...etFrameworkEf6DefaultValueBehaviorTests.cs | 37 +- ...eworkEf6ProjectionTests.ImplicitMapping.cs | 37 +- Mapify.NET.Tests/MapifyInstanceTests.cs | 209 +++++++++ .../MapifyProjectToExtensionsTests.cs | 70 +-- .../MapperCollectionResolutionTests.cs | 336 --------------- .../MapperDefaultValueBehaviorTests.cs | 128 ------ .../MapperTests.GetMapBehavior.cs | 60 --- .../MapperTests.IgnoreMarkerBehavior.cs | 59 --- ...MapperTests.ImplicitAndNullableBehavior.cs | 115 ----- .../MapperTests.MapCoreBehavior.cs | 175 -------- .../MapperTests.ResolverFallbackBehavior.cs | 47 -- Mapify.NET.Tests/MapperTests.TestModels.cs | 102 ----- .../MapperTests.TestProfilesAndResources.cs | 35 -- .../MapperTests.UseDefaultMapBehavior.cs | 44 -- Mapify.NET.Tests/MapperTests.cs | 9 - .../{Mapper.cs => Mapify.MappingInternals.cs} | 400 +----------------- Mapify/Mapify.cs | 44 +- Mapify/MapifyProjectToExtensions.cs | 75 +--- README.md | 134 +----- 21 files changed, 413 insertions(+), 1778 deletions(-) delete mode 100644 Mapify.NET.Tests/MapperCollectionResolutionTests.cs delete mode 100644 Mapify.NET.Tests/MapperDefaultValueBehaviorTests.cs delete mode 100644 Mapify.NET.Tests/MapperTests.GetMapBehavior.cs delete mode 100644 Mapify.NET.Tests/MapperTests.IgnoreMarkerBehavior.cs delete mode 100644 Mapify.NET.Tests/MapperTests.ImplicitAndNullableBehavior.cs delete mode 100644 Mapify.NET.Tests/MapperTests.MapCoreBehavior.cs delete mode 100644 Mapify.NET.Tests/MapperTests.ResolverFallbackBehavior.cs delete mode 100644 Mapify.NET.Tests/MapperTests.TestModels.cs delete mode 100644 Mapify.NET.Tests/MapperTests.TestProfilesAndResources.cs delete mode 100644 Mapify.NET.Tests/MapperTests.UseDefaultMapBehavior.cs delete mode 100644 Mapify.NET.Tests/MapperTests.cs rename Mapify/{Mapper.cs => Mapify.MappingInternals.cs} (71%) diff --git a/Mapify.NET.Tests.EFCore/MapifyEfCoreDefaultValueBehaviorTests.cs b/Mapify.NET.Tests.EFCore/MapifyEfCoreDefaultValueBehaviorTests.cs index dd684f0..e5b3c70 100644 --- a/Mapify.NET.Tests.EFCore/MapifyEfCoreDefaultValueBehaviorTests.cs +++ b/Mapify.NET.Tests.EFCore/MapifyEfCoreDefaultValueBehaviorTests.cs @@ -1,16 +1,11 @@ namespace Mapify.NET.Tests.EFCore; public class MapifyEfCoreDefaultValueBehaviorTests { - public MapifyEfCoreDefaultValueBehaviorTests() { - Mapper.UseDefaultMapIfTypeMapIsMissing(false); - Mapper.ClearMappings(); - } - [Fact] public void CreateMap_ShouldKeepNull_ForNullableCollection_WhenSourceIsNull() { - var map = Mapper.CreateMap(); + var mapify = new Mapify([new EfCoreNullableCollectionProfile()]); - var mapped = map.Map(new EfCoreNullableCollectionSource { + var mapped = mapify.Map(new EfCoreNullableCollectionSource { Numbers = null }); @@ -19,9 +14,9 @@ public void CreateMap_ShouldKeepNull_ForNullableCollection_WhenSourceIsNull() { [Fact] public void CreateMap_ShouldUseEmpty_ForNonNullableCollection_WhenSourceIsNull() { - var map = Mapper.CreateMap(); + var mapify = new Mapify([new EfCoreNonNullableCollectionProfile()]); - var mapped = map.Map(new EfCoreNullableCollectionSource { + var mapped = mapify.Map(new EfCoreNullableCollectionSource { Numbers = null }); @@ -31,9 +26,9 @@ public void CreateMap_ShouldUseEmpty_ForNonNullableCollection_WhenSourceIsNull() [Fact] public void CreateMap_ShouldUseEmpty_ForRequiredCollection_WhenSourceIsNull() { - var map = Mapper.CreateMap(); + var mapify = new Mapify([new EfCoreRequiredCollectionProfile()]); - var mapped = map.Map(new EfCoreNullableCollectionSource { + var mapped = mapify.Map(new EfCoreNullableCollectionSource { Numbers = null }); @@ -43,9 +38,9 @@ public void CreateMap_ShouldUseEmpty_ForRequiredCollection_WhenSourceIsNull() { [Fact] public void CreateMap_ShouldPreserveInitializer_ForNonNullableCollection_WhenSourcePropertyIsMissing() { - var map = Mapper.CreateMap(); + var mapify = new Mapify([new EfCoreInitializedCollectionProfile()]); - var mapped = map.Map(new EfCoreSourceWithoutCollection { + var mapped = mapify.Map(new EfCoreSourceWithoutCollection { Id = 5 }); @@ -55,9 +50,9 @@ public void CreateMap_ShouldPreserveInitializer_ForNonNullableCollection_WhenSou [Fact] public void CreateMap_ShouldUseEmpty_ForUninitializedNonNullableCollection_WhenSourcePropertyIsMissing() { - var map = Mapper.CreateMap(); + var mapify = new Mapify([new EfCoreUninitializedCollectionProfile()]); - var mapped = map.Map(new EfCoreSourceWithoutCollection { + var mapped = mapify.Map(new EfCoreSourceWithoutCollection { Id = 5 }); @@ -95,4 +90,24 @@ private sealed class EfCoreUninitializedCollectionTarget { public int Id { get; set; } public List Numbers { get; set; } = null!; } + + private sealed class EfCoreNullableCollectionProfile : MapifyProfile { + protected override void Configure() => CreateMap(); + } + + private sealed class EfCoreNonNullableCollectionProfile : MapifyProfile { + protected override void Configure() => CreateMap(); + } + + private sealed class EfCoreRequiredCollectionProfile : MapifyProfile { + protected override void Configure() => CreateMap(); + } + + private sealed class EfCoreInitializedCollectionProfile : MapifyProfile { + protected override void Configure() => CreateMap(); + } + + private sealed class EfCoreUninitializedCollectionProfile : MapifyProfile { + protected override void Configure() => CreateMap(); + } } diff --git a/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTests.ImplicitMapping.cs b/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTests.ImplicitMapping.cs index 8043de5..a93a4f3 100644 --- a/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTests.ImplicitMapping.cs +++ b/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTests.ImplicitMapping.cs @@ -37,14 +37,13 @@ public void CreateMap_ShouldWorkInEfCoreProjection_WithNestedInvokeForSingleAndC db.SaveChanges(); - var addressMap = Mapper.CreateMap(); - var phoneMap = Mapper.CreateMap(); + var mapify = new Mapify([ + new EfCoreAddressProfile(), + new EfCorePhoneProfile(), + new EfCorePersonInvokeProfile() + ]); - var map = Mapper.CreateMap(x => new EfCorePersonDto { - FullName = x.FirstName + " " + x.LastName, - HomeAddress = addressMap.Invoke(x.HomeAddress), - Phones = x.Phones.Select(p => phoneMap.Invoke(p)).ToList() - }); + var map = mapify.GetRequiredMap(); var result = db.People .AsExpandable() @@ -91,7 +90,8 @@ public void CreateMap_ShouldImplicitlyMapPrimitiveArraysAndCollections_InEfCoreP db.SaveChanges(); - var map = Mapper.CreateMap(); + var mapify = new Mapify([new EfCorePrimitiveCollectionsProfile()]); + var map = mapify.GetRequiredMap(); var result = db.People .OrderBy(x => x.Id) @@ -160,4 +160,18 @@ public void InstanceMapify_ShouldImplicitlyUseExistingMapsForNestedAndArrayMembe Assert.Equal("Manchester", result[1].HomeAddress.City); Assert.Equal(new[] { "+44-200" }, result[1].Phones.Select(x => x.Number).ToArray()); } + + private sealed class EfCorePersonInvokeProfile : MapifyProfile { + protected override void Configure() { + CreateMap(x => new EfCorePersonDto { + FullName = x.FirstName + " " + x.LastName, + HomeAddress = UseMap(x.HomeAddress), + Phones = UseMap, IEnumerable>(x.Phones).ToList() + }); + } + } + + private sealed class EfCorePrimitiveCollectionsProfile : MapifyProfile { + protected override void Configure() => CreateMap(); + } } diff --git a/Mapify.NET.Tests.NetFx/MapifyNetFrameworkEf6DefaultValueBehaviorTests.cs b/Mapify.NET.Tests.NetFx/MapifyNetFrameworkEf6DefaultValueBehaviorTests.cs index 4e3a95f..ee9d5fb 100644 --- a/Mapify.NET.Tests.NetFx/MapifyNetFrameworkEf6DefaultValueBehaviorTests.cs +++ b/Mapify.NET.Tests.NetFx/MapifyNetFrameworkEf6DefaultValueBehaviorTests.cs @@ -1,16 +1,11 @@ namespace Mapify.NET.Tests.NetFx; public class MapifyNetFrameworkEf6DefaultValueBehaviorTests { - public MapifyNetFrameworkEf6DefaultValueBehaviorTests() { - Mapper.UseDefaultMapIfTypeMapIsMissing(false); - Mapper.ClearMappings(); - } - [Fact] public void CreateMap_ShouldKeepNull_ForNullableCollection_WhenSourceIsNull() { - var map = Mapper.CreateMap(); + var mapify = new Mapify([new Ef6NullableCollectionProfile()]); - var mapped = map.Map(new Ef6NullableCollectionSource { + var mapped = mapify.Map(new Ef6NullableCollectionSource { Numbers = null }); @@ -19,9 +14,9 @@ public void CreateMap_ShouldKeepNull_ForNullableCollection_WhenSourceIsNull() { [Fact] public void CreateMap_ShouldUseEmpty_ForNonNullableCollection_WhenSourceIsNull() { - var map = Mapper.CreateMap(); + var mapify = new Mapify([new Ef6NonNullableCollectionProfile()]); - var mapped = map.Map(new Ef6NullableCollectionSource { + var mapped = mapify.Map(new Ef6NullableCollectionSource { Numbers = null }); @@ -31,9 +26,9 @@ public void CreateMap_ShouldUseEmpty_ForNonNullableCollection_WhenSourceIsNull() [Fact] public void CreateMap_ShouldPreserveInitializer_ForNonNullableCollection_WhenSourcePropertyIsMissing() { - var map = Mapper.CreateMap(); + var mapify = new Mapify([new Ef6InitializedCollectionProfile()]); - var mapped = map.Map(new Ef6SourceWithoutCollection { + var mapped = mapify.Map(new Ef6SourceWithoutCollection { Id = 7 }); @@ -43,9 +38,9 @@ public void CreateMap_ShouldPreserveInitializer_ForNonNullableCollection_WhenSou [Fact] public void CreateMap_ShouldUseEmpty_ForUninitializedNonNullableCollection_WhenSourcePropertyIsMissing() { - var map = Mapper.CreateMap(); + var mapify = new Mapify([new Ef6UninitializedCollectionProfile()]); - var mapped = map.Map(new Ef6SourceWithoutCollection { + var mapped = mapify.Map(new Ef6SourceWithoutCollection { Id = 7 }); @@ -79,4 +74,20 @@ private class Ef6UninitializedCollectionTarget { public int Id { get; set; } public List Numbers { get; set; } = null!; } + + private sealed class Ef6NullableCollectionProfile : MapifyProfile { + protected override void Configure() => CreateMap(); + } + + private sealed class Ef6NonNullableCollectionProfile : MapifyProfile { + protected override void Configure() => CreateMap(); + } + + private sealed class Ef6InitializedCollectionProfile : MapifyProfile { + protected override void Configure() => CreateMap(); + } + + private sealed class Ef6UninitializedCollectionProfile : MapifyProfile { + protected override void Configure() => CreateMap(); + } } diff --git a/Mapify.NET.Tests.NetFx/MapifyNetFrameworkEf6ProjectionTests.ImplicitMapping.cs b/Mapify.NET.Tests.NetFx/MapifyNetFrameworkEf6ProjectionTests.ImplicitMapping.cs index 0995f3f..eb66e97 100644 --- a/Mapify.NET.Tests.NetFx/MapifyNetFrameworkEf6ProjectionTests.ImplicitMapping.cs +++ b/Mapify.NET.Tests.NetFx/MapifyNetFrameworkEf6ProjectionTests.ImplicitMapping.cs @@ -33,14 +33,13 @@ public void CreateMap_ShouldWorkInEf6Projection_WithNestedInvokeForSingleAndColl db.SaveChanges(); - var addressMap = Mapper.CreateMap(); - var phoneMap = Mapper.CreateMap(); + var mapify = new Mapify([ + new Ef6AddressProfile(), + new Ef6PhoneProfile(), + new Ef6PersonInvokeProfile() + ]); - var map = Mapper.CreateMap(x => new Ef6PersonDto { - FullName = x.FirstName + " " + x.LastName, - HomeAddress = addressMap.Invoke(x.HomeAddress), - Phones = x.Phones.Select(p => phoneMap.Invoke(p)).ToList() - }); + var map = mapify.GetRequiredMap(); var result = db.People .AsExpandable() @@ -84,7 +83,8 @@ public void CreateMap_ShouldImplicitlyMapPrimitiveEnumerableCollections_InEf6Pro db.SaveChanges(); - var map = Mapper.CreateMap(); + var mapify = new Mapify([new Ef6PrimitiveCollectionsProfile()]); + var map = mapify.GetRequiredMap(); var result = db.People .OrderBy(x => x.Id) @@ -129,7 +129,8 @@ public void CreateMap_ShouldThrowForPrimitiveArrayProjection_InEf6Projection() { db.SaveChanges(); - var map = Mapper.CreateMap(); + var mapify = new Mapify([new Ef6PrimitiveArrayCollectionsProfile()]); + var map = mapify.GetRequiredMap(); // EF6 cannot translate source-side ToArray() inside LINQ-to-Entities projections. Assert.Throws(() => db.People @@ -233,4 +234,22 @@ public void InstanceMapify_ShouldThrowForImplicitNestedArrayProjection_InEf6Proj .Select(mapExpr) .ToList()); } + + private sealed class Ef6PersonInvokeProfile : MapifyProfile { + protected override void Configure() { + CreateMap(x => new Ef6PersonDto { + FullName = x.FirstName + " " + x.LastName, + HomeAddress = UseMap(x.HomeAddress), + Phones = UseMap, IEnumerable>(x.Phones).ToList() + }); + } + } + + private sealed class Ef6PrimitiveCollectionsProfile : MapifyProfile { + protected override void Configure() => CreateMap(); + } + + private sealed class Ef6PrimitiveArrayCollectionsProfile : MapifyProfile { + protected override void Configure() => CreateMap(); + } } diff --git a/Mapify.NET.Tests/MapifyInstanceTests.cs b/Mapify.NET.Tests/MapifyInstanceTests.cs index 31cde33..0c870a8 100644 --- a/Mapify.NET.Tests/MapifyInstanceTests.cs +++ b/Mapify.NET.Tests/MapifyInstanceTests.cs @@ -1,4 +1,5 @@ using System.Linq.Expressions; +using System.Reflection; namespace Mapify.NET.Tests; public class MapifyInstanceTests { @@ -201,6 +202,44 @@ private class CollectionUseMapTarget { public IList ItemsArrayAsList { get; set; } = []; } + private class CollectionContainerSource { + public List Items { get; set; } = []; + } + + private class CollectionContainerTarget { + public List Items { get; set; } = []; + } + + private class NullableCollectionContainerSource2 { + public List? Items { get; set; } + } + + private class NullableCollectionContainerTarget2 { + public List? Items { get; set; } + } + + private class NullableNumberContainerSource { + public NumberSource? Number { get; set; } + } + + private class NullableNumberContainerTarget { + public NumberTarget? Number { get; set; } + } + + private class AmbiguousEnumerable : IEnumerable, IEnumerable { + IEnumerator IEnumerable.GetEnumerator() => Array.Empty().AsEnumerable().GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => Array.Empty().AsEnumerable().GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => ((IEnumerable)this).GetEnumerator(); + } + + private class AmbiguousCollectionContainerSource { + public AmbiguousEnumerable Items { get; set; } = new AmbiguousEnumerable(); + } + + private class AmbiguousCollectionContainerTarget { + public List Items { get; set; } = []; + } + private class ImplicitPrimitiveCollectionsSource { public int[] Numbers { get; set; } = []; public ICollection Texts { get; set; } = []; @@ -641,6 +680,46 @@ protected override void Configure() { } } + private class EnumerableElementMapProfile : MapifyProfile { + protected override void Configure() { + CreateMap, IEnumerable>( + x => x.Select(i => new ElementTarget { Value = i.Value + 10 }) + ); + } + } + + private class CollectionContainerProfile : MapifyProfile { + protected override void Configure() { + CreateMap(); + } + } + + private class NullableCollectionContainerProfile : MapifyProfile { + protected override void Configure() { + CreateMap(); + } + } + + private class NullableNumberContainerProfile : MapifyProfile { + protected override void Configure() { + CreateMap(); + } + } + + private class NumberNullableExactProfile : MapifyProfile { + protected override void Configure() { + CreateMap(x => x == null + ? (NumberTarget?)null + : new NumberTarget { Value = x.Value.Value + 100 }); + } + } + + private class AmbiguousCollectionContainerProfile : MapifyProfile { + protected override void Configure() { + CreateMap(); + } + } + private class CollectionUseMapProfile : MapifyProfile { protected override void Configure() { CreateMap(x => new CollectionUseMapTarget { @@ -1106,6 +1185,20 @@ public void Constructor_ShouldThrow_WhenUseMapDepthExceedsDefaultHardCap() { Assert.Contains("exceeds the configured hard cap 10", ex.Message, StringComparison.Ordinal); } + [Fact] + public void Constructor_ShouldSupportNamedUseMapDepthOverload_WhenSourceTypeIsString() { + var mapify = new Mapify((IEnumerable?)null); + RegisterStringAppendMapNamed(mapify, "AppendNamed"); + RegisterStringSourceNamedDepthMap(mapify, mapName: "NamedDepth", nestedMapName: "AppendNamed", depth: 3); + + var buildMethod = typeof(Mapify).GetMethod("BuildRegisteredMaps", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + buildMethod.Invoke(mapify, null); + + var mapped = mapify.Map(new ConstantStringSource { Id = 1 }, "NamedDepth"); + + Assert.Equal("foo!", mapped.Text); + } + [Fact] public void Constructor_ShouldAllowHigherUseMapDepth_WhenHardCapIsIncreased() { var mapify = new Mapify((IEnumerable?)null); @@ -1471,6 +1564,50 @@ private static void RegisterStringAppendMap(Mapify mapify) { addPendingMap.Invoke(mapify, [null, partial]); } + private static void RegisterStringAppendMapNamed(Mapify mapify, string name) { + Expression> partial = x => x + "!"; + + var addPendingMap = typeof(Mapify) + .GetMethod("AddPendingMap", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .MakeGenericMethod(typeof(string), typeof(string)); + addPendingMap.Invoke(mapify, [name, partial]); + } + + private static void RegisterStringSourceNamedDepthMap(Mapify mapify, string mapName, string nestedMapName, int depth) { + var sourceParameter = Expression.Parameter(typeof(ConstantStringSource), "x"); + + var useMapMarker = typeof(MapifyProfile) + .GetMethods(System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic) + .Single(m => m.Name == "UseMap" + && m.IsGenericMethodDefinition + && m.GetParameters().Length == 3 + && m.GetParameters()[0].ParameterType == typeof(string) + && m.GetParameters()[2].ParameterType == typeof(int)) + .MakeGenericMethod(typeof(string), typeof(string)); + + var useMapCall = Expression.Call( + useMapMarker, + Expression.Constant(nestedMapName), + Expression.Constant("foo"), + Expression.Constant(depth) + ); + + var body = Expression.MemberInit( + Expression.New(typeof(ConstantStringTarget)), + Expression.Bind( + typeof(ConstantStringTarget).GetProperty(nameof(ConstantStringTarget.Text))!, + useMapCall + ) + ); + + var partial = Expression.Lambda>(body, sourceParameter); + + var addPendingMap = typeof(Mapify) + .GetMethod("AddPendingMap", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .MakeGenericMethod(typeof(ConstantStringSource), typeof(ConstantStringTarget)); + addPendingMap.Invoke(mapify, [mapName, partial]); + } + [Fact] public void Map_ShouldUseExistingNestedMapImplicitly_WhenPropertyTypesDiffer() { var mapify = new Mapify(new ChildProfile(), new ParentProfile()); @@ -1708,6 +1845,78 @@ public void Map_ShouldMapListToList_WhenOnlyElementMapIsRegistered() { Assert.Equal([2, 3], mapped.Select(x => x.Value).ToArray()); } + [Fact] + public void Map_ShouldPreferExactNullableMap_OverUnderlyingTypeFallback() { + var mapify = new Mapify(new NumberProfile(), new NumberNullableExactProfile(), new NullableNumberContainerProfile()); + + var mapped = mapify.Map(new NullableNumberContainerSource { + Number = new NumberSource { Value = 5 } + }); + + Assert.NotNull(mapped.Number); + Assert.Equal(105, mapped.Number!.Value.Value); + } + + [Fact] + public void Map_ShouldFallbackToUnderlyingNullableMap_WhenExactNullableMapIsMissing() { + var mapify = new Mapify(new NumberProfile(), new NullableNumberContainerProfile()); + + var mapped = mapify.Map(new NullableNumberContainerSource { + Number = new NumberSource { Value = 5 } + }); + + Assert.NotNull(mapped.Number); + Assert.Equal(6, mapped.Number!.Value.Value); + } + + [Fact] + public void GetMap_ShouldRequireExactTypes_AndNotReturnNullableOrElementFallbackMaps() { + var mapify = new Mapify(new NumberProfile(), new ElementProfile()); + mapify.UseDefaultMapIfTypeMapIsMissing(false); + + var nullableMap = mapify.GetMap(); + var collectionMap = mapify.GetMap, List>(); + + Assert.Null(nullableMap); + Assert.Null(collectionMap); + Assert.Throws(() => mapify.GetRequiredMap()); + Assert.Throws(() => mapify.GetRequiredMap, List>()); + } + + [Fact] + public void Map_ShouldUseAssignableCollectionMap_BeforeElementFallback() { + var mapify = new Mapify(new ElementProfile(), new EnumerableElementMapProfile(), new CollectionContainerProfile()); + + var mapped = mapify.Map(new CollectionContainerSource { + Items = [new ElementSource { Value = 1 }] + }); + + Assert.Equal([11], mapped.Items.Select(x => x.Value).ToArray()); + } + + [Fact] + public void Map_ShouldHandleNullCollectionSource_WithoutThrowing() { + var mapify = new Mapify(new ElementProfile(), new NullableCollectionContainerProfile()); + + var mapped = mapify.Map(new NullableCollectionContainerSource2 { + Items = null + }); + + Assert.Null(mapped.Items); + } + + [Fact] + public void Constructor_ShouldThrowForAmbiguousEnumerableElementType() { + var ex = Assert.ThrowsAny(() => new Mapify(new AmbiguousCollectionContainerProfile())); + + var effective = ex is TargetInvocationException tie && tie.InnerException != null + ? tie.InnerException + : ex; + + Assert.IsType(effective); + Assert.Contains("multiple IEnumerable element types", effective.Message, StringComparison.Ordinal); + } + [Theory] [MemberData(nameof(CollectionHierarchyKinds))] public void Map_ShouldFallbackFromConcreteListSource_ToAnyRegisteredHierarchyCollectionMap_WhenMappingToObject(CollectionHierarchyKind declaredMapKind) { diff --git a/Mapify.NET.Tests/MapifyProjectToExtensionsTests.cs b/Mapify.NET.Tests/MapifyProjectToExtensionsTests.cs index 57e4686..fcaf19c 100644 --- a/Mapify.NET.Tests/MapifyProjectToExtensionsTests.cs +++ b/Mapify.NET.Tests/MapifyProjectToExtensionsTests.cs @@ -5,11 +5,6 @@ namespace Mapify.NET.Tests; [Collection("Mapper Tests")] public class MapifyProjectToExtensionsTests { - public MapifyProjectToExtensionsTests() { - Mapper.UseDefaultMapIfTypeMapIsMissing(false); - Mapper.ClearMappings(); - } - private static TException AssertThrowsUnwrapped(Action action) where TException : Exception { try { @@ -21,34 +16,6 @@ private static TException AssertThrowsUnwrapped(Action action) return Assert.Throws(action); } - [Fact] - public void ProjectTo_IEnumerable_Static_ShouldProjectItems() { - Mapper.AddMap(x => new ProjectTarget { Value = x.Value + 1 }); - - IEnumerable source = new[] { - new ProjectSource { Value = 1 }, - new ProjectSource { Value = 2 } - }; - - var projected = source.ProjectTo().Select(x => x.Value).ToArray(); - - Assert.Equal([2, 3], projected); - } - - [Fact] - public void ProjectTo_IEnumerable_Static_ShouldProjectFlatList_WhenOnlyElementMapIsRegistered() { - Mapper.AddMap(x => new ProjectTarget { Value = x.Value + 1 }); - - IEnumerable source = new List { - new ProjectSource { Value = 1 }, - new ProjectSource { Value = 2 } - }; - - var projected = source.ProjectTo().ToList(); - - Assert.Equal([2, 3], projected.Select(x => x.Value).ToArray()); - } - [Fact] public void ProjectTo_IEnumerable_Instance_ShouldProjectFlatList_WhenOnlyElementMapIsRegistered() { var mapify = new Mapify([new EnumerableProjectProfile()]); @@ -228,7 +195,6 @@ public void ProjectTo_IEnumerable_ShouldThrowForNullSourceOrMapify() { IEnumerable? nullSource = null; var mapify = new Mapify([new EnumerableProjectProfile()]); - Assert.Throws(() => nullSource!.ProjectTo()); Assert.Throws(() => nullSource!.ProjectTo(mapify)); Assert.Throws(() => new[] { new ProjectSource { Value = 1 } }.ProjectTo((IMapify)null!)); } @@ -245,13 +211,6 @@ public void ProjectTo_IQueryable_ShouldThrowForInvalidNameOrNullMapify() { Assert.Throws(() => source.ProjectTo(mapify, " ", new Dictionary())); } - [Fact] - public void ProjectTo_IQueryable_StaticOverloads_ShouldThrowForNullSource() { - IQueryable? source = null; - - Assert.Throws(() => source!.ProjectTo()); - } - [Fact] public void ProjectTo_IQueryable_InstanceWithParameters_ShouldThrowForNullMapify() { IQueryable source = new[] { new ProjectSource { Value = 1 } }.AsQueryable(); @@ -302,37 +261,34 @@ public void ProjectTo_IEnumerable_InstanceWithParameters_ShouldThrowForEmptyPara [Fact] public void ProjectTo_IEnumerable_ShouldResolveStringElementTypeAsChar() { - Mapper.AddMap(c => c.ToString().ToUpperInvariant()); + var mapify = new Mapify([new EnumerableCharToStringProfile()]); IEnumerable source = "ab"; - var projected = source.ProjectTo().ToArray(); + var projected = source.ProjectTo(mapify).ToArray(); Assert.Equal(["A", "B"], projected); } [Fact] public void ProjectTo_IEnumerable_ShouldFallbackToObjectForNonGenericCollection() { - Mapper.AddMap(x => new ProjectTarget { - Value = ((ProjectSource)x).Value + 1 - }); - + var mapify = new Mapify([new EnumerableObjectToProjectTargetProfile()]); IEnumerable source = new ArrayList { new ProjectSource { Value = 7 } }; - var projected = source.ProjectTo().Single(); + var projected = source.ProjectTo(mapify).Single(); Assert.Equal(8, projected.Value); } [Fact] public void ProjectTo_IEnumerable_ShouldResolveElementTypeFromGenericEnumerableInterface() { - Mapper.AddMap(x => new ProjectTarget { Value = x.Value + 1 }); + var mapify = new Mapify([new EnumerableProjectProfile()]); IEnumerable source = new List { new ProjectSource { Value = 9 } }; - var projected = source.ProjectTo().Single(); + var projected = source.ProjectTo(mapify).Single(); Assert.Equal(10, projected.Value); } @@ -347,6 +303,20 @@ public void ProjectTo_IEnumerable_InstanceOverloads_ShouldThrowForNullSource() { Assert.Throws(() => source!.ProjectTo(mapify, "NamedParam", new Dictionary())); } + private sealed class EnumerableCharToStringProfile : MapifyProfile { + protected override void Configure() { + CreateMap(c => c.ToString().ToUpperInvariant()); + } + } + + private sealed class EnumerableObjectToProjectTargetProfile : MapifyProfile { + protected override void Configure() { + CreateMap(x => new ProjectTarget { + Value = ((ProjectSource)x).Value + 1 + }); + } + } + [Fact] public void ProjectTo_IEnumerable_InstanceNamed_ShouldThrowForNullMapify() { IEnumerable source = new[] { new ProjectSource { Value = 1 } }; diff --git a/Mapify.NET.Tests/MapperCollectionResolutionTests.cs b/Mapify.NET.Tests/MapperCollectionResolutionTests.cs deleted file mode 100644 index cdc87de..0000000 --- a/Mapify.NET.Tests/MapperCollectionResolutionTests.cs +++ /dev/null @@ -1,336 +0,0 @@ -namespace Mapify.NET.Tests; - -[Collection("Mapper Tests")] -public class MapperCollectionResolutionTests { - public MapperCollectionResolutionTests() { - Mapper.UseDefaultMapIfTypeMapIsMissing(false); - Mapper.ClearMappings(); - } - - [Fact] - public void CreateMap_ShouldPreferExactCollectionMap_OverElementFallback() { - Mapper.AddMap( - x => new CollectionElementTarget { Value = x.Value + 1 } - ); - Mapper.AddMap, List>( - x => x.Select(i => new CollectionElementTarget { Value = i.Value + 100 }).ToList() - ); - - var map = Mapper.CreateMap(); - var mapped = map.Map(new CollectionContainerSource { - Items = [new CollectionElementSource { Value = 1 }] - }); - - Assert.Equal([101], mapped.Items.Select(x => x.Value).ToArray()); - } - - [Fact] - public void CreateMap_ShouldFallbackToElementMap_WhenExactCollectionMapIsMissing() { - Mapper.AddMap( - x => new CollectionElementTarget { Value = x.Value + 1 } - ); - - var map = Mapper.CreateMap(); - var mapped = map.Map(new CollectionContainerSource { - Items = [new CollectionElementSource { Value = 1 }] - }); - - Assert.Equal([2], mapped.Items.Select(x => x.Value).ToArray()); - } - - [Fact] - public void CreateMap_ShouldUseAssignableCollectionMap_BeforeElementFallback() { - Mapper.AddMap( - x => new CollectionElementTarget { Value = x.Value + 1 } - ); - Mapper.AddMap, IEnumerable>( - x => x.Select(i => new CollectionElementTarget { Value = i.Value + 10 }) - ); - - var map = Mapper.CreateMap(); - var mapped = map.Map(new CollectionContainerSource { - Items = [new CollectionElementSource { Value = 1 }] - }); - - Assert.Equal([11], mapped.Items.Select(x => x.Value).ToArray()); - } - - [Fact] - public void CreateMap_ShouldHandleNullCollectionSource_WithoutThrowing() { - Mapper.AddMap( - x => new CollectionElementTarget { Value = x.Value + 1 } - ); - - var map = Mapper.CreateMap(); - var mapped = map.Map(new NullableCollectionContainerSource { - Items = null - }); - - Assert.Null(mapped.Items); - } - - [Fact] - public void Map_ShouldMapListToList_WhenOnlyElementMapIsRegistered() { - Mapper.AddMap( - x => new CollectionElementTarget { Value = x.Value + 1 } - ); - - var mapped = Mapper.Map, List>([ - new CollectionElementSource { Value = 1 }, - new CollectionElementSource { Value = 2 } - ]); - - Assert.Equal([2, 3], mapped.Select(x => x.Value).ToArray()); - } - - [Fact] - public void Map_ShouldMapListOfLists_WhenOnlyElementMapIsRegistered() { - Mapper.AddMap( - x => new CollectionElementTarget { Value = x.Value + 1 } - ); - - var mapped = Mapper.Map>, List>>([ - [ - new CollectionElementSource { Value = 1 }, - new CollectionElementSource { Value = 2 } - ], - [ - new CollectionElementSource { Value = 3 } - ] - ]); - - var flattened = mapped.SelectMany(inner => inner.Select(item => item.Value)).ToArray(); - - Assert.Equal([2, 3, 4], flattened); - } - - [Fact] - public void Map_ShouldMapListOfListOfLists_WhenOnlyElementMapIsRegistered() { - Mapper.AddMap( - x => new CollectionElementTarget { Value = x.Value + 1 } - ); - - var mapped = Mapper.Map>>, List>>>([ - [ - [ - new CollectionElementSource { Value = 1 }, - new CollectionElementSource { Value = 2 } - ] - ], - [ - [ - new CollectionElementSource { Value = 3 } - ] - ] - ]); - - var flattened = mapped - .SelectMany(middle => middle.SelectMany(inner => inner.Select(item => item.Value))) - .ToArray(); - - Assert.Equal([2, 3, 4], flattened); - } - - [Theory] - [MemberData(nameof(CollectionHierarchyKinds))] - public void Map_ShouldFallbackFromConcreteListSource_ToAnyRegisteredHierarchyCollectionMap_WhenMappingToObject(CollectionHierarchyKind declaredMapKind) { - var offset = GetOffsetForKind(declaredMapKind); - AddCollectionSummaryMap(declaredMapKind, offset); - - var mapped = Mapper.Map, CollectionSummaryTarget>(CreateCollectionSourceList()); - - Assert.Equal(3 + offset, mapped.Count); - } - - [Theory] - [MemberData(nameof(CollectionHierarchyCombinations))] - public void Map_ShouldResolveAccordingToCollectionHierarchy_ForAllSourceAndRegisteredCollectionCombinations( - CollectionHierarchyKind sourceKind, - CollectionHierarchyKind declaredMapKind - ) { - var offset = GetOffsetForKind(declaredMapKind); - AddCollectionSummaryMap(declaredMapKind, offset); - - if (CanResolveCollectionMap(sourceKind, declaredMapKind)) { - var mapped = MapCollectionSummary(sourceKind, CreateCollectionSourceList()); - Assert.Equal(3 + offset, mapped.Count); - } else { - Assert.Throws(() => MapCollectionSummary(sourceKind, CreateCollectionSourceList())); - } - } - - [Theory] - [MemberData(nameof(CollectionHierarchyCombinations))] - public void Map_ShouldResolveAllCollectionCombinations_WhenOnlyElementMapIsRegistered( - CollectionHierarchyKind sourceKind, - CollectionHierarchyKind targetKind - ) { - Mapper.AddMap( - x => new CollectionElementTarget { Value = x.Value + 1 } - ); - - var mappedCollection = MapCollectionElements(sourceKind, targetKind, CreateCollectionSourceList()); - var values = mappedCollection.Select(x => x.Value).ToArray(); - - Assert.Equal([2, 3, 4], values); - } - - [Fact] - public void Map_ShouldPreferHigherRankedCollectionMap_WhenMultipleInterfaceMapsExist_AndNoExactMapIsRegistered() { - AddCollectionSummaryMap(CollectionHierarchyKind.IEnumerable, 500); - AddCollectionSummaryMap(CollectionHierarchyKind.IReadOnlyCollection, 400); - AddCollectionSummaryMap(CollectionHierarchyKind.IReadOnlyList, 300); - AddCollectionSummaryMap(CollectionHierarchyKind.ICollection, 200); - AddCollectionSummaryMap(CollectionHierarchyKind.IList, 100); - - var mapped = Mapper.Map, CollectionSummaryTarget>(CreateCollectionSourceList()); - - Assert.Equal(103, mapped.Count); - } - - [Fact] - public void Map_ShouldPreferExactCollectionMap_OverHierarchyCandidates_WhenBothExist() { - AddCollectionSummaryMap(CollectionHierarchyKind.IList, 100); - AddCollectionSummaryMap(CollectionHierarchyKind.List, 1000); - - var mapped = Mapper.Map, CollectionSummaryTarget>(CreateCollectionSourceList()); - - Assert.Equal(1003, mapped.Count); - } - - public static IEnumerable CollectionHierarchyKinds() { - foreach (var kind in Enum.GetValues()) { - yield return [kind]; - } - } - - public static IEnumerable CollectionHierarchyCombinations() { - foreach (var sourceKind in Enum.GetValues()) { - foreach (var declaredMapKind in Enum.GetValues()) { - yield return [sourceKind, declaredMapKind]; - } - } - } - - private static List CreateCollectionSourceList() - => [ - new CollectionElementSource { Value = 1 }, - new CollectionElementSource { Value = 2 }, - new CollectionElementSource { Value = 3 } - ]; - - private static int GetOffsetForKind(CollectionHierarchyKind kind) - => kind switch { - CollectionHierarchyKind.List => 10, - CollectionHierarchyKind.IList => 20, - CollectionHierarchyKind.ICollection => 30, - CollectionHierarchyKind.IReadOnlyList => 40, - CollectionHierarchyKind.IReadOnlyCollection => 50, - CollectionHierarchyKind.IEnumerable => 60, - _ => throw new ArgumentOutOfRangeException(nameof(kind)) - }; - - private static void AddCollectionSummaryMap(CollectionHierarchyKind kind, int offset) { - switch (kind) { - case CollectionHierarchyKind.List: - Mapper.AddMap, CollectionSummaryTarget>(x => new CollectionSummaryTarget { Count = x.Count() + offset }); - break; - case CollectionHierarchyKind.IList: - Mapper.AddMap, CollectionSummaryTarget>(x => new CollectionSummaryTarget { Count = x.Count() + offset }); - break; - case CollectionHierarchyKind.ICollection: - Mapper.AddMap, CollectionSummaryTarget>(x => new CollectionSummaryTarget { Count = x.Count() + offset }); - break; - case CollectionHierarchyKind.IReadOnlyList: - Mapper.AddMap, CollectionSummaryTarget>(x => new CollectionSummaryTarget { Count = x.Count() + offset }); - break; - case CollectionHierarchyKind.IReadOnlyCollection: - Mapper.AddMap, CollectionSummaryTarget>(x => new CollectionSummaryTarget { Count = x.Count() + offset }); - break; - case CollectionHierarchyKind.IEnumerable: - Mapper.AddMap, CollectionSummaryTarget>(x => new CollectionSummaryTarget { Count = x.Count() + offset }); - break; - default: - throw new ArgumentOutOfRangeException(nameof(kind)); - } - } - - private static CollectionSummaryTarget MapCollectionSummary(CollectionHierarchyKind sourceKind, List source) - => sourceKind switch { - CollectionHierarchyKind.List => Mapper.Map, CollectionSummaryTarget>(source), - CollectionHierarchyKind.IList => Mapper.Map, CollectionSummaryTarget>(source), - CollectionHierarchyKind.ICollection => Mapper.Map, CollectionSummaryTarget>(source), - CollectionHierarchyKind.IReadOnlyList => Mapper.Map, CollectionSummaryTarget>(source), - CollectionHierarchyKind.IReadOnlyCollection => Mapper.Map, CollectionSummaryTarget>(source), - CollectionHierarchyKind.IEnumerable => Mapper.Map, CollectionSummaryTarget>(source), - _ => throw new ArgumentOutOfRangeException(nameof(sourceKind)) - }; - - private static IEnumerable MapCollectionElements( - CollectionHierarchyKind sourceKind, - CollectionHierarchyKind targetKind, - List source - ) - => sourceKind switch { - CollectionHierarchyKind.List => MapCollectionElementsCore>(source, targetKind), - CollectionHierarchyKind.IList => MapCollectionElementsCore>(source, targetKind), - CollectionHierarchyKind.ICollection => MapCollectionElementsCore>(source, targetKind), - CollectionHierarchyKind.IReadOnlyList => MapCollectionElementsCore>(source, targetKind), - CollectionHierarchyKind.IReadOnlyCollection => MapCollectionElementsCore>(source, targetKind), - CollectionHierarchyKind.IEnumerable => MapCollectionElementsCore>(source, targetKind), - _ => throw new ArgumentOutOfRangeException(nameof(sourceKind)) - }; - - private static IEnumerable MapCollectionElementsCore( - TSourceCollection source, - CollectionHierarchyKind targetKind - ) where TSourceCollection : IEnumerable - => targetKind switch { - CollectionHierarchyKind.List => Mapper.Map>(source), - CollectionHierarchyKind.IList => Mapper.Map>(source), - CollectionHierarchyKind.ICollection => Mapper.Map>(source), - CollectionHierarchyKind.IReadOnlyList => Mapper.Map>(source), - CollectionHierarchyKind.IReadOnlyCollection => Mapper.Map>(source), - CollectionHierarchyKind.IEnumerable => Mapper.Map>(source), - _ => throw new ArgumentOutOfRangeException(nameof(targetKind)) - }; - - private static bool CanResolveCollectionMap(CollectionHierarchyKind sourceKind, CollectionHierarchyKind declaredMapKind) - => sourceKind switch { - CollectionHierarchyKind.List => declaredMapKind is CollectionHierarchyKind.List - or CollectionHierarchyKind.IList - or CollectionHierarchyKind.ICollection - or CollectionHierarchyKind.IReadOnlyList - or CollectionHierarchyKind.IReadOnlyCollection - or CollectionHierarchyKind.IEnumerable, - CollectionHierarchyKind.IList => declaredMapKind is CollectionHierarchyKind.IList - or CollectionHierarchyKind.ICollection - or CollectionHierarchyKind.IEnumerable, - CollectionHierarchyKind.ICollection => declaredMapKind is CollectionHierarchyKind.ICollection - or CollectionHierarchyKind.IEnumerable, - CollectionHierarchyKind.IReadOnlyList => declaredMapKind is CollectionHierarchyKind.IReadOnlyList - or CollectionHierarchyKind.IReadOnlyCollection - or CollectionHierarchyKind.IEnumerable, - CollectionHierarchyKind.IReadOnlyCollection => declaredMapKind is CollectionHierarchyKind.IReadOnlyCollection - or CollectionHierarchyKind.IEnumerable, - CollectionHierarchyKind.IEnumerable => declaredMapKind is CollectionHierarchyKind.IEnumerable, - _ => false - }; - - public enum CollectionHierarchyKind { - List, - IList, - ICollection, - IReadOnlyList, - IReadOnlyCollection, - IEnumerable - } - - private sealed class CollectionElementSource { public int Value { get; set; } } - private sealed class CollectionElementTarget { public int Value { get; set; } } - private sealed class CollectionSummaryTarget { public int Count { get; set; } } - private sealed class CollectionContainerSource { public List Items { get; set; } = []; } - private sealed class CollectionContainerTarget { public List Items { get; set; } = []; } - private sealed class NullableCollectionContainerSource { public List? Items { get; set; } } - private sealed class NullableCollectionContainerTarget { public List? Items { get; set; } } -} diff --git a/Mapify.NET.Tests/MapperDefaultValueBehaviorTests.cs b/Mapify.NET.Tests/MapperDefaultValueBehaviorTests.cs deleted file mode 100644 index cda47d5..0000000 --- a/Mapify.NET.Tests/MapperDefaultValueBehaviorTests.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.Linq.Expressions; - -namespace Mapify.NET.Tests; - -[Collection("Mapper Tests")] -public class MapperDefaultValueBehaviorTests { - public MapperDefaultValueBehaviorTests() { - Mapper.UseDefaultMapIfTypeMapIsMissing(false); - Mapper.ClearMappings(); - } - - [Fact] - public void CreateMap_ShouldKeepNull_ForNullableCollection_WhenSourceIsNull() { - Mapper.AddMap( - x => new DefaultElementTarget { Value = x.Value + 1 } - ); - - var map = Mapper.CreateMap(); - var mapped = map.Map(new NullableCollectionSource { Items = null }); - - Assert.Null(mapped.Items); - } - - [Fact] - public void CreateMap_ShouldUseEmpty_ForNonNullableCollection_WhenSourceIsNull() { - Mapper.AddMap( - x => new DefaultElementTarget { Value = x.Value + 1 } - ); - - var map = Mapper.CreateMap(); - var mapped = map.Map(new NullableCollectionSource { Items = null }); - - Assert.NotNull(mapped.Items); - Assert.Empty(mapped.Items); - } - - [Fact] - public void CreateMap_ShouldUseEmpty_ForRequiredCollection_WhenSourceIsNull() { - Mapper.AddMap( - x => new DefaultElementTarget { Value = x.Value + 1 } - ); - - var map = Mapper.CreateMap(); - var mapped = map.Map(new NullableCollectionSource { Items = null }); - - Assert.NotNull(mapped.Items); - Assert.Empty(mapped.Items); - } - - [Fact] - public void CreateMap_ShouldPreserveInitializer_ForRequiredCollection_WhenSourcePropertyIsMissing() { - var map = Mapper.CreateMap(); - - var mapped = map.Map(new SourceWithoutItems { Value = 42 }); - - Assert.Equal(42, mapped.Value); - Assert.Equal([77], mapped.Items.Select(x => x.Value).ToArray()); - } - - [Fact] - public void CreateMap_ShouldUseEmpty_ForUninitializedNonNullableCollection_WhenSourcePropertyIsMissing() { - var map = Mapper.CreateMap(); - - var mapped = map.Map(new SourceWithoutItems { Value = 42 }); - - Assert.Equal(42, mapped.Value); - Assert.NotNull(mapped.Items); - Assert.Empty(mapped.Items); - } - - [Fact] - public void CreateMap_ShouldPreserveDerivedCtorInitializer_ForInheritedCollection_WhenSourcePropertyIsMissing() { - var map = Mapper.CreateMap(); - - var mapped = map.Map(new SourceWithoutItems { Value = 42 }); - - Assert.Equal(42, mapped.Value); - Assert.Equal([88], mapped.Items.Select(x => x.Value).ToArray()); - } - - [Fact] - public void ClearMappings_ShouldClearInitializedPropertyCache() { - var map = Mapper.CreateMap(); - _ = map.Map(new SourceWithoutItems { Value = 1 }); - - var cacheField = typeof(Mapper).GetField("_initializedPropertyCache", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)!; - var cache = cacheField.GetValue(null)!; - var countProperty = cache.GetType().GetProperty("Count")!; - - var countBefore = (int)countProperty.GetValue(cache)!; - Assert.True(countBefore > 0); - - Mapper.ClearMappings(); - - var countAfter = (int)countProperty.GetValue(cache)!; - Assert.Equal(0, countAfter); - } - - private sealed class DefaultElementSource { public int Value { get; set; } } - private sealed class DefaultElementTarget { public int Value { get; set; } } - - private sealed class NullableCollectionSource { public List? Items { get; set; } } - private sealed class NullableCollectionTarget { public List? Items { get; set; } } - private sealed class NonNullableCollectionTarget { public List Items { get; set; } = null!; } - private sealed class RequiredCollectionTarget { public required List Items { get; set; } } - - private sealed class SourceWithoutItems { public int Value { get; set; } } - private sealed class RequiredCollectionInitializedTarget { - public int Value { get; set; } - public required List Items { get; set; } = [new DefaultElementTarget { Value = 77 }]; - } - - private sealed class NonNullableUninitializedCollectionTarget { - public int Value { get; set; } - public List Items { get; set; } = null!; - } - - private abstract class BaseCollectionTarget { - public int Value { get; set; } - public List Items { get; set; } = null!; - } - - private sealed class DerivedCtorInitializedCollectionTarget : BaseCollectionTarget { - public DerivedCtorInitializedCollectionTarget() { - Items = [new DefaultElementTarget { Value = 88 }]; - } - } -} diff --git a/Mapify.NET.Tests/MapperTests.GetMapBehavior.cs b/Mapify.NET.Tests/MapperTests.GetMapBehavior.cs deleted file mode 100644 index d9e35a3..0000000 --- a/Mapify.NET.Tests/MapperTests.GetMapBehavior.cs +++ /dev/null @@ -1,60 +0,0 @@ -namespace Mapify.NET.Tests; - -public partial class MapperTests { - [Fact] - public void GetMap_ShouldReturnExpression() { - var source = new A2 { Prop = 10 }; - - Mapper.UseDefaultMapIfTypeMapIsMissing(true); - var expr = Mapper.GetMap(); - Assert.NotNull(expr); - - var func = expr!.Compile(); - var target = func(source); - - Assert.Equal(10, target.Prop); - } - - [Fact] - public void GetMap_ShouldReturnCachedDefaultExpression_OnSecondCall() { - Mapper.UseDefaultMapIfTypeMapIsMissing(true); - var expr1 = Mapper.GetMap(); - var expr2 = Mapper.GetMap(); - - Assert.NotNull(expr1); - Assert.Same(expr1, expr2); - } - - [Fact] - public void GetMap_ShouldReturnNull_WhenMapMissingAndDefaultDisabled() { - Mapper.UseDefaultMapIfTypeMapIsMissing(false); - - var expr = Mapper.GetMap(); - - Assert.Null(expr); - } - - [Fact] - public void GetRequiredMap_ShouldThrow_WhenMapMissingAndDefaultDisabled() { - Mapper.UseDefaultMapIfTypeMapIsMissing(false); - - Assert.Throws(() => Mapper.GetRequiredMap()); - } - - [Fact] - public void GetMap_ShouldRequireExactTypes_AndNotReturnNullableOrElementFallbackMaps() { - Mapper.UseDefaultMapIfTypeMapIsMissing(false); - Mapper.AddMap(x => new PrecedenceNumberTarget { Value = x.Value + 1 }); - Mapper.AddMap( - x => new PrecedenceCollectionElementTarget { Value = x.Value + 1 } - ); - - var nullableMap = Mapper.GetMap(); - var collectionMap = Mapper.GetMap, List>(); - - Assert.Null(nullableMap); - Assert.Null(collectionMap); - Assert.Throws(() => Mapper.GetRequiredMap()); - Assert.Throws(() => Mapper.GetRequiredMap, List>()); - } -} \ No newline at end of file diff --git a/Mapify.NET.Tests/MapperTests.IgnoreMarkerBehavior.cs b/Mapify.NET.Tests/MapperTests.IgnoreMarkerBehavior.cs deleted file mode 100644 index b79930f..0000000 --- a/Mapify.NET.Tests/MapperTests.IgnoreMarkerBehavior.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Linq.Expressions; - -namespace Mapify.NET.Tests; - -public partial class MapperTests { - [Fact] - public void CreateMap_ShouldRemoveIgnoredOptionalPropertyBinding() { - var map = CreateMapWithResolver(IgnoreMarkerProfile.CreateOptionalIgnorePartial(), null); - - var init = Assert.IsType(map.Body); - Assert.DoesNotContain(init.Bindings, x => x.Member.Name == nameof(IgnoreTarget.Ignored)); - - var mapped = map.Map(new IgnoreSource { Included = 7, Ignored = 99 }); - Assert.Equal(7, mapped.Included); - Assert.Equal(default, mapped.Ignored); - } - - [Fact] - public void CreateAndAddMap_WithIgnoreMarker_ShouldIgnorePropertyInStaticMapper() { - Mapper.CreateAndAddMap(IgnoreMarkerProfile.CreateOptionalIgnorePartial()); - - var mapped = Mapper.Map(new IgnoreSource { Included = 7, Ignored = 99 }); - - Assert.Equal(7, mapped.Included); - Assert.Equal(default, mapped.Ignored); - } - - [Fact] - public void CreateAndAddMap_WithIgnoreMarker_ShouldSkipIgnoredPropertyWhenMappingToExistingInStaticMapper() { - Mapper.CreateAndAddMap(IgnoreMarkerProfile.CreateOptionalIgnorePartial()); - - var target = new IgnoreTarget { Included = 1, Ignored = 123 }; - Mapper.Map(new IgnoreSource { Included = 10, Ignored = 999 }, target); - - Assert.Equal(10, target.Included); - Assert.Equal(123, target.Ignored); - } - - [Fact] - public void CreateMap_ShouldBindDefaultValue_WhenRequiredPropertyIsIgnored() { - var map = CreateMapWithResolver(IgnoreMarkerProfile.CreateRequiredIgnorePartial(), null); - var mapped = map.Map(new IgnoreSource { Included = 7, Ignored = 99 }); - - Assert.Equal(7, mapped.Included); - Assert.Equal(default, mapped.Ignored); - } - - [Fact] - public void CompileMapper_ShouldSkipIgnoredProperties_WhenMappingToExistingObject() { - var map = CreateMapWithResolver(IgnoreMarkerProfile.CreateRequiredIgnorePartial(), null); - var action = Mapper.CompileMapper(map); - - var target = new IgnoreRequiredTarget { Included = 1, Ignored = 123 }; - action(new IgnoreSource { Included = 10, Ignored = 999 }, target); - - Assert.Equal(10, target.Included); - Assert.Equal(123, target.Ignored); - } -} \ No newline at end of file diff --git a/Mapify.NET.Tests/MapperTests.ImplicitAndNullableBehavior.cs b/Mapify.NET.Tests/MapperTests.ImplicitAndNullableBehavior.cs deleted file mode 100644 index 4524cb4..0000000 --- a/Mapify.NET.Tests/MapperTests.ImplicitAndNullableBehavior.cs +++ /dev/null @@ -1,115 +0,0 @@ -namespace Mapify.NET.Tests; - -public partial class MapperTests { - [Fact] - public void CreateMap_ShouldUseExistingNestedMapImplicitly_WhenPropertyTypesDiffer() { - Mapper.AddMap(x => new NestedTarget { Value = x.Value + 1 }); - - var parentMap = Mapper.CreateMap(); - var mapped = parentMap.Map(new ParentWithNestedSource { - Nested = new NestedSource { Value = 9 } - }); - - Assert.NotNull(mapped.Nested); - Assert.Equal(10, mapped.Nested.Value); - } - - [Fact] - public void CreateMap_ShouldLiftNonNullableMap_ForAllNullableVariants() { - Mapper.AddMap(x => new NumberTarget { Value = x.Value + 1 }); - - var s1 = new ContainerSrcToTarget { Number = new NumberSource { Value = 1 } }; - var s2 = new ContainerSrcToNullableTarget { Number = new NumberSource { Value = 2 } }; - var s3WithValue = new ContainerNullableSrcToTarget { Number = new NumberSource { Value = 3 } }; - var s3Null = new ContainerNullableSrcToTarget { Number = null }; - var s4WithValue = new ContainerNullableSrcToNullableTarget { Number = new NumberSource { Value = 4 } }; - var s4Null = new ContainerNullableSrcToNullableTarget { Number = null }; - - var m1 = Mapper.CreateMap(); - var m2 = Mapper.CreateMap(); - var m3 = Mapper.CreateMap(); - var m4 = Mapper.CreateMap(); - - var r1 = m1.Map(s1); - var r2 = m2.Map(s2); - var r3WithValue = m3.Map(s3WithValue); - var r3Null = m3.Map(s3Null); - var r4WithValue = m4.Map(s4WithValue); - var r4Null = m4.Map(s4Null); - - Assert.Equal(2, r1.Number.Value); - Assert.NotNull(r2.Number); - Assert.Equal(3, r2.Number!.Value.Value); - - Assert.Equal(4, r3WithValue.Number.Value); - Assert.Equal(default, r3Null.Number); - - Assert.NotNull(r4WithValue.Number); - Assert.Equal(5, r4WithValue.Number!.Value.Value); - Assert.Null(r4Null.Number); - } - - [Fact] - public void CreateMap_ShouldPreferExactNullableMap_OverUnderlyingTypeFallback() { - Mapper.AddMap(x => new PrecedenceNumberTarget { Value = x.Value + 1 }); - Mapper.AddMap( - x => x == null - ? (PrecedenceNumberTarget?)null - : new PrecedenceNumberTarget { Value = x.Value.Value + 100 } - ); - - var map = Mapper.CreateMap(); - var mapped = map.Map(new PrecedenceNullableContainerSource { - Item = new PrecedenceNumberSource { Value = 5 } - }); - - Assert.NotNull(mapped.Item); - Assert.Equal(105, mapped.Item!.Value.Value); - } - - [Fact] - public void CreateMap_ShouldFallbackToUnderlyingNullableTypeMap_WhenExactNullableMapIsMissing() { - Mapper.AddMap(x => new PrecedenceNumberTarget { Value = x.Value + 1 }); - - var map = Mapper.CreateMap(); - var mapped = map.Map(new PrecedenceNullableContainerSource { - Item = new PrecedenceNumberSource { Value = 5 } - }); - - Assert.NotNull(mapped.Item); - Assert.Equal(6, mapped.Item!.Value.Value); - } - - [Fact] - public void CreateMap_ShouldThrowForAmbiguousEnumerableElementType() { - var ex = Assert.Throws(() => - Mapper.CreateMap() - ); - - Assert.Contains("multiple IEnumerable element types", ex.Message, StringComparison.Ordinal); - } - - [Fact] - public void CreateMap_NullableComplexStructToNonNullableSameType_ShouldUseExistingMapWhenPresent() { - Mapper.AddMap(x => new ComplexValue { Value = x.Value + 1 }); - - var withValue = new ComplexValueContainerSource { Item = new ComplexValue { Value = 10 } }; - var withNull = new ComplexValueContainerSource { Item = null }; - - var map = Mapper.CreateMap(); - var mappedWithValue = map.Map(withValue); - var mappedWithNull = map.Map(withNull); - - Assert.Equal(11, mappedWithValue.Item.Value); - Assert.Equal(default, mappedWithNull.Item); - } - - [Fact] - public void CreateMap_ShouldMaterializeEnumerableIntoPropertyTypeWithIEnumerableConstructor() { - var map = Mapper.CreateMap(); - - var mapped = map.Map(new EnumerableCtorSource { Numbers = [1, 2, 3] }); - - Assert.Equal([1, 2, 3], mapped.Numbers); - } -} \ No newline at end of file diff --git a/Mapify.NET.Tests/MapperTests.MapCoreBehavior.cs b/Mapify.NET.Tests/MapperTests.MapCoreBehavior.cs deleted file mode 100644 index 07e0b39..0000000 --- a/Mapify.NET.Tests/MapperTests.MapCoreBehavior.cs +++ /dev/null @@ -1,175 +0,0 @@ -using System.Linq.Expressions; - -namespace Mapify.NET.Tests; - -public partial class MapperTests { - [Fact] - public void AddMap_ShouldThrowException_WhenMappingAlreadyExists() { - Mapper.AddMap(s => new TargetSubset { Name = s.Name }); - - Assert.Throws(() => Mapper.AddMap(s => new TargetSubset { Name = s.Name })); - } - - [Fact] - public void Map_ShouldUseExplicitMap_WhenAdded() { - Mapper.AddMap(s => new B1 { Prop = s.Prop + 10 }); - var source = new A1 { Prop = 5 }; - - var target = Mapper.Map(source); - - Assert.Equal(15, target.Prop); - } - - [Fact] - public void Map_ToExistingObject_ShouldUpdateProperties() { - var source = new Source { Id = 10, Name = "Updated" }; - var target = new Target { Id = 1, Name = "Original" }; - - Mapper.UseDefaultMapIfTypeMapIsMissing(true); - Mapper.Map(source, target); - - Assert.Equal(10, target.Id); - Assert.Equal("Updated", target.Name); - } - - [Fact] - public void Map_ToExistingObject_WithExplicitMap() { - var source = new A4 { Prop = 20 }; - var target = new B4 { Prop = 10 }; - - Mapper.AddMap(s => new B4 { Prop = s.Prop + 10 }); - - Mapper.Map(source, target); - - Assert.Equal(30, target.Prop); - } - - [Fact] - public void Map_ShouldHandleNullableToNonNullable_WithValues() { - var source = new SourceNullable { Value = 10 }; - - Mapper.UseDefaultMapIfTypeMapIsMissing(true); - var target = Mapper.Map(source); - - Assert.Equal(10, target.Value); - } - - [Fact] - public void Map_ShouldHandleNullableToNonNullable_WithNull() { - var source = new SourceNullable { Value = null }; - - Mapper.UseDefaultMapIfTypeMapIsMissing(true); - var target = Mapper.Map(source); - - Assert.Equal(0, target.Value); - } - - [Fact] - public void Map_ShouldHandleNonNullableToNullable() { - var source = new TargetNonNullable { Value = 10 }; - - Mapper.UseDefaultMapIfTypeMapIsMissing(true); - var target = Mapper.Map(source); - - Assert.Equal(10, target.Value); - } - - [Fact] - public void CreateAndAddMap_ShouldCreateFullMap() { - var source = new C1 { Id = 1, Name = "Test" }; - - var createdMap = Mapper.CreateAndAddMap(s => new D1 { Name = s.Name + "_" }); - var target = Mapper.Map(source); - - Assert.NotNull(createdMap); - Assert.Equal(1, target.Id); - Assert.Equal("Test_", target.Name); - } - - [Fact] - public void Map_ShouldSupportEnumToEnumValueMapping() { - Mapper.AddMap(x => x == SourceStatus.Active ? TargetStatus.Enabled : TargetStatus.Disabled); - - var result = Mapper.Map(SourceStatus.Active); - - Assert.Equal(TargetStatus.Enabled, result); - } - - [Fact] - public void Map_ShouldSupportObjectToStringValueMapping() { - Mapper.AddMap(x => x.Name); - - var result = Mapper.Map(new PersonNameSource { Name = "Mapify" }); - - Assert.Equal("Mapify", result); - } - - [Fact] - public void Map_ToExisting_ShouldThrowForValueMapping() { - Mapper.AddMap(x => x.Name); - var source = new PersonNameSource { Name = "Mapify" }; - var target = string.Empty; - - Assert.Throws(() => Mapper.Map(source, target)); - } - - [Fact] - public void CompileMapper_ShouldReturnAction() { - Expression> expr = x => new B2 { Prop = x.Prop }; - - var action = Mapper.CompileMapper(expr); - var source = new A2 { Prop = 50 }; - var target = new B2(); - action(source, target); - - Assert.Equal(50, target.Prop); - } - - [Fact] - public void CompileMapper_ShouldThrow_WhenExpressionIsNotMemberInitializer() { - Expression> expr = x => new B2(); - - Assert.Throws(() => Mapper.CompileMapper(expr)); - } - - [Fact] - public void CompileMapper_ShouldThrow_WhenBindingIsNotMemberAssignment() { - Expression> expr = x => new ListBindingTarget { Items = { x.Value } }; - - Assert.Throws(() => Mapper.CompileMapper(expr)); - } - - [Fact] - public void Map_ExpressionOverload_WithCache_ShouldWorkForNewAndExistingTargets() { - Expression> expr = x => new B2 { Prop = x.Prop + 1 }; - - var firstNew = expr.Map(new A2 { Prop = 1 }, cache: true); - var secondNew = expr.Map(new A2 { Prop = 2 }, cache: true); - - var existing = new B2(); - expr.Map(new A2 { Prop = 3 }, existing, cache: true); - expr.Map(new A2 { Prop = 4 }, existing, cache: true); - - Assert.Equal(2, firstNew.Prop); - Assert.Equal(3, secondNew.Prop); - Assert.Equal(5, existing.Prop); - } - - [Fact] - public void ProjectTo_IQueryable_ShouldApplyRegisteredMap_AndAllowFurtherComposition() { - Mapper.AddMap(x => new B2 { Prop = x.Prop + 1 }); - - var projectedValues = new[] { - new A2 { Prop = 1 }, - new A2 { Prop = 2 }, - new A2 { Prop = 3 } - } - .AsQueryable() - .ProjectTo() - .Where(x => x.Prop > 2) - .Select(x => x.Prop) - .ToArray(); - - Assert.Equal([3, 4], projectedValues); - } -} \ No newline at end of file diff --git a/Mapify.NET.Tests/MapperTests.ResolverFallbackBehavior.cs b/Mapify.NET.Tests/MapperTests.ResolverFallbackBehavior.cs deleted file mode 100644 index 719c45c..0000000 --- a/Mapify.NET.Tests/MapperTests.ResolverFallbackBehavior.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Linq.Expressions; - -namespace Mapify.NET.Tests; - -public partial class MapperTests { - [Fact] - public void CreateMap_InternalResolverNull_ShouldFallbackWithoutExistingMapBinding() { - var map = CreateMapWithResolver(null, null); - - var mapped = map.Map(new ResolverSource { Child = new ResolverInnerSource { Value = 8 } }); - - Assert.Null(mapped.Child); - } - - [Fact] - public void CreateMap_InternalResolverWithInvalidParameterCount_ShouldFallbackWithoutBinding() { - static LambdaExpression? Resolver(Type a, Type b, string? c) => - (Expression>)((left, right) => new ResolverInnerTarget { Value = left.Value + right.Value }); - - var map = CreateMapWithResolver(null, Resolver); - var mapped = map.Map(new ResolverSource { Child = new ResolverInnerSource { Value = 3 } }); - - Assert.Null(mapped.Child); - } - - [Fact] - public void CreateMap_InternalResolverWithIncompatibleSourceParameter_ShouldFallbackWithoutBinding() { - static LambdaExpression? Resolver(Type a, Type b, string? c) => - (Expression>)(text => new ResolverInnerTarget { Value = text.Length }); - - var map = CreateMapWithResolver(null, Resolver); - var mapped = map.Map(new ResolverSource { Child = new ResolverInnerSource { Value = 5 } }); - - Assert.Null(mapped.Child); - } - - [Fact] - public void CreateMap_InternalResolverWithIncompatibleMappedResult_ShouldFallbackWithoutBinding() { - static LambdaExpression? Resolver(Type a, Type b, string? c) => - (Expression>)(x => x.Value); - - var map = CreateMapWithResolver(null, Resolver); - var mapped = map.Map(new ResolverSource { Child = new ResolverInnerSource { Value = 6 } }); - - Assert.Null(mapped.Child); - } -} \ No newline at end of file diff --git a/Mapify.NET.Tests/MapperTests.TestModels.cs b/Mapify.NET.Tests/MapperTests.TestModels.cs deleted file mode 100644 index 7f09d70..0000000 --- a/Mapify.NET.Tests/MapperTests.TestModels.cs +++ /dev/null @@ -1,102 +0,0 @@ -namespace Mapify.NET.Tests; - -public partial class MapperTests { - public class Source { public int Id { get; set; } public string Name { get; set; } = string.Empty; public int? Age { get; set; } public DateTime Date { get; set; } } - public class Target { public int Id { get; set; } public string Name { get; set; } = string.Empty; public int? Age { get; set; } public DateTime Date { get; set; } } - public class TargetSubset { public string Name { get; set; } = string.Empty; } - public class TargetWithDifferentProp { public int Id { get; set; } public string FullName { get; set; } = string.Empty; } - - public class SourceNullable { public int? Value { get; set; } } - public class TargetNonNullable { public int Value { get; set; } } - public class TargetNullable { public int? Value { get; set; } } - - public class C1 { public int Id { get; set; } public string Name { get; set; } = string.Empty; } - public class D1 { public int Id { get; set; } public string Name { get; set; } = string.Empty; } - - public class A1 { public int Prop { get; set; } } - public class B1 { public int Prop { get; set; } } - - public class A2 { public int Prop { get; set; } } - public class B2 { public int Prop { get; set; } } - - public class A3 { public int Prop { get; set; } } - public class B3 { public int Prop { get; set; } } - - public class A4 { public int Prop { get; set; } } - public class B4 { public int Prop { get; set; } } - - public class NestedSource { public int Value { get; set; } } - public class NestedTarget { public int Value { get; set; } } - - public class ParentWithNestedSource { public NestedSource Nested { get; set; } = new NestedSource(); } - public class ParentWithNestedTarget { public NestedTarget Nested { get; set; } = new NestedTarget(); } - - public struct NumberSource { public int Value { get; set; } } - public struct NumberTarget { public int Value { get; set; } } - - public class ContainerSrcToTarget { public NumberSource Number { get; set; } } - public class ContainerTarget { public NumberTarget Number { get; set; } } - - public class ContainerSrcToNullableTarget { public NumberSource Number { get; set; } } - public class ContainerNullableTarget { public NumberTarget? Number { get; set; } } - - public class ContainerNullableSrcToTarget { public NumberSource? Number { get; set; } } - public class ContainerNullableSrcToNullableTarget { public NumberSource? Number { get; set; } } - - public struct ComplexValue { public int Value { get; set; } } - public class ComplexValueContainerSource { public ComplexValue? Item { get; set; } } - public class ComplexValueContainerTarget { public ComplexValue Item { get; set; } } - - public struct PrecedenceNumberSource { public int Value { get; set; } } - public struct PrecedenceNumberTarget { public int Value { get; set; } } - public class PrecedenceNullableContainerSource { public PrecedenceNumberSource? Item { get; set; } } - public class PrecedenceNullableContainerTarget { public PrecedenceNumberTarget? Item { get; set; } } - - public class PrecedenceCollectionElementSource { public int Value { get; set; } } - public class PrecedenceCollectionElementTarget { public int Value { get; set; } } - public class PrecedenceCollectionContainerSource { public List Items { get; set; } = []; } - public class PrecedenceCollectionContainerTarget { public List Items { get; set; } = []; } - public class NullableCollectionContainerSource { public List? Items { get; set; } } - public class NullableCollectionContainerTarget { public List? Items { get; set; } } - - public class AmbiguousEnumerable : IEnumerable, IEnumerable { - IEnumerator IEnumerable.GetEnumerator() => Array.Empty().AsEnumerable().GetEnumerator(); - - IEnumerator IEnumerable.GetEnumerator() => Array.Empty().AsEnumerable().GetEnumerator(); - - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() - => ((IEnumerable)this).GetEnumerator(); - } - - public class AmbiguousEnumerableContainerSource { public AmbiguousEnumerable Items { get; set; } = new AmbiguousEnumerable(); } - public class AmbiguousEnumerableContainerTarget { public List Items { get; set; } = []; } - - public class ListBindingSource { public int Value { get; set; } } - public class ListBindingTarget { public List Items { get; } = []; } - - public class EnumerableCtorSource { public int[] Numbers { get; set; } = []; } - public class EnumerableCtorContainerTarget { public EnumerableCtorCollection Numbers { get; set; } = new EnumerableCtorCollection([]); } - public class EnumerableCtorCollection(IEnumerable values) : IEnumerable { - private readonly int[] _values = [.. values]; - - public int[] ToArray() => _values; - - public IEnumerator GetEnumerator() => ((IEnumerable)_values).GetEnumerator(); - - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => _values.GetEnumerator(); - } - - public class ResolverInnerSource { public int Value { get; set; } } - public class ResolverInnerTarget { public int Value { get; set; } } - public class ResolverSource { public ResolverInnerSource Child { get; set; } = new ResolverInnerSource(); } - public class ResolverTarget { public ResolverInnerTarget? Child { get; set; } } - - public class IgnoreSource { public int Included { get; set; } public int Ignored { get; set; } } - public class IgnoreTarget { public int Included { get; set; } public int Ignored { get; set; } } - public class IgnoreRequiredTarget { public int Included { get; set; } public required int Ignored { get; set; } } - - public class PersonNameSource { public string Name { get; set; } = string.Empty; } - - public enum SourceStatus { Inactive = 0, Active = 1 } - public enum TargetStatus { Disabled = 0, Enabled = 1 } -} diff --git a/Mapify.NET.Tests/MapperTests.TestProfilesAndResources.cs b/Mapify.NET.Tests/MapperTests.TestProfilesAndResources.cs deleted file mode 100644 index 8f18378..0000000 --- a/Mapify.NET.Tests/MapperTests.TestProfilesAndResources.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Linq.Expressions; -using System.Reflection; - -namespace Mapify.NET.Tests; - -public partial class MapperTests { - private sealed class IgnoreMarkerProfile : MapifyProfile { - protected override void Configure() { - } - - public static Expression> CreateOptionalIgnorePartial() - => x => new IgnoreTarget { - Included = x.Included, - Ignored = Ignore() - }; - - public static Expression> CreateRequiredIgnorePartial() - => x => new IgnoreRequiredTarget { - Included = x.Included, - Ignored = Ignore() - }; - } - - private static Expression> CreateMapWithResolver( - Expression>? partial, - Func? resolver - ) { - var method = typeof(Mapper) - .GetMethods(BindingFlags.Static | BindingFlags.NonPublic) - .Single(x => x.Name == "CreateMap" && x.IsGenericMethodDefinition && x.GetParameters().Length == 2); - - var generic = method.MakeGenericMethod(typeof(TSource), typeof(TTarget)); - return (Expression>)generic.Invoke(null, [partial, resolver])!; - } -} diff --git a/Mapify.NET.Tests/MapperTests.UseDefaultMapBehavior.cs b/Mapify.NET.Tests/MapperTests.UseDefaultMapBehavior.cs deleted file mode 100644 index 7ed875a..0000000 --- a/Mapify.NET.Tests/MapperTests.UseDefaultMapBehavior.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace Mapify.NET.Tests; - -public partial class MapperTests { - [Fact] - public void Map_ShouldMapProperties_WhenAutoMapIsUsed() { - var source = new Source { Id = 1, Name = "Test", Age = 25, Date = DateTime.Now }; - - Mapper.UseDefaultMapIfTypeMapIsMissing(true); - var target = Mapper.Map(source); - - Assert.Equal(source.Id, target.Id); - Assert.Equal(source.Name, target.Name); - Assert.Equal(source.Age, target.Age); - Assert.Equal(source.Date, target.Date); - } - - [Fact] - public void Map_ShouldThrowException_WhenMapMissingAndFlagFalse() { - Mapper.UseDefaultMapIfTypeMapIsMissing(false); - var source = new A2 { Prop = 5 }; - - Assert.Throws(() => Mapper.Map(source)); - } - - [Fact] - public void Map_ShouldUseDefaultMap_WhenMapMissingAndFlagTrue() { - var source = new A2 { Prop = 5 }; - - Mapper.UseDefaultMapIfTypeMapIsMissing(true); - var target = Mapper.Map(source); - - Assert.Equal(5, target.Prop); - } - - [Fact] - public void Test_GlobalUseDefaultMapIfTypeMapIsMissing() { - Mapper.UseDefaultMapIfTypeMapIsMissing(true); - var source = new A3 { Prop = 5 }; - - var target = Mapper.Map(source); - - Assert.Equal(5, target.Prop); - } -} \ No newline at end of file diff --git a/Mapify.NET.Tests/MapperTests.cs b/Mapify.NET.Tests/MapperTests.cs deleted file mode 100644 index b60f067..0000000 --- a/Mapify.NET.Tests/MapperTests.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Mapify.NET.Tests; - -[Collection("Mapper Tests")] -public partial class MapperTests { - public MapperTests() { - Mapper.UseDefaultMapIfTypeMapIsMissing(false); - Mapper.ClearMappings(); - } -} diff --git a/Mapify/Mapper.cs b/Mapify/Mapify.MappingInternals.cs similarity index 71% rename from Mapify/Mapper.cs rename to Mapify/Mapify.MappingInternals.cs index df86304..ed2c308 100644 --- a/Mapify/Mapper.cs +++ b/Mapify/Mapify.MappingInternals.cs @@ -1,16 +1,12 @@ -using System.Diagnostics.CodeAnalysis; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Reflection; using System.Runtime.CompilerServices; -using System.Runtime.ExceptionServices; -using System.Collections.Concurrent; -namespace Mapify.NET; -/// -/// Static mapper API for registering and executing mappings. -/// -public static class Mapper { +namespace Mapify.NET; +public partial class Mapify { private const string _useMapMarkerName = "UseMap"; private const string _ignoreMarkerName = "Ignore"; @@ -19,143 +15,10 @@ public static class Mapper { private const string _parameterMarkerName = "Parameter"; - private static bool _globalUseDefaultMapIfTypeMapIsMissing = false; - - /// - /// Configures whether a default map should be used if no type map was added with . - /// Be default, this is set to false, meaning that an exception will be thrown if no type map is found. - /// - public static void UseDefaultMapIfTypeMapIsMissing(bool value) { - _globalUseDefaultMapIfTypeMapIsMissing = value; - } - - /// - /// Clears all mappings and compiled delegates. - /// - public static void ClearMappings() { - _converters.Clear(); - _defaultMapCache.Clear(); - _compiledMapToExistingCache.Clear(); - _compiledMapToNewCache.Clear(); - _compiledSpecificMapToExistingCache.Clear(); - _compiledSpecificMapToNewCache.Clear(); - _initializedPropertyCache.Clear(); - } - - /// - /// Stores converters added with which are then returned by - /// - private static readonly Dictionary, LambdaExpression> _converters = []; - - /// - /// Caches default mappings such that they do not have to be created multiple times when calling GetMap(useDefaultMapIfTypeMapIsMissing: true). - /// - private static readonly Dictionary, LambdaExpression> _defaultMapCache = []; - - /// - /// Caches compiled mapping expressions which map to existing objects - /// - private static readonly Dictionary, Delegate> _compiledMapToExistingCache = []; - - /// - /// Caches compiled mapping expressions which create new target objects - /// - private static readonly Dictionary, Delegate> _compiledMapToNewCache = []; - - /// - /// Caches compiled mapping expressions which map to existing objects - /// - private static readonly Dictionary _compiledSpecificMapToExistingCache = []; - - /// - /// Caches compiled mapping expressions which create new target objects - /// - private static readonly Dictionary _compiledSpecificMapToNewCache = []; - - /// - /// Tracks destination member names marked via Ignore<T>() for generated map expressions. - /// The key is the generated map lambda, and the value is the set of ignored member names. - /// private static readonly ConditionalWeakTable> _ignoredMembersByMap = new(); private static readonly ConcurrentDictionary, bool> _initializedPropertyCache = new(); - /// - /// Creates a new mapping with the optional partial mapping expression and adds it to the mapping dictionary - /// - /// - /// - /// A lambda expression where the body is a initializer (x => new TTarget { ... }) - /// The created mapping expression. - public static Expression> CreateAndAddMap(Expression>? partial = null) { - var map = CreateMap(partial); - AddMap(map); - return map; - } - - /// - /// Creates and adds a new mapping expression - /// - /// - /// - /// A full mapping expression (e.g. created with Mapper.CreateMap),where the body is a initializer (x => new TTarget { ... }). - /// - public static void AddMap(Expression> mappingExpression) { - var key = new Tuple(typeof(TSource), typeof(TTarget)); - if (_converters.ContainsKey(key)) { - throw new ArgumentException($"There already exists a mapping from TSource ({typeof(TSource).FullName}) to TTarget ({typeof(TTarget).FullName}). There can only be one mapping for each combination of TSource and TTarget."); - } - _converters[key] = mappingExpression; - if (!ContainsParameterMarkers(mappingExpression)) { - _compiledMapToNewCache[key] = mappingExpression.Compile(); - } - - // Map-to-existing is only valid for member initializer expressions - // (x => new TTarget { ... }). Value mappings (e.g. enum->enum, object->string) - // are supported for Map(source) but not for Map(source, target). - if (mappingExpression.Body is MemberInitExpression && !ContainsParameterMarkers(mappingExpression)) { - _compiledMapToExistingCache[key] = CompileMapper(mappingExpression); - } - } - - /// - /// Gets the registered map expression for the source and target types. - /// Returns null when no map exists and default-map fallback is disabled. - /// - /// The source type. - /// The target type. - /// The mapping expression, or null if not found and fallback is disabled. - public static Expression>? GetMap() { - var key = new Tuple(typeof(TSource), typeof(TTarget)); - if (_converters.TryGetValue(key, out var existingConverter)) { - return (Expression>)existingConverter; - } else if (_globalUseDefaultMapIfTypeMapIsMissing) { - if (_defaultMapCache.TryGetValue(key, out var map)) { - return (Expression>)map; - } - var defaultMap = CreateMap(); - _defaultMapCache[key] = defaultMap; - return (Expression>)defaultMap; - } - return null; - } - - /// - /// Gets the registered map expression for the source and target types, throwing if none is available. - /// - /// The source type. - /// The target type. - /// The required mapping expression. - /// Thrown when no map is available. - public static Expression> GetRequiredMap() { - var map = GetMap(); - if (map != null) { - return map; - } - - throw new ArgumentException($"Missing type map configuration for TSource ({typeof(TSource).FullName}) to TTarget ({typeof(TTarget).FullName})"); - } - private static bool IsParameterMarker(MethodInfo method) { if (!method.IsGenericMethod || method.DeclaringType != typeof(MapifyProfile)) { return false; @@ -168,139 +31,7 @@ private static bool IsParameterMarker(MethodInfo method) { && genericDefinition.GetParameters()[0].ParameterType == typeof(string); } - /// - /// Maps the the source object of type TSource to an existing object of type TTarget. - /// The mapping expression must contain an initializer (x => new TTarget { ... }). - /// - /// The type to map from - /// The target type to map to - /// An initializer expression of the form (x => new TTarget { ... }) - /// - /// - public static void Map(this Expression> expression, TSource source, TTarget target, bool cache = false) { - if (_compiledSpecificMapToExistingCache.TryGetValue(expression, out var map)) { - ((Action)map).Invoke(source, target); - return; - } - var compiled = CompileMapper(expression); - if (cache) { - _compiledSpecificMapToExistingCache[expression] = compiled; - } - compiled.Invoke(source, target); - } - - /// - /// Maps the the source object of type TSource to an existing object of type TTarget. - /// - /// The type to map from - /// The target type to map to - /// A new object of type with the mapped values - public static void Map(TSource source, TTarget target) { - var key = new Tuple(typeof(TSource), typeof(TTarget)); - if (_compiledMapToExistingCache.TryGetValue(key, out var map)) { - ((Action)map).Invoke(source, target); - return; - } - - var expression = GetRequiredRuntimeMap(); - - if (expression.Body is not MemberInitExpression) { - throw new NotSupportedException($"Mapping from TSource ({typeof(TSource).FullName}) to TTarget ({typeof(TTarget).FullName}) cannot map to an existing target instance because the map does not use an object initializer (x => new TTarget {{ ... }}). Use Map(source) instead."); - } - - var compiled = CompileMapper(expression); - _compiledMapToExistingCache[key] = compiled; - compiled.Invoke(source, target); - } - - /// - /// Maps the the source object of type TSource to a new object of type TTarget. - /// The mapping expression must contain an initializer (x => new TTarget { ... }). - /// - /// The type to map from - /// The target type to map to - /// An initializer expression of the form (x => new TTarget { ... }) - /// A new object of type with the mapped values - /// - /// - public static TTarget Map(this Expression> expression, TSource source, bool cache = false) { - if (_compiledSpecificMapToNewCache.TryGetValue(expression, out var map)) { - return ((Func)map).Invoke(source); - } - var compiled = expression.Compile(); - if (cache) { - _compiledSpecificMapToNewCache[expression] = compiled; - } - return compiled.Invoke(source); - } - - /// - /// Maps the the source object of type TSource to a new object of type TTarget. - /// - /// The type to map from - /// The target type to map to - /// - /// A new object of type with the mapped values - public static TTarget Map(TSource source) { - var key = new Tuple(typeof(TSource), typeof(TTarget)); - if (_compiledMapToNewCache.TryGetValue(key, out var map)) { - return ((Func)map).Invoke(source); - } - - var expression = GetRequiredRuntimeMap(); - var compiled = expression.Compile(); - _compiledMapToNewCache[key] = compiled; - return compiled.Invoke(source); - } - - private static Expression> GetRequiredRuntimeMap() { - if (TryCreateRuntimeMapExpression(TryGetRegisteredMap, null, out var runtimeExpression)) { - return runtimeExpression; - } - - if (_globalUseDefaultMapIfTypeMapIsMissing) { - var key = new Tuple(typeof(TSource), typeof(TTarget)); - if (_defaultMapCache.TryGetValue(key, out var map)) { - return (Expression>)map; - } - - var defaultMap = CreateMap(); - _defaultMapCache[key] = defaultMap; - return defaultMap; - } - - throw new ArgumentException($"Missing type map configuration for TSource ({typeof(TSource).FullName}) to TTarget ({typeof(TTarget).FullName})"); - } - - internal static LambdaExpression GetRequiredRuntimeMapUntyped(Type sourceType, Type targetType) { - var genericMethod = typeof(Mapper) - .GetMethods(BindingFlags.Static | BindingFlags.NonPublic) - .Single(m => m.Name == nameof(GetRequiredRuntimeMap) - && m.IsGenericMethodDefinition - && m.GetGenericArguments().Length == 2 - && m.GetParameters().Length == 0) - .MakeGenericMethod(sourceType, targetType); - - try { - return (LambdaExpression)genericMethod.Invoke(null, null)!; - } catch (TargetInvocationException ex) when (ex.InnerException != null) { - ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); - throw; - } - } - - /// - /// Compiles a given mapping expression to an action which maps the values to an existing object instead. - /// The mapping expression must contain an initializer (x => new TTarget { ... }). - /// The initializer bindings are converted to assignment expressions. - /// - /// - /// - /// - /// An action that can be called to map an object of type to an existing object of type - /// - /// - public static Action CompileMapper(Expression> expression) { + private static Action CompileMapper(Expression> expression) { _ignoredMembersByMap.TryGetValue(expression, out HashSet? ignoredMembers); if (expression.Body is not MemberInitExpression initExpr) @@ -318,8 +49,6 @@ public static Action CompileMapper(Expressio continue; } - // ma.Member -> property on Target - // ma.Expression -> expression using source (x.Firstname.ToLower()) var replaced = new ParameterReplaceVisitor(expression.Parameters[0], sourceParam).Visit(ma.Expression); var assign = Expression.Assign( Expression.PropertyOrField(targetParam, ma.Member.Name), @@ -335,26 +64,12 @@ public static Action CompileMapper(Expressio return action; } - /// - /// Creates a mapping expression for the source and destination types. - /// - /// The source type. - /// The destination type. - /// Optional partial initializer that overrides selected destination bindings. - /// A full mapping expression that can be compiled or registered. - public static Expression> CreateMap( - Expression>? partial = null - ) { - return CreateMap(partial, TryGetRegisteredMap); - } - - internal static Expression> CreateMap( + private static Expression> CreateMap( Expression>? partial, Func? existingMapResolver ) { var baseParam = Expression.Parameter(typeof(TSource), "x"); - // get all public instance properties of the source type that can be read from var sourceProperties = typeof(TSource).GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.CanRead) .ToDictionary(p => p.Name); @@ -362,11 +77,9 @@ internal static Expression> CreateMap(); var ignoredBindings = new HashSet(StringComparer.Ordinal); if (partial != null) { - // update the parameter name of the partial expression to "x" var partialUpdated = (MemberInitExpression)new ParameterReplaceVisitor(partial.Parameters[0], baseParam) .Visit(partial.Body); - // copy existing bindings from the partial expression foreach (var partialBinding in partialUpdated.Bindings.OfType()) { var binding = MapPartialBinding(partialBinding, typeof(TDestination), existingMapResolver, out var isIgnored); if (isIgnored) { @@ -379,24 +92,19 @@ internal static Expression> CreateMap p.CanWrite); var allBindings = new List(existingBindings.Values); foreach (var destProp in destinationProperties) { - // skip properties that are already bound in the partial expression if (existingBindings.ContainsKey(destProp.Name) || ignoredBindings.Contains(destProp.Name)) continue; var sourceProp = GetSourceProperty(sourceProperties, destProp); - // If there is a source property with the same name, prefer an existing map for sourceType -> targetType. - // If no map exists, fallback to default implicit assignment when types are compatible. if (sourceProp != null) { if (TryGetBindingFromExistingMap(baseParam, sourceProp, destProp, existingMapResolver, out var mappedBinding)) { - // If a map for sourceProp.Type -> destProp.Type exists, prefer it over direct assignment allBindings.Add(mappedBinding); } else if (TryGetImplicitBinding(baseParam, sourceProp, destProp, out var implicitBinding)) { allBindings.Add(implicitBinding); @@ -417,12 +125,6 @@ internal static Expression> CreateMap - /// Maps a member assignment to one that is compatible with EF. - /// Casts nullable types where necessary and replaces coalesce operators with ternary operators. - /// - /// - /// private static MemberAssignment? MapPartialBinding( MemberAssignment partialBinding, Type destinationType, @@ -446,21 +148,16 @@ out bool isIgnored expr = new UseMapMarkerReplaceVisitor(existingMapResolver).Visit(expr)!; } - // replace the coalesce operator with a conditional expression - if (expr is BinaryExpression binaryExpr && - binaryExpr.NodeType == ExpressionType.Coalesce) { - // x ?? y => x != null ? x : y + if (expr is BinaryExpression binaryExpr && binaryExpr.NodeType == ExpressionType.Coalesce) { var test = Expression.NotEqual(binaryExpr.Left, Expression.Constant(null, binaryExpr.Left.Type)); - // if the left side is a nullable type and the right side is not equal to the null constant, use the .Value property - // to get the underlying value, otherwise use the left side directly, since null is a valid fallback for a nullable type var value = Nullable.GetUnderlyingType(binaryExpr.Left.Type) != null && (binaryExpr.Right is not ConstantExpression rightConst || rightConst.Value != null) ? Expression.Property(binaryExpr.Left, "Value") : binaryExpr.Left; expr = Expression.Condition(test, value, binaryExpr.Right); } - var binding = Expression.Bind(partialBinding.Member, expr); - return binding; + + return Expression.Bind(partialBinding.Member, expr); } private static bool TryResolveIgnoreMarker( @@ -652,11 +349,7 @@ out MemberAssignment mappedBinding nullFallback = Expression.Convert(nullFallback, adaptedResult.Type); } - adaptedResult = Expression.Condition( - sourceNullCheck, - adaptedResult, - nullFallback - ); + adaptedResult = Expression.Condition(sourceNullCheck, adaptedResult, nullFallback); } mappedBinding = Expression.Bind(destProp, adaptedResult); @@ -813,18 +506,7 @@ private static bool IsProjectToMarker(MethodInfo method) { return true; } - /// - /// The target is compatible if: - /// * the source property exists and - /// * the target property type is assignable from the source property type, or - /// * the source property is nullable and the underlying type is assignable to the target type. - /// This allows for nullable to non-nullable assignments with a default value fallback. - /// - /// The property to map from - /// The property to map to - /// private static PropertyInfo? GetSourceProperty(Dictionary sourceTypeProperties, PropertyInfo destProp) { - // Try to find a matching property with the same name if (!sourceTypeProperties.TryGetValue(destProp.Name, out var sourceProp)) { return null; } @@ -854,41 +536,24 @@ out MemberAssignment binding return true; } - /// - /// Create binding expression from source and dest property info. - /// PropertyName = (T?)x.PropertyName if the dest is nullable and the source is not. - /// PropertyName = x.PropertyName != null ? x.PropertyName.Value : default(T) if the source is nullable and the destination is not. - /// PropertyName = x.PropertyName otherwise. - /// - /// Parameter expression to be used for the final initializer - /// The property info of the source type property - /// The matching destination porperty info of the target type property - /// private static MemberAssignment GetImplicitBinding(ParameterExpression baseParam, PropertyInfo sourceProp, PropertyInfo destProp) { MemberAssignment binding; var sourceAccess = Expression.Property(baseParam, sourceProp); - // check if the destination property is nullable var sourceNullableType = Nullable.GetUnderlyingType(sourceProp.PropertyType); var destNullableType = Nullable.GetUnderlyingType(destProp.PropertyType); if (destNullableType != null && sourceProp.PropertyType == destNullableType) { - // if dest is nullable and source is not, cast it for type compatibility: - // destProp = (T?)source.Prop var nullableType = typeof(Nullable<>).MakeGenericType(destNullableType); var converted = Expression.Convert(sourceAccess, nullableType); binding = Expression.Bind(destProp, converted); } else if (destNullableType == null && sourceNullableType != null && destProp.PropertyType == sourceNullableType) { - // if source is nullable and dest is not, use the default value as fallback: - // destProp = sourceProp != null ? sourceProp.Value : default(destProp.PropertyType) var notNull = Expression.NotEqual(sourceAccess, Expression.Constant(null, sourceAccess.Type)); var value = Expression.Property(sourceAccess, "Value"); var defaultValue = Expression.Default(destProp.PropertyType); var conditional = Expression.Condition(notNull, value, defaultValue); binding = Expression.Bind(destProp, conditional); } else { - // Both types are either nullable or non-nullable and target is assignable from source: - // destProp = source.Prop binding = Expression.Bind(destProp, sourceAccess); } @@ -914,18 +579,13 @@ out MemberAssignment binding return false; } - // If source is nullable (or reference) and null, map to default(target). if (sourceNullCheck != null) { var nullFallback = CreatePropertyDefaultValueExpression(destProp); if (nullFallback.Type != adaptedResult.Type && adaptedResult.Type.IsAssignableFrom(nullFallback.Type)) { nullFallback = Expression.Convert(nullFallback, adaptedResult.Type); } - adaptedResult = Expression.Condition( - sourceNullCheck, - adaptedResult, - nullFallback - ); + adaptedResult = Expression.Condition(sourceNullCheck, adaptedResult, nullFallback); } binding = Expression.Bind(destProp, adaptedResult); @@ -1175,7 +835,7 @@ private static bool TryGetEnumerableElementType(Type type, out Type elementType) return true; } - internal static bool TryCreateRuntimeMapExpression( + private static bool TryCreateRuntimeMapExpression( Func resolver, string? preferredMapName, out Expression> expression @@ -1183,7 +843,7 @@ out Expression> expression expression = null!; var sourceParam = Expression.Parameter(typeof(TSource), "x"); - if (!TryBuildMappedExpression(sourceParam, typeof(TSource), typeof(TTarget), resolver, preferredMapName, out var mappedBody, out var sourceNullCheck)) { + if (!TryBuildMappedExpression(sourceParam, typeof(TSource), typeof(TTarget), resolver, preferredMapName, out var mappedBody, out _)) { return false; } @@ -1200,7 +860,6 @@ out Expression> expression var sourceCoreType = Nullable.GetUnderlyingType(sourceType) ?? sourceType; var destinationCoreType = Nullable.GetUnderlyingType(destinationType) ?? destinationType; - // Prefer exact first, then lifted variants. var resolved = resolver(sourceType, destinationType, preferredMapName) ?? resolver(sourceType, destinationCoreType, preferredMapName) ?? resolver(sourceCoreType, destinationType, preferredMapName) @@ -1226,13 +885,8 @@ out Expression> expression return null; } - var sourceCandidates = sourceIsCollection - ? GetCollectionResolutionCandidates(sourceType) - : [sourceType]; - - var destinationCandidates = destinationIsCollection - ? GetCollectionResolutionCandidates(destinationType) - : [destinationType]; + var sourceCandidates = sourceIsCollection ? GetCollectionResolutionCandidates(sourceType) : [sourceType]; + var destinationCandidates = destinationIsCollection ? GetCollectionResolutionCandidates(destinationType) : [destinationType]; foreach (var sourceCandidate in sourceCandidates) { foreach (var destinationCandidate in destinationCandidates) { @@ -1268,14 +922,12 @@ private static IReadOnlyList GetCollectionResolutionCandidates(Type type) } } - var distinct = candidates + return candidates .Distinct() .OrderBy(x => x == type ? 0 : 1) .ThenBy(GetCollectionCandidateRank) .ThenBy(x => x.FullName, StringComparer.Ordinal) .ToList(); - - return distinct; } private static int GetCollectionCandidateRank(Type type) { @@ -1317,7 +969,6 @@ out Expression? sourceHasValueCheck sourceHasValueCheck = null; if (sourceAccess.Type == mapSourceType) { - // For reference/nullable values, keep null fallback behavior. if (CanBeNull(sourceAccess.Type) && !IsInterfaceCollectionLikeType(sourceAccess.Type)) { sourceHasValueCheck = CreateHasValueCheck(sourceAccess); } @@ -1327,14 +978,12 @@ out Expression? sourceHasValueCheck var sourceNullableUnderlying = Nullable.GetUnderlyingType(sourceAccess.Type); var mapNullableUnderlying = Nullable.GetUnderlyingType(mapSourceType); - // T? -> T if (sourceNullableUnderlying != null && sourceNullableUnderlying == mapSourceType) { sourceHasValueCheck = Expression.NotEqual(sourceAccess, Expression.Constant(null, sourceAccess.Type)); adaptedSource = Expression.Property(sourceAccess, "Value"); return true; } - // T -> T? if (mapNullableUnderlying != null && sourceAccess.Type == mapNullableUnderlying) { adaptedSource = Expression.Convert(sourceAccess, mapSourceType); return true; @@ -1361,13 +1010,11 @@ private static bool TryAdaptMappedResult(Expression mappedBody, Type targetType, var targetNullableUnderlying = Nullable.GetUnderlyingType(targetType); var mappedNullableUnderlying = Nullable.GetUnderlyingType(mappedBody.Type); - // T -> T? if (targetNullableUnderlying != null && mappedBody.Type == targetNullableUnderlying) { adaptedResult = Expression.Convert(mappedBody, targetType); return true; } - // T? -> T (keep default fallback) if (mappedNullableUnderlying != null && targetType == mappedNullableUnderlying) { var hasValue = Expression.NotEqual(mappedBody, Expression.Constant(null, mappedBody.Type)); var value = Expression.Property(mappedBody, "Value"); @@ -1605,30 +1252,21 @@ private static Expression CreateDefaultValueExpression(Type type) ? Expression.Constant(null, type) : Expression.Default(type); - private static LambdaExpression? TryGetRegisteredMap(Type sourceType, Type destinationType, string? name) { - if (!string.IsNullOrWhiteSpace(name)) { - return null; - } - - var key = new Tuple(sourceType, destinationType); - return _converters.TryGetValue(key, out var existingConverter) ? existingConverter : null; - } - - internal static Expression> ApplyParameters( + private static Expression> ApplyParameters( Expression> mappingExpression, IReadOnlyDictionary? parameters ) { return (Expression>)ApplyParameters((LambdaExpression)mappingExpression, parameters); } - internal static LambdaExpression ApplyParameters(LambdaExpression mappingExpression, IReadOnlyDictionary? parameters) { + private static LambdaExpression ApplyParameters(LambdaExpression mappingExpression, IReadOnlyDictionary? parameters) { var replacedBody = new ParameterMarkerReplaceVisitor(parameters).Visit(mappingExpression.Body)!; return replacedBody == mappingExpression.Body ? mappingExpression : Expression.Lambda(replacedBody, mappingExpression.Parameters); } - internal static bool ContainsParameterMarkers(LambdaExpression expression) + private static bool ContainsParameterMarkers(LambdaExpression expression) => new ParameterMarkerDetector().ContainsMarker(expression); private sealed class ParameterMarkerDetector : ExpressionVisitor { diff --git a/Mapify/Mapify.cs b/Mapify/Mapify.cs index 337b935..b51ca61 100644 --- a/Mapify/Mapify.cs +++ b/Mapify/Mapify.cs @@ -7,7 +7,7 @@ namespace Mapify.NET; /// /// Instance-based mapper that builds and caches mapping expressions from configured profiles. /// -public class Mapify : IMapify, IMapifyConfigurator { +public partial class Mapify : IMapify, IMapifyConfigurator { private bool _useDefaultMapIfTypeMapIsMissing; private const int _defaultRecursiveUseMapDepth = 6; @@ -226,7 +226,7 @@ private LambdaExpression CreateMapFromPending(Type sourceType, Type targetType, } private Expression> CreateMapFromPendingGeneric(string? mapName, LambdaExpression? partial) - => Mapper.CreateMap((Expression>?)partial, (sourceType, targetType, requestedMapName) => ResolveExistingMapForBuild(sourceType, targetType, requestedMapName ?? mapName)); + => CreateMap((Expression>?)partial, (sourceType, targetType, requestedMapName) => ResolveExistingMapForBuild(sourceType, targetType, requestedMapName ?? mapName)); private LambdaExpression? ResolveExistingMapForBuild(Type sourceType, Type targetType, string? mapName) { if (!string.IsNullOrWhiteSpace(mapName)) { @@ -300,12 +300,12 @@ private void SetMapGeneric(LambdaExpression mappingExpression, return; } - if (!Mapper.ContainsParameterMarkers(expression)) { + if (!ContainsParameterMarkers(expression)) { _compiledMapToNewCache[key] = expression.Compile(); } - if (expression.Body is MemberInitExpression && !Mapper.ContainsParameterMarkers(expression)) { - _compiledMapToExistingCache[key] = Mapper.CompileMapper(expression); + if (expression.Body is MemberInitExpression && !ContainsParameterMarkers(expression)) { + _compiledMapToExistingCache[key] = CompileMapper(expression); } } @@ -317,12 +317,12 @@ private void AddMap(Expression> mapping } _converters[key] = mappingExpression; - if (!Mapper.ContainsParameterMarkers(mappingExpression)) { + if (!ContainsParameterMarkers(mappingExpression)) { _compiledMapToNewCache[key] = mappingExpression.Compile(); } - if (mappingExpression.Body is MemberInitExpression && !Mapper.ContainsParameterMarkers(mappingExpression)) { - _compiledMapToExistingCache[key] = Mapper.CompileMapper(mappingExpression); + if (mappingExpression.Body is MemberInitExpression && !ContainsParameterMarkers(mappingExpression)) { + _compiledMapToExistingCache[key] = CompileMapper(mappingExpression); } } @@ -358,7 +358,7 @@ private void AddMap(Expression> mapping var key = new MapKey(typeof(TSource), typeof(TTarget), name); if (_converters.TryGetValue(key, out var existingConverter)) { - return Mapper.ApplyParameters((Expression>)existingConverter, parameters); + return ApplyParameters((Expression>)existingConverter, parameters); } return null; @@ -418,18 +418,18 @@ public Expression> GetRequiredMap(strin var key = new MapKey(typeof(TSource), typeof(TTarget), null); if (_converters.TryGetValue(key, out var existingConverter)) { - return Mapper.ApplyParameters((Expression>)existingConverter, parameters); + return ApplyParameters((Expression>)existingConverter, parameters); } if (_useDefaultMapIfTypeMapIsMissing) { var defaultCacheKey = new Tuple(typeof(TSource), typeof(TTarget)); if (_defaultMapCache.TryGetValue(defaultCacheKey, out var existingDefaultMap)) { - return Mapper.ApplyParameters((Expression>)existingDefaultMap, parameters); + return ApplyParameters((Expression>)existingDefaultMap, parameters); } - var defaultMap = Mapper.CreateMap(null, (sourceType, targetType, requestedMapName) => ResolveExistingMapForBuild(sourceType, targetType, requestedMapName)); + var defaultMap = CreateMap(null, (sourceType, targetType, requestedMapName) => ResolveExistingMapForBuild(sourceType, targetType, requestedMapName)); _defaultMapCache[defaultCacheKey] = defaultMap; - return Mapper.ApplyParameters(defaultMap, parameters); + return ApplyParameters(defaultMap, parameters); } return null; @@ -490,7 +490,7 @@ public void Map(TSource source, TTarget target, string name) { throw new NotSupportedException($"Mapping from TSource ({typeof(TSource).FullName}) to TTarget ({typeof(TTarget).FullName}) cannot map to an existing target instance because the map does not use an object initializer (x => new TTarget {{ ... }}). Use Map(source, name) instead."); } - var compiled = Mapper.CompileMapper(expression); + var compiled = CompileMapper(expression); _compiledMapToExistingCache[key] = compiled; compiled.Invoke(source, target); } @@ -519,7 +519,7 @@ public void Map(TSource source, TTarget target, string name, I throw new NotSupportedException($"Mapping from TSource ({typeof(TSource).FullName}) to TTarget ({typeof(TTarget).FullName}) cannot map to an existing target instance because the map does not use an object initializer (x => new TTarget {{ ... }}). Use Map(source, name, parameters) instead."); } - var compiled = Mapper.CompileMapper(expression); + var compiled = CompileMapper(expression); compiled.Invoke(source, target); } @@ -545,7 +545,7 @@ public void Map(TSource source, TTarget target) { throw new NotSupportedException($"Mapping from TSource ({typeof(TSource).FullName}) to TTarget ({typeof(TTarget).FullName}) cannot map to an existing target instance because the map does not use an object initializer (x => new TTarget {{ ... }}). Use Map(source) instead."); } - var compiled = Mapper.CompileMapper(expression); + var compiled = CompileMapper(expression); _compiledMapToExistingCache[key] = compiled; compiled.Invoke(source, target); } @@ -569,7 +569,7 @@ public void Map(TSource source, TTarget target, IReadOnlyDicti throw new NotSupportedException($"Mapping from TSource ({typeof(TSource).FullName}) to TTarget ({typeof(TTarget).FullName}) cannot map to an existing target instance because the map does not use an object initializer (x => new TTarget {{ ... }}). Use Map(source, parameters) instead."); } - var compiled = Mapper.CompileMapper(expression); + var compiled = CompileMapper(expression); compiled.Invoke(source, target); } @@ -661,7 +661,7 @@ private Expression> GetRequiredRuntimeMap parameters ) { - if (Mapper.TryCreateRuntimeMapExpression( + if (TryCreateRuntimeMapExpression( (sourceType, targetType, requestedMapName) => ResolveRuntimeMapCandidate(sourceType, targetType, requestedMapName ?? name, parameters), name, out var runtimeMapExpression)) { @@ -671,12 +671,12 @@ private Expression> GetRequiredRuntimeMap(typeof(TSource), typeof(TTarget)); if (_defaultMapCache.TryGetValue(defaultCacheKey, out var existingDefaultMap)) { - return Mapper.ApplyParameters((Expression>)existingDefaultMap, parameters); + return ApplyParameters((Expression>)existingDefaultMap, parameters); } - var defaultMap = Mapper.CreateMap(null, (sourceType, targetType, requestedMapName) => ResolveExistingMapForBuild(sourceType, targetType, requestedMapName)); + var defaultMap = CreateMap(null, (sourceType, targetType, requestedMapName) => ResolveExistingMapForBuild(sourceType, targetType, requestedMapName)); _defaultMapCache[defaultCacheKey] = defaultMap; - return Mapper.ApplyParameters(defaultMap, parameters); + return ApplyParameters(defaultMap, parameters); } if (name == null) { @@ -717,7 +717,7 @@ internal LambdaExpression GetRequiredRuntimeMapUntyped( ) { var key = new MapKey(sourceType, targetType, name); if (_converters.TryGetValue(key, out var existingConverter)) { - return Mapper.ApplyParameters(existingConverter, parameters); + return ApplyParameters(existingConverter, parameters); } return null; diff --git a/Mapify/MapifyProjectToExtensions.cs b/Mapify/MapifyProjectToExtensions.cs index a766923..b6ee88d 100644 --- a/Mapify/MapifyProjectToExtensions.cs +++ b/Mapify/MapifyProjectToExtensions.cs @@ -7,6 +7,28 @@ namespace Mapify.NET; /// Extension methods for projecting non-generic and generic query/sequence sources to mapped target types. /// public static class MapifyProjectToExtensions { + /// + /// Marker overload intended for use inside map expressions. + /// For runtime projections, use query.ProjectTo<TTarget>(mapify). + /// + /// The target projection type. + /// The source query. + /// Never returns; this overload always throws. + /// Always thrown for direct runtime use. + public static IQueryable ProjectTo(this IQueryable source) + => throw new InvalidOperationException($"{nameof(ProjectTo)} without an {nameof(IMapify)} instance is a marker-only overload for {nameof(MapifyProfile)} expressions. Use query.ProjectTo(mapify) at runtime."); + + /// + /// Marker overload intended for use inside map expressions. + /// For runtime projections, use sequence.ProjectTo<TTarget>(mapify). + /// + /// The target projection type. + /// The source sequence. + /// Never returns; this overload always throws. + /// Always thrown for direct runtime use. + public static IEnumerable ProjectTo(this IEnumerable source) + => throw new InvalidOperationException($"{nameof(ProjectTo)} without an {nameof(IMapify)} instance is a marker-only overload for {nameof(MapifyProfile)} expressions. Use sequence.ProjectTo(mapify) at runtime."); + private static void ValidateRuntimeParameters(IReadOnlyDictionary parameters) { if (parameters == null) { throw new ArgumentNullException(nameof(parameters)); @@ -17,24 +39,6 @@ private static void ValidateRuntimeParameters(IReadOnlyDictionary - /// Projects a non-generic query to using the static mapper configuration. - /// - /// The target projection type. - /// The source query. - /// A projected query of . - /// Thrown when is null. - public static IQueryable ProjectTo(this IQueryable source) { - if (source == null) { - throw new ArgumentNullException(nameof(source)); - } - - var mapExpression = GetQueryMapExpression(() => GetStaticRuntimeMapExpression(source.ElementType, typeof(TTarget))); - return BuildProjectedQuery(source, mapExpression); - } - - - /// /// Projects a non-generic query to using an instance mapper. /// @@ -149,28 +153,6 @@ public static IQueryable ProjectTo(this IQueryable source, IMa public static IQueryable ProjectTo(this IQueryable source, string name) => throw new InvalidOperationException($"{nameof(ProjectTo)} with a map name requires an {nameof(IMapify)} instance. Use query.ProjectTo(mapify, name). Inside {nameof(MapifyProfile)} CreateMap expressions this overload is used as a marker and is resolved during map creation."); - /// - /// Projects a non-generic sequence to using the static mapper configuration. - /// - /// The target projection type. - /// The source sequence. - /// A projected sequence of . - /// Thrown when is null. - public static IEnumerable ProjectTo(this IEnumerable source) { - if (source == null) { - throw new ArgumentNullException(nameof(source)); - } - - var sourceElementType = GetEnumerableElementType(source.GetType()); - var method = typeof(MapifyProjectToExtensions) - .GetMethod(nameof(ProjectToEnumerableCoreStatic), BindingFlags.Static | BindingFlags.NonPublic)! - .MakeGenericMethod(sourceElementType, typeof(TTarget)); - - return (IEnumerable)method.Invoke(null, [source])!; - } - - - /// /// Projects a non-generic sequence to using an instance mapper. /// @@ -323,9 +305,6 @@ private static LambdaExpression GetQueryMapExpression(Func res } } - private static LambdaExpression GetStaticRuntimeMapExpression(Type sourceType, Type targetType) - => Mapper.GetRequiredRuntimeMapUntyped(sourceType, targetType); - private static LambdaExpression GetInstanceRuntimeMapExpression( Type sourceType, Type targetType, @@ -404,16 +383,6 @@ private static LambdaExpression GetInstanceMapExpression(Type sourceType, Type t return (LambdaExpression)genericMethod.Invoke(mapify, [name, parameters])!; } - private static IEnumerable ProjectToEnumerableCoreStatic(IEnumerable source) { - var map = ((Expression>)GetStaticRuntimeMapExpression(typeof(TSource), typeof(TTarget))).Compile(); - return source.Cast().Select(map); - } - - // Removed: static helper for parameterized enumerable projection. - // Parameterized static projections must be performed by calling - // `Mapper.ApplyParameters(Mapper.GetRequiredMap(), parameters)` - // and then compiling/using the resulting expression. - private static IEnumerable ProjectToEnumerableCoreInstance(IEnumerable source, IMapify mapify) { var map = ((Expression>)GetInstanceRuntimeMapExpression(typeof(TSource), typeof(TTarget), mapify)).Compile(); return source.Cast().Select(map); diff --git a/README.md b/README.md index ea9bf9a..c4e64d5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Tests](https://github.com/BenjaminVettori/Mapify.NET/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/BenjaminVettori/Mapify.NET/actions/workflows/tests.yml) [![Build](https://github.com/BenjaminVettori/Mapify.NET/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/BenjaminVettori/Mapify.NET/actions/workflows/build.yml) -Mapify is a lightweight .NET library for creating static mapping expressions for C# objects. It bridges the gap between in-memory object mapping and LINQ projections (e.g., Entity Framework), allowing you to reuse the same mapping logic consistently across your application. +Mapify is a lightweight .NET library for creating mapping expressions for C# objects. It bridges the gap between in-memory object mapping and LINQ projections (e.g., Entity Framework), allowing you to reuse the same mapping logic consistently across your application. ## Features ✨ @@ -257,15 +257,6 @@ var people = await _dbContext.Persons .ToArrayAsync(cancellationToken); ``` -If you use the static `Mapper` API (`Mapper.AddMap(...)`), you can call: - -```csharp -var people = _dbContext.Persons - .ProjectTo() - .OrderBy(x => x.Name) - .ToArray(); -``` - `ProjectTo` uses the same runtime fallback behavior as `Map`, including: - collection-shape fallback (for example `IEnumerable` map reused for `List`) @@ -413,8 +404,6 @@ var projected = dbContext.Requests .ToArray(); ``` -The static `Mapper` API does not provide parameterized `Map` / `GetMap` / `GetRequiredMap` / `ProjectTo` overloads. - Notes: parameter names are matched by key and values must be convertible to the `T` used in `Parameter(name)`. ## Detailed Functionality 📚 @@ -561,72 +550,7 @@ Rules: * Each `(TSource, TTarget, Name)` combination must be unique. * `UseMap` can target named maps via `UseMap("Name", sourceExpression)`. -### Static API (advanced scenarios) - -The static `Mapper` API is still fully supported, but typically used in advanced scenarios. - -#### Static map declarations - -```csharp -using Mapify.NET; -using System.Linq.Expressions; - -public static class PersonMappings { - public static readonly Expression> PersonToPersonDto = - Mapper.CreateMap(p => new PersonDto { - Name = $"{p.FirstName} {p.LastName}", - MainAddress = AddressMappings.AddressToAddressDto.Invoke(p.MainAddress) - }); -} - -public static class AddressMappings { - public static readonly Expression> AddressToAddressDto = - Mapper.CreateMap(); -} -``` - -#### Static API with Entity Framework + LINQKit - -If your static expressions use `.Invoke(...)`, combine with LINQKit and `.AsExpandable()`. - -```csharp -public async Task> GetPersonDtosAsync(int skip, int take, CancellationToken cancellationToken = default) { - return await _dbContext.Persons - .AsExpandable() - .Select(PersonMappings.PersonToPersonDto) - .OrderBy(x => x.Name) - .Skip(skip) - .Take(take) - .ToArrayAsync(cancellationToken); -} -``` - -`AsExpandable()` is the key piece that lets EF translate expressions that use `.Invoke()`. - -Use the package that matches your scenario: - -- `LinqKit.Core`: expression composition utilities (`PredicateBuilder`, `Invoke`, `Expand`) without EF integration. -- `LinqKit` or `LinqKit.EntityFramework`: for Entity Framework 6.x. -- `LinqKit.Microsoft.EntityFrameworkCore`: for Entity Framework Core. - -For EF Core, the package ID stays the same (`LinqKit.Microsoft.EntityFrameworkCore`), but the major version should match EF Core major. - -### Global Configuration & Static Maps - -You can register mappings globally to use the static `Mapper.Map` convenience methods. - -```csharp -// Register a map globally -Mapper.AddMap(PersonMappings.PersonToPersonDto); - -// Or create and add in one step -Mapper.CreateAndAddMap(p => new PersonDto { ... }); - -// Use the global map anywhere -var dto = Mapper.Map(person); -``` - -### Mapping to existing instances (instance + static) +### Mapping to existing instances `Map(source, target)` updates a provided target instance in-place only when the selected map expression is an object initializer (`MemberInit`). @@ -637,9 +561,6 @@ CreateMap(p => new PersonDto { Name = p.FirstName }); var dto1 = new PersonDto(); mapper.Map(person, dto1); -// static mapper -var dto2 = new PersonDto(); -Mapper.Map(person, dto2); ``` Implementation note: Mapify converts initializer bindings into assignments (for example `target.Name = ...`) and compiles them as `Action`. If the map returns a value directly (for example `CreateMap(u => "User" + u.Id)`), `Map(source, target)` throws `NotSupportedException`. @@ -651,11 +572,11 @@ For value mappings, use `Map(source)` and assign the returned value. Mapify also supports mappings where the expression returns a value directly (not `new TTarget { ... }`). ```csharp -Mapper.AddMap(x => x.FirstName); -Mapper.AddMap(x => x == SourceStatus.Active ? TargetStatus.Enabled : TargetStatus.Disabled); +CreateMap(x => x.FirstName); +CreateMap(x => x == SourceStatus.Active ? TargetStatus.Enabled : TargetStatus.Disabled); -var name = Mapper.Map(person); -var status = Mapper.Map(SourceStatus.Active); +var name = mapify.Map(person); +var status = mapify.Map(SourceStatus.Active); ``` > Note: value mappings are supported for `Map(source)` only. `Map(source, target)` requires an object-initializer mapping. @@ -665,7 +586,7 @@ var status = Mapper.Map(SourceStatus.Active); You can provide a partial initializer to override specific properties. Mapify also rewrites null-coalescing operators (`??`) to conditional expressions (`x != null ? x : y`) to ensure compatibility with all LINQ providers (some EF versions struggle with `??`). ```csharp -Mapper.CreateMap(p => new PersonDto { +CreateMap(p => new PersonDto { // Explicit override Name = p.FirstName + " " + p.LastName, @@ -678,61 +599,40 @@ Mapper.CreateMap(p => new PersonDto { * **Compiled Delegates**: Accessors are compiled to delegates. * **Strategy**: - * **Extension Method (`.Map()`)**: Caches the compiled delegate for that specific expression instance. - * **Static `Mapper.Map`**: Uses a global cache. - * **Priority**: Explicitly added maps (`AddMap`) take precedence over implicitly generated ones. - * **Auto-Cache**: If global fallback is enabled via `Mapper.UseDefaultMapIfTypeMapIsMissing(true)` before adding a custom map, a default map is generated and cached. Calling `AddMap` later **overwrites** this cache entry with your custom definition. + * **Mapify instance**: Caches compiled delegates per mapper instance. + * **Expression extension (`expression.Map(...)`)**: Optional per-expression delegate caching. ## Advanced Usage 🛠️ For advanced scenarios, Mapify exposes several lower-level methods. -### Compiling Mappers Manually - -If you need high-performance bulk mapping to **existing** objects and want to manage the delegate lifecycle yourself (referencing `Action`), you can use `CompileMapper`. - -```csharp -// Get the map expression -var mapExpr = PersonMappings.PersonToPersonDto; - -// Compile to an Action -Action mapAction = Mapper.CompileMapper(mapExpr); - -// Use it in a hot loop (zero dictionary lookups) -var target = new PersonDto(); -foreach (var item in largeCollection) { - mapAction(item, target); - // ... -} -``` - ### Retrieving Maps -You can retrieve registered maps using `GetMap`. This is useful in generic or dynamic contexts where you don't have direct access to the static field. +You can retrieve registered maps using `GetMap`. This is useful in generic or dynamic contexts. `GetMap` returns `null` if no map exists and default-map fallback is disabled. If you want throwing behavior, use `GetRequiredMap`. ```csharp // Retrieve a registered map (or null) -var mapExpr = Mapper.GetMap(); +var mapExpr = mapify.GetMap(); -// Enable global fallback once (if desired) -Mapper.UseDefaultMapIfTypeMapIsMissing(true); +// Enable per-instance fallback (if desired) +mapify.UseDefaultMapIfTypeMapIsMissing(true); // Then GetMap/GetRequiredMap can use generated default maps when missing -var fallbackMapExpr = Mapper.GetMap(); +var fallbackMapExpr = mapify.GetMap(); // Throw if the map is missing -var requiredMapExpr = Mapper.GetRequiredMap(); +var requiredMapExpr = mapify.GetRequiredMap(); ``` ### Strict Mode Configuration -By default, strict mode is **enabled** for global maps, meaning `Mapper.Map(src)` throws if no map is registered. You can disable this to allow automatic fallback to default maps globally, though explicit registration is recommended for performance and control. +By default, strict mode is **enabled**, meaning `mapify.Map(src)` throws if no map is registered. You can disable this per mapper instance to allow automatic fallback to default maps, though explicit registration is recommended for performance and control. ```csharp // Allow implicit generation of default maps if no custom map is found -Mapper.UseDefaultMapIfTypeMapIsMissing(true); +mapify.UseDefaultMapIfTypeMapIsMissing(true); ``` ## Contributing 🤝 From 6af96045ba3fa1f34eafd0da42a2a48a418a0782 Mon Sep 17 00:00:00 2001 From: Benjamin Vettori Date: Mon, 23 Mar 2026 23:35:20 +0100 Subject: [PATCH 2/4] Add DSL for single property mappings Added a .Map fluent API to be able to register single property mappings. This allows to avoid the initializer mapping e.g. if the required keyword is used often in target types, where each required property would have to be mapped explicitely, even if they could be mapped by convention. The initializer mapping works as before though, since the DSL is just an addition. --- .../MapifyEfCoreIgnoreMarkerTests.cs | 39 ++ Mapify.NET.Tests/MapifyDslMapBuilderTests.cs | 392 ++++++++++++++++++ Mapify/Mapify.MappingInternals.cs | 115 ++++- Mapify/Mapify.cs | 43 +- Mapify/MapifyMapBuilder.cs | 40 ++ Mapify/MapifyProfile.cs | 12 +- README.md | 39 ++ 7 files changed, 650 insertions(+), 30 deletions(-) create mode 100644 Mapify.NET.Tests/MapifyDslMapBuilderTests.cs create mode 100644 Mapify/MapifyMapBuilder.cs diff --git a/Mapify.NET.Tests.EFCore/MapifyEfCoreIgnoreMarkerTests.cs b/Mapify.NET.Tests.EFCore/MapifyEfCoreIgnoreMarkerTests.cs index b4508d0..9ff0b6c 100644 --- a/Mapify.NET.Tests.EFCore/MapifyEfCoreIgnoreMarkerTests.cs +++ b/Mapify.NET.Tests.EFCore/MapifyEfCoreIgnoreMarkerTests.cs @@ -37,6 +37,38 @@ public void IgnoreMarker_ShouldExcludeIgnoredPropertyFromEfCoreSqlProjection() { Assert.Null(projected.IgnoredFromDb); } + [Fact] + public void IgnoreMarker_ShouldExcludeIgnoredPropertyFromEfCoreSqlProjection_WhenUsingDslMap() { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + using var db = new EfCoreIgnoreMapifyContext(options); + db.Database.EnsureCreated(); + + db.Set().Add(new EfCoreProjectionIgnoreEntity { + Included = "included", + IgnoredFromDb = "ignored-db" + }); + db.SaveChanges(); + + var mapify = new Mapify([ + new EfCoreProjectionIgnoreDslProfile() + ]); + + var query = db.Set().ProjectTo(mapify); + var sql = query.ToQueryString(); + + Assert.DoesNotContain("\"IgnoredFromDb\"", sql, StringComparison.Ordinal); + + var projected = query.Single(); + Assert.Equal("included", projected.Included); + Assert.Null(projected.IgnoredFromDb); + } + [Fact] public void IgnoreMarker_ShouldExcludeIgnoredPropertyFromEfCoreSqlProjection_WhenUsingProjectTo() { using var connection = new SqliteConnection("Data Source=:memory:"); @@ -124,4 +156,11 @@ protected override void Configure() { }); } } + + private sealed class EfCoreProjectionIgnoreDslProfile : MapifyProfile { + protected override void Configure() { + CreateMap() + .Map(d => d.IgnoredFromDb, _ => Ignore()); + } + } } diff --git a/Mapify.NET.Tests/MapifyDslMapBuilderTests.cs b/Mapify.NET.Tests/MapifyDslMapBuilderTests.cs new file mode 100644 index 0000000..8478529 --- /dev/null +++ b/Mapify.NET.Tests/MapifyDslMapBuilderTests.cs @@ -0,0 +1,392 @@ +namespace Mapify.NET.Tests; + +public class MapifyDslMapBuilderTests { + [Fact] + public void MapBuilder_ShouldApplyExistingTypeMap_WhenPropertyTypesDiffer() { + var mapify = new Mapify([ + new ExistingMapFallbackProfile() + ]); + + var result = mapify.Map(new ExistingMapSource { + Name = "alice", + Child = new ExistingMapChildSource { Value = 7 } + }); + + Assert.Equal("alice", result.Name); + Assert.NotNull(result.Child); + Assert.Equal(7, result.Child!.Value); + } + + [Fact] + public void MapBuilder_ShouldOverrideInitializerBinding_WhenConfiguredAfterInitializer() { + var mapify = new Mapify([ + new InitializerOverrideProfile() + ]); + + var result = mapify.Map(new InitializerOverrideSource { + Name = "alice" + }); + + Assert.Equal("alice", result.Name); + Assert.Equal("dsl-value", result.Label); + } + + [Fact] + public void MapBuilder_ShouldSupportMarkers() { + var mapify = new Mapify([ + new MarkerDslProfile() + ]); + + var result = mapify.Map(new MarkerSource { + Name = "alice", + Child = new MarkerChildSource { Value = 3 }, + Internal = "hidden" + }); + + Assert.Equal("alice", result.Name); + Assert.NotNull(result.Child); + Assert.Equal(13, result.Child!.Value); + Assert.Null(result.Internal); + } + + [Fact] + public void MapBuilder_ShouldSupportParameterMarker() { + var mapify = new Mapify([ + new ParameterDslProfile() + ]); + + var parameters = new Dictionary { + ["minScore"] = 50 + }; + + var pass = mapify.Map(new ParameterSource { + Name = "alice", + Score = 70 + }, parameters); + + var fail = mapify.Map(new ParameterSource { + Name = "bob", + Score = 40 + }, parameters); + + Assert.Equal("alice", pass.Name); + Assert.Equal("Pass", pass.ScoreCategory); + Assert.Equal("bob", fail.Name); + Assert.Equal("Fail", fail.ScoreCategory); + } + + [Fact] + public void MapBuilder_ShouldSupportNamedUseMapMarker() { + var mapify = new Mapify([ + new NamedUseMapDslProfile() + ]); + + var result = mapify.Map(new NamedUseMapSource { + Name = "alice", + Child = new NamedUseMapChildSource { Value = 3 } + }); + + Assert.Equal("alice", result.Name); + Assert.NotNull(result.Child); + Assert.Equal(103, result.Child!.Value); + } + + [Fact] + public void MapBuilder_ShouldSupportProjectToMarker() { + var mapify = new Mapify([ + new ProjectToDslProfile() + ]); + + var result = mapify.Map(new ProjectToDslSource { + Children = [ + new ProjectToDslChildSource { Value = 1 }, + new ProjectToDslChildSource { Value = 2 } + ] + }); + + Assert.Equal([11, 12], result.Children.Select(x => x.Value).ToArray()); + } + + [Fact] + public void MapBuilder_ShouldSupportNamedProjectToMarker() { + var mapify = new Mapify([ + new NamedProjectToDslProfile() + ]); + + var result = mapify.Map(new NamedProjectToDslSource { + Children = [ + new NamedProjectToDslChildSource { Value = 1 }, + new NamedProjectToDslChildSource { Value = 2 } + ] + }); + + Assert.Equal([101, 102], result.Children.Select(x => x.Value).ToArray()); + } + + [Fact] + public void MapBuilder_NamedMapExecution_ShouldSupportMarkers() { + var mapify = new Mapify([ + new NamedDslExecutionProfile() + ]); + + var parameters = new Dictionary { + ["threshold"] = 50 + }; + + var result = mapify.Map(new NamedDslExecutionSource { + Name = "alice", + Score = 75, + Child = new NamedDslExecutionChildSource { Value = 4 }, + Internal = "secret" + }, "Special", parameters); + + Assert.Equal("alice-special", result.Name); + Assert.Equal("High", result.Category); + Assert.NotNull(result.Child); + Assert.Equal(204, result.Child!.Value); + Assert.Null(result.Internal); + } + + private sealed class ExistingMapChildSource { + public int Value { get; set; } + } + + private sealed class ExistingMapChildTarget { + public int Value { get; set; } + } + + private sealed class ExistingMapSource { + public string Name { get; set; } = string.Empty; + + public ExistingMapChildSource? Child { get; set; } + } + + private sealed class ExistingMapTarget { + public required string Name { get; set; } + + public ExistingMapChildTarget? Child { get; set; } + } + + private sealed class ExistingMapFallbackProfile : MapifyProfile { + protected override void Configure() { + CreateMap(); + + CreateMap() + .Map(d => d.Name, s => s.Name) + .Map(d => d.Child, s => s.Child); + } + } + + private sealed class InitializerOverrideSource { + public string Name { get; set; } = string.Empty; + } + + private sealed class InitializerOverrideTarget { + public required string Name { get; set; } + + public string Label { get; set; } = string.Empty; + } + + private sealed class InitializerOverrideProfile : MapifyProfile { + protected override void Configure() { + CreateMap(s => new InitializerOverrideTarget { + Name = s.Name, + Label = "initializer-value" + }) + .Map(d => d.Label, _ => "dsl-value"); + } + } + + private sealed class MarkerChildSource { + public int Value { get; set; } + } + + private sealed class MarkerChildTarget { + public int Value { get; set; } + } + + private sealed class MarkerSource { + public string Name { get; set; } = string.Empty; + + public MarkerChildSource? Child { get; set; } + + public string Internal { get; set; } = string.Empty; + } + + private sealed class MarkerTarget { + public required string Name { get; set; } + + public MarkerChildTarget? Child { get; set; } + + public string? Internal { get; set; } + } + + private sealed class MarkerDslProfile : MapifyProfile { + protected override void Configure() { + CreateMap(s => new MarkerChildTarget { + Value = s.Value + 10 + }); + + CreateMap() + .Map(d => d.Name, s => s.Name) + .Map(d => d.Child, s => UseMap(s.Child)) + .Map(d => d.Internal, _ => Ignore()); + } + } + + private sealed class ParameterSource { + public required string Name { get; set; } + + public int Score { get; set; } + } + + private sealed class ParameterTarget { + public required string Name { get; set; } + + public required string ScoreCategory { get; set; } + } + + private sealed class ParameterDslProfile : MapifyProfile { + protected override void Configure() { + CreateMap() + .Map(d => d.Name, s => s.Name) + .Map(d => d.ScoreCategory, s => s.Score >= Parameter("minScore") ? "Pass" : "Fail"); + } + } + + private sealed class NamedUseMapChildSource { + public int Value { get; set; } + } + + private sealed class NamedUseMapChildTarget { + public int Value { get; set; } + } + + private sealed class NamedUseMapSource { + public required string Name { get; set; } + + public NamedUseMapChildSource? Child { get; set; } + } + + private sealed class NamedUseMapTarget { + public required string Name { get; set; } + + public NamedUseMapChildTarget? Child { get; set; } + } + + private sealed class NamedUseMapDslProfile : MapifyProfile { + protected override void Configure() { + CreateMap(x => new NamedUseMapChildTarget { + Value = x.Value + 1 + }); + + CreateMap("Boost", x => new NamedUseMapChildTarget { + Value = x.Value + 100 + }); + + CreateMap() + .Map(d => d.Name, s => s.Name) + .Map(d => d.Child, s => UseMap("Boost", s.Child)); + } + } + + private sealed class ProjectToDslChildSource { + public int Value { get; set; } + } + + private sealed class ProjectToDslChildTarget { + public int Value { get; set; } + } + + private sealed class ProjectToDslSource { + public IEnumerable Children { get; set; } = []; + } + + private sealed class ProjectToDslTarget { + public ProjectToDslChildTarget[] Children { get; set; } = []; + } + + private sealed class ProjectToDslProfile : MapifyProfile { + protected override void Configure() { + CreateMap(x => new ProjectToDslChildTarget { + Value = x.Value + 10 + }); + + CreateMap() + .Map(d => d.Children, s => s.Children.ProjectTo().ToArray()); + } + } + + private sealed class NamedProjectToDslChildSource { + public int Value { get; set; } + } + + private sealed class NamedProjectToDslChildTarget { + public int Value { get; set; } + } + + private sealed class NamedProjectToDslSource { + public IEnumerable Children { get; set; } = []; + } + + private sealed class NamedProjectToDslTarget { + public NamedProjectToDslChildTarget[] Children { get; set; } = []; + } + + private sealed class NamedProjectToDslProfile : MapifyProfile { + protected override void Configure() { + CreateMap(x => new NamedProjectToDslChildTarget { + Value = x.Value + 1 + }); + + CreateMap("Boost", x => new NamedProjectToDslChildTarget { + Value = x.Value + 100 + }); + + CreateMap() + .Map(d => d.Children, s => s.Children.ProjectTo("Boost").ToArray()); + } + } + + private sealed class NamedDslExecutionChildSource { + public int Value { get; set; } + } + + private sealed class NamedDslExecutionChildTarget { + public int Value { get; set; } + } + + private sealed class NamedDslExecutionSource { + public required string Name { get; set; } + + public int Score { get; set; } + + public NamedDslExecutionChildSource? Child { get; set; } + + public string Internal { get; set; } = string.Empty; + } + + private sealed class NamedDslExecutionTarget { + public required string Name { get; set; } + + public required string Category { get; set; } + + public NamedDslExecutionChildTarget? Child { get; set; } + + public string? Internal { get; set; } + } + + private sealed class NamedDslExecutionProfile : MapifyProfile { + protected override void Configure() { + CreateMap("SpecialChild", x => new NamedDslExecutionChildTarget { + Value = x.Value + 200 + }); + + CreateMap("Special") + .Map(d => d.Name, s => s.Name + "-special") + .Map(d => d.Category, s => s.Score >= Parameter("threshold") ? "High" : "Low") + .Map(d => d.Child, s => UseMap("SpecialChild", s.Child)) + .Map(d => d.Internal, _ => Ignore()); + } + } +} diff --git a/Mapify/Mapify.MappingInternals.cs b/Mapify/Mapify.MappingInternals.cs index ed2c308..30778b1 100644 --- a/Mapify/Mapify.MappingInternals.cs +++ b/Mapify/Mapify.MappingInternals.cs @@ -66,6 +66,7 @@ private static Action CompileMapper(Expressi private static Expression> CreateMap( Expression>? partial, + IReadOnlyList? mapBuilderBindings, Func? existingMapResolver ) { var baseParam = Expression.Parameter(typeof(TSource), "x"); @@ -92,6 +93,22 @@ private static Expression> CreateMap(builderBinding, baseParam, existingMapResolver, out var destinationProperty, out var isIgnored); + if (isIgnored) { + ignoredBindings.Add(destinationProperty.Name); + } + + if (binding == null) { + continue; + } + + existingBindings[destinationProperty.Name] = binding; + ignoredBindings.Remove(destinationProperty.Name); + } + } + var destinationProperties = typeof(TDestination).GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.CanWrite); @@ -125,22 +142,73 @@ private static Expression> CreateMap( + MapBuilderBinding builderBinding, + ParameterExpression sourceParameter, + Func? existingMapResolver, + out PropertyInfo destinationProperty, + out bool isIgnored + ) { + destinationProperty = GetMapBuilderDestinationProperty(builderBinding.TargetExpression); + + if (builderBinding.SourceExpression.Parameters.Count != 1 || builderBinding.SourceExpression.Parameters[0].Type != typeof(TSource)) { + throw new InvalidOperationException($"DSL map binding for '{typeof(TSource).FullName} -> {typeof(TDestination).FullName}' requires a source selector with exactly one parameter of type '{typeof(TSource).FullName}'."); + } + + var sourceExpression = new ParameterReplaceVisitor(builderBinding.SourceExpression.Parameters[0], sourceParameter) + .Visit(builderBinding.SourceExpression.Body)!; + return MapPartialBinding(destinationProperty, sourceExpression, typeof(TDestination), existingMapResolver, out isIgnored, tryMapExpressionToDestinationProperty: true, dslSourceType: typeof(TSource)); + } + + private static PropertyInfo GetMapBuilderDestinationProperty(LambdaExpression destinationExpression) { + if (destinationExpression.Parameters.Count != 1 || destinationExpression.Parameters[0].Type != typeof(TDestination)) { + throw new InvalidOperationException($"DSL map binding for '{typeof(TSource).FullName} -> {typeof(TDestination).FullName}' requires a destination selector with exactly one parameter of type '{typeof(TDestination).FullName}'."); + } + + var destinationBody = UnwrapConvert(destinationExpression.Body); + if (destinationBody is not MemberExpression memberExpression + || memberExpression.Expression != destinationExpression.Parameters[0] + || memberExpression.Member is not PropertyInfo propertyInfo) { + throw new InvalidOperationException($"DSL map binding for '{typeof(TSource).FullName} -> {typeof(TDestination).FullName}' requires destination selector to be a direct writable property access (for example: d => d.Property)."); + } + + if (!propertyInfo.CanWrite) { + throw new InvalidOperationException($"DSL map binding for '{typeof(TSource).FullName} -> {typeof(TDestination).FullName}' cannot target non-writable property '{propertyInfo.Name}'."); + } + + return propertyInfo; + } + private static MemberAssignment? MapPartialBinding( MemberAssignment partialBinding, Type destinationType, Func? existingMapResolver, - out bool isIgnored + out bool isIgnored, + bool tryMapExpressionToDestinationProperty = false, + Type? dslSourceType = null + ) { + return MapPartialBinding(partialBinding.Member, partialBinding.Expression, destinationType, existingMapResolver, out isIgnored, tryMapExpressionToDestinationProperty, dslSourceType); + } + + private static MemberAssignment? MapPartialBinding( + MemberInfo destinationMember, + Expression destinationExpression, + Type destinationType, + Func? existingMapResolver, + out bool isIgnored, + bool tryMapExpressionToDestinationProperty = false, + Type? dslSourceType = null ) { isIgnored = false; - var expr = partialBinding.Expression; + var expr = destinationExpression; - if (TryResolveIgnoreMarker(partialBinding, destinationType, out var ignoredBinding)) { + if (TryResolveIgnoreMarker(destinationMember, expr, destinationType, out var ignoredBinding)) { isIgnored = true; return ignoredBinding; } - if (TryResolveUseMapMarker(partialBinding, existingMapResolver, out var mappedBinding)) { + if (TryResolveUseMapMarker(destinationMember, expr, existingMapResolver, out var mappedBinding)) { return mappedBinding; } @@ -157,17 +225,41 @@ out bool isIgnored expr = Expression.Condition(test, value, binaryExpr.Right); } - return Expression.Bind(partialBinding.Member, expr); + if (tryMapExpressionToDestinationProperty && destinationMember is PropertyInfo destinationProperty) { + if (!TryAdaptMappedResult(expr, destinationProperty.PropertyType, out var adaptedExpression)) { + if (existingMapResolver == null + || !TryBuildMappedExpression(expr, expr.Type, destinationProperty.PropertyType, existingMapResolver, null, out adaptedExpression, out var sourceNullCheck)) { + var mappingTypes = dslSourceType == null + ? destinationType.FullName + : $"{dslSourceType.FullName} -> {destinationType.FullName}"; + throw new InvalidOperationException($"DSL map binding for '{mappingTypes}' is missing type map configuration from '{expr.Type.FullName}' to '{destinationProperty.PropertyType.FullName}' for destination property '{destinationProperty.Name}'."); + } + + if (sourceNullCheck != null) { + var nullFallback = CreatePropertyDefaultValueExpression(destinationProperty); + if (nullFallback.Type != adaptedExpression.Type && adaptedExpression.Type.IsAssignableFrom(nullFallback.Type)) { + nullFallback = Expression.Convert(nullFallback, adaptedExpression.Type); + } + + adaptedExpression = Expression.Condition(sourceNullCheck, adaptedExpression, nullFallback); + } + } + + expr = adaptedExpression; + } + + return Expression.Bind(destinationMember, expr); } private static bool TryResolveIgnoreMarker( - MemberAssignment partialBinding, + MemberInfo destinationMember, + Expression destinationExpression, Type destinationType, out MemberAssignment? mappedBinding ) { mappedBinding = null; - var markerCandidate = UnwrapConvert(partialBinding.Expression); + var markerCandidate = UnwrapConvert(destinationExpression); if (markerCandidate is not MethodCallExpression methodCall) { return false; } @@ -180,7 +272,7 @@ out MemberAssignment? mappedBinding throw new InvalidOperationException($"{_ignoreMarkerName} does not accept arguments. Use {_ignoreMarkerName}() to ignore a destination property."); } - if (partialBinding.Member is not PropertyInfo destProp) { + if (destinationMember is not PropertyInfo destProp) { throw new InvalidOperationException($"{_ignoreMarkerName} marker can only be used for property bindings."); } @@ -304,13 +396,14 @@ out Expression resolvedExpression } private static bool TryResolveUseMapMarker( - MemberAssignment partialBinding, + MemberInfo destinationMember, + Expression destinationExpression, Func? existingMapResolver, out MemberAssignment mappedBinding ) { mappedBinding = null!; - var markerCandidate = UnwrapConvert(partialBinding.Expression); + var markerCandidate = UnwrapConvert(destinationExpression); if (markerCandidate is not MethodCallExpression methodCall) { return false; } @@ -319,7 +412,7 @@ out MemberAssignment mappedBinding return false; } - if (partialBinding.Member is not PropertyInfo destProp) { + if (destinationMember is not PropertyInfo destProp) { throw new InvalidOperationException($"{_useMapMarkerName} marker can only be used for property bindings."); } diff --git a/Mapify/Mapify.cs b/Mapify/Mapify.cs index b51ca61..55fb8f8 100644 --- a/Mapify/Mapify.cs +++ b/Mapify/Mapify.cs @@ -85,26 +85,31 @@ public void UseMaxRecursiveMapBuildDepth(int value) { _recursiveMapBuildHardCap = value; } - void IMapifyConfigurator.CreateMap(Expression>? partial) { - AddPendingMap(null, partial); + MapifyMapBuilder IMapifyConfigurator.CreateMap(Expression>? partial) { + return AddPendingMap(null, partial); } - void IMapifyConfigurator.CreateMap(string name, Expression>? partial) { + MapifyMapBuilder IMapifyConfigurator.CreateMap(string name, Expression>? partial) { if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("Mapping name must not be null or whitespace.", nameof(name)); } - AddPendingMap(name, partial); + return AddPendingMap(name, partial); } - private void AddPendingMap(string? name, Expression>? partial) { + private MapifyMapBuilder AddPendingMap(string? name, Expression>? partial) { var key = new MapKey(typeof(TSource), typeof(TTarget), name); if (_pendingRegistrations.ContainsKey(key) || _converters.ContainsKey(key)) { var mappingScope = name == null ? "default" : $"named '{name}'"; throw new ArgumentException($"There already exists a {mappingScope} mapping from TSource ({typeof(TSource).FullName}) to TTarget ({typeof(TTarget).FullName}). There can only be one mapping per name and source/target combination."); } - _pendingRegistrations[key] = new PendingMapRegistration(partial); + var registration = new PendingMapRegistration(partial); + _pendingRegistrations[key] = registration; + + return new MapifyMapBuilder((targetExpression, sourceExpression) => { + registration.AddBinding(targetExpression, sourceExpression); + }); } private void BuildRegisteredMaps() { @@ -152,7 +157,7 @@ private void EnsureBuilt(MapKey key) { } else { created = null!; for (var i = 0; i < recursiveBuildDepth; i++) { - created = CreateMapFromPending(key.SourceType, key.TargetType, key.Name, pending.Partial); + created = CreateMapFromPending(key.SourceType, key.TargetType, key.Name, pending); SetMapUntyped(created, key.Name, compileCaches: false); } } @@ -219,14 +224,14 @@ private LambdaExpression CreateRecursiveFallbackMap(Type sourceType, Type target private static Expression> CreateRecursiveFallbackMapGeneric() => _ => default!; - private LambdaExpression CreateMapFromPending(Type sourceType, Type targetType, string? mapName, LambdaExpression? partial) { + private LambdaExpression CreateMapFromPending(Type sourceType, Type targetType, string? mapName, PendingMapRegistration pending) { var method = typeof(Mapify).GetMethod(nameof(CreateMapFromPendingGeneric), System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; var generic = method.MakeGenericMethod(sourceType, targetType); - return (LambdaExpression)generic.Invoke(this, [mapName, partial])!; + return (LambdaExpression)generic.Invoke(this, [mapName, pending])!; } - private Expression> CreateMapFromPendingGeneric(string? mapName, LambdaExpression? partial) - => CreateMap((Expression>?)partial, (sourceType, targetType, requestedMapName) => ResolveExistingMapForBuild(sourceType, targetType, requestedMapName ?? mapName)); + private Expression> CreateMapFromPendingGeneric(string? mapName, PendingMapRegistration pending) + => CreateMap((Expression>?)pending.Partial, pending.Bindings, (sourceType, targetType, requestedMapName) => ResolveExistingMapForBuild(sourceType, targetType, requestedMapName ?? mapName)); private LambdaExpression? ResolveExistingMapForBuild(Type sourceType, Type targetType, string? mapName) { if (!string.IsNullOrWhiteSpace(mapName)) { @@ -427,7 +432,7 @@ public Expression> GetRequiredMap(strin return ApplyParameters((Expression>)existingDefaultMap, parameters); } - var defaultMap = CreateMap(null, (sourceType, targetType, requestedMapName) => ResolveExistingMapForBuild(sourceType, targetType, requestedMapName)); + var defaultMap = CreateMap(null, null, (sourceType, targetType, requestedMapName) => ResolveExistingMapForBuild(sourceType, targetType, requestedMapName)); _defaultMapCache[defaultCacheKey] = defaultMap; return ApplyParameters(defaultMap, parameters); } @@ -674,7 +679,7 @@ private Expression> GetRequiredRuntimeMap>)existingDefaultMap, parameters); } - var defaultMap = CreateMap(null, (sourceType, targetType, requestedMapName) => ResolveExistingMapForBuild(sourceType, targetType, requestedMapName)); + var defaultMap = CreateMap(null, null, (sourceType, targetType, requestedMapName) => ResolveExistingMapForBuild(sourceType, targetType, requestedMapName)); _defaultMapCache[defaultCacheKey] = defaultMap; return ApplyParameters(defaultMap, parameters); } @@ -751,6 +756,18 @@ public override int GetHashCode() { private sealed class PendingMapRegistration(LambdaExpression? partial) { public LambdaExpression? Partial { get; } = partial; + + public List Bindings { get; } = []; + + public void AddBinding(LambdaExpression targetExpression, LambdaExpression sourceExpression) { + Bindings.Add(new MapBuilderBinding(targetExpression, sourceExpression)); + } + } + + private sealed class MapBuilderBinding(LambdaExpression targetExpression, LambdaExpression sourceExpression) { + public LambdaExpression TargetExpression { get; } = targetExpression; + + public LambdaExpression SourceExpression { get; } = sourceExpression; } private enum MapBuildState { diff --git a/Mapify/MapifyMapBuilder.cs b/Mapify/MapifyMapBuilder.cs new file mode 100644 index 0000000..ec81e90 --- /dev/null +++ b/Mapify/MapifyMapBuilder.cs @@ -0,0 +1,40 @@ +using System.Linq.Expressions; + +namespace Mapify.NET; + +/// +/// Fluent builder for adding per-property bindings to a registered map. +/// +/// Source type. +/// Target type. +public sealed class MapifyMapBuilder { + private readonly Action _addBinding; + + internal MapifyMapBuilder(Action addBinding) { + _addBinding = addBinding; + } + + /// + /// Adds or overrides a binding for a single destination property. + /// + /// Destination property type. + /// Source expression type. + /// Destination property selector. + /// Source value expression. + /// The current map builder. + public MapifyMapBuilder Map( + Expression> target, + Expression> source + ) { + if (target == null) { + throw new ArgumentNullException(nameof(target)); + } + + if (source == null) { + throw new ArgumentNullException(nameof(source)); + } + + _addBinding(target, source); + return this; + } +} diff --git a/Mapify/MapifyProfile.cs b/Mapify/MapifyProfile.cs index 3509aa4..86b8eeb 100644 --- a/Mapify/MapifyProfile.cs +++ b/Mapify/MapifyProfile.cs @@ -2,9 +2,9 @@ namespace Mapify.NET; internal interface IMapifyConfigurator { - void CreateMap(Expression>? partial = null); + MapifyMapBuilder CreateMap(Expression>? partial = null); - void CreateMap(string name, Expression>? partial = null); + MapifyMapBuilder CreateMap(string name, Expression>? partial = null); } /// @@ -43,12 +43,12 @@ internal void Apply(IMapifyConfigurator configurator) { /// /// Thrown when called outside profile configuration. /// - protected void CreateMap(Expression>? partial = null) { + protected MapifyMapBuilder CreateMap(Expression>? partial = null) { if (_configurator == null) { throw new InvalidOperationException("CreateMap can only be called while configuring a profile."); } - _configurator.CreateMap(partial); + return _configurator.CreateMap(partial); } /// @@ -66,7 +66,7 @@ protected void CreateMap(Expression>? p /// /// Thrown when is null, empty, or whitespace. /// - protected void CreateMap(string name, Expression>? partial = null) { + protected MapifyMapBuilder CreateMap(string name, Expression>? partial = null) { if (_configurator == null) { throw new InvalidOperationException("CreateMap can only be called while configuring a profile."); } @@ -75,7 +75,7 @@ protected void CreateMap(string name, Expression diff --git a/README.md b/README.md index c4e64d5..d248c79 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,45 @@ var queryMapper = provider.GetMapify("queries"); `CreateMap(...)` inside `MapifyProfile` is registration-only. Map building is deferred until all profiles are registered, so unordered registrations are supported. +You can also chain a minimal per-property DSL from `CreateMap`. +The DSL uses **destination first**, then source/value: + +```csharp +CreateMap() + .Map(d => d.Name, s => s.FirstName + " " + s.LastName) + .Map(d => d.MainAddress, s => s.MainAddress); // uses existing Address -> AddressDto map fallback when needed +``` + +You can combine initializer + DSL as well: + +```csharp +CreateMap(s => new PersonDto { + Name = s.FirstName +}) + .Map(d => d.Name, s => s.FirstName + " " + s.LastName); +``` + +Binding precedence is: + +1. initializer bindings (`new TTarget { ... }`) +2. chained DSL `.Map(...)` bindings (override initializer when same destination member is mapped) +3. implicit/default member mapping +4. destination fallback values + +DSL value expressions run through the same marker pipeline, so markers like `UseMap`, `Ignore`, and `Parameter` also work in `.Map(...)`. + +Example with markers in DSL mappings: + +```csharp +CreateMap(); + +CreateMap() + .Map(d => d.Name, s => s.FirstName + " " + s.LastName) + .Map(d => d.MainAddress, s => UseMap(s.MainAddress)) + .Map(d => d.InternalCode, _ => Ignore()) + .Map(d => d.ScoreCategory, s => s.Score >= Parameter("minScore") ? "Pass" : "Fail"); +``` + When you need explicit nested map usage in a profile initializer, use `UseMap(x.SourceMember)`. During build, Mapify resolves the dependency to the registered map (including nullable variants). From 886b3c46801c2f402e95d4419aa3e130c516fd62 Mon Sep 17 00:00:00 2001 From: Benjamin Vettori Date: Mon, 23 Mar 2026 23:59:34 +0100 Subject: [PATCH 3/4] Added more tests for required fields Added more tests to ensure that objects with required fields are mapped correctly. Fixed an issue where ignored bindings were removed for DSL bindings that used the Ignored marker. --- Mapify.NET.Tests/MapifyDslMapBuilderTests.cs | 149 +++++++++++++++++++ Mapify/Mapify.MappingInternals.cs | 4 +- 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/Mapify.NET.Tests/MapifyDslMapBuilderTests.cs b/Mapify.NET.Tests/MapifyDslMapBuilderTests.cs index 8478529..40dc7c3 100644 --- a/Mapify.NET.Tests/MapifyDslMapBuilderTests.cs +++ b/Mapify.NET.Tests/MapifyDslMapBuilderTests.cs @@ -107,6 +107,84 @@ public void MapBuilder_ShouldSupportProjectToMarker() { Assert.Equal([11, 12], result.Children.Select(x => x.Value).ToArray()); } + [Fact] + public void MapBuilder_ShouldSupportRequiredTargetMembers_WhenOnlyOnePropertyIsMappedExplicitly() { + var mapify = new Mapify([ + new RequiredTargetDslProfile() + ]); + + var result = mapify.Map(new RequiredTargetDslSource { + Name = "alice" + }); + + Assert.Equal("alice", result.Name); + Assert.Equal(0, result.RequiredWithoutSource); + } + + [Fact] + public void MapBuilder_ShouldUseEmptyFallback_ForUnmappedRequiredCollection() { + var mapify = new Mapify([ + new RequiredCollectionDslProfile() + ]); + + var result = mapify.Map(new RequiredCollectionSource { + Name = "alice" + }); + + Assert.Equal("alice", result.Name); + Assert.NotNull(result.Tags); + Assert.Empty(result.Tags); + } + + [Fact] + public void MapBuilder_ShouldApplyFallback_ForIgnoredRequiredMember() { + var mapify = new Mapify([ + new RequiredIgnoreDslProfile() + ]); + + var result = mapify.Map(new RequiredIgnoreSource { + Name = "alice", + InternalCode = 99 + }); + + Assert.Equal("alice", result.Name); + Assert.Equal(0, result.InternalCode); + } + + [Fact] + public void MapBuilder_ShouldPreserveRequiredInitializer_WhenUnmapped() { + var mapify = new Mapify([ + new RequiredInitializerDslProfile() + ]); + + var result = mapify.Map(new RequiredInitializerSource { + Name = "alice" + }); + + Assert.Equal("alice", result.Name); + Assert.Equal("initialized", result.Code); + } + + [Fact] + public void MapBuilder_MapToExisting_ShouldKeepIgnoredRequiredMemberUnchanged() { + var mapify = new Mapify([ + new RequiredIgnoreDslProfile() + ]); + + var existing = new RequiredIgnoreTarget { + Name = "old-name", + InternalCode = 123 + }; + + mapify.Map(new RequiredIgnoreSource { + Name = "new-name", + InternalCode = 999 + }, existing); + + Assert.Equal("new-name", existing.Name); + Assert.Equal(123, existing.InternalCode); + } + [Fact] public void MapBuilder_ShouldSupportNamedProjectToMarker() { var mapify = new Mapify([ @@ -317,6 +395,77 @@ protected override void Configure() { } } + private sealed class RequiredTargetDslSource { + public required string Name { get; set; } + } + + private sealed class RequiredTargetDslTarget { + public required string Name { get; set; } + + public required int RequiredWithoutSource { get; set; } + } + + private sealed class RequiredTargetDslProfile : MapifyProfile { + protected override void Configure() { + CreateMap() + .Map(d => d.Name, s => s.Name); + } + } + + private sealed class RequiredCollectionSource { + public required string Name { get; set; } + } + + private sealed class RequiredCollectionTarget { + public required string Name { get; set; } + + public required List Tags { get; set; } = new List(); + } + + private sealed class RequiredCollectionDslProfile : MapifyProfile { + protected override void Configure() { + CreateMap() + .Map(d => d.Name, s => s.Name); + } + } + + private sealed class RequiredIgnoreSource { + public required string Name { get; set; } + + public int InternalCode { get; set; } + } + + private sealed class RequiredIgnoreTarget { + public required string Name { get; set; } + + public required int InternalCode { get; set; } + } + + private sealed class RequiredIgnoreDslProfile : MapifyProfile { + protected override void Configure() { + CreateMap() + .Map(d => d.Name, s => s.Name) + .Map(d => d.InternalCode, _ => Ignore()); + } + } + + private sealed class RequiredInitializerSource { + public required string Name { get; set; } + } + + private sealed class RequiredInitializerTarget { + public required string Name { get; set; } + + public required string Code { get; set; } = "initialized"; + } + + private sealed class RequiredInitializerDslProfile : MapifyProfile { + protected override void Configure() { + CreateMap() + .Map(d => d.Name, s => s.Name); + } + } + private sealed class NamedProjectToDslChildSource { public int Value { get; set; } } diff --git a/Mapify/Mapify.MappingInternals.cs b/Mapify/Mapify.MappingInternals.cs index 30778b1..08a41d1 100644 --- a/Mapify/Mapify.MappingInternals.cs +++ b/Mapify/Mapify.MappingInternals.cs @@ -105,7 +105,9 @@ private static Expression> CreateMap Date: Tue, 24 Mar 2026 01:10:20 +0100 Subject: [PATCH 4/4] Use ReferenceAssemblies NuGet package for .NET Framework support Removes the requirement to install the .NET Framework developer pack on CI runners by adding `Microsoft.NETFramework.ReferenceAssemblies` to the project files. Also updates the test runner label in the CI workflow to net472. --- .github/workflows/build.yml | 3 --- .github/workflows/tests.yml | 5 +---- Mapify.NET.Tests.NetFx/Mapify.NET.Tests.NetFx.csproj | 1 + Mapify/Mapify.NET.csproj | 1 + 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 711dbf8..bd52655 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,9 +27,6 @@ jobs: with: dotnet-version: 10.0.x - - name: Install .NET Framework 4.6.2 Developer Pack - run: choco install netfx-4.6.2-devpack -y - - name: GitVersion Setup uses: gittools/actions/gitversion/setup@v0 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9423b12..0d99aa3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,9 +22,6 @@ jobs: with: dotnet-version: 10.0.x - - name: Install .NET Framework 4.6.2 Developer Pack - run: choco install netfx-4.6.2-devpack -y - - name: Restore run: dotnet restore @@ -34,5 +31,5 @@ jobs: - name: Test - net10 EF Core run: dotnet test Mapify.NET.Tests.EFCore/Mapify.NET.Tests.EFCore.csproj -c Release --no-restore - - name: Test - net462 + - name: Test - net472 run: dotnet test Mapify.NET.Tests.NetFx/Mapify.NET.Tests.NetFx.csproj -c Release --no-restore diff --git a/Mapify.NET.Tests.NetFx/Mapify.NET.Tests.NetFx.csproj b/Mapify.NET.Tests.NetFx/Mapify.NET.Tests.NetFx.csproj index caedd52..21cfad4 100644 --- a/Mapify.NET.Tests.NetFx/Mapify.NET.Tests.NetFx.csproj +++ b/Mapify.NET.Tests.NetFx/Mapify.NET.Tests.NetFx.csproj @@ -16,6 +16,7 @@ + diff --git a/Mapify/Mapify.NET.csproj b/Mapify/Mapify.NET.csproj index 73391db..ac5006e 100644 --- a/Mapify/Mapify.NET.csproj +++ b/Mapify/Mapify.NET.csproj @@ -27,5 +27,6 @@ +