From 530f673ae413fda8b4fa7dd2e24c8ab0167433e5 Mon Sep 17 00:00:00 2001 From: Kataane Date: Fri, 24 Oct 2025 02:02:51 +0700 Subject: [PATCH 1/2] Cleanup and improve OnlyExplicitMappedMembers implementation --- .../MapperAttribute.cs | 7 ++ .../Configuration/MapperConfiguration.cs | 6 ++ .../MapperConfigurationMerger.cs | 5 ++ .../BuilderContext/IgnoredMembersBuilder.cs | 10 +++ ...ApiTest.PublicApiHasNotChanged.verified.cs | 1 + .../Mapping/ObjectPropertyIgnoreTest.cs | 69 +++++++++++++++++++ test/Riok.Mapperly.Tests/TestSourceBuilder.cs | 5 +- .../TestSourceBuilderOptions.cs | 3 +- 8 files changed, 103 insertions(+), 3 deletions(-) diff --git a/src/Riok.Mapperly.Abstractions/MapperAttribute.cs b/src/Riok.Mapperly.Abstractions/MapperAttribute.cs index f0e1190379..09d24f91fc 100644 --- a/src/Riok.Mapperly.Abstractions/MapperAttribute.cs +++ b/src/Riok.Mapperly.Abstractions/MapperAttribute.cs @@ -142,4 +142,11 @@ public class MapperAttribute : Attribute /// partial methods are discovered. /// public bool AutoUserMappings { get; set; } = true; + + /// + /// When set to true, only properties with explicit configurations (via attributes like MapProperty) + /// will be mapped. All other properties will be ignored by default. + /// This is useful when you want to map only a few specific properties from a class with many properties. + /// + public bool OnlyExplicitMappedMembers { get; set; } } diff --git a/src/Riok.Mapperly/Configuration/MapperConfiguration.cs b/src/Riok.Mapperly/Configuration/MapperConfiguration.cs index 68893d366a..7da6edad77 100644 --- a/src/Riok.Mapperly/Configuration/MapperConfiguration.cs +++ b/src/Riok.Mapperly/Configuration/MapperConfiguration.cs @@ -135,4 +135,10 @@ public record MapperConfiguration /// Can be overwritten on specific enums via mapping method configurations. /// public EnumNamingStrategy? EnumNamingStrategy { get; init; } + + /// + /// When set to true, only properties with explicit configurations (via attributes like MapProperty) + /// will be mapped. All other properties will be ignored by default. + /// + public bool? OnlyExplicitMappedMembers { get; init; } } diff --git a/src/Riok.Mapperly/Configuration/MapperConfigurationMerger.cs b/src/Riok.Mapperly/Configuration/MapperConfigurationMerger.cs index 4d625f49f3..5591041854 100644 --- a/src/Riok.Mapperly/Configuration/MapperConfigurationMerger.cs +++ b/src/Riok.Mapperly/Configuration/MapperConfigurationMerger.cs @@ -104,6 +104,11 @@ public static MapperAttribute MergeToAttribute(MapperConfiguration mapperConfigu mapper.EnumNamingStrategy = mapperConfiguration.EnumNamingStrategy ?? defaultMapperConfiguration.EnumNamingStrategy ?? mapper.EnumNamingStrategy; + mapper.OnlyExplicitMappedMembers = + mapperConfiguration.OnlyExplicitMappedMembers + ?? defaultMapperConfiguration.OnlyExplicitMappedMembers + ?? mapper.OnlyExplicitMappedMembers; + return mapper; } } diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IgnoredMembersBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IgnoredMembersBuilder.cs index b41a73a2d5..fb849c2631 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IgnoredMembersBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IgnoredMembersBuilder.cs @@ -28,6 +28,16 @@ .. GetIgnoredAtMemberMembers(ctx, sourceTarget), .. GetIgnoredObsoleteMembers(ctx, sourceTarget), ]; + if (ctx.Configuration.Mapper.OnlyExplicitMappedMembers) + { + var oppositeType = sourceTarget == MappingSourceTarget.Source ? ctx.Target : ctx.Source; + var oppositeTypeMembers = ctx.SymbolAccessor.GetAllAccessibleMappableMembers(oppositeType).Select(x => x.Name).ToHashSet(); + + var unmatchedMembers = allMembers.Except(oppositeTypeMembers, StringComparer.Ordinal); + + ignoredMembers.UnionWith(unmatchedMembers); + } + RemoveAndReportConfiguredIgnoredMembers(ctx, sourceTarget, ignoredMembers); ReportUnmatchedIgnoredMembers(ctx, sourceTarget, ignoredMembers, allMembers); return ignoredMembers; diff --git a/test/Riok.Mapperly.Abstractions.Tests/_snapshots/PublicApiTest.PublicApiHasNotChanged.verified.cs b/test/Riok.Mapperly.Abstractions.Tests/_snapshots/PublicApiTest.PublicApiHasNotChanged.verified.cs index 3ad1c41b2f..e7acaa9b3e 100644 --- a/test/Riok.Mapperly.Abstractions.Tests/_snapshots/PublicApiTest.PublicApiHasNotChanged.verified.cs +++ b/test/Riok.Mapperly.Abstractions.Tests/_snapshots/PublicApiTest.PublicApiHasNotChanged.verified.cs @@ -138,6 +138,7 @@ public MapperAttribute() { } public Riok.Mapperly.Abstractions.IgnoreObsoleteMembersStrategy IgnoreObsoleteMembersStrategy { get; set; } public Riok.Mapperly.Abstractions.MemberVisibility IncludedConstructors { get; set; } public Riok.Mapperly.Abstractions.MemberVisibility IncludedMembers { get; set; } + public bool OnlyExplicitMappedMembers { get; set; } public bool PreferParameterlessConstructors { get; set; } public Riok.Mapperly.Abstractions.PropertyNameMappingStrategy PropertyNameMappingStrategy { get; set; } public Riok.Mapperly.Abstractions.RequiredMappingStrategy RequiredEnumMappingStrategy { get; set; } diff --git a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyIgnoreTest.cs b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyIgnoreTest.cs index 7dc1b69dea..6b45c3dc2a 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyIgnoreTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyIgnoreTest.cs @@ -168,4 +168,73 @@ public void WithNestedIgnoredSourceAndTargetPropertyShouldDiagnostic() """ ); } + + [Fact] + public void OnlyExplicitMappedMembersWithExtraSourceMembers() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "partial B Map(A source);", + new TestSourceBuilderOptions(OnlyExplicitMappedMembers: true), + "class A { public int Value1 { get; set; } public int Value2 { get; set; } public int Value4 { get; set; } }", + "class B { public int Value1 { get; set; } public int Value2 { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value1 = source.Value1; + target.Value2 = source.Value2; + return target; + """ + ); + } + + [Fact] + public void OnlyExplicitMappedMembersWithExtraTargetMembers() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "partial B Map(A source);", + new TestSourceBuilderOptions(OnlyExplicitMappedMembers: true), + "class A { public int Value1 { get; set; } public int Value2 { get; set; } }", + "class B { public int Value1 { get; set; } public int Value2 { get; set; } public int Value4 { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value1 = source.Value1; + target.Value2 = source.Value2; + return target; + """ + ); + } + + [Fact] + public void OnlyExplicitMappedMembersWithExtraMembersInBoth() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "partial B Map(A source);", + new TestSourceBuilderOptions(OnlyExplicitMappedMembers: true), + "class A { public int Value1 { get; set; } public int Value2 { get; set; } public int Value4 { get; set; } }", + "class B { public int Value1 { get; set; } public int Value2 { get; set; } public int Value3 { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value1 = source.Value1; + target.Value2 = source.Value2; + return target; + """ + ); + } } diff --git a/test/Riok.Mapperly.Tests/TestSourceBuilder.cs b/test/Riok.Mapperly.Tests/TestSourceBuilder.cs index 5861f4b988..fc4cfde873 100644 --- a/test/Riok.Mapperly.Tests/TestSourceBuilder.cs +++ b/test/Riok.Mapperly.Tests/TestSourceBuilder.cs @@ -60,8 +60,8 @@ public static string MapperWithBody( {{body}} } - {{ additionalNamespaceContent ?? "" }} - {{(options is { Namespace: not null, UseFileScopedNamespace: false } ? "}" : "") }} + {{additionalNamespaceContent ?? ""}} + {{(options is { Namespace: not null, UseFileScopedNamespace: false } ? "}" : "")}} """ ); } @@ -107,6 +107,7 @@ private static string BuildAttribute(TestSourceBuilderOptions options) Attribute(options.IncludedConstructors), Attribute(options.PreferParameterlessConstructors), Attribute(options.AutoUserMappings), + Attribute(options.OnlyExplicitMappedMembers), }.WhereNotNull(); return $"[Mapper({string.Join(", ", attrs)})]"; diff --git a/test/Riok.Mapperly.Tests/TestSourceBuilderOptions.cs b/test/Riok.Mapperly.Tests/TestSourceBuilderOptions.cs index 6e166ff415..5b5a4acce8 100644 --- a/test/Riok.Mapperly.Tests/TestSourceBuilderOptions.cs +++ b/test/Riok.Mapperly.Tests/TestSourceBuilderOptions.cs @@ -24,7 +24,8 @@ public record TestSourceBuilderOptions( MemberVisibility? IncludedConstructors = null, bool Static = false, bool PreferParameterlessConstructors = true, - bool AutoUserMappings = true + bool AutoUserMappings = true, + bool OnlyExplicitMappedMembers = false ) { public const string DefaultMapperClassName = "Mapper"; From 684709f0cdbf76d74b0974716df75b4c54afd6be Mon Sep 17 00:00:00 2001 From: Kataane Date: Sun, 3 May 2026 14:21:00 +0700 Subject: [PATCH 2/2] Address PR review feedback for OnlyExplicitMappedMembers - Fix ignore logic to exclude all members not in explicit mappings (MapProperty/MapPropertyFromSource), not just those missing from the opposite type - Add tests with explicit [MapProperty] and [MapPropertyFromSource] attributes - Use tag for MapPropertyAttribute in XML doc comment --- .../MapperAttribute.cs | 2 +- .../BuilderContext/IgnoredMembersBuilder.cs | 8 +--- .../Mapping/ObjectPropertyIgnoreTest.cs | 43 ++++++++++++++----- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/Riok.Mapperly.Abstractions/MapperAttribute.cs b/src/Riok.Mapperly.Abstractions/MapperAttribute.cs index 09d24f91fc..1b01daca76 100644 --- a/src/Riok.Mapperly.Abstractions/MapperAttribute.cs +++ b/src/Riok.Mapperly.Abstractions/MapperAttribute.cs @@ -144,7 +144,7 @@ public class MapperAttribute : Attribute public bool AutoUserMappings { get; set; } = true; /// - /// When set to true, only properties with explicit configurations (via attributes like MapProperty) + /// When set to true, only properties with explicit configurations (via attributes like ) /// will be mapped. All other properties will be ignored by default. /// This is useful when you want to map only a few specific properties from a class with many properties. /// diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IgnoredMembersBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IgnoredMembersBuilder.cs index fb849c2631..b814807c43 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IgnoredMembersBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IgnoredMembersBuilder.cs @@ -30,12 +30,8 @@ .. GetIgnoredObsoleteMembers(ctx, sourceTarget), if (ctx.Configuration.Mapper.OnlyExplicitMappedMembers) { - var oppositeType = sourceTarget == MappingSourceTarget.Source ? ctx.Target : ctx.Source; - var oppositeTypeMembers = ctx.SymbolAccessor.GetAllAccessibleMappableMembers(oppositeType).Select(x => x.Name).ToHashSet(); - - var unmatchedMembers = allMembers.Except(oppositeTypeMembers, StringComparer.Ordinal); - - ignoredMembers.UnionWith(unmatchedMembers); + var explicitlyMappedMembers = ctx.Configuration.Members.GetMembersWithExplicitConfigurations(sourceTarget).ToHashSet(); + ignoredMembers.UnionWith(allMembers.Where(m => !explicitlyMappedMembers.Contains(m))); } RemoveAndReportConfiguredIgnoredMembers(ctx, sourceTarget, ignoredMembers); diff --git a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyIgnoreTest.cs b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyIgnoreTest.cs index 6b45c3dc2a..84d42fc0c9 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyIgnoreTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyIgnoreTest.cs @@ -170,12 +170,13 @@ public void WithNestedIgnoredSourceAndTargetPropertyShouldDiagnostic() } [Fact] - public void OnlyExplicitMappedMembersWithExtraSourceMembers() + public void OnlyExplicitMappedMembersWithNoExplicitMappings() { + // With OnlyExplicitMappedMembers=true and no [MapProperty], nothing is mapped (no warnings either) var source = TestSourceBuilder.MapperWithBodyAndTypes( "partial B Map(A source);", new TestSourceBuilderOptions(OnlyExplicitMappedMembers: true), - "class A { public int Value1 { get; set; } public int Value2 { get; set; } public int Value4 { get; set; } }", + "class A { public int Value1 { get; set; } public int Value2 { get; set; } }", "class B { public int Value1 { get; set; } public int Value2 { get; set; } }" ); @@ -185,21 +186,20 @@ public void OnlyExplicitMappedMembersWithExtraSourceMembers() .HaveSingleMethodBody( """ var target = new global::B(); - target.Value1 = source.Value1; - target.Value2 = source.Value2; return target; """ ); } [Fact] - public void OnlyExplicitMappedMembersWithExtraTargetMembers() + public void OnlyExplicitMappedMembersWithExplicitMapping() { + // Only the explicitly declared [MapProperty] member is mapped; Value2 is ignored even though it matches by name var source = TestSourceBuilder.MapperWithBodyAndTypes( - "partial B Map(A source);", + "[MapProperty(nameof(A.Value1), nameof(B.Value1))] partial B Map(A source);", new TestSourceBuilderOptions(OnlyExplicitMappedMembers: true), "class A { public int Value1 { get; set; } public int Value2 { get; set; } }", - "class B { public int Value1 { get; set; } public int Value2 { get; set; } public int Value4 { get; set; } }" + "class B { public int Value1 { get; set; } public int Value2 { get; set; } }" ); TestHelper @@ -209,17 +209,17 @@ public void OnlyExplicitMappedMembersWithExtraTargetMembers() """ var target = new global::B(); target.Value1 = source.Value1; - target.Value2 = source.Value2; return target; """ ); } [Fact] - public void OnlyExplicitMappedMembersWithExtraMembersInBoth() + public void OnlyExplicitMappedMembersWithMultipleExplicitMappings() { + // Only the two explicitly declared [MapProperty] members are mapped; unmatched/extra members are silently ignored var source = TestSourceBuilder.MapperWithBodyAndTypes( - "partial B Map(A source);", + "[MapProperty(nameof(A.Value1), nameof(B.Value1))] [MapProperty(nameof(A.Value2), nameof(B.Value2))] partial B Map(A source);", new TestSourceBuilderOptions(OnlyExplicitMappedMembers: true), "class A { public int Value1 { get; set; } public int Value2 { get; set; } public int Value4 { get; set; } }", "class B { public int Value1 { get; set; } public int Value2 { get; set; } public int Value3 { get; set; } }" @@ -237,4 +237,27 @@ public void OnlyExplicitMappedMembersWithExtraMembersInBoth() """ ); } + + [Fact] + public void OnlyExplicitMappedMembersWithMapPropertyFromSource() + { + // MapPropertyFromSource explicitly maps the whole source to a target member; other target members are ignored + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapPropertyFromSource(nameof(B.Source))] partial B Map(A source);", + new TestSourceBuilderOptions(OnlyExplicitMappedMembers: true), + "class A { public int Value1 { get; set; } }", + "class B { public A Source { get; set; } public int Other { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Source = source; + return target; + """ + ); + } }