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.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/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.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/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.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/MapifyDslMapBuilderTests.cs b/Mapify.NET.Tests/MapifyDslMapBuilderTests.cs new file mode 100644 index 0000000..40dc7c3 --- /dev/null +++ b/Mapify.NET.Tests/MapifyDslMapBuilderTests.cs @@ -0,0 +1,541 @@ +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_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([ + 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 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; } + } + + 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.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 70% rename from Mapify/Mapper.cs rename to Mapify/Mapify.MappingInternals.cs index df86304..08a41d1 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,13 @@ 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, + IReadOnlyList? mapBuilderBindings, 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 +78,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 +93,37 @@ internal 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; + if (!isIgnored) { + ignoredBindings.Remove(destinationProperty.Name); + } + } + } + var destinationProperties = typeof(TDestination).GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => 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,28 +144,73 @@ 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? MapMapBuilderBinding( + 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; } @@ -446,31 +218,50 @@ 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; + + 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; } @@ -483,7 +274,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."); } @@ -607,13 +398,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; } @@ -622,7 +414,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."); } @@ -652,11 +444,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 +601,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 +631,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 +674,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 +930,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 +938,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 +955,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 +980,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 +1017,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 +1064,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 +1073,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 +1105,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 +1347,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.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 @@ + diff --git a/Mapify/Mapify.cs b/Mapify/Mapify.cs index 337b935..55fb8f8 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; @@ -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) - => Mapper.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)) { @@ -300,12 +305,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 +322,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 +363,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 +423,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, null, (sourceType, targetType, requestedMapName) => ResolveExistingMapForBuild(sourceType, targetType, requestedMapName)); _defaultMapCache[defaultCacheKey] = defaultMap; - return Mapper.ApplyParameters(defaultMap, parameters); + return ApplyParameters(defaultMap, parameters); } return null; @@ -490,7 +495,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 +524,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 +550,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 +574,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 +666,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 +676,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, 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 +722,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; @@ -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/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..d248c79 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 ✨ @@ -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). @@ -257,15 +296,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 +443,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 +589,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 +600,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 +611,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 +625,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 +638,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 🤝